Skip to content
Back to skills

Studio Ui

ASecurity

Visual and component conventions for the Verbatra Studio dashboard (packages/studio), a React 19 / Vite 8 / Tailwind 4 single-page app served over a local verbatra project. Use when adding or reshaping UI under packages/studio/src/app: panels, primitives, design tokens in app/styles.css, theming, or anything rendered in the dashboard. Also use before accepting generic design advice that does not account for the dual light/dark theme or the strict Content-Security-Policy this app is served under.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 24, 2026
developmentjavascripttypescriptrustgojavashellbashreactexpressaws

Works with

  • cli
  • api

Security analysis

A100/100

Scanned September 24, 2026

npx -y skills add verbatra/verbatra --skill studio-ui --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Studio Ui?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Studio Ui
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/verbatra-studio-ui/badge)](https://www.skillsdirectory.com/skills/verbatra-studio-ui)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: studio-ui
description: 'Visual and component conventions for the Verbatra Studio dashboard (packages/studio), a React 19 / Vite 8 / Tailwind 4 single-page app served over a local verbatra project. Use when adding or reshaping UI under packages/studio/src/app: panels, primitives, design tokens in app/styles.css, theming, or anything rendered in the dashboard. Also use before accepting generic design advice that does not account for the dual light/dark theme or the strict Content-Security-Policy this app is served under.'
license: MIT
metadata:
  author: verbatra
  version: '1.0'
  source: 'internal'
user-invocable: true
---

# Studio UI (packages/studio)

## Overview

`@verbatra/studio` is a published, prebuilt single-page app that the `verbatra studio`
command serves over a real project on localhost. It is an operator console, not a
marketing page: the reader is a developer checking translation drift, clearing a review
queue, or spending provider budget. Its look is already decided and codified in
`packages/studio/src/app/styles.css` (172 lines).

Two things make generic frontend advice wrong here, and both are enforced by tests:

1. The app ships **two themes**, light and dark, and every token is defined twice.
2. The server sends a **strict CSP** with no `unsafe-inline` and no remote origins, so
   fonts, styles, and scripts cannot come from a CDN.

This skill states what is fixed, what is yours to extend, and where to look things up.

**When to use:** adding or restyling anything under `packages/studio/src/app/`, adding a
color/radius/shadow token, adding a panel, changing theming, or judging whether an
external design suggestion fits this app.

**When NOT to use:** the docs site (`apps/docs` has its own stack and its own skill,
`docs-ui`), the studio *server* under `packages/studio/src/server/` (no UI), or the
framework-neutral logic under `packages/studio/src/client/` (pure functions, tested
headlessly, no DOM).

## The stack, and why it constrains you

- `vite` 8.3.0, `@vitejs/plugin-react` 6.1.1, `tailwindcss` 4.3.3 through
  `@tailwindcss/vite`, `clsx` 2.1.1, `tailwind-merge` 3.7.0. React and TypeScript come
  from the workspace catalog.
- Tailwind 4 uses CSS-first configuration. There is no `tailwind.config.js` and no
  PostCSS config. Tokens are CSS custom properties and `@theme` blocks in
  `src/app/styles.css`.
- `vite.config.ts` sets `root: "src/app"`, `outDir: "../../dist/app"`, and
  `assetsInlineLimit: 0`. Nothing is inlined as a data URI; every asset is emitted as a
  file.
- There is no router library and no state library. Navigation is the URL hash, parsed by
  `src/client/routes.ts` and dispatched through the `PAGE_PANELS` table in
  `src/app/App.tsx`. State is React hooks plus small stores in `src/app/api.ts`.
- `src/app/index.html` is deliberately bare: a `<div id="root">` and one module script.
  Nothing else belongs in it.

## The CSP is a design constraint, not just a security header

`src/server/security-headers.ts` sends:

```
default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:;
font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none';
frame-ancestors 'none'
```

`src/server/csp-build-output.test.ts` runs a real Vite build and asserts the built HTML
contains no inline `<script>` with a body, no `<style>` element, no `on*` event handler
attribute, and no `javascript:` URL. Consequences for UI work:

- **No Google Fonts, no CDN stylesheet, no remote image.** `font-src 'self'` and
  `img-src 'self' data:` permit same-origin only. A web font must be self-hosted and
  bundled through Vite.
- **No `<style>` element and no literal `style="..."` in markup.** A React `style` prop
  is fine: React DOM writes through CSSOM, which CSP does not govern. Use it only for a
  value Tailwind cannot express, such as a computed percentage.
  `src/app/ProgressBar.tsx` (`style={{ width: \`${clamped}%\` }}`) is the single
  precedent in the app and should stay close to the only one.
- Anything that wants to reach a third-party origin at runtime is blocked by
  `default-src 'none'` and `connect-src 'self'`. Do not design a feature that needs one.

## Typography: no web font is loaded, and that is deliberate

`@theme` declares two families:

| Role | Stack head | Variable |
|---|---|---|
| Body | `"Inter"` then `ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, ...` | `--font-sans` |
| Code | `"JetBrains Mono"` then `ui-monospace, SFMono-Regular, Menlo, Monaco, ...` | `--font-mono` |

There is no `@font-face`, no `@fontsource` dependency, and no `<link>` in `index.html`.
Confirmed with a grep across `src/` and `vite.config.ts`. The named families are used
only if the operator already has them installed; otherwise the app renders in the system
UI font. This keeps the served bundle small and the CSP tight.

If you want a guaranteed typeface, that is a deliberate decision with a cost: add the
font files to the package, self-host them through Vite so they are served same-origin,
and accept the bundle growth. Do not add a `<link>` to a font CDN; CSP will block it and
the page will silently fall back.

Two families is the whole set. A third needs a reason that survives review.

## Token layers: always use the outermost one

`src/app/styles.css` has three layers. Write against the third, through Tailwind
utilities.

1. **Raw theme values, defined twice.** `--v-*` custom properties under `:root` (light)
   and again under `:root[data-theme="dark"]`. This is the only place a literal `hsl()`
   belongs.
2. **Static scales.** `@theme` holds the two font stacks and the radius scale. These do
   not change per theme.
3. **The Tailwind color mapping.** `@theme inline` maps every `--color-*` to its `--v-*`
   source, which is what turns a token into utilities such as `bg-card`,
   `text-muted-foreground`, `border-border`, `bg-success-soft`, `shadow-panel`.

Component code writes `className="bg-card text-muted-foreground"`. It does not write
`hsl(241 28% 11%)` and it does not write `var(--v-card)`. A raw color value in a
component is a defect: it breaks the single point of change and it will be wrong in one
of the two themes.

The token families, as they exist today:

```
surface/base   background  card  popover  muted  border
                foreground  muted-foreground
brand          primary  primary-strong  primary-foreground  accent
                accent-foreground  ring
sidebar        sidebar  sidebar-foreground  sidebar-muted  sidebar-border
                sidebar-accent  sidebar-active
status          success  warning  danger  neutral (each with a -soft companion)
diff            diff-new  diff-changed  diff-orphaned (each with a -soft companion)
shadow          shadow-panel  shadow-panel-lg
```

Two conventions carried by that list, both worth keeping:

- **Every status and diff hue is a pair**: a readable foreground (`--v-success`) and a
  low-alpha background (`--v-success-soft`). A new semantic hue ships both or it is not
  finished. `src/app/Badge.tsx` shows the pairing in use.
- **The sidebar is its own family and is dark in both themes.** In the light theme
  `--v-sidebar` is `hsl(244 48% 12%)`, a near-black indigo. That is intentional
  chrome-versus-canvas contrast, not an oversight. Do not "fix" it by mapping the sidebar
  onto the surface tokens.

**Known gap: there is no overlay token.** `OverlayBackdrop` (`src/app/ui.tsx`) draws the
modal scrim with `bg-foreground/40`. `--v-foreground` is near-black in light theme and
near-white in dark, so the scrim *brightens* the page behind a dialog in dark theme
instead of dimming it. This is a real defect, not a convention: do not copy the pattern,
and if you are touching overlays, add a `--v-overlay` pair to both `:root` blocks and
point the backdrop at it. A token whose polarity flips between themes is the failure mode
to watch for here, and it is easier to miss than a stray `dark:` variant because it looks
token-compliant.

Scales are fixed and narrow, deliberately:

- Radii: `--radius-sm` 4px, `--radius-md` 6px, `--radius-lg` 8px, `--radius-xl` 12px.
  These are tighter than the docs site's scale on purpose; a dense operator console reads
  better with less rounding. Do not import the docs values.
- Shadows: `--shadow-panel` and `--shadow-panel-lg`. Two, not a ramp, and each is
  redefined per theme because a lift that works on white is invisible on near-black.

## Both themes, every time

`:root` is light, `:root[data-theme="dark"]` is dark. The attribute is written by
`src/app/lib/theme-dom.ts` onto `document.documentElement`; the preference is
`"system" | "light" | "dark"`, persisted in `localStorage` under
`verbatra-studio-theme` (`src/client/theme.ts`), and `system` tracks
`(prefers-color-scheme: light)` live.

Rules that follow:

- A new `--v-*` token is added to **both** blocks in the same change. One block only is a
  bug that ships looking fine on the author's machine.
- Do not use Tailwind's `dark:` variant to patch a component. The theme belongs in the
  token, not in the markup. If a component needs different treatment per theme, the token
  it consumes is the wrong token or a new one is missing.
- Check contrast in both themes before calling a color done, particularly the `-soft`
  backgrounds, which sit at 10 to 18 percent alpha and shift substantially against a
  light versus a dark surface.

## Reuse before you build

`src/app/` holds 39 non-test `.tsx` files at its top level (that count includes `App.tsx`,
`main.tsx`, `ui.tsx` and `test-support.tsx`; the rest are components, each with a
co-located test). Compose these before writing anything new.

- **Primitives:** `Button` (`variant: "primary" | "secondary" | "ghost"`,
  `size: "sm" | "md"`, default `secondary`/`sm`), `Card` (`padding: "none" | "sm" | "md"`,
  `as: "div" | "section"`), `Badge` (`tone: "success" | "warning" | "neutral" | "danger"`),
  `Input`, `Select`, `Dropdown`, `Popover`, `Tooltip`, `Tabs`, `Accordion`, `Table`,
  `Sheet`, `Skeleton`, `Loading`, `ProgressBar`, `Toast`, `ErrorMessage`, `ErrorBoundary`.
  Extend a variant union rather than passing ad hoc `className` overrides at call sites.
- **Layout and shared class strings:** `src/app/ui.tsx` exports `Container`, `Section`,
  `PageSection`, `SectionCard`, `DetailList`, `EmptyState`, `DrawerShell`,
  `OverlayBackdrop`, `DialogCloseButton`, `MonoValue`, plus the shared strings `tableClasses`,
  `pillClassName`, `pillDotClassName`, and `microLabelClassName`. A new panel composes
  `PageSection` or `SectionCard`; it does not re-derive page padding or heading rhythm.
  The uppercase tracked micro-label is already a token (`microLabelClassName`) and is
  meant for structural labels such as table headers, not decoration above every heading.
  Be aware the shipped code does not fully honour that: `PageHeader.tsx` and `Sheet.tsx`
  apply it to a `kicker`, and on the Review and Translations panels that kicker reads
  "Workspace", restating the sidebar zone header sitting at the same height a few hundred
  pixels to the left. `SettingsPanel.tsx` shows the good case, where the kicker adds
  information. Treat a kicker that repeats its own navigation as the tell it is.
- **Icons:** `src/app/Icon.tsx` is a closed set of 24 inline SVG paths keyed by
  `IconName`. There is no icon package. A new icon is a new entry in `ICON_PATHS` and a
  new member of the union, drawn on the same 24-unit stroked grid as its neighbours.
- **Class composition:** always `cn()` from `src/app/lib/cn.ts` (clsx plus tailwind-merge),
  never string concatenation. It is what makes a caller's `className` override predictable.
- **Accessibility:** `use-dialog-a11y.ts` (focus trap and restore) and
  `src/client/roving-tabindex.ts` already exist. Any new overlay, drawer, or keyboard
  listbox uses them rather than hand-rolling focus management.
- **Panels:** the four panels under `src/app/panels/` all take `PanelProps`
  (`{ refreshToken: number }`, `src/app/panel-props.ts`) and are wired through
  `PAGE_PANELS`, `PAGE_LABELS`, `PAGE_ICONS`, and the `WORK_PAGES` / `REFERENCE_PAGES`
  split in `App.tsx`. Adding a page means adding it to `PAGE_IDS` in
  `src/client/routes.ts` and to every one of those tables; `App.tsx` throws at module load
  if the two zone arrays do not partition `PAGE_IDS` exactly.

## Capabilities are two flags, and the decision is a pure function

`StudioCapabilities` (`src/shared/rpc/snapshot.ts`) is
`{ spend: boolean; writeToDisk: boolean }`. Do not conflate them:

- `spend` costs provider tokens and is off unless the server was started with
  `--allow-spend` (`packages/cli/src/studio-command.ts`). Retranslate and
  translate-pending need it.
- `writeToDisk` is local editing. It defaults to true
  (`src/server/methods/snapshot.ts`), so an edit control is normally live even when
  `spend` is off.

The eligibility decision lives in `src/client/` as a tested pure function, never inline
in a component: `canRetranslate` (`src/client/retranslate-eligibility.ts`, requires
`spend`) and `canTranslatePending` (`src/client/refresh-toast.ts`, requires `spend` and
`writeToDisk`). The component reads `useCapabilities()` and calls the helper.
`KeyDetailDrawer.tsx` is the precedent for both shapes: `canRetranslate(capabilities, pill)`
for the spend-gated action and a direct `capabilities?.writeToDisk === true` check for
edit. `RefreshToast.tsx` is the precedent for a gated toast action, and
`SettingsPanel.tsx` is the only place that renders `spend` as visible status.

Note what these precedents are *not*: `RetranslateButton.tsx` and `ReviewRowActions.tsx`
read no capability at all. They are presentational, take callbacks, and are gated by
their caller. Keep it that way. A new gated control adds its rule to the pure function in
`src/client/`, where it can be tested without a DOM, and stays dumb itself.

## React and Next questions go to Context7

Do not answer React, Vite, or Tailwind 4 API questions from memory. Query Context7, one
concept per query. `vercel-react-best-practices` covers rendering and data-fetching
patterns; this app is a client-rendered SPA, so ignore its server-component guidance.

## Repository rules that bite in UI work

- No em dash (U+2014) anywhere, including inside JSX text.
- No prose comments and no JSDoc on internal code. Component names and structure carry
  intent. `@verbatra/studio` is published, so a declaration that reaches
  `packages/studio/dist/index.d.ts` is public API and does take JSDoc; a component under
  `src/app/` does not reach it.
- No emojis and no decorative formatting.
- Tests are co-located and this package extends `testInclude` with
  `src/app/**/*.test.tsx` (`packages/studio/vitest.config.ts`). The 90 percent coverage
  gate applies to `src/app/**/*.tsx` too. A new component ships its test in the same
  change.
- `@verbatra/studio` is publishable and versions independently of the sdk/cli pair. Any
  `src` change needs a changeset naming `@verbatra/studio`.
- Run `pnpm check` or `pnpm format` for Biome before committing.

## Verify before you trust this file

Tokens drift. Before quoting a value from this skill, confirm it:

```bash
grep -n '^\s*--v-\|^\s*--radius-\|^\s*--color-' packages/studio/src/app/styles.css
```

If the file and this skill disagree, the file wins, and this skill needs updating in the
same change.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.