Interface guidance applies the foundations to working software. Where a component appears here, its measurements are normative — a 40px input is 40px, not “about 40px”.
This is the multi-page printable view of this section. Click here to print.
Interface
- 1: Components
- 2: Composed patterns
- 3: Dark mode
- 4: Code
- 5: Icons
- 6: Forms
- 7: States
- 8: Accessibility
1 - Components
Every specimen on this page is live markup styled by the brand tokens, ported
from brand/html/projectious-kitchen-sink.html. They follow your colour mode —
switch the theme and they change with the documentation.
Measurements listed here are normative: a 40px input is 40px.
Buttons
Five variants, three sizes. Plus Jakarta Sans 600, 6px radius, 200ms transitions.
| Variant | Fill | Border | Text | Contrast | Use |
|---|---|---|---|---|---|
| Primary | midnight-9 | none | white | 12.75:1 | Default action |
| Accent | accent-solid #cc4528 | none | white | 4.72:1 | The single most important action |
| Outline | transparent | 1.5px orange-9 | orange-11 | 5.13:1 | Secondary action |
| Ghost | transparent | 1px border | slate-9 | — | Tertiary, toolbars |
| Danger | #a8261c | none | white | 7.10:1 | Destructive action |
orange-9 (#E05232) is the identity accent and is unchanged as a mark,
border, active state, or syntax colour. But white on it measures 3.87:1 —
below the 4.5:1 floor for 13–14px labels. Solid accent controls therefore fill
with --color-accent-solid (#cc4528), white at 4.72:1; hover
continues to accent-dark (#b84228, 5.46:1).
Use exactly one accent button per view. Give every button a verb — “Deploy”, “Save changes”.
Place two accent buttons side by side, or use a danger button for a reversible action.
Inputs
Default height 40px (sm 32, lg 48). Source Sans 3 at 13px, slate-7 border,
and a 2px focus ring at midnight 15% alpha.
Full rules — labels, validation, focus — are on Forms.
Cards
Radius 9px, padding 24px, 1px slate-4 border, shadow-1 at rest. Nested
controls step down one radius.
Tables
Header cells use the overline style. Striping uses the subtle background step, never a border-only rule. Wide tables scroll inside their own container.
| Pipeline | Policy | Agents | Status |
|---|---|---|---|
| validate-deploy | strict | 2 | Healthy |
| nightly-audit | advisory | 1 | Degraded |
| release-train | strict | 4 | Blocked |
Table with search and filters
A toolbar above the table carries the search field and filter chips. Active filters are shown as removable chips so the current view is always legible — never leave a filter applied with no visible indication.
| Pipeline | Policy | Owner | Status |
|---|---|---|---|
| release-train | strict | platform | Blocked |
| nightly-audit | strict | security | Failed |
Multi-level headers
Grouped columns use a two-row header. The group row is centred over its span and separated by a vertical rule — the only place the system uses one.
| Pipeline | This week | Last week | ||||
|---|---|---|---|---|---|---|
| Runs | Pass | p95 | Runs | Pass | p95 | |
| validate-deploy | 412 | 98.1% | 4m12s | 388 | 97.4% | 4m40s |
| nightly-audit | 7 | 71.4% | 18m03s | 7 | 85.7% | 17m22s |
| release-train | 14 | 92.9% | 9m51s | 12 | 91.7% | 10m08s |
Grouped rows and totals
Row groups act as sub-headings inside the table. A totals row is separated by a 2px rule — heavier than the body rules, so it reads as a summary rather than another record.
| Workload | Runs | Cost |
|---|---|---|
| Platform | ||
| validate-deploy | 412 | €48.10 |
| release-train | 14 | €12.60 |
| Security | ||
| nightly-audit | 7 | €21.40 |
| secret-scan | 96 | €9.05 |
| Total | 529 | €91.15 |
Wide tables
A wide table scrolls inside its own container, never the page, and pins its first column so the row identity stays visible while scrolling.
| Pipeline | Policy | Owner | Runs | Pass | p50 | p95 | Region | Status |
|---|---|---|---|---|---|---|---|---|
| validate-deploy | strict | platform | 412 | 98.1% | 2m01s | 4m12s | eu-central | Healthy |
| nightly-audit | strict | security | 7 | 71.4% | 11m40s | 18m03s | eu-west | Degraded |
Right-align numeric columns and set them in IBM Plex Mono so digits line up. Show the active filter state and the result count together.
Add vertical rules between ordinary columns, let a wide table scroll the whole page, or apply a filter without a visible chip saying so.
Navigation
The navbar follows the colour mode: midnight-1 at 88% alpha in light,
midnight-dark-1 at 88% in dark, both over a 12px backdrop blur so content
scrolling underneath stays legible. It is separated by a 1px border, not a
fill — the header is chrome, and a solid midnight band at the top of every page
spends the brand’s darkest surface on navigation.
The active link carries a 2px accent-solid underline plus the
high-emphasis text step. The underline is accent-solid rather than orange-9
because it sits directly against 13px text and is read as part of it.
This specimen mirrors .td-navbar — the header at the top of this page. Switch
the theme and both change together.
Alerts and feedback
Four semantic colours as a 4px left border on a tinted background. The hues are mode-specific — see Colour.
Success, warning, and danger carry meaning. Do not use them to add visual variety to neutral content.
Modals and overlays
Modal radius 13px with shadow-3; scrim is midnight at 40% alpha. Focus is
trapped while open and restored to the trigger on close; Esc always closes.
Data display
Pair every status dot with a text label, so meaning does not depend on colour perception.
Use a bare coloured dot, or rely on red/green alone to distinguish states.
Code and terminal
The code surface is always dark — see Code.
Plan: 3 to add, 0 to change, 0 to destroy.
✓ Policy check passed
● Deploying to staging…
2 - Composed patterns
Components answers “what does a card look like”. This page answers the question after it: what does a whole screen look like when it is made only of those parts.
Nothing here is a new component. Every element below already exists on the components page; what is normative here is the arrangement — which regions a screen has, in what order, at what widths.
The page shell
Three regions, always in this order in the DOM:
| Region | Width | Contents |
|---|---|---|
| Sidebar | 224px, fixed | Brand lockup, pj-sidebar items, account block pinned to the bottom |
| Header | 60px, fixed height | Page title, one line of context, search, and one accent action |
| Content | fills, scrolls | The page body — everything below |
The sidebar is the only large midnight fill on the screen. It is the application’s frame, so it stays constant while the content region changes; a sidebar that re-renders per route reads as a page reload.
The header carries exactly one accent button. That is the whole quota for the screen — one accent per view — which is why the pattern places it here rather than leaving it to the content region to spend.
Below lg (1024px) the sidebar leaves and becomes the mobile navigation
pattern; see Responsive.
The KPI row
Directly under the header: four stat cards in a repeat(4, 1fr) grid,
--space-4 gap.
Four, not three and not six. Three leaves a hole in a 12-column grid; six turns the row into a wall of numbers nobody reads. If there are five things worth measuring, the fifth one is not a KPI.
Each card is a pj-stat — the
number first in Plus Jakarta Sans 800, its label under it in the overline style,
then a delta line. The pj-stat carries its own border and radius, so it is
not nested inside a pj-card; that would draw the box twice.
A delta states its direction in text or an arrow, not in colour. The default
__delta is the success hue; a delta that is not good news takes an explicit
colour, and a delta that is merely neutral takes the muted foreground rather
than borrowing a semantic one.
Primary content and secondary panel
Under the KPI row, a 1.5fr / 1fr split: the thing the page is about on the left, the thing that gives it context on the right.
- Primary is the record set — a table
in a
pj-table-shell, with its toolbar, filter chips, and pagination footer. - Secondary is a
pj-cardholding a feed: apj-timelineorpj-listof recent events, each with a status dot and a text label.
align-items: start, so the two panels are independent — the feed does not
stretch to match a long table, and the table does not gain whitespace to match a
short feed.
The split is 1.5fr / 1fr because the primary panel holds tabular data with four or more columns and the secondary holds one column of prose. An even 1fr / 1fr starves the table and pads the feed.
At md the ratio flattens to 1fr / 1fr; below md the secondary panel moves
below the primary, in source order.
Reading the whole thing at once
Dashboard mockup — a client engagement dashboard built entirely from the parts above: page shell, KPI row, engagement table, agent-activity feed. Open it beside the components page and every element in it should be findable there.
Its narrow counterpart is Mobile onboarding — the same system with the sidebar replaced by a tab bar and every region in one column.
Density
One pattern, two densities. The measurements on the components page are the comfortable density and are the default. A compact density exists for screens whose job is scanning many rows at once — an audit log, a run history:
| Comfortable | Compact | |
|---|---|---|
| Table row padding | 12px | 8px |
| Control height | 40px | 32px |
| Card padding | 24px | 16px |
| Section gap | --space-6 32px | --space-5 24px |
Density changes padding and control height. It does not change type size, radius, or border weight — a compact table is the same table with less air, not a smaller one. Compact is never used on touch-primary surfaces, where the 44px floor applies regardless.
Compose screens from the documented components. Spend the one accent action in the header. Keep the KPI row at four. Let the secondary panel fall below the primary on narrow viewports.
Introduce a screen-specific component when an arrangement of existing ones will do, put a second accent button in the content region, or use compact density on a touch surface.
3 - Dark mode
Dark mode is not a variant of the brand — it is half of it. Both modes are designed, tested, and shipped together.
Implementation rules
- Default follows
prefers-color-scheme, with a manual override toggle persisted tolocalStorage. - CSS custom properties swap per theme. Components read tokens; they do not branch on the mode themselves.
- Step 9 (solid accent) stays constant across modes.
#E05232is#E05232everywhere. - No pure
#000or#fffas text. Use step 12 —#c5daf0on dark,#142438on light. - Images get a subtle overlay (black at low alpha) in dark mode, so a bright photograph does not punch a hole in a dark page.
- Code blocks are always dark, regardless of mode. See Code.
Surfaces
Elevation in dark mode is expressed by lightening the surface, not by deepening the shadow — a shadow against a near-black background is invisible.
| Layer | Light | Dark |
|---|---|---|
| App background | #ffffff | midnight-dark-1 #0e1720 |
| Subtle surface | midnight-1 | midnight-dark-2 #131e2b |
| Raised surface | midnight-2 | midnight-dark-3 #1a2b3e |
Restating text on dark surfaces
The default heading colour is midnight-9, chosen so headings read correctly on
light pages. That same value on a midnight surface is invisible — #1d3352 on
#1d3352 is a contrast ratio of 1.00:1.
Any component that paints its own dark background must therefore restate its text colours. This applies to covers, dark boxes, footers, and overlays:
.surface--dark {
--bs-body-color: #c5daf0; // midnight-dark-12
--bs-heading-color: #c5daf0;
--bs-secondary-color: #97a8b8; // slate-dark-11, still AA
}
Test every surface in both modes. Restate text colours on any component that sets its own dark background.
Ship a component that only works in one mode, or use pure black or white as a text colour in either.
4 - Code
Code blocks are always dark
Code blocks stay dark regardless of colour mode. A code surface that flips
with the theme forces the syntax palette to be designed twice and makes
screenshots inconsistent between users. The surface is midnight-2 from the
dark scale (#131e2b) in both light and dark mode.
The block you are reading is rendered by that rule:
// Agent pipeline definition
const pipeline = createPipeline({
name: "validate-deploy",
policy: "strict",
agents: ["auditor", "deployer"],
});
Syntax theme
Because the surface is always dark, every syntax value is read from the dark
scale. Contrast is measured against #131e2b.
Ten roles, not twenty-two tokens
Editors do not describe code with six token types. The Language Server Protocol defines 22 semantic token types and 10 modifiers, and TextMate grammars — the model behind VS Code, Sublime Text, and most highlighters — define 11 root scopes with a deep sub-scope tree under each.
A theme should not answer that with twenty-two colours. Past roughly nine, hue stops being a signal: everything is coloured, so nothing is marked. The two scope vocabularies are therefore grouped into ten roles, and the modifiers are carried by weight and slant rather than by more hue.
Every role is an existing brand or terminal value. The expansion introduced no new colour — the terminal palette had already added the two hues, cyan and magenta, that a syntax theme needs and the three interface scales do not have.
| Role | On surface | LSP semantic token | TextMate scope |
|---|---|---|---|
Plain and variables#c5daf0 midnight-dark-12The default. Anything the reader does not need to pick out. | 11.74:1 | variable · parameter · property · enumMember | variable · variable.parameter · variable.other.n .nv .nx .py .vc .vg .vi |
Keywords and modifiers#d491b4 terminal magenta (bright)Also structural keys — a YAML key is the keyword of its line. | 6.82:1 | keyword · modifier | keyword.control · storage.modifier · storage.type.k .kc .kd .kn .kp .kr .nt .na |
Types and classes#6cc090 terminal green (bright)Green, not cyan, because types are referenced on nearly every line of typed code and the most frequent role should hold the best-separated hue — 29.9 ΔE2000 from plain text, against cyan's 17.3. | 7.67:1 | type · class · struct · interface · enum · typeParameter · namespace | entity.name.type · entity.name.class · support.class.kt .nc .nn .ne .bp |
Functions and methods#e0a92a terminal yellow (bright)Callables read as callable at a glance — the single most useful distinction beyond the original six. | 7.91:1 | function · method | entity.name.function · support.function.nf .fm |
Decorators and macros#74c0c9 terminal cyan (bright)Cyan sits closer to plain text than green does, which is affordable here because decorators are rare. Code that runs at a different time from the code around it — including C preprocessor directives and Rust attributes, which Chroma files under Comment.Preproc but which are macros, not commentary. | 8.11:1 | macro · decorator · event | entity.name.tag · meta.decorator · support.macro.nd .ni .nl .cp .cpf |
Strings#ea7558 orange-dark-10Interpolation delimiters take the operator colour, so the expression inside stays readable as code. | 5.76:1 | string | string.quoted · string.interpolated · string.regexp.s .s1 .s2 .sa .sb .sc .se .sh .si .sr .ss .sx .dl |
Numbers and constants#f09878 orange-dark-11Literal values, including true/false/nil. | 7.58:1 | number · regexp | constant.numeric · constant.language · constant.character.m .mb .mf .mh .mi .mo .il .no |
Operators and punctuation#97a8b8 slate-dark-11Present but recessive — structure you read past, not at. | 6.90:1 | operator | keyword.operator · punctuation.o .ow .p |
Comments#72889d code-commentItalic. The only role with no scale step of its own. Documentation comments belong here even though Chroma files them under String.Doc — a docstring is documentation, not data. | 4.59:1 | comment | comment.line · comment.block · comment.block.documentation.c .ch .cm .c1 .cs .sd |
Invalid and deprecated#e55b5b terminal red (normal)Deprecated is struck through as well as coloured — the state does not depend on hue. | 4.79:1 | (modifier) deprecated | invalid.illegal · invalid.deprecated.err |
Modifiers are not colours
LSP modifiers combine with any token type: ten modifiers against ten roles is a hundred states. Hue cannot carry that, so it does not try.
| LSP modifier | Treatment | Why |
|---|---|---|
| declaration · definition | Weight 500 | Where a name is introduced, distinguished from where it is used. |
| deprecated | Line-through | A state, not a category — it must survive greyscale. |
| documentation | Italic, comment colour | Doc comments are comments; they are not a separate hue. |
| readonly · static · abstract · async · defaultLibrary | No distinct colour | Ten modifiers times ten roles is a hundred combinations. Colour cannot carry that, and a theme that tries becomes unreadable. |
deprecated is a state, not a category. It is struck through as well as
recoloured, so a reader who cannot separate the red from the plain text still
sees that the symbol should not be used.
Why comments have a dedicated token
Comments are the one syntax role with no scale step available to it. Steps 8–10 are border and solid-surface roles and are not held to text thresholds; step 11 is already spoken for by operators.
So code-comment (#72889d, 4.59:1) exists as a dedicated syntax token —
the dimmest value that clears AA while staying visibly below operators. It is
not a scale step and should not be treated as one.
Group scopes into roles, and let a language’s grammar map onto them. Keep the role count under ten, and check every value against the code surface.
Give each LSP token type its own hue, or use the accent as a syntax colour — it marks the primary action, and a code block is not one.
Worked examples
Each block below is a real, compilable-shaped fragment chosen to exercise as many of the ten roles as its language has. The coverage table after them records which roles each language actually reaches — several cannot reach all ten, and that is a property of the language, not a gap in the theme.
C
/* Ring buffer — fixed capacity, no allocation after init. */
#include <stdint.h>
#define RING_CAP 256 // macro: a decorator-role token
typedef enum { RING_OK = 0, RING_FULL = 1 } ring_status_t;
typedef struct {
uint8_t data[RING_CAP];
size_t head, tail;
_Bool wrapped;
} ring_t;
static inline size_t ring_len(const ring_t *r) {
return (r->head - r->tail) & (RING_CAP - 1);
}
static const char *RING_TAG = "ring\n"; // string literal
ring_status_t ring_push(ring_t *restrict r, uint8_t byte) {
// Reject when one slot short of capacity, so head never meets tail.
if (ring_len(r) == RING_CAP - 1) return RING_FULL;
r->data[r->head++ & (RING_CAP - 1)] = byte;
return RING_OK;
}
C++
// Policy-based cache. Types, templates, and a lambda.
#include <string>
#include <unordered_map>
namespace projectious::cache {
template <typename Key, typename Value>
class LruCache final {
public:
explicit LruCache(std::size_t capacity) noexcept : capacity_{capacity} {}
[[nodiscard]] auto get(const Key& key) const -> const Value* {
const auto it = entries_.find(key);
return it == entries_.end() ? nullptr : &it->second;
}
void put(Key key, Value value) {
static constexpr auto kTag = "lru"; // string literal
// Evict before insert so size never exceeds the capacity.
if (entries_.size() >= capacity_) evict();
entries_.emplace(std::move(key), std::move(value));
}
private:
void evict() noexcept { /* … */ }
std::size_t capacity_{0};
std::unordered_map<Key, Value> entries_{};
};
} // namespace projectious::cache
Python
"""Pipeline stages and their policy gates."""
from __future__ import annotations
import functools
from dataclasses import dataclass, field
from typing import Final, Iterable
# Retry budget is a policy decision, not a tuning knob.
MAX_RETRIES: Final[int] = 3
DEFAULT_POLICY = "strict"
@dataclass(frozen=True, slots=True)
class Stage:
"""A single stage. Immutable once constructed."""
name: str
policy: str = DEFAULT_POLICY
retries: int = 0
tags: list[str] = field(default_factory=list)
@property
def is_strict(self) -> bool:
return self.policy == "strict"
@staticmethod
def parse(raw: str) -> "Stage":
name, _, policy = raw.partition(":")
return Stage(name=name.strip(), policy=policy or DEFAULT_POLICY)
@functools.lru_cache(maxsize=None)
def validate(stages: Iterable[Stage]) -> bool:
for stage in stages:
if stage.retries > MAX_RETRIES:
raise ValueError(f"{stage.name!r} exceeds {MAX_RETRIES} retries")
return True
Rust
//! Policy evaluation for pipeline stages.
use std::collections::HashMap;
use std::fmt::{self, Display};
const MAX_RETRIES: u32 = 3;
/// How strictly a stage is evaluated.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Policy {
Strict,
Advisory,
}
#[derive(Debug, Default)]
pub struct Stage<'a> {
pub name: &'a str,
pub policy: Option<Policy>,
pub retries: u32,
}
impl<'a> Stage<'a> {
// Strict by default: a gate that is not configured should fail closed.
pub fn new(name: &'a str) -> Self {
Self { name, policy: Some(Policy::Strict), retries: 0 }
}
pub fn validate(&self) -> Result<(), String> {
if self.retries > MAX_RETRIES {
return Err(format!("{} exceeds {MAX_RETRIES} retries", self.name));
}
Ok(())
}
}
impl Display for Policy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", match self { Policy::Strict => "strict", _ => "advisory" })
}
}
Go
// Package pipeline evaluates stages against their policy gates.
package pipeline
import (
"errors"
"fmt"
)
const MaxRetries = 3
// Policy is how strictly a stage is evaluated.
type Policy int
const (
Strict Policy = iota
Advisory
)
var ErrTooManyRetries = errors.New("stage exceeds retry budget")
type Stage struct {
Name string `json:"name"`
Policy Policy `json:"policy"`
Retries int `json:"retries,omitempty"`
}
func (s *Stage) Validate() error {
if s.Retries > MaxRetries {
return fmt.Errorf("%q: %w", s.Name, ErrTooManyRetries)
}
return nil
}
func ValidateAll(stages []Stage) (ok bool, err error) {
for i := range stages {
if err = stages[i].Validate(); err != nil {
return false, err
}
}
return true, nil
}
Java
package work.projectious.pipeline;
import java.util.List;
import java.util.Objects;
/** A single pipeline stage and its policy gate. */
public final class Stage implements Comparable<Stage> {
public static final int MAX_RETRIES = 3;
public static final long TIMEOUT_MS = 30_000L;
public static final int MASK = 0xFF;
private final String name;
private final Policy policy;
private int retries = 0;
private double budget = 12.60;
public Stage(String name, Policy policy) {
this.name = Objects.requireNonNull(name, "name");
this.policy = policy;
}
@Override
public int compareTo(Stage other) {
return this.name.compareTo(other.name);
}
@Deprecated(since = "2.0", forRemoval = true)
public boolean isStrict() {
return policy == Policy.STRICT;
}
public void validate(List<String> errors) throws IllegalStateException {
if (retries > MAX_RETRIES) {
throw new IllegalStateException("%s exceeds %d retries".formatted(name, MAX_RETRIES));
}
}
public enum Policy { STRICT, ADVISORY }
}
Assembly (NASM)
; Sum a byte array. rdi = pointer, rsi = length, returns in rax.
section .data
msg: db "sum: ", 0
LEN equ 5
section .text
global sum_bytes
sum_bytes:
xor rax, rax ; accumulator
test rsi, rsi
jz .done ; empty input
.loop:
movzx rdx, byte [rdi]
add rax, rdx
inc rdi
dec rsi
jnz .loop
.done:
ret
Bash
#!/usr/bin/env bash
# Validate a pipeline definition and promote it when the gates pass.
set -euo pipefail
readonly MAX_RETRIES=3
readonly POLICY="${PIPELINE_POLICY:-strict}"
declare -A GATE_STATUS=()
log() { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" >&2; }
validate_stage() {
local -r name="$1" retries="${2:-0}"
if (( retries > MAX_RETRIES )); then
log "ERROR ${name} exceeds ${MAX_RETRIES} retries"
return 1
fi
GATE_STATUS["$name"]="ok"
}
main() {
local -a stages=("validate" "deploy")
for stage in "${stages[@]}"; do
validate_stage "$stage" 0 || exit 1
done
log "policy=${POLICY} stages=${#stages[@]}"
}
main "$@"
LaTeX
\documentclass[11pt,a4paper]{article}
\usepackage[utf8]{inputenc}
\usepackage{amsmath}
% Pipeline notation used throughout the paper.
\newcommand{\stage}[2]{\ensuremath{#1 \xrightarrow{#2}}}
\title{Policy Gates in Composable Pipelines}
\author{Jane Doe}
\begin{document}
\maketitle
\section{Definitions}
A stage $s_i$ passes when its retry count $r_i \leq 3$:
\begin{equation}
\forall s_i \in S : r_i \leq R_{\max}, \quad R_{\max} = 3
\end{equation}
\begin{itemize}
\item \textbf{Strict} — the gate fails closed.
\item \emph{Advisory} — the gate records and continues.
\end{itemize}
\end{document}
Markdown
---
title: Stage reference
weight: 10
---
# Stage reference
A stage passes when its retry count stays at or below **three**. See the
[policy guide](../policy/) for the full rules.
## Fields
| Field | Type | Default |
|---|---|---|
| `name` | string | — |
| `policy` | enum | `strict` |
> Advisory gates record a failure and continue. Strict gates fail closed.
1. Validate the configuration
2. Request promotion
3. Deploy
```sh
pipeline validate --policy strict
```
<!-- Deprecated: `--legacy-gate` is removed in 2.0. -->
JSON
{
"$schema": "https://projectious.work/schema/pipeline-2.json",
"name": "validate-deploy",
"policy": "strict",
"retries": 3,
"enabled": true,
"owner": null,
"budget": 12.6,
"stages": [
{ "name": "validate", "gate": "strict", "timeoutSeconds": 120 },
{ "name": "deploy", "gate": "advisory", "timeoutSeconds": 600 }
],
"tags": ["platform", "eu-central"]
}
YAML
# Pipeline definition — one policy gate per stage.
apiVersion: projectious.work/v2
kind: Pipeline
metadata:
name: validate-deploy
labels: { team: platform, region: eu-central }
defaults: &defaults
policy: strict
retries: 3
enabled: true
spec:
<<: *defaults
budget: 12.60
owner: ~
stages:
- name: validate
timeoutSeconds: 120
- name: deploy
policy: advisory
timeoutSeconds: 600
owner: "platform@projectious.work"
schema: 'https://projectious.work/schema/pipeline-2.json'
notes: |
Advisory gates record and continue.
Strict gates fail closed.
TOML
# Pipeline definition — one policy gate per stage.
schema = "https://projectious.work/schema/pipeline-2.json"
[pipeline]
name = "validate-deploy"
policy = "strict"
retries = 3
enabled = true
budget = 12.60
created = 2026-08-02T09:00:00Z
tags = ["platform", "eu-central"]
[[pipeline.stage]]
name = "validate"
gate = "strict"
timeoutSeconds = 120
[[pipeline.stage]]
name = "deploy"
gate = "advisory"
timeoutSeconds = 600
What each language reaches
The table is measured from the rendered page, not asserted: every block above is
parsed and its emitted token classes are mapped back to the roles. ● means the
role appears in that example.
| Language | Plain | Keyword | Type | Function | Macro | String | Number | Operator | Comment | Reached |
|---|---|---|---|---|---|---|---|---|---|---|
| C | ● | ● | ● | ● | ● | ● | ● | ● | ● | 9/9 |
| C++ | ● | ● | ● | ● | ● | ● | ● | ● | ● | 9/9 |
| Python | ● | ● | ● | ● | ● | ● | ● | ● | ● | 9/9 |
| Rust | ● | ● | ● | ● | ● | ● | ● | ● | ● | 9/9 |
| Go | ● | ● | ● | ● | · | ● | ● | ● | ● | 8/9 |
| Java | ● | ● | ● | ● | ● | ● | · | ● | ● | 8/9 |
| Bash | ● | ● | · | · | ● | ● | ● | ● | ● | 7/9 |
| Assembly (NASM) | ● | ● | ● | ● | ● | ● | ● | ● | ● | 9/9 |
| LaTeX | ● | ● | · | · | · | ● | ● | · | ● | 5/9 |
| Markdown | · | ● | ● | · | · | ● | ● | ● | ● | 6/9 |
| JSON | · | ● | · | · | · | ● | ● | ● | · | 4/9 |
| YAML | · | ● | · | · | ● | ● | ● | ● | ● | 6/9 |
| TOML | ● | ● | · | · | · | ● | ● | ● | ● | 6/9 |
Nine roles rather than ten, because invalid only appears when a grammar actually fails to parse — a correct example cannot demonstrate it.
Where a language falls short, the reason is the language or the lexer:
- Bash — no type system, and Chroma’s shell lexer does not mark function definitions.
- Go — Go has no macro or annotation construct; its struct tags are strings.
- Java — Chroma’s Java lexer emits a plain name for every numeric literal, so numbers cannot be separated. A lexer limitation, not a palette one.
- LaTeX — No type, callable or operator concept in the grammar — commands are keywords.
- Markdown — Prose, not code: there is nothing to name, call, or annotate.
- JSON — By design: no comments, no identifiers, no callables. Keys take the keyword role.
- YAML — Anchors and merge keys take the macro role; there are no callables or types.
- TOML — Table headers take the plain role; there are no callables or types.
Chroma files C preprocessor directives and Rust attributes under
Comment.Preproc, which would colour #define and #[derive(…)] as
commentary. They are macros — the LSP says so — and are coloured as macros here.
It also files documentation comments under String.Doc, which would colour a
Rust /// line and a Python docstring as data. Both are documentation, and take
the comment role.
Inline code
Inline code does not take the dark block treatment — it follows the
surrounding surface. On light surfaces it sits on midnight-2 (light scale)
with orange-11 text; in dark mode both values shift to their dark-scale
counterparts. It uses IBM Plex Mono at 13px with a 3px radius.
Terminal output
Terminal blocks use the same dark surface. Prompts take the comment colour, output takes the operator colour, so a transcript stays readable without becoming a second syntax theme.
$ hugo --gc --minify
Start building sites …
Total in 842 ms
5 - Icons
Library
The system uses Lucide — an actively maintained fork of Feather with over 1,400 icons.
| Property | Value |
|---|---|
| Licence | ISC (functionally equivalent to MIT) |
| Commercial use | Permitted |
| Attribution | Not required in product UI; retain the notice in source |
| Modification | Permitted |
ISC is one of the most permissive open-source licences available, which is why it was chosen over icon sets with attribution or share-alike terms. See the legal assessment for the clearance detail.
Getting the actual icon
Every icon in this system is a verbatim Lucide path. The specimens on this
page are lucide/shield, copied from the library rather than approximated —
including the 1 1 0 0 1 arc segments that give Lucide its corner radius, which
is exactly the character a redrawn path loses.
Take the file, do not trace it:
curl -O https://unpkg.com/lucide-static@0.544.0/icons/shield.svg
Pin the version. lucide-static@latest resolves to whatever shipped this
morning, which lets an upstream release silently change a committed document.
The copies used by the worked examples are
vendored at brand/examples/lucide/ at a pinned version, with the upstream ISC
notice beside them.
Inline the <path> and set width, height, and stroke at the use site;
keep viewBox="0 0 24 24", fill="none", stroke-width="2", and both
stroke-linecap and stroke-linejoin at round. Changing any of those five is
what makes an icon stop looking like the rest of the set.
Usage
- Stroke width 2, matching Lucide’s default. Do not thin or thicken strokes to “match” a layout.
- Size on the 4px grid: 16, 20, 24, 32px. 20px is the default for inline UI.
- Colour follows text. An icon beside a label takes the label’s colour.
Standalone icons carrying meaning take
--color-secondary; an icon marking the primary action takes the accent. - Icons are not decoration. Every icon must carry meaning the label does not
already carry, or be marked
aria-hidden="true".
Pair icons with text labels wherever the meaning is not universally understood. Give standalone icon buttons an accessible name.
Mix icon sets, recolour icons to arbitrary hues, or use an icon as the only indicator of state.
6 - Forms
Sizing
| Size | Height |
|---|---|
| sm | 32px |
| md | 40px — the default |
| lg | 48px |
All inputs are set in Source Sans 3 at 13px.
States
| State | Treatment |
|---|---|
| Default | slate-7 border |
| Focus | midnight-9 border + 2px focus ring, midnight at 15% alpha, 2px offset |
| Error | orange-9 border + orange-1 background |
| Disabled | slate-4 background, slate-8 text, cursor: not-allowed |
Labels
Labels use the overline style and sit above the field. Floating labels are not used anywhere in the system — they hide the label at exactly the moment the user is filling the field, and they break at long label lengths.
.field__label {
font-size: 11px;
font-weight: 600;
line-height: 1.3;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-secondary);
}
Validation
- Error text sits below the field, in
orange-11on light surfaces. - Never rely on border colour alone to signal an error — pair it with text.
- Validate on blur, not on every keystroke; re-validate on submit.
Keep labels visible at all times. Give every field an associated <label> and
describe errors in words.
Use placeholder text as a label, signal errors with colour alone, or disable the submit button without explaining what is missing.
7 - States
A component set shows every part working. A product spends a lot of its life in the three states where nothing is working yet: there is no data, the data has not arrived, and the data will not arrive. Those three screens are where a design system is actually tested, so they are specified here rather than left to whoever hits them first.
Every state on this page follows the same shape:
What is true → why → the one thing to do about it.
If a state cannot name the one thing to do, it is an error state with an optimistic title.
Empty states
Three kinds, and they are not interchangeable:
| Kind | When | The action |
|---|---|---|
| First run | The feature works; nothing has been created yet | The accent button that creates the first one |
| Filtered to nothing | There is data; the current filter excludes all of it | Clear the filter — and show what the filter is |
| Nothing to report | Empty is the good outcome — no open findings | No action. Say so plainly |
The first-run empty state is the only one that gets an accent button — it is the only one where creating something is what the user came to do. “Filtered to nothing” gets a ghost button, because the fix is to undo, not to create.
An empty state is body text, not an illustration. The system’s answer to a blank screen is a diagram or nothing, never a mascot or a spot illustration of a person holding a magnifying glass.
“No results” while a request is still in flight is a lie the user acts on. A region is in the loading state until the response arrives, and only then resolves to empty, populated, or error. Empty is a result, not a default.
Loading states
Two mechanisms, chosen by what you know about the shape of what is coming:
| Use | Why | |
|---|---|---|
| Skeleton | You know the layout — a table of rows, a card grid, a KPI row | The layout does not jump when content arrives |
| Spinner | You know nothing — an action in flight, an indeterminate wait | There is no shape to promise |
Prefer the skeleton. A spinner in the middle of a region that is about to become a table tells the user nothing and then reflows the page underneath them.
A skeleton is midnight-2 blocks at the real dimensions of the content,
with the real gaps and the real number of rows where that is known. It carries a
100ms shimmer at --duration-standard, and it does not shimmer at all under
prefers-reduced-motion: reduce — it simply sits there as static blocks.
Timing rules, so the loading state does not become its own flicker:
- Under 200ms — show nothing. A skeleton that appears and vanishes is worse than a brief pause.
- 200ms to 10s — skeleton or spinner.
- Over 10s — a determinate
pj-progressbar with a count (“3 of 12 checks”), or the state moves to a background job with its own row in the list.
Every loading region carries role="status" and an accessible label, or a
screen-reader user gets silence where a sighted user gets motion. Never animate
a skeleton in a way that hides that the operation has stalled — a shimmer that
runs for two minutes says “working” when the truth is “stuck”.
Error pages
Full-page errors — 404 and 500 — are the brand’s worst-case first impression, so they use the same page shell and the same voice as everything else. No apology paragraph, no cartoon, no error code as a headline.
| 404 | 500 | |
|---|---|---|
| Headline | “That page does not exist” | “Something failed on our side” |
| Body | What might have happened, in one sentence | What we know, and whether it is being worked on |
| Action | Back to a real destination + search | Retry, and a route to a status page or support |
| Blame | The link, not the reader | Us, explicitly |
The code (404, 500) appears as an overline above the headline, in the muted
foreground — findable when someone is reporting the problem, never the loudest
thing on the page.
req_8f2c14.A 500 says what did not happen. “Nothing was deployed” is the sentence the reader needs; “an unexpected error occurred” is the sentence they cannot act on. If the outcome is genuinely unknown, say that instead — it is a different fact and it changes what they do next.
Inline errors
Not every failure takes the page. A failure scoped to one region stays in that region, so the rest of the screen remains usable:
- A field fails with
pj-input--errorand a message below it — never a tooltip, never colour on the border alone. - A panel fails with a
pj-alert--dangerinside the panel and a retry control, while the surrounding page keeps working. - A background job fails into a row in the activity feed with a
pj-status --errdot and the word “Failed”.
Say what is true, why, and the one thing to do. Use a skeleton wherever the layout is known. Label every loading region for screen readers. Tell a reader what did not happen when something failed.
Show “no results” before the response arrives, put an accent button on a “filtered to nothing” state, headline an error with its status code, or let a region-scoped failure take the whole page.
8 - Accessibility
Accessibility appears throughout this system as measurements — contrast ratios on Colour, the 44px touch floor on Responsive, the focus trap on modals. This page covers what those cannot: order, announcement, and focus — the properties that only exist once components are composed into a screen.
The target is WCAG 2.2 Level AA.
Reading and focus order
There is one order, and the DOM defines it. Keyboard order, screen-reader order, and visual order must agree.
That makes source order a layout constraint, not a detail: write regions in the order they should be read, and let CSS place them. The page shell is therefore authored as skip link → header → sidebar → main → complementary, and Grid puts the sidebar on the left.
Never use order, row-reverse, or grid-area to change the sequence a
reader encounters. It moves the pixels and leaves the keyboard behind, which
is exactly the bug that a
stacked mobile layout exposes:
if the DOM is already in reading order, the collapse to one column is free.
Never set a positive tabindex. tabindex="0" puts an element in the
natural order and tabindex="-1" makes it programmatically focusable; anything
above zero creates a second, competing order.
Skip links
Every page begins with a skip link — the first focusable element in the DOM, visually hidden until focused, then rendered as a normal control against the midnight header.
One skip link per major landmark the keyboard would otherwise have to traverse. For the page shell that is two: Skip to content and Skip to navigation. A sidebar of thirty items in front of the content is thirty tab stops on every single page.
<a class="pj-skip" href="#main">Skip to content</a>
<a class="pj-skip" href="#nav">Skip to navigation</a>
.pj-skip {
position: absolute;
left: var(--space-4);
top: calc(-1 * var(--space-9)); /* off-screen, still focusable */
z-index: 100;
padding: var(--space-2) var(--space-4);
min-height: 44px;
background: var(--color-primary);
color: #fff;
border-radius: var(--radius-md);
transition: top var(--duration-standard) var(--ease-out);
}
.pj-skip:focus { top: var(--space-4); }
The target must be able to receive focus: <main id="main" tabindex="-1">.
Without it, some browsers move the viewport but leave focus at the top of the
document, and the next Tab lands back in the navigation.
Landmarks
The page shell maps onto landmarks one-to-one. Use the elements, not role
attributes on divs:
| Region | Element | Notes |
|---|---|---|
| Sidebar | <nav aria-label="Sections"> | The label distinguishes it from any other nav |
| Header | <header> | The page title inside it is the <h1> |
| Content | <main id="main" tabindex="-1"> | Exactly one per page |
| Secondary panel | <aside aria-labelledby="…"> | Points at the panel’s own heading |
| Footer | <footer> |
Two nav elements on a page need two different aria-labels. An unlabelled
pair is announced as “navigation, navigation”.
Headings are a real hierarchy, not a type ramp: one <h1> per page, no skipped
levels. If a heading is the wrong size, restyle it — do not renumber it.
Focus visibility
Focus is always visible. The system never sets outline: none without
replacing it with something at least as loud.
The focus ring is a 2px orange-10 (#cc4528) outline at 2px offset, drawn
with :focus-visible so a mouse click on a button does not leave a ring behind
while keyboard focus still does.
:focus-visible {
outline: 2px solid var(--color-accent-solid);
outline-offset: 2px;
}
The offset matters: at 0 the ring sits on the control’s own border and
disappears against an outline button, whose border is already the accent.
Moving focus
Focus moves only in response to something the user did, and it always ends somewhere they can see.
| Event | Focus goes to |
|---|---|
| Modal opens | The modal’s first focusable element, or its heading |
| Modal closes | The control that opened it |
| Drawer or menu opens | Its first item |
| A route changes | The new page’s <h1>, or <main> |
| A validation error appears on submit | The first invalid field |
| A row is deleted | The next row, or the region’s heading if it was the last |
Modals and drawers trap focus while open and close on Esc. Everything else
must not trap: a Tab inside a table, a card, or a tab strip leaves it again.
Deleting a row without moving focus leaves it on a detached node, which drops the keyboard user back at the top of the document — a silent, common, and entirely avoidable regression.
ARIA for the composed patterns
The components page shows tabs, navigation, status, and overlays as markup. This is what each owes assistive technology once it is real.
Tabs
pj-tabs is a tab strip, so it needs the full pattern: role="tablist" on the
container, role="tab" with aria-selected on each tab, role="tabpanel" with
aria-labelledby on each panel. Arrow keys move between tabs; Tab leaves the
strip entirely — only the selected tab is in the tab order.
If arrow-key navigation is not implemented, do not use the tab roles. A
tablist that does not respond to arrow keys is worse than a list of links,
because it has promised behaviour it does not have.
Navigation
The current page in a pj-sidebar or pj-navbar is marked
aria-current="page". The 600-weight and the accent underline are the visual
half of that statement; aria-current is the other half, and neither substitutes
for the other.
Status and semantic colour
Every pj-status dot is paired with its text label. That rule appears on the
components page as a visual-design rule; it is also the accessibility rule, and
it is why the system has no bare-dot variant to reach for.
Live regions announce changes that happen without a user action:
| Region | Attribute |
|---|---|
| Loading region | role="status" + aria-label |
| Toast, non-urgent update | aria-live="polite" |
| Validation summary, failure | aria-live="assertive" |
| Progress bar | role="progressbar" with aria-valuenow / min / max |
Reserve assertive for things that interrupt correctly. An assertive region
that fires on every keystroke makes the page unusable with a screen reader.
Icons
An icon that repeats its label is aria-hidden="true". An icon that is the
control carries the accessible name:
<button aria-label="Security settings">. See
Icons.
Tables
A pj-table uses <th scope="col">, and scope="row" on the identifying
column. Sortable headers carry aria-sort="ascending" | "descending" | "none",
updated when the sort changes — the arrow glyph alone is not announced.
Multi-level headers need
scope="colgroup" on the group row.
Motion and preference
The motion rules are an
accessibility requirement, not a stylistic one. Everything animated is wrapped in
prefers-reduced-motion: no-preference, or neutralised under reduce.
Skeletons stop shimmering under reduce but stay visible — the loading state is
information, and removing it would remove the information along with the motion.
What to check before shipping a screen
- Tab from the address bar to the end. Focus is visible at every stop and the order matches the visual order.
- The skip link is the first stop, and using it lands focus in
<main>. - Nothing is reachable only by mouse; nothing is reachable only by keyboard.
- Every image, icon button, and form control has an accessible name.
- Zoom to 200% and to 400%. Nothing is clipped; nothing scrolls in two directions at once.
- Turn colour off — greyscale the screen. Every state is still distinguishable.
- Contrast every new pairing against its actual surface, not against white.
Write regions in reading order and place them with CSS. Return focus to the
trigger when an overlay closes. Pair every colour signal with text. Label both
nav landmarks.
Use a positive tabindex, reorder content with order or grid-area, remove a
focus outline without replacing it, or apply tab roles to a strip that does not
handle arrow keys.