Skip to content

feat(studio): adopt the enterprise design system, rail navigation, and appearance controls - #601

Closed
jtenniswood wants to merge 12 commits into
feat/studio-modulefrom
feat/studio-enterprise-ui
Closed

jtenniswood wants to merge 12 commits into
feat/studio-modulefrom
feat/studio-enterprise-ui

Conversation

@jtenniswood

Copy link
Copy Markdown

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), since studio/ 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 #036a49 is the console's --brand — so most of this is convergence rather than translation.

What changed

Design tokens. Studio's ad-hoc --ink/--line/--panel/--paper are replaced by the console's full token set with a .dark block, re-exported through @theme inline as 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-ink accent. The task list nests under Chat, because a task is a chat. The navbar is h-16 bg-sidebar so its seam lines up with the rail's wordmark block, plus a Sheet drawer below md.

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

  • 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: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.
  • Font scaling required converting 79 px font sizes to rem. A px value silently opts that text out of scaling, so the conversation surface would not have moved.
  • shadcn primitives are copied from the prototype, not re-derived, so the two products cannot drift. components.json matches its style/baseColor, so npx shadcn add <x> lands matching output. Two copied files carried a stray SPDX-License-Identifier: Proprietary header 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.
  • The composer added an IME guard — committing a candidate with Enter no longer sends the turn.
  • Removed the composer hint line that claimed Enter-to-send while offering no visible affordance.

Verification

  • npm test — 5/5
  • npm run lint, npm run typecheck, npm run build — clean
  • Driven in a browser: both themes, all five destinations, and font scaling verified at 16px → 20px

Not run: the Go gates (task test, task build), untouched by this change.

🤖 Generated with Claude Code

jtenniswood and others added 12 commits August 17, 2026 12:01
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>
@jtenniswood

Copy link
Copy Markdown
Author

Closing: superseded by a change of direction.

This PR restyled Mecatl Studio (studio/) with the enterprise design system. We have since decided the Atrium workspace in stacklok/enterprise-ui-prototypes is the product, and moved Studio's real mecated integration into it rather than continuing to build two clients against the same daemon.

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

  • The per-session workspace insight (ADR 0032) — that one daemon serves many project folders — is what "projects" are built on there.
  • The model + effort picker, now wired to GET /v1/models and mecatl's real effort tiers.
  • The multimodal attachment path (images as prompt parts, text inlined and fenced untrusted).
  • The capability map, which began as the analysis in this PR's session and now lives at docs/mecatl-capability-map.md.

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 /fs/browse folder-picker dialog. The folder picker is the most likely thing to want next — Atrium's project creation currently takes a typed absolute path.

Two fixes in here are worth keeping regardless, since they are Studio bugs rather than design work:

  • var(--bg) was undefined, rendering schedule buttons transparent.
  • The composer had no IME guard, so committing a candidate with Enter sent the turn.

Nothing here is lost — the branch feat/studio-enterprise-ui remains.

@jtenniswood
jtenniswood deleted the feat/studio-enterprise-ui branch August 18, 2026 09:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant