feature matching for clean extraction
This commit is contained in:
7
Makefile
7
Makefile
@@ -42,7 +42,7 @@ $(eval $(ARGS):;@:)
|
|||||||
endif
|
endif
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.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
|
help: ## list targets
|
||||||
@grep -hE '^[a-z]+:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16
|
@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>]
|
dist: ## compile the plexus UIs to single files [<room>]
|
||||||
bash ctrl/dist.sh $(or $(ARGS),$(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 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
docs: ## documentation [serve [port]|graphs [theme]] (default serve)
|
docs: ## documentation [serve [port]|graphs [theme]] (default serve)
|
||||||
|
|||||||
58
ctrl/theme.sh
Executable file
58
ctrl/theme.sh
Executable 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
|
||||||
@@ -1,296 +1,411 @@
|
|||||||
<!DOCTYPE html>
|
<!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>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>MercadoPago API (MOCK) - Configuration</title>
|
<title>MercadoPago API (MOCK) - Configuration</title>
|
||||||
|
<!-- theme:here -->
|
||||||
|
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
:root {
|
||||||
body {
|
--accent: #d4a574;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
--font-mono: "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
|
||||||
background: #111827;
|
--font-size-base: 13px;
|
||||||
color: #e5e7eb;
|
--font-size-sm: 11px;
|
||||||
padding: 20px;
|
--font-ui: Inter, "Segoe UI", system-ui, -apple-system, Arial, sans-serif;
|
||||||
}
|
--label-spacing: 0.04em;
|
||||||
.container { max-width: 1200px; margin: 0 auto; }
|
--muted: #8888a0;
|
||||||
header {
|
--panel-border: 1px solid #2e2e38;
|
||||||
background: #0071f2;
|
--panel-header-height: 36px;
|
||||||
color: white;
|
--panel-radius: 6px;
|
||||||
padding: 20px;
|
--space-1: 4px;
|
||||||
border-radius: 8px;
|
--space-2: 8px;
|
||||||
margin-bottom: 24px;
|
--space-3: 12px;
|
||||||
}
|
--space-4: 16px;
|
||||||
h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 8px; }
|
--space-6: 24px;
|
||||||
.subtitle { opacity: 0.9; font-size: 0.875rem; }
|
--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 {
|
.mock-badge {
|
||||||
display: inline-block;
|
display: inline-block; margin-left: var(--space-2);
|
||||||
background: white;
|
padding: 2px 8px; border-radius: 3px;
|
||||||
color: #0071f2;
|
background: var(--status-escalating); color: var(--surface-0);
|
||||||
padding: 4px 12px;
|
font-size: 10px; font-weight: 600; text-transform: uppercase;
|
||||||
border-radius: 4px;
|
letter-spacing: var(--label-spacing, .08em); vertical-align: middle;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
.form-input, .form-textarea, .form-select {
|
|
||||||
width: 100%;
|
.panel { margin-bottom: var(--space-4); }
|
||||||
padding: 10px 12px;
|
.lede { color: var(--muted); font-size: 12px; margin: 0 0 var(--space-3); }
|
||||||
background: #374151;
|
|
||||||
border: 1px solid #4b5563;
|
.grid {
|
||||||
border-radius: 6px;
|
display: grid; gap: var(--space-2);
|
||||||
color: #e5e7eb;
|
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
.btn-primary {
|
|
||||||
background: #0071f2;
|
/* Selectable cards — one treatment, used by both lists. */
|
||||||
color: white;
|
.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; }
|
.card:hover:not(:disabled) { border-color: var(--accent); background: var(--surface-3); }
|
||||||
.btn-secondary {
|
.card.on { border-color: var(--accent); background: var(--surface-3); }
|
||||||
background: #4b5563;
|
.card-name { font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
|
||||||
color: #e5e7eb;
|
.card-desc { font-size: 11px; color: var(--muted); }
|
||||||
margin-left: 8px;
|
|
||||||
|
.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; }
|
.verb.POST { color: var(--status-live); }
|
||||||
.status-grid {
|
.verb.GET { color: var(--status-processing); }
|
||||||
display: grid;
|
.path { font-family: var(--font-mono); font-size: 12px; }
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
||||||
gap: 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 {
|
.field input, .field textarea { width: 100%; font-family: var(--font-mono); font-size: 12px; }
|
||||||
background: #374151;
|
.field textarea { min-height: 180px; resize: vertical; }
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
.actions { display: flex; gap: var(--space-2); }
|
||||||
cursor: pointer;
|
.primary { background: var(--accent); color: var(--surface-0); border-color: transparent; }
|
||||||
border: 2px solid 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; }
|
.note { color: var(--text-dim); font-size: 11px; margin: var(--space-2) 0 0; }
|
||||||
.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; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<h1>MercadoPago <span class="mock-badge">MOCK</span></h1>
|
<h1>MercadoPago <span class="mock-badge">mock</span></h1>
|
||||||
<div class="subtitle">Configure mock payment responses and behavior</div>
|
<div class="subtitle">Configure mock payment responses and behaviour</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Payment Status Configuration -->
|
<div class="panel">
|
||||||
<div class="section">
|
<div class="panel-header">
|
||||||
<div class="section-header">Default Payment Status</div>
|
<span class="panel-title">Default payment status</span>
|
||||||
<p style="color: #9ca3af; margin-bottom: 16px;">Choose what status new payments should return:</p>
|
<span class="panel-status live"></span>
|
||||||
<div class="status-grid">
|
</div>
|
||||||
<div class="status-option selected" onclick="selectStatus('approved')">
|
<div class="panel-body">
|
||||||
<div class="status-name">Approved</div>
|
<p class="lede">What status new payments should return.</p>
|
||||||
<div class="status-desc">Payment successful</div>
|
<div class="grid" id="statuses"></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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Endpoint Configuration -->
|
<div class="panel">
|
||||||
<div class="section">
|
<div class="panel-header">
|
||||||
<div class="section-header">Configure Endpoint Responses</div>
|
<span class="panel-title">Endpoint responses</span>
|
||||||
<div class="endpoint-list">
|
<span class="panel-actions"><span class="card-desc" id="count"></span></span>
|
||||||
<div class="endpoint-card" onclick="selectEndpoint('POST', '/checkout/preferences', 'preference')">
|
</div>
|
||||||
<div>
|
<div class="panel-body">
|
||||||
<span class="endpoint-method method-post">POST</span>
|
<div class="grid" id="endpoints"></div>
|
||||||
<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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Response Editor -->
|
<div class="panel" id="editor" hidden>
|
||||||
<div class="section" id="responseEditor" style="display: none;">
|
<div class="panel-header">
|
||||||
<div class="section-header">Edit Response</div>
|
<span class="panel-title">Edit response</span>
|
||||||
<div class="form-group">
|
<span class="panel-status processing"></span>
|
||||||
<label class="form-label">Endpoint</label>
|
</div>
|
||||||
<input class="form-input" id="endpointDisplay" readonly>
|
<div class="panel-body">
|
||||||
|
<div class="field">
|
||||||
|
<label for="endpointDisplay">Endpoint</label>
|
||||||
|
<input id="endpointDisplay" readonly>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="field">
|
||||||
<label class="form-label">Mock Response (JSON)</label>
|
<label for="responseJson">Mock response (JSON)</label>
|
||||||
<textarea class="form-textarea" id="responseJson" placeholder='{"id": "123456", "status": "approved", "_mock": "MercadoPago"}'></textarea>
|
<textarea id="responseJson"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="field">
|
||||||
<label class="form-label">HTTP Status Code</label>
|
<label for="statusCode">HTTP status code</label>
|
||||||
<input type="number" class="form-input" id="statusCode" value="200">
|
<input type="number" id="statusCode" value="200">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="field">
|
||||||
<label class="form-label">Delay (ms)</label>
|
<label for="delay">Delay (ms)</label>
|
||||||
<input type="number" class="form-input" id="delay" value="0">
|
<input type="number" id="delay" value="0">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="actions">
|
||||||
<button class="btn btn-primary" onclick="saveResponse()">Save Response</button>
|
<button class="primary" onclick="saveResponse()">Save response</button>
|
||||||
<button class="btn btn-secondary" onclick="closeEditor()">Cancel</button>
|
<button onclick="closeEditor()">Cancel</button>
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Quick Test -->
|
<div class="panel">
|
||||||
<div class="section">
|
<div class="panel-header"><span class="panel-title">Quick test</span></div>
|
||||||
<div class="section-header">Quick Test</div>
|
<div class="panel-body">
|
||||||
<p style="color: #9ca3af; margin-bottom: 12px;">Test endpoint URL to hit for configured responses:</p>
|
<p class="lede">Hit this URL to get the configured responses.</p>
|
||||||
<div class="form-input" style="background: #374151; user-select: all;">
|
<div class="url">http://localhost:8006/v1/payments</div>
|
||||||
http://localhost:8006/v1/payments
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let selectedEndpoint = null;
|
const STATUSES = [
|
||||||
let selectedPaymentStatus = 'approved';
|
{ 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) {
|
const ENDPOINTS = [
|
||||||
selectedPaymentStatus = status;
|
{ verb: 'POST', path: '/checkout/preferences', type: 'preference', desc: 'Create payment preference (Checkout Pro)' },
|
||||||
document.querySelectorAll('.status-option').forEach(opt => opt.classList.remove('selected'));
|
{ verb: 'POST', path: '/v1/payments', type: 'payment', desc: 'Create payment (Checkout API)' },
|
||||||
event.currentTarget.classList.add('selected');
|
{ 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) {
|
let paymentStatus = 'approved';
|
||||||
selectedEndpoint = {method, path, type};
|
let selected = null;
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultResponse(type) {
|
const $ = (id) => document.getElementById(id);
|
||||||
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] || '{}';
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveResponse() {
|
function pick(container, el) {
|
||||||
alert('Mock response saved (feature pending implementation)');
|
container.querySelectorAll('.card').forEach((c) => c.classList.remove('on'));
|
||||||
}
|
el.classList.add('on');
|
||||||
|
}
|
||||||
|
|
||||||
function closeEditor() {
|
STATUSES.forEach((s, i) => {
|
||||||
document.getElementById('responseEditor').style.display = 'none';
|
const b = document.createElement('button');
|
||||||
selectedEndpoint = null;
|
b.className = 'card' + (i === 0 ? ' on' : '');
|
||||||
document.querySelectorAll('.endpoint-card').forEach(c => c.classList.remove('active'));
|
b.innerHTML = '<div class="card-name"></div><div class="card-desc"></div>';
|
||||||
}
|
b.firstChild.textContent = s.name;
|
||||||
</script>
|
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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,13 +2,35 @@
|
|||||||
Jira Vein - FastAPI app.
|
Jira Vein - FastAPI app.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from .api.routes import router
|
from .api.routes import router
|
||||||
from .core.config import settings
|
from .core.config import settings
|
||||||
|
|
||||||
app = FastAPI(title="Jira Vein", version="0.1.0")
|
app = FastAPI(title="Jira Vein", version="0.1.0")
|
||||||
app.include_router(router)
|
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__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|||||||
496
soleprint/artery/veins/jira/ui/index.html
Normal file
496
soleprint/artery/veins/jira/ui/index.html
Normal 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 · 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>
|
||||||
@@ -35,19 +35,24 @@ SPR_ROOT = HERE.parent.parent # soleprint/
|
|||||||
TOKENS = HERE / "tokens.css"
|
TOKENS = HERE / "tokens.css"
|
||||||
DEFAULT_THEME = HERE / "themes" / "soleprint.css"
|
DEFAULT_THEME = HERE / "themes" / "soleprint.css"
|
||||||
|
|
||||||
|
PARTS = HERE / "parts"
|
||||||
|
|
||||||
BEGIN = "<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->"
|
BEGIN = "<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->"
|
||||||
END = "<!-- /theme:baked-defaults -->"
|
END = "<!-- /theme:baked-defaults -->"
|
||||||
|
|
||||||
# Pages that link /theme.css and therefore need a default to fall back on.
|
PARTS_BEGIN = "<!-- theme:parts — generated by common/theme/bake.py; do not edit -->"
|
||||||
PAGES = [
|
PARTS_END = "<!-- /theme:parts -->"
|
||||||
"index.html",
|
|
||||||
"artery/index.html",
|
# A page with no `/theme.css` link says where the blocks go with this. That is
|
||||||
"atlas/index.html",
|
# the whole opt-in for a standalone page: it cannot fetch a stylesheet, so there
|
||||||
"station/index.html",
|
# is no <link> to anchor on.
|
||||||
"station/tools/datagen/templates/index.html",
|
ANCHOR = "<!-- theme:here -->"
|
||||||
"station/tools/graphgen/templates/index.html",
|
|
||||||
"station/tools/shuntgen/templates/index.html",
|
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]:
|
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}
|
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]:
|
def used(html: str) -> set[str]:
|
||||||
"""Variables a page references, ignoring the baked block itself."""
|
"""Variables a page references, ignoring the baked blocks themselves."""
|
||||||
body = re.sub(re.escape(BEGIN) + r".*?" + re.escape(END), "", html, flags=re.S)
|
return set(re.findall(r"var\(\s*(--[\w-]+)", strip_blocks(html)))
|
||||||
return set(re.findall(r"var\(\s*(--[\w-]+)", body))
|
|
||||||
|
|
||||||
|
|
||||||
def block(names: set[str], values: dict[str, str], indent: str) -> str:
|
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)
|
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."""
|
"""Return (changed, note) for one page."""
|
||||||
html = path.read_text()
|
html = path.read_text()
|
||||||
|
original = html
|
||||||
|
|
||||||
link = re.search(r'([ \t]*)<link rel="stylesheet" href="/theme.css">', html)
|
link = re.search(r"([ \t]*)" + re.escape(LINK), html)
|
||||||
if not link:
|
anchor = re.search(r"([ \t]*)" + re.escape(ANCHOR), html)
|
||||||
return False, "no /theme.css link — skipped"
|
if link:
|
||||||
|
# Before the link, never after: document order is what makes the served
|
||||||
indent = link.group(1)
|
# stylesheet win over the baked one.
|
||||||
names = used(html)
|
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:
|
if not names:
|
||||||
return False, "uses no theme variables — skipped"
|
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:
|
def scaffold(title: str) -> int:
|
||||||
return False, f"up to date ({len(names)} variables)"
|
"""The simple page you start from, before any feature is on it.
|
||||||
path.write_text(updated)
|
|
||||||
return True, f"baked {len(names)} variables"
|
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:
|
def main() -> int:
|
||||||
check = "--check" in sys.argv
|
check = "--check" in sys.argv
|
||||||
values = palette()
|
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]
|
missing = [n for n, v in values.items() if not v]
|
||||||
if missing:
|
if missing:
|
||||||
print(f"warning: unresolved tokens: {', '.join(sorted(missing))}", file=sys.stderr)
|
print(f"warning: unresolved tokens: {', '.join(sorted(missing))}", file=sys.stderr)
|
||||||
|
|
||||||
stale = []
|
stale = []
|
||||||
for rel in PAGES:
|
for path in pages():
|
||||||
path = SPR_ROOT / rel
|
rel = path.relative_to(SPR_ROOT)
|
||||||
if not path.exists():
|
|
||||||
print(f" {rel}: not found")
|
|
||||||
continue
|
|
||||||
if check:
|
if check:
|
||||||
before = path.read_text()
|
before = path.read_text()
|
||||||
changed, note = bake(path, values)
|
changed, note = bake(path, values, parts)
|
||||||
if changed:
|
if changed:
|
||||||
path.write_text(before)
|
path.write_text(before)
|
||||||
stale.append(rel)
|
stale.append(str(rel))
|
||||||
print(f" {rel}: STALE")
|
print(f" {rel}: STALE")
|
||||||
else:
|
else:
|
||||||
print(f" {rel}: {note}")
|
print(f" {rel}: {note}")
|
||||||
else:
|
else:
|
||||||
_, note = bake(path, values)
|
_, note = bake(path, values, parts)
|
||||||
print(f" {rel}: {note}")
|
print(f" {rel}: {note}")
|
||||||
|
|
||||||
|
print("\nparts:")
|
||||||
|
failures = audit(parts, values)
|
||||||
|
|
||||||
if check and stale:
|
if check and stale:
|
||||||
print(f"\n{len(stale)} page(s) stale — run: python3 common/theme/bake.py", file=sys.stderr)
|
print(f"\n{len(stale)} page(s) stale — run: python3 common/theme/bake.py", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
return 0
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
206
soleprint/common/theme/parts/README.md
Normal file
206
soleprint/common/theme/parts/README.md
Normal 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.
|
||||||
105
soleprint/common/theme/parts/base.css
Normal file
105
soleprint/common/theme/parts/base.css
Normal 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;
|
||||||
|
}
|
||||||
909
soleprint/common/theme/parts/example.html
Normal file
909
soleprint/common/theme/parts/example.html
Normal 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>
|
||||||
173
soleprint/common/theme/parts/feed.js
Normal file
173
soleprint/common/theme/parts/feed.js
Normal 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)
|
||||||
60
soleprint/common/theme/parts/maximize.css
Normal file
60
soleprint/common/theme/parts/maximize.css
Normal 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;
|
||||||
|
}
|
||||||
78
soleprint/common/theme/parts/maximize.js
Normal file
78
soleprint/common/theme/parts/maximize.js
Normal 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()
|
||||||
|
}
|
||||||
|
})()
|
||||||
112
soleprint/common/theme/parts/panel.css
Normal file
112
soleprint/common/theme/parts/panel.css
Normal 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;
|
||||||
|
}
|
||||||
129
soleprint/common/theme/parts/params.css
Normal file
129
soleprint/common/theme/parts/params.css
Normal 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;
|
||||||
|
}
|
||||||
133
soleprint/common/theme/parts/params.js
Normal file
133
soleprint/common/theme/parts/params.js
Normal 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)
|
||||||
95
soleprint/common/theme/parts/split.css
Normal file
95
soleprint/common/theme/parts/split.css
Normal 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;
|
||||||
|
}
|
||||||
112
soleprint/common/theme/parts/split.js
Normal file
112
soleprint/common/theme/parts/split.js
Normal 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()
|
||||||
|
}
|
||||||
|
})()
|
||||||
@@ -3,13 +3,25 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>histgen — station</title>
|
<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">
|
<link rel="stylesheet" href="/theme.css">
|
||||||
<style>
|
<style>
|
||||||
body { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
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; }
|
margin: 0; padding: 2rem; line-height: 1.55; }
|
||||||
h1 { margin: 0 0 .25rem; font-size: 1.4rem; }
|
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; }
|
form { display: flex; gap: .5rem; margin-bottom: 1.5rem; }
|
||||||
input, button { font: inherit; padding: .45rem .7rem;
|
input, button { font: inherit; padding: .45rem .7rem;
|
||||||
background: var(--surface-1, #161c22); color: inherit;
|
background: var(--surface-1, #161c22); color: inherit;
|
||||||
@@ -20,10 +32,10 @@
|
|||||||
background: var(--surface-1, #161c22); }
|
background: var(--surface-1, #161c22); }
|
||||||
.n { color: var(--accent, #4fb3a6); }
|
.n { color: var(--accent, #4fb3a6); }
|
||||||
.title { font-weight: 600; }
|
.title { font-weight: 600; }
|
||||||
.untitled { color: var(--text-1, #8b98a5); font-style: italic; }
|
.untitled { color: var(--text-secondary, #8b98a5); font-style: italic; }
|
||||||
.paths { margin: .4rem 0 0; padding-left: 1.1rem; color: var(--text-1, #8b98a5);
|
.paths { margin: .4rem 0 0; padding-left: 1.1rem; color: var(--text-secondary, #8b98a5);
|
||||||
font-size: .87rem; }
|
font-size: .87rem; }
|
||||||
#summary { color: var(--text-1, #8b98a5); margin-bottom: 1rem; }
|
#summary { color: var(--text-secondary, #8b98a5); margin-bottom: 1rem; }
|
||||||
.err { color: #e08a5c; }
|
.err { color: #e08a5c; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Reference in New Issue
Block a user