makepad-ports/koboyo.html
andodeki 7ddd75fb70 Makepad 2.0 ports: rider, koboyo, insurance (+ HTML sources, assessments)
Three single-file HTML apps ported to Makepad 2.0 (dev b41e740) script_mod! DSL:
- rider (ride.html): 21 unit + 9 integration + 9 headless UI tests
- koboyo (koboyo.html): 29 + 14 + 8
- insurance (insurance.html): 28 + 14 + 8
All 140 tests green. Includes headless-Linux platform patch, full README,
setup script, and three brutal codebase assessments with execution plans.
2026-08-04 05:07:18 +00:00

1620 lines
114 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<title>Koboyo · Infinite Canvas</title>
<style>
/* ============================================================
THEME TOKENS — extracted from koboyo.com root.css
============================================================ */
:root{
--chrome-surface:#ffffffe6;
--chrome-popup:#ffffff;
--chrome-ring:#0000000f;
--chrome-divider:#0000000d;
--chrome-hover:#f5f5f5;
--chrome-active:#d4d4d4;
--chrome-rest:#fafafa;
--chrome-fg-900:#171717;
--chrome-fg-800:#262626;
--chrome-fg-700:#404040;
--chrome-fg-600:#525252;
--chrome-fg-500:#737373;
--chrome-fg-400:#a3a3a3;
--chrome-accent:#171717;
--chrome-accent-hover:#404040;
--chrome-danger:#dc2626;
--canvas-bg:#ffffff;
--canvas-paper-tint:#2866c403;
--canvas-dot:#1f293721;
--canvas-ink:#1e1e1e;
--canvas-ink-muted:#495057;
--canvas-selection:#4da1ff;
--sticky:#ffe08a;
color-scheme:light;
}
html.dark{
--chrome-surface:#26262aeb;
--chrome-popup:#2a2a2e;
--chrome-ring:#ffffff17;
--chrome-divider:#ffffff14;
--chrome-hover:#ffffff14;
--chrome-active:#ffffff29;
--chrome-rest:#ffffff0d;
--chrome-fg-900:#f5f5f7;
--chrome-fg-800:#e9e9ee;
--chrome-fg-700:#d6d6dc;
--chrome-fg-600:#b6b6bf;
--chrome-fg-500:#9a9aa5;
--chrome-fg-400:#7d7d88;
--chrome-accent:#3f3f46;
--chrome-accent-hover:#52525b;
--chrome-danger:#f87171;
--canvas-bg:#1c1c1f;
--canvas-paper-tint:#ffffff04;
--canvas-dot:#e4e4e71c;
--canvas-ink:#e6e6e6;
--canvas-ink-muted:#a6a7ae;
--canvas-selection:#4dabf7;
--sticky:#6b5426;
color-scheme:dark;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","SF Pro Display",Inter,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
background:var(--canvas-bg);
background-image:linear-gradient(var(--canvas-paper-tint),var(--canvas-paper-tint)),
radial-gradient(circle,var(--canvas-dot) .8px,transparent 1px);
background-size:auto,24px 24px;
color:var(--chrome-fg-900);
overflow:hidden;
-webkit-font-smoothing:antialiased;
transition:background-color .2s;
}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer;-webkit-tap-highlight-color:transparent}
::selection{background:rgba(77,161,255,.3)}
/* ============================================================
SHARED CHROME PIECES
============================================================ */
.chip{
position:fixed;z-index:40;
background:var(--chrome-surface);
backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
border-radius:12px;
box-shadow:0 0 0 1px var(--chrome-ring),0 2px 8px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
padding:2px;
transition:opacity .22s,transform .22s;
}
.icon-btn{
position:relative;display:flex;align-items:center;justify-content:center;
width:32px;height:32px;border-radius:8px;color:var(--chrome-fg-600);
transition:background-color .15s,color .15s,scale .1s;
}
.icon-btn:hover{background:var(--chrome-hover);color:var(--chrome-fg-900)}
.icon-btn:active{scale:.95}
.icon-btn.on{background:var(--chrome-active);color:var(--chrome-fg-900)}
.icon-btn:disabled{opacity:.38;pointer-events:none}
.view-only .tool-btn:not(.always-on){opacity:.38;pointer-events:none}
.v-div{height:1px;margin:4px 6px;background:var(--chrome-divider)}
.h-div{width:1px;align-self:stretch;margin:6px 3px;background:var(--chrome-divider)}
/* popup panel shared */
.popup{
position:fixed;z-index:60;min-width:190px;padding:4px;
background:var(--chrome-popup);border-radius:12px;
box-shadow:0 0 0 1px var(--chrome-ring),0 8px 28px rgba(0,0,0,.12),0 2px 8px rgba(0,0,0,.06);
animation:pin .13s ease;
max-height:calc(100dvh - 84px);overflow-y:auto;scrollbar-width:thin;
}
@keyframes pin{from{opacity:0;transform:translateY(-3px) scale(.98)}to{opacity:1;transform:none}}
.mi{
display:flex;align-items:center;gap:10px;width:100%;padding:7px 10px;border-radius:8px;
font-size:13px;font-weight:500;color:var(--chrome-fg-900);text-align:left;
transition:background-color .12s;
}
.mi:hover{background:var(--chrome-hover)}
.mi:disabled{opacity:.4;pointer-events:none}
.mi .mi-ic{flex:0 0 16px;display:grid;place-items:center;color:var(--chrome-fg-400)}
.mi .mi-sub{margin-left:auto;color:var(--chrome-fg-400);font-size:11.5px;display:flex;align-items:center;gap:6px}
.mi-sep{height:1px;margin:4px 6px;background:var(--chrome-divider)}
.mi-head{padding:7px 10px 5px;font-size:11px;font-weight:700;letter-spacing:.4px;color:var(--chrome-fg-400)}
/* switch */
.sw{position:relative;width:30px;height:17px;border-radius:999px;background:var(--chrome-fg-300);transition:background .18s;flex:0 0 30px}
.sw::after{content:'';position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%;background:#fff;transition:transform .18s;box-shadow:0 1px 2px rgba(0,0,0,.25)}
.sw.on{background:#4da1ff}
.sw.on::after{transform:translateX(13px)}
html.dark .sw{background:#5e5e66}
/* tooltip */
.tip{
position:fixed;z-index:100;pointer-events:none;display:flex;align-items:center;gap:7px;
background:var(--chrome-popup);color:var(--chrome-fg-900);font-size:11.5px;font-weight:600;
padding:5px 9px;border-radius:7px;box-shadow:0 0 0 1px var(--chrome-ring),0 3px 10px rgba(0,0,0,.14);
white-space:nowrap;opacity:0;transition:opacity .1s;
}
.tip kbd{
font:inherit;font-size:10px;color:var(--chrome-fg-500);background:var(--chrome-hover);
border-radius:4px;padding:1.5px 5px;box-shadow:inset 0 0 0 1px var(--chrome-ring);
}
/* ============================================================
TOP-LEFT: HAMBURGER + MAIN MENU
============================================================ */
.hamburger{left:16px;top:16px}
.hamburger .icon-btn{width:36px;height:28px}
.main-menu{top:56px;left:16px;min-width:200px}
.main-menu .user-head{padding:6px 10px 7px;line-height:1.35}
.main-menu .user-head b{display:block;font-size:13px;color:var(--chrome-fg-800);font-weight:600}
.main-menu .user-head span{font-size:12px;color:var(--chrome-fg-500)}
.submenu-anchor{position:relative}
.sub-fly{
position:absolute;top:-5px;left:min(calc(100% + 6px), calc(100vw - 200px));min-width:170px;padding:4px;
background:var(--chrome-popup);border-radius:12px;
box-shadow:0 0 0 1px var(--chrome-ring),0 8px 28px rgba(0,0,0,.12);
animation:pin .12s ease;
}
/* ============================================================
DESKTOP TOOL RAIL (left, vertical)
============================================================ */
.tool-rail{
left:16px;top:56px;display:flex;flex-direction:column;gap:2px;padding:4px;
max-height:calc(100dvh - 72px);overflow-y:auto;overflow-x:visible;scrollbar-width:none;
}
.tool-rail::-webkit-scrollbar{display:none}
.tool-pop{min-width:0;padding:6px}
.tool-grid{display:grid;gap:4px}
.tool-grid.cols3{grid-template-columns:repeat(3,1fr)}
.tool-grid.cols4{grid-template-columns:repeat(4,1fr)}
.tool-grid.cols5{grid-template-columns:repeat(5,1fr)}
/* ============================================================
TOP-CENTER TITLE CHIP
============================================================ */
.title-chip{
left:50%;top:16px;transform:translateX(-50%);
display:flex;align-items:center;gap:2px;padding:3px;
}
.title-chip .login{
height:26px;padding:0 12px;border-radius:8px;white-space:nowrap;
background:var(--chrome-accent);color:#fff;font-size:12.5px;font-weight:650;
transition:background .15s;
}
html.dark .title-chip .login{color:var(--chrome-fg-900)}
.title-chip .login:hover{background:var(--chrome-accent-hover)}
.title-chip .doc-name{
max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
padding:0 9px;font-size:13px;font-weight:550;color:var(--chrome-fg-900);outline:none;border-radius:6px;
}
.title-chip .doc-name:focus{background:var(--chrome-hover)}
/* ============================================================
TOP-RIGHT RAIL (desktop) + 3-DOTS (phone)
============================================================ */
.right-rail{right:16px;top:16px;display:flex;flex-direction:column;gap:2px;padding:4px}
.dots-btn{right:16px;top:16px;width:48px;height:44px;display:none;align-items:center;justify-content:center}
.dots-menu{top:68px;right:16px;min-width:196px}
/* ============================================================
BOTTOM-LEFT ZOOM + UNDO
============================================================ */
.bl-cluster{left:16px;bottom:16px;display:flex;gap:8px}
.zoom-chip,.hist-chip{display:flex;align-items:center;gap:2px;padding:4px}
.zoom-pct{
width:61px;height:32px;border-radius:8px;font-size:12.5px;font-weight:650;color:var(--chrome-fg-800);
display:flex;align-items:center;justify-content:center;transition:background .15s;font-variant-numeric:tabular-nums;
}
.zoom-pct:hover{background:var(--chrome-hover)}
/* ============================================================
BOTTOM-RIGHT HELP
============================================================ */
.help-fab{right:16px;bottom:16px;width:36px;height:36px;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700;color:var(--chrome-fg-500)}
.help-fab:hover{color:var(--chrome-fg-900)}
/* ============================================================
PHONE (<640px) — real site splits at 639px
============================================================ */
.phone-bar{
display:none;left:4px;right:4px;bottom:max(8px,env(safe-area-inset-bottom));
flex-direction:row;align-items:center;padding:2px;overflow-x:auto;scrollbar-width:none;
}
.phone-bar::-webkit-scrollbar{display:none}
.pgroup{display:flex;gap:2px;flex:0 0 auto}
@media(max-width:639px){
.desktop-only{display:none !important}
.phone-bar{display:flex}
.phone-bar .icon-btn{width:44px;height:44px;flex:0 0 44px}
.dots-btn{display:flex;width:48px;height:44px}
.hamburger{width:48px;height:44px;display:flex;align-items:center;justify-content:center;padding:0}
.hamburger .icon-btn{width:44px;height:40px}
.main-menu{top:68px;left:16px;max-width:calc(100vw - 32px)}
.dots-menu{left:auto;right:16px;max-width:calc(100vw - 32px)}
}
/* real site uses coarse:h-11 coarse:w-11 — 44px hit targets on every touch device */
@media(pointer:coarse){
.icon-btn{width:44px;height:44px}
.dots-btn .icon-btn{width:44px;height:40px}
}
/* ============================================================
CANVAS WORLD
============================================================ */
#stage{position:fixed;inset:0;overflow:hidden;touch-action:none}
#stage.tool-hand{cursor:grab}
#stage.panning{cursor:grabbing}
#world{position:absolute;left:0;top:0;transform-origin:0 0}
.el{position:absolute;user-select:none}
.el.sel::after{
content:'';position:absolute;inset:-6px;border:1.6px dashed var(--canvas-selection);border-radius:6px;pointer-events:none;
}
.el .et{width:100%;height:100%;overflow:hidden;outline:none}
.el-text .et{padding:2px 4px;font-size:28px;color:var(--canvas-ink);line-height:1.3;white-space:pre-wrap;word-break:break-word}
.el-sticky{background:var(--sticky);border-radius:4px;box-shadow:2px 4px 14px rgba(0,0,0,.14)}
.el-sticky .et{padding:10px 12px;font-size:15px;color:#3f2d00}
html.dark .el-sticky .et{color:#ffe9b8}
.el-badge{border-radius:999px;background:#fff2cc;border:2px solid #f5b93f;display:grid;place-items:center;font-weight:800;font-size:20px;color:#8a5a00}
html.dark .el-badge{background:#3d2f10;color:#ffd88a}
.el-img{border:2px dashed var(--chrome-fg-300);border-radius:10px;display:grid;place-items:center;background:var(--chrome-rest);font-size:34px}
.el-glyph{display:grid;place-items:center;font-size:44px}
.el-ent{background:var(--chrome-popup);border:2px solid var(--canvas-ink);border-radius:6px;font-size:13px;color:var(--canvas-ink)}
.el-ent .eh{border-bottom:2px solid var(--canvas-ink);padding:5px 9px;font-weight:800;text-align:center}
.el-ent .er{padding:4px 9px;font-size:12px;color:var(--canvas-ink-muted)}
.el-life .head{height:26%;background:var(--chrome-popup);border:2px solid var(--canvas-ink);border-radius:6px;display:grid;place-items:center;font-size:12px;font-weight:700;color:var(--canvas-ink)}
.el-life .line{width:0;height:72%;margin:0 auto;border-left:2px dashed var(--canvas-ink-muted)}
.el-frag{border:2px solid var(--canvas-ink);border-radius:6px;background:transparent}
.el-frag .tab{position:absolute;top:-2px;left:-2px;padding:4px 10px;background:var(--chrome-popup);border:2px solid var(--canvas-ink);border-radius:6px 0 4px 0;font-size:11px;font-weight:800;color:var(--canvas-ink)}
/* ============================================================
ONBOARDING HINTS (empty canvas)
============================================================ */
.coach{position:fixed;z-index:20;pointer-events:none;font-size:13.5px;font-weight:600;color:var(--chrome-fg-500);white-space:nowrap;transition:opacity .3s}
.coach svg{position:absolute;overflow:visible}
.coach svg path{fill:none;stroke:var(--chrome-fg-400);stroke-width:1.6;stroke-linecap:round}
.coach svg .ah{fill:var(--chrome-fg-400);stroke:none}
.center-hint{
position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:20;pointer-events:none;text-align:center;
transition:opacity .3s;
}
.center-hint .l1{font-size:17px;font-weight:650;color:var(--chrome-fg-600)}
.center-hint .l2{font-size:14px;color:var(--chrome-fg-400);margin-top:5px}
.hint-keys{display:flex;gap:14px;justify-content:center;margin-top:22px;flex-wrap:wrap}
.hint-keys span{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--chrome-fg-500)}
.hint-keys kbd{
font:inherit;font-size:11px;font-weight:750;color:var(--chrome-fg-700);
background:var(--chrome-popup);border-radius:6px;padding:3px 7px;min-width:24px;text-align:center;
box-shadow:0 0 0 1px var(--chrome-ring),0 1px 2px rgba(0,0,0,.05);
}
.hidden-hints .coach,.hidden-hints .center-hint{opacity:0}
@media(max-width:639px){.coach,.hint-keys{display:none}}
/* ============================================================
RIGHT SIDE PANELS (Slides / Diagram as code / AI)
============================================================ */
.side-panel{
position:fixed;top:64px;right:16px;bottom:16px;width:min(300px,86vw);z-index:35;
background:var(--chrome-surface);backdrop-filter:blur(10px);border-radius:14px;
box-shadow:0 0 0 1px var(--chrome-ring),0 12px 40px rgba(0,0,0,.12);
display:flex;flex-direction:column;overflow:hidden;
animation:slideIn .22s cubic-bezier(.22,1,.36,1);
}
@keyframes slideIn{from{opacity:0;transform:translateX(14px)}to{opacity:1;transform:none}}
.sp-head{display:flex;align-items:center;gap:8px;padding:12px 14px;border-bottom:1px solid var(--chrome-divider);font-size:13.5px;font-weight:700}
.sp-head .x{margin-left:auto;width:26px;height:26px;border-radius:7px;display:grid;place-items:center;color:var(--chrome-fg-500)}
.sp-head .x:hover{background:var(--chrome-hover);color:var(--chrome-fg-900)}
.sp-body{flex:1;overflow-y:auto;padding:14px;font-size:12.5px;color:var(--chrome-fg-600);line-height:1.6}
.sp-body .fake{height:9px;border-radius:5px;background:var(--chrome-rest);margin-bottom:9px}
.sp-btn{display:block;width:100%;margin-top:10px;padding:9px 0;border-radius:9px;background:var(--chrome-accent);color:#fff;font-size:13px;font-weight:650}
html.dark .sp-btn{color:var(--chrome-fg-900)}
.code-view{font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;font-size:11.5px;background:var(--chrome-rest);border-radius:9px;padding:12px;white-space:pre;color:var(--chrome-fg-700)}
/* ============================================================
ALL-TOOLS DRAWER — anchored to the toolbar (right on desktop,
above the bottom bar on phone). Spec from koboyo tool-drawer.
============================================================ */
.drawer{
position:fixed;z-index:71;width:256px;
background:var(--chrome-popup);border-radius:12px;
box-shadow:0 0 0 1px var(--chrome-ring),0 12px 38px rgba(0,0,0,.16),0 2px 8px rgba(0,0,0,.06);
display:flex;flex-direction:column;overflow:hidden;
animation:pin .13s ease;
}
.cmd-head{display:flex;align-items:center;gap:8px;padding:0 12px;border-bottom:1px solid var(--chrome-ring);color:var(--chrome-fg-400);flex:0 0 auto}
.cmd-head input{flex:1;background:none;border:0;outline:0;font:inherit;font-size:13.5px;color:var(--chrome-fg-900);padding:9px 0;min-width:0}
.cmd-head input::placeholder{color:var(--chrome-fg-400)}
.cmd-list{overflow-y:auto;padding:4px;min-height:0;scrollbar-width:thin}
.cmd-group{padding:7px 8px 2px;font-size:10.5px;font-weight:650;letter-spacing:.5px;text-transform:uppercase;color:var(--chrome-fg-400)}
.cmd-item{
display:flex;align-items:center;gap:8px;width:100%;padding:4px 8px;border-radius:8px;
text-align:left;font-size:13px;color:var(--chrome-fg-800);transition:background .1s;
}
.cmd-item:hover{background:var(--chrome-rest)}
.cmd-item.active{background:var(--chrome-hover)}
.cmd-item .ci-ic{width:20px;height:20px;display:grid;place-items:center;color:var(--chrome-fg-500);flex:0 0 20px}
.cmd-item.active .ci-ic{color:var(--chrome-fg-700)}
.cmd-item .ci-lbl{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cmd-item .ci-hint{margin-left:auto;font-size:11px;color:var(--chrome-fg-500);background:var(--chrome-divider);border-radius:6px;padding:2px 6px;font-family:inherit}
.cmd-empty{padding:24px 12px;text-align:center;font-size:12.5px;color:var(--chrome-fg-400)}
/* ============================================================
SHORTCUTS DIALOG
============================================================ */
.sc-veil{position:fixed;inset:0;z-index:70;background:rgba(0,0,0,.24);animation:pin .14s}
.sc-dialog{
position:fixed;z-index:71;left:50%;top:50%;transform:translate(-50%,-50%);
width:min(720px,calc(100vw - 28px));max-height:min(600px,86dvh);overflow-y:auto;
background:var(--chrome-popup);border-radius:16px;padding:22px 24px;
box-shadow:0 0 0 1px var(--chrome-ring),0 28px 80px rgba(0,0,0,.26);
animation:pin .15s;
}
.sc-dialog h2{font-size:16px;font-weight:750;margin-bottom:16px}
.sc-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:18px}
.sc-group h3{font-size:11px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;color:var(--chrome-fg-400);margin-bottom:8px}
.sc-row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:3.5px 0;font-size:12.5px;color:var(--chrome-fg-700)}
.sc-row kbd{
font:inherit;font-size:10.5px;font-weight:700;color:var(--chrome-fg-600);
background:var(--chrome-hover);border-radius:5px;padding:2px 6px;
box-shadow:inset 0 0 0 1px var(--chrome-ring);white-space:nowrap;
}
/* zen exit */
.zen-exit{
position:fixed;left:50%;bottom:20px;transform:translateX(-50%);z-index:45;
display:none;align-items:center;gap:8px;padding:9px 16px;border-radius:999px;font-size:13px;font-weight:650;
background:var(--chrome-surface);backdrop-filter:blur(10px);color:var(--chrome-fg-800);
box-shadow:0 0 0 1px var(--chrome-ring),0 8px 24px rgba(0,0,0,.14);
}
.zen .zen-exit{display:flex}
.zen .chrome-ui{opacity:0;pointer-events:none;transform:translateY(6px)}
.zen .tool-rail{transform:translateX(-10px)}
.zen .right-rail,.zen .dots-btn{transform:translateX(10px)}
.zen .title-chip{transform:translateX(-50%) translateY(-10px)}
/* toast */
.toast{
position:fixed;left:50%;bottom:64px;transform:translateX(-50%) translateY(10px);z-index:120;
background:var(--chrome-popup);color:var(--chrome-fg-900);font-size:13px;font-weight:600;
padding:10px 18px;border-radius:999px;opacity:0;pointer-events:none;
box-shadow:0 0 0 1px var(--chrome-ring),0 10px 30px rgba(0,0,0,.16);
transition:opacity .25s,transform .25s;white-space:nowrap;
}
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
@media(max-width:639px){.toast{bottom:84px}}
</style>
</head>
<body>
<!-- ==================== CANVAS ==================== -->
<div id="stage">
<div id="world"></div>
</div>
<!-- ==================== ONBOARDING ==================== -->
<div class="center-hint chrome-ui" id="centerHint">
<div class="l1">Double-click anywhere to add text</div>
<div class="l2">or pick a tool</div>
<div class="hint-keys">
<span><kbd>R</kbd> rectangle</span>
<span><kbd>O</kbd> ellipse</span>
<span><kbd>A</kbd> arrow</span>
<span><kbd>P</kbd> draw</span>
<span><kbd>/</kbd> all tools</span>
</div>
</div>
<div class="coach chrome-ui" style="left:120px;top:120px" id="coach1">
Pick a tool from here
<svg width="70" height="46" style="left:-66px;top:4px"><path d="M62 12 C 34 6, 16 14, 8 38"/><path class="ah" d="M5 31 L 8 41 L 14 33 Z"/></svg>
</div>
<div class="coach chrome-ui" style="right:74px;top:60px" id="coach2">
Slides &amp; Diagrams as Code
<svg width="56" height="34" style="right:-48px;top:-2px"><path d="M6 26 C 24 26, 40 20, 48 8"/><path class="ah" d="M44 6 L 52 4 L 49 12 Z"/></svg>
</div>
<div class="coach chrome-ui" style="left:130px;bottom:88px" id="coach3">
Zoom &amp; reset view
<svg width="56" height="34" style="left:-52px;bottom:-4px"><path d="M50 6 C 26 8, 12 14, 6 26"/><path class="ah" d="M4 20 L 6 30 L 12 23 Z"/></svg>
</div>
<div class="coach chrome-ui" style="right:96px;bottom:88px" id="coach4">
Keyboard shortcuts
<svg width="50" height="32" style="right:-44px;bottom:-2px"><path d="M4 6 C 16 10, 28 14, 40 24"/><path class="ah" d="M36 17 L 44 26 L 34 26 Z"/></svg>
</div>
<!-- ==================== TOP-LEFT: HAMBURGER + MAIN MENU ==================== -->
<div class="chip hamburger chrome-ui" id="hamChip">
<button class="icon-btn" id="hamBtn" aria-label="Menu" data-tip="">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
</div>
<div class="popup main-menu" id="mainMenu" hidden>
<div class="user-head"><b>Guest session</b><span>Saved on this device</span></div>
<div class="mi-sep"></div>
<button class="mi" data-act="new"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg></span>New canvas…<span class="mi-sub">⌘⇧N</span></button>
<button class="mi" data-act="canvases"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="7" rx="1.5"/><rect x="14" y="4" width="7" height="7" rx="1.5"/><rect x="3" y="15" width="7" height="6" rx="1.5"/><rect x="14" y="15" width="7" height="6" rx="1.5"/></svg></span>My canvases</button>
<div class="mi-sep"></div>
<button class="mi" data-act="open"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V6a2 2 0 0 1 2-2h4l2 3h6a2 2 0 0 1 2 2v2"/><path d="M4 19h14l3-8H7l-3 8Z"/></svg></span>Open…</button>
<button class="mi" data-act="import"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v11M8.5 11.5 12 15l3.5-3.5M5 19.5h14"/></svg></span>Import…</button>
<button class="mi" data-act="save"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4h11l3 3v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Z"/><path d="M8 4v4h7V4M8 20v-6h8v6"/></svg></span>Save<span class="mi-sub">⌘S</span></button>
<div class="mi-sep"></div>
<div class="submenu-anchor">
<button class="mi" data-sub="export"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V4M8.5 7.5 12 4l3.5 3.5M5 15v3a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-3"/></svg></span>Export<span class="mi-sub"></span></button>
<div class="sub-fly" id="subExport" hidden>
<button class="mi" data-act="expng"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2.5"/><circle cx="9" cy="10" r="1.6"/><path d="m5.5 17 4.5-4 3 3 2.5-2 3 3"/></svg></span>PNG image</button>
<button class="mi" data-act="exsvg"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5Z"/><path d="M14 3v5h5M9 13.5 6.5 16 9 18.5M15 13.5l2.5 2.5-2.5 2.5"/></svg></span>SVG image</button>
</div>
</div>
<div class="submenu-anchor">
<button class="mi" data-sub="view"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12s3.5-6 9-6 9 6 9 6-3.5 6-9 6-9-6-9-6Z"/><circle cx="12" cy="12" r="2.6"/></svg></span>View<span class="mi-sub"></span></button>
<div class="sub-fly" id="subView" hidden>
<button class="mi" data-toggle="dark"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20.5 14.5A8.5 8.5 0 0 1 9.5 3.5a8.5 8.5 0 1 0 11 11Z"/></svg></span>Dark mode<span class="mi-sub"><span class="sw" id="swDark"></span></span></button>
<button class="mi" data-toggle="zen"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2.5"/><path d="M9 9h6M9 12.5h6"/></svg></span>Zen mode<span class="mi-sub"><span class="sw" id="swZen"></span></span></button>
<button class="mi" data-toggle="viewonly"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="10.5" width="14" height="9" rx="2.5"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/></svg></span>View only<span class="mi-sub"><span class="sw" id="swView"></span></span></button>
</div>
</div>
<div class="mi-sep"></div>
<button class="mi" data-act="logout"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 8V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-2"/><path d="M9 12h12l-3-3M18 15l3-3"/></svg></span>Log out</button>
</div>
<!-- ==================== DESKTOP TOOL RAIL ==================== -->
<div class="chip tool-rail chrome-ui desktop-only" id="toolRail">
<button class="icon-btn tool-btn always-on" data-tool="select" data-tip="Select|V">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><path d="M5 3.5 18.5 12l-6.2 1.2L9 19.5 5 3.5Z"/></svg>
</button>
<button class="icon-btn tool-btn always-on" data-tool="hand" data-tip="Hand|H">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 11V5.5a1.5 1.5 0 0 1 3 0V11m0-6.5v-1a1.5 1.5 0 0 1 3 0V11m0-5.5a1.5 1.5 0 0 1 3 0v7m0-3a1.5 1.5 0 0 1 3 0v5c0 3.5-2.5 6.5-6.5 6.5-3 0-4.6-1.2-6.3-3.6L4.6 13c-.7-1-.2-2.2.8-2.5.7-.2 1.5 0 2 .6L8 12.6"/></svg>
</button>
<div class="v-div"></div>
<button class="icon-btn tool-btn" data-tool="rect" data-tip="Rectangle|R">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><rect x="4" y="6" width="16" height="12" rx="2"/></svg>
</button>
<button class="icon-btn tool-btn" data-tool="ellipse" data-tip="Ellipse|O">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><ellipse cx="12" cy="12" rx="8" ry="6"/></svg>
</button>
<button class="icon-btn tool-btn" id="moreShapesBtn" data-tip="More shapes" data-pop="popShapes">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><rect x="3.5" y="3.5" width="7.5" height="7.5" rx="1.5"/><circle cx="16.8" cy="7.2" r="3.8"/><path d="M7.2 13.2 4 19.5h6.4l-3.2-6.3ZM15.5 13.5h4.6l2.3 6h-9.2l2.3-6Z"/></svg>
</button>
<button class="icon-btn tool-btn" id="connectorBtn" data-tip="Connector|A" data-pop="popConnect">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19 17 7m0 0h-6m6 0v6"/></svg>
</button>
<div class="v-div"></div>
<button class="icon-btn tool-btn" data-tool="text" data-tip="Text|T">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><path d="M5 6.5V5h14v1.5M12 5v14m-3 0h6"/></svg>
</button>
<button class="icon-btn tool-btn" data-tool="sticky" data-tip="Sticky note|N">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><path d="M5 4h14v11l-5 5H5V4Z"/><path d="M19 15h-5v5"/></svg>
</button>
<button class="icon-btn tool-btn" data-tool="draw" data-tip="Draw|P">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m14.5 6 3.5 3.5L8.5 19H5v-3.5L14.5 6Z"/><path d="m13 7.5 3.5 3.5M5 19l2.5-2.5"/></svg>
</button>
<div class="v-div"></div>
<button class="icon-btn tool-btn" data-tool="image" data-tip="Image|I">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2.5"/><circle cx="9" cy="10" r="1.6"/><path d="m5.5 17 4.5-4 3 3 2.5-2 3 3"/></svg>
</button>
<button class="icon-btn tool-btn" data-tool="icons" data-tip="Icons|S">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3 2.1 4.9 5.3.5-4 3.6 1.2 5.2-4.6-2.7-4.6 2.7 1.2-5.2-4-3.6 5.3-.5L12 3Z"/></svg>
</button>
<button class="icon-btn tool-btn" data-tool="eraser" data-tip="Eraser|E">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m7 20-3.3-3.3a2 2 0 0 1 0-2.8L13 4.6a2 2 0 0 1 2.8 0l3.6 3.6a2 2 0 0 1 0 2.8L12 18.4M7 20h13M7 20l4-4"/></svg>
</button>
<div class="v-div"></div>
<button class="icon-btn tool-btn" id="lockBtn" data-tip="Keep tool active (lock)|⌘.">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><rect x="5.5" y="10.5" width="13" height="9" rx="2.5"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/></svg>
</button>
<div class="v-div"></div>
<button class="icon-btn always-on" id="allToolsBtn" data-tip="All tools|/">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="7" height="7" rx="1.8"/><rect x="13" y="4" width="7" height="7" rx="1.8"/><rect x="4" y="13" width="7" height="7" rx="1.8"/><rect x="13" y="13" width="7" height="7" rx="1.8"/></svg>
</button>
</div>
<!-- tool flyouts -->
<div class="popup tool-pop" id="popShapes" hidden>
<div class="tool-grid cols4">
<button class="icon-btn" data-shape="diamond" data-tip2="Diamond"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><path d="M12 4 20 12l-8 8-8-8 8-8Z"/></svg></button>
<button class="icon-btn" data-shape="triangle" data-tip2="Triangle"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><path d="M12 5 20.5 19h-17L12 5Z"/></svg></button>
<button class="icon-btn" data-shape="stepbadge" data-tip2="Step badge|B"><svg width="18" height="18" viewBox="0 0 24 24" fill="#f5b93f" stroke="#b57708" stroke-width="1.2"><circle cx="12" cy="12" r="8"/></svg></button>
<button class="icon-btn" data-shape="entity" data-tip2="Class / entity"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><rect x="5" y="4" width="14" height="16" rx="2"/><path d="M5 9.5h14M5 14.5h14" stroke-linecap="round"/></svg></button>
<button class="icon-btn" data-shape="lifeline" data-tip2="Lifeline"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><rect x="7" y="4" width="10" height="5" rx="1.5"/><path d="M12 11v9" stroke-dasharray="3 3"/></svg></button>
<button class="icon-btn" data-shape="fragment" data-tip2="Fragment"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><rect x="4" y="6" width="16" height="12" rx="1.5"/><path d="M4 6v5h7V4.5"/></svg></button>
</div>
</div>
<div class="popup tool-pop" id="popConnect" hidden>
<div class="tool-grid cols3">
<button class="icon-btn" data-conn="arrow" data-tip2="Arrow|A"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19 17 7m0 0h-6m6 0v6"/></svg></button>
<button class="icon-btn" data-conn="line" data-tip2="Line|L"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><path d="M5 19 19 5"/></svg></button>
<button class="icon-btn" data-conn="polyline" data-tip2="Multi-point line|C"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19 10 9l4 6 6-10"/></svg></button>
</div>
</div>
<!-- ==================== TOP-CENTER TITLE ==================== -->
<div class="chip title-chip chrome-ui desktop-only">
<button class="login" id="loginBtn">Log in to save</button>
<span class="doc-name" id="docName" contenteditable="true" spellcheck="false">Untitled canvas</span>
<button class="icon-btn" id="shareBtn" aria-label="Share">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 9H7a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-1.5"/><path d="M12 15V3M8.5 6.5 12 3l3.5 3.5"/></svg>
</button>
</div>
<!-- ==================== TOP-RIGHT RAIL (desktop) ==================== -->
<div class="chip right-rail chrome-ui desktop-only" id="rightRail">
<button class="icon-btn" id="aiBtn" data-tip="AI assistant" aria-label="AI assistant">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><path d="M14.6 4.2 15.7 8l3.8 1.1-3.8 1.1-1.1 3.8-1.1-3.8L9.7 9.1 13.5 8l1.1-3.8Z"/><path d="M18.7 14.5l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7.7-2ZM6.4 13.4l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8.8-2.2Z"/></svg>
</button>
<button class="icon-btn" id="slidesBtn" data-tip="Slides" aria-label="Slides">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="12" rx="2"/><path d="M12 17v3M8.5 21.5 12 20l3.5 1.5"/></svg>
</button>
<button class="icon-btn" id="codeBtn" data-tip="Diagram as code" aria-label="Diagram as code">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m8.5 8-4 4 4 4M15.5 8l4 4-4 4M13.2 5.5l-2.4 13"/></svg>
</button>
<button class="icon-btn" id="commentsBtn" data-tip="Show comments" aria-label="Show comments">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><path d="M12 4c-4.6 0-8.3 3-8.3 6.8 0 2.1 1.2 4 3.1 5.3l-.8 3 3.3-1.6c.9.2 1.8.4 2.7.4 4.6 0 8.3-3 8.3-6.9S16.6 4 12 4Z"/></svg>
</button>
</div>
<!-- ==================== TOP-RIGHT 3-DOTS (phone) ==================== -->
<div class="chip dots-btn chrome-ui" id="dotsChip">
<button class="icon-btn" id="dotsBtn" aria-label="More">
<svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="12" cy="19" r="1.7"/></svg>
</button>
</div>
<div class="popup dots-menu" id="dotsMenu" hidden>
<button class="mi" data-act="undo" id="ovUndo"><span class="mi-ic"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/></svg></span>Undo</button>
<button class="mi" data-act="redo" id="ovRedo"><span class="mi-ic"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m15 14 5-5-5-5"/><path d="M20 9H10a6 6 0 0 0 0 12h3"/></svg></span>Redo</button>
<div class="mi-sep"></div>
<button class="mi" data-act="fit"><span class="mi-ic"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3.5H5.5a2 2 0 0 0-2 2V8M16 3.5h2.5a2 2 0 0 1 2 2V8M8 20.5H5.5a2 2 0 0 1-2-2V16M16 20.5h2.5a2 2 0 0 0 2-2V16"/></svg></span>Zoom to fit</button>
<div class="mi-sep"></div>
<button class="mi" data-act="ai"><span class="mi-ic"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><path d="M14.6 4.2 15.7 8l3.8 1.1-3.8 1.1-1.1 3.8-1.1-3.8L9.7 9.1 13.5 8l1.1-3.8Z"/><path d="M18.7 14.5l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7.7-2Z"/></svg></span>AI assistant</button>
<button class="mi" data-act="slides"><span class="mi-ic"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="12" rx="2"/><path d="M12 17v3M8.5 21.5 12 20l3.5 1.5"/></svg></span>Slides</button>
<button class="mi" data-act="code"><span class="mi-ic"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m8.5 8-4 4 4 4M15.5 8l4 4-4 4M13.2 5.5l-2.4 13"/></svg></span>Diagram as code</button>
</div>
<!-- ==================== BOTTOM-LEFT ZOOM + HISTORY ==================== -->
<div class="bl-cluster chrome-ui desktop-only">
<div class="chip zoom-chip">
<button class="icon-btn" id="zoomOut" aria-label="Zoom out"><svg width="15" height="15" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"><path d="M6 12h12"/></svg></button>
<button class="zoom-pct" id="zoomPct" aria-label="Reset zoom">100%</button>
<button class="icon-btn" id="zoomIn" aria-label="Zoom in"><svg width="15" height="15" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"><path d="M12 6v12M6 12h12"/></svg></button>
<button class="icon-btn" id="zoomFit" aria-label="Zoom to fit"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3.5H5.5a2 2 0 0 0-2 2V8M16 3.5h2.5a2 2 0 0 1 2 2V8M8 20.5H5.5a2 2 0 0 1-2-2V16M16 20.5h2.5a2 2 0 0 0 2-2V16"/></svg></button>
</div>
<div class="chip hist-chip">
<button class="icon-btn" id="undoBtn" aria-label="Undo" disabled><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/></svg></button>
<button class="icon-btn" id="redoBtn" aria-label="Redo" disabled><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="m15 14 5-5-5-5"/><path d="M20 9H10a6 6 0 0 0 0 12h3"/></svg></button>
</div>
</div>
<!-- ==================== BOTTOM-RIGHT HELP ==================== -->
<div class="chip help-fab chrome-ui desktop-only" id="helpFab" aria-label="Keyboard shortcuts">?</div>
<!-- ==================== PHONE BOTTOM TOOLBAR ==================== -->
<div class="chip phone-bar chrome-ui" id="phoneBar">
<div class="pgroup">
<button class="icon-btn tool-btn always-on" data-tool="select" aria-label="Select"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><path d="M5 3.5 18.5 12l-6.2 1.2L9 19.5 5 3.5Z"/></svg></button>
<button class="icon-btn tool-btn always-on" data-tool="hand" aria-label="Hand"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 11V5.5a1.5 1.5 0 0 1 3 0V11m0-6.5v-1a1.5 1.5 0 0 1 3 0V11m0-5.5a1.5 1.5 0 0 1 3 0v7m0-3a1.5 1.5 0 0 1 3 0v5c0 3.5-2.5 6.5-6.5 6.5-3 0-4.6-1.2-6.3-3.6L4.6 13c-.7-1-.2-2.2.8-2.5.7-.2 1.5 0 2 .6L8 12.6"/></svg></button>
</div>
<div class="h-div"></div>
<div class="pgroup">
<button class="icon-btn tool-btn" data-tool="draw" aria-label="Draw"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m14.5 6 3.5 3.5L8.5 19H5v-3.5L14.5 6Z"/><path d="m13 7.5 3.5 3.5M5 19l2.5-2.5"/></svg></button>
<button class="icon-btn tool-btn" data-tool="eraser" aria-label="Eraser"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m7 20-3.3-3.3a2 2 0 0 1 0-2.8L13 4.6a2 2 0 0 1 2.8 0l3.6 3.6a2 2 0 0 1 0 2.8L12 18.4M7 20h13M7 20l4-4"/></svg></button>
</div>
<div class="h-div"></div>
<div class="pgroup">
<button class="icon-btn tool-btn" data-tool="rect" aria-label="Rectangle"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><rect x="4" y="6" width="16" height="12" rx="2"/></svg></button>
<button class="icon-btn tool-btn" id="moreShapesBtnM" aria-label="More shapes" data-pop="popShapesM"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><rect x="3.5" y="3.5" width="7.5" height="7.5" rx="1.5"/><circle cx="16.8" cy="7.2" r="3.8"/><path d="M7.2 13.2 4 19.5h6.4l-3.2-6.3ZM15.5 13.5h4.6l2.3 6h-9.2l2.3-6Z"/></svg></button>
<button class="icon-btn tool-btn" id="connectorBtnM" aria-label="Connector" data-pop="popConnectM"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19 17 7m0 0h-6m6 0v6"/></svg></button>
</div>
<div class="h-div"></div>
<div class="pgroup">
<button class="icon-btn always-on" id="allToolsBtnM" aria-label="All tools"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="7" height="7" rx="1.8"/><rect x="13" y="4" width="7" height="7" rx="1.8"/><rect x="4" y="13" width="7" height="7" rx="1.8"/><rect x="13" y="13" width="7" height="7" rx="1.8"/></svg></button>
</div>
</div>
<!-- phone flyouts (open upward) -->
<div class="popup tool-pop" id="popShapesM" hidden></div>
<div class="popup tool-pop" id="popConnectM" hidden></div>
<!-- ==================== SIDE PANELS ==================== -->
<div class="side-panel" id="panelSlides" hidden>
<div class="sp-head"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="12" rx="2"/><path d="M12 17v3M8.5 21.5 12 20l3.5 1.5"/></svg> Slides <button class="x" data-closepanel></button></div>
<div class="sp-body">
<p>Turn your canvas into a slide deck. Draw a frame (<b>F</b>) and it becomes a slide.</p>
<div class="fake" style="width:82%"></div><div class="fake" style="width:64%"></div><div class="fake" style="width:74%"></div>
<button class="sp-btn" data-toast="Presentation started ⌘⌥P">&nbsp;&nbsp;Present</button>
<button class="sp-btn" data-toast="Generating slides…" style="background:var(--chrome-rest);color:var(--chrome-fg-800)">&nbsp;&nbsp;Generate slides with AI</button>
</div>
</div>
<div class="side-panel" id="panelCode" hidden>
<div class="sp-head"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m8.5 8-4 4 4 4M15.5 8l4 4-4 4M13.2 5.5l-2.4 13"/></svg> Diagram as code <button class="x" data-closepanel></button></div>
<div class="sp-body">
<div class="code-view">graph TD
A[Start] --> B{Decision}
B -->|Yes| C[Ship it]
B -->|No| D[Rework]
D --> A</div>
<button class="sp-btn" data-toast="Code applied to canvas ✓">Apply to canvas</button>
</div>
</div>
<div class="side-panel" id="panelAI" hidden>
<div class="sp-head"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><path d="M14.6 4.2 15.7 8l3.8 1.1-3.8 1.1-1.1 3.8-1.1-3.8L9.7 9.1 13.5 8l1.1-3.8Z"/><path d="M18.7 14.5l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7.7-2Z"/></svg> AI assistant <button class="x" data-closepanel></button></div>
<div class="sp-body">
<p>Ask AI to draw, explain or tidy up your canvas.</p>
<button class="sp-btn" data-toast='Prompt: "Draw a diagram of…"'>&nbsp;&nbsp;Draw a diagram of…</button>
<button class="sp-btn" data-toast='Prompt: "Create a short deck about…"' style="background:var(--chrome-rest);color:var(--chrome-fg-800)">&nbsp;&nbsp;Create a short deck about…</button>
</div>
</div>
<div class="side-panel" id="panelComments" hidden>
<div class="sp-head">Comments <button class="x" data-closepanel></button></div>
<div class="sp-body"><p>No comments yet. Select something on the canvas and press <b>⇧C</b> to comment.</p></div>
</div>
<!-- ==================== ALL-TOOLS DRAWER ==================== -->
<div class="drawer" id="drawer" hidden>
<div class="cmd-head">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" style="flex:0 0 15px"><circle cx="11" cy="11" r="7"/><path d="m16.5 16.5 4 4"/></svg>
<input id="drawerSearch" data-testid="tool-drawer-input" placeholder="Search all tools" aria-label="Search all tools">
</div>
<div class="cmd-list" id="drawerList"></div>
</div>
<svg id="laserFx" style="position:fixed;inset:0;z-index:34;pointer-events:none"></svg>
<!-- ==================== SHORTCUTS DIALOG ==================== -->
<div class="sc-veil" id="scVeil" hidden></div>
<div class="sc-dialog" id="scDialog" hidden>
<h2>Keyboard shortcuts</h2>
<div class="sc-grid" id="scGrid"></div>
</div>
<button class="zen-exit" id="zenExit">Exit zen mode <kbd style="font:inherit;font-size:11px;opacity:.6">⌥Z</kbd></button>
<div class="toast" id="toast">Saved</div>
<div class="tip" id="tooltip"></div>
<script>
/* ================================================================
STATE
================================================================ */
const S = {
tool:'select', shape:'rect', conn:'arrow',
lock:false, zen:false, viewOnly:false, dark:false,
zoom:1, pan:{x:80,y:80},
els:[], selId:null, past:[], future:[],
};
let uidc = 1;
const $ = id => document.getElementById(id);
const stage = $('stage'), world = $('world');
/* ================================================================
WORLD RENDER
================================================================ */
function applyView(){
world.style.transform = `translate(${S.pan.x}px,${S.pan.y}px) scale(${S.zoom})`;
$('zoomPct').textContent = Math.round(S.zoom*100)+'%';
}
function history(){
S.past.push(JSON.stringify(S.els)); if(S.past.length>60) S.past.shift();
S.future = [];
render();
}
function render(){
world.innerHTML = '';
S.els.forEach(el => world.appendChild(renderEl(el)));
$('undoBtn').disabled = !S.past.length;
$('redoBtn').disabled = !S.future.length;
$('ovUndo').disabled = !S.past.length;
$('ovRedo').disabled = !S.future.length;
document.body.classList.toggle('hidden-hints', S.els.length>0);
}
function renderEl(el){
const d = document.createElement('div');
d.className = 'el' + (S.selId===el.id ? ' sel':'') + extraCls(el);
d.dataset.id = el.id;
d.style.cssText = `left:${el.x}px;top:${el.y}px;width:${el.w}px;height:${el.h}px;`;
d.innerHTML = elHTML(el);
d.addEventListener('pointerdown', ev => onElPointer(el, ev));
return d;
}
const INK = getComputedStyle(document.documentElement).getPropertyValue('--canvas-ink');
function ink(){ return getComputedStyle(document.documentElement).getPropertyValue('--canvas-ink').trim(); }
function extraCls(el){
const m = {text:' el-text',sticky:' el-sticky',stepbadge:' el-badge',image:' el-img',icons:' el-glyph',
entity:' el-ent',lifeline:' el-life',fragment:' el-frag',gshape:' el-gshape',highlight:' el-hl',
chart:' el-box',table:' el-box',frame:' el-box',ctrl:' el-ctrl'}[el.kind];
return m||'';
}
function svgWrap(inner){ return `<svg width="100%" height="100%" style="display:block;overflow:visible">${inner}</svg>`; }
function elHTML(el){
const c = ink();
switch(el.kind){
case 'rect': return svgWrap(`<rect x="2" y="2" width="${Math.max(4,el.w-4)}" height="${Math.max(4,el.h-4)}" rx="7" fill="none" stroke="${c}" stroke-width="2.4"/>`);
case 'ellipse': return svgWrap(`<ellipse cx="${el.w/2}" cy="${el.h/2}" rx="${Math.max(4,el.w/2-2)}" ry="${Math.max(4,el.h/2-2)}" fill="none" stroke="${c}" stroke-width="2.4"/>`);
case 'diamond': return svgWrap(`<path d="M${el.w/2} 2 L${el.w-2} ${el.h/2} L${el.w/2} ${el.h-2} L2 ${el.h/2} Z" fill="none" stroke="${c}" stroke-width="2.4" stroke-linejoin="round"/>`);
case 'triangle': return svgWrap(`<path d="M${el.w/2} 2 L${el.w-2} ${el.h-2} L2 ${el.h-2} Z" fill="none" stroke="${c}" stroke-width="2.4" stroke-linejoin="round"/>`);
case 'text': return `<div class="et" contenteditable="${S.tool==='select'}" spellcheck="false">${esc(el.text)||'Text'}</div>`;
case 'sticky': return `<div class="et" contenteditable="${S.tool==='select'}" spellcheck="false">${esc(el.text)||'Note…'}</div>`;
case 'stepbadge': return `<b style="font-size:${Math.min(el.w,el.h)*.44}px">${el.n||1}</b>`;
case 'image': return el.emoji || '🖼️';
case 'icons': return el.emoji || '⭐';
case 'entity': return `<div class="eh">${esc(el.text)||'Entity'}</div><div class="er">+ id: UUID</div><div class="er">+ created: date</div>`;
case 'lifeline': return `<div class="head">${esc(el.text)||'Actor'}</div><div class="line"></div>`;
case 'fragment': return `<span class="tab">${esc(el.text)||'loop'}</span>`;
case 'gshape': return `<svg width="100%" height="100%" viewBox="0 0 24 24" preserveAspectRatio="none" style="display:block;overflow:visible;color:${ink()}"><g fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">${SHAPES24[el.g]||SHAPES24.rectangle}</g></svg>`;
case 'highlight':{
const pts = el.pts.map(p=>`${p.x-el.minx},${p.y-el.miny}`).join(' ');
return svgWrap(`<polyline points="${pts}" fill="none" stroke="rgba(255,213,74,.55)" stroke-width="15" stroke-linecap="round" stroke-linejoin="round"/>`);
}
case 'chart': return chartHTML(el.chart);
case 'table': return tableHTML();
case 'frame': return frameHTML(el.frame);
case 'ctrl': return ctrlHTML(el.ctrl);
case 'arrow': case 'line': case 'polyline': case 'draw':{
const pts = el.pts, w=el.w, h=el.h;
const pstr = pts.map(p=>`${p.x-el.minx},${p.y-el.miny}`).join(' ');
const head = (el.kind==='arrow');
return svgWrap(`${head?`<defs><marker id="ah${el.id}" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8" fill="none" stroke="${c}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></marker></defs>`:''}
<polyline points="${pstr}" fill="none" stroke="${c}" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" ${head?`marker-end="url(#ah${el.id})"`:''}/>`);
}
}
return '';
}
function esc(s){ return (s||'').replace(/[&<>"]/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m])); }
world.addEventListener('input', e => {
const n = e.target.closest('.el'); if(!n) return;
const el = S.els.find(x => x.id == n.dataset.id); if(!el) return;
el.text = e.target.classList.contains('et') ? e.target.innerText
: e.target.classList.contains('eh') ? e.target.innerText
: e.target.innerText;
});
world.addEventListener('focusout', e => { if(e.target.closest('.el')) history(); });
/* ================================================================
SHAPE GLYPH LIBRARY (24×24, currentColor) — covers every shape
kind in koboyo's registry (Z map in their tools bundle)
================================================================ */
const SHAPES24 = {
'rectangle':'<rect x="2.5" y="4.5" width="19" height="15" rx="2.5"/>',
'ellipse':'<ellipse cx="12" cy="12" rx="9.5" ry="7.5"/>',
'oval':'<ellipse cx="12" cy="12" rx="9.5" ry="7.5"/>',
'diamond':'<path d="M12 2.5 21.5 12 12 21.5 2.5 12Z"/>',
'triangle':'<path d="M12 3 21 20 3 20Z"/>',
'parallelogram':'<path d="M5.5 4h16l-3.5 16H2Z"/>',
'trapezoid':'<path d="M7 4h10l4.5 16h-19Z"/>',
'cylinder':'<ellipse cx="12" cy="5.5" rx="8" ry="3"/><path d="M4 5.5v13a8 3 0 0 0 16 0v-13"/>',
'note':'<path d="M4 3h13l3 3.2V21H4Z"/><path d="M17 3v3.5h3"/>',
'hexagon':'<path d="M7.5 3h9L21.5 12 16.5 21h-9L2.5 12Z"/>',
'star':'<path d="M12 2.8l2.8 5.9 6.4.7-4.8 4.3 1.4 6.3L12 16.9l-5.8 3.1 1.4-6.3-4.8-4.3 6.4-.7Z"/>',
'checkbox':'<rect x="3.5" y="3.5" width="17" height="17" rx="3"/><path d="M7.5 12.2l3.2 3.2 5.8-6.4"/>',
'crossbox':'<rect x="3.5" y="3.5" width="17" height="17" rx="3"/><path d="M7.5 7.5l9 9M16.5 7.5l-9 9"/>',
'heart':'<path d="M12 20.3C5.7 15.7 3 12.3 3 8.9c0-2.7 2-4.4 4.3-4.4 1.8 0 3.5 1 4.7 2.6 1.2-1.6 2.9-2.6 4.7-2.6 2.3 0 4.3 1.7 4.3 4.4 0 3.4-2.7 6.8-9 11.4Z"/>',
'cloud':'<path d="M7 18.5a4.7 4.7 0 0 1-.6-9.4A6.2 6.2 0 0 1 18 10.7 4.1 4.1 0 0 1 17.6 18.5Z"/>',
'arrow-up':'<path d="M12 20.5v-17M5.3 10 12 3.3 18.7 10"/>',
'arrow-down':'<path d="M12 3.5v17M5.3 14 12 20.7 18.7 14"/>',
'arrow-left':'<path d="M20.5 12h-17M10 5.3 3.3 12 10 18.7"/>',
'arrow-right':'<path d="M3.5 12h17M14 5.3 20.7 12 14 18.7"/>',
'plus':'<path d="M12 3.5v17M3.5 12h17"/>',
'speech-bubble':'<path d="M4 4h16a2 2 0 0 1 2 2v8.5a2 2 0 0 1-2 2h-7.5L8 20.5v-4H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z"/>',
'bolt':'<path d="M13.2 2.2 4.8 13.2H11L10 21.8l8.4-11H12Z"/>',
'queue':'<rect x="3" y="5" width="18" height="14" rx="1.5"/><path d="M7.6 5v14"/>',
'callout':'<ellipse cx="12" cy="9.5" rx="8" ry="5.5"/><path d="M8.5 14.2 5.5 20l6.5-5.2"/>',
'document':'<path d="M6 2.8h7.5L19 8v13.2H6Z"/><path d="M13.5 2.8V8H19"/>',
'predefined-process':'<rect x="3.5" y="5" width="17" height="14" rx="1.5"/><path d="M7.8 5v14M16.2 5v14"/>',
'manual-input':'<path d="M3.5 8l17-3.5V19.5H3.5Z"/><path d="M8 8.7v10.8"/>',
'pentagon':'<path d="M12 2.6l9.3 6.8-3.6 11H6.3l-3.6-11Z"/>',
'chevron':'<path d="M3.5 4h8.5l7.5 8-7.5 8H3.5l7.5-8Z"/>',
'octagon':'<path d="M8 3h8l5 5v8l-5 5H8l-5-5V8Z"/>',
'cube':'<path d="M12 2.6 20.6 7v10L12 21.4 3.4 17V7Z"/><path d="M3.4 7 12 11.6 20.6 7M12 11.6v9.8"/>',
'actor':'<circle cx="12" cy="5" r="2.6"/><path d="M12 7.8V14M6.6 9.8h10.8M12 14l-5 7M12 14l5 7"/>',
'double-circle':'<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4.6"/>',
'card':'<rect x="3.5" y="5" width="17" height="14" rx="2"/><path d="M7 9.2h10M7 12.6h7"/>',
'delay':'<path d="M5 4h6.5a8 8 0 0 1 0 16H5Z"/>',
'fork-join':'<rect x="3" y="10.4" width="18" height="3.2" rx="1" fill="currentColor" stroke="none"/>',
'multi-document':'<path d="M9 5.2l4-2.7 5.5 5.5"/><path d="M6 8.5h7L18 13v8.5H6Z"/><path d="M13 8.5V13h5"/>',
'multi-process':'<rect x="6.5" y="8.5" width="13" height="11" rx="1.5"/><path d="M9.5 8.5v-3h11v11h-3"/>',
'package':'<path d="M4 6.5h16v13H4Z"/><path d="M4 6.5 7 3.8h10l3 2.7M9.5 3.8v3h5v-3"/>',
'component':'<rect x="6" y="4" width="12" height="16" rx="1.5"/><path d="M3.5 6.5H6M3.5 10H6M18 6.5h2.5M18 10h2.5"/>',
'brace':'<path d="M9.5 3.2C7 3.2 6.8 4.3 6.8 6v2.8c0 1.6-.9 2.6-2.6 3.2 1.7.6 2.6 1.6 2.6 3.2V18c0 1.7.2 2.8 2.7 2.8M14.5 3.2c2.5 0 2.7 1.1 2.7 2.8v2.8c0 1.6.9 2.6 2.6 3.2-1.7.6-2.6 1.6-2.6 3.2V18c0 1.7-.2 2.8-2.7 2.8"/>',
};
const SHAPE_KINDS = [
['diamond','Diamond'],['triangle','Triangle'],['oval','Oval'],['parallelogram','Parallelogram'],
['trapezoid','Trapezoid'],['cylinder','Cylinder'],['note','Note'],['hexagon','Hexagon'],['star','Star'],
['checkbox','Checkbox'],['crossbox','Crossed box'],['heart','Heart'],['cloud','Cloud'],
['arrow-up','Arrow up'],['arrow-down','Arrow down'],['arrow-left','Arrow left'],['arrow-right','Arrow right'],
['plus','Plus'],['speech-bubble','Speech bubble'],['bolt','Lightning bolt'],['queue','Queue'],
['callout','Callout'],['document','Document'],['predefined-process','Predefined process'],
['manual-input','Manual input'],['pentagon','Pentagon'],['chevron','Chevron'],['octagon','Octagon'],
['cube','Cube'],['actor','Actor'],['double-circle','Double circle'],['card','Card'],['delay','Delay'],
['fork-join','Fork / join'],['multi-document','Multi-document'],['multi-process','Multi-process'],
['package','Package'],['component','Component'],['brace','Brace'],
];
/* ================================================================
INSERT RENDERERS — charts, table, frames, UI controls
================================================================ */
const A_ = s => s; // passthrough helper for readability
function chartHTML(kind){
const P='#4da1ff',G='#34c77b',O='#ff9f3d',R='#f2698a',M='var(--canvas-ink-muted)',I='var(--canvas-ink)';
const box=(inner,vb="0 0 190 100")=>`<div style="width:100%;height:100%;background:var(--chrome-popup);border:1.6px solid ${I};border-radius:8px;overflow:hidden;display:flex;flex-direction:column">
<div style="padding:7px 10px 4px;font-size:11px;font-weight:700;color:${I};letter-spacing:.2px">Chart</div>
<svg viewBox="${vb}" style="flex:1;width:100%;min-height:0" preserveAspectRatio="xMidYMid meet">${inner}</svg></div>`;
switch(kind){
case 'bar': return box(`${[26,54,86,118,150].map((x,i)=>`<rect x="${x}" y="${[42,26,60,18,50][i]}" width="22" height="${[48,64,30,72,40][i]}" rx="3" fill="${[P,G,O,R,P][i]}"/>`).join('')}<line x1="14" y1="90" x2="180" y2="90" stroke="${M}" stroke-width="1.4"/>`);
case 'line': return box(`<polyline points="18,74 46,52 74,62 102,34 130,44 158,22 180,30" fill="none" stroke="${P}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/><line x1="14" y1="90" x2="180" y2="90" stroke="${M}" stroke-width="1.4"/>`);
case 'area': return box(`<path d="M18 74 46 52 74 62 102 34 130 44 158 22 180 30 V90 H18 Z" fill="${P}" opacity=".22"/><polyline points="18,74 46,52 74,62 102,34 130,44 158,22 180,30" fill="none" stroke="${P}" stroke-width="2.4"/>`);
case 'scatter': return box(`${[[30,70],[52,44],[66,60],[88,30],[104,52],[124,60],[142,26],[164,46],[176,34],[60,74],[112,74]].map(p=>`<circle cx="${p[0]}" cy="${p[1]}" r="4" fill="${P}"/>`).join('')}<line x1="14" y1="90" x2="180" y2="90" stroke="${M}" stroke-width="1.4"/>`);
case 'pie': return box(`<circle cx="95" cy="52" r="34" fill="${G}"/><path d="M95 52 95 18 A34 34 0 0 1 127 64 Z" fill="${P}"/><path d="M95 52 127 64 A34 34 0 0 1 78 81 Z" fill="${O}"/>`);
case 'donut': return box(`<circle cx="95" cy="52" r="34" fill="none" stroke="${G}" stroke-width="16"/><path d="M95 18 A34 34 0 0 1 127 64" fill="none" stroke="${P}" stroke-width="16"/><path d="M127 64 A34 34 0 0 1 78 81" fill="none" stroke="${O}" stroke-width="16"/>`);
case 'radar': return box(`<polygon points="95,14 128,38 116,78 74,78 62,38" fill="none" stroke="${M}" stroke-width="1.2"/><polygon points="95,26 117,42 110,68 80,68 73,42" fill="none" stroke="${M}" stroke-width="1"/><polygon points="95,22 120,44 108,72 82,64 78,40" fill="${P}" opacity=".3" stroke="${P}" stroke-width="1.8"/>`);
case 'quadrant': return box(`<line x1="95" y1="14" x2="95" y2="90" stroke="${M}" stroke-width="1.4"/><line x1="24" y1="52" x2="168" y2="52" stroke="${M}" stroke-width="1.4"/>${[[60,34],[120,30],[76,66],[138,60],[52,50]].map((p,i)=>`<circle cx="${p[0]}" cy="${p[1]}" r="5" fill="${[P,G,O,R,P][i]}"/>`).join('')}`);
}
return chartHTML('bar');
}
function tableHTML(){
const I='var(--canvas-ink)',M='var(--canvas-ink-muted)';
return `<div style="width:100%;height:100%;background:var(--chrome-popup);border:1.6px solid ${I};border-radius:8px;overflow:hidden">
${[0,1,2,3].map(r=>`<div style="display:flex;height:25%;border-top:${r?'1.2px solid '+M:'none'}">${[0,1,2].map(c=>`<div style="flex:1;border-left:${c?'1.2px solid '+M:'none'};background:${r===0?'var(--chrome-rest)':'transparent'}"></div>`).join('')}</div>`).join('')}
</div>`;
}
function frameHTML(kind){
const I='var(--canvas-ink)',M='var(--canvas-ink-muted)';
if(kind==='browser') return `<div style="width:100%;height:100%;background:var(--chrome-popup);border:1.6px solid ${I};border-radius:10px;overflow:hidden">
<div style="height:26px;display:flex;align-items:center;gap:6px;padding:0 10px;border-bottom:1.4px solid ${M}"><span style="width:8px;height:8px;border-radius:50%;background:#f2698a"></span><span style="width:8px;height:8px;border-radius:50%;background:#ffc53d"></span><span style="width:8px;height:8px;border-radius:50%;background:#34c77b"></span><span style="flex:1;height:14px;margin-left:6px;border:1.2px solid ${M};border-radius:7px"></span></div>
<div style="padding:10px"><div style="height:8px;border-radius:4px;background:var(--chrome-rest);width:70%;margin-bottom:8px"></div><div style="height:8px;border-radius:4px;background:var(--chrome-rest);width:52%"></div></div></div>`;
if(kind==='phone') return `<div style="width:100%;height:100%;background:var(--chrome-popup);border:2px solid ${I};border-radius:16px;overflow:hidden;position:relative">
<div style="width:44px;height:7px;border-radius:4px;background:${M};margin:8px auto"></div>
<div style="margin:10px 12px;height:8px;border-radius:4px;background:var(--chrome-rest);width:64%"></div>
<div style="margin:10px 12px;height:8px;border-radius:4px;background:var(--chrome-rest);width:44%"></div></div>`;
return `<div style="position:relative;width:100%;height:100%;border:1.6px solid ${I};border-radius:6px">
<span style="position:absolute;top:-1.6px;left:-1.6px;background:var(--chrome-popup);border:1.6px solid ${I};border-radius:6px 0 4px 0;padding:3px 10px;font-size:11px;font-weight:800;color:${I}">frame</span></div>`;
}
const CTRL_DEF = {
button:[120,36], input:[200,36], search:[200,36], checkbox:[110,26], radio:[110,26], toggle:[48,26],
dropdown:[200,36], slider:[180,24], navbar:[300,42], tabs:[240,34], image:[160,110], avatar:[42,42],
heading:[160,30], paragraph:[220,56], divider:[200,10], link:[64,22], textarea:[220,88],
stepper:[120,36], badge:[76,24], progress:[200,16], breadcrumbs:[180,22], pagination:[172,28],
rating:[112,24], card:[220,150], list:[220,110], alert:[240,44], modal:[240,150], menu:[160,140],
tooltip:[150,30], accordion:[240,120], datepicker:[200,36], sidebar:[260,150], scrollbar:[200,12],
segmented:[150,30], tabbar:[260,46], statusbar:[300,22], fab:[48,48],
};
function ctrlHTML(kind){
const I='var(--canvas-ink)',M='var(--canvas-ink-muted)',B='#4da1ff',POP='var(--chrome-popup)',RS='var(--chrome-rest)';
const d='display:block;width:100%;height:100%';
switch(kind){
case 'button': return `<svg style="${d}" viewBox="0 0 120 36" preserveAspectRatio="none"><rect x="1" y="1" width="118" height="34" rx="8" fill="${B}"/><text x="60" y="22" text-anchor="middle" font-size="13" font-weight="600" fill="#fff">Button</text></svg>`;
case 'input': return `<svg style="${d}" viewBox="0 0 200 36" preserveAspectRatio="none"><rect x="1" y="1" width="198" height="34" rx="8" fill="${POP}" stroke="${I}" stroke-width="1.6"/><text x="14" y="22" font-size="12.5" fill="${M}">Input</text><line x1="10" y1="10" x2="10" y2="26" stroke="${B}" stroke-width="1.6"/></svg>`;
case 'search': return `<svg style="${d}" viewBox="0 0 200 36" preserveAspectRatio="none"><rect x="1" y="1" width="198" height="34" rx="17" fill="${POP}" stroke="${I}" stroke-width="1.6"/><circle cx="20" cy="17" r="6" fill="none" stroke="${M}" stroke-width="1.6"/><line x1="24.5" y1="21.5" x2="28" y2="25" stroke="${M}" stroke-width="1.6"/><text x="38" y="22" font-size="12.5" fill="${M}">Search…</text></svg>`;
case 'checkbox': return `<svg style="${d}" viewBox="0 0 110 26" preserveAspectRatio="none"><rect x="2" y="4" width="18" height="18" rx="4.5" fill="${B}"/><path d="M6.5 13.2l2.6 2.6 4.6-5" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round"/><text x="28" y="17.5" font-size="12.5" fill="currentColor">Checkbox</text></svg>`;
case 'radio': return `<svg style="${d}" viewBox="0 0 110 26" preserveAspectRatio="none"><circle cx="11" cy="13" r="9" fill="${POP}" stroke="${B}" stroke-width="1.8"/><circle cx="11" cy="13" r="4.5" fill="${B}"/><text x="28" y="17.5" font-size="12.5" fill="currentColor">Radio</text></svg>`;
case 'toggle': return `<svg style="${d}" viewBox="0 0 48 26" preserveAspectRatio="none"><rect x="1" y="2" width="46" height="22" rx="11" fill="${B}"/><circle cx="36" cy="13" r="8" fill="#fff"/></svg>`;
case 'dropdown': return `<svg style="${d}" viewBox="0 0 200 36" preserveAspectRatio="none"><rect x="1" y="1" width="198" height="34" rx="8" fill="${POP}" stroke="${I}" stroke-width="1.6"/><text x="14" y="22" font-size="12.5" fill="currentColor">Dropdown</text><path d="M176 14.5l6 6 6-6" stroke="${M}" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
case 'slider': return `<svg style="${d}" viewBox="0 0 180 24" preserveAspectRatio="none"><line x1="4" y1="12" x2="176" y2="12" stroke="${M}" stroke-width="3.5" stroke-linecap="round"/><line x1="4" y1="12" x2="104" y2="12" stroke="${B}" stroke-width="3.5" stroke-linecap="round"/><circle cx="104" cy="12" r="7.5" fill="#fff" stroke="${B}" stroke-width="2"/></svg>`;
case 'navbar': return `<svg style="${d}" viewBox="0 0 300 42" preserveAspectRatio="none"><rect x="1" y="1" width="298" height="40" rx="9" fill="${POP}" stroke="${I}" stroke-width="1.6"/><circle cx="22" cy="21" r="7" fill="${B}"/><text x="40" y="25" font-size="12" font-weight="700" fill="currentColor">Brand</text>${[210,240,270].map(x=>`<rect x="${x-14}" y="16" width="24" height="9" rx="4.5" fill="${RS}"/>`).join('')}</svg>`;
case 'tabs': return `<svg style="${d}" viewBox="0 0 240 34" preserveAspectRatio="none"><rect x="1" y="1" width="238" height="32" rx="9" fill="${RS}" stroke="${M}"/><rect x="4" y="4" width="76" height="26" rx="7" fill="${POP}" stroke="${I}" stroke-width="1.2"/><text x="42" y="21" text-anchor="middle" font-size="11.5" fill="currentColor">Tab 1</text><text x="120" y="21" text-anchor="middle" font-size="11.5" fill="${M}">Tab 2</text><text x="198" y="21" text-anchor="middle" font-size="11.5" fill="${M}">Tab 3</text></svg>`;
case 'image': return `<svg style="${d}" viewBox="0 0 160 110" preserveAspectRatio="none"><rect x="1" y="1" width="158" height="108" rx="10" fill="${RS}" stroke="${M}" stroke-dasharray="5 4" stroke-width="1.6"/><circle cx="52" cy="42" r="10" fill="none" stroke="${M}" stroke-width="1.8"/><path d="M40 84 78 48l22 22 16-14 26 28" fill="none" stroke="${M}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
case 'avatar': return `<svg style="${d}" viewBox="0 0 42 42" preserveAspectRatio="none"><circle cx="21" cy="21" r="19" fill="${RS}" stroke="${I}" stroke-width="1.6"/><circle cx="21" cy="16" r="6.4" fill="none" stroke="${M}" stroke-width="1.8"/><path d="M9 35c2.5-7 7-9.5 12-9.5s9.5 2.5 12 9.5" fill="none" stroke="${M}" stroke-width="1.8"/></svg>`;
case 'heading': return `<svg style="${d}" viewBox="0 0 160 30" preserveAspectRatio="none"><text x="2" y="23" font-size="24" font-weight="800" fill="currentColor">Heading</text></svg>`;
case 'paragraph': return `<svg style="${d}" viewBox="0 0 220 56" preserveAspectRatio="none">${[0,16,32,48].map((y,i)=>`<rect x="0" y="${y}" width="${i===3 ? 120 : 218}" height="8" rx="4" fill="${RS}" stroke="${M}" stroke-width="1"/>`).join('')}</svg>`;
case 'divider': return `<svg style="${d}" viewBox="0 0 200 10" preserveAspectRatio="none"><line x1="0" y1="5" x2="200" y2="5" stroke="${M}" stroke-width="1.6"/></svg>`;
case 'link': return `<svg style="${d}" viewBox="0 0 64 22" preserveAspectRatio="none"><text x="2" y="16" font-size="14" fill="${B}">Link</text><line x1="2" y1="19" x2="34" y2="19" stroke="${B}" stroke-width="1.4"/></svg>`;
case 'textarea': return `<svg style="${d}" viewBox="0 0 220 88" preserveAspectRatio="none"><rect x="1" y="1" width="218" height="86" rx="9" fill="${POP}" stroke="${I}" stroke-width="1.6"/><text x="12" y="22" font-size="12" fill="${M}">Text area…</text></svg>`;
case 'stepper': return `<svg style="${d}" viewBox="0 0 120 36" preserveAspectRatio="none"><rect x="1" y="1" width="118" height="34" rx="8" fill="${POP}" stroke="${I}" stroke-width="1.6"/><text x="20" y="22.5" text-anchor="middle" font-size="14" fill="${M}"></text><text x="60" y="22" text-anchor="middle" font-size="13" fill="currentColor">1</text><text x="100" y="23" text-anchor="middle" font-size="14" fill="${M}">+</text><line x1="38" y1="7" x2="38" y2="29" stroke="${M}"/><line x1="82" y1="7" x2="82" y2="29" stroke="${M}"/></svg>`;
case 'badge': return `<svg style="${d}" viewBox="0 0 76 24" preserveAspectRatio="none"><rect x="1" y="1" width="74" height="22" rx="11" fill="${B}"/><text x="38" y="16" text-anchor="middle" font-size="11.5" font-weight="700" fill="#fff">Badge</text></svg>`;
case 'progress': return `<svg style="${d}" viewBox="0 0 200 16" preserveAspectRatio="none"><rect x="0" y="4" width="200" height="8" rx="4" fill="${RS}" stroke="${M}"/><rect x="0" y="4" width="124" height="8" rx="4" fill="${B}"/></svg>`;
case 'breadcrumbs': return `<svg style="${d}" viewBox="0 0 180 22" preserveAspectRatio="none"><text x="2" y="15" font-size="12.5" fill="${B}">Home</text><text x="46" y="15" font-size="12.5" fill="${M}">/</text><text x="58" y="15" font-size="12.5" fill="${B}">Library</text><text x="108" y="15" font-size="12.5" fill="${M}">/</text><text x="120" y="15" font-size="12.5" fill="currentColor">Data</text></svg>`;
case 'pagination': return `<svg style="${d}" viewBox="0 0 172 28" preserveAspectRatio="none">${['','1','2','3','…',''].map((t,i)=>{const on=i===1;return `<rect x="${2+i*28}" y="2" width="24" height="24" rx="6.5" fill="${on?B:POP}" stroke="${on?'none':M}" stroke-width="1.2"/><text x="${14+i*28}" y="18.5" text-anchor="middle" font-size="12" fill="${on?'#fff':'currentColor'}">${t}</text>`}).join('')}</svg>`;
case 'rating': return `<svg style="${d}" viewBox="0 0 112 24" preserveAspectRatio="none">${[0,1,2,3,4].map(i=>`<path d="M${12+i*22} 3l2.5 5.2 5.7.6-4.3 3.8 1.2 5.6-5.1-2.9-5.1 2.9 1.2-5.6-4.3-3.8 5.7-.6Z" fill="${i<4?'#ffc53d':RS}" stroke="${i<4?'#e8a70c':M}" stroke-width="1"/>`).join('')}</svg>`;
case 'card': return `<svg style="${d}" viewBox="0 0 220 150" preserveAspectRatio="none"><rect x="1" y="1" width="218" height="148" rx="11" fill="${POP}" stroke="${I}" stroke-width="1.6"/><rect x="13" y="14" width="194" height="66" rx="7" fill="${RS}"/><text x="16" y="102" font-size="14" font-weight="700" fill="currentColor">Card title</text><rect x="14" y="112" width="150" height="7" rx="3.5" fill="${RS}"/><rect x="14" y="128" width="70" height="14" rx="7" fill="${B}"/></svg>`;
case 'list': return `<svg style="${d}" viewBox="0 0 220 110" preserveAspectRatio="none"><rect x="1" y="1" width="218" height="108" rx="9" fill="${POP}" stroke="${I}" stroke-width="1.6"/>${[0,1,2].map(i=>`<g><circle cx="22" cy="${25+i*31}" r="7" fill="${RS}" stroke="${M}"/><rect x="38" y="${19+i*31}" width="130" height="6" rx="3" fill="${RS}"/><rect x="38" y="${28+i*31}" width="86" height="5" rx="2.5" fill="${RS}"/>${i?`<line x1="10" y1="${9+i*31}" x2="210" y2="${9+i*31}" stroke="${M}" stroke-width="1"/>`:''}</g>`).join('')}</svg>`;
case 'alert': return `<svg style="${d}" viewBox="0 0 240 44" preserveAspectRatio="none"><rect x="1" y="1" width="238" height="42" rx="9" fill="rgba(77,161,255,.12)" stroke="${B}" stroke-width="1.5"/><circle cx="22" cy="22" r="8" fill="${B}"/><text x="22" y="26" text-anchor="middle" font-size="11" font-weight="800" fill="#fff">i</text><text x="40" y="26" font-size="12.5" fill="currentColor">Something to note</text></svg>`;
case 'modal': return `<svg style="${d}" viewBox="0 0 240 150" preserveAspectRatio="none"><rect x="1" y="1" width="238" height="148" rx="11" fill="${POP}" stroke="${I}" stroke-width="1.6"/><text x="16" y="30" font-size="14" font-weight="800" fill="currentColor">Modal dialog</text><rect x="16" y="44" width="208" height="7" rx="3.5" fill="${RS}"/><rect x="16" y="58" width="150" height="7" rx="3.5" fill="${RS}"/><rect x="130" y="112" width="46" height="22" rx="7" fill="${RS}"/><rect x="182" y="112" width="46" height="22" rx="7" fill="${B}"/><text x="205" y="127" text-anchor="middle" font-size="10.5" font-weight="700" fill="#fff">OK</text></svg>`;
case 'menu': return `<svg style="${d}" viewBox="0 0 160 140" preserveAspectRatio="none"><rect x="1" y="1" width="158" height="138" rx="9" fill="${POP}" stroke="${I}" stroke-width="1.6"/>${[0,1,2,3].map(i=>`${i?`<line x1="8" y1="${8+i*32}" x2="152" y2="${8+i*32}" stroke="${M}" stroke-width="1"/>`:''}<rect x="16" y="${17+i*32}" width="10" height="10" rx="3" fill="${RS}" stroke="${M}"/><rect x="34" y="${19+i*32}" width="80" height="6" rx="3" fill="${RS}"/>`).join('')}</svg>`;
case 'tooltip': return `<svg style="${d}" viewBox="0 0 150 30" preserveAspectRatio="none"><path d="M1 8a7 7 0 0 1 7-7h134a7 7 0 0 1 7 7v10a7 7 0 0 1-7 7H81l-6 5-6-5H8a7 7 0 0 1-7-7Z" fill="currentColor"/><text x="75" y="17" text-anchor="middle" font-size="11.5" fill="${POP}">Tooltip text</text></svg>`;
case 'accordion': return `<svg style="${d}" viewBox="0 0 240 120" preserveAspectRatio="none"><rect x="1" y="1" width="238" height="118" rx="9" fill="${POP}" stroke="${I}" stroke-width="1.6"/>${[12,52,86].map((y,i)=>`<rect x="14" y="${y+8}" width="90" height="7" rx="3.5" fill="${RS}"/><path d="M${214-i*0} ${y+9}l5 5 5-5" stroke="${M}" stroke-width="1.6" fill="none" stroke-linecap="round"/>${i?`<line x1="8" y1="${y}" x2="232" y2="${y}" stroke="${M}" stroke-width="1"/>`:''}`).join('')}<rect x="14" y="32" width="180" height="6" rx="3" fill="${RS}"/></svg>`;
case 'datepicker': return `<svg style="${d}" viewBox="0 0 200 36" preserveAspectRatio="none"><rect x="1" y="1" width="198" height="34" rx="8" fill="${POP}" stroke="${I}" stroke-width="1.6"/><text x="14" y="22" font-size="12.5" fill="${M}">24 Feb 2001</text><rect x="172" y="9" width="18" height="18" rx="4.5" fill="none" stroke="${M}" stroke-width="1.4"/><line x1="176" y1="6.5" x2="176" y2="11" stroke="${M}" stroke-width="1.4"/><line x1="186" y1="6.5" x2="186" y2="11" stroke="${M}" stroke-width="1.4"/><line x1="172" y1="14" x2="190" y2="14" stroke="${M}" stroke-width="1.4"/></svg>`;
case 'sidebar': return `<svg style="${d}" viewBox="0 0 260 150" preserveAspectRatio="none"><rect x="1" y="1" width="258" height="148" rx="9" fill="${POP}" stroke="${I}" stroke-width="1.6"/><path d="M1 10a9 9 0 0 1 9-9h64v148H10a9 9 0 0 1-9-9Z" fill="${RS}"/><rect x="14" y="18" width="44" height="7" rx="3.5" fill="${M}"/>${[0,1,2].map(i=>`<rect x="14" y="${40+i*18}" width="52" height="6" rx="3" fill="${POP}"/>`).join('')}<rect x="90" y="18" width="80" height="8" rx="4" fill="${RS}"/><rect x="90" y="34" width="130" height="6" rx="3" fill="${RS}"/></svg>`;
case 'scrollbar': return `<svg style="${d}" viewBox="0 0 200 12" preserveAspectRatio="none"><rect x="0" y="0" width="200" height="12" rx="6" fill="${RS}" stroke="${M}"/><rect x="30" y="0" width="70" height="12" rx="6" fill="${M}"/></svg>`;
case 'segmented': return `<svg style="${d}" viewBox="0 0 150 30" preserveAspectRatio="none"><rect x="1" y="1" width="148" height="28" rx="9" fill="${RS}" stroke="${M}"/><rect x="4" y="4" width="70" height="22" rx="7" fill="${POP}" stroke="${I}" stroke-width="1.1"/><text x="39" y="19.5" text-anchor="middle" font-size="11.5" fill="currentColor">One</text><text x="112" y="19.5" text-anchor="middle" font-size="11.5" fill="${M}">Two</text></svg>`;
case 'tabbar': return `<svg style="${d}" viewBox="0 0 260 46" preserveAspectRatio="none"><rect x="1" y="1" width="258" height="44" rx="12" fill="${POP}" stroke="${I}" stroke-width="1.6"/>${[0,1,2,3].map(i=>`<g><rect x="${34+i*54}" y="12" width="14" height="12" rx="4" fill="${i?RS:B}" stroke="${i?M:'none'}" stroke-width="1.2"/><rect x="${32+i*54}" y="30" width="18" height="5" rx="2.5" fill="${i?RS:M}"/></g>`).join('')}</svg>`;
case 'statusbar': return `<svg style="${d}" viewBox="0 0 300 22" preserveAspectRatio="none"><rect x="1" y="1" width="298" height="20" rx="9" fill="${POP}" stroke="${I}" stroke-width="1.6"/><text x="16" y="15" font-size="10.5" font-weight="700" fill="currentColor">9:41</text><rect x="248" y="7" width="16" height="8" rx="2" fill="none" stroke="${M}"/><rect x="250" y="9" width="9" height="4" rx="1" fill="currentColor"/><path d="M266 9.5v3" stroke="${M}"/>${[0,1,2,3].map(i=>`<rect x="${226+i*4}" y="${13-i*2}" width="2.6" height="${3+i*2}" rx="1" fill="currentColor"/>`).join('')}</svg>`;
case 'fab': return `<svg style="${d}" viewBox="0 0 48 48" preserveAspectRatio="none"><circle cx="24" cy="24" r="22" fill="${B}"/><path d="M24 15v18M15 24h18" stroke="#fff" stroke-width="3" stroke-linecap="round"/></svg>`;
}
return ctrlHTML('button');
}
/* ================================================================
INSERT AT VIEW CENTER (registry "insertControl" equivalent)
================================================================ */
function insertAtCenter(kind, extra={}){
const r = stage.getBoundingClientRect();
const cx = (r.width/2 - S.pan.x)/S.zoom, cy = (r.height/2 - S.pan.y)/S.zoom;
let spec = {w:200,h:140};
if(kind==='ctrl'){ const [w,h] = CTRL_DEF[extra.ctrl]||[180,40]; spec = {w,h}; }
if(kind==='chart') spec = {w:220,h:150};
if(kind==='table') spec = {w:240,h:140};
if(kind==='frame') spec = extra.frame==='phone' ? {w:170,h:320} : extra.frame==='browser' ? {w:340,h:230} : {w:320,h:220};
const el = addEl({kind, x:cx-spec.w/2, y:cy-spec.h/2, ...spec, ...extra});
S.selId = el.id; render();
toast((extra.ctrl||extra.chart||extra.frame||kind)+' added');
}
/* ================================================================
TOOLS
================================================================ */
const DRAG_TOOLS = ['rect','ellipse','diamond','triangle','fragment'];
const CONN_TOOLS = ['arrow','line','polyline'];
const PLACE_TOOLS= ['text','sticky','image','icons','stepbadge','entity','lifeline'];
function toolKey(){ return S.tool==='connector' ? S.conn : S.tool; }
function setTool(t, opts={}){
if(opts.conn) S.conn = opts.conn;
if(opts.g) S.gshape = opts.g;
S.tool = t;
const MORE = ['diamond','triangle','stepbadge','entity','lifeline','fragment','gshape'];
document.querySelectorAll('.tool-btn').forEach(b => {
const k = b.dataset.tool; // undefined on popup buttons
let on = false;
if(S.tool==='connector' || CONN_TOOLS.includes(S.tool)) on = (b.id==='connectorBtn' || b.id==='connectorBtnM');
else if(MORE.includes(S.tool)) on = (b.id==='moreShapesBtn' || b.id==='moreShapesBtnM');
else on = (k && k===S.tool);
b.classList.toggle('on', !!on);
});
stage.classList.toggle('tool-hand', S.tool==='hand');
S.selId = null; render();
}
document.querySelectorAll('.tool-btn[data-tool]').forEach(b => {
b.addEventListener('click', () => {
if(b.dataset.pop) return; // popup buttons handled separately
S.shape = 'rect';
setTool(b.dataset.tool);
});
});
$('connectorBtn').addEventListener('click', e => { setTool('connector'); togglePop('popConnect', e.currentTarget); });
$('connectorBtnM').addEventListener('click', e => { setTool('connector'); togglePop('popConnectM', e.currentTarget, true); });
$('moreShapesBtn').addEventListener('click', e => togglePop('popShapes', e.currentTarget));
$('moreShapesBtnM').addEventListener('click', e => togglePop('popShapesM', e.currentTarget, true));
document.querySelectorAll('[data-shape]').forEach(b => b.addEventListener('click', () => {
const k = b.dataset.shape;
closePops();
if(k==='stepbadge'||k==='entity'||k==='lifeline') setTool(k);
else setTool(k==='fragment'?'fragment':k);
}));
document.querySelectorAll('[data-conn]').forEach(b => b.addEventListener('click', () => {
closePops(); setTool('connector',{conn:b.dataset.conn});
}));
$('lockBtn').addEventListener('click', () => {
S.lock=!S.lock; $('lockBtn').classList.toggle('on',S.lock);
toast(S.lock?'Tool locked — keeps active':'Tool unlocked');
});
/* phone flyouts: shapes grid is cols-5 and PREPENDS Ellipse on the phone
(desktop rail already has a dedicated Ellipse button) */
const shapeWire = p => p.querySelectorAll('[data-shape]').forEach(b => b.addEventListener('click', () => {
closePops();
const k = b.dataset.shape;
if(k==='stepbadge'||k==='entity'||k==='lifeline') setTool(k);
else setTool(k);
}));
$('popShapesM').innerHTML =
`<div class="tool-grid cols5">
<button class="icon-btn" data-shape="ellipse"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><ellipse cx="12" cy="12" rx="8" ry="6"/></svg></button>
${$('popShapes').querySelector('.tool-grid').innerHTML}
</div>`;
$('popConnectM').innerHTML = $('popConnect').innerHTML;
shapeWire($('popShapesM'));
$('popConnectM').querySelectorAll('[data-conn]').forEach(b => b.addEventListener('click', () => { closePops(); setTool('connector',{conn:b.dataset.conn}); }));
/* ================================================================
CANVAS INTERACTION
================================================================ */
let drag = null;
function toWorld(e){
const r = stage.getBoundingClientRect();
return { x:(e.clientX-r.left - S.pan.x)/S.zoom, y:(e.clientY-r.top - S.pan.y)/S.zoom };
}
stage.addEventListener('pointerdown', e => {
closeAllMenus();
if(e.button===2 || S.tool==='hand'){ // pan
drag = {mode:'pan', sx:e.clientX, sy:e.clientY, px:S.pan.x, py:S.pan.y};
stage.classList.add('panning'); stage.setPointerCapture(e.pointerId); return;
}
if(S.viewOnly) return;
const p = toWorld(e);
if(S.tool==='select'){ S.selId=null; render(); drag={mode:'maybe'}; return; }
if(S.tool==='gshape'){
drag = {mode:'create', kind:'gshape', g:S.gshape, x0:p.x, y0:p.y, el:null, pointerId:e.pointerId};
stage.setPointerCapture(e.pointerId);
} else if(S.tool==='highlight'){
drag = {mode:'draw', kind:'highlight', points:[p], pointerId:e.pointerId};
stage.setPointerCapture(e.pointerId);
} else if(S.tool==='laser'){
laserStart(e);
} else if(S.tool==='lasso'){
lassoStart(e,p);
} else if(DRAG_TOOLS.includes(S.tool)){
drag = {mode:'create', kind:S.tool, x0:p.x, y0:p.y, el:null, pointerId:e.pointerId};
stage.setPointerCapture(e.pointerId);
} else if(CONN_TOOLS.includes(S.tool==='connector'?S.conn:S.tool) || S.tool==='draw'){
const kind = S.tool==='draw'?'draw':S.conn;
drag = {mode:'draw', kind, points:[p], pointerId:e.pointerId};
stage.setPointerCapture(e.pointerId);
} else if(PLACE_TOOLS.includes(S.tool)){
const el = placeAt(S.tool, p);
if(!S.lock) setTool('select');
if(S.tool==='select' && el) focusEl(el);
}
});
stage.addEventListener('pointermove', e => {
if(!drag) return;
if(drag.mode==='pan'){
S.pan.x = drag.px + (e.clientX-drag.sx); S.pan.y = drag.py + (e.clientY-drag.sy);
applyView(); return;
}
const p = toWorld(e);
if(drag.mode==='laser'){
drag.pts.push(e.clientX+','+e.clientY);
drag.poly.setAttribute('points', drag.pts.join(' '));
return;
}
if(drag.mode==='lasso'){
drag.pts.push([e.clientX,e.clientY]);
drag.poly.setAttribute('points', drag.pts.map(q=>q.join(',')).join(' '));
return;
}
if(drag.mode==='create'){
const x = Math.min(drag.x0,p.x), y = Math.min(drag.y0,p.y);
const w = Math.max(6,Math.abs(p.x-drag.x0)), h = Math.max(6,Math.abs(p.y-drag.y0));
if(!drag.el){ drag.el = addEl({kind:drag.kind,x,y,w,h,g:drag.g},false); }
else Object.assign(drag.el,{x,y,w,h});
S.selId = drag.el.id; render();
} else if(drag.mode==='draw'){
drag.points.push(p);
if(drag.points.length>2 && !drag.el){
drag.el = addEl({kind:drag.kind,pts:drag.points,...bbox(drag.points)},false);
} else if(drag.el){ Object.assign(drag.el,{pts:drag.points,...bbox(drag.points)}); }
S.selId = drag.el?.id ?? null; render();
}
});
function endDrag(e){
if(!drag) return;
if(drag.mode==='laser'){
const poly = drag.poly;
poly.style.transition = 'opacity .8s ease';
poly.style.opacity = '0';
setTimeout(()=>poly.remove(), 850);
if(!S.lock) setTool('select');
drag=null; return;
}
if(drag.mode==='lasso'){
const sr = stage.getBoundingClientRect();
const inPoly = (px,py,pts)=>{ let c=false;
for(let i=0,j=pts.length-1;i<pts.length;j=i++){
const xi=pts[i][0],yi=pts[i][1],xj=pts[j][0],yj=pts[j][1];
if(((yi>py)!==(yj>py)) && (px < (xj-xi)*(py-yi)/(yj-yi)+xi)) c=!c;
} return c; };
let hit=null;
for(const el of S.els){
const cx = sr.left + S.pan.x + (el.x+el.w/2)*S.zoom;
const cy = sr.top + S.pan.y + (el.y+el.h/2)*S.zoom;
if(drag.pts.length>2 && inPoly(cx,cy,drag.pts)) hit=el;
}
drag.poly.remove();
S.selId = hit ? hit.id : null;
if(!S.lock) setTool('select');
render(); drag=null; return;
}
if((drag.mode==='create'||drag.mode==='draw') && drag.el){ history(); }
if(drag.mode==='create'||drag.mode==='draw'){
if(!S.lock) setTool('select');
S.selId = drag.el?.id ?? S.selId; render();
}
drag=null; stage.classList.remove('panning');
}
stage.addEventListener('pointerup', endDrag);
stage.addEventListener('pointercancel', endDrag);
stage.addEventListener('contextmenu', e => e.preventDefault());
stage.addEventListener('dblclick', e => {
if(S.viewOnly) return;
if(e.target.closest('.el')) return;
const p = toWorld(e);
const el = placeAt('text', p);
setTool('select');
if(el) focusEl(el);
});
stage.addEventListener('wheel', e => {
e.preventDefault();
const d = e.deltaY>0 ? -0.08 : 0.08;
setZoom(S.zoom + d, {x:e.clientX, y:e.clientY});
}, {passive:false});
/* laser pointer (present-mode red trail, fades, no history) */
function laserStart(e){
const fx = $('laserFx');
const poly = document.createElementNS('http://www.w3.org/2000/svg','polyline');
poly.setAttribute('fill','none');
poly.setAttribute('stroke','#f43f5e');
poly.setAttribute('stroke-width','4');
poly.setAttribute('stroke-linecap','round');
poly.setAttribute('stroke-linejoin','round');
poly.setAttribute('opacity','.9');
fx.appendChild(poly);
drag = {mode:'laser', poly, pts:[e.clientX+','+e.clientY], pointerId:e.pointerId};
stage.setPointerCapture(e.pointerId);
}
/* lasso select — free polygon, selects topmost element whose centre falls inside */
function lassoStart(e,p){
const fx = $('laserFx');
const poly = document.createElementNS('http://www.w3.org/2000/svg','polygon');
poly.setAttribute('fill','rgba(77,161,255,.12)');
poly.setAttribute('stroke','var(--canvas-selection)');
poly.setAttribute('stroke-width','1.6');
poly.setAttribute('stroke-dasharray','5 4');
fx.appendChild(poly);
drag = {mode:'lasso', poly, pts:[[e.clientX,e.clientY]], pointerId:e.pointerId};
stage.setPointerCapture(e.pointerId);
}
function bbox(pts){
const xs = pts.map(p=>p.x), ys = pts.map(p=>p.y);
const minx = Math.min(...xs)-4, miny = Math.min(...ys)-4;
return {minx, miny, x:minx, y:miny, w:Math.max(...xs)-minx+8, h:Math.max(...ys)-miny+8};
}
function addEl(el, commit=true){
el.id = uidc++;
S.els.push(el);
if(commit) history(); else render();
return el;
}
function placeAt(kind, p, focus=false){
const P = {
text:{x:p.x-40,y:p.y-16,w:120,h:36,text:''},
sticky:{x:p.x-70,y:p.y-60,w:150,h:130,text:''},
image:{x:p.x-60,y:p.y-45,w:120,h:90,emoji:'🌄'},
icons:{x:p.x-30,y:p.y-30,w:60,h:60,emoji:'⭐'},
stepbadge:{x:p.x-22,y:p.y-22,w:44,h:44,n:S.els.filter(e=>e.kind==='stepbadge').length+1},
entity:{x:p.x-65,y:p.y-50,w:140,h:110,text:'Entity'},
lifeline:{x:p.x-50,y:p.y-60,w:110,h:190,text:'Actor'},
fragment:{x:p.x-90,y:p.y-45,w:190,h:100,text:'loop'},
}[kind];
if(!P) return null;
const el = addEl({kind, ...P});
S.selId = el.id; render();
return el;
}
function focusEl(el){
if(el.kind!=='text' && el.kind!=='sticky') return;
const node = world.querySelector(`[data-id="${el.id}"] .et`);
if(node){ node.focus(); document.execCommand('selectAll', false, null); }
}
function onElPointer(el, ev){
ev.stopPropagation();
if(S.viewOnly) return;
if(S.tool==='eraser'){ S.els = S.els.filter(x=>x.id!==el.id); S.selId=null; history(); if(!S.lock) setTool('select'); toast('Deleted'); return; }
if(S.tool==='select'){
S.selId = el.id; render();
const start = toWorld(ev), ox = el.x, oy = el.y;
const move = e2 => { const p = toWorld(e2); el.x = ox + p.x - start.x; el.y = oy + p.y - start.y;
const n = world.querySelector(`[data-id="${el.id}"]`); if(n){ n.style.left = el.x+'px'; n.style.top = el.y+'px'; } };
const up = () => { window.removeEventListener('pointermove',move); window.removeEventListener('pointerup',up); history(); };
window.addEventListener('pointermove',move); window.addEventListener('pointerup',up);
}
}
/* ================================================================
ZOOM
================================================================ */
function setZoom(z, anchor){
z = Math.min(4, Math.max(.25, z));
if(anchor){
const r = stage.getBoundingClientRect();
const wx = (anchor.x-r.left-S.pan.x)/S.zoom, wy = (anchor.y-r.top-S.pan.y)/S.zoom;
S.pan.x = anchor.x-r.left - wx*z; S.pan.y = anchor.y-r.top - wy*z;
}
S.zoom = z; applyView();
}
$('zoomIn').onclick = () => setZoom(S.zoom+.1);
$('zoomOut').onclick = () => setZoom(S.zoom-.1);
$('zoomPct').onclick = () => { S.zoom=1; S.pan={x:80,y:80}; applyView(); toast('Zoom reset to 100%'); };
$('zoomFit').onclick = zoomToFit;
function zoomToFit(){
if(!S.els.length){ S.zoom=1; S.pan={x:80,y:80}; applyView(); return; }
const xs = S.els.map(e=>e.x), ys = S.els.map(e=>e.y);
const x2 = S.els.map(e=>e.x+e.w), y2 = S.els.map(e=>e.y+e.h);
const bx0=Math.min(...xs), by0=Math.min(...ys), bw=Math.max(...x2)-bx0, bh=Math.max(...y2)-by0;
const pad=90, vw=innerWidth-pad*2, vh=innerHeight-pad*2;
const z = Math.min(1.6, Math.max(.25, Math.min(vw/Math.max(bw,60), vh/Math.max(bh,60))));
S.zoom = z;
S.pan.x = pad + (vw-bw*z)/2 - bx0*z;
S.pan.y = pad + (vh-bh*z)/2 - by0*z;
applyView(); toast('Zoomed to fit');
}
/* ================================================================
UNDO / REDO
================================================================ */
function undo(){
if(!S.past.length) return;
S.future.push(JSON.stringify(S.els));
S.els = JSON.parse(S.past.pop()); S.selId=null; render();
}
function redo(){
if(!S.future.length) return;
S.past.push(JSON.stringify(S.els));
S.els = JSON.parse(S.future.pop()); S.selId=null; render();
}
$('undoBtn').onclick = undo; $('redoBtn').onclick = redo;
/* ================================================================
MENUS / POPUPS
================================================================ */
let openMenu = null, openPop = null;
function closeAllMenus(){ closeMenu(); closePops(); closeDots(); }
function closeMenu(){ $('mainMenu').hidden = true; $('subExport').hidden = true; $('subView').hidden = true; openMenu=null; }
function closePops(){ ['popShapes','popConnect','popShapesM','popConnectM'].forEach(id => $(id).hidden = true); openPop=null; hideTip(); }
function closeDots(){ $('dotsMenu').hidden = true; }
$('hamBtn').addEventListener('click', e => {
e.stopPropagation();
closePops(); $('dotsMenu').hidden = true;
$('mainMenu').hidden = !$('mainMenu').hidden;
});
document.querySelectorAll('[data-sub]').forEach(b => b.addEventListener('click', e => {
e.stopPropagation();
const t = b.dataset.sub==='export' ? $('subExport') : $('subView');
const willOpen = t.hidden;
$('subExport').hidden = true; $('subView').hidden = true;
t.hidden = !willOpen;
}));
$('dotsBtn').addEventListener('click', e => {
e.stopPropagation(); closeMenu(); closePops();
$('dotsMenu').hidden = !$('dotsMenu').hidden;
});
function togglePop(id, btn, up=false){
const p = $(id), willOpen = p.hidden;
closePops(); closeMenu(); $('dotsMenu').hidden = true;
if(willOpen){
const r = btn.getBoundingClientRect();
p.hidden = false;
if(up){ p.style.left = Math.min(r.left, innerWidth - p.offsetWidth - 8)+'px'; p.style.top = (r.top - p.offsetHeight - 10)+'px'; }
else { p.style.left = (r.right + 10)+'px'; p.style.top = Math.min(r.top, innerHeight - p.offsetHeight - 12)+'px'; }
openPop = id;
}
}
document.addEventListener('pointerdown', e => {
if(!e.target.closest('#mainMenu,#hamBtn,.sub-fly')) closeMenu();
if(!e.target.closest('#dotsMenu,#dotsBtn')) $('dotsMenu').hidden = true;
if(!e.target.closest('#popShapes,#popConnect,#popShapesM,#popConnectM,#moreShapesBtn,#connectorBtn,#moreShapesBtnM,#connectorBtnM')) closePops();
if(dl.open && !e.target.closest('#drawer,#allToolsBtn,#allToolsBtnM')) closeDrawer();
});
/* menu actions */
document.querySelectorAll('#mainMenu [data-act], #dotsMenu [data-act]').forEach(b => b.addEventListener('click', () => {
const a = b.dataset.act; closeAllMenus();
({ 'new':() => { S.els=[]; S.selId=null; history(); toast('New canvas'); $('docName').textContent='Untitled canvas'; },
'canvases':() => toast('My canvases — sign in to sync'),
'open':() => toast('Open… (.koboyo / .json)'),
'import':() => toast('Import — drop a file anywhere'),
'save':() => toast('Saved on this device ✓'),
'expng':() => toast('Exported as PNG ✓'),
'exsvg':() => toast('Exported as SVG ✓'),
'logout':() => toast('Logged out'),
'undo':undo, 'redo':redo, 'fit':zoomToFit,
'ai':() => openPanel('panelAI'), 'slides':() => openPanel('panelSlides'), 'code':() => openPanel('panelCode'),
})[a]?.();
}));
document.querySelectorAll('#mainMenu [data-toggle]').forEach(b => b.addEventListener('click', e => {
e.stopPropagation();
const t = b.dataset.toggle;
if(t==='dark') setDark(!S.dark);
if(t==='zen') setZen(!S.zen);
if(t==='viewonly') setViewOnly(!S.viewOnly);
}));
function setDark(v){
S.dark=v; document.documentElement.classList.toggle('dark',v);
$('swDark').classList.toggle('on',v); render(); applyView();
toast(v?'Dark mode':'Light mode');
}
function setZen(v){ S.zen=v; document.body.classList.toggle('zen',v); $('swZen').classList.toggle('on',v); if(v) closeAllMenus(); }
function setViewOnly(v){ S.viewOnly=v; document.body.classList.toggle('view-only',v); $('swView').classList.toggle('on',v); toast(v?'View-only mode':'Editing enabled'); }
$('zenExit').onclick = () => setZen(false);
/* ================================================================
SIDE PANELS
================================================================ */
const PANELS = ['panelSlides','panelCode','panelAI','panelComments'];
function openPanel(id){
const wasHidden = $(id).hidden;
PANELS.forEach(p => $(p).hidden = true);
$(id).hidden = !wasHidden;
$('slidesBtn').classList.toggle('on', !$('panelSlides').hidden);
$('codeBtn').classList.toggle('on', !$('panelCode').hidden);
$('aiBtn').classList.toggle('on', !$('panelAI').hidden);
$('commentsBtn').classList.toggle('on', !$('panelComments').hidden);
}
$('slidesBtn').onclick = () => openPanel('panelSlides');
$('codeBtn').onclick = () => openPanel('panelCode');
$('aiBtn').onclick = () => openPanel('panelAI');
$('commentsBtn').onclick = () => openPanel('panelComments');
document.querySelectorAll('[data-closepanel]').forEach(b => b.onclick = () => { b.closest('.side-panel').hidden = true;
['slidesBtn','codeBtn','aiBtn','commentsBtn'].forEach(i => $(i).classList.remove('on')); });
/* misc chrome */
$('loginBtn').onclick = () => toast('Log in to save & sync canvases');
$('shareBtn').onclick = () => toast('Share link copied ✓');
$('docName').addEventListener('keydown', e => { if(e.key==='Enter'){ e.preventDefault(); $('docName').blur(); toast('Renamed ✓'); }});
function toast(m){ const t=$('toast'); t.textContent=m; t.classList.add('show'); clearTimeout(t._t); t._t=setTimeout(()=>t.classList.remove('show'),1900); }
document.querySelectorAll('[data-toast]').forEach(b => b.addEventListener('click', () => toast(b.dataset.toast)));
/* ================================================================
ALL-TOOLS DRAWER — registry mirrors koboyo's tools bundle:
Basic · AI · Shapes · Connectors · Text & media ·
Charts & data · Frames · UI controls, with persisted Recents.
================================================================ */
const ICO = inner => `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`;
const TOOL_ICONS = {
select:ICO('<path d="M5 3.5 18.5 12l-6.2 1.2L9 19.5 5 3.5Z"/>'),
hand:ICO('<path d="M8 11V5.5a1.5 1.5 0 0 1 3 0V11m0-6.5v-1a1.5 1.5 0 0 1 3 0V11m0-5.5a1.5 1.5 0 0 1 3 0v7m0-3a1.5 1.5 0 0 1 3 0v5c0 3.5-2.5 6.5-6.5 6.5-3 0-4.6-1.2-6.3-3.6L4.6 13c-.7-1-.2-2.2.8-2.5.7-.2 1.5 0 2 .6L8 12.6"/>'),
lasso:ICO('<path d="M6.5 5.5a8.5 6.5 0 1 0 .5 13" stroke-dasharray="3.5 3"/><path d="m15 18 3 3m5-5-3 3 3 3"/>'),
eraser:ICO('<path d="m7 20-3.3-3.3a2 2 0 0 1 0-2.8L13 4.6a2 2 0 0 1 2.8 0l3.6 3.6a2 2 0 0 1 0 2.8L12 18.4M7 20h13M7 20l4-4"/>'),
laser:ICO('<circle cx="7" cy="17" r="2.2" fill="currentColor" stroke="none"/><path d="M7 17 20 4"/>'),
arrow:ICO('<path d="M5 19 17 7m0 0h-6m6 0v6"/>'),
line:ICO('<path d="M5 19 19 5"/>'),
polyline:ICO('<path d="M4 19 10 9l4 6 6-10"/>'),
text:ICO('<path d="M5 6.5V5h14v1.5M12 5v14m-3 0h6"/>'),
sticky:ICO('<path d="M5 4h14v11l-5 5H5V4Z"/><path d="M19 15h-5v5"/>'),
draw:ICO('<path d="m14.5 6 3.5 3.5L8.5 19H5v-3.5L14.5 6Z"/><path d="m13 7.5 3.5 3.5M5 19l2.5-2.5"/>'),
highlight:ICO('<path d="m9 15 7.5-7.5a1.8 1.8 0 0 1 2.5 0l1.5 1.5a1.8 1.8 0 0 1 0 2.5L13 19l-5.5 1.5L9 15Z"/><path d="M4.5 21.5h6"/>'),
image:ICO('<rect x="4" y="5" width="16" height="14" rx="2.5"/><circle cx="9" cy="10" r="1.6"/><path d="m5.5 17 4.5-4 3 3 2.5-2 3 3"/>'),
icon:ICO('<path d="m12 3 2.1 4.9 5.3.5-4 3.6 1.2 5.2-4.6-2.7-4.6 2.7 1.2-5.2-4-3.6 5.3-.5L12 3Z"/>'),
stepbadge:ICO('<circle cx="12" cy="12" r="8.5"/><path d="M10 9.2 12 8v8" stroke-width="1.9"/>'),
entity:ICO('<rect x="5" y="4" width="14" height="16" rx="2"/><path d="M5 9.5h14M5 14.5h14"/>'),
lifeline:ICO('<rect x="7" y="4" width="10" height="5" rx="1.5"/><path d="M12 11v9" stroke-dasharray="3 3"/>'),
fragment:ICO('<rect x="4" y="6" width="16" height="12" rx="1.5"/><path d="M4 6v5h7V4.5"/>'),
table:ICO('<rect x="3.5" y="4" width="17" height="16" rx="2"/><path d="M3.5 9.5h17M3.5 14.5h17M12 9.5V20"/>'),
'chart-bar':ICO('<path d="M5 19v-6M10 19V5M15 19v-4M20 19V9"/>'),
'chart-line':ICO('<path d="M3 17l5-6 4 3 6-8 3 3"/>'),
'chart-area':ICO('<path d="M3 16l4.5-6 4 3.5 5.5-7.5 4 4.5V20H3Z"/>'),
'chart-pie':ICO('<circle cx="11" cy="13" r="8"/><path d="M11 5v8h8"/>'),
'chart-donut':ICO('<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3.5"/>'),
'chart-scatter':ICO('<circle cx="6" cy="16" r="1.6" fill="currentColor" stroke="none"/><circle cx="11" cy="9" r="1.6" fill="currentColor" stroke="none"/><circle cx="16" cy="14" r="1.6" fill="currentColor" stroke="none"/><circle cx="19" cy="7" r="1.6" fill="currentColor" stroke="none"/>'),
'chart-radar':ICO('<polygon points="12 3 20 9 17 19 7 19 4 9"/><path d="M12 8.5l3.5 2.8-1.3 4.2h-4.4l-1.3-4.2Z"/>'),
'chart-quadrant':ICO('<path d="M12 3v18M3 12h18"/><circle cx="8" cy="8" r="1.8" fill="currentColor" stroke="none"/><circle cx="16" cy="15" r="1.8" fill="currentColor" stroke="none"/>'),
'frame-simple':ICO('<rect x="3.5" y="4" width="17" height="16" rx="2"/><path d="M3.5 4v5h8V2.5"/>'),
'frame-browser':ICO('<rect x="3" y="4.5" width="18" height="15" rx="2.5"/><path d="M3 9.5h18"/><circle cx="7" cy="7" r=".8" fill="currentColor" stroke="none"/><circle cx="10.5" cy="7" r=".8" fill="currentColor" stroke="none"/>'),
'frame-phone':ICO('<rect x="7" y="2.5" width="10" height="19" rx="2.5"/><path d="M10.5 5h3"/>'),
'ask-ai':ICO('<path d="M14.6 4.2 15.7 8l3.8 1.1-3.8 1.1-1.1 3.8-1.1-3.8L9.7 9.1 13.5 8l1.1-3.8Z"/><path d="M18.7 14.5l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7.7-2Z"/>'),
};
const GICON = {
'Basic':TOOL_ICONS.select,'AI':TOOL_ICONS['ask-ai'],'Connectors':TOOL_ICONS.arrow,
'Text & media':TOOL_ICONS.text,'UI controls':ICO('<rect x="3" y="8" width="18" height="9" rx="4.5"/><circle cx="8.5" cy="12.5" r="2"/>'),
'Charts & data':TOOL_ICONS['chart-bar'],'Frames':TOOL_ICONS['frame-simple'],
};
function iconFor(t){
if(t.id==='rectangle'||t.id==='ellipse')
return ICO(SHAPES24[t.id==='rectangle'?'rectangle':'ellipse']);
if(t.id.startsWith('shape-')) return ICO(SHAPES24[t.id.slice(6)]||SHAPES24.rectangle);
return TOOL_ICONS[t.id] || GICON[t.group] || TOOL_ICONS.select;
}
const TOOLS = [];
const RT = (id,label,group,hint,kw,run) => TOOLS.push({id,label,group,hint,kw,run});
RT('select','Select','Basic','V','',()=>setTool('select'));
RT('hand','Hand','Basic','H','pan',()=>setTool('hand'));
RT('lasso','Lasso select','Basic','Q','freehand select',()=>setTool('lasso'));
RT('eraser','Eraser','Basic','E','',()=>setTool('eraser'));
RT('laser','Laser pointer','Basic','K','present',()=>setTool('laser'));
RT('ask-ai','Ask AI','AI','','assistant chat sparkles help',()=>openPanel('panelAI'));
RT('ai-diagram','Generate diagram with AI','AI','','assistant architecture flowchart create',()=>{openPanel('panelAI');toast('Prompt: “Draw a diagram of ”')});
RT('ai-deck','Generate slides with AI','AI','','assistant deck presentation create',()=>{openPanel('panelAI');toast('Prompt: “Create a short deck about ”')});
RT('ai-image','Generate image with AI','AI','','assistant picture photo create',()=>{openPanel('panelAI');toast('Prompt: “Generate an image of ”')});
RT('rectangle','Rectangle','Shapes','R','box square',()=>setTool('rect'));
RT('ellipse','Ellipse','Shapes','O','circle oval',()=>setTool('ellipse'));
SHAPE_KINDS.forEach(([k,label])=>RT('shape-'+k,label,'Shapes','','shape',()=>setTool('gshape',{g:k})));
RT('stepbadge','Step badge','Shapes','B','number step',()=>setTool('stepbadge'));
RT('entity','Class / entity','Shapes','','erd uml class table attributes fields',()=>setTool('entity'));
RT('lifeline','Lifeline','Shapes','','sequence uml participant',()=>setTool('lifeline'));
RT('fragment','Fragment','Shapes','','sequence uml combined opt alt loop par frame container',()=>setTool('fragment'));
RT('arrow','Arrow','Connectors','A','connector',()=>setTool('connector',{conn:'arrow'}));
RT('line','Line','Connectors','L','connector',()=>setTool('connector',{conn:'line'}));
RT('polyline','Multi-point line','Connectors','C','connector elbow path',()=>setTool('connector',{conn:'polyline'}));
RT('text','Text','Text & media','T','',()=>setTool('text'));
RT('sticky','Sticky note','Text & media','N','note',()=>setTool('sticky'));
RT('draw','Draw','Text & media','P','pen pencil freehand',()=>setTool('draw'));
RT('highlight','Highlighter','Text & media','⇧P','marker highlight pen freehand',()=>setTool('highlight'));
RT('image','Image','Text & media','I','photo picture',()=>setTool('image'));
RT('icon','Icons','Text & media','S','emoji sticker symbol',()=>setTool('icons'));
RT('table','Table','Charts & data','','grid data',()=>insertAtCenter('table'));
[['bar','Bar chart'],['line','Line chart'],['area','Area chart'],['scatter','Scatter chart'],
['pie','Pie chart'],['donut','Donut chart'],['radar','Radar chart'],['quadrant','Quadrant chart']]
.forEach(([k,l])=>RT('chart-'+k,l,'Charts & data','','chart graph data',()=>insertAtCenter('chart',{chart:k})));
RT('frame-simple','Frame','Frames','F','frame container device mockup',()=>insertAtCenter('frame',{frame:'simple'}));
RT('frame-browser','Browser window','Frames','','frame container device mockup web',()=>insertAtCenter('frame',{frame:'browser'}));
RT('frame-phone','Phone frame','Frames','','frame container device mockup mobile',()=>insertAtCenter('frame',{frame:'phone'}));
[['button','Button'],['input','Input'],['search','Search field'],['checkbox','Checkbox'],['radio','Radio'],
['toggle','Toggle'],['dropdown','Dropdown'],['slider','Slider'],['navbar','Navbar'],['tabs','Tabs'],
['image','Image placeholder'],['avatar','Avatar'],['heading','Heading'],['paragraph','Paragraph'],
['divider','Divider'],['link','Link'],['textarea','Text area'],['stepper','Number stepper'],['badge','Badge'],
['progress','Progress bar'],['breadcrumbs','Breadcrumbs'],['pagination','Pagination'],['rating','Rating'],
['card','Card'],['list','List'],['alert','Alert'],['modal','Modal dialog'],['menu','Menu'],['tooltip','Tooltip'],
['accordion','Accordion'],['datepicker','Date picker'],['sidebar','Sidebar'],['scrollbar','Scrollbar'],
['segmented','Segmented control'],['tabbar','Bottom tab bar'],['statusbar','Status bar'],['fab','Floating button']]
.forEach(([k,l])=>RT('ctrl-'+k,l,'UI controls','','wireframe mockup ui widget',()=>insertAtCenter('ctrl',{ctrl:k})));
/* recents — persisted, max 8 (mirrors use-recent-list) */
const RKEY = 'koboyo:recent-tools';
function getRecent(){ try{ return JSON.parse(localStorage.getItem(RKEY)||'[]').filter(x=>typeof x==='string').slice(0,8); }catch{ return []; } }
function rememberRecent(id){
try{ localStorage.setItem(RKEY, JSON.stringify([id,...getRecent().filter(x=>x!==id)].slice(0,8))); }catch{}
}
/* drawer open/close + anchored positioning + keyboard nav */
let dl = {open:false, items:[], idx:0, q:''};
const isPhoneMode = () => matchMedia('(max-width:639px)').matches;
function openDrawer(){
$('drawer').hidden = false;
dl.open = true; dl.q = ''; dl.idx = 0;
$('drawerSearch').value = '';
buildDrawerList(); positionDrawer();
addEventListener('resize', positionDrawer);
if(!matchMedia('(pointer:coarse)').matches) $('drawerSearch').focus();
}
function closeDrawer(){
$('drawer').hidden = true; dl.open = false;
removeEventListener('resize', positionDrawer);
}
function positionDrawer(){
if(!dl.open) return;
const d = $('drawer');
if(isPhoneMode()){
const bar = $('phoneBar').getBoundingClientRect();
d.style.left = Math.max(8, bar.left)+'px'; d.style.top = '';
d.style.bottom = (innerHeight - bar.top + 8)+'px';
d.style.maxHeight = '55dvh';
} else {
const rail = $('toolRail').getBoundingClientRect();
d.style.left = (rail.right + 8)+'px';
d.style.top = Math.max(56, Math.min(rail.top, innerHeight - 360))+'px';
d.style.bottom = '';
d.style.maxHeight = 'calc(100dvh - 72px)';
}
}
function buildDrawerList(){
const q = dl.q.trim().toLowerCase();
let items;
if(q){
items = TOOLS.filter(t => (t.label+' '+t.group+' '+(t.kw||'')).toLowerCase().includes(q));
} else {
const byId = Object.fromEntries(TOOLS.map(t=>[t.id,t]));
items = [...getRecent().map(id=>byId[id]).filter(Boolean).map(t=>({...t,group:'Recent'})), ...TOOLS];
}
dl.items = items;
dl.idx = Math.min(dl.idx, Math.max(0, items.length-1));
const list = $('drawerList'); list.innerHTML = '';
if(!items.length){ list.innerHTML = '<div class="cmd-empty">No matching tools</div>'; return; }
items.forEach((t,i) => {
if(!q && (i===0 || items[i-1].group !== t.group)){
const h = document.createElement('div'); h.className = 'cmd-group'; h.textContent = t.group;
list.appendChild(h);
}
const b = document.createElement('button');
b.className = 'cmd-item'; b.dataset.testid = 'tool-'+t.id;
b.innerHTML = `<span class="ci-ic">${iconFor(t)}</span><span class="ci-lbl">${t.label}</span>${t.hint?`<span class="ci-hint">${t.hint}</span>`:''}`;
b.addEventListener('pointerenter', () => { const idx = [...list.querySelectorAll('.cmd-item')].indexOf(b); dl.idx = idx; syncActive(); });
b.addEventListener('click', () => runItem(t));
list.appendChild(b);
});
syncActive();
}
function syncActive(){
const btns = [...$('drawerList').querySelectorAll('.cmd-item')];
btns.forEach((n,i) => n.classList.toggle('active', i === dl.idx));
btns[dl.idx]?.scrollIntoView({block:'nearest'});
}
function runItem(t){ closeDrawer(); t.run(); rememberRecent(t.id); }
$('drawerSearch').addEventListener('input', e => { dl.q = e.target.value; dl.idx = 0; buildDrawerList(); });
$('drawerSearch').addEventListener('keydown', e => {
if(e.key==='ArrowDown'){ e.preventDefault(); dl.idx = Math.min(dl.idx+1, dl.items.length-1); syncActive(); }
else if(e.key==='ArrowUp'){ e.preventDefault(); dl.idx = Math.max(dl.idx-1, 0); syncActive(); }
else if(e.key==='Enter'){ e.preventDefault(); const t = dl.items[dl.idx]; if(t) runItem(t); }
});
$('allToolsBtn').onclick = () => dl.open ? closeDrawer() : openDrawer();
$('allToolsBtnM').onclick = () => dl.open ? closeDrawer() : openDrawer();
/* ================================================================
SHORTCUTS DIALOG
================================================================ */
const SC_GROUPS = [
['Tools',[['/','Search all tools'],['V','Select'],['Q','Lasso select'],['H','Hand (pan)'],['R','Rectangle'],['O','Ellipse'],['A','Arrow'],['L','Line'],['C','Multi-point line'],['P','Draw'],['⇧P','Highlighter'],['T','Text'],['N','Sticky note'],['F','Frame'],['I','Image'],['S','Icons'],['G','Chart'],['B','Step badge'],['E','Eraser'],['⇧C','Comment'],['⌘.','Keep tool active']]],
['Edit',[['⌘C','Copy'],['⌘X','Cut'],['⌘V','Paste'],['⌘D','Duplicate'],['⌘A','Select all'],['⌫','Delete'],['⌘Z','Undo'],['⌘⇧Z','Redo'],['⌘G','Group'],['⌘⇧G','Ungroup'],['⏎','Edit shape'],['Tab','Connected shape']]],
['View',[['⌘+','Zoom in'],['⌘−','Zoom out'],['⌘0','Reset zoom'],['⌘1','Zoom to fit'],['⌘F','Find on canvas'],['⌘K','Command palette'],['⌥Z','Zen mode'],['Right-drag','Pan the canvas']]],
];
$('scGrid').innerHTML = SC_GROUPS.map(([g,rows]) =>
`<div class="sc-group"><h3>${g}</h3>${rows.map(([k,l]) => `<div class="sc-row"><span>${l}</span><kbd>${k}</kbd></div>`).join('')}</div>`
).join('');
function openShortcuts(){ $('scVeil').hidden=false; $('scDialog').hidden=false; }
function closeShortcuts(){ $('scVeil').hidden=true; $('scDialog').hidden=true; }
$('helpFab').onclick = openShortcuts;
$('scVeil').onclick = closeShortcuts;
/* ================================================================
TOOLTIPS
================================================================ */
const tipEl = $('tooltip');
let tipTimer = null;
function showTip(html, x, y){
clearTimeout(tipTimer);
tipEl.innerHTML = html; tipEl.style.opacity = 1;
const r = tipEl.getBoundingClientRect();
tipEl.style.left = Math.min(x, innerWidth - r.width - 8)+'px';
tipEl.style.top = Math.max(8, y)+'px';
}
function hideTip(){ clearTimeout(tipTimer); tipEl.style.opacity = 0; }
document.querySelectorAll('[data-tip]').forEach(b => {
b.addEventListener('pointerenter', e => {
if(matchMedia('(pointer:coarse)').matches || !b.dataset.tip) return;
tipTimer = setTimeout(() => {
const [label,kbd] = b.dataset.tip.split('|');
const r = b.getBoundingClientRect();
showTip(label + (kbd?` <kbd>${kbd}</kbd>`:''), r.right + 10, r.top + r.height/2 - 13);
}, 380);
});
b.addEventListener('pointerleave', hideTip);
b.addEventListener('pointerdown', hideTip);
});
/* ================================================================
KEYBOARD
================================================================ */
document.addEventListener('keydown', e => {
const typing = /^(INPUT|TEXTAREA)$/.test(document.activeElement?.tagName) || document.activeElement?.isContentEditable;
const mod = e.metaKey || e.ctrlKey;
if(e.key==='Escape'){
if(!$('drawer').hidden) return closeDrawer();
if(!$('scDialog').hidden) return closeShortcuts();
closeAllMenus();
if(S.zen) return setZen(false);
S.selId=null; render(); return;
}
if(typing) return;
if(mod && e.key.toLowerCase()==='z'){ e.preventDefault(); e.shiftKey?redo():undo(); return; }
if(mod && e.key.toLowerCase()==='s'){ e.preventDefault(); toast('Saved on this device ✓'); return; }
if(mod && e.key==='.'){ e.preventDefault(); $('lockBtn').click(); return; }
if(mod) return;
if(e.key==='Alt' ) return;
const k = e.key.toLowerCase();
if(e.altKey && k==='z'){ e.preventDefault(); setZen(!S.zen); return; }
if(e.key==='/'){ e.preventDefault(); dl.open ? closeDrawer() : openDrawer(); return; }
if(e.key==='?'){ openShortcuts(); return; }
if(e.key==='Delete'||e.key==='Backspace'){
if(S.selId!=null && !S.viewOnly){ S.els = S.els.filter(x=>x.id!==S.selId); S.selId=null; history(); } return;
}
if(k==='p' && e.shiftKey) return setTool('highlight');
if(k==='q') return setTool('lasso');
if(k==='k') return setTool('laser');
const map = {v:'select',h:'hand',r:'rect',o:'ellipse',t:'text',n:'sticky',p:'draw',i:'image',s:'icons',e:'eraser',b:'stepbadge'};
if(S.viewOnly && !['v','h'].includes(k)) return;
if(map[k]) return setTool(map[k]);
if(k==='a') return setTool('connector',{conn:'arrow'});
if(k==='l') return setTool('connector',{conn:'line'});
if(k==='c') return setTool('connector',{conn:'polyline'});
});
/* ================================================================
RESPONSIVE HYGIENE — when the 640px split is crossed, close any
transient UI so a desktop flyout can't linger in phone mode
================================================================ */
matchMedia('(max-width:639px)').addEventListener('change', () => {
closeAllMenus();
hideTip();
});
/* ================================================================
BOOT — demo sketch so panels/tools have context, like the site's
first-run: keep canvas empty to show the onboarding hints.
================================================================ */
setTool('select');
applyView();
render();
</script>
</body>
</html>