diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..0ad0e1f
--- /dev/null
+++ b/DESIGN.md
@@ -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 |
+
diff --git a/docs/tui-qa/app.js b/docs/tui-qa/app.js
new file mode 100644
index 0000000..e4a8fe0
--- /dev/null
+++ b/docs/tui-qa/app.js
@@ -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 = `${frame.title}${frame.duration_ms}ms · ${frame.checks.length} checks`;
+ 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();
+
diff --git a/docs/tui-qa/index.html b/docs/tui-qa/index.html
new file mode 100644
index 0000000..ed385ff
--- /dev/null
+++ b/docs/tui-qa/index.html
@@ -0,0 +1,65 @@
+
+
+
+
+
+ Ourocode TUI QA
+
+
+
+
+
+
+
+
TUI QA
+
Ourocode frame review
+
+ Replay generated terminal states, inspect wrapping, and collect visible checks before recording or manual QA.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/tui-qa/styles.css b/docs/tui-qa/styles.css
new file mode 100644
index 0000000..5c1489c
--- /dev/null
+++ b/docs/tui-qa/styles.css
@@ -0,0 +1,300 @@
+:root {
+ color-scheme: light;
+ --bg: #fafaf7;
+ --panel: #ffffff;
+ --panel-2: #f1f2ed;
+ --ink: #111513;
+ --muted: #61655f;
+ --line: rgba(17, 21, 19, 0.14);
+ --mint: #087c58;
+ --amber: #a96c00;
+ --rose: #b84646;
+ --shadow: rgba(17, 21, 19, 0.16);
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 20px;
+ --space-6: 24px;
+ --space-8: 32px;
+ --space-10: 40px;
+ font-family:
+ "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
+ monospace;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ min-width: 320px;
+ background:
+ linear-gradient(180deg, rgba(8, 124, 88, 0.09), transparent 34rem),
+ var(--bg);
+ color: var(--ink);
+}
+
+body {
+ margin: 0;
+}
+
+button {
+ min-height: 38px;
+ border: 1px solid var(--line);
+ padding: 0 var(--space-3);
+ background: var(--panel);
+ color: var(--ink);
+ font: inherit;
+ cursor: pointer;
+ transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
+}
+
+button:hover,
+button[aria-current="true"] {
+ border-color: rgba(8, 124, 88, 0.42);
+ background: rgba(8, 124, 88, 0.08);
+}
+
+button:active {
+ transform: translateY(1px);
+}
+
+button:focus-visible,
+pre:focus-visible {
+ outline: 2px solid var(--mint);
+ outline-offset: 2px;
+}
+
+.qa-shell {
+ display: grid;
+ grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
+ gap: var(--space-6);
+ width: min(1440px, 100%);
+ min-height: 100dvh;
+ margin: 0 auto;
+ padding: var(--space-6);
+}
+
+.control-panel,
+.preview-panel,
+.evidence,
+.terminal-card {
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.82);
+}
+
+.control-panel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-5);
+ padding: var(--space-5);
+}
+
+.intro h1 {
+ margin: var(--space-2) 0 var(--space-3);
+ font-size: 40px;
+ line-height: 1.1;
+}
+
+.intro p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 15px;
+ line-height: 1.6;
+}
+
+.label {
+ color: var(--mint);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.toolbar,
+.viewport-controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+
+.scenario-list {
+ display: grid;
+ gap: var(--space-2);
+}
+
+.scenario {
+ display: grid;
+ gap: var(--space-1);
+ width: 100%;
+ min-height: 64px;
+ text-align: left;
+}
+
+.scenario span {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.preview-panel {
+ display: grid;
+ grid-template-rows: minmax(0, 1fr) auto;
+ gap: var(--space-4);
+ padding: var(--space-5);
+}
+
+.terminal-card {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ min-width: 0;
+ min-height: 560px;
+ overflow: hidden;
+ box-shadow: 0 1.2rem 3rem var(--shadow);
+}
+
+.terminal-bar {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-height: 48px;
+ border-bottom: 1px solid var(--line);
+ padding: 0 var(--space-4);
+ background: var(--panel-2);
+}
+
+.terminal-bar strong {
+ margin-left: var(--space-2);
+}
+
+.terminal-bar span:last-child {
+ margin-left: auto;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+}
+
+.danger {
+ background: var(--rose);
+}
+
+.warn {
+ background: var(--amber);
+}
+
+.ok {
+ background: var(--mint);
+}
+
+pre {
+ width: 100%;
+ min-height: 0;
+ margin: 0;
+ overflow: auto;
+ padding: var(--space-5);
+ background: var(--panel);
+ color: var(--ink);
+ font-size: 14px;
+ line-height: 1.45;
+ white-space: pre;
+}
+
+pre.cols-80 {
+ max-width: 80ch;
+}
+
+pre.cols-100 {
+ max-width: 100ch;
+}
+
+pre.cols-120 {
+ max-width: 120ch;
+}
+
+.evidence {
+ display: grid;
+ grid-template-columns: minmax(180px, 260px) minmax(0, 1fr);
+ gap: var(--space-4);
+ padding: var(--space-4);
+}
+
+.evidence strong {
+ display: block;
+ margin-top: var(--space-1);
+}
+
+.checks {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+
+.check {
+ border: 1px solid var(--line);
+ padding: var(--space-2) var(--space-3);
+ background: var(--panel-2);
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.check[data-status="pass"] {
+ color: var(--mint);
+}
+
+.check[data-status="trace"] {
+ color: var(--amber);
+}
+
+@media (max-width: 900px) {
+ .qa-shell {
+ grid-template-columns: 1fr;
+ padding: var(--space-4);
+ }
+
+ .control-panel {
+ order: 2;
+ }
+
+ .preview-panel {
+ order: 1;
+ }
+}
+
+@media (max-width: 640px) {
+ .qa-shell {
+ padding: var(--space-3);
+ }
+
+ .intro h1 {
+ font-size: 32px;
+ }
+
+ .evidence {
+ grid-template-columns: 1fr;
+ }
+
+ .terminal-card {
+ min-height: 420px;
+ }
+
+ pre {
+ font-size: 11px;
+ line-height: 1.45;
+ padding: var(--space-4);
+ }
+
+ pre.cols-100,
+ pre.cols-120 {
+ max-width: 80ch;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ * {
+ transition: none;
+ }
+}
+
diff --git a/lib/ourocode/terminal/qa_frames.ex b/lib/ourocode/terminal/qa_frames.ex
new file mode 100644
index 0000000..25988fb
--- /dev/null
+++ b/lib/ourocode/terminal/qa_frames.ex
@@ -0,0 +1,135 @@
+defmodule Ourocode.Terminal.QaFrames do
+ @moduledoc false
+
+ alias Ourocode.Terminal.{Tui, WorkspaceModel}
+
+ @base_frame """
+ +-- ourocode terminal region=header_status x=0 y=0 w=88 h=5
+ | app=ourocode status=healthy runtime=ready session=demo
+ | project=/Users/dev/Project/ourocode
+ | cwd=/Users/dev/Project/ourocode
+ +--
+ +-- Parent/Child Sessions region=runtime_panes layout=terminal_split
+ | [parent-region] x=0 y=0 w=80 h=8
+ | parent empty
+ | [child-region] x=0 y=9 w=80 h=12
+ | child empty
+ +--
+ +-- Plugin Status (1) region=plugin_status x=0 y=18 w=80 h=4
+ | status=ready visible=1
+ | [BUILT-IN] Ouroboros workflows - Official plugin - loaded
+ +--
+ +-- State
+ | surface=terminal focus=task_prompt layout=compact
+ | runtime=ready stream=streaming journal=ready
+ | queued=0 replayable?=true connections=ready
+ | hooks=idle events=0
+ +--
+ """
+
+ @spec all() :: [map()]
+ def all do
+ [
+ frame("Start", 950, "", [], %{}),
+ frame("Open guided work", 850, "ooo", [], %{}),
+ frame("Type a goal", 750, "ooo pm design plugin onboarding", [], %{}),
+ frame("Answer the interview", 1_450, "", ["you> ooo pm design plugin onboarding", "task: first picker ready"], %{
+ interview_block: pm_picker_block(),
+ wonder_focus: true
+ }),
+ frame("Continue safely", 1_200, "", ["you> ooo pm design plugin onboarding", "task: answer accepted"], %{
+ interview_block: accepted_block()
+ }),
+ frame("Track active work", 1_250, "", [], %{workspace: agents_workspace()}),
+ frame("Auto is approval-gated", 1_350, "", [], %{
+ workspace: WorkspaceModel.workflow_start("ooo auto improve onboarding")
+ })
+ ]
+ end
+
+ @spec all_json() :: String.t()
+ def all_json, do: Ourocode.Json.encode!(all())
+
+ defp frame(title, duration_ms, prompt, activity, opts) do
+ %{
+ title: title,
+ duration_ms: duration_ms,
+ text:
+ @base_frame
+ |> Tui.frame_lines(activity, prompt, 100, 24, Map.put_new(opts, :auth, {"model: codex cli", :ok}))
+ |> Enum.join("\n"),
+ checks: checks_for(title)
+ }
+ end
+
+ defp pm_picker_block do
+ {"INTERVIEW",
+ [
+ "Round 1 · PM interview",
+ "What outcome should this PM interview produce?",
+ ">> [1] Define the target user - anchor the PM brief around the primary audience",
+ " [2] Define the activation outcome - focus on the proof moment",
+ " [3] Audit the existing flow - start from the current path"
+ ], "Choose an option or type a custom answer"}
+ end
+
+ defp accepted_block do
+ {"INTERVIEW",
+ [
+ {"Round accepted", :strong},
+ {"Question What outcome should this PM interview produce?", :warn},
+ {"Answer Define the target user", :strong},
+ {"Next answer sent; generating choices", :dim},
+ :rule,
+ {"■■⬝ building next answer choices (~6s) - no input needed; Esc pauses", :dim},
+ {"No input needed; choices will appear automatically", :dim}
+ ], "type your answer + Enter Esc pause"}
+ end
+
+ defp agents_workspace do
+ %{
+ kind: "agents",
+ title: "Agents",
+ status: "running",
+ selected: "agent:pm",
+ records: [
+ %{
+ id: "agent:pm",
+ title: "PM interview",
+ state: "waiting",
+ health: "live",
+ fields: %{phase: "generating answer choices", progress: "answer accepted"}
+ },
+ %{
+ id: "agent:verify",
+ title: "Health checks",
+ state: "ready",
+ health: "ready",
+ fields: %{phase: "ready", progress: "17 checks passed"}
+ }
+ ],
+ detail: %{
+ id: "agent:pm",
+ title: "PM interview",
+ state: "waiting",
+ fields: %{
+ phase: "generating answer choices",
+ progress: "answer accepted",
+ controls: "Esc pause, /cancel, /sessions"
+ }
+ },
+ actions: [],
+ shortcuts: ["Up/Dn rows", "Enter row action", "type to compose"],
+ next: "Watch active work or type a new command."
+ }
+ end
+
+ defp checks_for(title) do
+ [
+ %{label: "frame generated", status: "pass"},
+ %{label: "terminal text present", status: "pass"},
+ %{label: title |> String.downcase() |> String.replace(" ", "-"), status: "trace"}
+ ]
+ end
+end
+
diff --git a/scripts/tui_qa_frames.exs b/scripts/tui_qa_frames.exs
new file mode 100644
index 0000000..ef85656
--- /dev/null
+++ b/scripts/tui_qa_frames.exs
@@ -0,0 +1 @@
+IO.write(Ourocode.Terminal.QaFrames.all_json())
diff --git a/scripts/tui_qa_server.exs b/scripts/tui_qa_server.exs
new file mode 100644
index 0000000..4590461
--- /dev/null
+++ b/scripts/tui_qa_server.exs
@@ -0,0 +1,128 @@
+defmodule Ourocode.TuiQaServer do
+ @moduledoc false
+
+ @root File.cwd!()
+ @ui_dir Path.join(@root, "docs/tui-qa")
+
+ def run(argv) do
+ port = port_from(argv)
+ {:ok, socket} = :gen_tcp.listen(port, [:binary, packet: :raw, active: false, reuseaddr: true])
+ url = "http://127.0.0.1:#{port}/"
+ IO.puts("TUI QA server: #{url}")
+ IO.puts("Frames: #{url}frames.json")
+ accept(socket)
+ end
+
+ defp port_from(argv) do
+ case Enum.find_index(argv, &(&1 == "--port")) do
+ nil -> env_port()
+ index -> argv |> Enum.at(index + 1, "") |> parse_port(env_port())
+ end
+ end
+
+ defp env_port do
+ System.get_env("TUI_QA_PORT", "5199") |> parse_port(5199)
+ end
+
+ defp parse_port(value, fallback) do
+ case Integer.parse(to_string(value)) do
+ {port, ""} when port > 0 and port < 65_536 -> port
+ _other -> fallback
+ end
+ end
+
+ defp accept(socket) do
+ {:ok, client} = :gen_tcp.accept(socket)
+ serve(client)
+ accept(socket)
+ end
+
+ defp serve(client) do
+ request = read_request(client)
+ {path, _query} = request_path(request)
+ {status, headers, body} = response(path)
+ :ok = :gen_tcp.send(client, http_response(status, headers, body))
+ :gen_tcp.close(client)
+ rescue
+ _exception ->
+ :gen_tcp.close(client)
+ end
+
+ defp read_request(client) do
+ case :gen_tcp.recv(client, 0, 2_000) do
+ {:ok, data} -> data
+ {:error, _reason} -> ""
+ end
+ end
+
+ defp request_path(request) do
+ request
+ |> String.split("\r\n", parts: 2)
+ |> List.first("")
+ |> String.split(" ")
+ |> case do
+ [_method, target, _version] -> URI.parse(target)
+ _other -> URI.parse("/")
+ end
+ |> then(&{normalize_path(&1.path), &1.query})
+ end
+
+ defp normalize_path(nil), do: "/"
+ defp normalize_path(""), do: "/"
+ defp normalize_path(path), do: URI.decode(path)
+
+ defp response("/health"), do: {200, [{"content-type", "text/plain; charset=utf-8"}], "ok"}
+
+ defp response("/frames.json") do
+ {200, [{"content-type", "application/json; charset=utf-8"}], Ourocode.Terminal.QaFrames.all_json()}
+ end
+
+ defp response(path) do
+ file_path = static_path(path)
+
+ if File.regular?(file_path) do
+ {200, [{"content-type", content_type(file_path)}], File.read!(file_path)}
+ else
+ {404, [{"content-type", "text/plain; charset=utf-8"}], "not found"}
+ end
+ end
+
+ defp static_path("/"), do: Path.join(@ui_dir, "index.html")
+
+ defp static_path(path) do
+ safe =
+ path
+ |> String.trim_leading("/")
+ |> Path.expand("/")
+ |> Path.relative_to("/")
+
+ Path.join(@ui_dir, safe)
+ end
+
+ defp content_type(path) do
+ case Path.extname(path) do
+ ".html" -> "text/html; charset=utf-8"
+ ".css" -> "text/css; charset=utf-8"
+ ".js" -> "text/javascript; charset=utf-8"
+ ".json" -> "application/json; charset=utf-8"
+ ".svg" -> "image/svg+xml"
+ ".png" -> "image/png"
+ _other -> "application/octet-stream"
+ end
+ end
+
+ defp http_response(status, headers, body) do
+ body = IO.iodata_to_binary(body)
+ reason = if status == 200, do: "OK", else: "Not Found"
+
+ header_lines =
+ headers
+ |> Enum.concat([{"content-length", byte_size(body)}, {"connection", "close"}])
+ |> Enum.map(fn {key, value} -> "#{key}: #{value}\r\n" end)
+
+ ["HTTP/1.1 #{status} #{reason}\r\n", header_lines, "\r\n", body]
+ end
+end
+
+Ourocode.TuiQaServer.run(System.argv())
+
diff --git a/test/ourocode/terminal/qa_frames_test.exs b/test/ourocode/terminal/qa_frames_test.exs
new file mode 100644
index 0000000..2c4514f
--- /dev/null
+++ b/test/ourocode/terminal/qa_frames_test.exs
@@ -0,0 +1,22 @@
+defmodule Ourocode.Terminal.QaFramesTest do
+ use ExUnit.Case, async: true
+
+ alias Ourocode.Terminal.QaFrames
+
+ test "builds browser QA frames from current TUI renderer" do
+ frames = QaFrames.all()
+
+ assert length(frames) >= 6
+ assert Enum.all?(frames, &is_binary(&1.text))
+ assert Enum.any?(frames, &(&1.title == "Answer the interview"))
+ assert Enum.any?(frames, &String.contains?(&1.text, "INTERVIEW"))
+ assert Enum.all?(frames, &(is_integer(&1.duration_ms) and &1.duration_ms > 0))
+ assert Enum.all?(frames, &(is_list(&1.checks) and &1.checks != []))
+ end
+
+ test "exports frame payload as json" do
+ assert {:ok, decoded} = Ourocode.Json.decode(QaFrames.all_json())
+ assert is_list(decoded)
+ assert decoded |> List.first() |> Map.fetch!("title") == "Start"
+ end
+end