Created: 2026-06-27
Last modified: 2026-08-26
Last modified summary: Public marketing surface rebuilt mobile-first — sticky 56px header, drawer owns theme + sign-in, viewport-relative display scale, ui-public-section/ui-public-container rhythm.
FleetCrown is mobile-first and dark-first. Every authenticated route must be usable on a 320px-wide phone without horizontal page scroll, with primary actions reachable above the floating bottom nav.
Tailwind defaults (via @theme inline in globals.css):
| Token | Width | Shell behaviour |
|---|---|---|
| (default) | <640px |
Bottom nav pill, compact page headers, stacked layouts |
sm |
≥640px |
Chip rows may wrap; some subtitles appear |
md |
≥768px |
Sidebar visible; desktop top-bar search; duplicate page titles allowed |
lg |
≥1024px |
Control workspace two-column rail + detail |
Defined in src/app/globals.css :root:
--mobile-nav-height Bottom tab bar height
--mobile-nav-offset Float gap above screen edge
--mobile-safe-bottom env(safe-area-inset-bottom)
--mobile-chrome-bottom Total bottom inset (nav + offset + safe area)
--app-topbar-height Sticky AppTopBar height
--app-viewport-height 100svh − top bar − mobile chrome (phones)
Rule: full-height surfaces (Loki, Terminal, workspace terminal) use .app-viewport-pane, not ad-hoc 100vh / 100dvh math.
.app-shell-frame overflow-x-clip — no page-level horizontal bleed
.app-main padding-bottom: mobile chrome + 1rem
.app-page Standard page gutter (px-4 … md:px-8)
.app-page-compact Tighter vertical padding for chat/terminal pages
.app-viewport-pane Full remaining viewport height
Authenticated pages should still enter through PageLayout (see
docs/branding-design.md → App-shell layout SSOT). Mixing raw app-page max-w-*
with bespoke wrappers is the main source of inconsistent positioning across
routes.
Navigation:
- Desktop (
md+):Sidebar+ fullAppTopBarsearch + theme cycle in top bar and sidebar footer - Mobile:
AppTopBarpage label + icon search + theme cycle; bottom bar is Today · Control · Loki · Menu; the Menu sheet mirrors sidebar sections (Work / Private / Site) with Settings, appearance, and sign-out in the footer
| Pattern | Class / component | Mobile note |
|---|---|---|
| Page title | PageTitle |
Hides when it duplicates AppTopBar label |
| Modals | Modal |
Bottom-anchored on phones; --mobile-chrome-bottom inset |
| Drawers | Drawer + ui-drawer-body |
Full width; safe-area padding on scroll body |
| Loki composer | ui-loki-composer |
Stacks textarea + toolbar row on <sm |
| Control project rail | ui-control-project-list |
Vertical list on phones; horizontal scroll removed |
| Horizontal filters | overflow-x-auto ui-scroll-fade-right |
Today, Projects, Events, Prompts chip rows |
| Touch targets | the pointer: coarse floor in globals.css |
Never restate the 44px number in JSX |
| Flow-page back link | PageLayout back={{href,label}} |
Renders above the title, not stranded below it |
| Theme | ThemeToggle cycle button (ui-theme-cycle-btn) |
One control cycles Light → Dark → Auto; select variant in Settings |
PublicSurface + ui-public-* follow the same THEME_OPTIONS (Light / Dark / Auto) as the app shell — do not pin .dark on the public subtree, and do not use text-white/* or bg-white/* outside a surface that is always dark (ui-public-download, ui-changelog-*). The footer carried that pattern after the surface became theme-aware and was invisible white-on-white in Light.
Header (ui-public-nav). Sticky at every width; 56px on a phone. Public pages run 3,000–13,000 CSS px tall there, so navigation must not require a flick to the top.
| Piece | Phone (<md) |
md+ |
|---|---|---|
| Brand | <BrandMark responsive /> — 36px glyph, one-line 18px wordmark, no kicker |
Full stacked lockup |
| Nav | PublicNavTrigger → full-screen drawer |
PublicNav mega-menu |
| Theme cycle | inside the drawer footer | in the header |
| Sign in | inside the drawer | in the header |
| Primary CTA | always visible, whitespace-nowrap, short label ("Open app") |
full label |
PublicHeaderActions — not PublicSurface — mounts the drawer, because it is the only public header piece that can call auth(): PublicSurface is imported by AuthShell, which client pages import, so the shell itself is bundled for the browser.
Type scale. Display classes are viewport-relative with a phone-sized floor; the rem ceiling keeps desktop unchanged. Never raise a floor above what 320–390px can render.
| Class | Floor | Ceiling |
|---|---|---|
ui-public-hero-title |
2.5rem |
5.75rem |
ui-public-page-title |
2.25rem |
4.5rem |
ui-public-display-lg |
2rem |
3.75rem |
ui-public-display-md |
1.75rem |
3rem |
--public-title-min |
34px |
108px |
Rhythm. Use ui-public-section (py-14 sm:py-20 lg:py-24), ui-public-section-gap, and ui-public-container{,-narrow,-mid,-wide} (16px gutter on phones) rather than ad-hoc py-24 + max-w-* px-6.
Long pages. ui-public-jumpbar (anchor chip row) on /roadmap; DocContents renders the whitepaper TOC as a collapsed <details> below sm and an open <nav> above it — open is DOM state, so one element cannot be toggled by a breakpoint. ui-public-code-pre wraps on phones instead of adding a second scroll axis.
Handheld content. Fleet Runner is a desktop binary, so a phone visitor is offered a handoff (open the web app / copy the install link) instead of a .deb — isHandheld() in DesktopDownload.tsx, checked before OS sniffing (an Android UA contains "linux"; an iPhone UA contains "Mac"). The hero's secondary CTA is "See how it works" on phones, "Download runner" from sm up.
Tap targets. The pointer: coarse floor in globals.css is the only place a 44px minimum is stated — but min-height does nothing to an inline box. A standalone link needs inline-flex (ui-public-link-standalone, ui-auth-hint-link, ui-link-muted); links inside a sentence keep ui-public-link and are exempt under WCAG 2.5.5.
16px inputs below sm. Safari zooms the whole viewport in whenever a focused field is under 16px, then leaves the page scrolled sideways. Every field that a phone user actually types into is text-base sm:text-sm: ui-input, ui-input-compact, ui-auth-input, ui-auth-prefix-input, ui-palette-input, ui-term-mcomposer-input, and the Loki composer. Adding a new input class? Match that pattern rather than defaulting to text-sm.
# Design-system colour/size violations in JSX
grep -rn "text-gray-\|bg-\[#\|text-\[1[0-9]px\]" src/components/ src/app/ --include="*.tsx"
# Ad-hoc viewport heights (should use app-viewport-pane instead)
grep -rn "100dvh\|100vh\|100svh" src/app src/components --include="*.tsx"
# Fixed min-widths that may overflow phones
grep -rn "min-w-\[" src/components src/app --include="*.tsx"Before shipping UI changes, verify at 375×667 and 320×568:
/today,/control,/projects— no horizontal scroll; bottom nav never covers primary CTAs/loki— composer Send visible; history/filter drawers full width/terminal— xterm fills pane; "My machine" tabs scroll horizontally above terminal- Modals (bootstrap project, run prompt) — actions above bottom nav
/peopledetail drawer — scroll + safe area- Landing
/— hero readable; public nav drawer opens - Public pages in Light as well as Dark — the surface follows the theme, and a white-on-white regression is invisible in a dark-only pass
Run npm run smoke with dev server up for route health; Playwright viewport tests are planned but not yet in CI.
Control's failure mode was never one bad component — it was twelve sibling
full-width sections with no hierarchy, and layout logic that started at sm:
(640px), which is above every phone. A 390px device therefore never got a
designed layout; it got the fallback (flex-col, full width, nothing aligned to
anything). Both are fixed, and both are easy to undo by accident:
Write phone rules as the base and relax them at sm:/md:. A rule that
only exists at sm: and up does not exist on a phone. The hero (ui-hero-*) is
the reference: base rules describe 320px, sm: only spends the extra room.
The hero answers one question. Is anything waiting on me? — headline,
the projects by name, and at most ONE button. It previously carried five things
(runner line + versions, refresh, new project, autopilot pulse, Pause fleet, a
four-chip counter row, an explanatory paragraph) and its loudest control was
Pause fleet, which a builder uses roughly never. Counters and builder state live
in a quiet foot line; autopilot and refresh live in ControlSettingsSheet.
Small tasks are one queue, not many strips. ControlInbox holds feedback
triage and widget coverage together, collapsed, capped at three rows per group,
nothing auto-expanding. Adding a third "just one more strip" to the page is the
regression this replaced — add a group to the inbox instead.
Row actions are never primaries. A list of filled buttons has no primary
whatever its colour. Filled (ui-btn-primary) is for the action on a whole
group; rows get ui-btn-secondary. Use the size axis ui-btn-sm — never invent
a fifth filled variant with a size baked into it (that is how ui-btn-save,
ui-btn-submit, ui-btn-lg and ui-btn-ready-primary all came to exist).
Nothing may rely on hover to be legible. Phones have none. OutcomeStreak
was five glyphs whose meaning lived in a title; it now states its summary in
words. A title is an enhancement, never the only copy of a fact.
Shared bottom-sheet shell — used by the terminal's session setup and Control's fleet settings. The rule that decides what belongs in one: settings, not state. Controls chosen deliberately, changed rarely, and never read while work is in flight. State stays on the page.
Both dispatch composers (Control's PromptInput, TerminalComposer) take voice
(Whisper, already wired) and images/text files by picker or paste via
useAttachments. A screenshot with no text is a valid send — the picture is the
instruction.
A terminal agent cannot read pixels, so an image is never forwarded: it is
described by the vision preflight and folded into the prompt as TEXT, server
side, in lib/composer-attachments — called from /api/inject,
/api/control/tab-inject and /api/orchestration/run. Do not fold client-side:
a client that forgets sends a prompt referring to a screenshot nobody looked at,
and that failure is silent.
Below md the terminal is three pieces and nothing else: a one-line header (TerminalMobileHeader — session name, live dot, full-screen toggle), the screen, and the dock (TerminalMobileDock). Everything that is setup rather than state — source, session list, agent, input mode, text size, live-keystrokes — lives in TerminalSessionSheet, one tap behind the session name. The desktop chrome (source bar, tab strip, session bar, status row) is hidden md:block; there is one TerminalView shared by both.
The key deck is the point. A soft keyboard has no arrows, no Esc, no Tab and no Ctrl — exactly the keys an agent TUI asks its questions with — so TerminalKeyDeck supplies them as buttons that write raw bytes through transport.sendKey. Sequences are SSOT in config/terminal-keys.ts; the deck is layout, hold-to-repeat and haptics. Arrows and Backspace auto-repeat when held; Enter and the control codes deliberately do not. Keys fire on pointerdown with preventDefault() so a tap never moves focus and dismisses the keyboard.
Typing goes through TerminalRawComposer, not the xterm canvas: a real <textarea> (with autoCorrect="off") that sends the line on Send, appending \r unless the ⏎ chip is toggled off. liveKeys (session sheet) hands the keyboard back to xterm and defaults per device — off on a phone, on at desktop widths. useKeyboardInset pads the surface by the height visualViewport says the soft keyboard is covering, so the deck sits on the keyboard rather than behind it.
Expand (TerminalMobileShell) switches to .ui-term-mobile-fullscreen: fixed 100svh, hides mobile nav and top bar, body class fc-terminal-fullscreen locks scroll — and there the keyboard inset is exact, since .app-main's nav padding is zeroed. Deep link after Loki dispatch: /terminal?source=machine&tab=<projectKey>.
Verified at 320 and 390 CSS px: no horizontal overflow, every keycap ≥44px, and the terminal keeps ~496px of the 844px screen (it had ~150px before).
Overlays (drawers/modals): opening any Drawer or Modal sets body.fc-overlay-open — hides bottom nav and top bar so full-screen project profiles and Loki slide-overs are not obscured. Audit: node scripts/mobile-pages-audit.mjs.
/projects uses a sticky search + filter bar (ui-projects-sticky-bar), one attention card per flagged project, then a compact list (ui-projects-row) capped at 25 rows with “Show all”. Freeform status values never render as filter chips — only lifecycle badges via shortProjectStatus() in lib/projects-display.ts. Duplicate entity names collapse server-side via mergeDuplicateProjectRows().