hung doan
Style Guide

hung doan — Style Guide

The shared component system for every /video/ page. Built from three stylesheets: css/tokens.css (design tokens + the sharp-corner reset), css/shell.css (chrome, buttons, forms, pills, modals), and css/editor.css (workspace, panels, layer/job rows, timeline). No build step — author HTML against these classes and it ships. This page itself uses only those classes.

This guide is self-contained — CSS, JS, the icon sprite and placeholder art are vendored into styleguide/, so the whole folder zips and runs anywhere with no ../ dependencies. Full-page layout references live in Reference pages.

An agent-readable version of this system lives in styleguide/skills/ — one Markdown spec per topic (start at skills/SKILL.md) so AI coding agents can load the design language directly. Each section below carries a skill: … link to its spec file.

Design principles

The core idea: this is a desktop application, not a web page. Every page wears native-app chrome so the product feels like a tool you install, not a site you visit. Seven rules carry that through:

1 · Desktop-app feel — the defining principle. Each page is a fixed-height .vshell (no page scroll) with a titlebar menu bar (File / Edit / Actions ribbon with drop-downs, .menus + .menu-drop), a statusbar at the bottom, and content organised into panels with collapsible sections (.panel-section + .panel-toggle) and tabs — exactly the affordances of a native editor (Photoshop, an NLE, a DAW). Right-click opens a .ctx-menu. Everything else here serves this feel.
2 · Authoring is desktop-only; front-facing views are responsive. The editing/authoring UI is desktop-only by design — the workspace, timeline and multi-panel tools are too complex to collapse onto a phone, so editors must never render at a mobile breakpoint. The front-facing, end-user views — browsing the template gallery, the media gallery, render-queue status — are responsive. The split is producer vs. consumer: an editor creates a reel and publishes a template on desktop; an end-user or stakeholder then browses and uses that template, or checks render status, on their phone.
3 · Sharp corners everywhere. A global reset zeroes border-radius on all elements — square, defined edges read as "application". The only exemption is inside [data-live-stage] (real artwork canvases, where payload radii must render). Don't add radii — if you must, qualify your selector to out-specify the reset (see the scene-map brief card).
4 · Graphite surfaces, blue accent. Layered greys (--bg--panel-3), one blue accent for primary/active, status colors reserved for meaning.
5 · No log modals. Logs and status live in persistent panels (queue bottom panel, jobs strip), never popups — like a docked output panel in an IDE.
6 · Mark unbuilt controls .future. Yellow-green + a data-stub toast — see Conventions below.
7 · Relative paths only. Every asset/script reference is relative so a page works at any depth.
See the desktop chrome assembled in the mock pages — the Mock Editor shows the menu bar, collapsible panel sections, tabs, and statusbar working together.

Shell & chrome — the desktop frame

The native-app feel comes from the frame every page wears. The titlebar at the top of this page is live — click Go to, File, Edit or View to open a drop-down ribbon (the File/Edit/View menus are inert — there for the desktop feel), and right-click the Context-menu demo below. The pieces:

Desktop menu-bar drop-down: the hung doan brand, a Go to menu opened with AI Studio / Asset Library / Help / Back to Dashboard items
The menu-bar ribbon in action — desktop-style drop-down navigation (.menus.menu-drop), opened by shell.js.
.vshell — full-viewport grid, grid-template-rows: 44px 1fr (titlebar / content). Add a third 26px row for a statusbar, and .with-timeline for a bottom strip. The shell never page-scrolls — content panels scroll internally, like an app window.
.titlebar holds: .brand (the wordmark) · .menus.menu.menu-drop (the File/Edit/Actions ribbon; .kbd for shortcuts, hr separators, .danger items) · .project-title (center) · .top-actions (right). Menu open/close, drop-downs, and data-modal / data-stub are wired by shell.js — no per-page JS.
.statusbar — the 26px footer (left message + right meta), an app-style status line.
.ctx-menu — right-click menu via VideoShell.contextMenu(x, y, items); see Overlays.
Assembled and interactive in the Mock Editor (menu bar + collapsible panel sections + tabs + statusbar) and the other mock pages.

Layout anatomy

How the desktop frame nests. The ASCII map shows the regions; the mermaid block is the same structure as a diagram you can paste into any mermaid renderer (kept as source so this bundle stays dependency-free).

Region map — a .vshell.with-timeline page
┌────────────────────────────────────────────────────────────────────────────────────────────┐
│  .titlebar (44px)                                                                          │
│  .brand     .menus (File / Actions)     .project-title     .top-actions                    │
├──────────────────────────┬──────────────────────────────────────┬──────────────────────────┤
│  .left-panel             │  .stage-area                         │  .right-panel            │
│   tray tabs              │   .viewer-frame                      │   .tabs                  │
│   > Cast                 │   (canvas)                           │   > .panel-section [v]   │
│   > Frames               │                                      │     (collapsible)        │
│                          │  .workspace - 3-col grid             │                          │
├──────────────────────────┴──────────────────────────────────────┴──────────────────────────┤
│  .timeline-wrap - jobs strip / timeline   (.with-timeline)                                 │
├────────────────────────────────────────────────────────────────────────────────────────────┤
│  .statusbar (26px)   left message ......... right meta                                     │
└────────────────────────────────────────────────────────────────────────────────────────────┘
Region map — rendered
.brand hung doan .menus (File / Actions ribbon) .project-title .top-actions .left-panel tray tabs > Cast / Frames .stage-area .viewer-frame (canvas) .right-panel .tabs > .panel-section [v] (collapsible) .workspace — 3-column CSS grid .timeline-wrap jobs strip / timeline · .with-timeline .statusbar — left message right meta
DOM hierarchy — mermaid source
mermaid
graph TD
  vshell[".vshell · CSS grid"] --> titlebar[".titlebar · 44px"]
  vshell --> workspace[".workspace · 3-col"]
  vshell --> timeline[".timeline-wrap · optional"]
  vshell --> statusbar[".statusbar · 26px"]
  titlebar --> brand[".brand"]
  titlebar --> menus[".menus → .menu → .menu-drop"]
  titlebar --> ptitle[".project-title"]
  titlebar --> actions[".top-actions"]
  workspace --> left[".left-panel · tray tabs"]
  workspace --> stage[".stage-area · .viewer-frame"]
  workspace --> right[".right-panel · .tabs + .panel-section ▾"]

Color tokens

CSS custom properties on :root in tokens.css. Always reference the variable, never the hex.

Surfaces
Lines
Text
Accents & status

Typography

Inter for UI (--font, 14px/1.4 base), monospace for IDs/timecodes/code (--mono). Readable content — documentation, front-facing/end-user copy, prose — uses the 14px base. Editor & tooling UIs drop to 12–13px to fit dense, panel-heavy workflows within the browser; that density is intentional there and shouldn't leak into readable text (this page itself follows the rule — its prose is 14px).

Page heading 24px · .page-head h1
Section heading 16–17px · modal h3
Panel head 15px · .panel-head strong
Body text — the 14px base --font
Label / secondary 12px · label, .card span
Section title (overline) 12px · .section-title
mono 0:42 · #4521 --mono · .timecode

Icons

Lucide-adapted SVG sprite at assets/icons/icons.svg, all currentColor. Place <i data-icon="name"></i>icons.js hydrates it on load; call VideoIcons.hydrate(root) after injecting markup. Sizes: .ic (16) · .ic-lg (20) · .ic-xl (24). Click any icon to copy its markup.

<i data-icon="play"></i>

Full-page layouts (mock)

Standalone pages assembling the components into the real app layouts — open each to study structure, spacing and the responsive grids. All self-contained in this folder.

Guided Tour — onboarding overlay

A drop-in walkthrough layer for any page. Two self-contained scripts (they inject their own CSS, no dependencies): a notice modal that frames the page (great for demos/onboarding, with a CTA), and a guide layer that turns tagged elements into glowing, click-to-explain hotspots plus a step-through tour. Built for the public /demo/ walkthroughs; vendored here as a reusable pattern. See it live in the Guided Tour reference page (the queue layout with the layer applied).

demo-notice.jsColored modal shown on load — headline, body, an italic IP note, and a CTA. Per-page instruction via data-hint on the script tag. Auto-adds a Show me around button when the page has hotspots. Dismiss: ×, Got it, backdrop, Esc.
demo-guide.jsTag elements data-guide="Title :: body" → soft pulsing glow + click-to-explain coachmark + a floating ✦ Guided tour that spotlights each hotspot (Back / Next / Done, ← → / Esc). Exposes window.DemoGuide.start().

Usage — add the two scripts, then tag any elements:

<script src="../js/demo-notice.js" data-hint="Click a region, or take the tour."></script>
<script src="../js/demo-guide.js"></script>

<section class="queue-list"
         data-guide="Jobs :: Filter by status and select a job to inspect its log.">
  …
</section>
Behaviours: real controls inside a hotspot (buttons, inputs, video, links) still work — only the hotspot's blank area or itself opens the explainer; the tour always covers every hotspot. Glow uses box-shadow (no layout shift, corners stay sharp). Self-contained — copy both files + tag elements and it runs anywhere.

Buttons

Bare <button> and .btn (for <a>) share the graphite base. Variants layer on intent.

Height30px (content-driven, no fixed height)
Padding4px 8px (v / h)
Border1px solid (#45494f · primary --accent-edge · danger #7d3445)
Font14px / 600 · line-height 19.6px
Icon gap7px (inline-flex, centered)
Radius0 (sharp)
Disabledopacity 0.45 · not-allowed
.btn-primary blue (the workflow CTA) · .btn-ghost transparent · .btn-danger red outline. For an AI-assist CTA the scene-map adds a page-local green .btn-ai — promote it to shell.css if a second page needs it.

Form controls

Inset background, --line border, full-width by default. label is a block element above its control.

.mock-input — mono, for read-only command/path display
Input height34px (6px pad + 14px/19.6 + 1px border)
Select height32px
Text padding (inside)6px 8px (v / h)
Border / bg1px solid --line · bg --inset
Font14px / 400 · color #e4e8ed
Width100% (fills its container)
Labelblock · 12px · margin-bottom 3px · --text-soft
Field spacing.control-group 9px · .two-col gap 8px
Textareasame padding · height = rows × line-height
.two-col for paired fields · .control-group spaces stacked fields · .selectish styles a button as a faux-select.

File upload

One upload pattern, two variants. All share the same three intake routes: click → file picker (a hidden <input type="file">), drag-and-drop (a .dragover highlight on hover), and — where a library exists — pick from existing assets. Live in the Mock Form (drop a file on a slot).

Variant 1 — reference / frame slots (the generate-tab "Advanced" frames, scene references, cast views)
The library picker (what the slot's "Pick from library" 🖼 button opens)

The reference/frame slot's middle button opens a shared image picker modal — do not build a new one. Call openImagePicker(title, onPick) from js/library/picker.js; it returns the chosen image via callback as { url, thumb, title }. Used by the AI Studio frames, storyboard editor & scene map, sequences, and cast views.

Module / APIjs/library/picker.js · openImagePicker(title, onPick)
CallbackonPick({ url, thumb, title }) — url resolves server-side
Sources (chips)AI Generated (listAssetUploads {generated:1}) · Live Assets (listAssets) · Frames (listFrames)
DOM requirementpage must contain the #picker-modal markup (#picker-title · #picker-source · #picker-search · #picker-grid)
Related pickereditorial's self-injecting openGallery({onPick}) — see Gallery browser
Two pickers exist, don't confuse them: openImagePicker (library/picker.js — needs the #picker-modal DOM in the page) is the general slot picker; openGallery (editorial/js/gallery.js — self-injects its DOM) is the editorial browser. For a new page, copy the #picker-modal block from studio/index.html and import openImagePicker.
Variant 2 — drop-zone bar (bulk file drops — uploads tab)
…or drop files anywhere in this bar
.slot-thumb132 × 92 · 1px dashed --line-strong · inset bg
.slot-thumb hover / .dragoverborder --accent · bg --accent-soft
.slot-actions buttonpadding 3px 8px (Upload · Pick · Clear)
.upload-barflex · 1px dashed --line-strong · padding 14px
Intakehidden file input · drag-drop · library pick
Wiring: a single hidden <input type="file"> the buttons trigger; dragover/drop handlers toggle .dragover and read e.dataTransfer.files; uploaded files post to the staged-frames API and the returned URL fills the slot. Real uses: storyboard editor & scene-map reference slots, the AI Studio Advanced frames.

Status pills

.pill.<state> — small caps-weight status chips with a semantic color per state.

running queued encoding complete failed published draft
Padding4px 9px (v / h)
Font11px / 800
Border1px solid (per-state color)
Height~25px (content-driven)
Displayinline-block · radius 0
Storyboard statuses map to these via STATUS_PILL: scripting/compiling→encoding, ready→queued, running→running, paused→draft, completed→complete, published→published, failed→failed. Animate an active pill by adding the job-pulse keyframe (1.6s).

Badges & chips

.badge — neutral metadata. .chip — selectable filter (toggle .active); group in a .chip-row.

1080 × 1920 · 30 fps 9:16
Badgepadding 4px 10px · 12px / 400 · 1px border
Chippadding 5px 10px · 12px / 600 · 1px border
Chip · activebg --accent-soft · border --accent · #fff
Chip rowflex-wrap · gap 7px

Tabs

Two flavors. .tabs — equal-width segmented control (right panels). .tray-tabs-cloud + .tray-tab — wrapping pill cloud (left panels); active is .is-active.

.tabs (segmented)
.tray-tab (cloud)
.tabsequal-width grid · gap 4px
.tabs buttonpadding 5px 0 · 14px / 600 · height 32px
.tabs .activebg #23406d · border #3479df
.tray-tabpadding 4px 9px · 11px / 700 · UPPERCASE
.tray-tab.is-activebg --accent-soft · border --accent

Panels & layout

.panel is the base surface. .panel-section + .panel-toggle make a collapsible block (toggle .is-collapsed on the section). The editor workspace is a 3-column grid: .workspace.left-panel / .stage-area / .right-panel.

Click the header to collapse. Body hides when the parent has .is-collapsed.

.vshell rows44px / 1fr (/ 26px statusbar · / 226px with-timeline)
.workspace colse.g. 300px / minmax(0,1fr) / 330px
.panel-sectionpadding-bottom 12px · 1px bottom border
.panel-toggle13px / 800 · 9px bottom pad · chevron rotates
.is-collapsedhides .panel-body · chevron −90°
Full-height app pages use the .vshell grid (44px titlebar / content / optional 26px statusbar); add .with-timeline for a bottom strip. Gallery/scroll pages use .page + .page-head.

Cards & galleries

.card-grid auto-fills .cards (240px min). A card has a .thumb (16:9, or .tall 9:16) and a .meta block. Selection: .selected/.active.

0:12
Asset title1080×1920 · © credit
Selected (.tall thumb)9:16
.card-gridauto-fill minmax(240px, 1fr) · gap 14px
.card .thumbaspect 16:9 (.tall 9:16, max-h 240px)
.card .metapadding 10px 12px · b 13px / span 12px
.card:hoverborder --line-strong
.selected / .activeborder --accent + 2px ring
Empty states: .grid-empty (centered, full-width). Picker/gallery cards reuse .lib-card (defined page-local where the picker is mounted).

Media asset cards

The library pattern: a grid of media cards, each opening a preview modal on click. Cards carry their data on data-* attributes; the click handler populates and opens #preview-modal. (Thumbnails here are self-contained placeholder SVGs.)

Click any card → the preview modal opens the full-size media.
Preview modal — the shared media viewer

One modal (#preview-modal) serves every tab via library/preview.jsopenPreview(asset) for catalog/upload rows, openStockPreview(item, opts) for Pexels results. It picks the element by kind — <img> / <video controls autoplay muted loop> / <audio controls> — and the footer carries contextual actions: Copy Path, Download (video) or Raw File (other), and conditionally Add to Live Assets / Delete. It stops playback on close (a MutationObserver pauses any video,audio when .open is removed) so audio doesn't keep playing behind the gallery.

See it in the Mock Gallery →
Category outlines — a gallery variant

When the gallery holds generated video work, a coloured outline + glow on each .lib-card says what the asset is at a glance — finished render vs. storyboard cut vs. single scene vs. still. The same four colours are the legend, and the legend is the filter: each .cat-chip carries its colour .dot and toggles the view. Live in Studio → Generate (library/ai.js, categoryOf()); classification reads asset.metadata client-side. A duration rides on each card. Use it anywhere the Asset Library browser shows mixed generated media.

Final cut🎬 master · 1:30
Storyboard cut🎬 board #43 · 0:21
Scene clip🎬 Grok · 4s
StillGrok Image
.cat-renderedgreen — finished render · masterCut || narrated || captioned
.cat-storyboardblue — a board's stitched cut · finalCut && storyboardId
.cat-sceneyellow — one scene clip · metadata.video
.cat-stillgrey — a generated image (fallback)
outlinebox-shadow: 0 0 0 1.5px ring, 0 0 16px -5px glow (no layout shift)
durationmetadata.duration (scenes) or probed metadata.durationSec (cuts, set by the thumbnail worker)
The chips double as legend and filter; filtering is client-side over the loaded page (the gallery is server-paginated). Order outlines by "finished-ness": rendered (green) → storyboard (blue) → scene (yellow) → still (grey).

Tables

Plain <table> picks up borders/padding from editor.css (bottom-border rows, dim th). .mini badges flag per-row state.

JobKindStatusAsset
#84stitchcomplete4621
#83video_genrunning
#82image_genfailed
live wait encode
th / tdpadding 10px 8px · bottom border --line-soft
th12px / 700 · --text-soft · left-aligned
td13px / 400
.mini badgepadding 4px 8px · 11px / 800 (live·wait·encode)

List & layer rows

.layer-row — selectable row with hover + .is-active (layers panel, scene lists). Used with a thumb + main column + trailing pill.

VIDScene 1 — The Empty Corridordone
VIDScene 2 — The Encounter (active)rendering

Jobs & progress

Job rows use the queue .job skin. In-flight work gets the live-progress pattern: a pulsating info line + an indeterminate creep bar (providers give no real %, so the bar creeps elapsed / expected and caps at ~96%). A 1s ticker re-reads data-since/data-expected between polls.

#127 video_gen⟳ rendering · scene 3 · 1:07 · ~1–3 min
running
#126 video_genopenrouter · scene 2
completed
Pipeline strip (sb-pipeline)
Script ✓ Ref frames 4/4 S3 CompilePublish

Timeline — tracks & lanes

The timeline is its own area of the system and will grow — tracks & lanes here, with clips, transitions, captions, the ruler & playhead to follow. It stacks labeled lanes, one per track type, each with its own color & graphic so a glance reads the track: video/scenes = blue filmstrip, voiceover = green waveform, music = purple waveform, captions/text = purple chips. Audio never uses the filmstrip — it draws a waveform.

Scenes
scene 01
scene 02
03
Voiceover
narration.mp3
Music
Upbeat Corporate
Captions
Imagine…
making money
while you sleep
Video / scenes.filmstrip · --clip-video (blue) · thumbnails
Voiceover.audioclip.voice · green · waveform
Music.audioclip · --audio (purple) · waveform
Captions / text.textclip · --purple chips
Transitions.transition-clip · --gold
Selection2px gold outline (all clip types)
.audioclip is purple (music) — add .voice for the green voiceover variant. The .waveform is a flex row of <i> bars whose heights are the audio peaks (filled by JS here; real peaks come from the backend per asset).

Asset tray

The left panel used in both the slideshow and reels editors. Top-level .tray-bar tabs group content by kind. Each tab has its own search (scoped) and optional category filter pills (sub-type chips) below it. The .more button is a visual placeholder — no action.

Assets tab
Audio tab
.tray-bartop-level tab strip — identical in slideshow + reels
TabsAssets · Text · Audio · FX · AI Media · ›
Assets sub-tabsMedia · Stock · Backgrounds · Characters · Stickman · Hands · Props · Graphics · Overlays · Transitions · Mattes
Text sub-tabsTypefaces · Snippets · Captions
.tray-bar .moreflex:none · narrow · no active state · placeholder
Category pills.tray-tabs-cloud + .tray-tab — sub-filters within the active tab
Searchscoped to active top-level tab
Audio list row.track-row · play · title · meta · waveform · license
Audio grid.track-grid · auto-fill minmax(150px,1fr)
Licensegreen "no credit" · gold "credit required"
Both editors (slideshow + reels) share the same tab structure and CSS. The active tab controls what content and category pills are shown; search always scopes to the active tab.

Audio inspector

Selecting an audio clip swaps the inspector's geometry/effect rows for a single Volume control — audio is non-visual, so X/Y/size and entrance effects don't apply. The slider runs 0–200 % (100 % = unity), with a live dB readout (20·log₁₀(v/100)). Preview caps at 100 % — the worker's ffmpeg volume filter applies true gain on render.

Range0–200 % · 100 = unity gain
ReadoutdB = 20·log₁₀(v/100) · −∞ at 0
Preview<audio>.volume capped at 1.0
Renderffmpeg -filter:a volume=v/100
Hidden for audio: X/Y · Width/Height · Effect · FX duration. Kept: Label, clip Start/Dur (trim), Delete. Volume lives on the node (props.volume, default 100).

Modals

.modal-backdrop (toggle .open) holds a .modal with .modal-head (eyebrow + title + .modal-close), a scrolling body, and .modal-foot. shell.js wires data-modal="#id" to open and data-modal-close/backdrop to close. Sits at z-50 — layer above app overlays with a higher local z-index (e.g. the picker on the scene map). Reserve modals for dialogs/pickers, never logs.

Context menu

Right-click menus reinforce the desktop-app feel. Open one with VideoShell.contextMenu(x, y, items) (from shell.js) — each item is { label, icon?, danger?, action }, rendered with the .ctx-menu styling (auto-positioned, closes on outside-click / Esc). danger: true tints an item red.

Right-click any row below → Copy · Duplicate · Expand · Delete (each fires a toast):
Scene 1 — The Empty Corridor right-click
Scene 2 — The Encounter right-click
Inspector Whiskers (cast) right-click
The real pattern — scope a contextmenu listener to specific surfaces
from js/typography.js — right-click stage nodes & layer rows
element.addEventListener("contextmenu", (e) => {
  const row = e.target.closest("[data-id]");        // a surface that has a menu
  if (!row) return;                                 // otherwise: native menu
  e.preventDefault();
  VideoShell.contextMenu(e.clientX, e.clientY, [
    { label: "Copy",      icon: "save",   action: () => copy(row.dataset.id) },
    { label: "Duplicate", icon: "layers", action: () => duplicate(row.dataset.id) },
    { label: "Expand",    icon: "zoom-in", action: () => expand(row.dataset.id) },
    { label: "Delete", icon: "trash", danger: true, action: () => remove(row.dataset.id) },
  ]);
});
Always preventDefault() only on surfaces you handle, so the browser's native menu still works elsewhere. Used in the Typography workbench (right-click a stage node or a .layer-row).

Stage & preview

A plain media preview uses .viewer-frame (16:9; .tall 9:16, .square 1:1). A live render canvas uses .reels-stage-frame with the data-live-stage attribute (the one place radii are allowed).

.viewer-frame.tall
.viewer-frame (16:9)

Conventions

Future / stub controls. Anything not yet wired gets .future (yellow-green) + data-stub="message"; shell.js shows a toast on click. Remove the class when wired.

Error banners. Provider/pipeline failures parse through friendlyError() (shared.js) — it pulls the message out of raw HTTP/JSON, adds an actionable hint, and links to a matching /video/help/#anchor section from help/errors.json. Render loud, above the stage.

⚠ Scene 1 submit failed The request failed because the input image may contain a real person.
→ Switch the video model, or regenerate the reference frame with a less photoreal look.
📖 Help: about this error

Code / payload view. Raw JSON or prompts render in a mono <pre> on the inset surface (green-on-dark), with a Copy affordance — never a modal.

{
  "logline": "Three kids on bikes blur past a sleeping relic…",
  "scenes": [ { "idx": 0, "shot": "wide establishing" } ]
}
hung doan
Style Guide — a self-contained, sharp-edged desktop-app design system. Drop the styleguide/ folder into any project; it runs with no build step and no external dependencies.
Built from css/tokens.css · css/shell.css · css/editor.css — author HTML against the classes documented above.
hung doan
component system