Shared UI design system (CAD-Preview ↔ MDPA-Preview ↔ KKSS)
The written, canonical description of "the look" shared by the Kratos preview family: extracted from CAD-Preview (the visual reference) and extended where MDPA-Preview has surfaces CAD-Preview lacks. Materialised as webview/design-system.css — a static, dependency-free stylesheet + token file written so CAD-Preview and KKSS can adopt the identical file verbatim.
Principles
- VS Code theme tokens are the only colour source. Every colour is
var(--vscode-*, <dark-fallback>); hardcoded hex appears only as the fallback, plus the handful of scene-side constants CAD-Preview already hardcodes (orientation-cube faces, axis arrows, quality band colours). - A border, and a light shadow on what floats. Floating surfaces are
editorWidget-background+ a 1 pxeditorWidget-border; the toolbar card, dropdown menus and floating panels add--ui-elev-2(the dock--ui-elev-3). Nothing in the page flow has a shadow. - No decorative motion. Hover/active states switch instantly. Exactly two animations are permitted, both of them status rather than ornament: the indeterminate progress sweep, and the mark on a full-screen loading overlay — allowed because a blocking screen is a state, not chrome, and because when the host cannot report a total there is otherwise nothing on screen saying the extension is alive. Overlay motion must be slow, single-axis and linear (MDPA-Preview turns its logo once every three seconds) so it reads as working rather than as decoration, and must be disabled under
prefers-reduced-motion: reduce— as must any motion added later. font: inheriton every control so buttons/inputs/selects never fall back to the browser default font.- Two active-state idioms, never mixed (documented in CAD-Preview's stylesheet):
- mode-on ("this mode is enabled"):
inputValidation-infoBackground+ 1 pxfocusBorderoutline. Used for toggles: Wireframe, Clip, Grid, Ortho. (The toolbar's own toggles — Pan, Quality, Field, Find, Inspect — now use CAD's filled accent instead, and an open menu trigger the quiet lift; see the Toolbar recipe.) - selected-1-of-N ("this option is the current one"): in a segmented track (see Segmented picker) the choice is the quiet lifted segment; the accent (
button-background) fill is for "the thing that is ON" — the clip axis, the Field panel's mode toggles (several can be on at once), an active.panel-btntoggle.
- mode-on ("this mode is enabled"):
Tokens (design-system.css :root)
| Token | Value | Role |
|---|---|---|
--ds-font | var(--vscode-font-family, sans-serif) | the one font stack |
--ui-fs-micro/label/sm/md/lg/xl (legacy --ds-font-size-xs…xl, one step behind: -md is --ui-fs-sm) | 9 / 10 / 11 / 12 / 13 / 14 px | the whole type scale; weight is default or 600, never 700. style.css carries no literal font-size — pick the token (a 15 px empty-state title became 14) |
--ui-r-xs/sm/md/lg/pill (legacy --ds-radius-sm/-/md/menu/card) | 3 / 5 / 8 / 12 / 999 px | by ROLE: xs chips, swatches, ticks, band bars; sm every button, input, select and segment; md floating cards — toolbar, menus, panels, toast; lg the nav dock; pill badges and the document chip. --ds-radius-sm (2 px) survives only for slider/progress tracks; 50% (dots, thumbs) is the one literal allowed. style.css carries no numeric border-radius |
--ui-s-0…6 | 2 / 4 / 6 / 8 / 12 / 16 / 24 px | spacing scale; use it for gaps, paddings and margins where the value is on the scale (odd one-offs — 3, 5, 10, 14 px — stay literal) |
--ui-h-ctl | 24 px | the height of every control: buttons, inputs, selects, segments (20 px inside the 2 px track), icon buttons |
--ds-fg / --ds-bg | foreground / editor-background | base text/canvas |
--ds-widget-bg / --ds-widget-border | editorWidget-background / editorWidget-border | floating surfaces (nav dock, toast, panels) |
--ds-border | alias of --ds-widget-border | the generic hairline, for rules that just want "a border" (it also silences the theme guard's warning about var(--ds-border, …) fallbacks) |
--ds-sidebar-bg / --ds-sidebar-border / --ds-section-header-fg | sideBar-background / sideBar-border / sideBarSectionHeader-foreground | the sidebar column |
--ds-primary / --ds-primary-fg / --ds-primary-hover | button-background / button-foreground / button-hoverBackground | primary buttons, selected-1-of-N |
--ds-secondary / --ds-secondary-fg / --ds-secondary-hover | button-secondary* | secondary buttons, unselected segments |
--ds-mode-on-bg / --ds-focus | inputValidation-infoBackground / focusBorder | the mode-on idiom |
--ds-input-bg / --ds-input-border / --ds-input-fg | input-* | text/number inputs, selects; focus = border-color swap, no outline ring |
--ds-menu-bg / --ds-menu-border / --ds-menu-fg / --ds-menu-sel-bg / --ds-menu-sel-fg | menu-* | dropdown panels and item hover |
--ds-hover / --ds-toolbar-hover / --ds-list-sel-bg / --ds-list-sel-fg | list-hoverBackground / toolbar-hoverBackground / list-activeSelection* | rows, ghost buttons, tree selection |
--ds-track / --ds-thumb | scrollbarSlider-background / progressBar-background | the slider recipe |
--ds-error / --ds-warning | errorForeground / editorWarning-foreground | inline errors / warnings |
--ds-shadow-menu | 0 2px 8px rgba(0, 0, 0, 0.36) | dropdowns only |
--ds-header-bg / --ds-header-border | editorGroupHeader-tabsBackground / editorWidget-border | the 34 px menubar strip and the 24 px status bar |
Component recipes
- Menubar: in-flow, full-width, 34 px,
--ds-header-bg, 1 px bottom--ds-header-border, 13 px. The File trigger is CAD's bordered pill (glyph("home")· label ·glyph("chevronDown"), 1 px--ds-widget-border, radius 5,padding: 3px 10px). Right-aligned (margin-left: auto, neverspace-between, which would re-centre the pill when the chip appears) is the document chip#doc-chip: an unsaved dot (.ui-dot) · file name · format badge (.ui-badge, the router's extension —mdpa,post.msh) · "N unsaved edits" (mono, muted). It shipshiddenand is filled by thedocumentInfohost→webview message (src/documentInfo.ts), posted by both providers onreadyand whenever the op list or save point moves, de-duplicated by serialisation. Dirty is not VS Code's tab dot: the dot is a one-way latch cleared only by a save or revert, while the chip counts the operations that differ from what the source file holds (unsavedEditCount, by record identity) — undoing back to the save point clears it at once, undoing past it counts what the file has and the view lacks. - Status bar:
#statusbar, the last child of#appafter#main(so the viewport, timeline bar, nav dock and toast all rise by layout, not arithmetic). 24 px (--ui-statusbar-h), 10 px muted text on--ds-header-bg, 1 px top border. Cells left→right:#engine-status(a dot withdata-toneidle/loading/ready + text, exactly as wide as the sidebar via--side-width, whichsidebarResize.tskeeps current),#sb-count-model("12,345 nodes · 6,789 elements · 42 conditions", zero kinds omitted),#sb-count-frame("frame 3 / 12 · step 0.25", only for multi-frame data), and a right-aligned#sb-cursor(the last Inspect pick, "element 45 · Triangle2D3 · node 123 (x, y, z)"; empty while the Inspect panel is closed). Every fact cell shipshiddenand shows only when it has text; wording is pure (src/statusStats.ts, en-US digit grouping, facts never verdicts). - Engine status (
engineActivity.ts→engineStatusmessage →#engine-status): idle → loading → ready per engine, INFERRED from calls exactly like CAD's kernel status. Real signals only:loadMeshio()(meshio++), the MMG runner call inoperations.ts(remesh / level-set, worker or in-process) and Pyodide's memoised init. "Ready" means "a call has succeeded this session", not "an instance is resident" — meshio++ is a fresh instance per call and each MMG run is its own worker. Engines nothing signals are left out (the Flowgraph editor is a forked process, not WASM), so a plain.mdpaopen reads "Engines idle". - Toolbar:
#toolbaris ONE floating card (CAD's recipe) —--ds-widget-bg+ 1 px--ds-widget-border, radius 8,--ui-elev-2,padding: 2px,gap: 2px, 12 px text,top: 8px; right: 8px, wrapping onto a second row rather than running under the sidebar. Buttons are ghosts: 28 px tall, transparent with a 1 px transparent border, radius 5,padding: 0 8px,gap: 6px, hover--ds-toolbar-hover, a 15 pxuiGlyphsglyph then the label (rotateCcwReset,movePan,activityQuality,paletteField,searchFind,crosshairInspect,eyeView,slidersAdvanced — the TikZ set stays for menu items). The two menu triggers (.tb-menu) end in a 12 pxchevronDownat opacity 0.7, and a.tb-div(1 px × 18 px hairline) separates them from the plain actions. A plain toggle that is on (.active) is the accent fill (--ds-primary); an open or.activemenu trigger is the quiet lift (list-inactiveSelectionBackground) plus a 6 px--ds-focusdot, never the accent. Everybutton[data-action]must stay a direct child of#toolbar— mesh's JS selects#toolbar button[data-action=…]. - Dropdown panel (
.tb-dropdownin CAD;#file-menu-popup,#view-popup,#advanced-popuphere):min-width: 200px, radius 8,--ds-menu-bg+ 1 px--ds-menu-border,--ui-elev-2,padding: 4px, above every floating panel; items radius 3,padding: 6px 12px,gap: 8px, hover/focus--ds-menu-sel-bg/-fg; separators 1 pxmargin: 4px 6px; checkable items reserve a✓column (::before,width: 1em, opacity 0 → 1). One wiring,webview/dropdownMenu.ts: opening a menu closes the others, an outside pointerdown dismisses (and is swallowed, so it never reaches the canvas), Escape closes and returns focus to the trigger, ArrowUp/Down/Home/End move between items, clicks inside don't dismiss, one-shot items close themselves. The toolbar's two popups are siblings of#toolbar, so the module hangs them under its bottom edge, right-aligned with the trigger, on every open. - Sidebar:
--ds-sidebar-bg, right border--ds-sidebar-border; section headers 11 px / 600 / uppercase /letter-spacing: 0.05em/--ds-section-header-fg, hairline separators. Section header = a chevron button (.panel-chevron, onechevronDownglyph rotated offaria-expanded) · a 22 px.panel-icontile ·.panel-title· optional 24 px.panel-icon-btnactions; a collapsed header drops its bottom border. The sections that edit the model stay top-level; read-only ones sit in one collapsed#advanced-groupcard with ann of mbadge. Default width 272 px, resizable. - Tree/list row: 22–24 px tall; hover
--ds-hover; selection--ds-list-sel-bg/-fg; chevron 10 px opacity 0.7; count badge 10 px opacity 0.55; row action buttons are ghost (18×18,line-height: 1, opacity ~0.5 rest → 1 +--ds-toolbar-hoveron hover). - Form row / controls: label column 72 px / 10 px / opacity 0.7; every input, number field and select is 24 px (
--ui-h-ctl), radius 5,padding: 0 6px, 11 px,font: inherit,--ds-input-bg+ 1 px--ds-input-border+--ds-input-fg; focus =border-color: --ds-focus,outline: none(no glow). Checkboxes/radios are left native. Instyle.cssthis is ONE selector list (the control list, above the panel rules), so a new control class is one added line. Numeric inputs are mono + tabular. Inline-editable fields are invisible until hover/focus (transparent border → input border → focus border). - Slider:
appearance: none; track 3 px, radius 2,--ds-track; thumb 12 px round--ds-thumb; disabled thumb falls back to the track colour. (Webkit-only — VS Code webviews and Electron are Chromium.) - Floating panel (MDPA extension — CAD has no floating panels): CAD's card —
--ds-widget-bg+ 1 px--ds-widget-border, radius 8,--ui-elev-2,padding: 10px 12px, 12 px text — written ONCE as an:is(#quality-panel, #field-panel, …)list, so the recipe is a single edit; each panel's own rule keeps only position, size and z-index. Header = CAD's.panel-header: an 11 px / 600 / uppercase title, a hairline under the row and a 24 px ghost.panel-icon-btn-style close (glyph("x"); the Field panel adds achevronUpcollapse). Panels anchored to the bottom edge (Quality, Mesh size/Spheres/Beams/Integrals, Inspect) stop above the nav dock —bottom: calc(var(--nav-rest) + var(--nav-height) + 8px)—--nav-restis the dock's resting line, set beside--nav-bottominsyncNavOffset(); the toast uses--nav-bottom, which also grows with a docked table or chart — instead of painting over its ends. - Secondary button (
.panel-btnand the panels' action rows): 24 px, radius 5, no border,--ds-secondary/-hover, 11 px;.active(a toggle that is ON) is the accent fill. One selector list. A row of them is.meshsize-actions. - Segmented picker (
.ui-segments/.ui-seg; the dock's.nav-segments/.nav-segand the panels'.field-modes/.meshsize-modesare the same recipe under historical names — new code usesui-*): CAD's inset track —--ds-input-bg,padding: 2px, radius 5, transparent segments with radius 3, hover--ds-toolbar-hover. The chosen segment is the quiet lift (list-inactiveSelectionBackground, 600);.ui-segments.accentand the Field panel's toggles use the accent fill. Keep the.active/aria-*hooks — the panel and dock code toggle them. Use it for a genuine 1-of-N; a row of plain actions is.meshsize-actions. - Toast (
#message, was the "status pill"): CAD's#status— bottom-centre over the canvas (left: 50%of#vtk-sub), radius 8,--ui-elev-2,--ds-widget-bg+ 1 px border,padding: 6px 12px, 12 px;.errorrecolors to--ds-error. Lifted clear of the nav dock:bottom: var(--nav-bottom) + var(--nav-height) + 8px, where--nav-bottomis the dock's resting offset (syncNavOffset(), which itself clears the 36 px timeline bar) and--nav-heightis the dock's REAL height, published byNavControlsthrough aResizeObserver— so a wrapped second row or a collapse moves the toast with it instead of a hardcoded card height. Inline feedback beats toasts whenever the webview owns the flow. - Progress: indeterminate sweep on a 2–3 px track (
--ds-trackbase,--ds-thumbbar); cancellable runs pair it with a play→stop button swap (MDPA extension). - Timeline bar (
#timeline-bar,webview/timeline.ts): 36 px, the status bar's surface (--ds-header-bg+ a top hairline, no shadow) at the viewport's bottom edge; prev / play-pause / next are 24 px ghost.panel-icon-btns carryinguiGlyphs(skipBack,play⇄pause,skipForward) and anaria-label,#tl-scrubis the standard slider,#tl-labeland the fps field are mono + tabular. The 36 px is load-bearing: the series chart and the dock's bottom offset are computed from it. - Numeric readouts: mono + tabular (
.ui-num) — the stats values, the timeline label, panel value cells, table cells. Applied by class list instyle.cssfor cells built by panel code. - Nav dock (
webview/navControls.ts, JS-built; CAD's#view-controlsrecipe): ONE wrapping row —[reset · fit · zoom out · zoom in]|[Shaded | Wire]|CLIP [Off] [X Y Z Free] ──o── readout|[Persp] [⋯]— with the collapse chevron last byorder: 2. Bottom-centre over#vtk-sub, radius 12,--ds-widget-bg+ 1 px border,--ui-elev-3,padding: 6px 12px,width: max-content; max-width: calc(100% - 32px)with#nav-body { flex-wrap: wrap }— one row while it fits, nevernowrapor a sideways scroll, wraps to a second row when narrow. Nav icons are 26 px ghost buttons with 15 pxuiGlyphs; hairlines.nav-divbetween clusters; the CLIP caption is.nav-label(10 px / 600 / uppercase / 0.06em / opacity 0.7). A 1-of-N choice (Display, Clip axes, rotate step) is ONE inset.nav-segmentstrack (--ds-input-bg, radius 5, 2 px padding) with the selected.nav-seglifted out: a quiet grey lift for a mode (.active), the accent fill only for the clip axis; standalone controls (.nav-pill: Off/On, Persp/Ortho, Flip, Edges) are 24 px bordered ghosts, and the toggled ones use the mode-on treatment. The readout is a.ui-numcell of fixed width, ellipsized (full text in itstitle). The ⋯ popover (#nav-more-popup, wired by the sharedsetupDropdown) is a.tb-dropdown-style card (radius 8,--ui-elev-2) that opens UPWARD from the dock (bottom: calc(100% + 6px), right-aligned,#nav-more-wrapstatic so the dock is its containing block) and holds Rotate (step picker + 24 px D-pad) beside Pan, Clip Flip + the Free-normal inputs, the theme picker + opacity, and Edges + Center on model. Clicks inside it leave it open; an outside click / Escape closes it, and a slider, number field or select inside keeps its own arrow keys. Collapsed = the chevron alone (rotated 180°).
Layout rules
- Orientation cube: top-left, ~96 px, 10 px margin. Uniform blue faces
#2b6cb0, border#1a4a7a, bold white labels RIGHT/LEFT/TOP/BOTTOM/FRONT/BACK; axis arrows X#ff3653, Y#8adb00, Z#2c8fff. - Nav dock: bottom-center. Toolbar: top-right. Toast: bottom-center, just above the nav dock. Status bar: full width, below everything. The 36 px timeline bar sits at the bottom of the viewport, i.e. directly above the status bar.
- Sidebar: left column; viewport fills the rest. The resize sash is invisible (6 px hit area over the sidebar's border) and shows a hover/focus border.
- z-ladder (low→high): canvas overlays (5) → floating bars/pills (10–12) → toolbar (15) → menubar (20) → floating panels (20–22) → dropdowns (30) → loading overlay (100).
Interaction vocabulary (glossary)
Canonical verbs, with MDPA's current names mapped:
| Canonical | CAD-Preview | MDPA-Preview today | Notes |
|---|---|---|---|
Fit | Fit | Fit | reframe in place |
Ctr | Ctr | Ctr | re-center focal point |
Clip | Clip | Cut Plane | rename pending Tier-1 approval |
Ortho / Persp | Persp↔Ortho toggle | Parallel Projection menu check | adopt the flip-label toggle |
Export… | Export… | Export as ▸ (inline formats) | MDPA keeps the inline format list (36 formats) |
Save/Load Preprocess… | Save/Load Preprocess… | Save/Load problem… | MDPA keeps "problem" (Kratos domain term, shipped .kratosproblem.zip format) |
BACK | BACK | REAR | cube face; rename pending Tier-1 approval |
Isolate | ⊙ Isolate | — (per-layer checkboxes) | no MDPA equivalent planned |
View snaps 1–6, i | — | 1–6, i | MDPA extension; candidate for upstreaming to CAD |
KKSS adoption notes
KKSS consumes both siblings as unmodified submodules, copies their stylesheets verbatim at build time, and gates the build on tools/check-theme-vars.mjs: every --vscode-* variable referenced must be defined in its app/renderer/theme/vscode-vars.css. Adopting this design system adds these variables to that contract (Dark Modern values in parentheses):
--vscode-sideBar-background (#181818*), --vscode-sideBar-border (#2b2b2b*), --vscode-sideBarSectionHeader-foreground (#cccccc), --vscode-editorWidget-border (#454545), --vscode-editorGroupHeader-tabsBackground (#181818*), --vscode-inputValidation-infoBackground (#063b49), --vscode-scrollbarSlider-background (rgba(121,121,121,0.4)), --vscode-list-activeSelectionBackground (#04395e), --vscode-list-activeSelectionForeground (#ffffff), --vscode-menubar-selectionBackground (rgba(90,93,94,0.31)). * classic Dark+ uses #252526/#3c3c3c — match whichever family vscode-vars.css follows.
KKSS integration steps on the next submodule bump: add mesh/media/design-system.css to the copies array in esbuild.mjs's copyArtifacts(), add one <link> before style.css in the mesh page template (tools/webviewMarkup.ts / gen-webview-html.mjs), extend check-theme-vars.mjs to scan the new file, and add the variables above.
Hoisting proposal (not yet executed)
Today the file lives in this repo and is adopted by verbatim copy — the same convention the three repos already use for the icon pipeline (build-toolbar-icons.mjs is copied verbatim) and that KKSS uses for whole stylesheets. Copying keeps each repo self-contained and has no licence consequence (each copy is licensed under its host repo's terms; CSS tokens/recipes of this kind are far below any threshold of originality concern between projects with the same author).
The alternative is a shared npm package (e.g. @kratos-multiphysics/ui-design-system) that each repo lists as a devDependency and copies into its media/ at build time (KKSS already does exactly this with xterm.css). Licence consequence: the package must be permissively licensed (MIT or Apache-2.0) to be consumable by GPL-2.0-or-later CAD-Preview and AGPL-3.0 MDPA/KKSS — publishing it under either GPL flavour would wall off one consumer. Recommendation: stay with verbatim copy until the file stabilises across both extensions, then publish MIT if drift becomes a maintenance problem.