Skip to content
Open
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
135 changes: 135 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Ourocode Design System

## 1. Atmosphere & Identity

Ourocode feels like a quiet terminal command center: dense enough for real work, but deliberate about hierarchy so the next action is visible. The signature is a productized terminal surface with mono typography, restrained mint accents, and verification evidence treated as first-class UI.

## 2. Color

### Palette

| Role | Token | Light | Dark | Usage |
| --- | --- | --- | --- | --- |
| Surface/primary | `--bg` | `#fafaf7` | `#0a0a0b` | Page background |
| Surface/panel | `--panel` | `#ffffff` | `#111111` | Main panels and terminal shells |
| Surface/secondary | `--panel-2` | `#f1f2ed` | `#1a1a1d` | Secondary panels, selected surfaces |
| Text/primary | `--ink` | `#111513` | `#e2e2e5` | Headlines and terminal text |
| Text/secondary | `--muted` | `#61655f` | `#a8a8a8` | Captions, hints, inactive rows |
| Border/default | `--line` | `rgba(17, 21, 19, 0.14)` | `rgba(226, 226, 229, 0.18)` | Dividers and panel outlines |
| Accent/primary | `--mint` | `#087c58` | `#66d9c2` | Primary action, selected state, healthy checks |
| Accent/warning | `--amber` | `#a96c00` | `#e8a45c` | Workflow/interview emphasis |
| Status/error | `--rose` | `#b84646` | `#f87171` | Failed checks or destructive actions |
| Shadow | `--shadow` | `rgba(17, 21, 19, 0.16)` | `rgba(0, 0, 0, 0.42)` | Elevated browser/tool surfaces |

### Rules

- Use mint for active verification and primary affordances only.
- Use amber for interview/workflow context, never as a general accent.
- Raw terminal text may remain monochrome; color is reserved for state and action.

## 3. Typography

### Scale

| Level | Size | Weight | Line Height | Tracking | Usage |
| --- | --- | --- | --- | --- | --- |
| Display | `clamp(3rem, 10vw, 8rem)` | 700 | 0.9 | 0 | Product hero only |
| H1 | `40px` | 700 | 1.1 | 0 | Tool/page title |
| H2 | `28px` | 700 | 1.2 | 0 | Section heading |
| H3 | `18px` | 700 | 1.35 | 0 | Panel heading |
| Body | `15px` | 400 | 1.6 | 0 | Default copy |
| Body/sm | `13px` | 400 | 1.5 | 0 | Metadata, helper text |
| Caption | `12px` | 700 | 1.4 | 0 | Labels and status chips |
| Terminal | `14px` | 400 | 1.45 | 0 | Rendered TUI frame text |

### Font Stack

- Primary and mono: `"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace`.

### Rules

- Letter spacing is 0 unless a pre-existing terminal label requires uppercase density.
- Body text must not drop below 13px in web QA tools.

## 4. Spacing & Layout

### Base Unit

All spacing derives from 4px.

| Token | Value | Usage |
| --- | --- | --- |
| `--space-1` | `4px` | Tight inline gaps |
| `--space-2` | `8px` | Compact row gaps |
| `--space-3` | `12px` | Control padding |
| `--space-4` | `16px` | Panel inner spacing |
| `--space-5` | `20px` | Dense section spacing |
| `--space-6` | `24px` | Tool shell spacing |
| `--space-8` | `32px` | Major group spacing |
| `--space-10` | `40px` | Page bands |

### Grid

- Max content width: `1440px`.
- Breakpoints: mobile `< 760px`, tablet `760px`, desktop `1100px`.
- QA tool layout: control rail plus terminal preview on desktop; stacked controls on mobile.

### Rules

- Terminal preview dimensions are stable and must not resize when frame content changes.
- Toolbars wrap before text overflows.

## 5. Components

### Terminal Shell

- **Structure**: title bar, status dots, fixed-height preformatted frame area.
- **Variants**: dark, light, compact.
- **Spacing**: `--space-4` and `--space-6`.
- **States**: default, playing, paused, empty, error.
- **Accessibility**: labelled region; text remains selectable; no color-only status.
- **Motion**: frame changes use opacity only.

### Scenario Rail

- **Structure**: list of frame buttons with title, duration, and check count.
- **Variants**: selected, failed, warning.
- **Spacing**: `--space-2` and `--space-3`.
- **States**: default, hover, active, focus, disabled.
- **Accessibility**: native buttons with `aria-current` on the selected scenario.
- **Motion**: hover color transition under 160ms.

### Evidence Panel

- **Structure**: key-value checks, viewport controls, export/copy actions.
- **Variants**: pass, warning, blocked.
- **Spacing**: `--space-3` and `--space-4`.
- **States**: default, loading, error.
- **Accessibility**: status text is explicit, not icon-only.
- **Motion**: none beyond control feedback.

## 6. Motion & Interaction

| Type | Duration | Easing | Usage |
| --- | --- | --- | --- |
| Micro | `120ms` | `ease` | Button and row hover |
| Standard | `180ms` | `ease-out` | Frame opacity change |

Rules:

- Animate only opacity and transform.
- Respect `prefers-reduced-motion` by disabling auto-play transitions.
- Keyboard QA must support play/pause, previous/next, and direct scenario selection.

## 7. Depth & Surface

### Strategy

Mixed: web documentation uses subtle shadows for elevated browser-like surfaces; terminal tool surfaces use borders plus tonal shifts.

| Level | Value | Usage |
| --- | --- | --- |
| Subtle border | `1px solid var(--line)` | Panels, controls |
| Elevated shadow | `0 1.2rem 3rem var(--shadow)` | Main terminal shell |
| Tonal shift | `var(--panel-2)` | Selected rows and secondary controls |

180 changes: 180 additions & 0 deletions docs/tui-qa/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@

const state = {
frames: [],
index: 0,
playing: false,
timer: 0,
columns: window.innerWidth <= 640 ? 80 : 100,
};

const elements = {
play: document.querySelector("#play"),
prev: document.querySelector("#prev"),
next: document.querySelector("#next"),
copy: document.querySelector("#copy"),
terminal: document.querySelector("#terminal"),
title: document.querySelector("#frame-title"),
meta: document.querySelector("#frame-meta"),
status: document.querySelector("#qa-status"),
scenarios: document.querySelector("#scenarios"),
checks: document.querySelector("#checks"),
viewportButtons: document.querySelectorAll("[data-width]"),
};


function validateFrames(value) {
if (!Array.isArray(value)) {
throw new Error("frames.json must be an array");
}

for (const frame of value) {
if (!frame || typeof frame.title !== "string" || typeof frame.text !== "string") {
throw new Error("frames.json contains an invalid frame");
}
if (!Number.isInteger(frame.duration_ms) || frame.duration_ms <= 0) {
throw new Error(`invalid duration for frame: ${frame.title}`);
}
if (!Array.isArray(frame.checks)) {
throw new Error(`invalid checks for frame: ${frame.title}`);
}
}

return value;
}

async function loadFrames() {
try {
const response = await fetch("./frames.json", { cache: "no-store" });
if (!response.ok) {
throw new Error(`frames request failed: ${response.status}`);
}

state.frames = validateFrames(await response.json());
elements.status.textContent = `${state.frames.length} live frames loaded`;
renderScenarioList();
renderFrame();
} catch (error) {
elements.status.textContent = `Live frame load failed: ${error.message}`;
elements.terminal.textContent = "Start with: mix run --no-start scripts/tui_qa_server.exs";
}
}

function renderScenarioList() {
elements.scenarios.replaceChildren(
...state.frames.map((frame, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "scenario";
button.setAttribute("aria-current", String(index === state.index));
button.innerHTML = `<strong>${frame.title}</strong><span>${frame.duration_ms}ms · ${frame.checks.length} checks</span>`;
button.addEventListener("click", () => selectFrame(index));
return button;
}),
);
}

function renderFrame() {
const frame = state.frames[state.index];
if (!frame) {
return;
}

elements.title.textContent = frame.title;
elements.meta.textContent = `${state.index + 1}/${state.frames.length} · ${frame.duration_ms}ms`;
elements.terminal.textContent = frame.text;
elements.terminal.className = `cols-${state.columns}`;
elements.checks.replaceChildren(
...frame.checks.map((check) => {
const item = document.createElement("span");
item.className = "check";
item.dataset.status = check.status;
item.textContent = `${check.status}: ${check.label}`;
return item;
}),
);
renderScenarioList();
syncViewportButtons();
}

function selectFrame(index) {
state.index = Math.max(0, Math.min(index, state.frames.length - 1));
renderFrame();
scheduleNext();
}

function nextFrame() {
selectFrame((state.index + 1) % state.frames.length);
}

function prevFrame() {
selectFrame((state.index - 1 + state.frames.length) % state.frames.length);
}

function togglePlay() {
state.playing = !state.playing;
elements.play.textContent = state.playing ? "Pause" : "Play";
scheduleNext();
}

function scheduleNext() {
window.clearTimeout(state.timer);
if (!state.playing || state.frames.length === 0) {
return;
}

const duration = state.frames[state.index].duration_ms || 1000;
state.timer = window.setTimeout(nextFrame, duration);
}

async function copyFrame() {
const frame = state.frames[state.index];
if (!frame) {
return;
}

try {
await navigator.clipboard.writeText(frame.text);
elements.status.textContent = "Current frame copied";
} catch (_error) {
elements.status.textContent = "Clipboard unavailable; select terminal text manually";
}
}

function syncViewportButtons() {
for (const button of elements.viewportButtons) {
button.setAttribute("aria-current", String(button.dataset.width === String(state.columns)));
}
}

function setColumns(width) {
state.columns = Number(width);
syncViewportButtons();
renderFrame();
}

elements.play.addEventListener("click", togglePlay);
elements.prev.addEventListener("click", prevFrame);
elements.next.addEventListener("click", nextFrame);
elements.copy.addEventListener("click", copyFrame);

for (const button of elements.viewportButtons) {
button.addEventListener("click", () => setColumns(button.dataset.width));
}

document.addEventListener("keydown", (event) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return;
}

if (event.key === " ") {
event.preventDefault();
togglePlay();
} else if (event.key === "ArrowRight") {
nextFrame();
} else if (event.key === "ArrowLeft") {
prevFrame();
}
});

loadFrames();

65 changes: 65 additions & 0 deletions docs/tui-qa/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ourocode TUI QA</title>
<meta
name="description"
content="Browser-based QA harness for replaying Ourocode terminal frames and checking TUI states."
/>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main class="qa-shell">
<section class="control-panel" aria-labelledby="qa-title">
<div class="intro">
<p class="label">TUI QA</p>
<h1 id="qa-title">Ourocode frame review</h1>
<p>
Replay generated terminal states, inspect wrapping, and collect visible checks before recording or manual QA.
</p>
</div>

<div class="toolbar" aria-label="Playback controls">
<button type="button" id="play">Play</button>
<button type="button" id="prev">Prev</button>
<button type="button" id="next">Next</button>
<button type="button" id="copy">Copy frame</button>
</div>

<div class="viewport-controls" aria-label="Viewport width">
<button type="button" data-width="80">80 cols</button>
<button type="button" data-width="100" aria-current="true">100 cols</button>
<button type="button" data-width="120">120 cols</button>
</div>

<div class="scenario-list" id="scenarios" aria-label="Scenarios"></div>
</section>

<section class="preview-panel" aria-labelledby="frame-title">
<div class="terminal-card">
<div class="terminal-bar">
<span class="dot danger"></span>
<span class="dot warn"></span>
<span class="dot ok"></span>
<strong id="frame-title">Loading</strong>
<span id="frame-meta"></span>
</div>
<pre id="terminal" tabindex="0" aria-label="Rendered terminal frame"></pre>
</div>

<aside class="evidence" aria-label="Frame evidence">
<div>
<span class="label">Status</span>
<strong id="qa-status">Loading frames</strong>
</div>
<div id="checks" class="checks"></div>
</aside>
</section>
</main>

<script src="./app.js"></script>
</body>
</html>

Loading