feat(studio): adopt the enterprise design system, rail navigation, and appearance controls - #601
jtenniswood wants to merge 12 commits into
Conversation
Bring Studio's visual language in line with the Stacklok console: the same token palette, the same shell grammar, and the same composer. Behaviour is untouched — every handler, effect, and protocol path is the one that was there before. Design tokens (app/globals.css) - Replace Studio's ad-hoc --ink/--line/--panel/--paper with the console's full token set, including a .dark block, and re-export them through @theme inline so they are reachable as Tailwind utilities. - Point the surviving component CSS at those tokens, which is what earns the whole app dark mode rather than only the migrated components. - Map every remaining literal colour onto a semantic token, using color-mix() tints so a tile re-derives on a theme flip instead of staying light. The only literals left are the modal scrim and its shadow, which are meant to be dark in both themes. - Drop 100 rules belonging to components this commit replaces. Shell - components/shell/task-sidebar.tsx: the console's left rail — fixed w-64, full-bleed rows, 3px brand-ink left accent, and the grid-slot trick that swaps timestamp for overflow affordance without reflowing the row. - components/shell/navbar.tsx: h-16 bg-sidebar bar whose seam lines up with the rail's wordmark block, plus the md-and-below Sheet drawer. - components/user-menu/*: the profile pill and dropdown, carrying workspace identity, the six configuration panels, and the theme switch. The panels moved here from the top bar: six always-visible config buttons is a settings tray, not a navigation surface. Composer - components/chat/composer.tsx: the console's three-box composer, where the toolbar is pulled up behind the input box so the two borders read as one outline. Keeps Studio's stop control (the console queues instead) and its CSV attach/drag path, and adds an IME guard so committing a candidate no longer sends the turn. Also fixes two pre-existing bugs: an undefined var(--bg) that rendered schedule buttons transparent, and the composer hint line that claimed Enter-to-send while offering no visible affordance for it. Primitives are copied from the prototype rather than re-derived, so the two products cannot drift. components.json is configured for the same style/baseColor, so `npx shadcn add <x>` lands matching output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the left rail into real navigation and retires the modal stack. Navigation - components/shell/nav-items.ts: Chat, then a Workspace group of Skills, Memory, and Scheduled, with Settings pinned to the rail's footer — the console's grouping, icons, and full-bleed row grammar. - Destinations are VIEWS, not routes: page.tsx owns the live SSE stream, so URL routing would mean lifting it into a layout and remounting the stream on every move. The conversation is display:none'd rather than unmounted, so a run keeps streaming while the operator reads Settings and returning preserves their scroll position. - The task list nests under Chat rather than sitting in a section of its own: a task IS a chat, so indenting shows that instead of competing with it. Settings page - Provider, Model router, and MCP gateway were three dialogs; they are now three sections of one page. The router form is taller than a dialog can show without its own scrollbar, and comparing the provider against the classifier used to mean closing one dialog to open another. - Skills, Memory, and Scheduled lose their dialog chrome too and gain the page column, which is what lets a long schedule list breathe. - Every panel keeps its existing markup and handlers; only the chrome and the container changed. Success paths that used to auto-close a dialog now settle in place. Appearance - components/font-scale.tsx: four UI scales driven by the ROOT font-size, persisted locally. This required converting the component CSS's 79 px font sizes to rem — a px value silently opts out of scaling, so the conversation surface would not have moved. - The profile menu is now only what a profile menu should be: identity, connection, theme, and text size. The configuration panels left it for the rail. Dark mode is now consistent rather than partial: every literal colour in the component CSS maps to a semantic token, tints use color-mix so they re-derive on a theme flip, and the composer goes through tokens instead of raw zinc steps. npm test (5/5), lint, typecheck, and build are green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The panel CSS was tuned for a ~440px dialog, so on the page column the type read as under-scaled and the empty states were centred in a space far wider than the text. Scope the overrides to .panel-page and .settings-section-body rather than changing the base rules, which the conversation surface still uses at its own scale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Font scaling moved font sizes only, so at 1.25x the copy grew while the padding, control heights, and gaps that frame it did not — the text crowded its own containers. Convert the type-relative properties to rem: padding, margin, gap, the width/height of controls that hold text, min/max sizing, flex bases, and border-radius. 324 of the 394 px values in the component CSS. Four families stay in px, deliberately: - border and outline widths — a hairline must stay one device pixel; 1.25px renders as a blurred smudge rather than a crisp edge. - box-shadow geometry — scaling a blur radius turns an edge into a halo. - transform offsets — paired with the geometry they nudge, not the type. - media-query breakpoints — in rem they would move as the operator scales type, so raising the font size would silently flip the layout to mobile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the single 256px rail with the two-level navigation the console's chat surface uses: a 64px icon rail for the five destinations, and a 288px list panel for the chat history. - components/shell/icon-rail.tsx: icon-only destinations, Settings pinned to the footer. Each item carries both aria-label and title — a Radix tooltip would re-add a dependency for five static strings. Keeps the 3px brand-ink left accent, so both nav levels read as one system. - components/shell/chat-panel.tsx: a "Chats" header with the new-task action, then the task list. It renders only on the chat destination, so Skills/Memory/Scheduled/Settings get the full column. - The rail's brand block, the panel header, and the navbar are all h-16, so the three seams line up. Two deliberate departures from the design being followed: It carries Projects and Agents groups; Studio has neither a project container nor an agent picker, so those headings would be furniture with nothing behind them. The panel shows one Tasks section instead. Daemon reachability keeps a permanent home at the rail's foot. The old wide rail had room to spell it out; an icon rail does not, but demoting it to the profile menu would mean an operator only discovers a dead daemon by sending a prompt into it. Task ages are now compact (now / 5m / 2h / 3d), which is both the design's form and a fix: the slot shares one grid cell with the hover affordance, so the old spelled-out "Just now" wrapped to two lines. The formatter moved into the panel that owns the format, and page.tsx loses the helper it was threading through the navbar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… scale Settings tabs - Provider / Model router / MCP gateway become tabs rather than three stacked sections. Re-adds the design system's Tabs primitive so the keyboard and ARIA behaviour is Radix's, not hand-rolled. Chat rename and delete - The row's ... affordance was carried over from the design as decoration with no handler behind it. It now opens a real menu: Rename edits in place, Delete is a two-step confirm inside the menu so a mis-click cannot destroy a transcript. - Delete is refused while that task is streaming — the run would keep writing to a task the list no longer has — and deleting the last task creates a fresh one, because the composer, navbar, and conversation all read from an active task. - Rename does NOT bump updatedAt: that field means "last activity", and reordering the list because someone fixed a typo would be wrong. - Blur is the only commit path; Enter and Escape just blur. Two paths racing over one edit each need to know whether the other already ran, which is what the first attempt got wrong. - Deletion is local. It drops the transcript Studio holds, not the session mecated persisted — a client should not be what decides a server-side session is finished with. Two bugs fixed while testing this - The overflow button was gated on `lg:group-hover`, leaving it pointer-events-none between 768 and 1023px: visible on hover and completely unclickable. The reveal is a hover behaviour, not a breakpoint one, and it now reveals on keyboard focus too. - The rename field's inline ref callback re-ran on every parent render — and the parent re-renders on every streamed chunk — re-selecting the field under the operator mid-keystroke. Guarded on activeElement. Type scale - Studio's CSS was drawn at its own much smaller scale: 47 rules sat at 10px or below, against the 14px body the new components use. Collapse the 12 legacy steps onto a four-rung ladder (11 micro / 12 meta / 14 body / 15 reading) so one screen no longer mixes two scales. Suggestion tiles now send - They filled the composer and left the operator to press Enter. They send through sendPrompt's existing text override, since reading `prompt` back after setPrompt would see the pre-update value in the same tick. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the display-only model popover with the combined picker from that
branch: the trigger reads "{model} {effort}", the menu drills into a Model
submenu and an Effort submenu, and a reset returns both to the server's
defaults.
Wired to real values rather than the prototype's hardcoded lists, because
both turned out to be genuinely settable:
- Model comes from the daemon's own /v1/models inventory, and sends
provider_id + model_id together — a bare model_id on an env-derived
default provider is a loud InvalidArgument.
- Effort sends CreateSession.reasoning_effort, using the neutral tiers the
proto defines (low/medium/high/xhigh/max, plus Auto = send no field).
Two departures from the source:
The selection applies to the NEXT session, and the menu says so when a task
is already bound. mecatl fixes provider and model for a session's lifetime,
so the alternative was the prototype's `lockedLabel` — a control that greys
out the moment you send your first message. Telling the operator when a
change takes effect is better than refusing the change.
A search field, because the source's list is five hardcoded models and a
real OpenRouter inventory is several hundred. It stops keydown propagation
so Radix's menu typeahead does not eat the first letter.
The trigger names the model instead of saying "Server default": an unpinned
session's resolved model is learned from CreateSession.resolved_model (the
only place mecated reports it) and persisted, so it survives a reload. Only
an unpinned session teaches us the default — a pinned one just echoes back
the id we asked for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lish Attachments beyond CSV - Text and code files (76 extensions) inline into the prompt as CSV already did, fenced and labelled UNTRUSTED. CSV and TSV keep their row/column shape, which is worth telling the model; everything else carries its mime type instead. - Images (PNG/JPEG/WebP/GIF) ride the /prompt endpoint's multimodal `parts` as base64 rather than being inlined — that endpoint already accepts them, well under the daemon's 20 MiB decoded media cap. - Kind is explicit rather than sniffed downstream, because inlined text and a multimodal part are genuinely different transports. - Extension is checked BEFORE the browser's sniffed type: some platforms report .ts as video/mp2t, which would have sent TypeScript as media. - The supported set is a list, not a "does it look textual" guess — a mis-sniffed binary becomes megabytes of mojibake in the model's context. - Images are refused up-front when the resolved session cannot accept them, using the session_capabilities CreateSession echoes, rather than letting the provider reject the turn mid-run. - Base64 conversion is chunked so a multi-megabyte image cannot blow String.fromCharCode's argument limit. Dictation - components/chat/use-voice-input.ts, ported from prototypes#130: Web Speech API, non-continuous so the microphone is never left open, with a Listening banner matching the box's other banners. The affordance is hidden where the browser has no recogniser rather than offered as a button that cannot work, and recognition stops if the composer unmounts mid-dictation. Composer polish - The textarea drew a grey focus box: globals.css's :focus-visible outline beat Tailwind's focus:outline-none, so the box needs the focus-visible variant too. - The stop control returns to the brand fill and a FILLED square. Red read as an error rather than a control, and an outline square at 16px is hard to tell from the send arrow at a glance. Model picker - The search row is a flex layout; absolute positioning had the icon overlapping the placeholder. - The list is capped at min(22rem, 55vh) — Radix only populates its available-height variable on Content, not SubContent, so the cap has to be viewport-relative to hold. - Models group under a provider heading instead of repeating the provider on every row, and the heading disappears when there is only one provider, where it would carry no information. - Dropped the "a session's model is fixed" paragraph; the trigger already names the model that is running. Skills page title is now "Skills". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Memory is configuration-adjacent, not a workspace surface you work in, so it joins Provider / Model router / MCP gateway as a Settings tab and leaves the rail to Chat, Skills, Scheduled, and Settings. It arrives as a SLOT rather than five more props: the panel reads a cluster of state that lives with the fetch filling it, and drilling userModel, userModelWired, memoryDir, memoryState, and memoryError through SettingsView would buy nothing. Its loader now primes on entering Settings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds Codex-style projects: a name plus the folder tasks run in. Verified end-to-end — a task in a project answered `pwd` with that project's path and listed its files, from a daemon launched in an entirely different directory. No daemon-per-project needed. mecated already takes the workspace PER SESSION (ADR 0032: a CreateSession whose workspace differs from the launch root routes through the per-session engine factory, pins child permission resolvers to the session root, and re-resolves that root's project rules), so one controller and one daemon serve every project. - scripts/local-controller.mjs gains GET /fs/browse. A browser cannot supply an absolute path — a directory <input> yields relative names with no root — so the folder chooser has to be served from the controller. DIRECTORIES AND NAMES ONLY: never file contents, never file names. This does not widen what the harness can reach (its Bash tool already sees the machine, posture-gated); it widens what the BROWSER can enumerate, which is why it is deliberately NOT in controller-security's readOnly allowlist and so requires the x-mecatl-studio-request header on top of the loopback+Origin gate. Verified: 403 without the header, 200 with it. Entries are capped at 500 so a huge directory cannot make the picker unusable. - components/projects/create-project-dialog.tsx: name + a navigable folder picker, with the folder's own name offered as the default project name until the operator types their own. - The chat panel groups tasks under their project, each with its own new-task action. Tasks predating a project keep a "No project" home rather than vanishing, and run against the controller's own root as before. - The profile pill names the active project and its path, so the folder about to be edited is always on screen. ONE folder per project, not the several the reference dialog offers: a mecatl session is rooted at a single `workspace` and every tool path resolves against it, so a project spanning two trees has no representation on the wire. A multi-folder field would be a control that silently kept only the first. Projects are refused in external mode, where the remote deployment owns its workspace. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
That branch added an icon-only mode to the console's rail, which is exactly
this rail's case, so the metrics come from it rather than being guessed:
- 19px icons, up from 20px. Its expanded rail uses 17px; an icon-only row has
no label to sit against, so it carries slightly more weight.
- Rows are `justify-center py-2.5` rather than a fixed h-11.
- Group boundaries get a `pt-5` gap instead of heading text, so the runs still
read as groups without labels — its approach for the same problem.
- Real tooltips (side="right" sideOffset={6}) rather than `title`: a native
tooltip's delay makes an unlabelled rail feel unresponsive. The accessible
name is an sr-only label inside the button, so the name and the visible
tooltip cannot disagree.
Icon vocabulary and order follow the branch too: Clock for Scheduled (not
Clock3), Brain for Memory (now the Settings tab's icon), and Chats before
Scheduled before Skills.
The tooltip primitive is the CURRENT upstream shadcn one, not the prototype's
fork — that fork adds a truncation-detecting effect which this project's
stricter eslint rejects, and none of its behaviour is needed for a static
label.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removes the daemon-reachability dot and its tooltip from the rail's foot. The state is still on screen in the profile menu, which shows the same dot with "Connected · <provider>", so nothing becomes undiscoverable — it just stops occupying the rail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Closing: superseded by a change of direction. This PR restyled Mecatl Studio ( That work is enterprise-ui-prototypes#138 — every agent hook now drives a live daemon, with all fixture data removed. What was harvested from this branch
What was not carried over, and is still here in the history if wanted: the token/dark-mode pass, font scaling, the two-level icon rail, and the Two fixes in here are worth keeping regardless, since they are Studio bugs rather than design work:
Nothing here is lost — the branch |
Summary
A visual update to Mecatl Studio: it now shares the Stacklok console's design system, shell grammar, and composer. Every handler, effect, and protocol path is the one that was already there — no functional change to how Studio drives
mecated.Based on
feat/studio-module(#548), sincestudio/only exists there.The two products turned out to share a lineage already — same fonts (Inter + Merriweather), same Tailwind 4.2.1, and Studio's brand green
#036a49is the console's--brand— so most of this is convergence rather than translation.What changed
Design tokens. Studio's ad-hoc
--ink/--line/--panel/--paperare replaced by the console's full token set with a.darkblock, re-exported through@theme inlineas Tailwind utilities. Pointing the surviving component CSS at those tokens is what earns the whole app dark mode rather than only the migrated parts.Shell. The left rail is now real navigation: Chat, a Workspace group (Skills, Memory, Scheduled), and Settings pinned to the footer — the console's icons, grouping, and full-bleed rows with the 3px
brand-inkaccent. The task list nests under Chat, because a task is a chat. The navbar ish-16 bg-sidebarso its seam lines up with the rail's wordmark block, plus a Sheet drawer belowmd.Composer. The console's three-box composer, where the toolbar is pulled up behind the input box so the two borders read as one outline. Keeps Studio's stop control (the console queues instead) and its CSV attach/drag path.
Settings page. Provider, Model router, and MCP gateway were three dialogs; they are now three sections of one page. The router form is taller than a dialog shows without its own scrollbar, and comparing the provider against the classifier used to mean closing one dialog to open another. Skills, Memory, and Scheduled lose their dialog chrome too.
Appearance. Four UI font scales driven by the root font-size, persisted locally, plus the light/dark switch — both in the profile menu, which is now only identity, connection, and appearance.
Notes for review
page.tsxowns the live SSE stream, so URL routing would mean lifting it into a layout and remounting the stream on every move. The conversation isdisplay:noned rather than unmounted, so a run keeps streaming while you read Settings. Real routes are a follow-up that has to solve that first.components.jsonmatches its style/baseColor, sonpx shadcn add <x>lands matching output. Two copied files carried a straySPDX-License-Identifier: Proprietaryheader that contradicts that repo's own Apache-2.0 LICENSE; those files were dropped as unused, but the headers are worth fixing at the source.Drive-by fixes
var(--bg)was undefined, rendering schedule buttons transparent.Verification
npm test— 5/5npm run lint,npm run typecheck,npm run build— cleanNot run: the Go gates (
task test,task build), untouched by this change.🤖 Generated with Claude Code