feature matching for clean extraction

This commit is contained in:
2026-09-14 16:00:27 -03:00
parent 26f99265ca
commit aba696df79
18 changed files with 3567 additions and 306 deletions

View File

@@ -42,7 +42,7 @@ $(eval $(ARGS):;@:)
endif
.DEFAULT_GOAL := help
.PHONY: help build start stop dist docs cluster deploy component
.PHONY: help build start stop dist theme docs cluster deploy component
help: ## list targets
@grep -hE '^[a-z]+:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16
@@ -61,6 +61,11 @@ stop: ## stop a running room [<room>]
dist: ## compile the plexus UIs to single files [<room>]
bash ctrl/dist.sh $(or $(ARGS),$(ROOM))
# ── theme ──────────────────────────────────────────────────────────────────
theme: ## ad-hoc pages: scaffold, add parts, bake [new|parts|bake|check|export]
bash ctrl/theme.sh $(or $(ARGS),bake)
# ── docs ───────────────────────────────────────────────────────────────────
docs: ## documentation [serve [port]|graphs [theme]] (default serve)

58
ctrl/theme.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Bake the theme and its parts into the pages that use them.
#
# Usage:
# ./ctrl/theme.sh # bake — rewrite every generated block
# ./ctrl/theme.sh check # fail if any page is stale; changes nothing
# ./ctrl/theme.sh new [title] # a scaffold page to start from
# ./ctrl/theme.sh parts # what can be added, and the markup that adds it
# ./ctrl/theme.sh export [name...] # the contract for a subset, as one doc
#
# `export` is for handing a vetted LLM what it needs to write an ad-hoc page —
# the chosen parts, their markup, and the tokens resolved to literal values, so
# the document stands alone. Naming parts is the point: hand over everything and
# you get back a page built from Vue components that cannot run standalone.
#
# ./ctrl/theme.sh export panel split > /tmp/contract.md
#
# Call the script directly when piping; `make` echoes its recipe to stdout.
# For whole-repo context this is the wrong tool — station/tools/distill already
# flattens a tree to one budgeted document.
#
# A page that says `background: var(--bg)` and never gets `--bg` is UNSTYLED,
# not merely unbranded — the declaration is invalid at computed-value time. That
# is why every page carries a baked default, and why `check` is worth running.
#
# This exists because bake.py was reachable by no command at all: not from the
# Makefile, not from ctrl/, not from build.py. A drift check nobody runs is a
# drift check that reports nothing, and the evidence was already on disk —
# histgen's page linked /theme.css for months, was missing from the old
# hardcoded page list, and so was never baked once.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$ROOT_DIR/soleprint"
PYTHON="${PYTHON:-python3}"
case "${1:-bake}" in
bake) exec "$PYTHON" common/theme/bake.py ;;
check) exec "$PYTHON" common/theme/bake.py --check ;;
parts)
exec "$PYTHON" common/theme/bake.py --parts
;;
new)
shift
exec "$PYTHON" common/theme/bake.py --new "$@"
;;
export)
shift
exec "$PYTHON" common/theme/bake.py --export "$@"
;;
*)
echo "Unknown: $1" >&2
echo "Usage: ./ctrl/theme.sh [new [title]|parts|bake|check|export [name...]]" >&2
exit 1
;;
esac

View File

@@ -1,296 +1,411 @@
<!DOCTYPE html>
<html lang="en">
<!--
MercadoPago shunt — config UI.
Brought onto the theme: it used to carry ~30 hardcoded hexes, its own reset,
its own button and input rules, and zero var(--…), so it was the one page in
the tree that could not follow a theme at all. Everything structural now comes
from the baked parts; what is left below is this shunt's own.
A shunt serves this on its own port and cannot fetch soleprint's /theme.css,
so the theme and the parts are baked in between markers by
common/theme/bake.py. Run `make theme bake` after changing a class.
Only `base` and `panel` are baked here — this page has no split pane, so it
carries no split.css and no split.js. That is the mechanism doing its job.
-->
<html lang="en" data-theme="soleprint">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MercadoPago API (MOCK) - Configuration</title>
<!-- theme:here -->
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #111827;
color: #e5e7eb;
padding: 20px;
}
.container { max-width: 1200px; margin: 0 auto; }
header {
background: #0071f2;
color: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 24px;
}
h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 8px; }
.subtitle { opacity: 0.9; font-size: 0.875rem; }
:root {
--accent: #d4a574;
--font-mono: "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
--font-size-base: 13px;
--font-size-sm: 11px;
--font-ui: Inter, "Segoe UI", system-ui, -apple-system, Arial, sans-serif;
--label-spacing: 0.04em;
--muted: #8888a0;
--panel-border: 1px solid #2e2e38;
--panel-header-height: 36px;
--panel-radius: 6px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--status-error: #f06565;
--status-escalating: #f5a623;
--status-idle: #555568;
--status-live: #3ecf8e;
--status-processing: #4f9cf9;
--surface-0: #0d0d0f;
--surface-1: #16161a;
--surface-2: #1e1e24;
--surface-3: #2e2e38;
--text-dim: #555568;
--text-primary: #e8e8f0;
--text-secondary: #8888a0;
}
</style>
<!-- /theme:baked-defaults -->
<!-- theme:parts — generated by common/theme/bake.py; do not edit -->
<style>
/* part: base — common/theme/parts/base.css */
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
}
body {
background: var(--surface-0);
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--font-size-base);
}
/* Opt in on <html>, <body> and the top element when the page is an app shell
* that should fill the viewport. Left off, the page scrolls like a document —
* which is what most ad-hoc vein pages actually want. */
.fills {
height: 100%;
width: 100%;
}
button {
font-family: var(--font-ui);
font-size: var(--font-size-base);
color: var(--text-primary);
background: var(--surface-2);
border: var(--panel-border);
border-radius: var(--panel-radius);
padding: var(--space-1) var(--space-3);
cursor: pointer;
}
button:hover:not(:disabled) {
background: var(--surface-3);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
input,
select,
textarea {
font-family: var(--font-ui);
font-size: var(--font-size-base);
color: var(--text-primary);
background: var(--surface-0);
border: var(--panel-border);
border-radius: 4px;
padding: var(--space-1) var(--space-2);
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--surface-3);
border-radius: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
/* part: panel — common/theme/parts/panel.css */
.panel {
position: relative;
background: var(--surface-1);
border: var(--panel-border);
border-radius: var(--panel-radius);
overflow: hidden;
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: center;
gap: var(--space-2);
height: var(--panel-header-height);
padding: 0 var(--space-3);
background: var(--surface-2);
border-bottom: var(--panel-border);
flex-shrink: 0;
}
.panel-title {
font-family: var(--font-ui);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.panel-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-2);
}
.panel-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background: var(--status-idle);
}
.panel-status.idle { background: var(--status-idle); }
.panel-status.live { background: var(--status-live); }
.panel-status.processing { background: var(--status-processing); }
.panel-status.error { background: var(--status-error); }
.panel-body {
flex: 1;
overflow: auto;
padding: var(--space-2);
min-height: 0;
}
</style>
<!-- /theme:parts -->
<style>
/* This page's own, and only its own. */
body { padding: var(--space-6); }
.container { max-width: 1100px; margin: 0 auto; }
header { margin-bottom: var(--space-6); }
h1 { margin: 0; font-size: 20px; font-family: var(--font-ui); }
.subtitle { color: var(--muted); font-size: 12px; margin-top: 4px; }
.mock-badge {
display: inline-block;
background: white;
color: #0071f2;
padding: 4px 12px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
margin-left: 12px;
}
.section {
background: #1f2937;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.section-header {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 16px;
color: #f9fafb;
}
.endpoint-list { display: flex; flex-direction: column; gap: 12px; }
.endpoint-card {
background: #374151;
border: 2px solid transparent;
border-radius: 6px;
padding: 16px;
cursor: pointer;
transition: all 0.2s;
}
.endpoint-card:hover { border-color: #0071f2; background: #4b5563; }
.endpoint-card.active { border-color: #0071f2; background: #4b5563; }
.endpoint-method {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
margin-right: 8px;
}
.method-post { background: #10b981; color: white; }
.method-get { background: #3b82f6; color: white; }
.endpoint-path { font-family: monospace; font-size: 0.875rem; }
.endpoint-desc { font-size: 0.75rem; color: #9ca3af; margin-top: 6px; }
.form-group { margin-bottom: 16px; }
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 6px;
color: #f9fafb;
display: inline-block; margin-left: var(--space-2);
padding: 2px 8px; border-radius: 3px;
background: var(--status-escalating); color: var(--surface-0);
font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: var(--label-spacing, .08em); vertical-align: middle;
}
.form-input, .form-textarea, .form-select {
width: 100%;
padding: 10px 12px;
background: #374151;
border: 1px solid #4b5563;
border-radius: 6px;
color: #e5e7eb;
font-size: 0.875rem;
}
.form-textarea { min-height: 200px; font-family: monospace; }
.form-input:focus, .form-textarea:focus, .form-select:focus {
outline: none;
border-color: #0071f2;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
.panel { margin-bottom: var(--space-4); }
.lede { color: var(--muted); font-size: 12px; margin: 0 0 var(--space-3); }
.grid {
display: grid; gap: var(--space-2);
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
}
.btn-primary {
background: #0071f2;
color: white;
/* Selectable cards — one treatment, used by both lists. */
.card {
padding: var(--space-3); cursor: pointer; text-align: left;
background: var(--surface-2); border: var(--panel-border);
border-radius: var(--panel-radius);
}
.btn-primary:hover { background: #005ac1; }
.btn-secondary {
background: #4b5563;
color: #e5e7eb;
margin-left: 8px;
.card:hover:not(:disabled) { border-color: var(--accent); background: var(--surface-3); }
.card.on { border-color: var(--accent); background: var(--surface-3); }
.card-name { font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.card-desc { font-size: 11px; color: var(--muted); }
.verb {
font-size: 10px; font-weight: 600; padding: 1px 6px;
border-radius: 3px; border: 1px solid currentColor; margin-right: var(--space-2);
}
.btn-secondary:hover { background: #6b7280; }
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
.verb.POST { color: var(--status-live); }
.verb.GET { color: var(--status-processing); }
.path { font-family: var(--font-mono); font-size: 12px; }
.field { margin-bottom: var(--space-3); }
.field label {
display: block; margin-bottom: 4px; font-size: 11px;
color: var(--muted); text-transform: uppercase; letter-spacing: .04em;
}
.status-option {
background: #374151;
padding: 12px;
border-radius: 6px;
cursor: pointer;
border: 2px solid transparent;
.field input, .field textarea { width: 100%; font-family: var(--font-mono); font-size: 12px; }
.field textarea { min-height: 180px; resize: vertical; }
.actions { display: flex; gap: var(--space-2); }
.primary { background: var(--accent); color: var(--surface-0); border-color: transparent; }
.primary:hover:not(:disabled) { opacity: .9; background: var(--accent); }
.url {
font-family: var(--font-mono); font-size: 12px; user-select: all;
padding: var(--space-2) var(--space-3);
background: var(--surface-0); border: var(--panel-border);
border-radius: var(--panel-radius);
}
.status-option:hover { border-color: #0071f2; }
.status-option.selected { border-color: #0071f2; background: #4b5563; }
.status-name { font-weight: 600; color: #f9fafb; margin-bottom: 4px; }
.status-desc { font-size: 0.75rem; color: #9ca3af; }
.note { color: var(--text-dim); font-size: 11px; margin: var(--space-2) 0 0; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>MercadoPago <span class="mock-badge">MOCK</span></h1>
<div class="subtitle">Configure mock payment responses and behavior</div>
</header>
<!-- Payment Status Configuration -->
<div class="section">
<div class="section-header">Default Payment Status</div>
<p style="color: #9ca3af; margin-bottom: 16px;">Choose what status new payments should return:</p>
<div class="status-grid">
<div class="status-option selected" onclick="selectStatus('approved')">
<div class="status-name">Approved</div>
<div class="status-desc">Payment successful</div>
</div>
<div class="status-option" onclick="selectStatus('rejected')">
<div class="status-name">Rejected</div>
<div class="status-desc">Payment failed</div>
</div>
<div class="status-option" onclick="selectStatus('pending')">
<div class="status-name">Pending</div>
<div class="status-desc">Awaiting confirmation</div>
</div>
<div class="status-option" onclick="selectStatus('in_process')">
<div class="status-name">In Process</div>
<div class="status-desc">Being processed</div>
</div>
</div>
<div class="container">
<header>
<h1>MercadoPago <span class="mock-badge">mock</span></h1>
<div class="subtitle">Configure mock payment responses and behaviour</div>
</header>
<div class="panel">
<div class="panel-header">
<span class="panel-title">Default payment status</span>
<span class="panel-status live"></span>
</div>
<div class="panel-body">
<p class="lede">What status new payments should return.</p>
<div class="grid" id="statuses"></div>
</div>
</div>
<!-- Endpoint Configuration -->
<div class="section">
<div class="section-header">Configure Endpoint Responses</div>
<div class="endpoint-list">
<div class="endpoint-card" onclick="selectEndpoint('POST', '/checkout/preferences', 'preference')">
<div>
<span class="endpoint-method method-post">POST</span>
<span class="endpoint-path">/checkout/preferences</span>
</div>
<div class="endpoint-desc">Create payment preference (Checkout Pro)</div>
</div>
<div class="endpoint-card" onclick="selectEndpoint('POST', '/v1/payments', 'payment')">
<div>
<span class="endpoint-method method-post">POST</span>
<span class="endpoint-path">/v1/payments</span>
</div>
<div class="endpoint-desc">Create payment (Checkout API)</div>
</div>
<div class="endpoint-card" onclick="selectEndpoint('GET', '/v1/payments/{id}', 'payment_get')">
<div>
<span class="endpoint-method method-get">GET</span>
<span class="endpoint-path">/v1/payments/{id}</span>
</div>
<div class="endpoint-desc">Get payment details</div>
</div>
<div class="endpoint-card" onclick="selectEndpoint('POST', '/oauth/token', 'oauth')">
<div>
<span class="endpoint-method method-post">POST</span>
<span class="endpoint-path">/oauth/token</span>
</div>
<div class="endpoint-desc">OAuth token exchange/refresh</div>
</div>
</div>
<div class="panel">
<div class="panel-header">
<span class="panel-title">Endpoint responses</span>
<span class="panel-actions"><span class="card-desc" id="count"></span></span>
</div>
<div class="panel-body">
<div class="grid" id="endpoints"></div>
</div>
</div>
<!-- Response Editor -->
<div class="section" id="responseEditor" style="display: none;">
<div class="section-header">Edit Response</div>
<div class="form-group">
<label class="form-label">Endpoint</label>
<input class="form-input" id="endpointDisplay" readonly>
<div class="panel" id="editor" hidden>
<div class="panel-header">
<span class="panel-title">Edit response</span>
<span class="panel-status processing"></span>
</div>
<div class="panel-body">
<div class="field">
<label for="endpointDisplay">Endpoint</label>
<input id="endpointDisplay" readonly>
</div>
<div class="form-group">
<label class="form-label">Mock Response (JSON)</label>
<textarea class="form-textarea" id="responseJson" placeholder='{"id": "123456", "status": "approved", "_mock": "MercadoPago"}'></textarea>
<div class="field">
<label for="responseJson">Mock response (JSON)</label>
<textarea id="responseJson"></textarea>
</div>
<div class="form-group">
<label class="form-label">HTTP Status Code</label>
<input type="number" class="form-input" id="statusCode" value="200">
<div class="field">
<label for="statusCode">HTTP status code</label>
<input type="number" id="statusCode" value="200">
</div>
<div class="form-group">
<label class="form-label">Delay (ms)</label>
<input type="number" class="form-input" id="delay" value="0">
<div class="field">
<label for="delay">Delay (ms)</label>
<input type="number" id="delay" value="0">
</div>
<div>
<button class="btn btn-primary" onclick="saveResponse()">Save Response</button>
<button class="btn btn-secondary" onclick="closeEditor()">Cancel</button>
<div class="actions">
<button class="primary" onclick="saveResponse()">Save response</button>
<button onclick="closeEditor()">Cancel</button>
</div>
<p class="note">Saving is not implemented yet — it was not implemented before this
page was rebrought onto the theme either, and pretending otherwise would be worse.</p>
</div>
</div>
<!-- Quick Test -->
<div class="section">
<div class="section-header">Quick Test</div>
<p style="color: #9ca3af; margin-bottom: 12px;">Test endpoint URL to hit for configured responses:</p>
<div class="form-input" style="background: #374151; user-select: all;">
http://localhost:8006/v1/payments
</div>
<div class="panel">
<div class="panel-header"><span class="panel-title">Quick test</span></div>
<div class="panel-body">
<p class="lede">Hit this URL to get the configured responses.</p>
<div class="url">http://localhost:8006/v1/payments</div>
</div>
</div>
</div>
<script>
let selectedEndpoint = null;
let selectedPaymentStatus = 'approved';
<script>
const STATUSES = [
{ key: 'approved', name: 'Approved', desc: 'Payment successful' },
{ key: 'rejected', name: 'Rejected', desc: 'Payment failed' },
{ key: 'pending', name: 'Pending', desc: 'Awaiting confirmation' },
{ key: 'in_process', name: 'In Process', desc: 'Being processed' },
];
function selectStatus(status) {
selectedPaymentStatus = status;
document.querySelectorAll('.status-option').forEach(opt => opt.classList.remove('selected'));
event.currentTarget.classList.add('selected');
}
const ENDPOINTS = [
{ verb: 'POST', path: '/checkout/preferences', type: 'preference', desc: 'Create payment preference (Checkout Pro)' },
{ verb: 'POST', path: '/v1/payments', type: 'payment', desc: 'Create payment (Checkout API)' },
{ verb: 'GET', path: '/v1/payments/{id}', type: 'payment_get', desc: 'Get payment details' },
{ verb: 'POST', path: '/oauth/token', type: 'oauth', desc: 'OAuth token exchange/refresh' },
];
function selectEndpoint(method, path, type) {
selectedEndpoint = {method, path, type};
document.querySelectorAll('.endpoint-card').forEach(c => c.classList.remove('active'));
event.currentTarget.classList.add('active');
document.getElementById('responseEditor').style.display = 'block';
document.getElementById('endpointDisplay').value = `${method} ${path}`;
document.getElementById('responseJson').value = getDefaultResponse(type);
}
let paymentStatus = 'approved';
let selected = null;
function getDefaultResponse(type) {
const defaults = {
preference: JSON.stringify({
"id": "123456-pref-id",
"init_point": "https://www.mercadopago.com.ar/checkout/v1/redirect?pref_id=123456",
"sandbox_init_point": "https://sandbox.mercadopago.com.ar/checkout/v1/redirect?pref_id=123456",
"_mock": "MercadoPago"
}, null, 2),
payment: JSON.stringify({
"id": 123456,
"status": selectedPaymentStatus,
"status_detail": selectedPaymentStatus === 'approved' ? 'accredited' : 'cc_rejected_other_reason',
"transaction_amount": 1500,
"currency_id": "ARS",
"_mock": "MercadoPago"
}, null, 2),
payment_get: JSON.stringify({
"id": 123456,
"status": "approved",
"status_detail": "accredited",
"transaction_amount": 1500,
"_mock": "MercadoPago"
}, null, 2),
oauth: JSON.stringify({
"access_token": "APP_USR-123456-mock-token",
"token_type": "Bearer",
"expires_in": 15552000,
"refresh_token": "TG-123456-mock-refresh",
"_mock": "MercadoPago"
}, null, 2)
};
return defaults[type] || '{}';
}
const $ = (id) => document.getElementById(id);
function saveResponse() {
alert('Mock response saved (feature pending implementation)');
}
function pick(container, el) {
container.querySelectorAll('.card').forEach((c) => c.classList.remove('on'));
el.classList.add('on');
}
function closeEditor() {
document.getElementById('responseEditor').style.display = 'none';
selectedEndpoint = null;
document.querySelectorAll('.endpoint-card').forEach(c => c.classList.remove('active'));
}
</script>
STATUSES.forEach((s, i) => {
const b = document.createElement('button');
b.className = 'card' + (i === 0 ? ' on' : '');
b.innerHTML = '<div class="card-name"></div><div class="card-desc"></div>';
b.firstChild.textContent = s.name;
b.lastChild.textContent = s.desc;
b.onclick = () => { paymentStatus = s.key; pick($('statuses'), b); };
$('statuses').appendChild(b);
});
ENDPOINTS.forEach((e) => {
const b = document.createElement('button');
b.className = 'card';
b.innerHTML = '<div><span class="verb ' + e.verb + '">' + e.verb + '</span>' +
'<span class="path"></span></div><div class="card-desc"></div>';
b.querySelector('.path').textContent = e.path;
b.lastChild.textContent = e.desc;
b.onclick = () => {
selected = e;
pick($('endpoints'), b);
$('editor').hidden = false;
$('endpointDisplay').value = e.verb + ' ' + e.path;
$('responseJson').value = defaultResponse(e.type);
};
$('endpoints').appendChild(b);
});
$('count').textContent = ENDPOINTS.length + ' endpoints';
function defaultResponse(type) {
const bodies = {
preference: {
id: '123456-pref-id',
init_point: 'https://www.mercadopago.com.ar/checkout/v1/redirect?pref_id=123456',
sandbox_init_point: 'https://sandbox.mercadopago.com.ar/checkout/v1/redirect?pref_id=123456',
_mock: 'MercadoPago',
},
payment: {
id: 123456,
status: paymentStatus,
status_detail: paymentStatus === 'approved' ? 'accredited' : 'cc_rejected_other_reason',
transaction_amount: 1500,
currency_id: 'ARS',
_mock: 'MercadoPago',
},
payment_get: {
id: 123456, status: 'approved', status_detail: 'accredited',
transaction_amount: 1500, _mock: 'MercadoPago',
},
oauth: {
access_token: 'APP_USR-123456-mock-token', token_type: 'Bearer',
expires_in: 15552000, refresh_token: 'TG-123456-mock-refresh', _mock: 'MercadoPago',
},
};
return JSON.stringify(bodies[type] || {}, null, 2);
}
function saveResponse() {
alert('Mock response saved (feature pending implementation)');
}
function closeEditor() {
$('editor').hidden = true;
selected = null;
document.querySelectorAll('#endpoints .card').forEach((c) => c.classList.remove('on'));
}
</script>
</body>
</html>

View File

@@ -2,13 +2,35 @@
Jira Vein - FastAPI app.
"""
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse, JSONResponse
from .api.routes import router
from .core.config import settings
app = FastAPI(title="Jira Vein", version="0.1.0")
app.include_router(router)
UI = Path(__file__).parent / "ui" / "index.html"
@app.get("/ui", include_in_schema=False)
def ui():
"""The vein's ad-hoc interface.
One file, served as-is. Its theme and its parts are baked in by
common/theme/bake.py, so this is a plain FileResponse rather than a
template: there is nothing left to fill in at request time, and the same
bytes open from a double-click when nothing is serving them.
"""
if not UI.is_file():
return JSONResponse(
{"error": "no ui/index.html", "hint": "run `make theme bake` from the spr root"},
status_code=404,
)
return FileResponse(UI, media_type="text/html")
if __name__ == "__main__":
import uvicorn

View File

@@ -0,0 +1,496 @@
<!DOCTYPE html>
<!--
The jira vein's ad-hoc interface — the `ui/` slot veins/__init__.py has
declared since the beginning and no vein had filled.
It does the vein page job, which rig-ui states twice in its own comments after
rebuilding this look by hand: name what the thing exposes, and show what comes
back. Tool chrome and output are styled apart on purpose — that separation is
what tells you whether you are reading the tool or its result.
THREE RULES, all consequences of "a vein serves this on its own port, and it
must also open from a double-clicked file":
no /theme.css an absolute path assumes a server at the root
no build step no npm, no bundler, no Vue
no webfont a blocked stylesheet is a stall, not a fallback
So the theme and the parts are BAKED IN, between markers, by
common/theme/bake.py. Everything outside those markers is this page's, to edit
freely. Run `make theme bake` after changing a class; `make theme check` says
when a copy has gone stale.
The parts are chosen by what the markup uses — panel and split here, nothing
else. That is the whole point: this page carries no table, no log view, no
uplot, no vue-flow, because it uses none of them.
-->
<html lang="en" data-theme="soleprint">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>jira — vein</title>
<!-- theme:here -->
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--font-mono: "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
--font-size-base: 13px;
--font-size-sm: 11px;
--font-ui: Inter, "Segoe UI", system-ui, -apple-system, Arial, sans-serif;
--muted: #8888a0;
--panel-border: 1px solid #2e2e38;
--panel-header-height: 36px;
--panel-radius: 6px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--status-error: #f06565;
--status-idle: #555568;
--status-live: #3ecf8e;
--status-processing: #4f9cf9;
--surface-0: #0d0d0f;
--surface-1: #16161a;
--surface-2: #1e1e24;
--surface-3: #2e2e38;
--text-dim: #555568;
--text-primary: #e8e8f0;
--text-secondary: #8888a0;
}
</style>
<!-- /theme:baked-defaults -->
<!-- theme:parts — generated by common/theme/bake.py; do not edit -->
<style>
/* part: base — common/theme/parts/base.css */
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
}
body {
background: var(--surface-0);
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--font-size-base);
}
/* Opt in on <html>, <body> and the top element when the page is an app shell
* that should fill the viewport. Left off, the page scrolls like a document —
* which is what most ad-hoc vein pages actually want. */
.fills {
height: 100%;
width: 100%;
}
button {
font-family: var(--font-ui);
font-size: var(--font-size-base);
color: var(--text-primary);
background: var(--surface-2);
border: var(--panel-border);
border-radius: var(--panel-radius);
padding: var(--space-1) var(--space-3);
cursor: pointer;
}
button:hover:not(:disabled) {
background: var(--surface-3);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
input,
select,
textarea {
font-family: var(--font-ui);
font-size: var(--font-size-base);
color: var(--text-primary);
background: var(--surface-0);
border: var(--panel-border);
border-radius: 4px;
padding: var(--space-1) var(--space-2);
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--surface-3);
border-radius: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
/* part: panel — common/theme/parts/panel.css */
.panel {
position: relative;
background: var(--surface-1);
border: var(--panel-border);
border-radius: var(--panel-radius);
overflow: hidden;
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: center;
gap: var(--space-2);
height: var(--panel-header-height);
padding: 0 var(--space-3);
background: var(--surface-2);
border-bottom: var(--panel-border);
flex-shrink: 0;
}
.panel-title {
font-family: var(--font-ui);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.panel-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-2);
}
.panel-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background: var(--status-idle);
}
.panel-status.idle { background: var(--status-idle); }
.panel-status.live { background: var(--status-live); }
.panel-status.processing { background: var(--status-processing); }
.panel-status.error { background: var(--status-error); }
.panel-body {
flex: 1;
overflow: auto;
padding: var(--space-2);
min-height: 0;
}
/* part: split — common/theme/parts/split.css */
.split-pane {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.split-pane.horizontal {
flex-direction: row;
}
.split-pane.vertical {
flex-direction: column;
}
.split-first,
.split-second {
min-height: 0;
min-width: 0;
overflow: hidden;
flex: 1;
}
/* Children fill their pane. */
.split-first > *,
.split-second > * {
width: 100%;
height: 100%;
}
.split-divider {
flex-shrink: 0;
background: transparent;
transition: background 0.15s;
touch-action: none;
z-index: 10;
}
.split-divider:hover,
.split-divider.dragging {
background: var(--text-dim);
}
.split-pane.horizontal > .split-divider {
width: 4px;
cursor: col-resize;
margin: 0 -2px;
}
.split-pane.vertical > .split-divider {
height: 4px;
cursor: row-resize;
margin: -2px 0;
}
</style>
<script>
/* part: split — common/theme/parts/split.js */
(function () {
'use strict'
function setup(root) {
var divider = root.querySelector(':scope > .split-divider')
if (!divider) return // no divider: a fixed split, deliberately
var first = root.querySelector(':scope > .split-first')
var second = root.querySelector(':scope > .split-second')
if (!first || !second) return
var horizontal = !root.classList.contains('vertical')
var mode = root.dataset.mode === 'px' ? 'px' : 'ratio'
var anchor = root.dataset.anchor === 'second' ? 'second' : 'first'
var size = parseFloat(root.dataset.size)
if (isNaN(size)) size = 1
var min = parseFloat(root.dataset.min)
if (isNaN(min)) min = mode === 'px' ? 0 : 0.1
var max = parseFloat(root.dataset.max)
if (isNaN(max)) max = mode === 'px' ? Infinity : 10
var sized = anchor === 'second' ? second : first
var flexed = anchor === 'second' ? first : second
var dragging = false
var startPos = 0
function apply() {
flexed.style.flex = '1'
if (mode === 'px') {
sized.style.flex = '0 0 auto'
sized.style[horizontal ? 'width' : 'height'] = size + 'px'
} else {
sized.style.flex = String(size)
}
}
divider.addEventListener('pointerdown', function (e) {
dragging = true
startPos = horizontal ? e.clientX : e.clientY
divider.classList.add('dragging')
divider.setPointerCapture(e.pointerId)
})
divider.addEventListener('pointermove', function (e) {
if (!dragging) return
var pos = horizontal ? e.clientX : e.clientY
var delta = pos - startPos
startPos = pos
// Dragging right/down grows the first pane. When the SECOND pane is the
// anchored one, that same gesture must shrink it, so invert.
if (anchor === 'second') delta = -delta
// Ratio mode is unitless, so pixels are scaled into it. The two constants
// are the SFC's, kept rather than re-derived: they are what the existing
// panes were tuned against, and vertical drags cover less travel.
var step = mode === 'px' ? delta : delta * (horizontal ? 0.01 : 0.02)
size = Math.max(min, Math.min(max, size + step))
apply()
})
function end(e) {
if (!dragging) return
dragging = false
divider.classList.remove('dragging')
if (e && e.pointerId !== undefined && divider.hasPointerCapture(e.pointerId)) {
divider.releasePointerCapture(e.pointerId)
}
}
divider.addEventListener('pointerup', end)
divider.addEventListener('pointercancel', end)
apply()
}
function start() {
var panes = document.querySelectorAll('[data-split]')
for (var i = 0; i < panes.length; i++) setup(panes[i])
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start)
} else {
start()
}
})()
</script>
<!-- /theme:parts -->
<style>
/* This page's own, and only its own. */
body { padding: var(--space-4); }
.page { display: flex; flex-direction: column; gap: var(--space-3); height: calc(100vh - 2 * var(--space-4)); }
header { display: flex; align-items: baseline; gap: var(--space-3); flex-wrap: wrap; }
h1 { margin: 0; font-size: 20px; font-family: var(--font-ui); }
.sub { color: var(--muted); font-family: var(--font-mono); font-size: 12px; }
.split-pane { border: var(--panel-border); border-radius: var(--panel-radius); }
.routes { display: flex; flex-direction: column; gap: 2px; }
.route {
display: flex; align-items: center; gap: var(--space-2);
padding: 4px var(--space-2); border-radius: var(--panel-radius);
font-family: var(--font-mono); font-size: 12px;
background: none; border: 0; width: 100%; text-align: left;
}
.route:hover:not(:disabled) { background: var(--surface-2); }
.route.on { background: var(--surface-3); }
.verb {
font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 3px;
border: 1px solid currentColor; flex-shrink: 0;
}
.verb.GET { color: var(--status-processing); }
.verb.POST { color: var(--status-live); }
.controls { display: flex; gap: var(--space-2); margin-bottom: var(--space-2); }
.controls input { flex: 1; font-family: var(--font-mono); font-size: 12px; }
/* OUTPUT — a raised block, monospace, selectable. It is a payload, not
furniture, and should not read like the tool that produced it. */
.out {
margin: 0; padding: var(--space-3);
background: var(--surface-0); border: var(--panel-border);
border-radius: var(--panel-radius);
font-family: var(--font-mono); font-size: 12px; line-height: 1.5;
white-space: pre-wrap; word-break: break-word;
user-select: text; min-height: 100%;
}
.out.err { color: var(--status-error); }
.hint { color: var(--text-dim); }
</style>
</head>
<body>
<div class="page">
<header>
<h1>jira</h1>
<span class="sub">vein &middot; stateless API connector</span>
<span class="sub hint" id="base"></span>
</header>
<div class="split-pane horizontal" data-split data-size="1" data-min="0.4" data-max="3" style="flex:1; min-height:0;">
<div class="split-first">
<div class="panel">
<div class="panel-header">
<span class="panel-title">Exposes</span>
<span class="panel-actions"><span class="sub" id="count"></span></span>
</div>
<div class="panel-body">
<div class="routes" id="routes"></div>
</div>
</div>
</div>
<div class="split-divider"></div>
<div class="split-second">
<div class="panel">
<div class="panel-header">
<span class="panel-title">Returns</span>
<span class="panel-actions"><button id="run" disabled>send</button></span>
<span class="panel-status idle" id="dot"></span>
</div>
<div class="panel-body">
<div class="controls">
<input id="arg" placeholder="pick a route" disabled>
</div>
<pre class="out hint" id="out">Pick a route on the left, then send.
Opened from a file rather than served? Nothing will answer — the routes are
still the contract, which is half of what this page is for.</pre>
</div>
</div>
</div>
</div>
</div>
<script>
/* The vein's surface, as the page understands it. Kept here rather than fetched
so the page still says what the vein exposes when nothing is serving it. */
const ROUTES = [
{ verb: 'GET', path: '/health', arg: null, hint: 'connection check' },
{ verb: 'GET', path: '/mine', arg: null, hint: 'tickets assigned to you' },
{ verb: 'GET', path: '/backlog', arg: null, hint: 'the backlog' },
{ verb: 'GET', path: '/sprint', arg: null, hint: 'the current sprint' },
{ verb: 'GET', path: '/ticket/{key}', arg: 'key', hint: 'one ticket, e.g. PROJ-123' },
{ verb: 'POST', path: '/search', arg: 'jql', hint: 'a JQL query' },
{ verb: 'GET', path: '/epic/{key}/status', arg: 'key', hint: 'epic processing status' },
];
const $ = (id) => document.getElementById(id);
let active = null;
$('base').textContent = location.protocol === 'file:' ? 'not served — file://' : location.origin;
$('count').textContent = ROUTES.length + ' routes';
ROUTES.forEach((r, i) => {
const b = document.createElement('button');
b.className = 'route';
b.innerHTML = '<span class="verb ' + r.verb + '">' + r.verb + '</span>' +
'<span>' + r.path + '</span>';
b.title = r.hint;
b.onclick = () => select(i, b);
$('routes').appendChild(b);
});
function select(i, el) {
active = ROUTES[i];
document.querySelectorAll('.route').forEach((n) => n.classList.remove('on'));
el.classList.add('on');
$('arg').disabled = !active.arg;
$('arg').placeholder = active.arg ? active.hint : 'no argument';
$('arg').value = '';
$('run').disabled = false;
}
function status(state) { $('dot').className = 'panel-status ' + state; }
$('run').onclick = async () => {
if (!active) return;
status('processing');
$('out').className = 'out';
$('out').textContent = '…';
let path = active.path, init = { method: active.verb };
const v = $('arg').value.trim();
if (active.arg === 'key') path = path.replace('{key}', encodeURIComponent(v));
if (active.arg === 'jql') {
init.headers = { 'Content-Type': 'application/json' };
init.body = JSON.stringify({ jql: v });
}
try {
const res = await fetch(path, init);
const text = await res.text();
let body = text;
try { body = JSON.stringify(JSON.parse(text), null, 2); } catch (_) {}
$('out').textContent = res.status + ' ' + res.statusText + '\n\n' + body;
status(res.ok ? 'live' : 'error');
if (!res.ok) $('out').className = 'out err';
} catch (e) {
$('out').className = 'out err';
$('out').textContent = String(e) +
(location.protocol === 'file:' ? '\n\nThis page is not being served.' : '');
status('error');
}
};
</script>
</body>
</html>

View File

@@ -35,19 +35,24 @@ SPR_ROOT = HERE.parent.parent # soleprint/
TOKENS = HERE / "tokens.css"
DEFAULT_THEME = HERE / "themes" / "soleprint.css"
PARTS = HERE / "parts"
BEGIN = "<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->"
END = "<!-- /theme:baked-defaults -->"
# Pages that link /theme.css and therefore need a default to fall back on.
PAGES = [
"index.html",
"artery/index.html",
"atlas/index.html",
"station/index.html",
"station/tools/datagen/templates/index.html",
"station/tools/graphgen/templates/index.html",
"station/tools/shuntgen/templates/index.html",
]
PARTS_BEGIN = "<!-- theme:parts — generated by common/theme/bake.py; do not edit -->"
PARTS_END = "<!-- /theme:parts -->"
# A page with no `/theme.css` link says where the blocks go with this. That is
# the whole opt-in for a standalone page: it cannot fetch a stylesheet, so there
# is no <link> to anchor on.
ANCHOR = "<!-- theme:here -->"
LINK = '<link rel="stylesheet" href="/theme.css">'
# Directories with nothing bakeable in them. `gen/` is build output — baking
# there would be editing an artifact, and the next build overwrites it anyway.
SKIP_DIRS = {"node_modules", ".venv", "__pycache__", ".git", "gen", "dist", "def"}
def declarations(css: str, selector: str) -> dict[str, str]:
@@ -95,10 +100,84 @@ def palette() -> dict[str, str]:
return {name: resolve(name, tokens) for name in tokens}
def part_header(name: str, css: str) -> str:
"""A part's leading comment, from whichever of its files exists."""
source = css or (PARTS / f"{name}.js").read_text()
return source[: source.find("*/") + 2] if "*/" in source else ""
def load_parts() -> dict[str, tuple[str, set[str], set[str], bool]]:
"""Every part: name -> (css, classes, detect tokens, is-always).
Class names are read out of the CSS rather than listed here. A second list
to maintain is how PAGES went wrong -- histgen linked /theme.css for months
and was silently never baked, because adding it to a list was a step nobody
took.
"""
out = {}
for name in sorted({p.stem for p in PARTS.glob("*.css")} | {p.stem for p in PARTS.glob("*.js")}):
css_path, js_path = PARTS / f"{name}.css", PARTS / f"{name}.js"
css = css_path.read_text() if css_path.exists() else ""
js = js_path.read_text() if js_path.exists() else ""
classes = set(re.findall(r"\.([a-zA-Z][\w-]*)", re.sub(r"/\*.*?\*/", "", css, flags=re.S)))
# A behaviour part has no class to be recognised by, so it names what to
# look for: an attribute (`data-maximize`) or a global (`sprParams`).
# BOTH headers are read, not just the preferred one -- found by running
# it: `params` and `maximize` have CSS *and* a DETECT line that lives in
# their .js, so reading one file silently lost them.
detects, always = set(), False
for text in (css, js):
if not text:
continue
head = text[: text.find("*/")] if "*/" in text else ""
found = re.search(r"DETECT:\s*(.+)", head)
if found:
detects |= set(found.group(1).split())
always = always or "ALWAYS:" in head
out[name] = (css, classes, detects, always)
return out
def parts_used(html: str, parts: dict) -> list[str]:
"""Which parts a page needs, by the classes its markup actually uses.
Same rule as `used()` one level up: emit what the page asks for, nothing
else. A page with a panel and no split pane does not carry split.css.
"""
body = strip_blocks(html)
present = set()
for quote in ('"', "'"):
for value in re.findall(r"class\s*=\s*%s([^%s]*)%s" % (quote, quote, quote), body):
present.update(value.split())
# DETECT tokens are attributes and globals, so they are looked for anywhere
# in the page -- but not in comments, or this file's own prose about a part
# would summon it.
code = re.sub(r"<!--.*?-->", "", body, flags=re.S)
wanted = [
n for n, (_, classes, detects, always) in parts.items()
if not always and ((classes & present) or any(d in code for d in detects))
]
if wanted:
wanted += [n for n, (_, _, _, always) in parts.items() if always]
return sorted(wanted)
def strip_blocks(html: str) -> str:
"""The page without either generated block, so scans see only what a human wrote."""
for begin, end in ((BEGIN, END), (PARTS_BEGIN, PARTS_END)):
html = re.sub(re.escape(begin) + r".*?" + re.escape(end), "", html, flags=re.S)
return html
def used(html: str) -> set[str]:
"""Variables a page references, ignoring the baked block itself."""
body = re.sub(re.escape(BEGIN) + r".*?" + re.escape(END), "", html, flags=re.S)
return set(re.findall(r"var\(\s*(--[\w-]+)", body))
"""Variables a page references, ignoring the baked blocks themselves."""
return set(re.findall(r"var\(\s*(--[\w-]+)", strip_blocks(html)))
def block(names: set[str], values: dict[str, str], indent: str) -> str:
@@ -112,65 +191,427 @@ def block(names: set[str], values: dict[str, str], indent: str) -> str:
return "\n".join(lines)
def bake(path: Path, values: dict[str, str]) -> tuple[bool, str]:
def parts_block(names: list[str], parts: dict, indent: str) -> str:
"""The baked <style> (+ <script>) for the parts a page uses, inside markers.
A part's behaviour travels with its looks. split.css lays the panes out but
the divider only drags once split.js is on the page, and a part that needs
the consumer to remember a second file is a part that ships half-working.
"""
def wrap(text):
return "\n".join(f"{indent}{line}".rstrip() for line in text.rstrip().splitlines())
def body(name, text, ext):
"""The part without its header comment, stamped with where it came from.
The headers carry the evidence for each part -- who used it, what was
left out, which line of the SFC it came from. That belongs in
common/theme/parts/, maintained once, not copied into every page that
bakes it. A page carrying forty lines of provenance for eight lines of
CSS is the same disease this whole approach exists to avoid.
"""
stripped = re.sub(r"\A\s*/\*.*?\*/\s*", "", text, count=1, flags=re.S)
return wrap(f"/* part: {name} — common/theme/parts/{name}.{ext} */\n{stripped}")
styled = [n for n in names if parts[n][0].strip()]
lines = [f"{indent}{PARTS_BEGIN}"]
if styled:
lines.append(f"{indent}<style>")
for name in styled:
lines.append(body(name, parts[name][0], "css"))
lines.append(f"{indent}</style>")
for name in names:
js = PARTS / f"{name}.js"
if js.exists():
lines += [f"{indent}<script>", body(name, js.read_text(), "js"), f"{indent}</script>"]
lines.append(f"{indent}{PARTS_END}")
return "\n".join(lines)
def replace_block(html: str, fresh: str, begin: str, end: str, at: int, indent: str) -> str:
"""Swap an existing marked block, or insert a fresh one at `at`."""
existing = re.search(re.escape(begin) + r".*?" + re.escape(end), html, re.S)
if existing:
return html[: existing.start()] + fresh.lstrip() + html[existing.end() :]
return html[:at] + fresh + "\n" + html[at:]
def bake(path: Path, values: dict[str, str], parts: dict) -> tuple[bool, str]:
"""Return (changed, note) for one page."""
html = path.read_text()
original = html
link = re.search(r'([ \t]*)<link rel="stylesheet" href="/theme.css">', html)
if not link:
return False, "no /theme.css link — skipped"
indent = link.group(1)
names = used(html)
link = re.search(r"([ \t]*)" + re.escape(LINK), html)
anchor = re.search(r"([ \t]*)" + re.escape(ANCHOR), html)
if link:
# Before the link, never after: document order is what makes the served
# stylesheet win over the baked one.
indent, at = link.group(1), link.start()
elif anchor:
indent, at = anchor.group(1), anchor.end() + 1
else:
return False, "no /theme.css link and no <!-- theme:here --> — skipped"
# Parts are opt-in, and the anchor IS the opt-in. Found by running it: the
# shuntgen template hand-writes its own `.panel` rules, so detecting classes
# in a page that never asked for parts injected a second, conflicting copy.
# Adopting a part means deleting the hand-written version -- a migration
# someone does on purpose, not something a formatter does to them.
#
# Parts first. Their CSS is part of what the page references, so the token
# block below has to see it -- a baked part whose variables nobody baked
# renders UNSTYLED, which is the failure this whole file exists to prevent.
wanted = parts_used(html, parts) if anchor else []
part_vars: set[str] = set()
if wanted:
fresh = parts_block(wanted, parts, indent)
html = replace_block(html, fresh, PARTS_BEGIN, PARTS_END, at, indent)
for name in wanted:
part_vars |= set(re.findall(r"var\(\s*(--[\w-]+)", parts[name][0]))
# The token block goes above the parts block.
at = html.index(PARTS_BEGIN) - len(indent)
names = used(html) | part_vars
if not names:
return False, "uses no theme variables — skipped"
fresh = block(names, values, indent)
html = replace_block(html, block(names, values, indent), BEGIN, END, at, indent)
# A name the theme does not define cannot be baked, so the page falls back
# forever and never follows a theme switch. `block()` skips it silently,
# which is how histgen spent months rendering var(--text-0) -- a name that
# exists nowhere -- against a hardcoded fallback. Say so.
unknown = sorted(n for n in names if not values.get(n))
if unknown:
print(f" warning: not in the theme, never baked: {', '.join(unknown)}", file=sys.stderr)
note = f"{len(names) - len(unknown)} variables" + (f", parts: {' '.join(wanted)}" if wanted else "")
if html == original:
return False, f"up to date ({note})"
path.write_text(html)
return True, f"baked {note}"
SCAFFOLD = """<!DOCTYPE html>
<!--
%(title)s — an ad-hoc page.
HOW THIS GROWS. You never pick parts and you never edit the generated blocks.
You write the markup for the feature you want, run `make theme bake`, and the
part arrives. Remove the markup, bake again, and it leaves.
`./ctrl/theme.sh parts` what can be added, and the markup for each
`make theme bake` put it in
`make theme check` says when this page has gone stale
It must open from a double-click as well as be served, so: no /theme.css, no
CDN, no webfont, no build step. Everything is in this one file.
-->
<html lang="en" data-theme="soleprint">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>%(title)s</title>
<!-- theme:here -->
<style>
/* This page's own. Style with var(--…), never with a literal colour — a hex
typed here cannot follow a theme. `./ctrl/theme.sh export` lists the names. */
body { padding: var(--space-4); }
.page { max-width: 1100px; margin: 0 auto; }
header { margin-bottom: var(--space-4); }
h1 { margin: 0; font-size: 20px; font-family: var(--font-ui); }
.sub { color: var(--muted); font-family: var(--font-mono); font-size: 12px; }
</style>
</head>
<body>
<div class="page">
<header>
<h1>%(title)s</h1>
<span class="sub">what this is</span>
</header>
<div class="panel">
<div class="panel-header">
<span class="panel-title">Panel</span>
<span class="panel-actions"></span>
<span class="panel-status idle"></span>
</div>
<div class="panel-body">
<p>Replace this. Add a feature by adding its markup, then bake.</p>
</div>
</div>
</div>
</body>
</html>
"""
existing = re.search(re.escape(BEGIN) + r".*?" + re.escape(END), html, re.S)
if existing:
updated = html[: existing.start()] + fresh.lstrip() + html[existing.end() :]
else:
# Before the link, never after: document order is what makes the served
# stylesheet win over the baked one.
updated = html[: link.start()] + fresh + "\n" + html[link.start() :]
if updated == html:
return False, f"up to date ({len(names)} variables)"
path.write_text(updated)
return True, f"baked {len(names)} variables"
def scaffold(title: str) -> int:
"""The simple page you start from, before any feature is on it.
Deliberately one panel and nothing else. The flow this system is for is
scaffold first, features after -- so the starting point has to be the
smallest thing that already works, not a gallery to delete from.
"""
print(SCAFFOLD % {"title": title or "page"}, end="")
return 0
def field(header: str, name: str) -> str:
"""One `NAME: …` line out of a part header, wrapped lines joined."""
match = re.search(rf"^ \* {name}:[ \t]*(.+(?:\n \*(?![ \t]*[A-Z]+:)[ \t]+.+)*)", header, re.M)
if not match:
return ""
return " ".join(line.strip(" *\t") for line in match.group(1).splitlines()).strip()
def snippet(header: str) -> list[str]:
"""The indented block under `ADD:` — what to paste to turn a feature on."""
out, grabbing = [], False
for line in header.splitlines():
if re.match(r"^ \* ADD:", line):
grabbing = True
continue
if grabbing:
if re.match(r"^ \*\s*$", line) or re.match(r"^ \* [A-Z]", line):
break
out.append(line[3:] if line.startswith(" * ") else line.lstrip(" *"))
return out
def catalogue(parts: dict) -> int:
"""What can be added to a page, and the markup that adds it."""
print("Add a feature by adding its markup, then `make theme bake`.")
print("You never name a part on the command line to use it — bake finds it.\n")
for name in sorted(parts):
css, _, _, always = parts[name]
header = part_header(name, css)
files = [f"{name}.{e}" for e in ("css", "js") if (PARTS / f"{name}.{e}").exists()]
summary = re.search(r"part: \S+ — (.+)", header)
print(f"── {name} ({', '.join(files)})")
if summary:
print(f" {summary.group(1).strip()}")
for label in ("USE WHEN", "NEEDS"):
value = field(header, label)
if value:
print(f" {label.lower()}: {value}")
if always:
print(" baked automatically whenever any other part is")
for line in snippet(header):
print(f" {line}")
print()
return 0
def audit(parts: dict, values: dict[str, str]) -> int:
"""Compare each part with the source it was derived from. Returns failures.
Two different questions, and only one of them can be answered mechanically.
HARD: does the part use a token the theme does not define? That is the
`renders unstyled` failure, and it is decidable -- so it fails the build.
SOFT: has the part fallen behind its source? A scoped SFC <style> and a
plain stylesheet cannot be AST-compared the way common/selftest.py compares
two copies of cli.py, so the checkable invariant is the token set. A name
the SFC uses and the part does not usually means the SFC was rethemed and
the part was not. Reported, never failed: some gaps are deliberate, and
every part states its own in its header.
"""
failures = 0
for name, (css, _, _, _) in sorted(parts.items()):
header = part_header(name, css)
mine = set(re.findall(r"var\(\s*(--[\w-]+)", css))
js = PARTS / f"{name}.js"
if js.exists():
mine |= set(re.findall(r"var\(\s*(--[\w-]+)", js.read_text()))
undefined = sorted(n for n in mine if not values.get(n))
if undefined:
print(f" {name}: FAIL — not in the theme: {', '.join(undefined)}", file=sys.stderr)
failures += 1
# The source is named in the part's own header, not in a list here --
# same reason PAGES was dropped.
match = re.search(r"Derived from ([\w./-]+)", header)
if not match:
print(f" {name}: no 'Derived from' in the header — cannot audit")
continue
source = SPR_ROOT / match.group(1).rstrip(".")
if not source.exists():
print(f" {name}: FAIL — source not found: {match.group(1)}", file=sys.stderr)
failures += 1
continue
theirs = set(re.findall(r"var\(\s*(--[\w-]+)", source.read_text()))
behind = sorted(theirs - mine)
added = sorted(mine - theirs)
note = f" {name}: {len(mine)} tokens, from {match.group(1).rstrip('.')}"
if behind:
note += f"\n in the source, not in the part: {', '.join(behind)}"
if added:
note += f"\n in the part, not in the source: {', '.join(added)}"
print(note)
return failures
def export(parts: dict, values: dict[str, str], wanted: list[str]) -> int:
"""Write the plain-HTML contract for a chosen subset, as one document.
For handing to a vetted LLM when an ad-hoc page is what you want back. The
selection is the point: asking for a page and pasting the whole framework
gets you a page built out of Vue components that cannot run standalone.
This is NOT another distiller. `station/tools/distill` already flattens
repos to one document and budgets tokens; point it here for whole-tree
context. What this does instead is RESOLVE -- tokens come out as literal
values, so the document stands on its own with no theme files beside it.
The parts' own headers carry the markup and the reasoning, so the guide and
the code are the same file and cannot drift apart. A separate guide would
be a second thing to keep true.
"""
unknown = [n for n in wanted if n not in parts]
if unknown:
print(f"unknown part(s): {', '.join(unknown)}", file=sys.stderr)
print(f"available: {', '.join(sorted(parts))}", file=sys.stderr)
return 1
names = sorted(wanted) if wanted else sorted(parts)
# An always-part is the element layer; a page with panels and OS-default
# buttons is not what anyone is asking for.
names += [n for n, (_, _, _, always) in parts.items() if always and n not in names]
names = sorted(set(names))
tokens: set[str] = set()
for name in names:
tokens |= set(re.findall(r"var\(\s*(--[\w-]+)", parts[name][0]))
js = PARTS / f"{name}.js"
if js.exists():
tokens |= set(re.findall(r"var\(\s*(--[\w-]+)", js.read_text()))
out = [
"# soleprint — plain-HTML parts",
"",
f"Parts: **{', '.join(names)}**. Generated by `common/theme/bake.py --export`.",
"",
"## The rules this contract exists to keep",
"",
"1. **One self-contained file.** It is served by a vein or a shunt on its own",
" port AND must open from a double-click. No bundler, no npm, no framework.",
"2. **No external resource of any kind** — no `/theme.css` (an absolute path",
" assumes a server at the root), no CDN, no webfont. A blocked stylesheet is",
" a stall, not a fallback.",
"3. **Use the class names below as given.** They are shared with the Vue",
" components and with hand-written React markup elsewhere; a third spelling",
" of the same box is the problem, not the fix.",
"4. **Do not paste the CSS below into the page.** Write the markup and the",
" page's own styles only, leave `<!-- theme:here -->` in `<head>`, and run",
" `make theme bake` — it inserts exactly the parts the markup uses.",
"5. **Style with the variables, never with literals.** The values below are",
" resolved for reference; a hex typed into the page cannot follow a theme.",
"",
"## How a page is built",
"",
"Scaffold first, features after. `./ctrl/theme.sh new \"Title\"` gives a page with",
"one panel and the `<!-- theme:here -->` anchor. To add a feature you add its",
"markup and run `make theme bake`; the part appears. Remove the markup, bake",
"again, and it leaves. Nothing is selected by hand.",
"",
"Full workflow, rules and the markup for every part: `common/theme/parts/README.md`,",
"and `./ctrl/theme.sh parts` for the catalogue.",
"",
"## Tokens these parts use",
"",
"```css",
":root {",
]
for token in sorted(tokens):
value = values.get(token)
out.append(f" {token}: {value};" if value else f" /* {token}: NOT IN THE THEME */")
out += ["}", "```", ""]
for name in names:
css = parts[name][0]
header = part_header(name, css)
out += [f"## part: {name}", ""]
if css.strip():
out += ["```css", header.strip(), "", css[len(header):].strip(), "```", ""]
else:
out += ["```", header.strip(), "```", ""]
js = PARTS / f"{name}.js"
if js.exists():
out += [f"### {name}.js — the behaviour", "", "```js", js.read_text().strip(), "```", ""]
print("\n".join(out))
return 0
def pages() -> list[Path]:
"""Every page that asks to be baked, found rather than listed.
The old hardcoded list was already wrong: station/tools/histgen/templates/
index.html links /theme.css and was missing from it, so it was silently
never baked and its text colour never followed a theme. A new vein page must
not have to edit this file to be covered.
"""
found = []
for path in sorted(SPR_ROOT.rglob("*.html")):
if SKIP_DIRS & set(path.relative_to(SPR_ROOT).parts):
continue
text = path.read_text(errors="replace")
if LINK in text or ANCHOR in text:
found.append(path)
return found
def main() -> int:
check = "--check" in sys.argv
values = palette()
parts = load_parts()
if "--parts" in sys.argv:
return catalogue(parts)
if "--new" in sys.argv:
after = sys.argv[sys.argv.index("--new") + 1 :]
return scaffold(next((a for a in after if not a.startswith("-")), ""))
if "--export" in sys.argv:
after = sys.argv[sys.argv.index("--export") + 1 :]
return export(parts, values, [a for a in after if not a.startswith("-")])
missing = [n for n, v in values.items() if not v]
if missing:
print(f"warning: unresolved tokens: {', '.join(sorted(missing))}", file=sys.stderr)
stale = []
for rel in PAGES:
path = SPR_ROOT / rel
if not path.exists():
print(f" {rel}: not found")
continue
for path in pages():
rel = path.relative_to(SPR_ROOT)
if check:
before = path.read_text()
changed, note = bake(path, values)
changed, note = bake(path, values, parts)
if changed:
path.write_text(before)
stale.append(rel)
stale.append(str(rel))
print(f" {rel}: STALE")
else:
print(f" {rel}: {note}")
else:
_, note = bake(path, values)
_, note = bake(path, values, parts)
print(f" {rel}: {note}")
print("\nparts:")
failures = audit(parts, values)
if check and stale:
print(f"\n{len(stale)} page(s) stale — run: python3 common/theme/bake.py", file=sys.stderr)
return 1
return 0
return 1 if failures else 0
if __name__ == "__main__":

View File

@@ -0,0 +1,206 @@
# parts — ad-hoc pages that stay standalone
Everything needed to build one of these pages is in this file. You do not need to
read `common/ui`, any Vue source, or any other document.
## What this is for
A vein or a shunt needs an interface: a page it serves on its own port, which
must **also** open from a double-click on a machine with no npm, no server and no
network. These pages were being written from scratch every time, each re-solving
panels, buttons, split panes and live updates.
The parts are that solved work, as plain CSS and plain JS. They are **baked into
the page** — copied in, between markers — so the page stays one self-contained
file and depends on nothing.
## The flow: scaffold first, features after
**1. Make the simple page.**
```bash
./ctrl/theme.sh new "Jira vein" > soleprint/artery/veins/jira/ui/index.html
```
One panel and nothing else, with `<!-- theme:here -->` already in the `<head>`.
**2. Add a feature by adding its markup.** To get a status light and a title bar
you write a `.panel`. To get maximize you add `data-maximize`. To get live data
you call `new SprFeed(...)`. You never name a part anywhere.
**3. Bake.**
```bash
make theme bake
```
The parts the markup uses appear between `<!-- theme:parts -->` markers. Remove
the markup and bake again, and they leave. Both directions are exercised in the
verification below.
**4. Check, whenever a part upstream changes.**
```bash
make theme check # exit 1 and names the stale pages
```
## The rules — break these and the page stops being standalone
1. **One file.** No bundler, no npm, no build step, no framework.
2. **No external resource.** No `/theme.css` (an absolute path assumes a server
at the root, and soleprint is not always at one), no CDN, no webfont. A
blocked stylesheet is a stall, not a fallback.
3. **Never edit between the markers.** Both generated blocks are overwritten on
every bake. Everything outside them is yours.
4. **Never paste part CSS into the page yourself.** Write the markup; bake
inserts the CSS. Pasting it means it can never be updated.
5. **Style with `var(--…)`, never a literal colour.** A hex typed into the page
cannot follow a theme. The names are listed below.
6. **Leave `<!-- theme:here -->` in the `<head>`.** It is the anchor, and it is
how bake knows this page wants parts at all.
## What can be added
`./ctrl/theme.sh parts` prints this with the markup for each, generated from the
parts themselves, so it is never out of date.
| part | use when | needs |
| --- | --- | --- |
| `base` | — baked automatically with any other part; never alone | |
| `panel` | the page puts anything in a titled box, or needs a status light | |
| `split` | two regions the reader should be able to resize; nest for more | |
| `maximize` | one panel is the main event and should fill the screen | `panel` |
| `params` | numeric knobs, on/off toggles, or a choice from a list | |
| `feed` | values arrive over time and the page must update itself | |
### The markup for each
```html
<!-- panel -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">Title</span>
<span class="panel-actions"><button>reload</button></span>
<span class="panel-status idle"></span> <!-- idle | live | processing | error -->
</div>
<div class="panel-body"></div>
</div>
<!-- split: direction is the class; data-* are the sizing -->
<div class="split-pane horizontal" data-split data-size="1" data-min=".3" data-max="3">
<div class="split-first"></div>
<div class="split-divider"></div>
<div class="split-second"></div>
</div>
<!-- data-mode="px" for a fixed pane, data-anchor="second" to size the other one.
Omit .split-divider entirely for a split that cannot be dragged. -->
<!-- maximize: the attribute is the whole change -->
<div class="panel" data-maximize>
<!-- params -->
<div id="cfg"></div>
<script>
var FIELDS = [
{ name: 'rate', type: 'int', default: 240, min: 10, max: 600,
description: 'events per second', options: null },
{ name: 'shape', type: 'str', default: 'sine', min: null, max: null,
description: 'waveform', options: ['sine', 'saw', 'noise'] },
{ name: 'verbose', type: 'bool', default: true, min: null, max: null,
description: 'log every event', options: null },
]
var values = { rate: 240, shape: 'sine', verbose: true }
sprParams(document.getElementById('cfg'), FIELDS, values, function (name, v) {
values[name] = v // fires on every input — debounce if it costs anything
})
</script>
<!-- feed -->
<script>
var feed = new SprFeed({ url: '/api/stream', events: ['tick', 'log'], retries: 10 })
feed.on('status', function (s) { dot.className = 'panel-status ' + s })
feed.on('log', function (entry) { }) // every event
feed.onFrame('tick', function (latest) { }) // ONE call per animation frame
feed.connect()
</script>
```
**`on()` vs `onFrame()` is the one decision that matters at high frequency.**
`on()` fires for every event — right for logs and counters. `onFrame()` fires at
most once per animation frame with the newest payload and drops the rest — right
for anything you redraw. At a few hundred events a second, an `on()` handler that
writes to the DOM will lay out per event and stall.
`feed`'s status values are exactly the `panel-status` classes (`idle`,
`connecting`, `live`, `error`), so wiring one into the other needs no glue.
`on()` returns an unsubscribe function; call it if the element goes away.
## The token names
Style with these. `./ctrl/theme.sh export` prints them with their current values.
```
surfaces --surface-0 --surface-1 --surface-2 --surface-3 --border
text --text-primary --text-secondary --text-dim --muted
status --status-idle --status-live --status-processing --status-error
--status-escalating --accent
spacing --space-1 (4px) --space-2 (8px) --space-3 (12px) --space-4 (16px)
--space-6 (24px)
type --font-ui --font-mono --font-size-sm (11px) --font-size-base (13px)
panel --panel-border --panel-radius --panel-header-height
```
## Worked example — "a page showing live GPU stats with a couple of knobs"
Read it off the table: live values arriving → **feed**. Knobs → **params**.
Boxes with titles → **panel**. Two resizable regions → **split**. `base` comes
along automatically. So:
```bash
./ctrl/theme.sh new "GPU stats" > soleprint/artery/veins/gpu/ui/index.html
# write a split-pane with two panels; call sprParams() in one and SprFeed() in the other
make theme bake # → parts: base feed panel params split
```
To hand the whole contract to an LLM instead of writing it yourself:
```bash
./ctrl/theme.sh export feed params panel split > /tmp/contract.md
```
`export` with no names gives every part. Name the ones you need and the document
shrinks — `panel` alone is 7.7 KB against 33 KB for all six. That selection is
the point: hand over everything and you get back a page built out of parts it
does not use.
## Where pages are found
`bake` and `check` scan **everything under `soleprint/`** for the anchor or a
`/theme.css` link. There is no list of pages to add yourself to — a list is how
`histgen`'s page went months without ever being baked. A page written outside
`soleprint/` is not found; that is the only placement rule.
## The ceiling, so it is not discovered the hard way
**A chart cannot be one of these parts.** The Vue `TimeSeriesRenderer` is uplot,
~40 KB of third-party code. Baking that into every page is the bundle this
approach exists to avoid, and fetching it breaks rule 2. Hand-drawn marks are the
limit — `example.html`'s bar strip is what that looks like. Past it, the choice
is to vendor uplot as a part deliberately, or to accept that a page needing a
real chart is a served page that can load one.
## Seeing it work
`example.html` in this directory carries every part at once — nested splits,
maximize, sliders, and a feed at a few hundred events a second with coalesced and
uncoalesced counters side by side. Open it directly; it needs no server. **No real
page should look like it**: the jira vein carries `base panel split`, the
mercadopago shunt carries `base panel`.
## How the parts stay honest
Each part names, in its own header, the file it was derived from. `make theme
check` compares the two token sets and reports when a part has fallen behind its
source. `maximize` names nothing because it has no upstream — nothing in
`common/ui` does maximize — and the check prints `cannot audit` rather than
pretending otherwise.

View File

@@ -0,0 +1,105 @@
/* part: base — element defaults, so a page has a shell without restating one.
*
* USE WHEN: always — it is baked whenever any other part is, and never alone.
* NEEDS: nothing
*
* ADD: paste this into the page, then run `make theme bake`.
* (nothing — it arrives with the first part you add)
*
* Derived from common/ui/src/base.css. It is the part with the most evidence
* behind it: four consumers needed this layer and each pasted its own copy.
*
* mts/ui/meetus-app/src/styles.css byte-identical to base.css, md5 d5761ed8…
* mts/ui/doocus-app/src/styles.css the same bytes again
* mpr/ui/detection-app/src/App.vue a third reset, plus ~12 hand-rolled buttons
* mpr/ui/common/styles/theme.css a fourth, for the React side
*
* tokens.css defines variables and styles no element, so a page that has only
* tokens still renders an OS-default <button> next to a themed panel. This is
* the missing half.
*
* ALWAYS: this part is baked whenever a page bakes any part at all. It styles
* elements, not classes, so there is nothing in the markup to detect it by --
* and a page that uses a panel but keeps OS-default buttons is not a thing
* anyone wants.
*
* TOKENS: --surface-0 --surface-2 --surface-3 --text-primary --panel-border
* --panel-radius --font-ui --font-size-base --space-1 --space-3
*
* TWO DELIBERATE CHANGES from the source, both because a part must travel:
*
* 1. No `#app` selector. The source sizes `html, body, #app` together, which
* assumes a Vue mount point. A standalone page has no #app, and every
* panel styles itself height:100%, so it would collapse to zero height.
* The height chain is `.fills` below, opt-in and named.
* 2. Inputs get padding. The source sets none, which is why doocus-app
* re-specified the whole input recipe inline five times over.
*/
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
}
body {
background: var(--surface-0);
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--font-size-base);
}
/* Opt in on <html>, <body> and the top element when the page is an app shell
* that should fill the viewport. Left off, the page scrolls like a document —
* which is what most ad-hoc vein pages actually want. */
.fills {
height: 100%;
width: 100%;
}
button {
font-family: var(--font-ui);
font-size: var(--font-size-base);
color: var(--text-primary);
background: var(--surface-2);
border: var(--panel-border);
border-radius: var(--panel-radius);
padding: var(--space-1) var(--space-3);
cursor: pointer;
}
button:hover:not(:disabled) {
background: var(--surface-3);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
input,
select,
textarea {
font-family: var(--font-ui);
font-size: var(--font-size-base);
color: var(--text-primary);
background: var(--surface-0);
border: var(--panel-border);
border-radius: 4px;
padding: var(--space-1) var(--space-2);
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--surface-3);
border-radius: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}

View File

@@ -0,0 +1,909 @@
<!DOCTYPE html>
<!--
The parts, doing the job they exist for — open it, no server needed.
This is the specimen. It is here rather than in a vein because it belongs to
the parts, and because `make theme check` keeps it honest: if a part changes
and this page is not rebaked, the check goes red.
It carries every part at once (base, panel, split, params, maximize, feed),
which no real page should — a real page carries what its markup uses, and the
two pages next door prove it: the jira vein has no params and no feed, the
mercadopago shunt has no split.
WHAT TO LOOK AT
· vertical and horizontal splits, nested, all draggable
· ⤢ on any panel header — maximize, Escape or the backdrop to come back
· sliders that are schema-driven, not hand-written markup
· a live feed at a few hundred events a second, with the coalesced and
uncoalesced counters side by side. That gap IS the point of onFrame().
With no server there is no EventSource to connect to, so the page drives the
same handlers from a local generator. That is not a mock of the transport for
its own sake: the thing being demonstrated is what happens to the DOM under
load, and that is identical either way.
-->
<html lang="en" data-theme="soleprint" class="fills">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>soleprint parts</title>
<!-- theme:here -->
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--font-mono: "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
--font-size-base: 13px;
--font-size-sm: 11px;
--font-ui: Inter, "Segoe UI", system-ui, -apple-system, Arial, sans-serif;
--muted: #8888a0;
--panel-border: 1px solid #2e2e38;
--panel-header-height: 36px;
--panel-radius: 6px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--status-error: #f06565;
--status-escalating: #f5a623;
--status-idle: #555568;
--status-live: #3ecf8e;
--status-processing: #4f9cf9;
--surface-0: #0d0d0f;
--surface-1: #16161a;
--surface-2: #1e1e24;
--surface-3: #2e2e38;
--text-dim: #555568;
--text-primary: #e8e8f0;
--text-secondary: #8888a0;
}
</style>
<!-- /theme:baked-defaults -->
<!-- theme:parts — generated by common/theme/bake.py; do not edit -->
<style>
/* part: base — common/theme/parts/base.css */
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
}
body {
background: var(--surface-0);
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--font-size-base);
}
/* Opt in on <html>, <body> and the top element when the page is an app shell
* that should fill the viewport. Left off, the page scrolls like a document —
* which is what most ad-hoc vein pages actually want. */
.fills {
height: 100%;
width: 100%;
}
button {
font-family: var(--font-ui);
font-size: var(--font-size-base);
color: var(--text-primary);
background: var(--surface-2);
border: var(--panel-border);
border-radius: var(--panel-radius);
padding: var(--space-1) var(--space-3);
cursor: pointer;
}
button:hover:not(:disabled) {
background: var(--surface-3);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
input,
select,
textarea {
font-family: var(--font-ui);
font-size: var(--font-size-base);
color: var(--text-primary);
background: var(--surface-0);
border: var(--panel-border);
border-radius: 4px;
padding: var(--space-1) var(--space-2);
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--surface-3);
border-radius: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
/* part: maximize — common/theme/parts/maximize.css */
.panel-maximized {
position: fixed;
inset: var(--space-4);
z-index: 1000;
}
/* The page behind it keeps its layout — only this panel moves — so a backdrop
* is what stops the rest showing through around the inset. */
.panel-maximize-backdrop {
position: fixed;
inset: 0;
z-index: 999;
background: var(--surface-0);
opacity: 0.85;
}
.panel-maximize-btn {
padding: 0 6px;
line-height: 1;
font-size: 13px;
}
/* part: panel — common/theme/parts/panel.css */
.panel {
position: relative;
background: var(--surface-1);
border: var(--panel-border);
border-radius: var(--panel-radius);
overflow: hidden;
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: center;
gap: var(--space-2);
height: var(--panel-header-height);
padding: 0 var(--space-3);
background: var(--surface-2);
border-bottom: var(--panel-border);
flex-shrink: 0;
}
.panel-title {
font-family: var(--font-ui);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.panel-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-2);
}
.panel-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background: var(--status-idle);
}
.panel-status.idle { background: var(--status-idle); }
.panel-status.live { background: var(--status-live); }
.panel-status.processing { background: var(--status-processing); }
.panel-status.error { background: var(--status-error); }
.panel-body {
flex: 1;
overflow: auto;
padding: var(--space-2);
min-height: 0;
}
/* part: params — common/theme/parts/params.css */
.param-editor {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.param-field {
display: flex;
flex-direction: column;
gap: 2px;
}
.bool-field {
flex-direction: row;
align-items: center;
gap: 6px;
cursor: pointer;
}
.field-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.field-label {
color: var(--text-secondary);
font-size: 10px;
text-transform: capitalize;
}
.field-value {
font-weight: 600;
font-size: 10px;
color: var(--text-primary);
min-width: 30px;
text-align: right;
}
.field-range {
display: flex;
justify-content: space-between;
font-size: 9px;
color: var(--text-dim);
}
input[type="range"] {
width: 100%;
height: 3px;
padding: 0;
border: 0;
border-radius: 2px;
background: var(--surface-3);
appearance: none;
-webkit-appearance: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 11px;
height: 11px;
border: 0;
border-radius: 50%;
background: var(--text-secondary);
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 11px;
height: 11px;
border: 0;
border-radius: 50%;
background: var(--text-secondary);
cursor: pointer;
}
/* part: split — common/theme/parts/split.css */
.split-pane {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.split-pane.horizontal {
flex-direction: row;
}
.split-pane.vertical {
flex-direction: column;
}
.split-first,
.split-second {
min-height: 0;
min-width: 0;
overflow: hidden;
flex: 1;
}
/* Children fill their pane. */
.split-first > *,
.split-second > * {
width: 100%;
height: 100%;
}
.split-divider {
flex-shrink: 0;
background: transparent;
transition: background 0.15s;
touch-action: none;
z-index: 10;
}
.split-divider:hover,
.split-divider.dragging {
background: var(--text-dim);
}
.split-pane.horizontal > .split-divider {
width: 4px;
cursor: col-resize;
margin: 0 -2px;
}
.split-pane.vertical > .split-divider {
height: 4px;
cursor: row-resize;
margin: -2px 0;
}
</style>
<script>
/* part: feed — common/theme/parts/feed.js */
(function (global) {
'use strict'
function SprFeed(opts) {
this.url = opts.url
this.events = opts.events || []
this.retries = opts.retries == null ? 10 : opts.retries
this.status = 'idle'
this.error = null
this.data = null
this._es = null
this._tries = 0
this._listeners = {}
this._frames = {}
}
SprFeed.prototype.on = function (type, handler) {
;(this._listeners[type] || (this._listeners[type] = [])).push(handler)
var self = this
return function () {
var list = self._listeners[type] || []
var i = list.indexOf(handler)
if (i >= 0) list.splice(i, 1)
}
}
/* Coalesced to one call per animation frame, newest payload wins. */
SprFeed.prototype.onFrame = function (type, handler) {
var self = this
return this.on(type, function (payload) {
var slot = self._frames[type] || (self._frames[type] = { pending: false, last: null })
slot.last = payload
if (slot.pending) return
slot.pending = true
global.requestAnimationFrame(function () {
slot.pending = false
handler(slot.last)
})
})
}
SprFeed.prototype._emit = function (type, payload) {
var list = this._listeners[type]
if (!list) return
// Copy first: a handler that unsubscribes itself would otherwise shorten
// the array mid-loop and skip its neighbour.
list.slice().forEach(function (fn) { fn(payload) })
}
SprFeed.prototype._setStatus = function (status) {
if (this.status === status) return
this.status = status
this._emit('status', status)
}
SprFeed.prototype.connect = function () {
if (this._es) return
var self = this
this._setStatus('connecting')
this.error = null
this._es = new EventSource(this.url)
this._es.onopen = function () {
self._tries = 0
self._setStatus('live')
}
this._es.onerror = function () {
if (!self._es || self._es.readyState !== EventSource.CLOSED) return
self._tries++
if (self._tries >= self.retries) {
self.error = 'Connection lost after ' + self.retries + ' retries'
self.disconnect()
self._setStatus('error')
self._emit('error', self.error)
} else {
self._setStatus('connecting')
}
}
this.events.forEach(function (type) {
self._es.addEventListener(type, function (e) {
var parsed
try {
parsed = JSON.parse(e.data)
} catch (_) {
return // malformed event, ignored — same as the source
}
self.data = parsed
self._emit(type, parsed)
})
})
// Terminal event: the producer says it is finished, success or not.
this._es.addEventListener('done', function () { self._setStatus('idle') })
}
SprFeed.prototype.disconnect = function () {
if (!this._es) return
this._es.close()
this._es = null
}
SprFeed.prototype.setUrl = function (url) {
this.url = url
if (this.status === 'live' || this.status === 'connecting') {
this.disconnect()
this.connect()
}
}
global.SprFeed = SprFeed
})(window)
</script>
<script>
/* part: maximize — common/theme/parts/maximize.js */
(function () {
'use strict'
var open = null
var backdrop = null
function restore() {
if (!open) return
open.panel.classList.remove('panel-maximized')
open.button.textContent = '⤢'
open.button.title = 'Maximize'
if (backdrop && backdrop.parentNode) backdrop.parentNode.removeChild(backdrop)
open = null
}
function maximize(panel, button) {
restore()
if (!backdrop) {
backdrop = document.createElement('div')
backdrop.className = 'panel-maximize-backdrop'
backdrop.addEventListener('click', restore)
}
document.body.appendChild(backdrop)
panel.classList.add('panel-maximized')
button.textContent = '⤡'
button.title = 'Restore'
open = { panel: panel, button: button }
}
function setup(panel) {
var actions = panel.querySelector(':scope > .panel-header > .panel-actions')
if (!actions) {
// The header has no actions strip, so there is nowhere to put the button.
// Say so rather than failing silently: the fix is one empty <span>.
var header = panel.querySelector(':scope > .panel-header')
if (!header) return
actions = document.createElement('span')
actions.className = 'panel-actions'
header.appendChild(actions)
}
var button = document.createElement('button')
button.className = 'panel-maximize-btn'
button.type = 'button'
button.textContent = '⤢'
button.title = 'Maximize'
button.addEventListener('click', function () {
if (open && open.panel === panel) restore()
else maximize(panel, button)
})
actions.appendChild(button)
}
function start() {
var panels = document.querySelectorAll('[data-maximize]')
for (var i = 0; i < panels.length; i++) setup(panels[i])
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') restore()
})
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start)
} else {
start()
}
})()
</script>
<script>
/* part: params — common/theme/parts/params.js */
(function (global) {
'use strict'
function el(tag, cls, text) {
var n = document.createElement(tag)
if (cls) n.className = cls
if (text != null) n.textContent = text
return n
}
function label(field) {
// The SFC strips a leading `edge_` and turns underscores into spaces; the
// capitalising is CSS. Same treatment, so a schema reads identically here.
return String(field.name).replace(/^edge_/, '').replace(/_/g, ' ')
}
function sprParams(root, fields, values, onUpdate) {
if (!root) return
root.classList.add('param-editor')
root.textContent = ''
var update = onUpdate || function () {}
fields.forEach(function (f) {
var value = values && values[f.name] != null ? values[f.name] : f.default
if (f.options && f.options.length) {
var wrap = el('div', 'param-field')
var head = el('div', 'field-header')
head.appendChild(el('span', 'field-label', label(f)))
wrap.appendChild(head)
var select = el('select')
f.options.forEach(function (opt) {
var o = el('option', null, opt)
o.value = opt
if (opt === value) o.selected = true
select.appendChild(o)
})
select.title = f.description || ''
select.addEventListener('change', function () { update(f.name, select.value) })
wrap.appendChild(select)
root.appendChild(wrap)
return
}
if (f.type === 'bool') {
var l = el('label', 'param-field bool-field')
var box = el('input')
box.type = 'checkbox'
box.checked = !!value
box.addEventListener('change', function () { update(f.name, box.checked) })
var name = el('span', 'field-label', label(f))
name.title = f.description || ''
l.appendChild(box)
l.appendChild(name)
root.appendChild(l)
return
}
if (f.type === 'int' || f.type === 'float') {
var min = f.min == null ? 0 : f.min
var max = f.max == null ? 500 : f.max
var field = el('div', 'param-field')
var header = el('div', 'field-header')
var title = el('span', 'field-label', label(f))
title.title = f.description || ''
var shown = el('span', 'field-value', String(value))
header.appendChild(title)
header.appendChild(shown)
var range = el('input')
range.type = 'range'
range.min = min
range.max = max
range.step = f.type === 'float' ? 0.01 : 1
range.value = value
range.addEventListener('input', function () {
var n = Number(range.value)
shown.textContent = range.value
update(f.name, n)
})
var ends = el('div', 'field-range')
ends.appendChild(el('span', null, String(min)))
ends.appendChild(el('span', null, String(max)))
field.appendChild(header)
field.appendChild(range)
field.appendChild(ends)
root.appendChild(field)
return
}
// Anything else: a text input rather than nothing. The SFC drops these
// silently, which is how an unrecognised type becomes a missing control.
var other = el('div', 'param-field')
var oh = el('div', 'field-header')
oh.appendChild(el('span', 'field-label', label(f)))
other.appendChild(oh)
var input = el('input')
input.type = 'text'
input.value = value == null ? '' : value
input.title = f.description || ''
input.addEventListener('input', function () { update(f.name, input.value) })
other.appendChild(input)
root.appendChild(other)
})
}
global.sprParams = sprParams
})(window)
</script>
<script>
/* part: split — common/theme/parts/split.js */
(function () {
'use strict'
function setup(root) {
var divider = root.querySelector(':scope > .split-divider')
if (!divider) return // no divider: a fixed split, deliberately
var first = root.querySelector(':scope > .split-first')
var second = root.querySelector(':scope > .split-second')
if (!first || !second) return
var horizontal = !root.classList.contains('vertical')
var mode = root.dataset.mode === 'px' ? 'px' : 'ratio'
var anchor = root.dataset.anchor === 'second' ? 'second' : 'first'
var size = parseFloat(root.dataset.size)
if (isNaN(size)) size = 1
var min = parseFloat(root.dataset.min)
if (isNaN(min)) min = mode === 'px' ? 0 : 0.1
var max = parseFloat(root.dataset.max)
if (isNaN(max)) max = mode === 'px' ? Infinity : 10
var sized = anchor === 'second' ? second : first
var flexed = anchor === 'second' ? first : second
var dragging = false
var startPos = 0
function apply() {
flexed.style.flex = '1'
if (mode === 'px') {
sized.style.flex = '0 0 auto'
sized.style[horizontal ? 'width' : 'height'] = size + 'px'
} else {
sized.style.flex = String(size)
}
}
divider.addEventListener('pointerdown', function (e) {
dragging = true
startPos = horizontal ? e.clientX : e.clientY
divider.classList.add('dragging')
divider.setPointerCapture(e.pointerId)
})
divider.addEventListener('pointermove', function (e) {
if (!dragging) return
var pos = horizontal ? e.clientX : e.clientY
var delta = pos - startPos
startPos = pos
// Dragging right/down grows the first pane. When the SECOND pane is the
// anchored one, that same gesture must shrink it, so invert.
if (anchor === 'second') delta = -delta
// Ratio mode is unitless, so pixels are scaled into it. The two constants
// are the SFC's, kept rather than re-derived: they are what the existing
// panes were tuned against, and vertical drags cover less travel.
var step = mode === 'px' ? delta : delta * (horizontal ? 0.01 : 0.02)
size = Math.max(min, Math.min(max, size + step))
apply()
})
function end(e) {
if (!dragging) return
dragging = false
divider.classList.remove('dragging')
if (e && e.pointerId !== undefined && divider.hasPointerCapture(e.pointerId)) {
divider.releasePointerCapture(e.pointerId)
}
}
divider.addEventListener('pointerup', end)
divider.addEventListener('pointercancel', end)
apply()
}
function start() {
var panes = document.querySelectorAll('[data-split]')
for (var i = 0; i < panes.length; i++) setup(panes[i])
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start)
} else {
start()
}
})()
</script>
<!-- /theme:parts -->
<style>
body { padding: var(--space-3); }
.fills { height: 100%; width: 100%; }
body.fills { display: flex; flex-direction: column; gap: var(--space-3); }
header { display: flex; align-items: baseline; gap: var(--space-3); flex-wrap: wrap; flex-shrink: 0; }
h1 { margin: 0; font-size: 18px; font-family: var(--font-ui); }
.sub { color: var(--muted); font-family: var(--font-mono); font-size: 12px; }
.stage { flex: 1; min-height: 0; }
.readout { display: flex; flex-direction: column; gap: var(--space-2); font-family: var(--font-mono); }
.metric { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-3); }
.metric .k { color: var(--text-secondary); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
.metric .v { font-size: 18px; font-weight: 600; }
.metric .v.warn { color: var(--status-escalating); }
.bars { display: flex; align-items: flex-end; gap: 2px; height: 90px; }
.bar { flex: 1; background: var(--status-processing); min-height: 1px; }
.log { font-family: var(--font-mono); font-size: 11px; line-height: 1.5; }
.log div { white-space: pre; color: var(--text-secondary); }
.note { color: var(--text-dim); font-size: 11px; margin: var(--space-2) 0 0; }
</style>
</head>
<body class="fills">
<header>
<h1>soleprint parts</h1>
<span class="sub">every part at once — a real page carries fewer</span>
<span class="sub" id="mode"></span>
</header>
<div class="stage">
<div class="split-pane horizontal" data-split data-size="1" data-min="0.35" data-max="3">
<div class="split-first">
<div class="split-pane vertical" data-split data-size="1.2" data-min="0.3" data-max="4">
<div class="split-first">
<div class="panel" data-maximize>
<div class="panel-header">
<span class="panel-title">Throughput</span>
<span class="panel-actions"></span>
<span class="panel-status idle" id="dot"></span>
</div>
<div class="panel-body">
<div class="readout">
<div class="metric"><span class="k">events in</span><span class="v" id="in">0</span></div>
<div class="metric"><span class="k">dom writes (onFrame)</span><span class="v" id="drawn">0</span></div>
<div class="metric"><span class="k">writes avoided</span><span class="v warn" id="saved">0</span></div>
</div>
<div class="bars" id="bars"></div>
<p class="note">Every bar is one frame's latest value. The third number is
what a naive handler would have written to the DOM and didn't.</p>
</div>
</div>
</div>
<div class="split-divider"></div>
<div class="split-second">
<div class="panel" data-maximize>
<div class="panel-header">
<span class="panel-title">Stream</span>
<span class="panel-actions"></span>
</div>
<div class="panel-body"><div class="log" id="log"></div></div>
</div>
</div>
</div>
</div>
<div class="split-divider"></div>
<div class="split-second">
<div class="panel" data-maximize>
<div class="panel-header">
<span class="panel-title">Parameters</span>
<span class="panel-actions"><button id="stop">pause</button></span>
</div>
<div class="panel-body">
<div id="cfg"></div>
<p class="note">Rendered by <code>sprParams()</code> from the field list below —
the same shape ParameterEditor.vue takes, including the
<code>options</code> enum it never rendered.</p>
</div>
</div>
</div>
</div>
</div>
<script>
const FIELDS = [
{ name: 'rate', type: 'int', default: 240, min: 10, max: 600, description: 'events per second', options: null },
{ name: 'amplitude', type: 'float', default: 0.6, min: 0, max: 1, description: 'signal swing', options: null },
{ name: 'shape', type: 'str', default: 'sine', min: null, max: null, description: 'waveform',
options: ['sine', 'saw', 'noise'] },
{ name: 'log_events', type: 'bool', default: true, min: null, max: null, description: 'append to the stream panel', options: null },
];
const values = {};
FIELDS.forEach((f) => { values[f.name] = f.default; });
const $ = (id) => document.getElementById(id);
let received = 0, drawn = 0, running = true, phase = 0;
const history = [];
sprParams($('cfg'), FIELDS, values, (name, v) => { values[name] = v; });
/* A SprFeed is what a served page would build:
*
* const feed = new SprFeed({ url: '/api/stream', events: ['tick'] })
* feed.on('status', s => dot.className = 'panel-status ' + s)
* feed.onFrame('tick', draw) // coalesced — one DOM write per frame
* feed.on('tick', () => received++)
* feed.connect()
*
* With no server, the same two handlers are driven directly below. */
const feed = new SprFeed({ url: '/api/stream', events: ['tick'] });
feed.on('status', (s) => { $('dot').className = 'panel-status ' + s; });
const onEvery = () => { received++; };
const onFrameDraw = (payload) => {
drawn++;
history.push(payload.value);
if (history.length > 60) history.shift();
$('in').textContent = received.toLocaleString();
$('drawn').textContent = drawn.toLocaleString();
$('saved').textContent = (received - drawn).toLocaleString();
const bars = $('bars');
while (bars.children.length < history.length) bars.appendChild(Object.assign(document.createElement('div'), { className: 'bar' }));
history.forEach((v, i) => { bars.children[i].style.height = Math.max(1, v * 100) + '%'; });
};
const served = location.protocol !== 'file:';
$('mode').textContent = served ? 'live feed at /api/stream' : 'not served — driven locally';
if (served) {
feed.on('tick', onEvery);
feed.onFrame('tick', onFrameDraw);
feed.connect();
} else {
// Same handlers, same coalescing, no transport.
const emit = feed._emit.bind(feed);
feed.on('tick', onEvery);
feed.onFrame('tick', onFrameDraw);
feed._setStatus('live');
setInterval(() => {
if (!running) return;
const n = Math.max(1, Math.round(values.rate / 60));
for (let i = 0; i < n; i++) {
phase += 0.05;
let v;
if (values.shape === 'saw') v = (phase % 6.28) / 6.28;
else if (values.shape === 'noise') v = Math.random();
else v = (Math.sin(phase) + 1) / 2;
emit('tick', { value: v * values.amplitude, seq: received + i });
}
if (values.log_events) {
const line = document.createElement('div');
line.textContent = new Date().toISOString().slice(11, 23) + ' tick seq=' + received;
$('log').prepend(line);
while ($('log').children.length > 80) $('log').lastChild.remove();
}
}, 16);
}
$('stop').onclick = () => {
running = !running;
$('stop').textContent = running ? 'pause' : 'resume';
feed._setStatus(running ? 'live' : 'idle');
};
</script>
</body>
</html>

View File

@@ -0,0 +1,173 @@
/* part: feed — a live data source, demultiplexed by event type.
*
* USE WHEN: values arrive over time from the server and the page must update itself.
* NEEDS: nothing — an SSE endpoint if you want real data, but it runs without one
*
* ADD: paste this into the page, then run `make theme bake`.
* <script>
* var feed = new SprFeed({ url: '/api/stream', events: ['tick'] })
* feed.on('status', function (s) { dot.className = 'panel-status ' + s })
* feed.onFrame('tick', function (latest) { …one DOM write per frame… })
* feed.connect()
* </script>
*
* Derived from common/ui/src/datasources/DataSource.ts and SSEDataSource.ts.
*
* DETECT: SprFeed
*
* This is the part that makes a page LIVE rather than a form. Without it the
* others are chrome: panel draws a box, split divides it, and nothing updates.
*
* The Vue original is already almost framework-free — its only coupling is
* three `ref()`s for data/status/error. mpr's React side re-implemented the
* same five moves in 92 lines (ui/chunker/src/hooks/useEventStream.ts) rather
* than depend on it, which is the evidence that it ports cleanly. This is the
* plain one, so there is no fourth.
*
* USAGE
* var feed = new SprFeed({
* url: '/api/stream/job-1',
* events: ['tick', 'log', 'stats'],
* retries: 10, // default 10
* })
* feed.on('tick', (payload) => { … }) // per event type
* feed.on('status', (s) => dot.className = 'panel-status ' + s)
* feed.connect()
*
* `on()` returns an unsubscribe function. Call it — every consumer in
* semester/ drops that return value today, which is a leak nobody has been
* bitten by only because their panels live as long as the page.
*
* STATUS values are the panel part's dot classes, deliberately: idle,
* connecting, live, error. `feed.on('status', …)` into `.panel-status` and the
* header dot tracks the transport with no glue.
*
* ONE THING THE SOURCE DOES NOT DO — high frequency.
*
* mpr's own spec says the panel layer does "render throttling via
* requestAnimationFrame". It does not; nothing in common/ui throttles anything,
* and at a few hundred events a second a handler that writes to the DOM will
* lay out per event and stall. So `onFrame()` is here and is NEW, not
* extracted: same subscription, but the handler runs at most once per animation
* frame with the most recent payload, and intermediate ones are dropped.
*
* feed.onFrame('tick', (latest) => { …one DOM write per frame… })
*
* Use `on()` when every event matters (logs, counters you increment) and
* `onFrame()` when only the newest does (gauges, charts, anything you redraw).
*/
(function (global) {
'use strict'
function SprFeed(opts) {
this.url = opts.url
this.events = opts.events || []
this.retries = opts.retries == null ? 10 : opts.retries
this.status = 'idle'
this.error = null
this.data = null
this._es = null
this._tries = 0
this._listeners = {}
this._frames = {}
}
SprFeed.prototype.on = function (type, handler) {
;(this._listeners[type] || (this._listeners[type] = [])).push(handler)
var self = this
return function () {
var list = self._listeners[type] || []
var i = list.indexOf(handler)
if (i >= 0) list.splice(i, 1)
}
}
/* Coalesced to one call per animation frame, newest payload wins. */
SprFeed.prototype.onFrame = function (type, handler) {
var self = this
return this.on(type, function (payload) {
var slot = self._frames[type] || (self._frames[type] = { pending: false, last: null })
slot.last = payload
if (slot.pending) return
slot.pending = true
global.requestAnimationFrame(function () {
slot.pending = false
handler(slot.last)
})
})
}
SprFeed.prototype._emit = function (type, payload) {
var list = this._listeners[type]
if (!list) return
// Copy first: a handler that unsubscribes itself would otherwise shorten
// the array mid-loop and skip its neighbour.
list.slice().forEach(function (fn) { fn(payload) })
}
SprFeed.prototype._setStatus = function (status) {
if (this.status === status) return
this.status = status
this._emit('status', status)
}
SprFeed.prototype.connect = function () {
if (this._es) return
var self = this
this._setStatus('connecting')
this.error = null
this._es = new EventSource(this.url)
this._es.onopen = function () {
self._tries = 0
self._setStatus('live')
}
this._es.onerror = function () {
if (!self._es || self._es.readyState !== EventSource.CLOSED) return
self._tries++
if (self._tries >= self.retries) {
self.error = 'Connection lost after ' + self.retries + ' retries'
self.disconnect()
self._setStatus('error')
self._emit('error', self.error)
} else {
self._setStatus('connecting')
}
}
this.events.forEach(function (type) {
self._es.addEventListener(type, function (e) {
var parsed
try {
parsed = JSON.parse(e.data)
} catch (_) {
return // malformed event, ignored — same as the source
}
self.data = parsed
self._emit(type, parsed)
})
})
// Terminal event: the producer says it is finished, success or not.
this._es.addEventListener('done', function () { self._setStatus('idle') })
}
SprFeed.prototype.disconnect = function () {
if (!this._es) return
this._es.close()
this._es = null
}
SprFeed.prototype.setUrl = function (url) {
this.url = url
if (this.status === 'live' || this.status === 'connecting') {
this.disconnect()
this.connect()
}
}
global.SprFeed = SprFeed
})(window)

View File

@@ -0,0 +1,60 @@
/* part: maximize — one panel fills the viewport, and comes back.
*
* USE WHEN: one panel is the main event and should be able to fill the screen.
* NEEDS: panel (it maximizes .panel; there is no other box)
*
* ADD: paste this into the page, then run `make theme bake`.
* <div class="panel" data-maximize> <!-- the attribute is the whole change -->
*
* NOT derived from anything. It is the one thing on the "components that work"
* list that the framework does not have: `maximize`, `fullscreen` and `expand`
* appear nowhere in common/ui. mts built a Teleport lightbox for frames and
* doocus built a collapse-to-bar; neither is this, and neither is reusable.
*
* So there is no `Derived from` line and the audit will say so. That is
* correct — this part has no upstream to drift from, and inventing a
* correspondence to make a check pass would be worse than the check printing
* "cannot audit".
*
* TOKENS: --surface-0 --space-4
*
* Needs maximize.js. Pairs with panel.css — it maximizes `.panel`, and there is
* no second box in this system to maximize.
*
* MARKUP add the attribute; the button is injected into .panel-actions.
* <div class="panel" data-maximize>
* <div class="panel-header">
* <span class="panel-title">Stream</span>
* <span class="panel-actions"></span>
* </div>
* <div class="panel-body">…</div>
* </div>
*
* `position: fixed` rather than the Fullscreen API on purpose: a fixed element
* still lives in the page, so a feed writing into it keeps working and Escape
* is ours to handle. The Fullscreen API also needs a user gesture and is
* refused outright in some embedded webviews — which is exactly where these
* pages get opened.
*/
.panel-maximized {
position: fixed;
inset: var(--space-4);
z-index: 1000;
}
/* The page behind it keeps its layout — only this panel moves — so a backdrop
* is what stops the rest showing through around the inset. */
.panel-maximize-backdrop {
position: fixed;
inset: 0;
z-index: 999;
background: var(--surface-0);
opacity: 0.85;
}
.panel-maximize-btn {
padding: 0 6px;
line-height: 1;
font-size: 13px;
}

View File

@@ -0,0 +1,78 @@
/* part: maximize (behaviour) — the toggle half of maximize.css.
*
* DETECT: data-maximize
*
* Injects a button into each `[data-maximize] .panel-actions`, so the markup
* stays a plain panel and nothing has to be wired by hand. Escape closes.
*
* Only one panel is maximized at a time: maximizing a second restores the
* first. Two fixed panels at the same inset would sit exactly on top of each
* other, which looks like a rendering bug rather than a state.
*/
(function () {
'use strict'
var open = null
var backdrop = null
function restore() {
if (!open) return
open.panel.classList.remove('panel-maximized')
open.button.textContent = '⤢'
open.button.title = 'Maximize'
if (backdrop && backdrop.parentNode) backdrop.parentNode.removeChild(backdrop)
open = null
}
function maximize(panel, button) {
restore()
if (!backdrop) {
backdrop = document.createElement('div')
backdrop.className = 'panel-maximize-backdrop'
backdrop.addEventListener('click', restore)
}
document.body.appendChild(backdrop)
panel.classList.add('panel-maximized')
button.textContent = '⤡'
button.title = 'Restore'
open = { panel: panel, button: button }
}
function setup(panel) {
var actions = panel.querySelector(':scope > .panel-header > .panel-actions')
if (!actions) {
// The header has no actions strip, so there is nowhere to put the button.
// Say so rather than failing silently: the fix is one empty <span>.
var header = panel.querySelector(':scope > .panel-header')
if (!header) return
actions = document.createElement('span')
actions.className = 'panel-actions'
header.appendChild(actions)
}
var button = document.createElement('button')
button.className = 'panel-maximize-btn'
button.type = 'button'
button.textContent = '⤢'
button.title = 'Maximize'
button.addEventListener('click', function () {
if (open && open.panel === panel) restore()
else maximize(panel, button)
})
actions.appendChild(button)
}
function start() {
var panels = document.querySelectorAll('[data-maximize]')
for (var i = 0; i < panels.length; i++) setup(panels[i])
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') restore()
})
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start)
} else {
start()
}
})()

View File

@@ -0,0 +1,112 @@
/* part: panel — the bordered box with an uppercase header and a status dot.
*
* USE WHEN: the page puts anything in a titled box, or needs a status light.
* NEEDS: nothing
*
* ADD: paste this into the page, then run `make theme bake`.
* <div class="panel">
* <div class="panel-header">
* <span class="panel-title">Title</span>
* <span class="panel-actions"></span>
* <span class="panel-status idle"></span>
* </div>
* <div class="panel-body">…</div>
* </div>
*
* Derived from common/ui/src/components/Panel.vue. It is the single most-reused
* thing in the framework: every consumer uses it, and nothing else is used by
* all of them.
*
* mpr/ui/detection-app 13 instantiations
* mts (meetus + doocus) 7
* unt/ui/app 4
* nvi/ui/app yes
*
* TOKENS: --surface-1 --surface-2 --panel-border --panel-radius
* --panel-header-height --font-ui --font-size-sm --text-secondary
* --space-2 --space-3 --status-idle --status-live --status-processing
* --status-error
*
* Class names are the SFC's own, unchanged. That is deliberate: mpr's React
* side already spells `.panel-header` + `<h2>` by hand
* (chunker/src/components/{ErrorLog,StatsPanel,QueueGauge}.tsx), so this part
* is adoptable there as-is. A third spelling of the same box is the disease,
* not the cure.
*
* MARKUP
* <div class="panel">
* <div class="panel-header">
* <span class="panel-title">Routes</span>
* <span class="panel-actions"><button>reload</button></span>
* <span class="panel-status live"></span>
* </div>
* <div class="panel-body">…</div>
* </div>
*
* `panel-actions` and `panel-status` are both optional. Measured before
* copying: the `actions` slot is filled in 6 of mts's 7 panels and 0 of mpr's
* 13 — so it is load-bearing and stays. The `overlay` slot is filled by NOBODY
* in any consumer, so it is not in this part. It comes back if something needs
* it, from the SFC that still has it.
*
* ONE DELIBERATE CHANGE: `.panel-body` scrolls (`overflow: auto`) where the SFC
* hides (`overflow: hidden`). In a Vue app the slotted child fills the body and
* does its own scrolling; on a plain page the body IS the content, and hidden
* silently truncates it.
*/
.panel {
position: relative;
background: var(--surface-1);
border: var(--panel-border);
border-radius: var(--panel-radius);
overflow: hidden;
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: center;
gap: var(--space-2);
height: var(--panel-header-height);
padding: 0 var(--space-3);
background: var(--surface-2);
border-bottom: var(--panel-border);
flex-shrink: 0;
}
.panel-title {
font-family: var(--font-ui);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.panel-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-2);
}
.panel-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background: var(--status-idle);
}
.panel-status.idle { background: var(--status-idle); }
.panel-status.live { background: var(--status-live); }
.panel-status.processing { background: var(--status-processing); }
.panel-status.error { background: var(--status-error); }
.panel-body {
flex: 1;
overflow: auto;
padding: var(--space-2);
min-height: 0;
}

View File

@@ -0,0 +1,129 @@
/* part: params — schema-driven sliders and checkboxes.
*
* USE WHEN: the page exposes numeric knobs, on/off toggles, or a choice from a list.
* NEEDS: nothing
*
* ADD: paste this into the page, then run `make theme bake`.
* <div id="cfg"></div>
* <script>
* sprParams(document.getElementById('cfg'), FIELDS, values, function (name, v) {
* values[name] = v
* })
* </script>
*
* Derived from common/ui/src/components/ParameterEditor.vue.
*
* TOKENS: --space-2 --surface-3 --text-dim --text-primary --text-secondary
*
* The sliders are real: ParameterEditor.vue:51 is `type="range"` with styled
* webkit and moz thumbs, and that thumb styling is the part nobody wants to
* write twice — a bare range input looks like 2003 next to a themed panel.
*
* Needs params.js to render from a field list. The CSS alone styles hand-written
* markup of the same shape, which is the useful failure mode.
*
* MARKUP (what params.js emits; write it by hand if you prefer)
* <div class="param-editor">
* <label class="param-field bool-field">
* <input type="checkbox"><span class="field-label">enabled</span>
* </label>
* <div class="param-field">
* <div class="field-header">
* <span class="field-label">threshold</span>
* <span class="field-value">120</span>
* </div>
* <input type="range" min="0" max="500" value="120">
* <div class="field-range"><span>0</span><span>500</span></div>
* </div>
* </div>
*
* Class names are the SFC's own, unchanged.
*
* TWO DELIBERATE CHANGES:
*
* 1. `options: string[]` renders. The type has carried it from the start and
* the SFC never read it — `numericFields` filters int/float and
* `boolFields` filters bool, so an enum parameter silently vanished from
* the form. Here it is a <select>.
* 2. Field order is the caller's. The SFC renders all booleans first and then
* all numbers, regardless of the order they were declared in, which
* scatters related controls. This keeps the list as given.
*/
.param-editor {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.param-field {
display: flex;
flex-direction: column;
gap: 2px;
}
.bool-field {
flex-direction: row;
align-items: center;
gap: 6px;
cursor: pointer;
}
.field-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.field-label {
color: var(--text-secondary);
font-size: 10px;
text-transform: capitalize;
}
.field-value {
font-weight: 600;
font-size: 10px;
color: var(--text-primary);
min-width: 30px;
text-align: right;
}
.field-range {
display: flex;
justify-content: space-between;
font-size: 9px;
color: var(--text-dim);
}
input[type="range"] {
width: 100%;
height: 3px;
padding: 0;
border: 0;
border-radius: 2px;
background: var(--surface-3);
appearance: none;
-webkit-appearance: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 11px;
height: 11px;
border: 0;
border-radius: 50%;
background: var(--text-secondary);
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 11px;
height: 11px;
border: 0;
border-radius: 50%;
background: var(--text-secondary);
cursor: pointer;
}

View File

@@ -0,0 +1,133 @@
/* part: params (behaviour) — render a field list into params.css's markup.
*
* DETECT: sprParams
*
* Derived from ParameterEditor.vue's template. Same field shape, so a schema
* that drives the Vue component drives this unchanged:
*
* { name, type: 'int'|'float'|'bool'|'str', default, description,
* min, max, options }
*
* USAGE
* var values = { threshold: 120, enabled: true, mode: 'fast' }
* sprParams(document.getElementById('cfg'), FIELDS, values, function (name, v) {
* values[name] = v
* feed.setUrl('/api/stream?' + new URLSearchParams(values))
* })
*
* The callback fires on every input event — a slider drag is many of them. If
* the change costs anything (a refetch, a reconnect), debounce it: the Vue
* side's useEditorExecution does this with a 150 ms default, and that number is
* the only part of it worth carrying over.
*/
(function (global) {
'use strict'
function el(tag, cls, text) {
var n = document.createElement(tag)
if (cls) n.className = cls
if (text != null) n.textContent = text
return n
}
function label(field) {
// The SFC strips a leading `edge_` and turns underscores into spaces; the
// capitalising is CSS. Same treatment, so a schema reads identically here.
return String(field.name).replace(/^edge_/, '').replace(/_/g, ' ')
}
function sprParams(root, fields, values, onUpdate) {
if (!root) return
root.classList.add('param-editor')
root.textContent = ''
var update = onUpdate || function () {}
fields.forEach(function (f) {
var value = values && values[f.name] != null ? values[f.name] : f.default
if (f.options && f.options.length) {
var wrap = el('div', 'param-field')
var head = el('div', 'field-header')
head.appendChild(el('span', 'field-label', label(f)))
wrap.appendChild(head)
var select = el('select')
f.options.forEach(function (opt) {
var o = el('option', null, opt)
o.value = opt
if (opt === value) o.selected = true
select.appendChild(o)
})
select.title = f.description || ''
select.addEventListener('change', function () { update(f.name, select.value) })
wrap.appendChild(select)
root.appendChild(wrap)
return
}
if (f.type === 'bool') {
var l = el('label', 'param-field bool-field')
var box = el('input')
box.type = 'checkbox'
box.checked = !!value
box.addEventListener('change', function () { update(f.name, box.checked) })
var name = el('span', 'field-label', label(f))
name.title = f.description || ''
l.appendChild(box)
l.appendChild(name)
root.appendChild(l)
return
}
if (f.type === 'int' || f.type === 'float') {
var min = f.min == null ? 0 : f.min
var max = f.max == null ? 500 : f.max
var field = el('div', 'param-field')
var header = el('div', 'field-header')
var title = el('span', 'field-label', label(f))
title.title = f.description || ''
var shown = el('span', 'field-value', String(value))
header.appendChild(title)
header.appendChild(shown)
var range = el('input')
range.type = 'range'
range.min = min
range.max = max
range.step = f.type === 'float' ? 0.01 : 1
range.value = value
range.addEventListener('input', function () {
var n = Number(range.value)
shown.textContent = range.value
update(f.name, n)
})
var ends = el('div', 'field-range')
ends.appendChild(el('span', null, String(min)))
ends.appendChild(el('span', null, String(max)))
field.appendChild(header)
field.appendChild(range)
field.appendChild(ends)
root.appendChild(field)
return
}
// Anything else: a text input rather than nothing. The SFC drops these
// silently, which is how an unrecognised type becomes a missing control.
var other = el('div', 'param-field')
var oh = el('div', 'field-header')
oh.appendChild(el('span', 'field-label', label(f)))
other.appendChild(oh)
var input = el('input')
input.type = 'text'
input.value = value == null ? '' : value
input.title = f.description || ''
input.addEventListener('input', function () { update(f.name, input.value) })
other.appendChild(input)
root.appendChild(other)
})
}
global.sprParams = sprParams
})(window)

View File

@@ -0,0 +1,95 @@
/* part: split — two panes and a draggable divider.
*
* USE WHEN: two regions the reader should be able to resize. Nest for three or more.
* NEEDS: nothing
*
* ADD: paste this into the page, then run `make theme bake`.
* <div class="split-pane horizontal" data-split data-size="1" data-min=".3" data-max="3">
* <div class="split-first">…</div>
* <div class="split-divider"></div>
* <div class="split-second">…</div>
* </div>
*
* Derived from common/ui/src/components/SplitPane.vue. Used by every consumer:
* mpr 7 sites, mts 4, unt and nvi one each.
*
* TOKENS: --text-dim
*
* Needs split.js for the drag. Without it the CSS still lays the panes out —
* the divider is simply inert, which is the right failure: a page with no
* script gets a fixed split, not a broken one.
*
* MARKUP
* <div class="split-pane horizontal" data-split data-size="1.4"
* data-min="0.4" data-max="4">
* <div class="split-first">…</div>
* <div class="split-divider"></div>
* <div class="split-second">…</div>
* </div>
*
* WHAT WAS LEFT OUT, and why — measured across all four consumers:
* `resizable={false}` passed by nobody, ever. A page that wants a fixed
* split omits the divider element.
* `anchor="second"` kept: one real user (mpr App.vue:199), three lines.
* px mode kept: mpr uses it, mts does not.
*
* The SFC's `> :deep(*) { width:100%; height:100% }` becomes a plain child
* selector here. Same rule, no scoping compiler.
*/
.split-pane {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.split-pane.horizontal {
flex-direction: row;
}
.split-pane.vertical {
flex-direction: column;
}
.split-first,
.split-second {
min-height: 0;
min-width: 0;
overflow: hidden;
flex: 1;
}
/* Children fill their pane. */
.split-first > *,
.split-second > * {
width: 100%;
height: 100%;
}
.split-divider {
flex-shrink: 0;
background: transparent;
transition: background 0.15s;
touch-action: none;
z-index: 10;
}
.split-divider:hover,
.split-divider.dragging {
background: var(--text-dim);
}
.split-pane.horizontal > .split-divider {
width: 4px;
cursor: col-resize;
margin: 0 -2px;
}
.split-pane.vertical > .split-divider {
height: 4px;
cursor: row-resize;
margin: -2px 0;
}

View File

@@ -0,0 +1,112 @@
/* part: split (behaviour) — the drag half of split.css.
*
* Derived from common/ui/src/components/SplitPane.vue's pointer handlers. Plain
* DOM, no framework, no build step, no imports: a <script> tag on a page opened
* over file:// runs this as-is.
*
* The drag-delta idiom this implements is currently written FOUR times in
* semester/ — SplitPane.vue, ResizeHandle.vue, mpr's FrameStrip.vue, and
* mpr/ui/timeline's Timeline.tsx. This is the plain-HTML one, so the ad-hoc
* pages stop making it five.
*
* USAGE <script src="split.js"></script> (or paste it; it self-starts)
*
* <div class="split-pane horizontal" data-split
* data-size="1.4" data-min="0.4" data-max="4">
*
* data-split marks the element. Required.
* data-size (default 1) initial size of the anchored pane
* data-mode ratio | px (default ratio)
* data-anchor first | second (default first)
* data-min / data-max clamps, in the same unit as data-size
*
* Direction comes from the `horizontal` / `vertical` class, so CSS and JS
* cannot disagree about it.
*/
(function () {
'use strict'
function setup(root) {
var divider = root.querySelector(':scope > .split-divider')
if (!divider) return // no divider: a fixed split, deliberately
var first = root.querySelector(':scope > .split-first')
var second = root.querySelector(':scope > .split-second')
if (!first || !second) return
var horizontal = !root.classList.contains('vertical')
var mode = root.dataset.mode === 'px' ? 'px' : 'ratio'
var anchor = root.dataset.anchor === 'second' ? 'second' : 'first'
var size = parseFloat(root.dataset.size)
if (isNaN(size)) size = 1
var min = parseFloat(root.dataset.min)
if (isNaN(min)) min = mode === 'px' ? 0 : 0.1
var max = parseFloat(root.dataset.max)
if (isNaN(max)) max = mode === 'px' ? Infinity : 10
var sized = anchor === 'second' ? second : first
var flexed = anchor === 'second' ? first : second
var dragging = false
var startPos = 0
function apply() {
flexed.style.flex = '1'
if (mode === 'px') {
sized.style.flex = '0 0 auto'
sized.style[horizontal ? 'width' : 'height'] = size + 'px'
} else {
sized.style.flex = String(size)
}
}
divider.addEventListener('pointerdown', function (e) {
dragging = true
startPos = horizontal ? e.clientX : e.clientY
divider.classList.add('dragging')
divider.setPointerCapture(e.pointerId)
})
divider.addEventListener('pointermove', function (e) {
if (!dragging) return
var pos = horizontal ? e.clientX : e.clientY
var delta = pos - startPos
startPos = pos
// Dragging right/down grows the first pane. When the SECOND pane is the
// anchored one, that same gesture must shrink it, so invert.
if (anchor === 'second') delta = -delta
// Ratio mode is unitless, so pixels are scaled into it. The two constants
// are the SFC's, kept rather than re-derived: they are what the existing
// panes were tuned against, and vertical drags cover less travel.
var step = mode === 'px' ? delta : delta * (horizontal ? 0.01 : 0.02)
size = Math.max(min, Math.min(max, size + step))
apply()
})
function end(e) {
if (!dragging) return
dragging = false
divider.classList.remove('dragging')
if (e && e.pointerId !== undefined && divider.hasPointerCapture(e.pointerId)) {
divider.releasePointerCapture(e.pointerId)
}
}
divider.addEventListener('pointerup', end)
divider.addEventListener('pointercancel', end)
apply()
}
function start() {
var panes = document.querySelectorAll('[data-split]')
for (var i = 0; i < panes.length; i++) setup(panes[i])
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start)
} else {
start()
}
})()

View File

@@ -3,13 +3,25 @@
<head>
<meta charset="utf-8">
<title>histgen — station</title>
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--accent: #d4a574;
--border: #2e2e38;
--surface-0: #0d0d0f;
--surface-1: #16161a;
--text-primary: #e8e8f0;
--text-secondary: #8888a0;
}
</style>
<!-- /theme:baked-defaults -->
<link rel="stylesheet" href="/theme.css">
<style>
body { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--surface-0, #101418); color: var(--text-0, #d8dee4);
background: var(--surface-0, #101418); color: var(--text-primary, #d8dee4);
margin: 0; padding: 2rem; line-height: 1.55; }
h1 { margin: 0 0 .25rem; font-size: 1.4rem; }
p.lede { margin: 0 0 1.5rem; color: var(--text-1, #8b98a5); }
p.lede { margin: 0 0 1.5rem; color: var(--text-secondary, #8b98a5); }
form { display: flex; gap: .5rem; margin-bottom: 1.5rem; }
input, button { font: inherit; padding: .45rem .7rem;
background: var(--surface-1, #161c22); color: inherit;
@@ -20,10 +32,10 @@
background: var(--surface-1, #161c22); }
.n { color: var(--accent, #4fb3a6); }
.title { font-weight: 600; }
.untitled { color: var(--text-1, #8b98a5); font-style: italic; }
.paths { margin: .4rem 0 0; padding-left: 1.1rem; color: var(--text-1, #8b98a5);
.untitled { color: var(--text-secondary, #8b98a5); font-style: italic; }
.paths { margin: .4rem 0 0; padding-left: 1.1rem; color: var(--text-secondary, #8b98a5);
font-size: .87rem; }
#summary { color: var(--text-1, #8b98a5); margin-bottom: 1rem; }
#summary { color: var(--text-secondary, #8b98a5); margin-bottom: 1rem; }
.err { color: #e08a5c; }
</style>
</head>