feat(triage): mode triage — vider le backlog au clavier (issue #11)

/triage (sélecteur par taille de backlog) + /triage/{o}/{r}
(session plein cadre, file embarquée): 1-5 colonnes, X fermer,
L étiquettes, A s'assigner (nouvel endpoint assign-self +
client.set_issue_assignees, idempotent), Entrée suivante, Z annuler.
Progression + résumé de session; décisions via /api/move et les
API existantes. Entrées: board + palette.
This commit is contained in:
Spark
2026-10-11 13:17:21 -04:00
parent 25040f974c
commit 8a32d080b5
8 changed files with 539 additions and 0 deletions
+12
View File
@@ -340,3 +340,15 @@ class GiteaClient:
r.raise_for_status() r.raise_for_status()
self._cache_clear() self._cache_clear()
return r.json() return r.json()
async def set_issue_assignees(
self, owner: str, repo: str, issue_number: int, logins: list[str]
) -> dict:
"""Replace the assignee set of an issue (triage "assign me")."""
r = await self.client.patch(
f"/repos/{owner}/{repo}/issues/{issue_number}",
json={"assignees": logins},
)
r.raise_for_status()
self._cache_clear()
return r.json()
+102
View File
@@ -20,6 +20,7 @@ import hashlib
import hmac import hmac
import json import json
import logging import logging
import re
import secrets import secrets
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -686,6 +687,107 @@ async def _collect_focus_groups() -> list[dict]:
return groups return groups
@app.post("/api/issues/{owner}/{repo}/{number}/assign-self")
async def api_assign_self(
request: Request,
owner: str,
repo: str,
number: int,
csrf_token: str = Form(""),
):
"""Assign the token's user to an issue (triage key A, issue #11)."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
user = await gitea.get_user()
login = user.get("login", "")
if not login:
return JSONResponse(
{"ok": False, "error": "Could not resolve the current user"},
status_code=502,
)
issue = await gitea.get_issue(owner, repo, number)
current = [
a.get("login", "")
for a in (issue.get("assignees") or [])
if a.get("login")
]
if issue.get("assignee") and issue["assignee"].get("login"):
if issue["assignee"]["login"] not in current:
current.append(issue["assignee"]["login"])
if login not in current:
current.append(login)
await gitea.set_issue_assignees(owner, repo, number, current)
await _publish_board_changed(f"{owner}/{repo}")
return {"ok": True, "assignees": current}
def _excerpt(body: str | None, limit: int = 280) -> str:
"""Plain-text excerpt of an issue body for the triage card."""
text = re.sub(r"[#>*`_~\-]{1,}", " ", body or "")
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
text = re.sub(r"\s+", " ", text).strip()
return text[:limit] + ("…" if len(text) > limit else "")
@app.get("/triage", response_class=HTMLResponse)
async def triage_picker(request: Request):
"""Triage mode (issue #11): pick a project by its backlog size."""
repos = await gitea.search_repos()
active, _inactive, _archived = classify_repos(repos)
projects = []
for repo_info in active:
owner, _, name = repo_info["full_name"].partition("/")
try:
all_items = await gitea.get_repo_issues(owner, name, state="all")
except Exception:
continue
columns, _display, _state = _board_columns_for(owner, name, all_items)
backlog = len(columns["backlog"])
if backlog:
projects.append(
{"full_name": repo_info["full_name"], "backlog": backlog}
)
projects.sort(key=lambda p: -p["backlog"])
return render(request, "triage.html", projects=projects)
@app.get("/triage/{owner}/{repo}", response_class=HTMLResponse)
async def triage_session(request: Request, owner: str, repo: str):
"""One backlog card at a time, decided from the keyboard."""
full_name = f"{owner}/{repo}"
all_items = await gitea.get_repo_issues(owner, repo, state="all")
columns, display_issues, board_state = _board_columns_for(
owner, repo, all_items
)
ages = _card_ages(full_name, display_issues, board_state)
queue = [
{
"number": issue["number"],
"title": issue.get("title", ""),
"excerpt": _excerpt(issue.get("body")),
"labels": [
{"name": l.get("name", ""), "color": l.get("color", "")}
for l in issue.get("labels") or []
],
"created_at": (issue.get("created_at") or "")[:10],
"age_days": (ages.get(issue["number"]) or {}).get("days"),
"html_url": issue.get("html_url", ""),
"assignees": [
a.get("login", "") for a in issue.get("assignees") or []
],
}
for issue in columns["backlog"]
]
return render(
request,
"triage_session.html",
full_name=full_name,
queue=queue,
queue_json=json.dumps(queue, ensure_ascii=False),
column_labels=COLUMN_LABELS,
)
@app.get("/focus", response_class=HTMLResponse) @app.get("/focus", response_class=HTMLResponse)
async def focus(request: Request): async def focus(request: Request):
"""Cross-project view: everything in progress or in review.""" """Cross-project view: everything in progress or in review."""
+1
View File
@@ -67,6 +67,7 @@
{% endfor %} {% endfor %}
</select> </select>
<button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button> <button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button>
<a href="/triage/{{ full_name }}" class="btn-sm" style="text-decoration:none">📥 Triage du backlog</a>
<form id="create-issue-form"> <form id="create-issue-form">
<input type="text" id="new-issue-title" maxlength="255" <input type="text" id="new-issue-title" maxlength="255"
placeholder="➕ Nouvelle issue (titre)..." required> placeholder="➕ Nouvelle issue (titre)..." required>
+52
View File
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Triage — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
<main class="focus-main">
<section class="briefing-card">
<h1>📥 Mode Triage</h1>
<p>Le backlog se vide carte par carte, au clavier : une décision par carte, aucune souris requise. Choisis un projet pour commencer une session.</p>
</section>
{% if projects %}
<div class="triage-projects">
{% for p in projects %}
<a class="triage-project" href="/triage/{{ p.full_name }}">
<span class="triage-project-name">📂 {{ p.full_name }}</span>
<span class="triage-project-count">{{ p.backlog }} carte{{ 's' if p.backlog > 1 }} au backlog</span>
</a>
{% endfor %}
</div>
{% else %}
<div class="briefing-card"><p>🎉 Aucun backlog à trier : tous les projets actifs sont à zéro. Inbox zero, déjà.</p></div>
{% endif %}
</main>
<footer>
<span>Kanbante v{{ version }} · triage : 1-5 colonnes · L étiquettes · A s'assigner · X fermer · Entrée suivante · Z annuler</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/live.js" defer></script>
</body>
</html>
+81
View File
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Triage {{ full_name }} — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/board/{{ full_name }}" class="nav-pill">📋 Board</a>
<a href="/triage" class="nav-pill">📥 Autre projet</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
<main class="triage-main" data-repo="{{ full_name }}">
<div class="triage-head">
<h1>📥 Triage — {{ full_name }}</h1>
<div class="triage-progress-wrap">
<div class="triage-progress"><div class="triage-progress-bar" id="triage-bar"></div></div>
<span class="triage-progress-label" id="triage-progress-label"></span>
</div>
</div>
<section class="triage-card" id="triage-card" aria-live="polite">
<div class="triage-card-top">
<span class="triage-card-num" id="tc-num"></span>
<span class="triage-card-date" id="tc-date"></span>
<a class="triage-card-link" id="tc-link" href="#" target="_blank" rel="noopener">Ouvrir dans Gitea ↗</a>
</div>
<h2 class="triage-card-title" id="tc-title"></h2>
<div class="triage-card-labels" id="tc-labels"></div>
<p class="triage-card-excerpt" id="tc-excerpt"></p>
<p class="triage-card-assign" id="tc-assign"></p>
</section>
<section class="triage-done" id="triage-done" hidden>
<h2>🎉 Session terminée</h2>
<ul class="triage-summary" id="triage-summary"></ul>
<p>
<a class="btn-save btn-inline" href="/board/{{ full_name }}">📋 Retour au board</a>
<a class="btn-save btn-inline" href="/triage/{{ full_name }}">🔁 Nouvelle session</a>
</p>
</section>
<div class="triage-status" id="triage-status" role="status"></div>
<section class="triage-keys" id="triage-keys">
<button type="button" data-act="move" data-col="todo"><kbd>1</kbd> 📋 À faire</button>
<button type="button" data-act="move" data-col="in_progress"><kbd>2</kbd> 🚧 En cours</button>
<button type="button" data-act="move" data-col="qa"><kbd>3</kbd> 🧪 QA</button>
<button type="button" data-act="move" data-col="review"><kbd>4</kbd> 👀 Révision</button>
<button type="button" data-act="move" data-col="done"><kbd>5</kbd> ✅ Terminé</button>
<button type="button" data-act="labels"><kbd>L</kbd> 🏷️ Étiquettes</button>
<button type="button" data-act="assign"><kbd>A</kbd> 👤 M'assigner</button>
<button type="button" data-act="close"><kbd>X</kbd> 🚫 Fermer</button>
<button type="button" data-act="next"><kbd>Entrée</kbd> ⏭️ Suivante</button>
<button type="button" data-act="undo"><kbd>Z</kbd> ↩️ Annuler</button>
</section>
<div class="triage-labels" id="triage-labels" hidden>
<div class="triage-labels-head">
<strong>🏷️ Étiquettes de la carte</strong>
<button type="button" class="btn-sm" id="triage-labels-close">✕ (Échap)</button>
</div>
<div class="triage-labels-list" id="triage-labels-list">Chargement…</div>
</div>
</main>
<footer>
<span>Kanbante v{{ version }} · session de triage — les décisions partent dans Gitea immédiatement</span>
</footer>
<script id="triage-queue" type="application/json">{{ queue_json|safe }}</script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/triage.js" defer></script>
</body>
</html>
+54
View File
@@ -1026,3 +1026,57 @@ footer {
color: var(--text-3); font-size: 0.78rem; text-align: right; color: var(--text-3); font-size: 0.78rem; text-align: right;
} }
.palette-trigger { cursor: pointer; font: inherit; } .palette-trigger { cursor: pointer; font: inherit; }
/* ── Mode Triage (0.10.0, #11) ── */
.triage-main { max-width: 860px; margin: 0 auto; padding: 24px 16px 40px; }
.triage-head h1 { margin: 0 0 12px; font-size: 1.4rem; }
.triage-progress-wrap { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }
.triage-progress { flex: 1; height: 10px; border-radius: 99px; background: var(--surface-3, #1d2942); overflow: hidden; }
.triage-progress-bar { height: 100%; width: 0; background: var(--good, #3fb950); border-radius: 99px; transition: width 0.25s ease; }
.triage-progress-label { color: var(--text-3); font-size: 0.85rem; white-space: nowrap; }
.triage-card {
background: var(--surface-1, #131a29);
border: 1px solid var(--border-strong, #2c3a52);
border-radius: 16px; padding: 26px 28px; min-height: 220px;
}
.triage-card-top { display: flex; gap: 14px; align-items: baseline; color: var(--text-3); font-size: 0.88rem; }
.triage-card-num { font-weight: 700; color: var(--accent, #58a6ff); }
.triage-card-link { margin-left: auto; color: var(--text-3); text-decoration: none; }
.triage-card-title { margin: 10px 0 8px; font-size: 1.5rem; line-height: 1.3; }
.triage-card-labels { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
.triage-card-excerpt { color: var(--text-2, #b6c2d2); line-height: 1.55; white-space: pre-wrap; }
.triage-card-assign { color: var(--text-3); }
.triage-keys { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; }
.triage-keys button {
background: var(--surface-2, #182236); color: var(--text-1);
border: 1px solid var(--border, #22304a); border-radius: 10px;
padding: 9px 13px; font: inherit; font-size: 0.9rem; cursor: pointer;
}
.triage-keys button:hover { border-color: var(--accent, #58a6ff); }
.triage-keys kbd {
background: var(--surface-3, #1d2942); border: 1px solid var(--border-strong, #2c3a52);
border-radius: 5px; padding: 1px 6px; font-size: 0.78rem; margin-right: 4px;
}
.triage-status { min-height: 22px; margin-top: 12px; color: var(--good, #3fb950); }
.triage-labels {
margin-top: 14px; background: var(--surface-1, #131a29);
border: 1px solid var(--border-strong, #2c3a52); border-radius: 14px; padding: 16px 18px;
}
.triage-labels-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.triage-labels-list { display: flex; gap: 8px; flex-wrap: wrap; }
.triage-label-chip {
background: var(--surface-3, #1d2942); color: var(--text-1);
border: 1px solid var(--border, #22304a); border-radius: 99px;
padding: 5px 12px; font: inherit; font-size: 0.85rem; cursor: pointer;
}
.triage-label-chip.on { border-color: var(--good, #3fb950); color: var(--good, #3fb950); }
.triage-done { background: var(--surface-1, #131a29); border: 1px solid var(--border-strong, #2c3a52); border-radius: 16px; padding: 26px 28px; }
.triage-summary { line-height: 1.9; }
.triage-projects { display: grid; gap: 10px; }
.triage-project {
display: flex; justify-content: space-between; align-items: center;
background: var(--surface-1, #131a29); border: 1px solid var(--border, #22304a);
border-radius: 12px; padding: 16px 20px; color: var(--text-1); text-decoration: none;
}
.triage-project:hover { border-color: var(--accent, #58a6ff); }
.triage-project-count { color: var(--text-3); }
+1
View File
@@ -17,6 +17,7 @@
{ icon: '📰', label: 'Activité — journal des événements', href: '/activity' }, { icon: '📰', label: 'Activité — journal des événements', href: '/activity' },
{ icon: '📊', label: 'Briefing quotidien', href: '/briefing' }, { icon: '📊', label: 'Briefing quotidien', href: '/briefing' },
{ icon: '⚡', label: 'Capture éclair — nouvelle idée', href: '/capture' }, { icon: '⚡', label: 'Capture éclair — nouvelle idée', href: '/capture' },
{ icon: '📥', label: 'Mode Triage — vider un backlog au clavier', href: '/triage' },
{ icon: '🧠', label: 'Digest hebdo — vers ObsiGate', href: '/digest' }, { icon: '🧠', label: 'Digest hebdo — vers ObsiGate', href: '/digest' },
{ icon: '🖥️', label: 'Wallboard — mode TV', href: '/wall' }, { icon: '🖥️', label: 'Wallboard — mode TV', href: '/wall' },
{ icon: '🔍', label: 'Recherche avancée', href: '/search' }, { icon: '🔍', label: 'Recherche avancée', href: '/search' },
+236
View File
@@ -0,0 +1,236 @@
/**
* Kanbante — Triage session (issue #11).
*
* One backlog card at a time, decided from the keyboard:
* 1-5 move to a column (5 = done, closes), L labels panel,
* A assign me, X close, Enter next (skip), Z undo last move.
* Decisions hit the same APIs as the board (/api/move, label
* toggles, assign-self) — the queue lives entirely client-side.
*/
(function () {
var main = document.querySelector('.triage-main');
if (!main) return;
var repo = main.getAttribute('data-repo');
var parts = repo.split('/');
var owner = parts[0], name = parts.slice(1).join('/');
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
var queue = JSON.parse(document.getElementById('triage-queue').textContent || '[]');
var idx = 0;
var history = []; // {number, to} for undoable moves
var counts = {}; // decision tally for the summary
var busy = false;
var el = function (id) { return document.getElementById(id); };
var cardEl = el('triage-card'), doneEl = el('triage-done');
var statusEl = el('triage-status'), labelsPanel = el('triage-labels');
function current() { return queue[idx] || null; }
function status(msg, isError) {
statusEl.textContent = msg || '';
statusEl.style.color = isError ? '#ff7b72' : '';
}
function render() {
var card = current();
var total = queue.length;
el('triage-bar').style.width = total ? Math.round(100 * idx / total) + '%' : '100%';
el('triage-progress-label').textContent = total
? 'Carte ' + Math.min(idx + 1, total) + ' / ' + total
: '0 carte';
if (!card) {
cardEl.hidden = true;
el('triage-keys').hidden = true;
doneEl.hidden = false;
var summary = el('triage-summary');
summary.innerHTML = '';
var labels = {
todo: '📋 À faire', in_progress: '🚧 En cours', qa: '🧪 QA',
review: '👀 Révision', done: '✅ Terminé / fermée',
skip: '⏭️ Passées (restent au backlog)',
};
Object.keys(labels).forEach(function (key) {
if (counts[key]) {
var li = document.createElement('li');
li.textContent = labels[key] + ' : ' + counts[key];
summary.appendChild(li);
}
});
if (!Object.keys(counts).length) {
var li = document.createElement('li');
li.textContent = 'Aucune décision prise.';
summary.appendChild(li);
}
return;
}
cardEl.hidden = false;
doneEl.hidden = true;
el('tc-num').textContent = '#' + card.number;
el('tc-date').textContent = card.created_at
? 'créée le ' + card.created_at + (card.age_days != null ? ' · ⏳ ' + card.age_days + ' j' : '')
: '';
el('tc-link').href = card.html_url || '#';
el('tc-title').textContent = card.title;
el('tc-excerpt').textContent = card.excerpt || '(aucune description)';
var labelsBox = el('tc-labels');
labelsBox.innerHTML = '';
(card.labels || []).forEach(function (l) {
var chip = document.createElement('span');
chip.className = 'label-tag';
chip.style.background = '#' + l.color + '30';
chip.style.color = '#' + l.color;
chip.textContent = l.name;
labelsBox.appendChild(chip);
});
el('tc-assign').textContent = (card.assignees && card.assignees.length)
? '👤 Assignée à : ' + card.assignees.join(', ')
: '';
status('');
}
function post(url, fields) {
var data = new FormData();
Object.keys(fields).forEach(function (k) { data.append(k, fields[k]); });
data.append('csrf_token', csrf);
return fetch(url, { method: 'POST', body: data }).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (body) {
if (!r.ok || body.ok === false) throw new Error(body.error || ('HTTP ' + r.status));
return body;
});
});
}
function advance(kind) {
counts[kind] = (counts[kind] || 0) + 1;
idx += 1;
render();
}
function decide(toColumn) {
var card = current();
if (!card || busy) return;
busy = true;
status('⏳ Envoi…');
post('/api/move', {
repo_full_name: repo, issue_number: card.number,
from_column: 'backlog', to_column: toColumn, from_order: '', to_order: '',
}).then(function () {
history.push({ number: card.number, to: toColumn });
busy = false;
advance(toColumn);
}).catch(function (err) {
busy = false;
status('❌ ' + err.message + ' — réessaie.', true);
});
}
function undo() {
var last = history.pop();
var card = current();
if (!last || busy) return;
// The undone card is the one just before the current index.
var target = queue[idx - 1];
if (!target || target.number !== last.number) return;
busy = true;
status('⏳ Annulation…');
post('/api/move', {
repo_full_name: repo, issue_number: last.number,
from_column: last.to, to_column: 'backlog', from_order: '', to_order: '',
}).then(function () {
counts[last.to] = Math.max(0, (counts[last.to] || 1) - 1);
if (!counts[last.to]) delete counts[last.to];
idx -= 1;
busy = false;
render();
}).catch(function (err) {
busy = false;
history.push(last);
status('❌ Annulation impossible : ' + err.message, true);
});
void card;
}
function assignSelf() {
var card = current();
if (!card || busy) return;
busy = true;
status('⏳ Assignation…');
post('/api/issues/' + owner + '/' + name + '/' + card.number + '/assign-self', {})
.then(function (body) {
card.assignees = body.assignees || card.assignees;
busy = false;
render();
status('👤 Assignée à : ' + (body.assignees || []).join(', '));
})
.catch(function (err) { busy = false; status('❌ ' + err.message, true); });
}
function openLabels() {
var card = current();
if (!card) return;
labelsPanel.hidden = false;
var list = el('triage-labels-list');
list.textContent = 'Chargement…';
fetch('/api/repos/' + owner + '/' + name + '/labels')
.then(function (r) { return r.json(); })
.then(function (data) {
list.innerHTML = '';
var mine = (card.labels || []).map(function (l) { return l.name; });
(data.labels || []).forEach(function (label) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'triage-label-chip' + (mine.includes(label.name) ? ' on' : '');
btn.textContent = (mine.includes(label.name) ? '✓ ' : '') + label.name;
btn.addEventListener('click', function () {
var action = mine.includes(label.name) ? 'remove' : 'add';
post('/api/issues/' + owner + '/' + name + '/' + card.number + '/labels',
{ label_id: label.id, action: action })
.then(function (body) {
card.labels = body.labels || [];
render();
openLabels();
})
.catch(function (err) { status('❌ ' + err.message, true); });
});
list.appendChild(btn);
});
if (!(data.labels || []).length) list.textContent = 'Aucune étiquette dans ce dépôt.';
})
.catch(function () { list.textContent = 'Échec du chargement des étiquettes.'; });
}
function closeLabels() { labelsPanel.hidden = true; }
document.querySelectorAll('.triage-keys button').forEach(function (btn) {
btn.addEventListener('click', function () {
var act = btn.getAttribute('data-act');
if (act === 'move') decide(btn.getAttribute('data-col'));
else if (act === 'close') decide('done');
else if (act === 'next') { if (current()) advance('skip'); }
else if (act === 'undo') undo();
else if (act === 'assign') assignSelf();
else if (act === 'labels') { labelsPanel.hidden ? openLabels() : closeLabels(); }
});
});
el('triage-labels-close').addEventListener('click', closeLabels);
document.addEventListener('keydown', function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
var tag = (document.activeElement || {}).tagName || '';
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (!labelsPanel.hidden) {
if (e.key === 'Escape' || e.key.toLowerCase() === 'l') { e.preventDefault(); closeLabels(); }
return;
}
var colByKey = { '1': 'todo', '2': 'in_progress', '3': 'qa', '4': 'review', '5': 'done' };
if (colByKey[e.key]) { e.preventDefault(); decide(colByKey[e.key]); }
else if (e.key === 'Enter') { e.preventDefault(); if (current()) advance('skip'); }
else if (e.key.toLowerCase() === 'x') { e.preventDefault(); decide('done'); }
else if (e.key.toLowerCase() === 'l') { e.preventDefault(); openLabels(); }
else if (e.key.toLowerCase() === 'a') { e.preventDefault(); assignSelf(); }
else if (e.key.toLowerCase() === 'z') { e.preventDefault(); undo(); }
});
render();
})();