Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# DESIGN.md

Visual system for FieldOS. Written 2026-08-23 via `/impeccable teach`, by reading
`packages/workshop-frontend/src/styles.css` rather than by proposing something new.

Strategic context lives in [PRODUCT.md](./PRODUCT.md). Read that first: it is what makes the
choices here non-arbitrary.

## The system this actually is

FieldOS renders through **Kumo** (`@cloudflare/kumo` v2.9) with **Tailwind 4**, and overrides Kumo's
semantic tokens in `styles.css`. Design work happens by changing those token values, not by adding
component-local colors. Two themes ship and **both are real** — dark is selected by
`[data-mode="dark"]`, not by `prefers-color-scheme`.

**The two themes are built on different systems, and that is the single most important fact here.**
Dark mode is coherent OKLCH with every neutral tinted toward hue 285 and a comment explaining the
intent. Light mode is hand-written hex with a warm undertone. They were authored at different times
by different hands, and light mode is the one carrying unfinished migration.

## Color

**Strategy: Restrained.** Tinted neutrals carry the surface; one accent appears only for intent —
active item, link, primary action, focus ring. `styles.css` states this explicitly and it is the
right call for a tool people sit in all day.

### Brand

FieldOS green, hue 152. Chosen rather than inherited: neither of the two values found in the file
was a decision (see Known defects). Every value below is verified for white-on-button contrast.

| token | light | dark | white text |
|---|---|---|---|
| `--color-kumo-brand` | `oklch(0.44 0.075 152)` `#2f5e3e` | `oklch(0.52 0.085 152)` `#407750` | 7.52:1 / 5.29:1 |
| `--color-kumo-brand-hover` | `oklch(0.385 0.078 152)` `#1d4f2e` | `oklch(0.465 0.082 152)` `#326642` | 9.50:1 / 6.74:1 |
| `--text-color-kumo-brand` | `#2f5e3e` | `oklch(0.78 0.105 152)` `#82cb96` | 7.33:1 / 10.59:1 on own surface |

**Hover darkens; it never changes hue.** Zero-degree shift in both themes. A hover that changes hue
reads as a different button, not the same button reacting.

### Neutrals

Never `#000` or `#fff` for a surface. Dark mode tints toward hue 285; light mode toward warm grey.
Status colors (`info` / `warning` / `danger` / `success`) are already OKLCH in both themes and are
correct as they stand — leave them alone.

## Typography

`FT Kunst Grotesk` (sans) and `Apercu Mono Pro` (mono), both self-hosted. **Self-hosting is a
requirement, not a preference:** the deployment is airgapped, so a webfont CDN would silently fall
back to system fonts on exactly the installs that matter most.

The scale restores Tailwind's defaults over Kumo's 12/13/14/16px steps. Body copy stays within
65–75ch.

## Layout

Three-pane working surface: sidebar, chat, artifact. **The artifact is the subject and the chat is
the tool** — screen weight should keep saying so, because it is also what distinguishes FieldOS from
the centered-chat-column look of every LLM wrapper.

## Motion

Ease out; no bounce, no elastic. Do not animate layout properties. Existing transitions sit at
120–180ms, which is the right range for a tool.

## Known defects (found while writing this, all measured)

1. **The light-mode primary button changes hue on hover.** `--color-kumo-brand` was `#2d6a4f`
(green, hue 153°) and `--color-kumo-brand-hover` was `#e03f00` (orange, hue 17°): a **137° swap**
at the moment of interaction. Dark mode shifts 1°, which is what correct looks like. Live on the
onboarding "Let's build" CTA, `SettingsPage`, `BlueprintLandingPage` and `__root`.
2. **That same hover fails contrast.** `#e03f00` with white text is **4.30:1**, under the 4.5:1 AA
floor — so the button became both wrong-hued *and* non-compliant precisely when touched.
3. **Light mode was half-migrated.** Its own comment reads *"the brand **orange** is reserved for
intent only"* while the token directly beneath it is green, and dark mode was never converted at
all (`#b84e00`, `#ff8a5c` are still Cloudflare orange). The comment, the hover, and the dark
theme all still describe the pre-fork design.

These are recorded as *defects* rather than preferences because two of the three are measurable
against a standard, and the third is an internal contradiction in the file.

## How the contrast numbers here were derived

Stated so they can be re-derived rather than trusted. A ratio without its inputs is the same defect
as a count without its scope.

- **Formula:** WCAG 2.x relative luminance — sRGB channels linearised (`c/12.92` below 0.04045, else
`((c+0.055)/1.055)^2.4`), weighted `0.2126 R + 0.7152 G + 0.0722 B`, then
`(L_lighter + 0.05) / (L_darker + 0.05)`. OKLCH values were converted to sRGB first.
- **Which pair, for each claim:**
- *Button figures* — `#ffffff` text against `--color-kumo-brand` / `--color-kumo-brand-hover`, per
theme. That is the real pairing: every primary CTA in the codebase sets `text-white`.
- *Selected text* — `--color-selection-text` against `--color-selection-bg`.
- *Warning icon in the gadget empty state* — `--text-color-kumo-warning` against
`--color-kumo-warning-tint`, judged against the **3:1** non-text bar rather than 4.5:1, because
it is an icon.
- **Thresholds:** 4.5:1 for text (AA normal), 3:1 for non-text/icons. Large-text 3:1 was not claimed
anywhere here.
- **What was not measured:** neutral body text on neutral surfaces, and the status colors
(`info` / `danger` / `success`). Both were inherited unchanged and are outside the scope of the
brand migration — absence of a number here is not a pass.
72 changes: 72 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# PRODUCT.md

Strategic design context for FieldOS. Written 2026-08-23 via `/impeccable teach`.

## Register

**product** — design serves the work. The bulk of the surface is workspace, chat, gadget panes,
settings and admin: tools people return to, not a campaign they see once. Signup and the blueprint
landing page are entry points *into* a product, and inherit the product language rather than
setting their own.

## Users

Deliberately, uncomfortably broad — and this is the product thesis rather than an unresolved
question. Upstream states it plainly: *"a large portion of Cloudflare's workforce, from engineering
to sales and everything in between"*, and the security framing exists so *"non-technical users can
safely go nuts and nothing bad will happen."*

So the same screen serves:

- someone in sales who has never written code, describing a tool in plain language;
- an operator who wants a small thing built and used once;
- an engineer reading the generated diff and rewiring a gadget's bindings.

**The design consequence is specific:** neither "simplify for beginners" nor "dense tool for
experts" is available. Both would break half the audience. The resolution the product already
reaches for is *progressive disclosure by surface* — the App tab is for anyone, the Code tab is for
people who want it, and neither is hidden behind the other. Design work should strengthen that
split rather than average the two audiences into one bland middle.

FieldOS adds a constraint upstream does not have: it runs **airgapped, on customer infrastructure,
with no internet access and models served locally.** Sessions happen in offices and in operational
settings; light and dark are both real, not a preference toggle. That also means every asset must be
self-hosted, so any typographic or visual choice that depends on a CDN is off the table.

## Product purpose

Ask an agent for a small personal application ("a gadget"), get working code, run it safely in a
sandbox, and share it. The security framework is what makes the casual use safe.

## Brand personality

**A serious instrument, not a toy.** Calm, precise, and trustworthy for real operational work. The
interface should feel like something that will still be running next quarter.

Three anti-references, all confirmed by the product owner:

1. **Not a playful consumer SaaS.** No mascots, no confetti, no celebratory empty states. People are
doing work.
2. **Not another AI chat product.** Avoid the LLM-wrapper look: a centered chat column as the whole
application, sparkle iconography, purple/violet gradients, "AI magic" framing. FieldOS's chat is
*one pane beside a working artifact*, and the layout should keep saying so.
3. **Not a generic Cloudflare dashboard.** It is a fork built on Kumo and should stay compatible
with it, but it should read as FieldOS. Inherited Cloudflare visual defaults are not decisions.

## Strategic design principles

1. **Silence is the enemy.** This codebase's signature bug is a legitimate path that renders
identically to a broken one: a blank pane, a dead subscription, a form that swallows input, a
message sent with no model selected. Every empty state must distinguish *nothing yet* from
*something is wrong*, and say which. Design here is a correctness concern, not decoration.
2. **The artifact is the subject; the chat is the tool.** Screen weight should follow that.
3. **Two audiences, one screen, no averaging.** Keep the plain-language path and the code path both
first-class and visibly separate.
4. **Inherited defaults are not decisions.** Where Kumo or upstream left a value, treat it as
unexamined until someone examines it.

## Accessibility

Non-negotiable: visible focus rings on every interactive element, contrast that holds in both
themes, and no meaning carried by color alone (status must also carry text or shape). Both themes
ship, so both must pass — a fix that only holds in light mode is not a fix.
2 changes: 1 addition & 1 deletion docs/trying-it-out.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ system reported success for something that had not happened. Treat "untested" as
Three things have to be true, and none of them is automatic. If a deployment feels dead, it is
almost always one of these.

**1. A model must be configured.** Settings → AI models. Paste the **base URL** of your
**1. A model must be configured.** Go to **/providers** ("AI providers" → *Add provider*). Paste the **base URL** of your
OpenAI-compatible server, not the full endpoint — though the normalizer now handles
`.../v1/chat/completions` if you paste it from a vendor's docs.

Expand Down
6 changes: 5 additions & 1 deletion packages/workshop-frontend/src/AddModelModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,11 @@ export default function AddModelModal({ visible, onCancel, onSuccess, authentica
}
setApiToken('')
setAccountId('')
setApiUrl(sel.provider === 'ollama' ? 'http://localhost:11434' : '')
// Deliberately not pre-filled. localhost:11434 is a reasonable *hint* and a bad *default*: a
// pre-filled field is a form that already looks complete, so the natural action is to submit
// it, and addModel never contacts the endpoint -- an unreachable address is stored and reported
// as "added successfully" (#156). The placeholder still shows the shape of the answer.
setApiUrl('')
}

const validate = (): boolean => {
Expand Down
45 changes: 33 additions & 12 deletions packages/workshop-frontend/src/GadgetUI.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { Text, Loader, Banner } from '@cloudflare/kumo'
import { Sparkle } from '@phosphor-icons/react'
import { Sparkle, Warning } from '@phosphor-icons/react'
import { RpcStub, RpcTarget, newMessagePortRpcSession } from 'capnweb'
import { GadgetClient, ConsoleLogEvent } from '@gadgets/workshop-shared/api'

Expand Down Expand Up @@ -161,21 +161,41 @@ const UI_BUNDLE_LOAD_TIMEOUT_MS = 20_000
const RECONNECT_TIMEOUT_MS = 5_000

// The pane shown when there is nothing to look at. Shared by the two states that reach it -- no
// client.js at all, and a client.js that rendered nothing -- because they differ only in wording and
// a near-duplicate block beside the original would leave the next reader diffing two panels.
function EmptyUiPanel({ heading, detail }: { heading: string; detail: string }) {
// client.js at all, and a client.js that rendered nothing -- because a near-duplicate block beside
// the original would leave the next reader diffing two panels.
//
// `tone` is not decoration. These two states must be distinguishable at a glance, not only by
// reading them: "waiting" is a hopeful state the agent will resolve, while "the gadget renders
// nothing" is a defect the user has to act on. Giving both the same sparkle and accent glow made a
// broken gadget look like a pending one, which is the same silent-failure shape this pane exists to
// break -- and a sparkle on an error is the AI-chat idiom PRODUCT.md names as an anti-reference.
function EmptyUiPanel(
{ heading, detail, tone = 'waiting' }:
{ heading: string; detail: string; tone?: 'waiting' | 'problem' },
) {
const isProblem = tone === 'problem'
return (
<>
<div
className="themed-accent-glow absolute left-1/2 top-1/2 h-80 w-80 -translate-x-1/2 -translate-y-1/2 rounded-full pointer-events-none"
style={{
filter: 'blur(18px)',
}}
/>
{!isProblem && (
<div
className="themed-accent-glow absolute left-1/2 top-1/2 h-80 w-80 -translate-x-1/2 -translate-y-1/2 rounded-full pointer-events-none"
style={{
filter: 'blur(18px)',
}}
/>
)}

<div className="relative flex max-w-sm flex-col items-center gap-3 px-6 text-center">
<div className="themed-user-bubble-shadow flex h-12 w-12 items-center justify-center rounded-xl border border-kumo-line bg-kumo-elevated text-kumo-subtle">
<Sparkle size={22} weight="regular" />
<div
className={
isProblem
? 'flex h-12 w-12 items-center justify-center rounded-xl border border-kumo-warning bg-kumo-warning-tint text-kumo-warning'
: 'themed-user-bubble-shadow flex h-12 w-12 items-center justify-center rounded-xl border border-kumo-line bg-kumo-elevated text-kumo-subtle'
}
>
{isProblem
? <Warning size={22} weight="regular" />
: <Sparkle size={22} weight="regular" />}
</div>
<div className="space-y-1">
<h2 className="text-[20px] leading-7 font-normal tracking-[-0.45px] text-kumo-default">
Expand Down Expand Up @@ -568,6 +588,7 @@ function GadgetUISession({ gadget, height, reloadTrigger, isVisible = true, chat
style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}
>
<EmptyUiPanel
tone="problem"
heading="client.js exports no UI"
detail={
'This gadget\u2019s client.js ran without rendering anything. If the interface was ' +
Expand Down
Loading
Loading