docs/design/ holds the preview page (brand, 13 console views in five states, 13 terminal screens, 39-feature coverage with status), the README spec and the Relay mark. Design only; no package code changed. Open for Jason: default palette and mode, final mark, web resolution, one mosaic app. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1459 lines
137 KiB
HTML
1459 lines
137 KiB
HTML
<title>Mosaic Stack Design</title>
|
||
<meta name="description" content="Brand, WebUI and terminal design for Mosaic Stack, with every feature and its status.">
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap">
|
||
<style>
|
||
/* Tokens: Harbor (proposed default). JS replaces them with the chosen palette and mode. */
|
||
:root{
|
||
--canvas:#f3f5f7;--surface:#ffffff;--raised:#e6eaef;--text:#434d5b;--muted:#5e6978;--line:#ced5df;--border:#636e7e;
|
||
--action:#2266bf;--on-action:#ffffff;--accent:#8d5e20;--focus:#2266bf;--success:#287658;--warning:#8f5c19;--danger:#ba2e26;
|
||
--strong:color-mix(in srgb,var(--text) 72%,#000);
|
||
--t-bg:#12161c;--t-fg:#b9c1cb;--t-muted:#7f8a97;--t-line:#384556;--t-action:#74a6e7;--t-accent:#e1b47a;--t-success:#85d5b7;--t-warning:#e7b574;--t-danger:#e37d78;--t-sel:#28323e;--t-strong:#e6eaef;
|
||
--sans:"Manrope",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
|
||
--mono:"JetBrains Mono",ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;
|
||
--r:6px;
|
||
}
|
||
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
|
||
--canvas:#12161c;--surface:#1c232b;--raised:#28323e;--text:#b9c1cb;--muted:#a4acb7;--line:#384556;--border:#7d8997;
|
||
--action:#74a6e7;--on-action:#10141a;--accent:#e1b47a;--focus:#74a6e7;--success:#85d5b7;--warning:#e7b574;--danger:#e37d78;
|
||
--strong:color-mix(in srgb,var(--text) 70%,#fff);color-scheme:dark}}
|
||
:root[data-theme="dark"]{
|
||
--canvas:#12161c;--surface:#1c232b;--raised:#28323e;--text:#b9c1cb;--muted:#a4acb7;--line:#384556;--border:#7d8997;
|
||
--action:#74a6e7;--on-action:#10141a;--accent:#e1b47a;--focus:#74a6e7;--success:#85d5b7;--warning:#e7b574;--danger:#e37d78;
|
||
--strong:color-mix(in srgb,var(--text) 70%,#fff);color-scheme:dark}
|
||
|
||
*{box-sizing:border-box}
|
||
html{-webkit-text-size-adjust:100%}
|
||
body{background:var(--canvas);color:var(--text);font:400 14px/1.5 var(--sans);margin:0;padding-inline:16px;padding-block:0 40px}
|
||
h1,h2,h3,h4{color:var(--strong);text-wrap:balance;margin:0;line-height:1.2}
|
||
p{margin:0}
|
||
a{color:var(--action)}
|
||
code,kbd,.mono{font-family:var(--mono);font-size:.92em}
|
||
kbd{border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;padding:0 .35em;background:var(--surface);color:var(--strong);white-space:nowrap}
|
||
button,select,input,textarea{font:inherit;color:inherit}
|
||
:focus-visible{outline:2px solid var(--focus);outline-offset:2px}
|
||
.wrap{max-width:1320px;margin-inline:auto}
|
||
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
||
.tnum{font-variant-numeric:tabular-nums}
|
||
svg.i{width:18px;height:18px;flex:none;fill:none;stroke:currentColor;stroke-width:1.75;stroke-linecap:round;stroke-linejoin:round}
|
||
|
||
/* site header */
|
||
.site{position:sticky;top:env(safe-area-inset-top,0px);z-index:30;background:var(--canvas);border-bottom:1px solid var(--line);margin-inline:-16px;padding:10px 16px}
|
||
.site .wrap{display:flex;flex-wrap:wrap;align-items:center;gap:10px 18px}
|
||
.brandline{display:flex;align-items:center;gap:10px;color:var(--strong);font-weight:800;letter-spacing:-.01em;font-size:16px}
|
||
.brandline svg{width:24px;height:auto;fill:var(--action)}
|
||
.brandline small{font-weight:500;color:var(--muted);font-size:13px;letter-spacing:0}
|
||
.tabs{display:flex;gap:2px;flex-wrap:wrap}
|
||
.tabs button{border:0;background:none;padding:6px 10px;border-radius:var(--r);cursor:pointer;color:var(--muted);font-weight:600}
|
||
.tabs button[aria-selected="true"]{background:var(--raised);color:var(--strong)}
|
||
.tabs button:hover{color:var(--strong)}
|
||
.appearance{margin-left:auto;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
||
.seg{display:inline-flex;border:1px solid var(--line);border-radius:var(--r);overflow:hidden;background:var(--surface)}
|
||
.seg button{border:0;background:none;padding:4px 9px;cursor:pointer;color:var(--muted);font-size:13px;font-weight:600}
|
||
.seg button[aria-pressed="true"]{background:var(--action);color:var(--on-action)}
|
||
select{background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:4px 8px;font-size:13px}
|
||
.label{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
|
||
|
||
section.tab{padding-block:28px 0}
|
||
section.tab[hidden]{display:none}
|
||
.lede{max-width:68ch;color:var(--text);font-size:15px}
|
||
.lede+.lede{margin-top:8px}
|
||
.h1{font-size:28px;font-weight:800;letter-spacing:-.02em;margin-bottom:8px}
|
||
.h2{font-size:19px;font-weight:700;letter-spacing:-.01em}
|
||
.stack{display:grid;gap:14px;align-content:start}
|
||
.grid2{display:grid;gap:20px;align-items:start;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr))}
|
||
.panel{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:20px;min-width:0}
|
||
.note{font-size:13px;color:var(--muted)}
|
||
.sim{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--accent);border:1px dashed var(--accent);border-radius:4px;padding:0 5px;white-space:nowrap}
|
||
|
||
/* status badges */
|
||
.st{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;white-space:nowrap;padding:1px 7px;border-radius:999px;border:1px solid var(--line);background:var(--surface);color:var(--text)}
|
||
.st::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--muted)}
|
||
.st.implemented::before{background:var(--success)}
|
||
.st.progress::before{background:var(--action)}
|
||
.st.planned::before{background:transparent;border:1.5px solid var(--action)}
|
||
.st.proposed::before{background:transparent;border:1.5px dashed var(--accent)}
|
||
.chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;padding:1px 7px;border-radius:4px;background:var(--raised);color:var(--strong);white-space:nowrap}
|
||
.chip.gated{background:color-mix(in srgb,var(--danger) 14%,var(--surface));color:var(--danger)}
|
||
.chip.block{background:var(--danger);color:var(--surface)}
|
||
.chip.cross{background:color-mix(in srgb,var(--accent) 16%,var(--surface));color:var(--accent)}
|
||
.chip.within,.chip.routine{background:var(--raised);color:var(--muted)}
|
||
.chip.warn{background:color-mix(in srgb,var(--warning) 16%,var(--surface));color:var(--warning)}
|
||
.chip.ok{background:color-mix(in srgb,var(--success) 15%,var(--surface));color:var(--success)}
|
||
.dot{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
|
||
.dot b{font:700 12px/1 var(--mono);width:14px;text-align:center}
|
||
.s-working b{color:var(--success)}.s-waiting b{color:var(--warning)}.s-error b{color:var(--danger)}
|
||
.s-offline b,.s-idle b,.s-unknown b{color:var(--muted)}
|
||
|
||
/* ---------- BRAND ---------- */
|
||
.hero-brand{display:grid;gap:28px;grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr));align-items:center}
|
||
.markbig{display:grid;place-items:center;aspect-ratio:16/10;max-height:340px;max-width:100%;background:var(--surface);border:1px solid var(--line);border-radius:12px}
|
||
.markbig svg{width:min(36%,200px);height:auto;height:auto;fill:var(--action)}
|
||
.sizes{display:flex;flex-wrap:wrap;gap:12px}
|
||
.sizes figure{margin:0;display:grid;justify-items:center;gap:6px;padding:12px;border-radius:8px;border:1px solid var(--line);background:var(--surface);min-width:72px}
|
||
.sizes figure.inv{background:var(--strong);border-color:var(--strong)}
|
||
.sizes figure.inv svg{fill:var(--canvas)}
|
||
.sizes svg{fill:var(--action);display:block}
|
||
.sizes figcaption{font:500 11px var(--mono);color:var(--muted)}
|
||
.sizes figure.inv figcaption{color:var(--canvas)}
|
||
.compare{display:grid;grid-template-columns:auto 1fr;gap:10px 16px;align-items:center}
|
||
.compare .px{display:flex;gap:10px;align-items:center}
|
||
.compare svg{fill:var(--action)}
|
||
.lockups{display:flex;flex-wrap:wrap;gap:24px;align-items:center}
|
||
.lockup{display:flex;align-items:center;gap:12px;color:var(--strong);font-weight:800;font-size:28px;letter-spacing:-.025em}
|
||
.lockup svg{width:38px;height:auto;fill:var(--action)}
|
||
.lockup.sm{font-size:16px;gap:8px;letter-spacing:-.01em}.lockup.sm svg{width:20px}
|
||
.lockup .stack-word{font-weight:500;color:var(--muted)}
|
||
.voice{display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr))}
|
||
.say{border-left:3px solid var(--success);padding:8px 12px;background:var(--surface);border-radius:0 6px 6px 0}
|
||
.say.no{border-color:var(--danger)}
|
||
.say .label{display:block;margin-bottom:2px}
|
||
.pals{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(min(100%,232px),1fr))}
|
||
.pal{display:grid;gap:8px;text-align:left;padding:12px;border-radius:10px;border:1px solid var(--line);background:var(--surface);cursor:pointer;min-width:0}
|
||
.pal[aria-pressed="true"]{border-color:var(--action);box-shadow:0 0 0 1px var(--action)}
|
||
.pal .sw{display:flex;height:34px;border-radius:6px;overflow:hidden;border:1px solid var(--line)}
|
||
.pal .sw i{flex:1}
|
||
.pal .sw i.big{flex:2.2}
|
||
.pal b{color:var(--strong);font-size:14px}
|
||
.pal span{font-size:12px;color:var(--muted)}
|
||
.typescale{display:grid;gap:10px}
|
||
.typescale div{display:grid;grid-template-columns:86px 1fr;gap:12px;align-items:baseline;min-width:0}
|
||
.typescale code{color:var(--muted);font-size:11px}
|
||
.icons{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:8px}
|
||
.icons figure{margin:0;display:grid;justify-items:center;gap:6px;padding:12px 4px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--strong)}
|
||
.icons svg.i{width:24px;height:24px}
|
||
.icons figcaption{font-size:11px;color:var(--muted)}
|
||
.vocab{display:grid;gap:8px 18px;grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr))}
|
||
.vocab dl{margin:0;display:grid;gap:6px;align-content:start}
|
||
.vocab dd{margin:0}
|
||
.vocab dt{margin-top:4px}
|
||
|
||
/* ---------- CONSOLE ---------- */
|
||
.console-intro{display:flex;flex-wrap:wrap;gap:12px 24px;align-items:end;justify-content:space-between;margin-bottom:16px}
|
||
.app{display:grid;grid-template-columns:220px minmax(0,1fr);grid-template-rows:auto minmax(0,1fr);height:min(78vh,780px);min-height:560px;border:1px solid var(--line);border-radius:12px;overflow:hidden;background:var(--canvas);position:relative}
|
||
.app.with-insp{grid-template-columns:196px minmax(0,1fr) minmax(300px,370px)}
|
||
.cmdbar{grid-column:1/-1;display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--line);background:var(--surface);min-width:0}
|
||
.cmdbar .mk{display:flex;align-items:center;gap:8px;font-weight:800;color:var(--strong);letter-spacing:-.01em;white-space:nowrap}
|
||
.cmdbar .mk svg{width:20px;height:auto;fill:var(--action)}
|
||
.biz{border:1px solid var(--line);background:var(--canvas);border-radius:var(--r);padding:3px 8px;font-size:13px;font-weight:600;white-space:nowrap}
|
||
.cmdk{flex:1;max-width:460px;display:flex;align-items:center;gap:8px;border:1px solid var(--line);background:var(--canvas);border-radius:var(--r);padding:5px 10px;color:var(--muted);cursor:pointer;text-align:left;min-width:0}
|
||
.cmdk span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.fresh{font-size:12px;color:var(--muted);white-space:nowrap;margin-left:auto}
|
||
.fresh b{color:var(--success);font-weight:600}
|
||
.side{border-right:1px solid var(--line);background:var(--surface);overflow:auto;padding:10px 8px;display:flex;flex-direction:column;gap:2px;min-height:0}
|
||
.side .label{padding:10px 8px 4px}
|
||
.nav{display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:var(--r);color:var(--text);cursor:pointer;border:0;background:none;text-align:left;width:100%;font-weight:600;font-size:13.5px}
|
||
.nav:hover{background:var(--raised)}
|
||
.nav[aria-current="page"]{background:color-mix(in srgb,var(--action) 13%,var(--surface));color:var(--strong)}
|
||
.nav[aria-current="page"] svg{color:var(--action)}
|
||
.nav .n{margin-left:auto;font:600 11px var(--mono);color:var(--muted)}
|
||
.nav .n.hot{color:var(--surface);background:var(--danger);border-radius:999px;padding:0 6px}
|
||
.tree{font-size:13px;padding:2px 8px 0 12px;display:grid;gap:3px;color:var(--muted)}
|
||
.tree b{color:var(--strong);font-weight:600}
|
||
.main{overflow:auto;min-width:0;min-height:0;padding:18px 20px 28px}
|
||
.vh{display:flex;flex-wrap:wrap;gap:8px 14px;align-items:baseline;margin-bottom:12px}
|
||
.vh h2{font-size:20px;font-weight:800;letter-spacing:-.015em}
|
||
.vh .note{flex:1 1 auto}
|
||
.vh .right{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||
.subtabs{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:10px;border-bottom:1px solid var(--line)}
|
||
.subtabs button{border:0;background:none;padding:6px 10px;cursor:pointer;color:var(--muted);font-weight:600;border-bottom:2px solid transparent;margin-bottom:-1px}
|
||
.subtabs button[aria-selected="true"]{color:var(--strong);border-bottom-color:var(--action)}
|
||
.chips{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}
|
||
.chips button{border:1px solid var(--line);background:var(--surface);border-radius:999px;padding:2px 10px;font-size:12.5px;font-weight:600;cursor:pointer;color:var(--text)}
|
||
.chips button[aria-pressed="true"]{background:var(--strong);color:var(--surface);border-color:var(--strong)}
|
||
.tbl{overflow-x:auto;border:1px solid var(--line);border-radius:8px;background:var(--surface)}
|
||
table{border-collapse:collapse;width:100%;font-size:13px}
|
||
th{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;background:var(--surface);position:sticky;top:0}
|
||
td{padding:7px 10px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);vertical-align:top}
|
||
tr:last-child td{border-bottom:0}
|
||
tbody tr.row{cursor:pointer}
|
||
tbody tr.row:hover{background:color-mix(in srgb,var(--raised) 55%,var(--surface))}
|
||
tbody tr.row:focus-visible{outline:2px solid var(--focus);outline-offset:-2px}
|
||
tbody tr.row[aria-selected="true"]{background:color-mix(in srgb,var(--action) 10%,var(--surface));box-shadow:inset 3px 0 0 var(--action)}
|
||
td.mono,td code{font-family:var(--mono);font-size:12.5px;white-space:nowrap}
|
||
td.wrap{min-width:16ch}
|
||
.empty{padding:28px 16px;text-align:center;color:var(--muted)}
|
||
.empty b{display:block;color:var(--strong);font-size:15px;margin-bottom:4px}
|
||
.banner{display:flex;gap:10px;align-items:flex-start;padding:10px 12px;border-radius:8px;margin-bottom:12px;border:1px solid;font-size:13px}
|
||
.banner.err{border-color:color-mix(in srgb,var(--danger) 50%,var(--line));background:color-mix(in srgb,var(--danger) 8%,var(--surface))}
|
||
.banner.ref{border-color:color-mix(in srgb,var(--warning) 50%,var(--line));background:color-mix(in srgb,var(--warning) 9%,var(--surface))}
|
||
.banner.info{border-color:var(--line);background:var(--surface)}
|
||
.banner strong{color:var(--strong)}
|
||
.banner .grow{flex:1;min-width:0}
|
||
.skel{height:12px;border-radius:4px;background:var(--raised);animation:pulse 1.4s ease-in-out infinite}
|
||
@keyframes pulse{50%{opacity:.45}}
|
||
.cards{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(min(100%,220px),1fr));margin-bottom:16px}
|
||
.card{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:12px 14px;min-width:0}
|
||
.card .label{margin-bottom:4px;display:block}
|
||
.card .big{font-size:22px;font-weight:800;color:var(--strong);letter-spacing:-.02em}
|
||
.meter{display:flex;gap:3px;margin-top:6px}
|
||
.meter i{height:6px;flex:1;border-radius:2px;background:var(--raised)}
|
||
.meter i.on{background:var(--action)}
|
||
.attn{font-size:12.5px;color:var(--strong)}
|
||
.attn b{color:var(--warning)}
|
||
.btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:4px 10px;font-size:13px;font-weight:600;cursor:pointer;color:var(--strong);white-space:nowrap}
|
||
.btn:hover{border-color:var(--border)}
|
||
.btn.pri{background:var(--action);border-color:var(--action);color:var(--on-action)}
|
||
.btn[disabled]{opacity:.55;cursor:not-allowed}
|
||
.btn svg.i{width:15px;height:15px}
|
||
.section{margin-top:22px}
|
||
.section>h3{font-size:14px;font-weight:700;margin-bottom:8px}
|
||
|
||
/* inspector */
|
||
.insp{border-left:1px solid var(--line);background:var(--surface);overflow:auto;min-height:0;display:flex;flex-direction:column}
|
||
.insp[hidden]{display:none}
|
||
.insp header{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--surface);z-index:1}
|
||
.insp header .mono{font-weight:600;color:var(--strong)}
|
||
.insp header .x{margin-left:auto;border:0;background:none;cursor:pointer;color:var(--muted);padding:4px;border-radius:4px}
|
||
.insp .body{padding:14px;display:grid;gap:14px}
|
||
.insp h3{font-size:16px;font-weight:700}
|
||
.auth{border:1px solid var(--line);border-left:3px solid var(--action);border-radius:0 6px 6px 0;padding:8px 10px;background:var(--canvas);font-size:13px;color:var(--strong)}
|
||
.auth code{color:var(--action)}
|
||
.opts{list-style:none;margin:0;padding:0;display:grid;gap:8px}
|
||
.opts li{border:1px solid var(--line);border-radius:8px;padding:9px 10px;display:grid;gap:6px;background:var(--canvas)}
|
||
.opts li.rec{border-color:var(--action)}
|
||
.opts .k{font:700 13px var(--mono);color:var(--strong)}
|
||
.opts .cmd{display:flex;gap:6px;align-items:center;min-width:0}
|
||
.opts .cmd code{flex:1;min-width:0;overflow-x:auto;white-space:nowrap;background:var(--surface);border:1px solid var(--line);border-radius:4px;padding:3px 6px;font-size:12px}
|
||
dl.kv{display:grid;grid-template-columns:auto 1fr;gap:5px 12px;margin:0;font-size:13px}
|
||
dl.kv dt{color:var(--muted)}
|
||
dl.kv dd{margin:0;color:var(--strong);min-width:0;overflow-wrap:anywhere}
|
||
ol.hist{list-style:none;margin:0;padding:0;display:grid;gap:6px;font-size:12.5px}
|
||
ol.hist li{display:grid;grid-template-columns:62px 1fr;gap:8px}
|
||
ol.hist time{font-family:var(--mono);color:var(--muted)}
|
||
|
||
/* trail */
|
||
.trail{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;background:var(--surface)}
|
||
.trail li{display:grid;grid-template-columns:78px 170px 120px minmax(0,1fr);gap:10px;padding:7px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);font-size:13px;align-items:baseline}
|
||
.trail li:last-child{border-bottom:0}
|
||
.trail time,.trail .k{font-family:var(--mono);font-size:12px}
|
||
.trail time{color:var(--muted)}
|
||
.trail .k{color:var(--strong)}
|
||
.trail .k.bad{color:var(--danger)}.trail .k.dec{color:var(--accent)}.trail .k.unk{color:var(--warning)}
|
||
.trail .who{color:var(--muted)}
|
||
|
||
/* talk */
|
||
.talk{display:grid;grid-template-rows:auto minmax(0,1fr) auto;height:100%;min-height:440px;border:1px solid var(--line);border-radius:8px;background:var(--surface);overflow:hidden}
|
||
.talk .th{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:8px 12px;border-bottom:1px solid var(--line);font-size:13px}
|
||
.talk .th .state{font-family:var(--mono);font-size:12.5px;color:var(--strong)}
|
||
.talk .th .right{margin-left:auto;display:flex;gap:6px;flex-wrap:wrap}
|
||
.msgs{overflow:auto;padding:14px;display:flex;flex-direction:column;gap:12px}
|
||
.msg{max-width:72ch;display:grid;gap:3px}
|
||
.msg .who{font-size:12px;color:var(--muted);font-weight:600}
|
||
.msg .who time{font-family:var(--mono);font-weight:400;margin-left:6px}
|
||
.msg .txt{color:var(--strong);font-size:14px}
|
||
.msg.me{align-self:flex-end;text-align:right}
|
||
.msg.me .txt{background:color-mix(in srgb,var(--action) 12%,var(--surface));padding:7px 11px;border-radius:10px 10px 2px 10px;text-align:left}
|
||
.msg .rcpt{font:500 11px var(--mono);color:var(--muted)}
|
||
.msg .rcpt.bad{color:var(--danger)}
|
||
.notice{align-self:center;font-size:12px;color:var(--muted);border:1px dashed var(--line);border-radius:999px;padding:2px 10px;font-family:var(--mono)}
|
||
.composer{border-top:1px solid var(--line);padding:10px 12px;display:grid;gap:6px}
|
||
.composer textarea{width:100%;resize:vertical;min-height:58px;border:1px solid var(--line);border-radius:6px;background:var(--canvas);padding:8px 10px;color:var(--strong)}
|
||
.composer .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||
.composer .status{font:500 12px var(--mono);color:var(--muted);flex:1;min-width:0}
|
||
.composer .status.bad{color:var(--danger)}
|
||
|
||
/* matrix */
|
||
.matrix td.c{text-align:center;font:700 12px var(--mono)}
|
||
.matrix td.W{color:var(--success)}.matrix td.X{color:var(--accent)}.matrix td.G{color:var(--danger)}
|
||
|
||
/* wizard */
|
||
.steps{display:flex;gap:6px;flex-wrap:wrap;list-style:none;margin:0 0 16px;padding:0;counter-reset:s}
|
||
.steps li{counter-increment:s;display:flex;align-items:center;gap:6px;font-size:13px;color:var(--muted);font-weight:600}
|
||
.steps li::before{content:counter(s);display:grid;place-items:center;width:22px;height:22px;border-radius:50%;border:1.5px solid var(--line);font:700 11px var(--mono)}
|
||
.steps li[aria-current="step"]{color:var(--strong)}
|
||
.steps li[aria-current="step"]::before{border-color:var(--action);background:var(--action);color:var(--on-action)}
|
||
.steps li.done::before{content:"✓";border-color:var(--success);color:var(--success)}
|
||
.steps li+li::after{content:none}
|
||
.wiz{max-width:640px;display:grid;gap:14px}
|
||
.check{display:grid;grid-template-columns:22px 1fr;gap:8px;align-items:start;font-size:13.5px}
|
||
.check b{font-family:var(--mono)}
|
||
.check .ok{color:var(--success)}.check .bad{color:var(--danger)}.check .wait{color:var(--muted)}
|
||
pre.code{margin:0;font:12.5px/1.55 var(--mono);background:var(--canvas);border:1px solid var(--line);border-radius:6px;padding:10px 12px;overflow-x:auto;color:var(--strong)}
|
||
|
||
/* palette */
|
||
.overlay{position:absolute;inset:0;background:color-mix(in srgb,var(--strong) 28%,transparent);display:grid;justify-items:center;align-items:start;padding-top:70px;z-index:20}
|
||
.overlay[hidden]{display:none}
|
||
.pk{width:min(560px,calc(100% - 24px));background:var(--surface);border:1px solid var(--line);border-radius:10px;box-shadow:0 18px 50px -10px color-mix(in srgb,#000 35%,transparent);overflow:hidden}
|
||
.pk input{width:100%;border:0;border-bottom:1px solid var(--line);padding:12px 14px;background:var(--surface);font-size:15px;color:var(--strong)}
|
||
.pk input:focus{outline:none}
|
||
.pk ul{list-style:none;margin:0;padding:6px;max-height:340px;overflow:auto}
|
||
.pk li{display:flex;gap:10px;align-items:center;padding:7px 10px;border-radius:6px;cursor:pointer;font-size:13.5px}
|
||
.pk li[aria-selected="true"]{background:color-mix(in srgb,var(--action) 14%,var(--surface));color:var(--strong)}
|
||
.pk li .g{margin-left:auto;font-size:11px;color:var(--muted)}
|
||
.toast{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);background:var(--strong);color:var(--surface);padding:6px 14px;border-radius:6px;font-size:13px;z-index:25;max-width:calc(100% - 24px)}
|
||
.toast[hidden]{display:none}
|
||
|
||
/* ---------- TERMINAL ---------- */
|
||
.term-layout{display:grid;gap:20px;grid-template-columns:minmax(0,1fr) 300px;align-items:start}
|
||
.term-side{display:grid;gap:14px}
|
||
.screens{display:grid;gap:4px}
|
||
.screens button{display:flex;justify-content:space-between;gap:8px;text-align:left;border:1px solid transparent;background:none;padding:5px 9px;border-radius:6px;cursor:pointer;font-size:13px;color:var(--text);font-weight:600}
|
||
.screens button:hover{background:var(--raised)}
|
||
.screens button[aria-pressed="true"]{background:var(--surface);border-color:var(--line);color:var(--strong)}
|
||
.screens button kbd{font-size:11px}
|
||
.termwin{border-radius:10px;overflow:hidden;border:1px solid var(--line);background:var(--t-bg);min-width:0}
|
||
.termbar{display:flex;align-items:center;gap:8px;padding:6px 10px;background:color-mix(in srgb,var(--t-bg) 80%,var(--t-fg));color:var(--t-fg);font:500 12px var(--mono)}
|
||
.termbar i{width:10px;height:10px;border-radius:50%;background:var(--t-line)}
|
||
.termbar span{margin-left:6px;opacity:.85}
|
||
.termscroll{overflow-x:auto}
|
||
pre.term{margin:0;padding:10px 12px;font:12.5px/1.4 var(--mono);color:var(--t-fg);background:var(--t-bg);white-space:pre;width:max-content;min-width:100%}
|
||
pre.term:focus{outline:none}
|
||
.termwin:focus-within{box-shadow:0 0 0 2px var(--focus)}
|
||
pre.term .a{color:var(--t-action)}pre.term .m{color:var(--t-muted)}pre.term .w{color:var(--t-warning)}pre.term .d{color:var(--t-danger)}
|
||
pre.term .s{color:var(--t-success)}pre.term .c{color:var(--t-accent)}pre.term .b{color:var(--t-strong);font-weight:700}
|
||
pre.term .r{background:var(--t-sel);color:var(--t-strong)}
|
||
pre.term .h{background:var(--t-action);color:var(--t-bg);font-weight:700}
|
||
pre.term .x{background:var(--t-danger);color:var(--t-bg);font-weight:700}
|
||
pre.term .u{background:var(--t-fg);color:var(--t-bg)}
|
||
pre.term.nocolor *{color:inherit!important;background:none!important;font-weight:400!important}
|
||
.keymap{display:grid;grid-template-columns:auto 1fr;gap:5px 10px;font-size:13px;margin:0}
|
||
.keymap dt{white-space:nowrap}
|
||
.keymap dd{margin:0;color:var(--text)}
|
||
.ansi{display:grid;gap:4px;font-size:13px}
|
||
.ansi div{display:grid;grid-template-columns:16px 120px 1fr;gap:8px;align-items:center}
|
||
.ansi i{width:14px;height:14px;border-radius:3px;border:1px solid var(--line)}
|
||
|
||
/* ---------- COVERAGE ---------- */
|
||
.cov td:first-child{font-weight:600;color:var(--strong);min-width:18ch}
|
||
.cov td.src{font-family:var(--mono);font-size:12px;color:var(--muted);white-space:nowrap}
|
||
.legend{display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center;font-size:13px}
|
||
.decisions-open{display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr))}
|
||
.decisions-open .panel{padding:14px 16px}
|
||
.decisions-open h3{font-size:14px;margin-bottom:4px}
|
||
|
||
footer.site-foot{margin-top:40px;padding-top:14px;border-top:1px solid var(--line);font-size:12.5px;color:var(--muted);display:flex;flex-wrap:wrap;gap:6px 18px}
|
||
|
||
@media (max-width:1100px){
|
||
.app.with-insp{grid-template-columns:200px minmax(0,1fr)}
|
||
.app.with-insp .insp{position:absolute;right:0;top:45px;bottom:0;width:min(400px,100%);box-shadow:-12px 0 30px -12px color-mix(in srgb,#000 35%,transparent);z-index:10}
|
||
.app{grid-template-columns:200px minmax(0,1fr)}
|
||
.term-layout{grid-template-columns:minmax(0,1fr)}
|
||
}
|
||
@media (max-width:760px){
|
||
.app,.app.with-insp{grid-template-columns:minmax(0,1fr);grid-template-rows:auto auto minmax(0,1fr);height:auto;min-height:0}
|
||
.side{flex-direction:row;overflow-x:auto;border-right:0;border-bottom:1px solid var(--line);padding:6px}
|
||
.side .label,.side .tree{display:none}
|
||
.nav{width:auto;white-space:nowrap}
|
||
.main{padding:14px 12px 22px;overflow:visible}
|
||
.app.with-insp .insp{position:fixed;inset:0;top:0;width:100%;z-index:40}
|
||
.fresh,.biz{display:none}
|
||
.trail li{grid-template-columns:64px minmax(0,1fr);gap:2px 8px}
|
||
.trail li .who{grid-column:2}.trail li>span:last-child{grid-column:2}
|
||
.talk{height:auto;min-height:0}
|
||
.msgs{max-height:420px}
|
||
.appearance{margin-left:0}
|
||
.site{position:static}
|
||
.tabs{flex-wrap:nowrap;overflow-x:auto;max-width:100%}
|
||
.tabs button{white-space:nowrap}
|
||
}
|
||
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
|
||
</style>
|
||
|
||
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
||
<defs>
|
||
<symbol id="relay" viewBox="0 0 16 17"><path fill-rule="evenodd" d="M1 7 5 3 9 3 12 6 9 9 6 6 4 8 4 9 7 12 5 14 1 10ZM15 10 11 14 7 14 4 11 7 8 10 11 12 9 12 8 9 5 11 3 15 7Z"/></symbol>
|
||
<symbol id="relay-orig" viewBox="0 0 56 56"><path fill-rule="evenodd" d="M4 18 18 4h12l10 10-8 8-8-8h-2L14 22v2l8 8-8 8L4 30zm48 8L38 40H26L16 30l8-8 8 8h2l8-8v-2l-8-8 8-8 10 10z" transform="translate(0 6)"/></symbol>
|
||
<symbol id="i-board" viewBox="0 0 24 24"><rect x="3.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="13.5" width="7" height="7" rx="1.5"/></symbol>
|
||
<symbol id="i-inbox" viewBox="0 0 24 24"><path d="M3.5 13.5 6 5.5h12l2.5 8v5a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/><path d="M3.5 13.5h5l1 2.5h5l1-2.5h5"/></symbol>
|
||
<symbol id="i-talk" viewBox="0 0 24 24"><path d="M4.5 5h15a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H10l-4.5 3.5V16h-1a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z"/><path d="M8 10.5h8"/></symbol>
|
||
<symbol id="i-tasks" viewBox="0 0 24 24"><path d="M10 6.5h10M10 12h10M10 17.5h10"/><path d="m3.5 6.5 1.5 1.5 2.5-3"/><path d="m3.5 12 1.5 1.5 2.5-3"/><path d="M4 17.5h3"/></symbol>
|
||
<symbol id="i-agents" viewBox="0 0 24 24"><circle cx="9" cy="8.5" r="3"/><path d="M3.5 19.5c.6-3.2 2.8-5 5.5-5s4.9 1.8 5.5 5"/><circle cx="17" cy="9.5" r="2.4"/><path d="M15.8 14.6c2.2.2 3.9 1.8 4.7 4.9"/></symbol>
|
||
<symbol id="i-trail" viewBox="0 0 24 24"><circle cx="6" cy="5.5" r="2"/><circle cx="18" cy="18.5" r="2"/><path d="M8 5.5h7.5a3.25 3.25 0 0 1 0 6.5h-7a3.25 3.25 0 0 0 0 6.5H16"/></symbol>
|
||
<symbol id="i-queue" viewBox="0 0 24 24"><path d="M4 6.5h16M4 12h16M4 17.5h9"/><path d="m16.5 15.5 2 2 2-2"/></symbol>
|
||
<symbol id="i-runs" viewBox="0 0 24 24"><path d="M12 3.5 20 8v8l-8 4.5L4 16V8z"/><path d="m4 8 8 4.5L20 8M12 12.5v8"/></symbol>
|
||
<symbol id="i-ledger" viewBox="0 0 24 24"><path d="M5 20.5V10M10 20.5V4M15 20.5v-8M20 20.5v-5"/></symbol>
|
||
<symbol id="i-business" viewBox="0 0 24 24"><path d="M4.5 20.5V8.5l6-4v16M10.5 20.5h9v-9l-9-3"/><path d="M3 20.5h18M14 13.5h2M14 16.5h2M6.5 10.5h2M6.5 13.5h2"/></symbol>
|
||
<symbol id="i-library" viewBox="0 0 24 24"><path d="M4.5 4.5h4v15h-4zM10 4.5h4v15h-4z"/><path d="m15.5 5.3 3.4-.9 3.1 14.3-3.4.9z"/></symbol>
|
||
<symbol id="i-settings" viewBox="0 0 24 24"><path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2"/><circle cx="9" cy="17" r="2"/></symbol>
|
||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="6"/><path d="m20 20-4.5-4.5"/></symbol>
|
||
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="8.5" y="8.5" width="11" height="11" rx="1.5"/><path d="M15.5 8.5v-3a1 1 0 0 0-1-1h-9a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h3"/></symbol>
|
||
<symbol id="i-close" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></symbol>
|
||
<symbol id="i-term" viewBox="0 0 24 24"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="m7.5 9.5 3 2.5-3 2.5M12.5 15h4"/></symbol>
|
||
<symbol id="i-flag" viewBox="0 0 24 24"><path d="M5.5 20.5v-16M5.5 4.5h12l-2.5 4 2.5 4h-12"/></symbol>
|
||
</defs>
|
||
</svg>
|
||
|
||
<header class="site">
|
||
<div class="wrap">
|
||
<div class="brandline"><svg viewBox="0 0 16 17" aria-hidden="true"><use href="#relay"/></svg>Mosaic Stack <small>design · draft 1 · 2026-10-09</small></div>
|
||
<nav class="tabs" role="tablist" aria-label="Design sections">
|
||
<button role="tab" id="tab-brand" aria-controls="brand" aria-selected="true">Brand</button>
|
||
<button role="tab" id="tab-console" aria-controls="console" aria-selected="false">Console (web)</button>
|
||
<button role="tab" id="tab-terminal" aria-controls="terminal" aria-selected="false">Terminal (<code>mosaic</code>)</button>
|
||
<button role="tab" id="tab-coverage" aria-controls="coverage" aria-selected="false">Coverage</button>
|
||
</nav>
|
||
<div class="appearance">
|
||
<label class="sr" for="palSel">Palette</label>
|
||
<select id="palSel"></select>
|
||
<div class="seg" role="group" aria-label="Mode" id="modeSeg">
|
||
<button data-mode="system" aria-pressed="true">System</button>
|
||
<button data-mode="light" aria-pressed="false">Light</button>
|
||
<button data-mode="dim" aria-pressed="false">Dim</button>
|
||
<button data-mode="dark" aria-pressed="false">Dark</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="wrap">
|
||
|
||
<!-- ================= BRAND ================= -->
|
||
<section class="tab" id="brand" role="tabpanel" aria-labelledby="tab-brand">
|
||
<div class="hero-brand">
|
||
<div class="stack">
|
||
<span class="label">Identity</span>
|
||
<h1 class="h1">Work passes hand to hand. Only gated decisions reach you.</h1>
|
||
<p class="lede">Mosaic Stack lets you declare a business, its projects and its roles. Agents in those roles carry the work end to end under the policy you declared. The brand stays quiet so the work is what you see: a precise blue for action, a warm accent for things that need you, and plain words.</p>
|
||
<p class="lede">The mark is <strong>Relay</strong>, the one you preferred. Two hooks pass a piece of work between them, the way messages pass between roles rather than between people (REQ-MSG-1). This draft redraws it on a 16-unit pixel grid so the slits between the hooks still show at favicon size.</p>
|
||
</div>
|
||
<div class="markbig" aria-label="Relay mark, refined"><svg viewBox="0 0 16 17" role="img" aria-label="Relay mark"><use href="#relay"/></svg></div>
|
||
</div>
|
||
|
||
<div class="section grid2">
|
||
<div class="panel stack">
|
||
<h2 class="h2">Small sizes</h2>
|
||
<p class="note">The original draws its gaps from overlapping shapes about 2.8/56 units wide, which comes to 0.8 px at 16 px, so they blur away. The refined path keeps the silhouette and widens the slits to about 1.4 px at 16 px.</p>
|
||
<div class="compare">
|
||
<span class="label">Original</span>
|
||
<div class="px"><svg width="16" height="16" viewBox="0 0 56 56"><use href="#relay-orig"/></svg><svg width="24" height="24" viewBox="0 0 56 56"><use href="#relay-orig"/></svg><svg width="32" height="32" viewBox="0 0 56 56"><use href="#relay-orig"/></svg><svg width="64" height="64" viewBox="0 0 56 56"><use href="#relay-orig"/></svg></div>
|
||
<span class="label">Refined</span>
|
||
<div class="px"><svg width="16" height="17" viewBox="0 0 16 17"><use href="#relay"/></svg><svg width="24" height="25.5" viewBox="0 0 16 17"><use href="#relay"/></svg><svg width="32" height="34" viewBox="0 0 16 17"><use href="#relay"/></svg><svg width="64" height="68" viewBox="0 0 16 17"><use href="#relay"/></svg></div>
|
||
</div>
|
||
<div class="sizes" aria-label="Refined mark on light and inverted grounds">
|
||
<figure><svg width="48" height="51" viewBox="0 0 16 17"><use href="#relay"/></svg><figcaption>48</figcaption></figure>
|
||
<figure><svg width="32" height="34" viewBox="0 0 16 17"><use href="#relay"/></svg><figcaption>32</figcaption></figure>
|
||
<figure><svg width="16" height="17" viewBox="0 0 16 17"><use href="#relay"/></svg><figcaption>16</figcaption></figure>
|
||
<figure class="inv"><svg width="48" height="51" viewBox="0 0 16 17"><use href="#relay"/></svg><figcaption>48</figcaption></figure>
|
||
<figure class="inv"><svg width="16" height="17" viewBox="0 0 16 17"><use href="#relay"/></svg><figcaption>16</figcaption></figure>
|
||
</div>
|
||
<p class="note">Source: <code>docs/design/brand/relay-refined.svg</code>. One even-odd path, no strokes, no gradient. It is drawn in the palette's action color, or in the text color when it sits on action.</p>
|
||
</div>
|
||
<div class="panel stack">
|
||
<h2 class="h2">Wordmark and lockups</h2>
|
||
<div class="lockups">
|
||
<div class="lockup"><svg viewBox="0 0 16 17" aria-hidden="true"><use href="#relay"/></svg><span>Mosaic <span class="stack-word">Stack</span></span></div>
|
||
<div class="lockup sm"><svg viewBox="0 0 16 17" aria-hidden="true"><use href="#relay"/></svg><span>mosaic</span></div>
|
||
</div>
|
||
<p class="note">Manrope 800 for the name with tight tracking (−0.025em at display size). "Stack" steps down to 500 in the muted color. In product chrome the short form <code>mosaic</code> matches the command people type. Leave clear space around the mark equal to its slit-to-edge distance, about one eighth of its width.</p>
|
||
<h3 class="label" style="margin-top:6px">Voice: calm precision</h3>
|
||
<div class="voice">
|
||
<p class="say"><span class="label">Say</span>Vikunja last read 14:21:07 (12 s ago).</p>
|
||
<p class="say no"><span class="label">Not</span>Everything is up to date! 🎉</p>
|
||
<p class="say"><span class="label">Say</span>refused: tracker-starting. Vikunja has not answered the first poll yet. Try again after it does.</p>
|
||
<p class="say no"><span class="label">Not</span>Oops, something went wrong. Please try again later.</p>
|
||
<p class="say"><span class="label">Say</span>Choosing “1” authorizes git.push.protected on origin/refactor. Any other choice declines.</p>
|
||
<p class="say no"><span class="label">Not</span>Approve? (Recommended!)</p>
|
||
</div>
|
||
<p class="note">Name the thing, the state and the next step. Keep the system's own words: refusal codes, event kinds and task refs appear exactly as the bus records them, in mono.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="vh"><h2 class="h2">Palettes</h2><p class="note">Dewey's ten palettes, each with Light, Dim and Dark. Choose one to apply it to this whole page. Harbor is the proposed default; the default palette and mode are still your call.</p></div>
|
||
<div class="pals" id="palGrid"></div>
|
||
</div>
|
||
|
||
<div class="section grid2">
|
||
<div class="panel stack">
|
||
<h2 class="h2">Type</h2>
|
||
<div class="typescale">
|
||
<div><code>28 / 800</code><span style="font-size:28px;font-weight:800;color:var(--strong);letter-spacing:-.02em">Inbox</span></div>
|
||
<div><code>20 / 800</code><span style="font-size:20px;font-weight:800;color:var(--strong)">Tasks in project 32</span></div>
|
||
<div><code>16 / 700</code><span style="font-size:16px;font-weight:700;color:var(--strong)">Push the reviewed commits?</span></div>
|
||
<div><code>14 / 400</code><span>Running text and table cells sit at 13 to 14 px for density.</span></div>
|
||
<div><code>11 / 700 caps</code><span class="label">Section label</span></div>
|
||
<div><code>mono 12.5</code><code>vikunja:32/48 · 7f3a91c2 · git.push.protected</code></div>
|
||
</div>
|
||
<p class="note">Manrope (approved) for interface text. JetBrains Mono for anything a person might type or paste: ids, refs, actions, refusal codes and commands. Both come from Google Fonts with system fallbacks. Numbers in columns use tabular figures.</p>
|
||
</div>
|
||
<div class="panel stack">
|
||
<h2 class="h2">Icons</h2>
|
||
<div class="icons" id="iconGrid"></div>
|
||
<p class="note">Drawn on a 24-unit grid with a 1.75 stroke and round joins, no fills. Each one names a section. Icons never carry state alone; state always has a word or glyph next to it.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section panel">
|
||
<h2 class="h2" style="margin-bottom:12px">Status vocabulary</h2>
|
||
<div class="vocab">
|
||
<dl><dt class="label">Agent state (control board)</dt>
|
||
<dd><span class="dot s-working"><b>●</b>working</span></dd><dd><span class="dot s-waiting"><b>◐</b>waiting</span> · shows <em>Input needed:</em> text</dd><dd><span class="dot s-error"><b>▲</b>error</span></dd><dd><span class="dot s-offline"><b>○</b>offline</span> · <span class="dot s-idle"><b>–</b>idle</span> · <span class="dot s-unknown"><b>?</b>unknown</span></dd></dl>
|
||
<dl><dt class="label">Decision class and routing</dt>
|
||
<dd><span class="chip routine">routine</span> <span class="chip within">within-role</span> logged</dd><dd><span class="chip cross">cross-role</span> goes to the arbiter (delivery → pm, technical → cto)</dd><dd><span class="chip gated">gated</span> goes to you</dd><dd><span class="chip block">BLOCKING</span> CLI inbox + Discord DM now; otherwise the 08:00 CT digest</dd></dl>
|
||
<dl><dt class="label">Task markers</dt>
|
||
<dd><span class="chip warn">changed in Vikunja</span></dd><dd><span class="chip">not created by the stack</span></dd><dd><span class="chip gated">conflict</span></dd><dd><span class="chip warn">missing: moved</span> <span class="chip warn">not-found</span> <span class="chip warn">no-access</span></dd></dl>
|
||
<dl><dt class="label">Feature status in this design</dt>
|
||
<dd><span class="st implemented">implemented</span> in the repository now</dd><dd><span class="st progress">in progress</span> on an open queue row</dd><dd><span class="st planned">planned</span> in the PRD or a brief</dd><dd><span class="st proposed">proposed</span> new in this design; needs a ruling</dd></dl>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ================= CONSOLE ================= -->
|
||
<section class="tab" id="console" role="tabpanel" aria-labelledby="tab-console" hidden>
|
||
<div class="console-intro">
|
||
<div class="stack" style="gap:6px">
|
||
<h1 class="h1">Console</h1>
|
||
<p class="lede">The layout you chose (d3): a command bar with <kbd>Ctrl</kbd> <kbd>K</kbd>, a section list with the project tree, dense tables, and an inspector drawer on the right. The board is home. Everything below is a working mockup with <strong>sample data</strong>; actions marked <span class="sim">Simulated</span> change nothing.</p>
|
||
</div>
|
||
<div class="stack" style="gap:4px">
|
||
<label class="label" for="stateSel">Preview state</label>
|
||
<select id="stateSel">
|
||
<option value="normal">Normal</option><option value="loading">Loading</option><option value="empty">Empty</option>
|
||
<option value="error">Error, stale data kept</option><option value="refusal">Refusal (fail closed)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="app" id="app">
|
||
<div class="cmdbar">
|
||
<span class="mk"><svg viewBox="0 0 16 17" aria-hidden="true"><use href="#relay"/></svg>mosaic</span>
|
||
<span class="biz" title="v1 has one business">Mosaic Stack</span>
|
||
<button class="cmdk" id="cmdkBtn" aria-haspopup="dialog"><svg class="i"><use href="#i-search"/></svg><span>Go to, find a task or decision, copy a command…</span><kbd>Ctrl K</kbd></button>
|
||
<span class="fresh tnum" id="fresh">Vikunja last read <b>14:21:07</b> · bus live</span>
|
||
</div>
|
||
<nav class="side" id="side" aria-label="Sections"></nav>
|
||
<div class="main" id="view" tabindex="-1"></div>
|
||
<aside class="insp" id="insp" hidden aria-label="Inspector"></aside>
|
||
<div class="overlay" id="pkOverlay" hidden>
|
||
<div class="pk" role="dialog" aria-modal="true" aria-label="Command palette">
|
||
<input id="pkInput" placeholder="Type a section, task ref, decision id or command" aria-controls="pkList" aria-autocomplete="list" autocomplete="off">
|
||
<ul id="pkList" role="listbox"></ul>
|
||
</div>
|
||
</div>
|
||
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
|
||
</div>
|
||
<p class="note" style="margin-top:10px">Keys: <kbd>Ctrl K</kbd> palette · <kbd>↑</kbd><kbd>↓</kbd> move through table rows · <kbd>Enter</kbd> open in the inspector · <kbd>Esc</kbd> close it. Served on 127.0.0.1:7330 only, refreshed every 10 s, no sign-in in v1.</p>
|
||
</section>
|
||
|
||
<!-- ================= TERMINAL ================= -->
|
||
<section class="tab" id="terminal" role="tabpanel" aria-labelledby="tab-terminal" hidden>
|
||
<div class="stack" style="gap:6px;margin-bottom:16px">
|
||
<h1 class="h1">Terminal: <code>mosaic</code></h1>
|
||
<p class="lede"><code>mosaic</code> is the front door and the only place a decision gets resolved (REQ-CLI-1, REQ-DEC-3). Today it is a set of verbs (<code>inbox</code>, <code>decide</code>, <code>tasks</code>, <code>agents</code>, <code>trail</code>, <code>bus</code>) plus the conversation terminal. This design joins them into one full-screen app, with <code>mosaic</code> and no arguments opening on Talk. The verbs stay as they are, for scripts.</p>
|
||
<p class="lede">Click the screen and use the keys, or pick a screen from the list. Screens are drawn at 120×36 columns, with an 80×24 fallback. Every state is shown as text as well as color, so <code>NO_COLOR</code> loses nothing.</p>
|
||
</div>
|
||
<div class="term-layout">
|
||
<div class="termwin">
|
||
<div class="termbar"><i></i><i></i><i></i><span id="termTitle">mosaic — 120×36</span></div>
|
||
<div class="termscroll"><pre class="term" id="term" tabindex="0" aria-label="Simulated mosaic terminal; focus and press keys"></pre></div>
|
||
</div>
|
||
<div class="term-side">
|
||
<div class="panel" style="padding:14px">
|
||
<span class="label">Screens</span>
|
||
<div class="screens" id="screenList" style="margin-top:8px"></div>
|
||
</div>
|
||
<div class="panel" style="padding:14px">
|
||
<span class="label">Keys (nav mode)</span>
|
||
<dl class="keymap" style="margin-top:8px">
|
||
<dt><kbd>c</kbd> <kbd>i</kbd> <kbd>t</kbd> <kbd>a</kbd> <kbd>r</kbd></dt><dd>Talk, Inbox, Tasks, Agents, Trail</dd>
|
||
<dt><kbd>Tab</kbd></dt><dd>Move between nav and the composer</dd>
|
||
<dt><kbd>↑</kbd><kbd>↓</kbd> <kbd>j</kbd><kbd>k</kbd></dt><dd>Move the selection</dd>
|
||
<dt><kbd>1</kbd>–<kbd>9</kbd></dt><dd>In Inbox: choose that option, then confirm</dd>
|
||
<dt><kbd>v</kbd></dt><dd>Trail of the selected item</dd>
|
||
<dt><kbd>:</kbd></dt><dd>Command line (<code>:decide</code>, <code>:trail</code>, <code>:q</code>)</dd>
|
||
<dt><kbd>?</kbd></dt><dd>Help</dd>
|
||
<dt><kbd>q</kbd></dt><dd>Quit to the shell</dd>
|
||
</dl>
|
||
<p class="note" style="margin-top:8px">The composer keeps the conversation terminal's keys: <kbd>Enter</kbd> send, <kbd>Ctrl J</kbd> newline, <kbd>Ctrl T</kbd> take control, <kbd>Ctrl G</kbd> interrupt, <kbd>Ctrl O</kbd> reconnect, <kbd>PgUp</kbd>/<kbd>PgDn</kbd> scroll.</p>
|
||
</div>
|
||
<div class="panel" style="padding:14px">
|
||
<span class="label">ANSI roles</span>
|
||
<div class="ansi" id="ansi" style="margin-top:8px"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ================= COVERAGE ================= -->
|
||
<section class="tab" id="coverage" role="tabpanel" aria-labelledby="tab-coverage" hidden>
|
||
<div class="stack" style="gap:6px;margin-bottom:16px">
|
||
<h1 class="h1">Feature coverage</h1>
|
||
<p class="lede">Every feature found in the PRD, the slice 1 briefs and the packages, where it lives in each surface, and how far along it is. "Web" and "TUI" name the screen in this design. The status describes the capability in the code today (CLI, bus or packages), not these screens: <b>implemented</b> exists in the code now, <b>in progress</b> has an open queue row, <b>planned</b> is in the PRD or a brief with no code yet, <b>proposed</b> is new in this design.</p>
|
||
</div>
|
||
<div class="chips" id="covChips"></div>
|
||
<div class="tbl"><table class="cov"><thead><tr><th>Feature</th><th>Source</th><th>Web</th><th>TUI</th><th>Status</th></tr></thead><tbody id="covBody"></tbody></table></div>
|
||
|
||
<div class="section">
|
||
<h2 class="h2" style="margin-bottom:10px">Open decisions for you</h2>
|
||
<div class="decisions-open">
|
||
<div class="panel"><h3>Default palette and mode</h3><p class="note">Proposed: Harbor, following the system setting. Dim stays available for long sessions.</p></div>
|
||
<div class="panel"><h3>Final mark</h3><p class="note">Proposed: refined Relay. The original stays usable from 48 px up if you prefer its proportions.</p></div>
|
||
<div class="panel"><h3>Resolving from the web</h3><p class="note">The PRD rules it out (REQ-DEC-3), so the web gives a Copy button. Proposed: keep it that way through v1.</p></div>
|
||
<div class="panel"><h3>One <code>mosaic</code> app</h3><p class="note">Proposed: <code>mosaic</code> with no arguments opens the full-screen app, and the verbs stay for scripts.</p></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<footer class="site-foot wrap">
|
||
<span>Mosaic Stack design, draft 1. Sample data throughout; ids, times and counts are illustrative.</span>
|
||
<span>Spec: <code>docs/design/README.md</code></span>
|
||
<span>© <span id="yr">2026</span> Mosaic Stack</span>
|
||
</footer>
|
||
</main>
|
||
|
||
<script>
|
||
(function(){
|
||
"use strict";
|
||
const PALETTES = [{"id":"harbor","name":"Harbor","theory":"Complementary","note":"Clear blue with a restrained copper counterpoint. The warm accent balances cool navigation and gives selected details a second voice.","modes":{"light":{"canvas":"#f3f5f7","surface":"#ffffff","raised":"#e6eaef","text":"#434d5b","muted":"#5e6978","line":"#ced5df","border":"#636e7e","action":"#2266bf","onAction":"#ffffff","accent":"#8d5e20","focus":"#2266bf","success":"#287658","warning":"#8f5c19","danger":"#ba2e26"},"dim":{"canvas":"#242d38","surface":"#2e3947","raised":"#384556","text":"#d6dbe1","muted":"#a9b1bc","line":"#47576c","border":"#a4acb7","action":"#8ab4ea","onAction":"#10141a","accent":"#e1b47a","focus":"#8ab4ea","success":"#85d5b7","warning":"#e7b574","danger":"#ea9d99"},"dark":{"canvas":"#12161c","surface":"#1c232b","raised":"#28323e","text":"#b9c1cb","muted":"#a4acb7","line":"#384556","border":"#a4acb7","action":"#74a6e7","onAction":"#10141a","accent":"#e1b47a","focus":"#74a6e7","success":"#85d5b7","warning":"#e7b574","danger":"#e37d78"}}},{"id":"carmine","name":"Carmine","theory":"Near-complementary","note":"A red-led identity with a cool turquoise counterpoint. Rose-tinted neutrals keep the red from covering every surface.","modes":{"light":{"canvas":"#f7f3f3","surface":"#ffffff","raised":"#efe7e8","text":"#5e4549","muted":"#7b6065","line":"#deced1","border":"#7e6367","action":"#b92740","onAction":"#ffffff","accent":"#2e706c","focus":"#b92740","success":"#287658","warning":"#8f5c19","danger":"#ba2e26"},"dim":{"canvas":"#372528","surface":"#462f33","raised":"#55393e","text":"#ded3d5","muted":"#bca9ac","line":"#6b484e","border":"#b7a4a7","action":"#e996a4","onAction":"#10141a","accent":"#8bd0cb","focus":"#e996a4","success":"#85d5b7","warning":"#e7b574","danger":"#e99995"},"dark":{"canvas":"#1b1214","surface":"#2b1d1f","raised":"#3d292c","text":"#c9b6b9","muted":"#b7a4a7","line":"#55393e","border":"#b7a4a7","action":"#e2788a","onAction":"#10141a","accent":"#8bd0cb","focus":"#e2788a","success":"#85d5b7","warning":"#e7b574","danger":"#e37d78"}}},{"id":"atlantic","name":"Atlantic","theory":"Analogous","note":"Ocean blue and teal sit close on the hue wheel. Their shared cool bias makes a quieter combination than an opposing accent.","modes":{"light":{"canvas":"#f3f6f7","surface":"#ffffff","raised":"#e6ecef","text":"#3f4e55","muted":"#5c6d75","line":"#cdd9df","border":"#63757e","action":"#19719a","onAction":"#ffffff","accent":"#257467","focus":"#19719a","success":"#287658","warning":"#935f1a","danger":"#ba2e26"},"dim":{"canvas":"#243238","surface":"#2e3f47","raised":"#384d57","text":"#e2e7e9","muted":"#afbbc0","line":"#46606d","border":"#a4b1b7","action":"#73c3e8","onAction":"#10141a","accent":"#83d8ca","focus":"#73c3e8","success":"#85d5b7","warning":"#e7b574","danger":"#edaaa6"},"dark":{"canvas":"#12191c","surface":"#1c272b","raised":"#28373e","text":"#bcc8cd","muted":"#a4b1b7","line":"#384d57","border":"#a4b1b7","action":"#73c3e8","onAction":"#10141a","accent":"#83d8ca","focus":"#73c3e8","success":"#85d5b7","warning":"#e7b574","danger":"#e4817c"}}},{"id":"terracotta","name":"Terracotta","theory":"Complementary","note":"Fired-clay orange meets a desaturated steel blue. Warm neutral surfaces connect the palette without needing a beige wash everywhere.","modes":{"light":{"canvas":"#f7f4f3","surface":"#ffffff","raised":"#eee9e7","text":"#584841","muted":"#78665e","line":"#ded3cf","border":"#7e6b63","action":"#a74f2a","onAction":"#ffffff","accent":"#356f88","focus":"#a74f2a","success":"#287658","warning":"#8f5c19","danger":"#ba2e26"},"dim":{"canvas":"#362b26","surface":"#453630","raised":"#54423b","text":"#e5dfdc","muted":"#beb2ac","line":"#695349","border":"#b7a9a4","action":"#e2a68d","onAction":"#10141a","accent":"#89bcd1","focus":"#e2a68d","success":"#85d5b7","warning":"#e7b574","danger":"#eba29e"},"dark":{"canvas":"#1b1513","surface":"#2a211d","raised":"#3c2f2a","text":"#cbbeb9","muted":"#b7a9a4","line":"#54423b","border":"#b7a9a4","action":"#de9a7c","onAction":"#10141a","accent":"#89bcd1","focus":"#de9a7c","success":"#85d5b7","warning":"#e7b574","danger":"#e37d78"}}},{"id":"aubergine","name":"Aubergine","theory":"Triadic pair","note":"Muted plum and ochre sit 120 degrees apart on the hue wheel. The third triadic hue, teal, is left out so the interface has two brand accents rather than three.","modes":{"light":{"canvas":"#f5f4f6","surface":"#ffffff","raised":"#ece8ed","text":"#59455e","muted":"#75607b","line":"#d9d2db","border":"#78637e","action":"#88469b","onAction":"#ffffff","accent":"#77672c","focus":"#88469b","success":"#287658","warning":"#8f5c19","danger":"#ba2e26"},"dim":{"canvas":"#312933","surface":"#3e3441","raised":"#4c3f50","text":"#e1d9e3","muted":"#baacbe","line":"#5f4f63","border":"#b3a4b7","action":"#cba4d6","onAction":"#10141a","accent":"#d3c388","focus":"#cba4d6","success":"#85d5b7","warning":"#e7b574","danger":"#ea9d99"},"dark":{"canvas":"#18141a","surface":"#262028","raised":"#362d39","text":"#c7b9cb","muted":"#b3a4b7","line":"#4c3f50","border":"#b3a4b7","action":"#bf8ecc","onAction":"#10141a","accent":"#d3c388","focus":"#bf8ecc","success":"#85d5b7","warning":"#e7b574","danger":"#e37d78"}}},{"id":"mineral","name":"Mineral","theory":"Complementary","note":"Deep teal and dusty rose oppose each other while keeping saturation restrained. Blue-green tinted layers carry the identity in dark mode.","modes":{"light":{"canvas":"#f3f6f6","surface":"#ffffff","raised":"#e8eded","text":"#3d524f","muted":"#586f6c","line":"#d0dcda","border":"#637e7a","action":"#2a7469","onAction":"#ffffff","accent":"#9f414f","focus":"#2a7469","success":"#287658","warning":"#935f1a","danger":"#ba2e26"},"dim":{"canvas":"#273432","surface":"#324340","raised":"#3d514e","text":"#e5ebea","muted":"#b2c2c0","line":"#4d6662","border":"#a4b7b4","action":"#87d4c8","onAction":"#10141a","accent":"#e0b3ba","focus":"#87d4c8","success":"#85d5b7","warning":"#e7b574","danger":"#eeaeaa"},"dark":{"canvas":"#141a19","surface":"#1f2927","raised":"#2c3a38","text":"#b9cbc8","muted":"#a4b7b4","line":"#3d514e","border":"#a4b7b4","action":"#87d4c8","onAction":"#10141a","accent":"#d18f99","focus":"#87d4c8","success":"#85d5b7","warning":"#e7b574","danger":"#e58580"}}},{"id":"cobalt","name":"Cobalt","theory":"Complementary","note":"A stronger royal blue meets a small amber accent. The high hue separation is controlled by using amber only in secondary details.","modes":{"light":{"canvas":"#f3f3f7","surface":"#ffffff","raised":"#e6e8ef","text":"#454a5e","muted":"#60657b","line":"#cdd1df","border":"#63687e","action":"#1c3bc4","onAction":"#ffffff","accent":"#79671b","focus":"#1c3bc4","success":"#277255","warning":"#8f5c19","danger":"#ba2e26"},"dim":{"canvas":"#242838","surface":"#2e3248","raised":"#383d57","text":"#d0d2dc","muted":"#a6aab9","line":"#464d6d","border":"#a4a7b7","action":"#94a5f0","onAction":"#10141a","accent":"#e1ce7a","focus":"#94a5f0","success":"#85d5b7","warning":"#e7b574","danger":"#e89591"},"dark":{"canvas":"#12141c","surface":"#1c1f2c","raised":"#282c3e","text":"#b6b9c9","muted":"#a4a7b7","line":"#383d57","border":"#a4a7b7","action":"#798eec","onAction":"#10141a","accent":"#e1ce7a","focus":"#798eec","success":"#85d5b7","warning":"#e7b574","danger":"#e37d78"}}},{"id":"rosewood","name":"Rosewood","theory":"Triadic pair","note":"Dusty pink and steel blue occupy two points of a triadic relationship. The third, yellow-green, is omitted to avoid an overly colorful interface.","modes":{"light":{"canvas":"#f6f3f5","surface":"#ffffff","raised":"#ede8eb","text":"#5e4552","muted":"#7b606e","line":"#dcd0d6","border":"#7e6371","action":"#a53b72","onAction":"#ffffff","accent":"#416c95","focus":"#a53b72","success":"#287658","warning":"#8f5c19","danger":"#ba2e26"},"dim":{"canvas":"#34272e","surface":"#43323b","raised":"#513d48","text":"#e1d6db","muted":"#beacb5","line":"#664d5a","border":"#b7a4ae","action":"#dc9ebe","onAction":"#10141a","accent":"#95b3d0","focus":"#dc9ebe","success":"#85d5b7","warning":"#e7b574","danger":"#ea9d99"},"dark":{"canvas":"#1a1417","surface":"#291f24","raised":"#3a2c33","text":"#cbb9c2","muted":"#b7a4ae","line":"#513d48","border":"#b7a4ae","action":"#d487af","onAction":"#10141a","accent":"#8eaecd","focus":"#d487af","success":"#85d5b7","warning":"#e7b574","danger":"#e37d78"}}},{"id":"graphite","name":"Graphite","theory":"Monochromatic","note":"One blue-gray hue uses value and saturation changes instead of a second brand hue. Semantic status colors remain independent.","modes":{"light":{"canvas":"#f5f5f5","surface":"#ffffff","raised":"#eaeaeb","text":"#434d5b","muted":"#5e6978","line":"#d5d6d7","border":"#636e7e","action":"#616975","onAction":"#ffffff","accent":"#5e6978","focus":"#616975","success":"#287658","warning":"#8f5c19","danger":"#ba2e26"},"dim":{"canvas":"#2d2e2f","surface":"#393a3c","raised":"#454749","text":"#dfe2e7","muted":"#afb6c0","line":"#57595c","border":"#a4abb7","action":"#b1b6be","onAction":"#10141a","accent":"#afb6c0","focus":"#b1b6be","success":"#85d5b7","warning":"#e7b574","danger":"#eba29e"},"dark":{"canvas":"#161718","surface":"#232425","raised":"#323334","text":"#bcc3cd","muted":"#a4abb7","line":"#454749","border":"#a4abb7","action":"#a6acb5","onAction":"#10141a","accent":"#a4abb7","focus":"#a6acb5","success":"#85d5b7","warning":"#e7b574","danger":"#e37d78"}}},{"id":"grove","name":"Grove","theory":"Split-complementary pair","note":"Muted forest green meets dusty rose, 30 degrees to one side of its magenta complement. The second split accent is omitted. Low-saturation surfaces keep green from dominating.","modes":{"light":{"canvas":"#f4f6f5","surface":"#ffffff","raised":"#e8edea","text":"#3d5246","muted":"#586f62","line":"#d2dbd6","border":"#637e6e","action":"#377752","onAction":"#ffffff","accent":"#9c444a","focus":"#377752","success":"#287658","warning":"#935f1a","danger":"#ba2e26"},"dim":{"canvas":"#29332d","surface":"#34413a","raised":"#3f4f46","text":"#e2e9e5","muted":"#afc0b6","line":"#4f6358","border":"#a4b7ac","action":"#8fcca9","onAction":"#10141a","accent":"#ddb1b4","focus":"#8fcca9","success":"#85d5b7","warning":"#e7b574","danger":"#edaaa6"},"dark":{"canvas":"#141917","surface":"#202823","raised":"#2d3932","text":"#b9cbc1","muted":"#a4b7ac","line":"#3f4f46","border":"#a4b7ac","action":"#8fcca9","onAction":"#10141a","accent":"#cd8e92","focus":"#8fcca9","success":"#85d5b7","warning":"#e7b574","danger":"#e58580"}}}];
|
||
const root = document.documentElement;
|
||
const $ = (s, el) => (el || document).querySelector(s);
|
||
const $$ = (s, el) => Array.from((el || document).querySelectorAll(s));
|
||
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||
const store = { get(k){ try { return localStorage.getItem(k); } catch(e){ return null; } }, set(k,v){ try { localStorage.setItem(k,v); } catch(e){} } };
|
||
$("#yr").textContent = new Date().getFullYear();
|
||
|
||
/* ---------------- theme ---------------- */
|
||
const VARS = {canvas:"--canvas",surface:"--surface",raised:"--raised",text:"--text",muted:"--muted",line:"--line",border:"--border",action:"--action",onAction:"--on-action",accent:"--accent",focus:"--focus",success:"--success",warning:"--warning",danger:"--danger"};
|
||
const theme = { palette: store.get("ms.palette") || "harbor", mode: store.get("ms.mode") || "system" };
|
||
if (!PALETTES.some(p => p.id === theme.palette)) theme.palette = "harbor";
|
||
const mq = window.matchMedia ? matchMedia("(prefers-color-scheme: dark)") : null;
|
||
function resolvedMode(){
|
||
if (theme.mode !== "system") return theme.mode;
|
||
const dt = root.getAttribute("data-theme");
|
||
if (dt === "dark" || dt === "light") return dt;
|
||
return mq && mq.matches ? "dark" : "light";
|
||
}
|
||
function applyTheme(){
|
||
const p = PALETTES.find(x => x.id === theme.palette);
|
||
const mode = resolvedMode();
|
||
const t = p.modes[mode];
|
||
for (const k in VARS) root.style.setProperty(VARS[k], t[k]);
|
||
root.style.setProperty("--strong", mode === "light" ? "color-mix(in srgb,var(--text) 72%,#000)" : "color-mix(in srgb,var(--text) 70%,#fff)");
|
||
root.style.colorScheme = mode === "light" ? "light" : "dark";
|
||
const d = p.modes.dark;
|
||
const tv = {"--t-bg":d.canvas,"--t-fg":d.text,"--t-muted":d.muted,"--t-line":d.line,"--t-action":d.action,"--t-accent":d.accent,"--t-success":d.success,"--t-warning":d.warning,"--t-danger":d.danger,"--t-sel":d.raised,"--t-strong":"color-mix(in srgb,"+d.text+" 60%,#fff)"};
|
||
for (const k in tv) root.style.setProperty(k, tv[k]);
|
||
$("#palSel").value = theme.palette;
|
||
$$("#modeSeg button").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.mode === theme.mode)));
|
||
renderPalGrid();
|
||
}
|
||
function setTheme(patch){ Object.assign(theme, patch); store.set("ms.palette", theme.palette); store.set("ms.mode", theme.mode); applyTheme(); }
|
||
$("#palSel").innerHTML = PALETTES.map(p => `<option value="${p.id}">${esc(p.name)}${p.id==="harbor"?" (proposed default)":""}</option>`).join("");
|
||
$("#palSel").addEventListener("change", e => setTheme({palette:e.target.value}));
|
||
$$("#modeSeg button").forEach(b => b.addEventListener("click", () => setTheme({mode:b.dataset.mode})));
|
||
if (mq && mq.addEventListener) mq.addEventListener("change", () => theme.mode === "system" && applyTheme());
|
||
new MutationObserver(() => theme.mode === "system" && applyTheme()).observe(root, {attributes:true, attributeFilter:["data-theme"]});
|
||
|
||
function renderPalGrid(){
|
||
const mode = resolvedMode();
|
||
$("#palGrid").innerHTML = PALETTES.map(p => {
|
||
const t = p.modes[mode];
|
||
const sw = ["canvas","surface","text","action","accent","success","warning","danger"].map((k,i) => `<i class="${i===3?"big":""}" style="background:${t[k]}"></i>`).join("");
|
||
return `<button class="pal" data-pal="${p.id}" aria-pressed="${p.id===theme.palette}"><div class="sw" aria-hidden="true">${sw}</div><b>${esc(p.name)}${p.id==="harbor"?" · proposed default":""}</b><span>${esc(p.theory)}. ${esc(p.note||"")}</span></button>`;
|
||
}).join("");
|
||
}
|
||
$("#palGrid").addEventListener("click", e => { const b = e.target.closest(".pal"); if (b) setTheme({palette:b.dataset.pal}); });
|
||
|
||
/* ---------------- icons gallery ---------------- */
|
||
const ICONS = [["board","Board"],["inbox","Inbox"],["talk","Talk"],["tasks","Tasks"],["agents","Agents"],["trail","Trail"],["queue","Queue"],["runs","Runs"],["ledger","Ledger"],["business","Business"],["library","Library"],["settings","Settings"],["search","Search"],["copy","Copy"],["term","Terminal"],["flag","Onboarding"]];
|
||
$("#iconGrid").innerHTML = ICONS.map(([k,n]) => `<figure><svg class="i" aria-hidden="true"><use href="#i-${k}"/></svg><figcaption>${n}</figcaption></figure>`).join("");
|
||
|
||
/* ---------------- tabs ---------------- */
|
||
const TABS = ["brand","console","terminal","coverage"];
|
||
function showTab(id, focus){
|
||
if (!TABS.includes(id)) id = "brand";
|
||
TABS.forEach(t => { $("#"+t).hidden = t !== id; $("#tab-"+t).setAttribute("aria-selected", String(t===id)); $("#tab-"+t).tabIndex = t===id ? 0 : -1; });
|
||
if (focus) $("#tab-"+id).focus();
|
||
if (id === "terminal") drawTerm();
|
||
}
|
||
TABS.forEach(t => $("#tab-"+t).addEventListener("click", () => showTab(t)));
|
||
$(".tabs").addEventListener("keydown", e => {
|
||
const i = TABS.findIndex(t => $("#tab-"+t).getAttribute("aria-selected")==="true");
|
||
if (e.key === "ArrowRight") { showTab(TABS[(i+1)%TABS.length], true); e.preventDefault(); }
|
||
if (e.key === "ArrowLeft") { showTab(TABS[(i+TABS.length-1)%TABS.length], true); e.preventDefault(); }
|
||
});
|
||
|
||
/* ================= SAMPLE DATA ================= */
|
||
const DEC = [
|
||
{id:"7f3a91c2-5d0e-4b8a-9c11-2e6f0a4d7b13", cls:"gated", blocking:true, action:"git.push.protected", target:"origin/refactor", role:"pm", seat:"Sage", run:"run-1914-pm", at:"19:19", age:"14m", q:"Push the six reviewed row 41 commits to origin/refactor?", opts:[["1","Push now"],["2","Hold until you have read the diff"]], rec:"1", approval:"1", task:"vikunja:32/48", route:"you", state:"open", seen:true,
|
||
hist:[["19:19","raised by pm (run-1914-pm)"],["19:19","delivered: CLI inbox, Discord DM"],["19:22","seen by you (CLI)"]]},
|
||
{id:"c41d77e0-1b2f-4e60-8a3d-55c9e2f01a88", cls:"gated", blocking:true, action:"role.revoke", target:"role:coder", role:"pm", seat:"Sage", run:"run-1914-pm", at:"18:52", age:"41m", q:"The coder session on vikunja:32/52 has not answered for 25 minutes and holds the role lock. Revoke it so a new session can claim the role?", opts:[["1","Revoke the holder"],["2","Wait another 30 minutes"],["3","Leave it; I will look"]], rec:"1", approval:"1", task:"vikunja:32/52", route:"you", state:"open", seen:false,
|
||
hist:[["18:52","raised by pm (run-1914-pm)"],["18:52","delivered: CLI inbox, Discord DM"]]},
|
||
{id:"2b8e04d1-77c1-4d2a-b0e5-0f3d9a6c2e41", cls:"gated", blocking:false, action:"message.external", target:"discord:#releases", role:"pm", seat:"Sage", run:"run-1914-pm", at:"17:40", age:"1h 53m", q:"Post the 0.0.12 release note to #releases? Draft is attached to vikunja:32/50.", opts:[["1","Post it"],["2","Not yet"]], rec:"2", approval:"1", task:"vikunja:32/50", route:"you", state:"open", seen:false,
|
||
hist:[["17:40","raised by pm (run-1914-pm)"],["—","queued for the 08:00 CT digest"]]},
|
||
{id:"91aa30f5-0c44-4a7e-9d10-6b2e8f5c3d07", cls:"cross-role", blocking:false, action:"task.scope.change", target:"vikunja:32/51", role:"reviewer", seat:"review session", run:"run-1902-rev", at:"19:02", age:"31m", q:"Split keyboard support for trail chips into its own task?", opts:[["1","Split it"],["2","Keep it in 32/51"]], rec:"1", approval:"1", task:"vikunja:32/51", route:"cto", state:"open", seen:false,
|
||
hist:[["19:02","raised by reviewer (run-1902-rev)"],["19:02","routed to cto, technical arbiter"]]},
|
||
{id:"5e0c2b19-a3d8-4f71-8e26-c4b07d1e9a52", cls:"cross-role", blocking:false, action:"task.priority.change", target:"vikunja:32/47", role:"coder", seat:"coder session", run:"run-1846-cod", at:"18:46", age:"47m", q:"Raise the daily digest task to priority 4 so it lands before S7?", opts:[["1","Raise to 4"],["2","Keep at 3"]], rec:"2", approval:"1", task:"vikunja:32/47", route:"pm", state:"open", seen:true,
|
||
hist:[["18:46","raised by coder (run-1846-cod)"],["18:46","routed to pm, delivery arbiter"],["18:50","seen by pm"]]},
|
||
{id:"e07b5a3c-6d29-4c1b-91f0-3a8e2d4c7b60", cls:"gated", blocking:true, action:"git.push.protected", target:"origin/refactor", role:"pm", seat:"Sage", run:"run-1120-pm", at:"11:20", age:"8h", q:"Push the row 46 cohort fix?", opts:[["1","Push now"],["2","Hold"]], rec:"1", approval:"1", task:"vikunja:32/46", route:"you", state:"resolved", by:"you: 1", seen:true, hist:[["11:20","raised"],["11:31","resolved by you: 1 (authorized)"]]},
|
||
{id:"3d1c9e8a-2f40-4b6d-a5e7-81c0f9d2b346", cls:"gated", blocking:false, action:"message.external", target:"discord:#general", role:"pm", seat:"Sage", run:"run-0905-pm", at:"09:05", age:"10h", q:"Welcome the new reviewer role in #general?", opts:[["1","Post"],["2","Skip"]], rec:"2", approval:"1", task:null, route:"you", state:"withdrawn", by:"pm withdrew it", seen:true, hist:[["09:05","raised"],["09:40","withdrawn by pm"]]},
|
||
{id:"a6f2d0b7-9e13-4c58-b2a4-7d5e1c8f0a29", cls:"gated", blocking:false, action:"deploy", target:"tasks.mosaicstack.dev", role:"cto", seat:"Darkwing", run:"run-0712-cto", at:"Oct 8", age:"1d", q:"Upgrade the bundled Vikunja image?", opts:[["1","Upgrade"],["2","Stay on v2.7.0"]], rec:"2", approval:"1", task:null, route:"you", state:"expired", by:"expired after 24 h", seen:true, hist:[["Oct 8","raised"],["Oct 9","expired"]]}
|
||
];
|
||
const TASKS = [
|
||
{ref:"vikunja:32/48", title:"mosaic talk: the PM conversation is the front door", bucket:"in-progress", pri:4, who:"coder", req:"REQ-CLI-1", markers:[], upd:"19:14"},
|
||
{ref:"vikunja:32/51", title:"WebUI trail filter chips", bucket:"in-review", pri:3, who:"reviewer", req:"REQ-WEB-1", markers:[["warn","changed in Vikunja"]], upd:"19:02"},
|
||
{ref:"vikunja:32/52", title:"Free the role claim when a run ends", bucket:"blocked", pri:4, who:"coder", req:"REQ-ROLE-2", markers:[["gated","conflict"]], upd:"18:52"},
|
||
{ref:"vikunja:32/47", title:"Daily digest at 08:00 Central", bucket:"todo", pri:3, who:"—", req:"REQ-DEC-4", markers:[], upd:"18:46"},
|
||
{ref:"vikunja:32/53", title:"Warn 14 days before a role token expires", bucket:"todo", pri:2, who:"—", req:"REQ-CRED-1", markers:[["","not created by the stack"]], upd:"16:30"},
|
||
{ref:"vikunja:32/50", title:"Release note for 0.0.12", bucket:"in-review", pri:2, who:"pm", req:"REQ-CLI-1", markers:[], upd:"17:40"},
|
||
{ref:"vikunja:32/39", title:"Hourly reconcile receipts", bucket:"?", pri:"—", who:"—", req:"REQ-TASK-2", markers:[["warn","missing: moved"]], upd:"13:05"},
|
||
{ref:"vikunja:32/46", title:"Conversation cohort: K1, K3, K10 on scope fixtures", bucket:"done", pri:3, who:"coder", req:"REQ-CLI-2", markers:[], upd:"11:31"}
|
||
];
|
||
const ROLES = [
|
||
{role:"pm", holder:"Sage", harness:"pi · opus", state:"working", since:"3h 12m", run:"run-1914-pm", attn:""},
|
||
{role:"cto", holder:"Darkwing", harness:"pi · opus", state:"waiting", since:"1h 05m", run:"run-1832-cto", attn:"Input needed: pick the trail chip split (91aa30f5)"},
|
||
{role:"coder", holder:"session 3c9e", harness:"claude · sonnet", state:"error", since:"25m", run:"run-1846-cod", attn:"no output for 25 min"},
|
||
{role:"reviewer", holder:"—", harness:"—", state:"idle", since:"", run:"", attn:""}
|
||
];
|
||
const SEATS = [
|
||
["Sage","lead · pm","working","","1m"],["Darkwing","cto","waiting","Input needed: pick the trail chip split (91aa30f5)","4m"],
|
||
["Dewey","frontend","working","","2m"],["Filbert","row 41 S6","working","","6m"],["Rocko","coder","idle","","38m"],["Researcher","research","offline","","2d"]
|
||
];
|
||
const TRAIL = [
|
||
["14:02:11","human.input","you → pm","“Ship the trail filter fix today if review passes.”","req"],
|
||
["14:02:40","task.created","pm","vikunja:32/48 · REQ-CLI-1","task"],
|
||
["14:02:41","task.assigned","pm","vikunja:32/48 → coder","task"],
|
||
["14:03:02","session.launched","pm","coder · claude · sonnet (Sonnet 1 of 4)","launch"],
|
||
["14:11:55","action.allowed","coder","task.update.assigned · bucket → in-review","task"],
|
||
["14:12:00","review.requested","coder → reviewer","vikunja:32/48","review"],
|
||
["14:18:47","review.verdict","reviewer","pass, 0 findings","review"],
|
||
["14:19:04","action.refused","pm","git.push.protected · human-required","dec"],
|
||
["14:19:04","decision","pm → you","7f3a91c2 raised · gated, blocking · CLI inbox + Discord DM","dec"],
|
||
["14:22:30","decision","you","7f3a91c2 seen (CLI)","dec"],
|
||
["14:23:41","human.input","you","decide 7f3a91c2 1 → outcome-unknown; nothing retried","req"],
|
||
["14:24:02","task.changed.external","sync","vikunja:32/51 title edited in Vikunja","task"]
|
||
];
|
||
const QUEUE = [
|
||
["38","Slice 1 S3: tasks, the Vikunja adapter, broker task verbs and sync","darkwing","done"],
|
||
["39","Slice 1 S4: the mosaic CLI, inbox, decide, tasks, agents, trail","rocko","done"],
|
||
["40","Slice 1 S5: WebUI inbox, tasks, agents and trails; CHAT-03 Gate E","dewey","in-progress"],
|
||
["41","Slice 1 S6: meta-harness and launching; the PM moves off T3","filbert","in-progress"],
|
||
["42","Slice 1 S7: acceptance, five pieces in a row","sage","queued"],
|
||
["43","S2b: gated approvals are single-use","rocko","done"],
|
||
["44","S2c: a within-role message cites a decision without consuming it","rocko","done"],
|
||
["45","S4 follow-up: notifier retry limit, journal types, round 2 test gaps","rocko","done"],
|
||
["46","Conversation cohort: K1, K3 and K10 fail on the scope fixtures","darkwing","done"]
|
||
];
|
||
const ACTIONS = ["task.create","task.assign","task.reassign","task.schedule","task.update.assigned","task.close","task.priority.change","task.scope.change","role.launch","message.send","review.request","git.push.protected","git.merge.protected","deploy","spend","credential.mint","message.external","policy.change","prd.approve","role.revoke"];
|
||
const ALWAYS_GATED = ["git.push.protected","git.merge.protected","deploy","spend","credential.mint","message.external","policy.change","prd.approve","role.revoke"];
|
||
const AUTH = {
|
||
pm:{W:["task.create","task.assign","task.reassign","task.schedule","task.update.assigned","task.close","role.launch","message.send"],X:["task.priority.change","task.scope.change"]},
|
||
cto:{W:["task.update.assigned","message.send","review.request"],X:["task.scope.change"]},
|
||
coder:{W:["task.update.assigned","message.send","review.request"],X:[]},
|
||
reviewer:{W:["task.update.assigned","message.send"],X:[]}
|
||
};
|
||
|
||
/* ================= CONSOLE ================= */
|
||
const SECTIONS = [
|
||
["board","Board","board"],["inbox","Inbox","inbox"],["talk","Talk","talk"],["tasks","Tasks","tasks"],["agents","Agents","agents"],["trail","Trail","trail"],
|
||
["-","Build"],["queue","Queue","queue"],["runs","Runs and releases","runs"],["ledger","Ledger","ledger"],
|
||
["-","Configure"],["business","Business","business"],["library","Library","library"],["settings","Settings","settings"],["onboarding","Set up","flag"]
|
||
];
|
||
const S = { route:"board", sel:null, inboxTab:"you", taskFilter:"open", trailFilter:"All", trailSubject:"vikunja:32/48", previewState:"normal", controller:false, talkStatus:"", talkLog:null, wizStep:0, seenSeats:{} };
|
||
const openFor = () => DEC.filter(d => d.state === "open" && d.route === "you");
|
||
|
||
function renderSide(){
|
||
const open = openFor().length;
|
||
let h = "";
|
||
for (const s of SECTIONS) {
|
||
if (s[0] === "-") { h += `<span class="label">${s[1]}</span>`; continue; }
|
||
const n = s[0]==="inbox" ? `<span class="n hot" aria-label="${open} open for you">${open}</span>` : s[0]==="tasks" ? `<span class="n">${TASKS.filter(t=>t.bucket!=="done").length}</span>` : "";
|
||
h += `<button class="nav" data-go="${s[0]}" ${S.route.split("/")[0]===s[0]?'aria-current="page"':""}><svg class="i" aria-hidden="true"><use href="#i-${s[2]}"/></svg>${s[1]}${n}</button>`;
|
||
if (s[0]==="trail") h += `<span class="label">Projects</span><div class="tree"><span><b>Mosaic Stack</b></span><span>└ stack · tracker 32 · refactor</span></div>`;
|
||
}
|
||
$("#side").innerHTML = h;
|
||
}
|
||
$("#side").addEventListener("click", e => { const b = e.target.closest("[data-go]"); if (b) go(b.dataset.go); });
|
||
|
||
function go(route, opts){
|
||
opts = opts || {};
|
||
const base = route.split("/")[0];
|
||
if (!SECTIONS.some(s => s[0]===base)) route = "notfound:" + route;
|
||
S.route = route;
|
||
if (!opts.keepSel) closeInsp();
|
||
renderSide();
|
||
renderView();
|
||
if (!opts.noFocus) $("#view").focus({preventScroll:true});
|
||
$("#view").scrollTop = 0;
|
||
}
|
||
|
||
function vh(title, note, right){ return `<div class="vh"><h2>${title}</h2>${note?`<p class="note">${note}</p>`:""}${right?`<div class="right">${right}</div>`:""}</div>`; }
|
||
function stateBanner(kind){
|
||
const st = S.previewState;
|
||
if (st === "error") return `<div class="banner err" role="alert"><svg class="i" aria-hidden="true"><use href="#i-flag"/></svg><div class="grow"><strong>Can't reach the bus host.</strong> Showing what was read at 14:20:57, 70 s ago. Retrying every 10 s. If it stays down, run <code>mosaic bus status</code> in a terminal.</div><button class="btn" data-sim="Retried: still unreachable">Retry now</button></div>`;
|
||
if (st === "refusal") return `<div class="banner ref" role="alert"><div class="grow"><strong>refused: not-configured.</strong> The business file for <code>mosaic-stack</code> is missing or invalid, so nothing is shown (fail closed). The stack never writes this file. Fix it by hand, then run <code>mosaic bus start mosaic-stack</code>. <a href="#" data-go="onboarding">Open Set up</a></div></div>`;
|
||
return "";
|
||
}
|
||
function skelRows(cols, n){ let r=""; for(let i=0;i<(n||6);i++){ r += "<tr>"+Array.from({length:cols},(_,j)=>`<td><div class="skel" style="width:${[60,90,70,40,80,55][(i+j)%6]}%"></div></td>`).join("")+"</tr>"; } return r; }
|
||
function body(rowsHtml, cols, emptyHtml){
|
||
if (S.previewState === "loading") return skelRows(cols);
|
||
if (S.previewState === "empty" || S.previewState === "refusal") return S.previewState==="refusal" ? `<tr><td colspan="${cols}"><div class="empty"><b>Nothing to show</b>The view stays empty until the configuration problem above is fixed.</div></td></tr>` : `<tr><td colspan="${cols}"><div class="empty">${emptyHtml}</div></td></tr>`;
|
||
return rowsHtml;
|
||
}
|
||
function stDot(s){ const g={working:"●",waiting:"◐",error:"▲",offline:"○",idle:"–",unknown:"?"}[s]||"?"; return `<span class="dot s-${s}"><b aria-hidden="true">${g}</b>${s}</span>`; }
|
||
function clsChip(d){ return `<span class="chip ${d.cls==="gated"?"gated":d.cls==="cross-role"?"cross":"within"}">${d.cls}</span>${d.blocking?` <span class="chip block">BLOCKING</span>`:""}`; }
|
||
|
||
const VIEWS = {
|
||
board(){
|
||
const open = openFor(); const blk = open.filter(d=>d.blocking).length;
|
||
const cards = S.previewState==="loading" ? "" : `<div class="cards">
|
||
<div class="card"><span class="label">Waiting on you</span><div class="big tnum">${S.previewState==="empty"?0:open.length}</div><div class="note">${S.previewState==="empty"?"Nothing is waiting.":`${blk} blocking · <a href="#" data-go="inbox">open Inbox</a>`}</div></div>
|
||
<div class="card"><span class="label">Open tasks · project 32</span><div class="big tnum">${S.previewState==="empty"?0:TASKS.filter(t=>t.bucket!=="done").length}</div><div class="note">1 blocked · 2 in review · 1 missing</div></div>
|
||
<div class="card"><span class="label">Sessions</span><div class="tnum" style="font-weight:700;color:var(--strong)">Opus 2 of 4 · Sonnet 1 of 4</div><div class="meter" aria-hidden="true"><i class="on"></i><i class="on"></i><i></i><i></i><i style="flex:.3;background:none"></i><i class="on"></i><i></i><i></i><i></i></div><div class="note">Launching allowed</div></div>
|
||
<div class="card"><span class="label">Next digest</span><div class="big tnum">08:00</div><div class="note">Central · 1 non-blocking decision queued</div></div></div>`;
|
||
const rows = SEATS.map(([n,r,s,a,t]) => {
|
||
const seen = S.seenSeats[n];
|
||
return `<tr class="row" tabindex="0" data-seat="${n}"><td><b style="color:var(--strong)">${n}</b></td><td>${r}</td><td>${stDot(s)}</td><td class="wrap attn">${a?`<b>Input needed:</b> ${esc(a.replace("Input needed: ",""))}`:'<span class="note">—</span>'}</td><td class="mono tnum">${t}</td><td>${a?`<button class="btn" data-seen="${n}" aria-pressed="${!!seen}">${seen?"Unsee":"Seen"}</button>`:""}</td></tr>`;
|
||
}).join("");
|
||
return vh("Board","Home. Who is working, who is waiting and what needs you. Refreshes every 10 s.") + stateBanner() + cards +
|
||
`<div class="tbl"><table><thead><tr><th>Seat</th><th>Role</th><th>State</th><th>Attention</th><th>Last</th><th><span class="sr">Seen</span></th></tr></thead><tbody>${body(rows,6,"<b>No seats are registered</b>Seats appear here when a role is claimed or a development seat starts.")}</tbody></table></div>
|
||
<p class="note" style="margin-top:8px">Seen marks that you noticed an <em>Input needed:</em> line. It does not answer it. Answers go to the seat, and decisions go through <code>mosaic decide</code>.</p>`;
|
||
},
|
||
inbox(){
|
||
const tabs = [["you","For you",openFor().length],["roles","Routed to roles",DEC.filter(d=>d.state==="open"&&d.route!=="you").length],["recent","Recent",DEC.filter(d=>d.state!=="open").length]];
|
||
const list = S.inboxTab==="you" ? openFor().sort((a,b)=>(b.blocking-a.blocking)) : S.inboxTab==="roles" ? DEC.filter(d=>d.state==="open"&&d.route!=="you") : DEC.filter(d=>d.state!=="open");
|
||
const rows = list.map(d => `<tr class="row" tabindex="0" data-dec="${d.id}" aria-selected="${S.sel===d.id}"><td class="mono">${d.id.slice(0,8)}</td><td>${clsChip(d)}</td><td class="mono">${d.action}</td><td class="mono">${d.target}</td><td>${d.role} <span class="note">(${esc(d.seat)})</span></td>${S.inboxTab==="you"?`<td>${d.blocking?"now":"digest"}</td>`:S.inboxTab==="roles"?`<td>${d.route}</td>`:`<td>${d.state}<div class="note">${esc(d.by)}</div></td>`}<td class="mono tnum">${d.age}</td>${S.inboxTab==="you"?`<td>${d.seen?'<span class="note">seen</span>':'<b style="color:var(--strong)">new</b>'}</td>`:"<td></td>"}</tr>`).join("");
|
||
const col4 = S.inboxTab==="you"?"Reaches you":S.inboxTab==="roles"?"Arbiter":"Outcome";
|
||
return vh("Inbox","Decisions only. Choose one to read it and copy its command; resolving happens in <code>mosaic</code>.") + stateBanner() +
|
||
`<div class="subtabs" role="tablist">${tabs.map(([k,n,c])=>`<button role="tab" data-itab="${k}" aria-selected="${S.inboxTab===k}">${n} <span class="note tnum">${c}</span></button>`).join("")}</div>
|
||
<div class="tbl"><table><thead><tr><th>ID</th><th>Class</th><th>Action</th><th>Target</th><th>Raised by</th><th>${col4}</th><th>Age</th><th><span class="sr">Seen</span></th></tr></thead><tbody>${body(rows,8,S.inboxTab==="you"?"<b>Nothing waits on you</b>Gated decisions land here, and blocking ones also send a Discord DM.":"<b>Nothing here</b>")}</tbody></table></div>`;
|
||
},
|
||
talk(){
|
||
if (!S.talkLog) S.talkLog = [
|
||
{k:"me",who:"you",t:"14:02",txt:"Ship the trail filter fix today if review passes.",r:"admitted"},
|
||
{k:"pm",who:"pm · Sage",t:"14:02",txt:"Understood. 32/48 is assigned to a coder session and review goes to the reviewer role. I'll ask you before anything is pushed."},
|
||
{k:"n",txt:"review.verdict pass on vikunja:32/48 (reviewer)"},
|
||
{k:"pm",who:"pm · Sage",t:"14:19",txt:"Review passed with no findings. The push is gated, so it's in your inbox as 7f3a91c2. I recommend 1, push now."},
|
||
{k:"n",txt:"decision 7f3a91c2 raised · gated, blocking"},
|
||
{k:"me",who:"you",t:"14:26",txt:"Also draft the release note but don't post it.",r:"admitted"},
|
||
{k:"pm",who:"pm · Sage",t:"14:26",txt:"Drafting it on vikunja:32/50. Posting would be message.external, which is gated, so it will come to you."}
|
||
];
|
||
const msgs = S.previewState==="loading" ? `<div class="skel" style="width:40%"></div><div class="skel" style="width:70%"></div><div class="skel" style="width:55%"></div>` :
|
||
S.previewState==="empty" ? `<div class="empty"><b>No conversation yet</b>Say what you want done. The PM turns it into tasks that cite a requirement.</div>` :
|
||
S.talkLog.map(m => m.k==="n" ? `<div class="notice">${esc(m.txt)}</div>` : `<div class="msg ${m.k==="me"?"me":""}"><span class="who">${esc(m.who)}<time>${m.t}</time></span><div class="txt">${esc(m.txt)}</div>${m.r?`<span class="rcpt ${m.r.startsWith("not")?"bad":""}">${esc(m.r)}</span>`:""}</div>`).join("");
|
||
const role = S.controller ? "controller" : "observer";
|
||
return vh("Talk","The PM session the stack launched and owns (REQ-CLI-2). One controller types; others watch.", `<span class="st progress">in progress · row 40</span>`) + stateBanner() +
|
||
`<div class="talk"><div class="th"><span class="state">${S.previewState==="error"?"disconnected":"working"} | ${role} | unknown events: 0</span><span class="note">pm · Sage · pi · opus</span>
|
||
<div class="right"><button class="btn" id="takeCtl" ${S.controller?"disabled":""}>Take control <kbd>Ctrl T</kbd></button><button class="btn" data-sim="Interrupt sent (simulated)">Interrupt <kbd>Ctrl G</kbd></button><button class="btn" data-sim="Reconnected (simulated)">Reconnect <kbd>Ctrl O</kbd></button></div></div>
|
||
<div class="msgs" id="msgs" aria-live="polite">${msgs}</div>
|
||
<form class="composer" id="composer"><label class="sr" for="talkIn">Message to the PM</label><textarea id="talkIn" placeholder="${S.controller?"Message the PM. Enter sends, Shift+Enter adds a line.":"You are watching. Take control to type."}"></textarea>
|
||
<div class="row"><span class="status ${S.talkStatus.startsWith("not")?"bad":""}" id="talkStatus">${esc(S.talkStatus|| (S.controller?"controller · prompts are admitted or refused with a code":"observer · Enter sends nothing"))}</span><span class="sim">Simulated</span><button class="btn pri" type="submit">Send</button></div></form></div>`;
|
||
},
|
||
tasks(){
|
||
const f = S.taskFilter;
|
||
const list = TASKS.filter(t => f==="all" ? true : f==="open" ? t.bucket!=="done" : t.bucket===f);
|
||
const rows = list.map(t => `<tr class="row" tabindex="0" data-task="${t.ref}" aria-selected="${S.sel===t.ref}"><td class="mono">${t.ref.replace("vikunja:","")}</td><td class="wrap" style="color:var(--strong)">${esc(t.title)}</td><td class="mono">${t.bucket}</td><td class="mono tnum">${t.pri}</td><td>${t.who}</td><td class="mono">${t.req}</td><td>${t.markers.map(([c,m])=>`<span class="chip ${c}">${m}</span>`).join(" ")}</td></tr>`).join("");
|
||
const chips = ["open","todo","in-progress","in-review","blocked","done","all"];
|
||
return vh("Tasks","Project 32 in Vikunja. Polled every 30 s, fully reconciled every hour.", `<span class="note tnum">Vikunja last read <b style="color:var(--strong)">${S.previewState==="error"?"14:20:57 (stale)":"14:21:07"}</b></span><span class="st implemented">implemented</span>`) + stateBanner() +
|
||
`<div class="chips" role="group" aria-label="Bucket">${chips.map(c=>`<button data-tf="${c}" aria-pressed="${f===c}">${c}</button>`).join("")}</div>
|
||
<div class="tbl"><table><thead><tr><th>Ref</th><th>Title</th><th>Bucket</th><th>Pri</th><th>Assignee</th><th>Requirement</th><th>Markers</th></tr></thead><tbody>${body(rows,7,"<b>No open tasks</b>Tell the PM what you want done in Talk.")}</tbody></table></div>
|
||
<p class="note" style="margin-top:8px">Read-only. Agents change tasks through eight broker verbs; your edits in Vikunja come back as events and show the <em>changed in Vikunja</em> marker.</p>`;
|
||
},
|
||
agents(){
|
||
const rows = ROLES.map(r => `<tr class="row" tabindex="0" data-role="${r.role}"><td class="mono"><b>${r.role}</b></td><td>${esc(r.holder)}</td><td class="mono">${r.harness}</td><td>${stDot(r.state)}</td><td class="wrap attn">${r.attn?esc(r.attn):'<span class="note">—</span>'}</td><td class="mono tnum">${r.since||"—"}</td><td class="mono">${r.run||"—"}</td></tr>`).join("");
|
||
const creds = [["gitea","mosaic-stack-pm-bot","pm","no expiry; rotate by 2026-11-01",""],["gitea","mosaic-stack-cto-bot","cto","no expiry; rotate by 2026-11-01",""],["vikunja","bot-mosaic-stack-pm","pm","expires 2026-11-02",""],["vikunja","bot-mosaic-stack-sync","sync (read-only)","expires 2026-10-21 · 12 days","warn"],["vikunja","bot-mosaic-stack-coder","coder","not configured","bad"]];
|
||
return vh("Agents","Role instances, who holds each one, and the launch budget.", `<span class="st implemented">implemented</span>`) + stateBanner() +
|
||
`<div class="cards"><div class="card"><span class="label">Opus sessions</span><div class="big tnum">2 <span class="note">of 4</span></div><div class="meter" aria-hidden="true"><i class="on"></i><i class="on"></i><i></i><i></i></div></div>
|
||
<div class="card"><span class="label">Sonnet sessions</span><div class="big tnum">1 <span class="note">of 4</span></div><div class="meter" aria-hidden="true"><i class="on"></i><i></i><i></i><i></i></div></div>
|
||
<div class="card"><span class="label">Launching</span><div class="big" style="color:var(--success)">allowed</div><div class="note">Stop all launches with <code>mosaic stop</code> <span class="st planned">planned</span></div></div></div>
|
||
<div class="tbl"><table><thead><tr><th>Role</th><th>Holder</th><th>Harness</th><th>State</th><th>Attention</th><th>Held for</th><th>Run</th></tr></thead><tbody>${body(rows,7,"<b>No role is claimed</b>The PM launches role sessions within the business file's limits.")}</tbody></table></div>
|
||
<div class="section"><h3>Role credentials</h3><p class="note" style="margin-bottom:8px">Metadata only. Values and file paths are never shown; the broker alone reads them.</p>
|
||
<div class="tbl"><table><thead><tr><th>Service</th><th>Account</th><th>Role</th><th>Status</th></tr></thead><tbody>${body(creds.map(([s,a,r,x,c])=>`<tr><td>${s}</td><td class="mono">${a}</td><td>${r}</td><td>${c?`<span class="chip ${c==="bad"?"gated":"warn"}">${x}</span>`:x}</td></tr>`).join(""),4,"<b>No credentials declared</b>")}</tbody></table></div></div>`;
|
||
},
|
||
trail(){
|
||
const chips = [["All",null],["Requests","req"],["Decisions","dec"],["Launches","launch"],["Task changes","task"],["Review","review"]];
|
||
const cat = chips.find(c=>c[0]===S.trailFilter)[1];
|
||
const list = TRAIL.filter(e => !cat || e[4]===cat);
|
||
const items = S.previewState==="loading" ? Array.from({length:6},()=>`<li><div class="skel"></div><div class="skel"></div><div class="skel"></div><div class="skel"></div></li>`).join("") :
|
||
(S.previewState==="empty"||S.previewState==="refusal") ? `<li style="display:block"><div class="empty"><b>No events for this subject</b>Every step in slice 1 writes an event; a task with none has not been touched by the stack.</div></li>` :
|
||
list.map(e => `<li><time>${e[0]}</time><span class="k ${e[1]==="action.refused"?"bad":e[1]==="decision"?"dec":/unknown/.test(e[3])?"unk":""}">${e[1]}</span><span class="who">${esc(e[2])}</span><span>${esc(e[3])}</span></li>`).join("");
|
||
return vh("Trail","Every event for one task or decision, oldest first, from the append-only bus.", `<label class="sr" for="trailSub">Subject</label><select id="trailSub"><option ${S.trailSubject==="vikunja:32/48"?"selected":""}>vikunja:32/48</option><option ${S.trailSubject==="decision 7f3a91c2"?"selected":""}>decision 7f3a91c2</option></select>`) + stateBanner() +
|
||
`<div class="chips" role="group" aria-label="Event filter">${chips.map(([c])=>`<button data-trf="${c}" aria-pressed="${S.trailFilter===c}">${c}</button>`).join("")}</div><ol class="trail">${items}</ol>`;
|
||
},
|
||
queue(){
|
||
const rows = QUEUE.map(([id,p,o,s]) => `<tr><td class="mono tnum">${id}</td><td class="wrap" style="color:var(--strong)">${esc(p)}</td><td>${o}</td><td class="mono">${s==="in-progress"?`<span class="chip ok">${s}</span>`:s==="queued"?`<span class="chip">${s}</span>`:`<span class="note">${s}</span>`}</td></tr>`).join("");
|
||
return vh("Queue","Development rows from <code>docs/plans/queue.json</code>. Snapshot of 2026-10-09; read-only here. Rows move with <code>scripts/mosaic queue move</code>.", `<span class="st proposed">proposed view</span>`) + stateBanner() +
|
||
`<div class="tbl"><table><thead><tr><th>Row</th><th>Piece</th><th>Owner</th><th>State</th></tr></thead><tbody>${body(rows,4,"<b>Queue is empty</b>")}</tbody></table></div><p class="note" style="margin-top:8px">States: queued, briefed, in-progress, in-review, waiting-on-jason, done, blocked, parked.</p>`;
|
||
},
|
||
runs(){
|
||
const runs = [["run-1914-pm","pm","pi · opus","running","3h 12m",""],["run-1846-cod","coder","claude · sonnet","no output 25m","25m","warn"],["run-1832-cto","cto","pi · opus","running","1h 05m",""],["run-1120-pm","pm","pi · opus","exited 0","2h 40m",""],["run-0903-rev","reviewer","pi · sonnet","exited 1","14m","bad"]];
|
||
return vh("Runs and releases","Run records are write-once evidence under <code><dataRoot>/runs/</code>.", `<span class="st proposed">proposed view</span>`) + stateBanner() +
|
||
`<div class="cards"><div class="card"><span class="label">Active release</span><div class="big mono">0.0.12</div><div class="note">activated 2026-10-08 · <code>release.sh status</code></div></div><div class="card"><span class="label">Rollback target</span><div class="big mono">0.0.11</div><div class="note"><code>release.sh rollback</code> from a terminal</div></div></div>
|
||
<div class="tbl"><table><thead><tr><th>Run</th><th>Role</th><th>Harness</th><th>Result</th><th>Length</th></tr></thead><tbody>${body(runs.map(([r,ro,h,x,l,c])=>`<tr><td class="mono">${r}</td><td>${ro}</td><td class="mono">${h}</td><td>${c?`<span class="chip ${c==="bad"?"gated":"warn"}">${x}</span>`:x}</td><td class="mono tnum">${l}</td></tr>`).join(""),5,"<b>No runs recorded</b>")}</tbody></table></div>`;
|
||
},
|
||
ledger(){
|
||
return vh("Ledger","Weekly counts from commits, issues and seat sessions, plus the queue check.", `<span class="st proposed">proposed view</span>`) + stateBanner() +
|
||
(S.previewState==="loading"?`<div class="skel" style="width:50%"></div>`:`<div class="cards"><div class="card"><span class="label">Queue check</span><div class="big">reduced pass</div><div class="note">0 violations. Never a full pass: no owner's process identity is verified.</div></div>
|
||
<div class="card"><span class="label">Issues closed</span><div class="big tnum">11</div><div class="note">2026-10-03 to 2026-10-09 · median 19.4 h open</div></div>
|
||
<div class="card"><span class="label">Tagged commits</span><div class="big tnum">64</div><div class="note">follow-ups per issue 1.8</div></div></div>
|
||
<p class="note">The three results are <code>fail</code>, <code>incomplete</code> and <code>reduced pass</code>. Source: <code>node packages/ledger/src/cli.mjs --since …</code></p>`);
|
||
},
|
||
business(){
|
||
const roles = ["pm","cto","coder","reviewer"];
|
||
const cell = (r,a) => ALWAYS_GATED.includes(a) ? "G" : AUTH[r].W.includes(a) ? "W" : AUTH[r].X.includes(a) ? "X" : "G";
|
||
const matrix = ACTIONS.map(a => `<tr><td class="mono">${a}</td>${roles.map(r => { const c = cell(r,a); return `<td class="c ${c}" title="${c==="W"?"within role":c==="X"?"cross-role":"gated"}">${c}</td>`; }).join("")}</tr>`).join("");
|
||
const vars = [["limits.sessions.opus","4","—","—","—","4","limits intersect"],["limits.sessions.sonnet","4","—","—","—","4","limits intersect"],["digest.time","—","08:00 America/Chicago","—","—","08:00 CT","most specific wins"],["tracker.baseUrl","—","https://tasks.mosaicstack.dev","—","—","tasks.mosaicstack.dev","most specific wins"],["tracker.project","—","—","32","—","32","most specific wins"],["git.workingBranch","—","—","refactor","—","refactor","most specific wins"],["model.default","sonnet","—","—","opus (pm)","opus for pm","most specific wins"]];
|
||
return vh("Business: Mosaic Stack","Read-only. You write the business file; the stack never does, and a missing or invalid file fails closed (REQ-ROLE-3).", `<span class="st planned">planned view</span>`) + stateBanner() +
|
||
`<div class="cards"><div class="card"><span class="label">Role instances</span><div style="color:var(--strong);font-weight:600">pm · Sage<br>cto · Darkwing<br>coder<br>reviewer</div></div>
|
||
<div class="card"><span class="label">Arbiters</span><div style="color:var(--strong);font-weight:600">delivery → pm<br>technical → cto</div><div class="note">Cross-role decisions go to the arbiter for their domain.</div></div>
|
||
<div class="card"><span class="label">Roles</span><div class="mono" style="color:var(--strong)">roles/*.json · v2</div><div class="note">Change only through reviewed commits. Anything not listed is gated.</div></div></div>
|
||
<div class="grid2"><div style="min-width:0"><h3 class="label" style="margin-bottom:6px">Authority map</h3><div class="tbl"><table class="matrix"><thead><tr><th>Action</th>${roles.map(r=>`<th style="text-align:center">${r}</th>`).join("")}</tr></thead><tbody>${matrix}</tbody></table></div>
|
||
<p class="note" style="margin-top:6px"><b style="color:var(--success)">W</b> within role · <b style="color:var(--accent)">X</b> cross-role, to the arbiter · <b style="color:var(--danger)">G</b> gated, to you. Rows for cto, coder and reviewer are illustrative until their v2 files land.</p></div>
|
||
<div style="min-width:0"><h3 class="label" style="margin-bottom:6px">Variables</h3><div class="tbl"><table><thead><tr><th>Key</th><th>System</th><th>Business</th><th>Project</th><th>Agent</th><th>Resolved</th></tr></thead><tbody>${vars.map(v=>`<tr><td class="mono">${v[0]}</td><td class="mono">${v[1]}</td><td class="mono">${v[2]}</td><td class="mono">${v[3]}</td><td class="mono">${v[4]}</td><td class="mono" style="color:var(--strong)" title="${v[6]}">${v[5]}</td></tr>`).join("")}</tbody></table></div>
|
||
<p class="note" style="margin-top:6px">Plain values: the most specific layer wins. Limits intersect, so a lower layer can only narrow them. Secrets appear only as a reference name.</p></div></div>`;
|
||
},
|
||
library(){
|
||
const sk = [["ms-conductor","enabled","pm, cto"],["ms-board","enabled","all"],["ms-communications","enabled","pm"],["ms-blast-radius","enabled","cto, reviewer"],["ms-executive-update","available","—"],["ms-arena","available","—"],["ms-archify","available","—"]];
|
||
return vh("Library","Skills the meta-harness can put in a launch bundle (REQ-HARN-1).", `<span class="st proposed">proposed view</span>`) + stateBanner() +
|
||
`<div class="tbl"><table><thead><tr><th>Skill</th><th>State</th><th>Bundled for</th></tr></thead><tbody>${body(sk.map(([n,s,r])=>`<tr><td class="mono">${n}</td><td>${s==="enabled"?'<span class="chip ok">enabled</span>':'<span class="chip">available</span>'}</td><td>${r}</td></tr>`).join(""),3,"<b>No skills found</b>")}</tbody></table></div>
|
||
<p class="note" style="margin-top:8px">Enabling a skill is a configuration change made in the repository, not here. Sample list.</p>`;
|
||
},
|
||
settings(){
|
||
return vh("Settings","Appearance is yours to change here. Everything else is read from configuration and shown for reference.") + stateBanner() +
|
||
`<div class="grid2"><div class="panel stack"><h3 class="h2" style="font-size:16px">Appearance</h3><p class="note">Saved in this browser only.</p>
|
||
<div class="pals" style="grid-template-columns:repeat(auto-fill,minmax(150px,1fr))">${PALETTES.map(p=>`<button class="pal" data-pal="${p.id}" aria-pressed="${p.id===theme.palette}"><div class="sw" aria-hidden="true">${["canvas","action","accent","success"].map(k=>`<i style="background:${p.modes[resolvedMode()][k]}"></i>`).join("")}</div><b>${esc(p.name)}</b></button>`).join("")}</div>
|
||
<div class="seg" role="group" aria-label="Mode">${["system","light","dim","dark"].map(m=>`<button data-setmode="${m}" aria-pressed="${theme.mode===m}">${m[0].toUpperCase()+m.slice(1)}</button>`).join("")}</div></div>
|
||
<div class="panel stack"><h3 class="h2" style="font-size:16px">Notifications</h3>
|
||
<dl class="kv"><dt>Blocking gated</dt><dd>CLI inbox and a Discord DM, right away</dd><dt>Everything else</dt><dd>One digest at 08:00 America/Chicago</dd><dt>Discord</dt><dd>connector #1509 · DM to you only</dd></dl>
|
||
<p class="note">Set in the business variables. <span class="st implemented">implemented</span> DM · <span class="st planned">planned</span> digest</p>
|
||
<h3 class="h2" style="font-size:16px;margin-top:8px">About</h3>
|
||
<dl class="kv"><dt>Release</dt><dd class="mono">0.0.12</dd><dt>WebUI</dt><dd class="mono">127.0.0.1:7330 · loopback only</dd><dt>Control board</dt><dd class="mono">127.0.0.1:7331</dd><dt>Sign-in</dt><dd>None in v1; localhost only, no SSO</dd><dt>System config</dt><dd class="mono">~/.config/mosaic-dev/config.json</dd></dl></div></div>`;
|
||
},
|
||
onboarding(){
|
||
const steps = ["System config","Business file","Task tracker","Role tokens","Talk to the PM"];
|
||
const panes = [
|
||
`<h3 class="h2">Check the system config</h3><p>The stack reads one file and never writes it. <code>scripts/bootstrap.sh</code> creates it once.</p><div class="check"><b class="ok">✓</b><span><code>~/.config/mosaic-dev/config.json</code> found and valid</span></div><div class="check"><b class="ok">✓</b><span>data root <code>~/.mosaic-dev</code> writable</span></div>`,
|
||
`<h3 class="h2">Write the business file</h3><p>Declare role instances, arbiters and credential references. The stack refuses to start without it.</p><pre class="code">{ "id": "mosaic-stack",
|
||
"roles": { "pm": {...}, "cto": {...}, "coder": {...}, "reviewer": {...} },
|
||
"arbiters": { "delivery": "pm", "technical": "cto" },
|
||
"limits": { "opus": 4, "sonnet": 4 } }</pre><div class="check"><b class="bad">✗</b><span>refused: not-configured. <code>arbiters.technical</code> names a role that isn't declared. Fix the file, then check again.</span></div>`,
|
||
`<h3 class="h2">Choose a task tracker</h3><p>Point at an existing Vikunja, or deploy the bundled upstream image (REQ-TASK-3).</p><div class="check"><b class="wait">…</b><span>Existing: <code>https://tasks.mosaicstack.dev</code>, project 32</span></div><div class="check"><b class="wait">…</b><span>Bundled: Vikunja v2.7.0, unmodified</span></div>`,
|
||
`<h3 class="h2">Create role tokens</h3><p>You create these by hand from the runbook; the stack never sees your own credentials (REQ-CRED-1, hard limit C).</p><div class="check"><b class="ok">✓</b><span>gitea · mosaic-stack-pm-bot</span></div><div class="check"><b class="bad">✗</b><span>vikunja · bot-mosaic-stack-coder: not configured</span></div>`,
|
||
`<h3 class="h2">Talk to the PM</h3><p>Open a terminal and run <code>mosaic</code>. Say what you want done in one sentence.</p><pre class="code">$ mosaic</pre>`
|
||
];
|
||
const i = S.wizStep;
|
||
return vh("Set up","A first-run path for someone who isn't Jason (PRD Users).", `<span class="st proposed">proposed</span>`) +
|
||
`<ol class="steps">${steps.map((s,j)=>`<li ${j===i?'aria-current="step"':""} class="${j<i?"done":""}">${s}</li>`).join("")}</ol>
|
||
<div class="wiz panel">${panes[i]}<div class="row" style="display:flex;gap:8px;flex-wrap:wrap"><button class="btn" id="wizBack" ${i===0?"disabled":""}>Back</button><button class="btn pri" id="wizNext" ${i===steps.length-1?"disabled":""}>Check and continue</button><span class="sim">Simulated</span></div></div>`;
|
||
},
|
||
notfound(){
|
||
const bad = S.route.replace("notfound:","");
|
||
return `<div class="empty" style="padding:60px 16px"><b>No view called “${esc(bad)}”</b>It may have moved. <a href="#" data-go="board">Go to the Board</a> or press <kbd>Ctrl K</kbd> to search.</div>`;
|
||
}
|
||
};
|
||
|
||
function renderView(){
|
||
const base = S.route.split("/")[0];
|
||
const fn = S.route.startsWith("notfound:") ? VIEWS.notfound : VIEWS[base];
|
||
$("#view").innerHTML = fn();
|
||
}
|
||
|
||
/* ---- inspector ---- */
|
||
function openInsp(kind, key){
|
||
S.sel = key;
|
||
const app = $("#app"); app.classList.add("with-insp");
|
||
const el = $("#insp"); el.hidden = false;
|
||
el.innerHTML = kind==="dec" ? decInsp(DEC.find(d=>d.id===key)) : kind==="task" ? taskInsp(TASKS.find(t=>t.ref===key)) : roleInsp(ROLES.find(r=>r.role===key));
|
||
$$("#view tr.row").forEach(r => r.setAttribute("aria-selected", String(r.dataset.dec===key||r.dataset.task===key||r.dataset.role===key)));
|
||
$(".x", el).focus();
|
||
}
|
||
function closeInsp(){
|
||
const had = S.sel; S.sel = null;
|
||
$("#app").classList.remove("with-insp"); $("#insp").hidden = true; $("#insp").innerHTML = "";
|
||
$$("#view tr.row[aria-selected]").forEach(r => r.setAttribute("aria-selected","false"));
|
||
if (had) { const r = $(`#view tr.row[data-dec="${had}"],#view tr.row[data-task="${had}"],#view tr.row[data-role="${had}"]`); if (r) r.focus(); }
|
||
}
|
||
function inspHead(label, extra){ return `<header><span class="mono">${label}</span>${extra||""}<button class="x" aria-label="Close inspector (Esc)"><svg class="i"><use href="#i-close"/></svg></button></header>`; }
|
||
function decInsp(d){
|
||
const short = d.id.slice(0,8);
|
||
const auth = `<p class="auth">Choosing “${d.approval}” authorizes <code>${d.action}</code> on <code>${d.target}</code>. Any other choice declines.</p>`;
|
||
const opts = d.opts.map(([k,t]) => { const cmd = `mosaic decide ${short} ${k}`; return `<li class="${k===d.rec?"rec":""}"><div><span class="k">${k}</span> <span style="color:var(--strong)">${esc(t)}</span>${k===d.rec?` <span class="chip ok">recommended</span>`:""}${k===d.approval?` <span class="note">authorizes</span>`:""}</div>${d.state==="open"&&d.route==="you"?`<div class="cmd"><code>${cmd}</code><button class="btn" data-copy="${cmd}" aria-label="Copy ${cmd}"><svg class="i"><use href="#i-copy"/></svg>Copy</button></div>`:""}</li>`; }).join("");
|
||
return inspHead(short, ` ${clsChip(d)}`) + `<div class="body">
|
||
<h3>${esc(d.q)}</h3>${auth}
|
||
<dl class="kv"><dt>Raised by</dt><dd>${d.role} (${esc(d.seat)}) · ${d.run}</dd><dt>At</dt><dd class="tnum">${d.at} · ${d.age} ago</dd>${d.task?`<dt>Task</dt><dd><a href="#" data-opentask="${d.task}" class="mono">${d.task}</a></dd>`:""}<dt>Routed to</dt><dd>${d.route==="you"?(d.blocking?"you · CLI inbox and Discord DM":"you · 08:00 CT digest"):d.route+" (arbiter)"}</dd><dt>State</dt><dd>${d.state}${d.by?` · ${esc(d.by)}`:""}</dd></dl>
|
||
<div><span class="label">Options</span><ol class="opts" style="margin-top:6px">${opts}</ol></div>
|
||
${d.state==="open"&&d.route==="you"?`<p class="note">Resolve in a terminal you started yourself, outside any agent run. The web has no resolve button by design (REQ-DEC-3). <code>decide</code> shows the decision again and asks <code>[y/N]</code>.</p>`:""}
|
||
${d.route!=="you"&&d.state==="open"?`<p class="note">This one is for the ${d.route} role to settle. You can read it; you are told only if it escalates to gated.</p>`:""}
|
||
<div><span class="label">History</span><ol class="hist" style="margin-top:6px">${d.hist.map(([t,x])=>`<li><time>${t}</time><span>${esc(x)}</span></li>`).join("")}</ol></div>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap"><button class="btn" data-trailof="decision ${short}"><svg class="i"><use href="#i-trail"/></svg>Open trail</button>${d.state==="open"&&d.route==="you"&&!d.seen?`<button class="btn" data-markseen="${d.id}">Mark seen</button> <span class="st proposed">proposed</span>`:""}</div>
|
||
</div>`;
|
||
}
|
||
function taskInsp(t){
|
||
const fields = [["title","pm"],["description","pm"],["bucket (state)","assigned role"],["priority","pm (cross-role: arbiter)"],["assignee","pm"],["due date","pm"],["requirement","pm, at creation"]];
|
||
const verbs = ["task.create","task.assign","task.reassign","task.schedule","task.update.assigned","task.close","task.priority.change","task.scope.change"];
|
||
const why = {"changed in Vikunja":"A person edited this in Vikunja after the stack last wrote it. The edit arrived as task.changed.external.","not created by the stack":"This task was made in Vikunja directly. The stack reads it but cites no requirement for it.","conflict":"The stack's last write and a Vikunja edit disagree on an owned field. The broker refuses further writes (task-conflict) until the owner resolves it.","missing: moved":"The last poll couldn't find it in project 32. It may have moved projects; the hourly reconcile will look again."};
|
||
return inspHead(t.ref.replace("vikunja:",""), ` <span class="chip">${t.bucket}</span>`) + `<div class="body">
|
||
<h3>${esc(t.title)}</h3>
|
||
${t.markers.map(([c,m])=>`<div class="banner ${c==="gated"?"err":"ref"}"><div class="grow"><strong>${m}.</strong> ${why[m]||""}</div></div>`).join("")}
|
||
<dl class="kv"><dt>Ref</dt><dd class="mono">${t.ref}</dd><dt>Requirement</dt><dd class="mono">${t.req}</dd><dt>Priority</dt><dd class="tnum">${t.pri} of 5</dd><dt>Assignee</dt><dd>${t.who}</dd><dt>Updated</dt><dd class="tnum">${t.upd}</dd></dl>
|
||
<div><span class="label">Who writes each field</span><div class="tbl" style="margin-top:6px"><table><tbody>${fields.map(([f,w])=>`<tr><td>${f}</td><td>${w}</td></tr>`).join("")}</tbody></table></div><p class="note" style="margin-top:4px">From the slice 1 field table (REQ-TASK-1). The broker's verbs enforce it, not the tokens.</p></div>
|
||
<div><span class="label">Agent verbs</span><p class="mono" style="font-size:12px;margin-top:4px;line-height:1.7">${verbs.join(" · ")}</p></div>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap"><button class="btn" data-trailof="${t.ref}"><svg class="i"><use href="#i-trail"/></svg>Open trail</button><span class="note">Open in Vikunja: tasks.mosaicstack.dev/tasks/${t.ref.split("/")[1]}</span></div></div>`;
|
||
}
|
||
function roleInsp(r){
|
||
return inspHead(r.role, ` ${stDot(r.state)}`) + `<div class="body"><h3>${r.role} · ${esc(r.holder)}</h3>
|
||
${r.attn?`<div class="banner ref"><div class="grow"><strong>${esc(r.attn)}</strong></div></div>`:""}
|
||
<dl class="kv"><dt>Harness</dt><dd class="mono">${r.harness}</dd><dt>Run</dt><dd class="mono">${r.run||"—"}</dd><dt>Held for</dt><dd>${r.since||"—"}</dd><dt>Lock</dt><dd>${r.holder==="—"?"free":"one holder; freed on release or run end"}</dd></dl>
|
||
${r.state==="error"?`<p class="note">A stuck holder is released only by a gated <code>role.revoke</code>, which cites a resolved decision (REQ-ROLE-2). It is in your inbox as c41d77e0.</p>`:""}
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap">${r.run?`<button class="btn" data-go="talk">Open conversation</button>`:""}<button class="btn" data-go="business">Role contract</button></div></div>`;
|
||
}
|
||
$("#insp").addEventListener("click", e => {
|
||
if (e.target.closest(".x")) return closeInsp();
|
||
const c = e.target.closest("[data-copy]"); if (c) return copy(c.dataset.copy, c);
|
||
const ot = e.target.closest("[data-opentask]"); if (ot) { e.preventDefault(); S.taskFilter="all"; go("tasks",{noFocus:true}); return openInsp("task", ot.dataset.opentask); }
|
||
const tr = e.target.closest("[data-trailof]"); if (tr) { S.trailSubject = tr.dataset.trailof.startsWith("decision") ? "decision 7f3a91c2" : "vikunja:32/48"; S.trailFilter="All"; return go("trail"); }
|
||
const ms = e.target.closest("[data-markseen]"); if (ms) { const d = DEC.find(x=>x.id===ms.dataset.markseen); d.seen = true; d.hist.push(["now","seen by you (web, simulated)"]); renderView(); openInsp("dec", d.id); return toast("Marked seen (simulated). This does not resolve it."); }
|
||
const g = e.target.closest("[data-go]"); if (g) { e.preventDefault(); go(g.dataset.go); }
|
||
});
|
||
|
||
function copy(text, btn){
|
||
const done = () => toast("Copied: " + text);
|
||
const fallback = () => { const code = btn && btn.previousElementSibling; if (code) { const r = document.createRange(); r.selectNodeContents(code); const s = getSelection(); s.removeAllRanges(); s.addRange(r); } toast("Selected. Press Ctrl+C to copy."); };
|
||
try { navigator.clipboard.writeText(text).then(done, fallback); } catch(e) { fallback(); }
|
||
}
|
||
let toastT;
|
||
function toast(msg){ const t = $("#toast"); t.textContent = msg; t.hidden = false; clearTimeout(toastT); toastT = setTimeout(() => { t.hidden = true; }, 2600); }
|
||
|
||
/* ---- view events ---- */
|
||
$("#view").addEventListener("click", e => {
|
||
const g = e.target.closest("[data-go]"); if (g) { e.preventDefault(); return go(g.dataset.go); }
|
||
const it = e.target.closest("[data-itab]"); if (it) { S.inboxTab = it.dataset.itab; closeInsp(); return renderView(); }
|
||
const tf = e.target.closest("[data-tf]"); if (tf) { S.taskFilter = tf.dataset.tf; closeInsp(); return renderView(); }
|
||
const trf = e.target.closest("[data-trf]"); if (trf) { S.trailFilter = trf.dataset.trf; return renderView(); }
|
||
const sn = e.target.closest("[data-seen]"); if (sn) { e.stopPropagation(); S.seenSeats[sn.dataset.seen] = !S.seenSeats[sn.dataset.seen]; renderView(); return toast(S.seenSeats[sn.dataset.seen] ? "Seen (simulated). The seat still waits for an answer." : "Unseen (simulated)"); }
|
||
const sim = e.target.closest("[data-sim]"); if (sim) return toast(sim.dataset.sim);
|
||
const pal = e.target.closest("[data-pal]"); if (pal) { setTheme({palette:pal.dataset.pal}); return renderView(); }
|
||
const sm = e.target.closest("[data-setmode]"); if (sm) { setTheme({mode:sm.dataset.setmode}); return renderView(); }
|
||
if (e.target.id === "takeCtl") { S.controller = true; S.talkStatus = "controller · you took control (simulated)"; renderView(); const ti = $("#talkIn"); if (ti) ti.focus(); return; }
|
||
if (e.target.id === "wizNext") { S.wizStep = Math.min(4, S.wizStep+1); return renderView(); }
|
||
if (e.target.id === "wizBack") { S.wizStep = Math.max(0, S.wizStep-1); return renderView(); }
|
||
const row = e.target.closest("tr.row"); if (row) activateRow(row);
|
||
});
|
||
function activateRow(row){
|
||
if (row.dataset.dec) openInsp("dec", row.dataset.dec);
|
||
else if (row.dataset.task) openInsp("task", row.dataset.task);
|
||
else if (row.dataset.role) openInsp("role", row.dataset.role);
|
||
else if (row.dataset.seat) toast(row.dataset.seat + ": conversation view is per seat (simulated)");
|
||
}
|
||
$("#view").addEventListener("change", e => { if (e.target.id === "trailSub") { S.trailSubject = e.target.value; renderView(); } });
|
||
$("#view").addEventListener("submit", e => {
|
||
if (e.target.id !== "composer") return;
|
||
e.preventDefault();
|
||
const ta = $("#talkIn"); const text = ta.value;
|
||
if (!S.controller) { S.talkStatus = "not admitted: controller"; }
|
||
else if (!text.trim()) { S.talkStatus = "nothing to send"; }
|
||
else if (text.trimStart().startsWith("/")) { S.talkStatus = "not admitted: text-policy (a leading / is never sent)"; }
|
||
else { S.talkLog.push({k:"me",who:"you",t:"now",txt:text,r:"admitted (simulated)"}); S.talkStatus = "admitted"; ta.value = ""; }
|
||
const keep = ta.value; renderView(); const n = $("#talkIn"); if (n) { n.value = keep; n.focus(); } const m = $("#msgs"); if (m) m.scrollTop = m.scrollHeight;
|
||
});
|
||
$("#view").addEventListener("keydown", e => {
|
||
if (e.target.id === "talkIn" && e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); $("#composer").requestSubmit(); return; }
|
||
const row = e.target.closest && e.target.closest("tr.row");
|
||
if (!row) return;
|
||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); activateRow(row); }
|
||
if (e.key === "ArrowDown" || e.key === "j") { e.preventDefault(); let n = row.nextElementSibling; if (n && n.classList.contains("row")) n.focus(); }
|
||
if (e.key === "ArrowUp" || e.key === "k") { e.preventDefault(); let p = row.previousElementSibling; if (p && p.classList.contains("row")) p.focus(); }
|
||
});
|
||
$("#stateSel").addEventListener("change", e => { S.previewState = e.target.value; $("#fresh").innerHTML = S.previewState==="error" ? `Vikunja last read <b style="color:var(--warning)">14:20:57</b> · bus unreachable` : `Vikunja last read <b>14:21:07</b> · bus live`; closeInsp(); renderView(); });
|
||
|
||
/* ---- command palette ---- */
|
||
function pkItems(){
|
||
const items = SECTIONS.filter(s=>s[0]!=="-").map(s => ({label:"Go to " + s[1], group:"View", run:() => go(s[0])}));
|
||
DEC.filter(d=>d.state==="open").forEach(d => items.push({label:`${d.id.slice(0,8)} ${d.action} on ${d.target}`, group:"Decision", run:() => { go("inbox",{noFocus:true}); if (d.route!=="you") { S.inboxTab="roles"; renderView(); } openInsp("dec", d.id); }}));
|
||
openFor().forEach(d => d.opts.forEach(([k]) => items.push({label:`Copy: mosaic decide ${d.id.slice(0,8)} ${k}`, group:"Command", run:() => copy(`mosaic decide ${d.id.slice(0,8)} ${k}`)})));
|
||
TASKS.forEach(t => items.push({label:`${t.ref} ${t.title}`, group:"Task", run:() => { S.taskFilter="all"; go("tasks",{noFocus:true}); openInsp("task", t.ref); }}));
|
||
PALETTES.forEach(p => items.push({label:`Palette: ${p.name}`, group:"Appearance", run:() => { setTheme({palette:p.id}); renderView(); }}));
|
||
["system","light","dim","dark"].forEach(m => items.push({label:`Mode: ${m}`, group:"Appearance", run:() => { setTheme({mode:m}); renderView(); }}));
|
||
items.push({label:"Go to /trust-me (unknown route)", group:"View", run:() => go("trust-me")});
|
||
return items;
|
||
}
|
||
let pk = {items:[], shown:[], i:0, ret:null};
|
||
function openPk(){ pk.ret = document.activeElement; pk.items = pkItems(); $("#pkOverlay").hidden = false; const inp = $("#pkInput"); inp.value = ""; filterPk(); inp.focus(); }
|
||
function closePk(){ $("#pkOverlay").hidden = true; if (pk.ret && pk.ret.focus) pk.ret.focus(); }
|
||
function filterPk(){
|
||
const q = $("#pkInput").value.toLowerCase().trim();
|
||
pk.shown = pk.items.filter(x => !q || (x.label+" "+x.group).toLowerCase().includes(q)).slice(0, 40); pk.i = 0; drawPk();
|
||
}
|
||
function drawPk(){
|
||
$("#pkList").innerHTML = pk.shown.length ? pk.shown.map((x,j) => `<li role="option" id="pk-${j}" aria-selected="${j===pk.i}" data-j="${j}"><span>${esc(x.label)}</span><span class="g">${x.group}</span></li>`).join("") : `<li aria-disabled="true">No match. Try a task ref like 32/48 or a decision id.</li>`;
|
||
$("#pkInput").setAttribute("aria-activedescendant", pk.shown.length ? "pk-"+pk.i : "");
|
||
const s = $(`#pk-${pk.i}`); if (s) s.scrollIntoView({block:"nearest"});
|
||
}
|
||
$("#pkInput").addEventListener("input", filterPk);
|
||
$("#pkInput").addEventListener("keydown", e => {
|
||
if (e.key === "ArrowDown") { pk.i = Math.min(pk.shown.length-1, pk.i+1); drawPk(); e.preventDefault(); }
|
||
else if (e.key === "ArrowUp") { pk.i = Math.max(0, pk.i-1); drawPk(); e.preventDefault(); }
|
||
else if (e.key === "Enter") { const x = pk.shown[pk.i]; e.preventDefault(); if (x) { pk.ret = null; $("#pkOverlay").hidden = true; x.run(); } }
|
||
else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); closePk(); }
|
||
else if (e.key === "Tab") { e.preventDefault(); }
|
||
});
|
||
$("#pkList").addEventListener("click", e => { const li = e.target.closest("[data-j]"); if (li) { const x = pk.shown[+li.dataset.j]; pk.ret = null; $("#pkOverlay").hidden = true; x.run(); } });
|
||
$("#pkOverlay").addEventListener("click", e => { if (e.target.id === "pkOverlay") closePk(); });
|
||
$("#cmdkBtn").addEventListener("click", openPk);
|
||
document.addEventListener("keydown", e => {
|
||
const consoleOn = !$("#console").hidden;
|
||
if (consoleOn && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { e.preventDefault(); openPk(); return; }
|
||
if (consoleOn && e.key === "Escape" && $("#pkOverlay").hidden && S.sel) { e.preventDefault(); closeInsp(); }
|
||
});
|
||
|
||
/* ================= TERMINAL ================= */
|
||
const W = 120, H = 36;
|
||
const vis = (s) => s.replace(/\{[a-z]\|/g,"").replace(/\}/g,"");
|
||
function mk(s){ return esc(s).replace(/\{([a-z])\|([^}]*)\}/g, (_,c,t) => `<span class="${c}">${t}</span>`); }
|
||
function pad(s, w){ const n = vis(s).length; return n >= w ? s : s + " ".repeat(w - n); }
|
||
function lr(l, r, w){ const n = w - vis(l).length - vis(r).length; return l + " ".repeat(Math.max(1,n)) + r; }
|
||
function cols(cells, wd, sel){
|
||
const line = " " + cells.map((c,i) => i === cells.length-1 ? c : pad(c, wd[i])).join("");
|
||
return sel ? " {r|" + pad(vis(line).slice(1), W - 2) + "} " : line;
|
||
}
|
||
function wrapAt(lines, w){
|
||
const out = [];
|
||
lines.forEach(l => { const m = /^\{([a-z])\|([^}]*)\}$/.exec(l); const t = m ? m[2] : null;
|
||
if (t !== null && t.length > w) { for (let i = 0; i < t.length; i += w) out.push("{" + m[1] + "|" + t.slice(i, i + w) + "}"); } else out.push(l); });
|
||
return out;
|
||
}
|
||
function rule(w){ return "{m|" + "─".repeat(w) + "}"; }
|
||
function boxed(lines, w, title){
|
||
const inner = w - 4; const out = [];
|
||
out.push("{a|┌─ " + title + " " + "─".repeat(Math.max(0, w - 5 - title.length)) + "┐}");
|
||
lines.forEach(l => out.push("{a|│} " + pad(l, inner) + " {a|│}"));
|
||
out.push("{a|└" + "─".repeat(w - 2) + "┘}");
|
||
return out;
|
||
}
|
||
function center(block, line, width){ const w = vis(block[0]).length; const left = Math.max(0, Math.floor(((width||W) - w)/2)); return " ".repeat(left); }
|
||
function navLine(cur, w){
|
||
const tabs = [["c","Talk"],["i","Inbox 3"],["t","Tasks"],["a","Agents"],["r","Trail"]];
|
||
const l = " {h| mosaic } {b|mosaic-stack} " + tabs.map(([k,n]) => n.startsWith(cur) ? `{r| ${k} ${n} }` : ` {a|${k}} ${n} `).join("") ;
|
||
const r = w >= 120 ? "{m|Vikunja read 12s ago · Opus 2/4 · Sonnet 1/4 } " : "{m|12s} ";
|
||
return lr(l, r, w);
|
||
}
|
||
function frame(cur, bodyLines, foot, w, h){
|
||
w = w || W; h = h || H;
|
||
const top = [navLine(cur, w), rule(w)];
|
||
const bottom = [rule(w)].concat(foot);
|
||
const room = h - top.length - bottom.length;
|
||
const b = bodyLines.slice(0, room); while (b.length < room) b.push("");
|
||
return top.concat(b, bottom).map(l => pad(l, w));
|
||
}
|
||
const HINT_NAV = " {b|Tab} composer {b|c i t a r} views {b|↑↓} move {b|Enter} open {b|v} trail {b|:} command {b|?} help {b|q} quit";
|
||
const SCREENS = {
|
||
talk(){
|
||
const body = [
|
||
" {s|working} {m||} controller {m||} unknown events: 0 {m|pm · Sage · pi · opus · run-1914-pm}",
|
||
"",
|
||
" {a|you} {m|14:02} Ship the trail filter fix today if review passes. {m|admitted}",
|
||
" {c|pm} {m|14:02} Understood. 32/48 is assigned to a coder session and review goes to the reviewer role.",
|
||
" I'll ask you before anything is pushed.",
|
||
"",
|
||
" {m|· review.verdict pass on vikunja:32/48 (reviewer)}",
|
||
"",
|
||
" {c|pm} {m|14:19} Review passed with no findings. The push is gated, so it's in your inbox as",
|
||
" {b|7f3a91c2}. I recommend 1, push now. {m|→ i to open the inbox}",
|
||
"",
|
||
" {m|· decision 7f3a91c2 raised · gated, blocking}",
|
||
"",
|
||
" {a|you} {m|14:26} Also draft the release note but don't post it. {m|admitted}",
|
||
" {c|pm} {m|14:26} Drafting it on vikunja:32/50. Posting would be message.external, which is gated,",
|
||
" so it will come to you.",
|
||
"",
|
||
" {m|· control characters show as} {w|^[} {m|and bidi marks as} {w|<U+202E>}{m|; nothing is hidden}",
|
||
];
|
||
return frame("Talk", body, [
|
||
" {m|status:} admitted · last receipt 14:26:03",
|
||
" {a|>} Push it after you've checked the CI log{u| }",
|
||
" {b|Enter} send {b|Ctrl-J} newline {b|Ctrl-T} take control {b|Ctrl-G} interrupt {b|Ctrl-O} reconnect {b|PgUp/PgDn} scroll {b|Tab} nav"
|
||
]);
|
||
},
|
||
observer(){
|
||
const s = SCREENS.talk();
|
||
s[2] = pad(" {w|working} {m||} {w|observer} {m||} unknown events: 2 {m|pm · Sage · pi · opus · run-1914-pm}", W);
|
||
s[H-3] = pad(" {d|not admitted: controller} {m|· you are watching; Ctrl-T asks for control}", W);
|
||
s[H-2] = pad(" {a|>} Push it now{u| }", W);
|
||
return s;
|
||
},
|
||
inbox(){
|
||
const body = [
|
||
" {b|Inbox} {r| For you 3 } Routed to roles 2 Recent 3 {m|blocking first, then oldest}",
|
||
"",
|
||
" {m| ID CLASS FLAGS ACTION TARGET RAISED BY REACHES YOU AGE }",
|
||
" {r|› 7f3a91c2 gated }{x| BLOCKING }{r| git.push.protected origin/refactor pm (Sage) now 14m }",
|
||
" c41d77e0 gated {x| BLOCKING } role.revoke role:coder pm (Sage) now 41m {b|new}",
|
||
" 2b8e04d1 gated digest message.external discord:#releases pm (Sage) 08:00 CT 1h53m {b|new}",
|
||
"",
|
||
rule(118).replace("{m|","{m| "),
|
||
" decision {b|7f3a91c2-5d0e-4b8a-9c11-2e6f0a4d7b13}",
|
||
" gated, blocking, raised 2026-10-09T19:19:04Z by pm (run-1914-pm)",
|
||
" question: Push the six reviewed row 41 commits to origin/refactor?",
|
||
" options: {b|1} = Push now; {b|2} = Hold until you have read the diff",
|
||
" recommended: {s|1}",
|
||
" action: {a|git.push.protected} on {a|origin/refactor}",
|
||
" choosing \"1\" authorizes it; any other choice declines",
|
||
" task: vikunja:32/48",
|
||
"",
|
||
" {m|history} 19:19 raised · 19:19 delivered: CLI inbox, Discord DM · 19:22 seen by you",
|
||
"",
|
||
" {m|Press} {b|1}{m|–}{b|2} {m|to choose. You will see the decision again and confirm with} {b|y}{m|.}"
|
||
];
|
||
return frame("Inbox", body, [" {m|3 for you · 2 blocking · next digest 08:00 CT}", " {m|> (Tab to type to the PM)}", HINT_NAV]);
|
||
},
|
||
decide(){
|
||
const s = SCREENS.inbox();
|
||
const box = boxed([
|
||
"decision 7f3a91c2-5d0e-4b8a-9c11-2e6f0a4d7b13",
|
||
" gated, blocking, raised 2026-10-09T19:19:04Z by pm (run-1914-pm)",
|
||
" question: Push the six reviewed row 41 commits to origin/refactor?",
|
||
" options: 1 = Push now; 2 = Hold until you have read the diff",
|
||
" recommended: 1",
|
||
" action: {a|git.push.protected} on {a|origin/refactor}",
|
||
" choosing \"1\" authorizes it; any other choice declines",
|
||
" task: vikunja:32/48",
|
||
"",
|
||
"{b|your choice: 1 (Push now); this authorizes the action}",
|
||
"",
|
||
"resolve 7f3a91c2 with 1? [y/N] {u| }",
|
||
"",
|
||
"{m|y resolves · n, Esc or Enter cancels (the default is No)}"
|
||
], 84, "decide");
|
||
const off = " ".repeat(18);
|
||
box.forEach((l, j) => { s[8 + j] = pad(off + l, W); });
|
||
return s;
|
||
},
|
||
unknown(){
|
||
const s = SCREENS.inbox();
|
||
const box = boxed([
|
||
"{w|outcome unknown: the broker may have recorded it. Check mosaic inbox or}",
|
||
"{w|mosaic trail 7f3a91c2-5d0e-4b8a-9c11-2e6f0a4d7b13 before trying again}",
|
||
"",
|
||
"The connection closed before the bus answered. Nothing was retried,",
|
||
"and the choice was not sent twice. {m|(exit status 1 in the plain CLI)}",
|
||
"",
|
||
"{b|v} open the trail {b|r} re-read the inbox {b|Esc} back"
|
||
], 84, "outcome-unknown");
|
||
const off = " ".repeat(18);
|
||
box.forEach((l, j) => { s[11 + j] = pad(off + l, W); });
|
||
return s;
|
||
},
|
||
tasks(){
|
||
const wd = [2,8,14,5,10,13,42,25];
|
||
const rows = [
|
||
["32/48","in-progress","4","coder","REQ-CLI-1","mosaic talk: the PM conversation is the…",""],
|
||
["32/51","in-review","3","reviewer","REQ-WEB-1","WebUI trail filter chips","{w|changed in Vikunja}"],
|
||
["32/50","in-review","2","pm","REQ-CLI-1","Release note for 0.0.12",""],
|
||
["32/52","{d|blocked}","4","coder","REQ-ROLE-2","Free the role claim when a run ends","{d|conflict}"],
|
||
["32/47","todo","3","-","REQ-DEC-4","Daily digest at 08:00 Central",""],
|
||
["32/53","todo","2","-","REQ-CRED-1","Warn 14 days before a role token expires","not created by the stack"],
|
||
["32/39","{w|?}","-","-","REQ-TASK-2","Hourly reconcile receipts","{w|missing: moved}"]
|
||
];
|
||
const body = [
|
||
lr(" {b|Tasks} project 32 · {s|Vikunja last read 14:21:07 (12s ago)}", "filter: {r| open } todo in-progress in-review blocked done ", W),
|
||
"",
|
||
"{m|" + vis(cols(["","REF","BUCKET","PRI","ASSIGNEE","REQUIREMENT","TITLE","MARKERS"], wd)) + "}",
|
||
...rows.map((r,i) => cols([i===0?"›":"", ...r], wd, i===0)),
|
||
"",
|
||
rule(118).replace("{m|","{m| "),
|
||
" {b|vikunja:32/48} mosaic talk: the PM conversation is the front door",
|
||
" bucket in-progress · priority 4 of 5 · assignee coder · cites REQ-CLI-1 · updated 19:14",
|
||
" last events: task.assigned 14:02 · session.launched 14:03 · action.allowed 14:11 · review.verdict pass 14:18",
|
||
" {m|read-only here; agents change tasks through the broker's eight verbs}"
|
||
];
|
||
return frame("Tasks", body, [" {m|7 open · 1 blocked · 1 missing · polls every 30s, full reconcile hourly}", " {m|> (Tab to type to the PM)}", HINT_NAV]);
|
||
},
|
||
agents(){
|
||
const wd = [2,11,15,18,13,10,50];
|
||
const rows = [
|
||
["pm","Sage","pi · opus","● working","3h12m",""],
|
||
["cto","Darkwing","pi · opus","{w|◐ waiting}","1h05m","{w|Input needed:} pick the trail chip split (91aa30f5)"],
|
||
["coder","session 3c9e","claude · sonnet","{d|▲ error}","25m","no output for 25 min · revoke is c41d77e0 in Inbox"],
|
||
["reviewer","-","-","{m|– idle}","",""]
|
||
];
|
||
const body = [
|
||
" {b|Agents} launching: {s|allowed} Opus {b|2} of 4 {a|██}{m|░░} Sonnet {b|1} of 4 {a|█}{m|░░░}",
|
||
"",
|
||
"{m|" + vis(cols(["","ROLE","HOLDER","HARNESS","STATE","HELD FOR","ATTENTION"], wd)) + "}",
|
||
...rows.map((r,i) => cols([i===0?"›":"", ...r], wd, i===0)),
|
||
"",
|
||
" {b|Role credentials} {m|metadata only; values and paths are never shown}",
|
||
" gitea mosaic-stack-pm-bot pm no expiry; rotate by 2026-11-01",
|
||
" gitea mosaic-stack-cto-bot cto no expiry; rotate by 2026-11-01",
|
||
" vikunja bot-mosaic-stack-pm pm expires 2026-11-02",
|
||
" vikunja bot-mosaic-stack-sync sync, read-only {w|expires 2026-10-21 · 12 days}",
|
||
" vikunja bot-mosaic-stack-coder coder {d|not configured}",
|
||
"",
|
||
" {m|Stop every launch with} {b|mosaic stop} {m|(planned). Restoring launches is logged as launch.restored.}"
|
||
];
|
||
return frame("Agents", body, [" {m|3 roles held · 1 free · 0 revoked}", " {m|> (Tab to type to the PM)}", HINT_NAV]);
|
||
},
|
||
trail(){
|
||
const body = [
|
||
" {b|Trail} vikunja:32/48 {r| All } Requests Decisions Launches Task changes Review",
|
||
"",
|
||
" {m|14:02:11} human.input you → pm “Ship the trail filter fix today if review passes.”",
|
||
" {m|14:02:40} task.created pm vikunja:32/48 · REQ-CLI-1",
|
||
" {m|14:02:41} task.assigned pm vikunja:32/48 → coder",
|
||
" {m|14:03:02} session.launched pm coder · claude · sonnet (Sonnet 1 of 4)",
|
||
" {m|14:11:55} action.allowed coder task.update.assigned · bucket → in-review",
|
||
" {m|14:12:00} review.requested coder → reviewer vikunja:32/48",
|
||
" {m|14:18:47} review.verdict reviewer {s|pass}, 0 findings",
|
||
" {m|14:19:04} {d|action.refused} pm git.push.protected · {d|human-required}",
|
||
" {m|14:19:04} {c|decision} pm → you 7f3a91c2 raised · gated, blocking · CLI inbox + Discord DM",
|
||
" {m|14:22:30} {c|decision} you 7f3a91c2 seen",
|
||
" {m|14:23:41} human.input you decide 7f3a91c2 1 → {w|outcome-unknown}; nothing retried",
|
||
" {m|14:24:02} task.changed.external sync vikunja:32/51 title edited in Vikunja",
|
||
"",
|
||
" {m|Append-only. Every step in slice 1 writes one event (REQ-EVT-1). Filters: f cycles, F resets.}"
|
||
];
|
||
return frame("Trail", body, [" {m|12 events · oldest first}", " {m|> (Tab to type to the PM)}", HINT_NAV]);
|
||
},
|
||
help(){
|
||
const s = SCREENS.talk();
|
||
const box = boxed([
|
||
"{b|Views} {b|Inbox} {b|Composer (Tab)}",
|
||
"c Talk 1-9 choose option, then y Enter send",
|
||
"i Inbox v trail of the decision Ctrl-J newline (Alt-Enter too)",
|
||
"t Tasks s mark seen Ctrl-T take control",
|
||
"a Agents / filter Ctrl-G interrupt the PM",
|
||
"r Trail Ctrl-O reconnect",
|
||
" {b|Command line (:)} PgUp/Dn scroll",
|
||
"{b|Anywhere} :decide <id> <option> A leading / is never sent",
|
||
"↑↓ / j k move :trail <task|decision>",
|
||
"Enter open :business <id>",
|
||
"? this help :q quit",
|
||
"q quit",
|
||
"",
|
||
"{m|Decisions resolve only here, from a shell you started yourself. Inside an}",
|
||
"{m|agent run, mosaic refuses with exit 3. Esc closes this help.}"
|
||
], 100, "help");
|
||
const off = " ".repeat(10);
|
||
box.forEach((l, j) => { s[5 + j] = pad(off + l, W); });
|
||
return s;
|
||
},
|
||
compact(){
|
||
const w = 80, h = 24;
|
||
const body = [
|
||
" {b|Inbox} {r| For you 3 } Roles 2 Recent 3",
|
||
"",
|
||
" {r|› 7f3a91c2 }{x| BLOCKING }{r| git.push.protected origin/refactor 14m }",
|
||
" c41d77e0 {x| BLOCKING } role.revoke role:coder 41m",
|
||
" 2b8e04d1 digest message.external discord:#releases 1h53m",
|
||
"",
|
||
rule(78).replace("{m|","{m| "),
|
||
" {b|7f3a91c2} gated, blocking · pm (run-1914-pm)",
|
||
" Push the six reviewed row 41 commits to origin/refactor?",
|
||
" {b|1} Push now {s|(recommended)}",
|
||
" {b|2} Hold until you have read the diff",
|
||
" choosing \"1\" authorizes {a|git.push.protected} on {a|origin/refactor}",
|
||
"",
|
||
" {m|Below 100 columns the nav shows keys only; tables drop the}",
|
||
" {m|RAISED BY and REACHES YOU columns, and detail moves below.}"
|
||
];
|
||
return frame("Inbox", body, [" {m|> (Tab to type)}", " {b|c i t a r} views {b|1-9} choose {b|?} help {b|q} quit"], w, h);
|
||
},
|
||
nocolor(){ return SCREENS.inbox(); },
|
||
agentenv(){
|
||
const lines = [
|
||
"{m|# inside a Claude Code run (CLAUDECODE and CLAUDE_CODE_ENTRYPOINT are set)}",
|
||
"{s|$} mosaic decide 7f3a91c2 1",
|
||
"{d|refused: this runs only from a human shell, outside any agent run (found CLAUDECODE, CLAUDE_CODE_ENTRYPOINT in the environment)}",
|
||
"{s|$} echo $?",
|
||
"3",
|
||
"{s|$} mosaic",
|
||
"{d|refused: this runs only from a human shell, outside any agent run (found CLAUDECODE, CLAUDE_CODE_ENTRYPOINT in the environment)}",
|
||
"{s|$} echo $?",
|
||
"3",
|
||
"",
|
||
"{m|# the full-screen app makes the same check before drawing anything. Agents reach decisions only}",
|
||
"{m|# through the broker, which stamps role and run from the launch record (REQ-DEC-3).}",
|
||
"",
|
||
"{m|# exit codes: 0 ok · 1 failed or outcome unknown · 2 invalid · 3 refused or config problem · 4 usage}"
|
||
];
|
||
const out = wrapAt(lines, W); while (out.length < 16) out.push("");
|
||
return out.map(l => pad(l, W));
|
||
},
|
||
quit(){
|
||
const out = ["{s|$} mosaic","{m|(app closed with q; 0 decisions resolved this session)}","{s|$} mosaic inbox","inbox: 3 open decision(s)","","7f3a91c2 git.push.protected (gated, blocking) raised 2026-10-09T19:19:04Z by pm"," Push the six reviewed row 41 commits to origin/refactor?"," options: 1 = Push now; 2 = Hold until you have read the diff (recommended: 1)"," action: git.push.protected on origin/refactor"," choosing \"1\" authorizes it; any other choice declines"," task: vikunja:32/48"," decide: mosaic decide 7f3a91c2 <option>","","{m|… two more. The verbs stay as they are for scripts and pipes; --json gives the same data.}","{s|$} {u| }"];
|
||
while (out.length < 16) out.push("");
|
||
return out.map(l => pad(l, W));
|
||
}
|
||
};
|
||
const SCREEN_LIST = [["talk","Talk (controller)","c"],["observer","Talk (observer)",""],["inbox","Inbox","i"],["decide","Decide: confirm","1"],["unknown","Outcome unknown","y"],["tasks","Tasks","t"],["agents","Agents","a"],["trail","Trail","r"],["help","Help","?"],["compact","80×24 compact",""],["nocolor","NO_COLOR",""],["agentenv","Refused in an agent run",""],["quit","Quit to shell","q"]];
|
||
let curScreen = "talk";
|
||
function drawTerm(){
|
||
const lines = SCREENS[curScreen]();
|
||
const t = $("#term");
|
||
t.innerHTML = lines.map(mk).join("\n");
|
||
t.classList.toggle("nocolor", curScreen === "nocolor");
|
||
const cols = curScreen === "compact" ? "80×24" : (curScreen === "agentenv" || curScreen === "quit") ? "shell" : "120×36";
|
||
$("#termTitle").textContent = (curScreen === "nocolor" ? "NO_COLOR=1 mosaic — " : "mosaic — ") + cols;
|
||
$("#screenList").innerHTML = SCREEN_LIST.map(([k,n,key]) => `<button data-scr="${k}" aria-pressed="${k===curScreen}"><span>${n}</span>${key?`<kbd>${esc(key)}</kbd>`:""}</button>`).join("");
|
||
}
|
||
$("#screenList").addEventListener("click", e => { const b = e.target.closest("[data-scr]"); if (b) { curScreen = b.dataset.scr; drawTerm(); } });
|
||
$("#term").addEventListener("keydown", e => {
|
||
const k = e.key; let next = null;
|
||
if (curScreen === "decide") { if (k === "y") next = "unknown"; else if (k === "n" || k === "Escape" || k === "Enter") next = "inbox"; }
|
||
else if (curScreen === "unknown" || curScreen === "help") { if (k === "Escape") next = "inbox"; else if (k === "v") next = "trail"; else if (k === "r") next = "inbox"; }
|
||
else if (curScreen === "quit" || curScreen === "agentenv") { if (k === "Enter") next = "talk"; }
|
||
if (!next) {
|
||
const map = {c:"talk", i:"inbox", t:"tasks", a:"agents", r:"trail", "?":"help", q:"quit", v:"trail", Tab:curScreen==="talk"?"inbox":"talk"};
|
||
if (curScreen === "inbox" && (k === "1" || k === "2")) next = "decide";
|
||
else if (map[k] && !e.ctrlKey && !e.metaKey && !e.altKey) next = map[k];
|
||
}
|
||
if (next) { e.preventDefault(); curScreen = next; drawTerm(); }
|
||
});
|
||
const ANSI = [["--t-action","blue (4)","action, selection, links, your messages"],["--t-accent","yellow (3)","the PM, decisions"],["--t-success","green (2)","working, pass, fresh"],["--t-warning","bright yellow (11)","waiting, stale, expiring, outcome unknown"],["--t-danger","red (1)","blocking, refused, error, conflict"],["--t-muted","bright black (8)","timestamps, hints, rules"],["--t-sel","reverse video","selected row"]];
|
||
$("#ansi").innerHTML = ANSI.map(([v,n,u]) => `<div><i style="background:var(${v})"></i><code>${n}</code><span class="note">${u}</span></div>`).join("") + `<p class="note">The app uses the terminal's own 16 colors, so it follows the user's terminal theme. The swatches here show the current palette's Dark mode as one possible theme.</p>`;
|
||
|
||
/* ================= COVERAGE ================= */
|
||
const COV = [
|
||
["Talk to the PM","REQ-CLI-1, REQ-CLI-2","Talk","Talk (c)","progress","Conversation terminal implemented; the stack-owned PM session is row 41"],
|
||
["Controller and observer, take control, interrupt, reconnect","CHAT-03","Talk","Talk","implemented",""],
|
||
["Prompt receipts: admitted or refused:<code>","CHAT-03","Talk","Talk status line","implemented",""],
|
||
["Leading / never sent (text-policy)","CHAT-01","Talk composer","composer","implemented",""],
|
||
["Visible control and bidi characters","packages/conversation","Talk","Talk","implemented",""],
|
||
["Decision inbox","REQ-DEC-1, REQ-CLI-1","Inbox","Inbox (i)","implemented","CLI verb implemented; web inbox route implemented"],
|
||
["For you / Routed to roles / Recent","REQ-DEC-2","Inbox tabs","Inbox tabs","proposed",""],
|
||
["Authorization sentence","S2b","Inbox inspector","Inbox detail, decide","implemented","CLI wording; web matches it"],
|
||
["Resolve a decision","REQ-DEC-3","Copy command only","1-9 then y","implemented","CLI decide implemented; full-screen key path proposed"],
|
||
["Outcome unknown, no retry","bus","toast + trail","outcome-unknown box","implemented",""],
|
||
["Refused inside an agent run (exit 3)","REQ-DEC-3","n/a","shell","implemented",""],
|
||
["Blocking decisions: Discord DM","REQ-DEC-4, #1509","Settings › Notifications","Inbox footer","implemented",""],
|
||
["Daily digest 08:00 Central","REQ-DEC-4","Board card, Settings","Inbox footer","planned",""],
|
||
["Mark decision seen","bus decision ops","Inspector button","s in Inbox","proposed","Seen is not resolution"],
|
||
["Tasks list, buckets, priority, requirement","REQ-TASK-1","Tasks","Tasks (t)","implemented",""],
|
||
["Task markers (changed, not created, conflict, missing)","REQ-TASK-1, -2","Tasks + inspector","Tasks","implemented",""],
|
||
["Freshness: Vikunja last read","REQ-TASK-2","command bar, Tasks","Tasks header","implemented",""],
|
||
["Field writers per task","REQ-TASK-1","Task inspector","Tasks detail","proposed","From the slice 1 field table"],
|
||
["Agents: role holders and state","REQ-ROLE-2, REQ-CLI-1","Agents","Agents (a)","implemented",""],
|
||
["Launch budget: Opus n of 4, Sonnet n of 4","REQ-LAUNCH-1","Agents, Board","Agents","progress","row 41"],
|
||
["Revoke launches in one word","REQ-LAUNCH-1","Agents card","mosaic stop","planned",""],
|
||
["Revoke a stuck holder (gated)","REQ-ROLE-2","Role inspector → Inbox","Agents → Inbox","implemented","role.revoke is always gated"],
|
||
["Credential metadata and expiry","REQ-CRED-1","Agents, Settings","Agents","proposed","Names and dates only"],
|
||
["Trail of a task or decision","REQ-EVT-1, REQ-CLI-1","Trail","Trail (r)","implemented",""],
|
||
["Trail filter chips","slice 1 S5","Trail","Trail","progress","row 40"],
|
||
["Control board: seat states, Input needed, Seen/Unsee","control-board","Board (home)","—","implemented","Port 7331 today; joins the console"],
|
||
["Command palette Ctrl+K","D-002","everywhere",": command line","proposed",""],
|
||
["Business file: roles, arbiters, limits","REQ-ROLE-3, -4","Business","—","planned","Read-only view"],
|
||
["Authority map (within / cross / gated)","REQ-ROLE-1","Business","—","proposed",""],
|
||
["Variables in four layers","REQ-VAR-1","Business","—","proposed",""],
|
||
["Skills in launch bundles","REQ-HARN-1","Library","—","proposed",""],
|
||
["Queue rows","#1508","Queue","—","proposed","Rows move only through queue move"],
|
||
["Runs and releases","release.sh","Runs and releases","—","proposed",""],
|
||
["Ledger counts and queue check","packages/ledger","Ledger","—","proposed",""],
|
||
["Palettes and modes","DECISIONS.md","Settings, header","terminal theme","proposed","Default still Jason's call"],
|
||
["First-run setup","PRD Users","Set up","—","proposed","Installer is out of v1 scope"],
|
||
["Loading, empty, error-with-stale-data, refusal states","fail closed","every view","every screen","proposed",""],
|
||
["Not found","—","Not found view","unknown : command","proposed",""],
|
||
["Loopback only, no sign-in","PRD v1 scope","Settings › About","—","implemented",""]
|
||
];
|
||
const COVSTAT = {implemented:"implemented", progress:"in progress", planned:"planned", proposed:"proposed"};
|
||
let covF = "all";
|
||
function drawCov(){
|
||
const cnt = k => COV.filter(r => r[4]===k).length;
|
||
$("#covChips").innerHTML = [["all","All "+COV.length]].concat(Object.keys(COVSTAT).map(k => [k, COVSTAT[k]+" "+cnt(k)])).map(([k,n]) => `<button data-cf="${k}" aria-pressed="${covF===k}">${n}</button>`).join("");
|
||
$("#covBody").innerHTML = COV.filter(r => covF==="all" || r[4]===covF).map(r => `<tr><td>${esc(r[0])}${r[5]?`<div class="note" style="font-weight:400">${esc(r[5])}</div>`:""}</td><td class="src">${esc(r[1])}</td><td>${esc(r[2])}</td><td>${esc(r[3])}</td><td><span class="st ${r[4]}">${COVSTAT[r[4]]}</span></td></tr>`).join("");
|
||
}
|
||
$("#covChips").addEventListener("click", e => { const b = e.target.closest("[data-cf]"); if (b) { covF = b.dataset.cf; drawCov(); } });
|
||
|
||
/* ---------------- boot ---------------- */
|
||
applyTheme();
|
||
renderSide();
|
||
renderView();
|
||
drawCov();
|
||
drawTerm();
|
||
const h = (location.hash || "").slice(1);
|
||
showTab(TABS.includes(h) ? h : "brand");
|
||
window.addEventListener("hashchange", () => { const x = location.hash.slice(1); if (TABS.includes(x)) showTab(x); });
|
||
})();
|
||
</script>
|