Design System (GEL)
The Global Expression Language (GEL) is how ProtocolSoup looks and reads on every surface: the main app (protocolsoup.com), the wallet harness (wallet.protocolsoup.com), and these docs. It exists so a new protocol, panel, or page looks like it belongs without anyone having to guess.
One directory defines it: ProtocolSoup/design/, which holds tokens.css (the values), tailwind-theme.css (how the apps consume them) and brand/ (the wordmark). Everything below is either a token in those files or a rule for using one. Where the codebase does not yet follow a rule, the known gaps say so.
Identity
Section titled “Identity”ProtocolSoup is a lab, not a marketing site. The UI is a dark terminal-style instrument for watching real protocol traffic: real payloads front and centre. The terminal voice is the brand. Everything else (colour roles, tints, spacing) exists to keep it legible.
- Product name: ProtocolSoup, one word, capital P and S. “Protocol Soup” is only an alternate name in structured data.
- Repository:
ProtocolSoup/is the repo directory. It never appears in UI copy. - Tagline: “Real Flows, Real Infrastructure”. Footer microcopy: “real hops · real tokens · no mocks”.
- Voice: precise and plain. Name the RFC or spec section, say what the protocol actually returned, never soften or paraphrase an error into something friendlier than the wire response.
Logo and mark
Section titled “Logo and mark”The mark is the soup bowl in frontend/public/favicon.svg (the docs carry an identical copy): three steam wisps in the brand trio (orange, purple, cyan), a slate bowl, and five ingredients (the trio plus a green and a yellow) on a #0f172a tile. It is the app icon, favicon, and stand-alone avatar. In React, SoupMark draws the same bowl without the tile.
The wordmark is artwork, not typed text. It is built once from JetBrains Mono Bold outlines by design/brand/build-wordmark.mjs and every surface renders the same paths:
- “Protocol” in
--ps-fg, “Soup” in--ps-brand. - The “o” of Soup is the bowl, steaming in the brand trio, drawn one monospace cell wide from the baseline to cap height.
- The cursor is the “live” moment: a flat underscore one cell wide in
--ps-interactive, blinking after the word. It appears only in the homepage hero (live). Every other surface (headers, footers, docs, share cards) uses the bare wordmark, so navigation chrome stays quiet. - The art is 1.08em tall for a given font size, so it sits at the same cap height as bold mono text beside it (for example the “Wallet” suffix in the wallet harness).
Because it is vector artwork, it looks identical before and after web fonts load, and it never leaks letters into the page text. The indexable and accessible name is always the plain string ProtocolSoup, supplied next to the art:
- Apps:
Wordmarkfromfrontend/src/components/common/Brand.tsxrenders the SVGaria-hiddenwith the real textProtocolSouplaid over it in transparent mono bold on the same cell grid, so thetextContentof the header link or the heroh1is exactlyProtocolSoup, and drag-selecting or copying the wordmark yields that string. Size it with a font-size utility (text-base sm:text-lgin the header,text-basein the footer,text-3xl sm:text-4xlin the hero).WordmarkArtis the hook-free drawing used by thenext/ogcards infrontend/src/lib/og.tsx. UseSoupMarkfrom the same file where only the icon fits (404, error boundary). - Static images:
design/brand/wordmark-dark.svgandwordmark-light.svgcarryrole="img",<title>ProtocolSoup</title>, and must be placed withalt="ProtocolSoup". The sync script copies both tofrontend/public/brand/anddocs/starlight/src/assets/(Starlight header, API reference topbar, this page) and the dark one towallet-ui/public/brand/(wallet footer). The sharp-rendered share cards infrontend/scripts/generate-og-image.mjsandwallet-ui/scripts/generate-seo-assets.mjsread the dark SVG frompublic/brand/. - Never type “ProtocolSoup” as a logo, never use the 🍜 emoji (it renders differently on every OS and cannot carry the brand colours), and never show the mark next to the wordmark: the bowl is already in the word. Do not recolour the bowl or the cursor, swap the font, or set the wordmark in title case without the bowl.
To change the wordmark, edit the generator, then from ProtocolSoup/design/ run npm install and npm run build:brand (which also runs the sync).
Colour
Section titled “Colour”Colour is split into roles that never overlap. If you are reaching for a colour, first decide which role you are in.
| Role | Token | Used for | Never used for |
|---|---|---|---|
| Brand | --ps-brand (purple) |
Wordmark, hero, docs navigation, wallet and docs theme-color |
Buttons, status |
| Brand gradient | --ps-brand-gradient |
Wordmark-scale moments: hero text, 404, OG images | Body text, borders, backgrounds |
| Interactive | --ps-interactive (cyan) |
Primary actions, in-app links, focus ring | Status meaning |
| Status | --ps-status-* |
Outcome of a step, check, or request | Decoration, protocol identity |
| Protocol identity | --ps-protocol-{id} |
Cards, icons, and tags for that protocol | Success or failure |
Surfaces and text
Section titled “Surfaces and text”These swatches read the live tokens, so toggling the docs theme shows the light values.
The canvas is indigo black. Every surface (app, wallet, docs, share cards, PWA splash) paints --ps-bg-base, #05070f. In components and CSS it is always bg-canvas or the token; never a literal. In OKLCH:
- Black first, blue second.
lab-0is#05070f: lightness 0.130, chroma 0.019, hue 270. It reads as black with a cool blue undertone, which reads as calm and trustworthy, without competing with the accents (chroma 0.13 to 0.23). Rejected alternatives: near-neutral#0a0a0f(lifeless), aubergine at hue 305 (#120a19too purple,#09060dstill warmer than wanted), saturated navy ink (reads as a generic dark website), and ambient brand light pooled behind the hero (reads as a marketing gradient). - Steps stay in the same hue.
lab-1tolab-3(raised,overlay,elevated) step up 0.035 in lightness at hue ~270. - Panels share the hue. The header, inputs and Looking Glass panels paint with
surface-700tosurface-950(and Tailwind’sslate-700toslate-950), which resolve to--ps-indigo-700to--ps-indigo-950: slate’s lightness steps at the canvas hue. Panels lift off the canvas by lightness, so there is one neutral family on screen. Stock slate panels (hue ~257, chroma up to 0.04) on a different-hue canvas read as two palettes and were rejected. - Glow is for content, not the canvas. The hero cards’ corner blooms and
--ps-glow-brandon the active flow node are the only soft light on a page. Do not add page-level gradients or radial glows behind the header or hero.
Literal copies of the canvas. A few places cannot read CSS variables and carry the hex values by hand. When --ps-lab-0 to --ps-lab-3 change, update all of them in the same commit, then regenerate the rasterised assets:
| File | Carries | Then run |
|---|---|---|
frontend/public/manifest.json, wallet-ui/public/manifest.json |
background_color (PWA splash) = lab-0 |
nothing |
frontend/src/lib/og.tsx |
next/og card gradient lab-0 to lab-1; WORDMARK_DARK in Brand.tsx carries the fixed brand colours for the same cards |
nothing (rendered at request time) |
frontend/scripts/generate-og-image.mjs |
gradient stops lab-0, lab-1, lab-3; card fill lab-0 |
node scripts/generate-og-image.mjs from frontend/ |
wallet-ui/scripts/generate-seo-assets.mjs |
same stops and fill | node scripts/generate-seo-assets.mjs from wallet-ui/ |
rg -n "05070f|0b0e18|13161f|1b1e26" --glob "!**/node_modules/**" from ProtocolSoup/ should list exactly these files, the token copies and this page.
- Elevation comes from borders, not shadows:
--ps-border-subtle(white 5%),--ps-border-default(white 10%),--ps-border-strong(white 20%). - The app’s structural neutrals are the
surface-50tosurface-950scale in Tailwind: true slate from 50 to 600 (text and light-theme colours), indigo from 700 to 950 (dark panels and borders). - Contrast floor: body text must reach 4.5:1. On
--ps-bg-base,--ps-fgis 15.9:1,--ps-fg-muted/surface-400are 7.9:1, and brand purple is 5.1:1 (4.9:1 onraised, 4.6:1 onoverlay, 4.5:1 onsurface-900, below 4.5:1 onelevated, so keep purple text off elevated surfaces).--ps-fg-subtleandsurface-500are about 4.0:1, so use them only for large text, icons, and placeholders.surface-600and darker (2.6:1 or less) are never text colours.
Brand trio
Section titled “Brand trio”Purple is the brand (wordmark accent, docs). Cyan is the action colour (primary buttons, in-app links, focus ring). Orange and amber are the warm chrome accent: the >_ hero prompt, the footer (hairline, microcopy, link and nav hovers), the app’s theme-color, the gradient and the mark, and JSON keys in payload views. All three appear on every page; a page with no warm accent has lost part of the identity. Warm chrome is decoration, not status, so keep it away from anything that could be read as a warning badge.
Status
Section titled “Status”The rule: status has one meaning on every surface, and every status is shown as icon + label + colour, never colour alone.
| Status | Token | Tailwind | Icon (lucide) | Meaning |
|---|---|---|---|---|
| Success | --ps-status-success |
emerald |
CheckCircle2 |
Step completed, check passed, PASS |
| Danger | --ps-status-danger |
red |
XCircle |
Error, FAIL, MUST / MUST NOT violated |
| Warning | --ps-status-warning |
amber |
AlertTriangle, Hand (user action) |
Needs attention or user action, CHECK, SHOULD |
| Info | --ps-status-info |
cyan |
Info, Loader2 (spinning) |
In progress, explanatory notice |
| Deviation | --ps-status-deviation |
purple |
GitBranch |
Documented spec deviation with an ADR |
| Pending | --ps-status-pending |
neutral surface |
Circle with animate-pulse-slow |
Not started, not evaluated |
Waiting states use motion (spinner or pulse), not a new hue, so they never collide with a protocol colour.
Where it is implemented today:
- Flow status:
STATUS_BADGE_VARIANTSinfrontend/src/views/LookingGlass.tsxrendered byStatusBadgecovers Success, Info (executing), Warning (awaiting input) and Danger, and is the reference for the pattern. - Notices:
ProtocolNoticecovers Danger, Warning and Info with the raw protocol error kept verbatim. - Deviation and Pending have no shared component yet. Until one exists, build them with
StatusBadgeand the icons above; do not invent a different icon or hue per screen.
Older inspectors (SVIDInspector, RealFlowPanel and others) still use green-* and yellow-* for status; see known gaps. New code uses the table.
Protocol identity hues
Section titled “Protocol identity hues”Each protocol family owns one hue. The hue says which protocol, never whether it worked.
The canonical mapping is COLOR_BY_PROTOCOL in frontend/src/protocols/presentation/protocol-catalog.ts, which drives the homepage grid and the catalog. The grid is two columns, so neighbouring families share a hue region.
Two older maps still exist and do not agree with it: protocolMeta in frontend/src/protocols/registry.ts (the protocol detail page header and card gradients) and PROTOCOL_COLORS in frontend/src/lookingglass/registry.ts (the Looking Glass protocol selector). Six of the eight families change hue between the homepage and their own page as a result, and OID4VCI uses emerald there, which this guide reserves for success. Until they are collapsed into the catalog (a known gap), a new protocol has to be added to all three, with the same hue.
Reserved for new protocols: violet, rose, lime, fuchsia. Never use red (danger) or emerald (success) as a protocol hue.
The tint recipe
Section titled “The tint recipe”Coloured chips, cards, notices, and buttons all use one recipe on the dark canvas, with the hue swapped in:
| Part | Quiet | Default | Emphasis |
|---|---|---|---|
| Fill | bg-X-500/5 |
bg-X-500/10 |
bg-X-500/20 |
| Border | border-X-500/20 |
border-X-500/30 |
border-X-500/40 |
| Text | text-X-300 or text-X-400 |
Text on a tint is always the 300 or 400 step; 500 and darker drop below 4.5:1 for small text. PROTOCOL_ACCENT_CLASSES, ProtocolNotice, SegmentedChoice, and the wallet btn-* utilities all follow this recipe. Keep the class strings literal ('bg-blue-500/10', not `bg-${hue}-500/10`) so Tailwind can detect them.
Typography
Section titled “Typography”One voice per surface. Fonts are self-hosted with @fontsource-variable/*; no surface loads fonts from a CDN.
| Surface | Family | Token / utility | Notes |
|---|---|---|---|
| App and wallet | JetBrains Mono, everything | --ps-font-mono / font-mono (default on body) |
Wordmark, headings, nav, prose, labels, buttons, chips, status text, wire data. Inter is not loaded in the apps. |
| Docs | Inter for body prose, JetBrains Mono for headings and chrome | --ps-font-sans for --sl-font; mono on headings, header, sidebars, pagination (custom.css) |
The docs are a reading surface; everything that frames the reading keeps the lab voice. |
The app is mono, all of it
Section titled “The app is mono, all of it”The lab reads as one instrument. The moment a paragraph switches to a sans-serif, the page reads as a product site with a code theme, and the character goes. So:
- Never add
font-sansinfrontend/orwallet-ui/. The utility exists in the Tailwind theme for the token mapping, but Inter is not shipped to the apps, so it would fall back to the system sans. Long prose is still mono: give itleading-relaxed, amax-w-*, andtext-surface-300or lighter. - Looking Glass is the reference. It is the signature screen and reads as a protocol trace; every other screen should feel like the same instrument.
- Wire data is marked by more than the font: payloads sit in
<pre>panels with syntax colours (--ps-syntax-*), and parameter names use the label/value pattern (FieldRow), so they stay distinct inside mono chrome.
| Step | Token | Typical utility |
|---|---|---|
| Display | --ps-text-display (clamp 2rem to 3.5rem), tracking -0.03em |
Hero only |
| Heading | --ps-text-heading, tracking -0.02em |
text-xl sm:text-2xl font-semibold (mono by default) |
| Body | --ps-text-body |
text-sm (14px) |
| Caption | --ps-text-caption |
text-xs (12px) |
| Data | --ps-text-data |
text-xs; text-[10px] / text-[11px] only in dense inspectors. font-mono is now a no-op in the apps and can be dropped when touching a file |
Shape, space, and elevation
Section titled “Shape, space, and elevation”| Token | Value | Tailwind | Use for |
|---|---|---|---|
--ps-radius-inline |
4px | rounded |
Inline spec tags, kbd, scrollbar thumb |
--ps-radius-control |
8px | rounded-lg |
Buttons, inputs, notices, code blocks |
--ps-radius-panel |
12px | rounded-xl |
Cards, panels, modals |
--ps-radius-pill |
9999px | rounded-full |
Status badges, protocol tags |
- Spacing uses the default Tailwind scale. Pages sit in
max-w-5xl mx-auto px-4 sm:px-6; panels usep-3 sm:p-4(dense) orp-4 sm:p-6. - The canvas carries a faint 60px grid (
Layout.tsx); do not add competing background textures. - Glow (
--ps-glow-brand,--ps-glow-interactive) is reserved for brand moments and the active node in a flow diagram.
Motion and accessibility
Section titled “Motion and accessibility”- Hover and colour transitions:
--ps-duration-hover(150ms). Enter transitions:--ps-duration-enter(300ms). Flow loops: about 2s. prefers-reduced-motion: reducecollapses CSS animation and transition durations globally (design/tailwind-theme.css). Forframer-motion, keep animations decorative so the UI is complete without them.- Every interactive element shows a visible focus ring (
:focus-visible, 2px--ps-focus-ring, 2px offset). Do not remove it withoutline-noneunless you replace it with afocus:ring-*of equal visibility. - Touch targets are at least 44px on screens under 640px (set globally).
- Icons are
lucide-reactonly. Decorative icons getaria-hidden; icon-only buttons get anaria-label.
Component recipes
Section titled “Component recipes”These are the shared building blocks. Reuse them before writing new markup.
| Need | Use | Location |
|---|---|---|
| Panel | glass utility, or rounded-xl border border-white/10 bg-surface-900/50 |
design/tailwind-theme.css |
| Status chip | StatusBadge with variants that include an icon |
frontend/src/lookingglass/components/shared/StatusBadge.tsx |
| Callout or protocol error | ProtocolNotice (error, warning, info); keeps the raw protocol error verbatim |
.../shared/ProtocolNotice.tsx |
| Option picker | SegmentedChoice |
.../shared/SegmentedChoice.tsx |
| Label and value | FieldRow (set mono for wire values) |
.../shared/FieldRow.tsx |
| Copy control | CopyButton |
.../shared/CopyButton.tsx |
| Collapsible detail | ExpandableSection |
.../shared/ExpandableSection.tsx |
| Wallet buttons and inputs | btn-primary, btn-secondary, btn-danger, btn-success, glass-input |
wallet-ui/src/index.css |
| Payload view | <pre> on bg-surface-950, rounded-lg, text-xs, horizontal scroll; never truncate |
Inspectors under lookingglass/components/inspectors/ |
Payload views must show the real bytes. Do not elide, reformat, or “prettify” wire data in ways that change it; pretty-printing JSON is fine, re-ordering claims or shortening a token in the main view is not.
Tokens and how they flow
Section titled “Tokens and how they flow”ProtocolSoup/design/tokens.css --ps-* custom properties (dark default, [data-theme='light'] overrides)ProtocolSoup/design/tailwind-theme.css Tailwind v4 @theme inline mapping, base layer, shared utilitiesProtocolSoup/design/brand/ wordmark.generated.ts + wordmark-{dark,light}.svg (from build-wordmark.mjs) │ node design/sync-tokens.mjs ├─ frontend/src/styles/design/ → frontend/src/index.css ├─ frontend/src/components/common/wordmark.generated.ts, frontend/public/brand/ ├─ wallet-ui/src/styles/design/ → wallet-ui/src/index.css ├─ wallet-ui/public/brand/ ├─ docs/starlight/src/styles/design/tokens.css → custom.css maps --sl-* onto --ps-* └─ docs/starlight/src/assets/wordmark-*.svgThe copies are committed because each Docker build context only contains its own app directory. To change a token:
- Edit
ProtocolSoup/design/tokens.cssortailwind-theme.css(or regenerate the brand assets, see above). - Run
node design/sync-tokens.mjsfromProtocolSoup/. - Commit the source and the generated copies together. CI runs
node design/sync-tokens.mjs --checkand fails if a copy is stale.
Tailwind utilities available from the theme:
- Semantic:
bg-canvas,bg-raised,bg-overlay,bg-elevated,border-line-subtle,border-line,border-line-strong,text-fg,text-fg-muted,text-fg-subtle,text-brand,text-interactive, andsuccess,danger,warning,info,deviation,pendingwith any colour utility. - Protocol:
text-protocol-oauth2,bg-protocol-saml/10, and so on. - Existing scales:
surface-*andaccent-{orange,purple,cyan,green,red}. - Breakpoint
xs(475px), animationsanimate-fade-in,animate-pulse-slow,animate-flow,animate-caret(wordmark cursor), and utilitiesglass,gradient-text,scrollbar-hide.
New code should prefer the semantic utilities. Most existing code predates them and uses surface-* plus Tailwind palette colours in a GEL role; that is fine to leave, and fine to migrate when you are already editing the file. There is no tailwind.config.js; the theme is CSS-only.
Surface differences
Section titled “Surface differences”The three surfaces share every token. Two differences are deliberate:
- Accent in docs: Starlight exposes one accent variable, so docs links and navigation use brand purple. In the apps, links and actions are interactive cyan.
- Light mode: only the docs offer it today. The light values already exist in
tokens.css, so the apps can add a theme switch later without restructuring.
Adding a protocol
Section titled “Adding a protocol”- Pick a hue from the reserved list and add it to
ProtocolAccent,PROTOCOL_ACCENT_CLASSES(following the tint recipe), andCOLOR_BY_PROTOCOLinfrontend/src/protocols/presentation/protocol-catalog.ts. - Use the same hue in
protocolMeta(frontend/src/protocols/registry.ts,colorandgradient) andPROTOCOL_COLORS(frontend/src/lookingglass/registry.ts) until those maps are retired. - Add
--ps-protocol-{id}todesign/tokens.css, map--color-protocol-{id}indesign/tailwind-theme.css, and run the sync script. - Pick a
lucide-reacticon forICON_BY_PROTOCOLthat is not already used by another family, and use the same one in thegetProtocolIconswitch infrontend/src/views/ProtocolDemo.tsx. - Flow card gradients in
flow-meta.tsshould start from the protocol hue.
The full wiring checklist is in Add A Protocol.
Review checklist
Section titled “Review checklist”Before opening a PR that touches UI:
- The app and wallet are entirely mono;
font-sansappears nowhere underfrontend/orwallet-ui/. - The brand is the
WordmarkorSoupMarkcomponent (or awordmark-*.svgwithalt="ProtocolSoup"), never typed text or the 🍜 emoji, and the surrounding text content still reads exactlyProtocolSoup. - No new hardcoded hex or
rgba()in components or CSS; colours come from tokens or the Tailwind palette in a GEL role. The only sanctioned literals are the canvas copies andWORDMARK_DARK, which exist for surfaces that cannot read CSS variables. - Status shows icon + label + colour from the fixed status set.
- Protocol hues are used only for protocol identity.
- Coloured surfaces follow the tint recipe and text is the 300 or 400 step.
- Focus is visible, and the change works with reduced motion.
- Token changes were made in
ProtocolSoup/design/and synced. - Real payloads are shown in full, with the spec reference beside them.
Known gaps
Section titled “Known gaps”These are tracked follow-ups, not exceptions to follow:
- Protocol hue maps disagree.
protocolMetaandPROTOCOL_COLORS(see protocol identity hues) should be derived fromCOLOR_BY_PROTOCOLso a family has one hue everywhere; thegetProtocolIconswitch inProtocolDemo.tsxshould readICON_BY_PROTOCOLfor the same reason. - Status drift. About 17 components still use
green-*/yellow-*for status (SVIDInspector,RealFlowPanel, …) instead ofemerald/amber, and Deviation and Pending have no shared component. - Legacy literals.
FlowDiagram.tsxholds its node colours as hex;ErrorBoundary.tsxpaints its cardbg-[#0f172a](old slate-900, which no longer matches the indigo panels; should bebg-surface-900); the favicon tile is#0f172awhile the PWA splash is the#05070fcanvas. - The wallet harness header shows its session-provided app title with a wallet icon; the wordmark appears in its footer.
- The apps are dark-only, and there is no visual regression suite or Tailwind class linting yet.