Files
soleprint/soleprint/artery/veins/jira/ui/index.html

497 lines
15 KiB
HTML

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