diff --git a/CLAUDE.md b/CLAUDE.md index 8576ddb..50f7576 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,12 +157,14 @@ for whole-diagram swaps). ## Conventions -- **CSS design tokens** in `css/style.css` `:root` (`--bg`, `--panel`, `--panel2`, - `--border`, `--text`, `--text-dim`, `--accent #4a9eff`, `--red`, `--green`, - `--font`). Style with tokens, not hardcoded hex. Note: `.btn-primary` and the - running Run button use darker shades (`#1565c0` / `#2e7d32`) rather than raw - `--accent`/`--green` to meet WCAG AA contrast against white text — match that when - putting text on a colored fill. +- **CSS design tokens** in `css/style.css` `:root` ("Graphite · Lime" system: + `--bg`, `--canvas`, `--panel`, `--panel2`, `--border`, `--border-soft`, `--text`, + `--text-bright`, `--text-dim`, `--text-faint`, `--accent #b6e94d` lime, + `--accent-ink #14151a` for text on lime, `--accent-tint`, `--red`, `--green`, + `--font` Space Grotesk, `--mono` JetBrains Mono). Style with tokens, not + hardcoded hex. Every numeric value renders in `--mono`. Text on a lime fill is + always `--accent-ink` (dark), never white — that pairing is what passes WCAG AA. + Fonts are vendored in `vendor/fonts/` (no Google Fonts requests at runtime). - **Shared App helpers:** `_faIcon(name)` (Font Awesome ``), `_toast(msg)`, and `_confirmGuard(message, title)` (Promise-based styled confirm — use instead of `confirm()`). diff --git a/css/style.css b/css/style.css index 0499f79..c33bb61 100644 --- a/css/style.css +++ b/css/style.css @@ -1,28 +1,81 @@ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +/* ── Vendored fonts (Graphite · Lime redesign) ───────────────────────────── + Space Grotesk for UI text, JetBrains Mono for every numeric value. */ +@font-face { + font-family: 'Space Grotesk'; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url('../vendor/fonts/space-grotesk-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'Space Grotesk'; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url('../vendor/fonts/space-grotesk-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('../vendor/fonts/jetbrains-mono-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('../vendor/fonts/jetbrains-mono-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + :root { - --bg: #0f1117; - --panel: #141820; - --panel2: #1a1e2e; - --border: #252d45; - --text: #c8cfe0; - --text-dim: #95a3bc; /* ≥ 6.5:1 on --bg for WCAG AA small text */ - --accent: #4a9eff; - --accent2: #ffa726; + --bg: #101114; /* app chrome bg */ + --canvas: #0d0e11; /* canvas + input backgrounds */ + --panel: #16181d; + --panel2: #1d2027; /* raised */ + --border: #2a2e38; + --border-soft: #22252e; /* row dividers */ + --text: #d5d8e0; + --text-bright: #e8ebf2; /* titles */ + --text-dim: #8a90a0; + --text-faint: #6b7180; /* section labels, hints */ + --accent: #b6e94d; /* lime: selection, running, active tool, focus */ + --accent-ink: #14151a; /* text on lime */ + --accent-tint: rgba(182, 233, 77, 0.12); + --accent2: #ffa726; /* resource flow orange */ --red: #ef5350; --green: #4caf50; - --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font: 'Space Grotesk', -apple-system, sans-serif; + --mono: 'JetBrains Mono', monospace; } html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--font); font-size: 13px; overflow: hidden; } +/* Custom chevron on every select: no native-styled controls anywhere. */ +select { + appearance: none; + -webkit-appearance: none; + background-image: url('data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%2210%22%20height=%226%22%3E%3Cpath%20d=%22M1%201l4%204%204-4%22%20fill=%22none%22%20stroke=%22%238a90a0%22%20stroke-width=%221.5%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E'); + background-repeat: no-repeat; + background-position: right 8px center; + padding-right: 24px !important; + cursor: pointer; +} + /* Inline node-label editor: floats over the canvas on double-click. */ .node-label-edit { position: fixed; z-index: 200; transform: translateY(-50%); text-align: center; background: var(--panel); color: var(--text); - border: 1px solid var(--accent); border-radius: 4px; + border: 1px solid var(--accent); border-radius: 6px; padding: 2px 6px; outline: none; font-family: var(--font); box-shadow: 0 4px 16px rgba(0,0,0,0.5); @@ -32,8 +85,8 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil /* ── Topbar ── */ #topbar { - display: flex; align-items: center; gap: 16px; padding: 4px 16px; - min-height: 48px; background: var(--panel); border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: 10px; padding: 4px 12px; + min-height: 56px; background: var(--panel); border-bottom: 1px solid var(--border); flex-shrink: 0; z-index: 10; /* Wrap clusters onto a second row on narrow windows. The page never scrolls (body is overflow:hidden), so anything pushed past the right edge would be @@ -42,59 +95,75 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil } .logo { - display: flex; align-items: center; - margin-right: 4px; user-select: none; flex-shrink: 0; - color: var(--accent); /* drives the mark via currentColor; follows the scheme */ + display: flex; align-items: center; gap: 10px; + margin-right: 2px; user-select: none; flex-shrink: 0; + color: var(--accent); /* drives the mark via currentColor */ } .logo-mark { display: block; } +.logo-word { + font-size: 15px; font-weight: 600; letter-spacing: -0.01em; + color: var(--text-bright); white-space: nowrap; +} -.sim-controls { display: flex; align-items: center; gap: 10px; } -.file-controls { display: flex; gap: 6px; margin-left: auto; align-items: center; } +.sim-controls { display: flex; align-items: center; gap: 8px; } +.file-controls { display: flex; gap: 6px; align-items: center; } -/* Functional clusters read as single units: shared pill background. */ +/* Functional clusters read as single units: shared 40px pill background. */ .sim-cluster, .speed-cluster { - display: flex; align-items: center; gap: 4px; + display: flex; align-items: center; gap: 6px; height: 40px; background: var(--panel2); border: 1px solid var(--border); - border-radius: 7px; padding: 3px; + border-radius: 9px; padding: 0 4px; } -.speed-cluster { padding: 3px 8px; gap: 6px; } -.speed-cluster input[type="range"] { width: 80px; } -#speed-label { font-family: monospace; font-size: 12px; color: var(--text-dim); min-width: 26px; } +.sim-cluster .btn { border-radius: 6px; background: var(--panel); } +.speed-cluster { padding: 0 11px; gap: 9px; } +.speed-cluster input[type="range"] { width: 78px; } +.speed-icon { font-size: 12px; color: var(--text-dim); } +#speed-label { font-family: var(--mono); font-size: 12px; color: var(--text-dim); min-width: 26px; } -/* Run is the primary verb of the app; running state is unmistakable. */ -#btn-run { min-width: 76px; } +/* Run is the primary verb of the app: lime, dark ink, pulsing while running. */ +#btn-run { + min-width: 76px; + background: var(--accent); border-color: var(--accent); color: var(--accent-ink); + font-weight: 600; +} +#btn-run:hover { background: #c4f266; border-color: #c4f266; color: var(--accent-ink); } #btn-run.running { - /* #2e7d32 on white = 5.2:1 — passes WCAG AA */ - background: #2e7d32; border-color: #2e7d32; color: #fff; + background: var(--accent); border-color: var(--accent); color: var(--accent-ink); animation: run-pulse 2s ease-in-out infinite; } @keyframes run-pulse { - 0%, 100% { box-shadow: 0 0 0 0 rgba(76,175,80,0.35); } - 50% { box-shadow: 0 0 0 5px rgba(76,175,80,0); } + 0%, 100% { box-shadow: 0 0 0 0 rgba(182,233,77,0.35); } + 50% { box-shadow: 0 0 0 5px rgba(182,233,77,0); } } /* Vertical separator between topbar groups. */ .tb-sep { width: 1px; height: 22px; background: var(--border); flex-shrink: 0; } #step-counter.tb-chip { - font-family: monospace; font-size: 12px; color: var(--text); + display: inline-flex; align-items: center; justify-content: center; height: 32px; + font-family: var(--mono); font-size: 12px; color: var(--text-bright); background: var(--panel2); border: 1px solid var(--border); - border-radius: 6px; padding: 4px 10px; min-width: 76px; text-align: center; + border-radius: 7px; padding: 0 12px; min-width: 76px; text-align: center; white-space: nowrap; /* growing step numbers must widen the chip, not wrap inside it */ } #sim-status { - font-size: 12px; font-weight: 600; color: var(--green); + display: flex; align-items: center; gap: 7px; + font-size: 12px; font-weight: 600; color: var(--accent); white-space: nowrap; } +#sim-status:not(:empty)::before { + content: ''; width: 7px; height: 7px; border-radius: 50%; + background: var(--accent); box-shadow: 0 0 8px var(--accent); +} /* ── Main layout ── */ #main { display: flex; flex: 1; overflow: hidden; } /* ── Left palette ── */ #palette { - width: 72px; background: var(--panel); border-right: 1px solid var(--border); - display: flex; flex-direction: column; align-items: center; padding: 8px 0; + width: 82px; background: var(--panel); border-right: 1px solid var(--border); + display: flex; flex-direction: column; align-items: center; padding: 10px 0; gap: 2px; flex-shrink: 0; overflow-y: auto; /* Always-visible thin scrollbar: on short windows half the toolbox sits below the fold, and macOS overlay scrollbars give no hint that it scrolls. */ @@ -106,8 +175,8 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil .palette-section + .palette-section { border-top: 1px solid var(--border); } .palette-header { width: 100%; background: none; border: none; cursor: pointer; - color: var(--text-dim); font-size: 10px; text-transform: uppercase; - letter-spacing: 0.5px; padding: 7px 4px 3px; + color: var(--text-faint); font-size: 9px; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.08em; padding: 8px 4px 6px; display: flex; align-items: center; justify-content: center; gap: 4px; } .palette-header:hover { color: var(--text); } @@ -119,30 +188,30 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil .palette-header[aria-expanded="false"]::after { content: '\f0da'; /* fa-caret-right */ } .palette-items { display: flex; flex-direction: column; align-items: center; - gap: 2px; width: 100%; padding-bottom: 4px; + gap: 4px; width: 100%; padding-bottom: 10px; } .palette-header[aria-expanded="false"] + .palette-items { display: none; } [data-tool] { - display: flex; flex-direction: column; align-items: center; gap: 3px; - width: 56px; padding: 7px 4px; border: 1px solid transparent; - border-radius: 6px; background: none; color: var(--text); cursor: pointer; - font-size: 10px; transition: all 0.15s; + display: flex; flex-direction: column; align-items: center; gap: 4px; + width: 62px; padding: 8px 4px; border: 1px solid transparent; + border-radius: 8px; background: none; color: var(--text); cursor: pointer; + font-size: 10px; font-family: var(--font); transition: all 0.15s; } [data-tool]:hover { background: var(--panel2); border-color: var(--border); } -[data-tool].active { background: rgba(74,158,255,0.15); border-color: var(--accent); color: var(--accent); } +[data-tool].active { background: var(--accent-tint); border-color: var(--accent); color: var(--accent); } .tool-icon { - width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; + width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; } .tool-icon svg { width: 26px; height: 26px; } -.tool-icon .fa-solid { font-size: 17px; } +.tool-icon .fa-solid { font-size: 15px; } .pal-kbd { display: inline-block; margin-left: 2px; - font-family: monospace; font-size: 10px; line-height: 1; + font-family: var(--mono); font-size: 10px; line-height: 1; color: var(--text-dim); background: var(--panel2); border: 1px solid var(--border); border-radius: 3px; padding: 1px 3px; vertical-align: middle; @@ -155,10 +224,10 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil /* Minimap overview (bottom-right of the canvas) */ #minimap { - position: absolute; right: 12px; bottom: 12px; - width: 200px; height: 140px; z-index: 6; - background: rgba(10,12,18,0.85); border: 1px solid var(--border); - border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,0.45); + position: absolute; right: 14px; bottom: 14px; + width: 190px; height: 120px; z-index: 6; + background: rgba(13,14,17,0.9); border: 1px solid var(--border); + border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,0.45); overflow: hidden; } #minimap.hidden { display: none; } @@ -172,7 +241,7 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil .wide-input:focus-visible, .var-wide-input:focus-visible, .var-name-input:focus-visible, .var-range-num:focus-visible, .var-delete-btn:focus-visible, .kb-link:focus-visible, .tl-chip:focus-visible, .props-help:focus-visible, -.branch-name:focus-visible, .note-textarea:focus-visible { +.branch-name:focus-visible, .note-textarea:focus-visible, .lib-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } @@ -188,84 +257,94 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil /* ── Right props panel ── */ #props-panel { - width: 280px; background: var(--panel); border-left: 1px solid var(--border); + width: 300px; background: var(--panel); border-left: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; } #props-header { - padding: 12px 16px; border-bottom: 1px solid var(--border); + padding: 14px 18px; border-bottom: 1px solid var(--border); font-size: 11px; font-weight: 600; color: var(--text-dim); - text-transform: uppercase; letter-spacing: 0.8px; + text-transform: uppercase; letter-spacing: 0.07em; } -#props-content { flex: 1; overflow-y: auto; padding: 12px 16px; } +#props-content { flex: 1; overflow-y: auto; padding: 16px 18px; } .props-empty { color: var(--text-dim); font-size: 12px; line-height: 1.5; margin-top: 8px; } /* ── Far-right diagram rail ── */ #diagram-rail { - width: 64px; background: var(--panel); border-left: 1px solid var(--border); - display: flex; flex-direction: column; align-items: center; padding: 8px 0; + width: 70px; background: var(--panel); border-left: 1px solid var(--border); + display: flex; flex-direction: column; align-items: center; padding: 10px 0; gap: 2px; flex-shrink: 0; overflow-y: auto; z-index: 6; } .rail-title { - font-size: 9px; font-weight: 600; color: var(--text-dim); - text-transform: uppercase; letter-spacing: 0.6px; - padding: 2px 0 5px; white-space: nowrap; + font-size: 9px; font-weight: 700; color: var(--text-faint); + text-transform: uppercase; letter-spacing: 0.07em; + padding: 2px 0 8px; white-space: nowrap; } .rail-btn { - display: flex; flex-direction: column; align-items: center; gap: 3px; - width: 56px; padding: 7px 4px; border: 1px solid transparent; - border-radius: 6px; background: none; color: var(--text); cursor: pointer; - font-size: 10px; transition: all 0.15s; + display: flex; flex-direction: column; align-items: center; gap: 4px; + width: 62px; padding: 9px 4px; border: 1px solid transparent; + border-radius: 8px; background: none; color: var(--text-dim); cursor: pointer; + font-size: 9.5px; font-family: var(--font); transition: all 0.15s; } -.rail-btn:hover { background: var(--panel2); border-color: var(--border); } -.rail-btn.active { background: rgba(74,158,255,0.15); border-color: var(--accent); color: var(--accent); } +.rail-btn:hover { background: var(--panel2); border-color: var(--border); color: var(--text); } +.rail-btn.active { background: var(--accent-tint); border-color: var(--accent); color: var(--accent); } .props-title { display: flex; align-items: center; - font-size: 14px; font-weight: 600; color: var(--text); - margin-bottom: 14px; padding-bottom: 8px; border-bottom: 1px solid var(--border); + font-size: 18px; font-weight: 600; color: var(--text-bright); + margin-bottom: 14px; padding-bottom: 10px; border-bottom: 1px solid var(--border); } /* Typed selection header: kind overline + colored dot, then the name large. */ -.props-title-block { margin-bottom: 14px; padding-bottom: 10px; border-bottom: 1px solid var(--border); } -.props-title-block .props-title { margin: 0; padding: 0; border: none; font-size: 15px; } +.props-title-block { margin-bottom: 14px; padding-bottom: 12px; border-bottom: 1px solid var(--border); } +.props-title-block .props-title { margin: 0; padding: 0; border: none; font-size: 18px; } .props-overline { - display: flex; align-items: center; gap: 6px; + display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.7px; color: var(--text-dim); margin-bottom: 4px; + letter-spacing: 0.07em; color: var(--text-dim); margin-bottom: 6px; } .props-dot { - width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; - box-shadow: 0 0 6px rgba(255,255,255,0.12); + width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; + box-shadow: 0 0 8px rgba(255,255,255,0.15); } -/* Labelled section header — the panel reads as an outline, not a wall. */ +/* Labelled section header: the panel reads as an outline, not a wall. */ .props-sec { - font-size: 11px; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.7px; color: var(--text-dim); - margin: 18px 0 8px; padding-top: 10px; border-top: 1px solid var(--border); + font-size: 10px; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.08em; color: var(--text-faint); + margin: 18px 0 10px; padding-top: 12px; border-top: 1px solid var(--border-soft); } .props-sec:first-child { margin-top: 0; padding-top: 0; border-top: none; } .props-info { color: var(--text-dim); font-size: 12px; margin-bottom: 12px; line-height: 1.45; } +/* Fields read as label-over-input columns (4a); rows that pair a label with a + small inline control (checkboxes, condition builders) stay horizontal. */ .prop-row { - display: flex; align-items: center; gap: 8px; margin-bottom: 10px; + display: flex; flex-direction: column; align-items: stretch; gap: 5px; margin-bottom: 10px; } -.prop-row label { - flex: 0 0 90px; font-size: 11px; color: var(--text-dim); +.prop-row > label { + flex: none; font-size: 11px; color: var(--text-dim); text-transform: capitalize; } +.prop-row > label:empty { display: none; } +.prop-row:has(> input[type="checkbox"]), +.cond-details .prop-row { + flex-direction: row; align-items: center; gap: 8px; +} +.prop-row:has(> input[type="checkbox"]) > label { flex: 1; } +.cond-details .prop-row > label { flex: 0 0 auto; } .prop-row input, .prop-row select { - flex: 1; min-width: 0; background: var(--bg); border: 1px solid var(--border); - border-radius: 4px; color: var(--text); padding: 4px 8px; - font-size: 12px; font-family: var(--font); + flex: 1; min-width: 0; width: 100%; background: var(--canvas); border: 1px solid var(--border); + border-radius: 6px; color: var(--text-bright); padding: 6px 10px; + font-size: 13px; font-family: var(--font); outline: none; transition: border-color 0.15s; } +.prop-row input[type="number"] { font-family: var(--mono); font-size: 12px; } .prop-row input:focus, .prop-row select:focus { border-color: var(--accent); @@ -278,23 +357,24 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil /* Invalid input (e.g. malformed number array / dice notation): red border, value is not committed until it parses. */ -.prop-row input.invalid, .prop-row input.invalid:focus { border-color: #ef5350; } +.prop-row input.invalid, .prop-row input.invalid:focus { border-color: var(--red); } /* ── Custom Variables Panel ────────────────────────────────────────────── */ .var-card { - background: var(--panel2); border: 1px solid var(--border); - border-radius: 8px; margin-bottom: 14px; overflow: hidden; + background: var(--canvas); border: 1px solid var(--border); + border-radius: 10px; margin-bottom: 14px; overflow: hidden; } .var-card-header { display: flex; align-items: center; gap: 8px; - padding: 10px 12px; border-bottom: 1px solid var(--border); + padding: 10px 12px; border-bottom: 1px solid var(--border-soft); } .var-name-input { flex: 1; background: transparent; border: none; - border-bottom: 2px solid transparent; color: var(--text); + border-bottom: 2px solid transparent; color: var(--text-bright); font-size: 13px; font-weight: 600; padding: 1px 0; outline: none; + font-family: var(--font); transition: border-color 0.15s; } .var-name-input:focus { border-bottom-color: var(--accent); } @@ -307,34 +387,35 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil .var-kind-tabs { display: flex; gap: 4px; - padding: 8px 12px; border-bottom: 1px solid var(--border); + padding: 8px 12px; border-bottom: 1px solid var(--border-soft); } .var-kind-tab { flex: 1; padding: 4px 2px; border: 1px solid var(--border); - border-radius: 4px; background: none; color: var(--text-dim); - font-size: 11px; cursor: pointer; transition: all 0.15s; text-align: center; + border-radius: 5px; background: none; color: var(--text-dim); + font-size: 11px; font-family: var(--font); cursor: pointer; transition: all 0.15s; text-align: center; } -.var-kind-tab:hover { color: var(--text); background: var(--bg); } +.var-kind-tab:hover { color: var(--text); background: var(--panel2); } .var-kind-tab.active { - background: rgba(74,158,255,0.15); border-color: var(--accent); color: var(--accent); + background: var(--accent-tint); border-color: var(--accent); color: var(--accent); } -.var-body { padding: 12px; border-bottom: 1px solid var(--border); } +.var-body { padding: 12px; border-bottom: 1px solid var(--border-soft); } .var-field-label, .field-label { - font-size: 11px; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.5px; color: var(--text-dim); margin-bottom: 6px; + font-size: 10px; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.08em; color: var(--text-faint); margin-bottom: 6px; } .field-label { margin-top: 2px; } /* Field label with a trailing inline control (e.g. a "?" guide link). */ .field-label-row { display: flex; align-items: center; } .var-wide-input, .wide-input { - width: 100%; background: var(--bg); border: 1px solid var(--border); - border-radius: 5px; color: var(--text); padding: 7px 10px; + width: 100%; height: 32px; background: var(--canvas); border: 1px solid var(--border); + border-radius: 6px; color: var(--text-bright); padding: 6px 10px; font-size: 12px; outline: none; transition: border-color 0.15s; font-family: var(--font); } +.var-card .var-wide-input, .var-card .wide-input { background: var(--panel); } .var-wide-input:focus, .wide-input:focus { border-color: var(--accent); } .var-wide-input.invalid, .var-wide-input.invalid:focus { border-color: var(--red); } .wide-input { margin-bottom: 12px; } @@ -342,9 +423,9 @@ select.wide-input option { background: var(--panel2); } .var-range-row { display: flex; align-items: center; gap: 8px; } .var-range-num { - flex: 1; min-width: 0; background: var(--bg); border: 1px solid var(--border); - border-radius: 5px; color: var(--text); padding: 7px 10px; - font-size: 12px; outline: none; transition: border-color 0.15s; + flex: 1; min-width: 0; height: 32px; background: var(--panel); border: 1px solid var(--border); + border-radius: 6px; color: var(--text-bright); padding: 6px 10px; + font-size: 12px; font-family: var(--mono); outline: none; transition: border-color 0.15s; } .var-range-num:focus { border-color: var(--accent); } .var-range-num.invalid, .var-range-num.invalid:focus { border-color: var(--red); } @@ -358,34 +439,44 @@ select.wide-input option { background: var(--panel2); } .var-chip { padding: 3px 9px; border: 1px solid var(--border); border-radius: 10px; background: none; color: var(--text-dim); - font-size: 11px; cursor: pointer; transition: all 0.15s; + font-size: 11px; font-family: var(--font); cursor: pointer; transition: all 0.15s; } .var-chip:hover { color: var(--text); border-color: var(--text-dim); } .var-chip.active { - background: rgba(74,158,255,0.15); border-color: var(--accent); color: var(--accent); + background: var(--accent-tint); border-color: var(--accent); color: var(--accent); } +/* Mode chip rows (rate modes, routing) — one focused choice per row. */ +.chip-row { display: flex; gap: 4px; margin-bottom: 12px; } +.chip-row .var-chip { flex: 1; padding: 5px 4px; border-radius: 5px; text-align: center; } + /* State-connection role picker (properties panel) */ .role-chips { flex-wrap: wrap; margin-bottom: 12px; } .role-chips .var-chip { flex: 1 1 45%; padding: 5px 8px; } .var-value-display { - margin-left: auto; font-family: monospace; font-size: 15px; - font-weight: 700; color: var(--accent); white-space: nowrap; + margin-left: auto; font-family: var(--mono); font-size: 15px; + font-weight: 600; color: var(--accent); white-space: nowrap; } -.var-add-btn { width: 100%; margin-top: 2px; margin-bottom: 10px; } +/* Dashed lime add-button per the design's "+ Add …" pattern. */ +.var-add-btn { + width: 100%; height: 32px; margin-top: 2px; margin-bottom: 10px; + border: 1px dashed var(--accent); border-radius: 7px; + background: rgba(182,233,77,0.08); color: var(--accent); +} +.var-add-btn:hover { background: var(--accent-tint); color: var(--accent); border-color: var(--accent); } /* Legacy compat — kept for any stray references */ -.randvar-card { border: 1px solid var(--border); border-radius: 6px; padding: 8px; margin-bottom: 10px; } -.randvar-value { font-size: 11px; font-family: monospace; color: var(--accent); } +.randvar-card { border: 1px solid var(--border); border-radius: 8px; padding: 8px; margin-bottom: 10px; } +.randvar-value { font-size: 11px; font-family: var(--mono); color: var(--accent); } /* ── Simulation panel (nothing selected) ───────────────────────────────── */ .sim-desc { resize: vertical; min-height: 56px; line-height: 1.45; } .sim-thumb { - width: 100%; border: 1px solid var(--border); border-radius: 6px; - overflow: hidden; background: var(--bg); margin-bottom: 8px; + width: 100%; border: 1px solid var(--border); border-radius: 8px; + overflow: hidden; background: var(--canvas); margin-bottom: 8px; } .sim-thumb img { display: block; width: 100%; } .sim-thumb.empty { @@ -398,39 +489,54 @@ select.wide-input option { background: var(--panel2); } .sim-bg-row { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; } .sim-bg-row input[type="color"] { - flex: 1; height: 30px; padding: 2px; background: var(--bg); - border: 1px solid var(--border); border-radius: 5px; cursor: pointer; + flex: 1; height: 30px; padding: 2px; background: var(--canvas); + border: 1px solid var(--border); border-radius: 6px; cursor: pointer; } .sim-meta { - border: 1px solid var(--border); border-radius: 6px; - background: rgba(255,255,255,0.02); overflow: hidden; margin-bottom: 12px; + border: 1px solid var(--border); border-radius: 8px; + background: var(--canvas); overflow: hidden; margin-bottom: 12px; } .sim-meta-row { display: flex; justify-content: space-between; align-items: center; padding: 6px 10px; font-size: 11px; } -.sim-meta-row + .sim-meta-row { border-top: 1px solid var(--border); } +.sim-meta-row + .sim-meta-row { border-top: 1px solid var(--border-soft); } .sim-meta-row span { color: var(--text-dim); } -.sim-meta-row b { font-weight: 600; font-family: monospace; font-size: 11px; } +.sim-meta-row b { font-weight: 600; font-family: var(--mono); font-size: 11px; color: var(--text-bright); } .chart-section { margin-top: 16px; } .chart-label { font-size: 11px; color: var(--text-dim); margin-bottom: 6px; } -.sparkline { border-radius: 4px; display: block; width: 100%; } +.sparkline { border-radius: 6px; display: block; width: 100%; } + +/* Hero readout card (props panel): big mono value on a canvas-dark card. */ +.hero-card { + text-align: center; background: var(--canvas); border: 1px solid var(--border); + border-radius: 10px; padding: 12px; margin-bottom: 16px; +} +.hero-card-label { + font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; + color: var(--text-faint); margin-bottom: 2px; +} +.hero-card-value { + font-family: var(--mono); font-size: 34px; font-weight: 600; + color: var(--accent); line-height: 1; +} +.hero-card-sub { font-size: 11px; color: var(--text-faint); margin-top: 3px; } +.hero-card .sparkline { margin-top: 8px; } /* ── Buttons ── */ .btn { - padding: 5px 12px; border: 1px solid var(--border); border-radius: 5px; + padding: 5px 10px; min-height: 32px; border: 1px solid var(--border); border-radius: 7px; background: var(--panel2); color: var(--text); cursor: pointer; - font-size: 12px; font-family: var(--font); + font-size: 13px; font-family: var(--font); transition: all 0.15s; white-space: nowrap; } .btn:hover { border-color: var(--accent); color: var(--accent); } -/* #1565c0 on white = 6.0:1, #1254a8 = 7.2:1 — both pass WCAG AA */ -.btn.btn-primary { background: #1565c0; border-color: #1565c0; color: #fff; } -.btn.btn-primary:hover { background: #1254a8; border-color: #1254a8; } -.btn.active { border-color: var(--accent); color: var(--accent); background: rgba(74,158,255,0.12); } +.btn.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; } +.btn.btn-primary:hover { background: #c4f266; border-color: #c4f266; color: var(--accent-ink); } +.btn.active { border-color: var(--accent); color: var(--accent); background: var(--accent-tint); } .btn:disabled { opacity: 0.45; cursor: default; } .btn:disabled:hover { border-color: var(--border); color: var(--text); background: var(--panel2); } .btn.btn-danger { border-color: var(--red); color: var(--red); } @@ -439,20 +545,20 @@ select.wide-input option { background: var(--panel2); } /* ── Dropdown menus (File, …) ── */ .menu { position: relative; display: inline-flex; } .menu-popup { - position: absolute; top: calc(100% + 6px); right: 0; min-width: 190px; + position: absolute; top: calc(100% + 6px); right: 0; min-width: 200px; padding: 5px; background: var(--panel2); border: 1px solid var(--border); - border-radius: 7px; box-shadow: 0 10px 32px rgba(0,0,0,0.5); + border-radius: 10px; box-shadow: 0 10px 32px rgba(0,0,0,0.5); display: flex; flex-direction: column; gap: 1px; z-index: 60; } .menu-popup.hidden { display: none; } .menu-popup.align-left { left: 0; right: auto; } .menu-item { text-align: left; background: none; border: none; color: var(--text); - padding: 7px 12px; border-radius: 5px; font-size: 12px; font-family: var(--font); + padding: 7px 12px; border-radius: 6px; font-size: 12px; font-family: var(--font); cursor: pointer; white-space: nowrap; } -.menu-item:hover { background: rgba(74,158,255,0.14); color: var(--accent); } -.menu-sep { height: 1px; background: var(--border); margin: 4px 8px; } +.menu-item:hover { background: var(--accent-tint); color: var(--accent); } +.menu-sep { height: 1px; background: var(--border-soft); margin: 4px 8px; } /* Toggleable menu items show a leading check when active (e.g. timeline on). */ .menu-item-toggle::before { content: '\f00c'; /* fa-check */ @@ -464,9 +570,9 @@ select.wide-input option { background: var(--panel2); } /* ── Right-click context menu ── */ .ctx-menu { - position: fixed; z-index: 200; min-width: 184px; + position: fixed; z-index: 200; min-width: 200px; padding: 5px; background: var(--panel2); border: 1px solid var(--border); - border-radius: 7px; box-shadow: 0 10px 32px rgba(0,0,0,0.5); + border-radius: 10px; box-shadow: 0 10px 32px rgba(0,0,0,0.5); display: flex; flex-direction: column; gap: 1px; } .ctx-menu.hidden { display: none; } @@ -475,7 +581,7 @@ select.wide-input option { background: var(--panel2); } .ctx-menu .menu-item:hover > i { color: var(--accent); } .ctx-menu .menu-item .ctx-shortcut { margin-left: auto; padding-left: 20px; font-size: 11px; color: var(--text-dim); - font-variant-numeric: tabular-nums; + font-family: var(--mono); } .ctx-menu .menu-item.ctx-danger:hover { background: rgba(239,83,80,0.15); color: var(--red); } .ctx-menu .menu-item.ctx-danger:hover > i { color: var(--red); } @@ -487,10 +593,18 @@ select.wide-input option { background: var(--panel2); } .node { user-select: none; } /* ── Connection SVG styles ── */ .conn { cursor: pointer; } -.conn.selected .conn-path { filter: drop-shadow(0 0 3px rgba(255,255,255,0.6)); } +/* Selected connection: soft lime underglow beneath the lime line. */ +.conn.selected .conn-path { filter: drop-shadow(0 0 8px rgba(182,233,77,0.45)); } .conn-cp-handle { opacity: 0.6; transition: opacity 0.12s; } .conn-cp-handle:hover { opacity: 1; } +/* Flow animation: dash-march on connections that carried resources this step. */ +@keyframes flow-dash { to { stroke-dashoffset: -11; } } +.conn.flowing .conn-path { animation: flow-dash 0.7s linear infinite; } +@media (prefers-reduced-motion: reduce) { + .conn.flowing .conn-path { animation: none; } +} + /* Corner resize handles on selected groups / notes / charts. */ .resize-handle { opacity: 0.85; transition: opacity 0.12s; } .resize-handle:hover { opacity: 1; } @@ -500,11 +614,11 @@ select.wide-input option { background: var(--panel2); } .conn-style-btn { flex: 1; padding: 4px 0; font-size: 15px; line-height: 1; background: var(--panel2); border: 1px solid var(--border); - color: var(--text-dim); cursor: pointer; border-radius: 4px; + color: var(--text-dim); cursor: pointer; border-radius: 5px; transition: background 0.12s, color 0.12s, border-color 0.12s; } .conn-style-btn:hover:not(.active) { background: var(--border); color: var(--text); } -.conn-style-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); } +.conn-style-btn.active { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); } input[type="range"] { width: 80px; accent-color: var(--accent); cursor: pointer; @@ -516,13 +630,13 @@ input[type="range"] { ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } /* ── Props extras ── */ -.props-sep { height: 1px; background: var(--border); margin: 10px 0; } +.props-sep { height: 1px; background: var(--border-soft); margin: 10px 0; } .props-info { color: var(--text-dim); font-size: 12px; margin-bottom: 12px; line-height: 1.45; } -.prop-inline-warn { font-size: 11px; color: #ffa726; margin-bottom: 8px; line-height: 1.4; } +.prop-inline-warn { font-size: 11px; color: var(--accent2); margin-bottom: 8px; line-height: 1.4; } .reg-value { - font-size: 24px; font-weight: 700; text-align: center; color: var(--accent); - padding: 10px 0 6px; font-family: monospace; + font-size: 26px; font-weight: 600; text-align: center; color: var(--accent); + padding: 10px 0 6px; font-family: var(--mono); } .cond-details { padding-left: 8px; margin-bottom: 6px; } @@ -538,24 +652,33 @@ input[type="range"] { /* ── Node SVG text ── */ /* Labels get a canvas-colored halo (paint-order trick) so they stay readable when a connection or grid line passes underneath. */ -.n-count { fill: #fff; font-size: 13px; font-weight: 700; font-family: monospace; } +.n-count { fill: #fff; font-size: 13px; font-weight: 600; font-family: var(--mono); } .n-label, .n-badge, .grp-label { - paint-order: stroke; stroke: var(--bg); stroke-width: 3px; stroke-linejoin: round; + paint-order: stroke; stroke: var(--canvas); stroke-width: 3px; stroke-linejoin: round; } -.n-label { fill: var(--text); font-size: 11px; font-family: var(--font); } +.n-label { fill: var(--text); font-size: 12px; font-family: var(--font); } +.node.selected .n-label { font-weight: 600; fill: var(--text-bright); } .n-badge { fill: var(--text-dim); font-size: 11px; font-family: var(--font); } +/* In-node glyphs (∞ × % ƒx …) always render in mono. */ +.n-glyph { font-family: var(--mono); pointer-events: none; } +.n-caption { fill: var(--text-dim); font-size: 9px; font-family: var(--mono); } /* ── Node states ── */ -.node.selected .ns { filter: url(#glow); } .node.firing .ns { animation: fire 0.25s ease-out; } @keyframes fire { 0% { opacity: 1; } 40% { opacity: 0.4; filter: brightness(2.5); } 100% { opacity: 1; } } +/* Expanding ring emitted on each firing (18e). */ +.fire-ring { animation: fire-ring 0.25s ease-out forwards; } +@keyframes fire-ring { + 0% { opacity: 0.7; transform: scale(1); } + 100% { opacity: 0; transform: scale(1.35); } +} /* ── Connection label pills ── */ -.conn-label { fill: var(--text); } +.conn-label { fill: var(--text-bright); font-family: var(--mono); } .conn-label-g { transition: opacity 0.1s; } .conn-label-g:hover .conn-label-bg { filter: brightness(1.25); } .conn-label-bg { cursor: grab; } @@ -565,23 +688,24 @@ input[type="range"] { /* ── Analysis controls ── */ .analysis-controls { - display: flex; gap: 6px; margin-left: 8px; align-items: center; - border-left: 1px solid var(--border); padding-left: 14px; + display: flex; gap: 6px; margin-left: auto; align-items: center; } -/* Zoom cluster (− / level / + / Fit) — same pill treatment as sim controls. */ +/* Zoom cluster (− / level / + / Fit): same pill treatment as sim controls. */ .zoom-cluster { - display: flex; gap: 4px; align-items: center; + display: flex; gap: 3px; align-items: center; background: var(--panel2); border: 1px solid var(--border); border-radius: 7px; padding: 3px; } +.zoom-cluster .btn { min-height: 24px; } .zoom-btn { - min-width: 28px; padding: 5px 0; text-align: center; - font-size: 15px; font-weight: 600; line-height: 1; + min-width: 28px; padding: 3px 0; text-align: center; + font-size: 13px; font-weight: 600; line-height: 1; + background: transparent; border-color: transparent; } .zoom-level { - min-width: 50px; text-align: center; font-family: monospace; - font-variant-numeric: tabular-nums; + min-width: 48px; text-align: center; font-family: var(--mono); font-size: 12px; + background: transparent; border-color: transparent; } /* ── Timeline panel ── */ @@ -596,39 +720,40 @@ input[type="range"] { } #tl-resize:hover { background: var(--accent); } .timeline-head { - display: flex; align-items: center; gap: 8px; - padding: 4px 10px; font-size: 11px; color: var(--text-dim); + display: flex; align-items: center; gap: 10px; + padding: 7px 14px; font-size: 11px; color: var(--text-dim); border-bottom: 1px solid var(--border); flex-shrink: 0; min-height: 28px; } -.timeline-title { flex-shrink: 0; } +.timeline-title { flex-shrink: 0; font-size: 12px; font-weight: 600; color: var(--text-bright); } /* The legend's flex:1 right-aligns the trailing controls (compare + close). */ -.timeline-head .btn { padding: 2px 8px; font-size: 11px; } +.timeline-head .btn { padding: 2px 8px; min-height: 24px; font-size: 11px; } .tl-compare-info { flex-shrink: 0; font-size: 11px; color: var(--accent); - white-space: nowrap; font-variant-numeric: tabular-nums; + white-space: nowrap; font-family: var(--mono); } #tl-compare-clear { flex-shrink: 0; } #tl-compare-info.hidden, #tl-compare-clear.hidden { display: none; } .tl-scale { - flex-shrink: 0; font-size: 11px; padding: 1px 4px; - background: var(--bg); border: 1px solid var(--border); border-radius: 4px; - color: var(--text-dim); cursor: pointer; + flex-shrink: 0; font-size: 11px; padding: 4px 8px; + background: var(--canvas); border: 1px solid var(--border); border-radius: 5px; + color: var(--text-dim); cursor: pointer; font-family: var(--font); } .tl-scale:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } .tl-legend { - display: flex; flex-wrap: wrap; gap: 4px; flex: 1; min-width: 0; + display: flex; flex-wrap: wrap; gap: 6px; flex: 1; min-width: 0; } .tl-chip { - padding: 1px 7px; border-radius: 10px; font-size: 11px; cursor: pointer; + padding: 2px 10px; border-radius: 11px; font-size: 11px; cursor: pointer; border: 1px solid var(--chip-color, #4a9eff); color: var(--chip-color, #4a9eff); background: transparent; + font-family: var(--font); transition: opacity 0.15s, background 0.15s; white-space: nowrap; max-width: 120px; overflow: hidden; text-overflow: ellipsis; } .tl-chip:hover { background: color-mix(in srgb, var(--chip-color, #4a9eff) 15%, transparent); } /* solid muted colors instead of opacity — opacity 0.35 gave ~1.9:1 contrast (fails AA) */ .tl-chip-off { color: var(--text-dim); border-color: var(--border); text-decoration: line-through; } -.tl-branch-chip { border-style: dashed; --chip-color: #95a3bc; } +.tl-branch-chip { border-style: dashed; --chip-color: #8a90a0; } /* Bulk show/hide-all action: an accent-tinted chip, distinct from the node-colored series chips and never struck through. */ .tl-chip-all { --chip-color: var(--accent); font-weight: 600; flex-shrink: 0; margin-right: 2px; } @@ -636,13 +761,21 @@ input[type="range"] { /* History scrubber (timeline footer) */ #tl-scrub { - display: flex; align-items: center; gap: 10px; - padding: 5px 10px; border-top: 1px solid var(--border); flex-shrink: 0; + display: flex; align-items: center; gap: 12px; + padding: 7px 14px; border-top: 1px solid var(--border); flex-shrink: 0; } -#tl-scrub .btn { padding: 2px 9px; font-size: 11px; } +#tl-scrub .btn { padding: 2px 9px; min-height: 24px; font-size: 11px; } #tl-range { flex: 1; min-width: 0; accent-color: var(--accent); cursor: pointer; } #tl-range:disabled { cursor: default; opacity: 0.5; } -#tl-scrub-label { flex-shrink: 0; min-width: 92px; text-align: center; } +#tl-scrub-label { + flex-shrink: 0; min-width: 92px; text-align: center; + font-family: var(--mono); font-size: 11px; color: var(--text-dim); + background: none; border: none; padding: 0; +} +/* While scrubbing, the position label flips amber so "not live" is unmissable. */ +#tl-scrub.scrubbing #tl-scrub-label { color: #ffd27a; } +#tl-live { border-color: var(--accent); background: var(--accent-tint); color: var(--accent); } +#tl-live:disabled { border-color: var(--border); background: var(--panel2); color: var(--text); } /* Subtle cue that the canvas is showing a past frame, not the live state. */ #canvas.scrubbing { outline: 2px solid var(--accent); outline-offset: -2px; } @@ -652,14 +785,15 @@ input[type="range"] { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; } .branch-name { - flex: 1; min-width: 0; background: var(--bg); border: 1px solid var(--border); - border-radius: 4px; color: var(--text); padding: 3px 7px; font-size: 12px; + flex: 1; min-width: 0; background: var(--canvas); border: 1px solid var(--border); + border-radius: 5px; color: var(--text); padding: 3px 7px; font-size: 12px; + font-family: var(--font); } .branch-name:focus { border-color: var(--accent); outline: none; } -.branch-step { font-size: 11px; color: var(--text); white-space: nowrap; } -.branch-mini-btn { padding: 3px 7px; font-size: 11px; } +.branch-step { font-size: 11px; color: var(--text); white-space: nowrap; font-family: var(--mono); } +.branch-mini-btn { padding: 3px 7px; min-height: 24px; font-size: 11px; } -/* ── Monte Carlo modal ── */ +/* ── Modal shell (shared) ── */ #mc-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); display: flex; align-items: center; justify-content: center; z-index: 100; @@ -667,33 +801,40 @@ input[type="range"] { #mc-overlay.hidden { display: none; } #mc-modal { width: 580px; max-width: 92vw; max-height: 86vh; overflow: auto; - background: var(--panel); border: 1px solid var(--border); border-radius: 8px; + background: var(--panel); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 12px 48px rgba(0,0,0,0.5); } .mc-head { display: flex; justify-content: space-between; align-items: center; - padding: 12px 16px; border-bottom: 1px solid var(--border); - font-weight: 600; font-size: 13px; + padding: 12px 18px; border-bottom: 1px solid var(--border); + font-weight: 600; font-size: 15px; color: var(--text-bright); } -.mc-head .btn { padding: 2px 8px; } +.mc-head > span > i { color: var(--accent); margin-right: 4px; } +.mc-head .btn { padding: 2px 8px; min-height: 28px; min-width: 28px; } /* Shared settings band — Runs / Steps / Seed feed every analysis below. */ .mc-settings { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; - padding: 12px 16px; border-bottom: 1px solid var(--border); + padding: 12px 18px; border-bottom: 1px solid var(--border); + background: var(--bg); } /* One analysis per section: a titled header with its run button on the right, then that analysis's own controls. Consistent dividers between every band. */ -.mc-section { padding: 10px 16px 12px; border-bottom: 1px solid var(--border); } +.mc-section { padding: 10px 18px 12px; border-bottom: 1px solid var(--border-soft); } .mc-section-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .mc-section-title { - font-size: 11px; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.6px; color: var(--text-dim); + font-size: 10px; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.08em; color: var(--text-faint); } .mc-section-head .btn { white-space: nowrap; } +/* Secondary run actions (sweep, sensitivity) read as lime outline buttons. */ +#mc-sweep-run, #mc-sens-run { + background: transparent; border-color: var(--accent); color: var(--accent); font-weight: 600; +} +#mc-sweep-run:hover, #mc-sens-run:hover { background: var(--accent-tint); } .mc-section-desc { font-size: 11px; color: var(--text-dim); margin: 6px 0 0; line-height: 1.45; } .mc-section-row { display: flex; gap: 10px 12px; align-items: center; flex-wrap: wrap; margin-top: 8px; @@ -705,21 +846,27 @@ input[type="range"] { font-size: 12px; color: var(--text-dim); display: flex; gap: 6px; align-items: center; } .mc-settings input, .mc-section-row input { - width: 60px; background: var(--bg); border: 1px solid var(--border); - border-radius: 4px; color: var(--text); padding: 4px 6px; + width: 60px; height: 32px; background: var(--canvas); border: 1px solid var(--border); + border-radius: 6px; color: var(--text-bright); padding: 4px 6px; font-family: var(--mono); font-size: 12px; } #mc-seed { width: 92px; } .mc-section-row select { - background: var(--bg); border: 1px solid var(--border); border-radius: 4px; - color: var(--text); padding: 4px 6px; max-width: 150px; + height: 32px; background: var(--canvas); border: 1px solid var(--border); border-radius: 6px; + color: var(--text-bright); padding: 4px 6px; max-width: 150px; font-family: var(--font); } .mc-actions { margin: 12px 0 4px; } /* Cancel button shown alongside batch progress. */ -.mc-cancel-btn { padding: 2px 10px; margin-left: 8px; font-size: 11px; } +.mc-cancel-btn { padding: 2px 10px; margin-left: 8px; font-size: 11px; border-color: var(--red); color: var(--red); } + +/* Batch progress: mono count + slim lime bar + red-outline Cancel. */ +.mc-progress { padding-bottom: 4px; } +.mc-progress .mc-prog-text { font-family: var(--mono); color: var(--text); } +.mc-progress-bar { height: 6px; border-radius: 3px; background: var(--border-soft); overflow: hidden; margin-top: 6px; } +.mc-progress-bar > div { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.15s; } /* Library save rows reuse .mc-controls — kept for them. */ -.mc-controls { display: flex; gap: 12px; align-items: center; padding: 12px 16px; } +.mc-controls { display: flex; gap: 12px; align-items: center; padding: 12px 18px; } .mc-controls label { font-size: 12px; color: var(--text-dim); display: flex; gap: 6px; align-items: center; } /* ── Sensitivity heatmap ── */ @@ -738,7 +885,7 @@ input[type="range"] { #mc-results table.sens-table td { text-align: center; } #mc-results table.sens-table th:first-child, #mc-results table.sens-table td:first-child { text-align: left; } -.sens-cell { font-variant-numeric: tabular-nums; } +.sens-cell { font-family: var(--mono); } .sens-na { color: var(--text-dim); } /* ── Help / shortcuts modal ── */ @@ -749,21 +896,21 @@ input[type="range"] { #help-overlay.hidden { display: none; } #help-modal { width: 640px; max-width: 92vw; max-height: 86vh; overflow: auto; - background: var(--panel); border: 1px solid var(--border); border-radius: 8px; + background: var(--panel); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 12px 48px rgba(0,0,0,0.5); } .help-body { display: flex; gap: 24px; padding: 14px 18px 18px; } .help-col { flex: 1; min-width: 0; } .help-col h3 { - font-size: 11px; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.7px; color: var(--text-dim); margin: 14px 0 8px; + font-size: 10px; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.08em; color: var(--text-faint); margin: 14px 0 8px; } .help-col h3:first-child { margin-top: 0; } .help-col dl { display: grid; grid-template-columns: auto 1fr; gap: 5px 12px; margin: 0; } .help-col dt { font-size: 11px; color: var(--text); white-space: nowrap; } .help-col dd { font-size: 11px; color: var(--text-dim); margin: 0; } .help-col kbd { - font-family: monospace; font-size: 10px; color: var(--text); + font-family: var(--mono); font-size: 10px; color: var(--text); background: var(--panel2); border: 1px solid var(--border); border-radius: 3px; padding: 1px 4px; } @@ -778,7 +925,7 @@ input[type="range"] { #kb-modal { width: 880px; max-width: 94vw; height: 600px; max-height: 88vh; display: flex; flex-direction: column; overflow: hidden; - background: var(--panel); border: 1px solid var(--border); border-radius: 8px; + background: var(--panel); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 12px 48px rgba(0,0,0,0.5); } .kb-search { @@ -786,43 +933,43 @@ input[type="range"] { padding: 10px 16px; border-bottom: 1px solid var(--border); color: var(--text-dim); } .kb-search input { - flex: 1; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; - color: var(--text); padding: 7px 10px; font-size: 13px; font-family: var(--font); + flex: 1; background: var(--canvas); border: 1px solid var(--border); border-radius: 6px; + color: var(--text-bright); padding: 7px 10px; font-size: 13px; font-family: var(--font); } .kb-search input:focus { outline: none; border-color: var(--accent); } .kb-body { display: flex; flex: 1; min-height: 0; } .kb-nav { - width: 244px; flex-shrink: 0; overflow-y: auto; + width: 230px; flex-shrink: 0; overflow-y: auto; border-right: 1px solid var(--border); padding: 8px; } .kb-cat { - font-size: 11px; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.7px; color: var(--text-dim); margin: 12px 8px 4px; + font-size: 10px; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.08em; color: var(--text-faint); margin: 12px 8px 4px; } .kb-cat:first-child { margin-top: 4px; } .kb-link { display: block; width: 100%; text-align: left; background: none; border: none; - color: var(--text); padding: 6px 10px; border-radius: 5px; font-size: 12px; + color: var(--text); padding: 6px 10px; border-radius: 6px; font-size: 12px; cursor: pointer; font-family: var(--font); } -.kb-link:hover { background: rgba(74,158,255,0.14); color: var(--accent); } -.kb-link.active { background: rgba(74,158,255,0.2); color: var(--accent); font-weight: 600; } +.kb-link:hover { background: var(--accent-tint); color: var(--accent); } +.kb-link.active { background: var(--accent-tint); color: var(--accent); font-weight: 600; } .kb-noresults { color: var(--text-dim); font-size: 12px; padding: 10px; } .kb-article { flex: 1; overflow-y: auto; padding: 20px 24px; } .kb-article:focus:not(:focus-visible) { outline: none; } .kb-article .kb-cat-label { - font-size: 11px; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.7px; color: var(--text-dim); margin-bottom: 6px; + font-size: 10px; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.08em; color: var(--text-faint); margin-bottom: 6px; } -.kb-article h2 { font-size: 18px; margin: 0 0 12px; color: var(--text); } -.kb-article p { font-size: 13px; line-height: 1.65; color: var(--text); margin: 0; } +.kb-article h2 { font-size: 19px; margin: 0 0 12px; color: var(--text-bright); } +.kb-article p { font-size: 13px; line-height: 1.7; color: var(--text); margin: 0; } /* Context "?" in the properties selection header — opens the guide article. */ .props-help { - margin-left: auto; background: none; border: none; color: var(--text-dim); - cursor: pointer; font-size: 12px; line-height: 1; padding: 2px 6px; border-radius: 4px; + margin-left: auto; background: none; border: none; color: var(--text-faint); + cursor: pointer; font-size: 13px; line-height: 1; padding: 2px 6px; border-radius: 4px; } -.props-help:hover { color: var(--accent); background: rgba(74,158,255,0.14); } +.props-help:hover { color: var(--accent); background: var(--accent-tint); } /* ── Interactive tour (coach-marks) ── */ /* The container doesn't capture clicks, so the user can still operate the @@ -841,15 +988,15 @@ input[type="range"] { #tour-coach { position: fixed; pointer-events: auto; width: 290px; max-width: 92vw; - background: var(--panel); border: 1px solid var(--accent); border-radius: 8px; + background: var(--panel); border: 1px solid var(--accent); border-radius: 10px; padding: 14px 16px; box-shadow: 0 10px 36px rgba(0,0,0,0.55); } #tour-coach .tour-count { - font-size: 11px; font-weight: 700; letter-spacing: 0.7px; text-transform: uppercase; + font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent); margin-bottom: 6px; } #tour-coach p { font-size: 13px; line-height: 1.55; color: var(--text); margin: 0; } -#tour-coach p b { color: var(--text); } +#tour-coach p b { color: var(--text-bright); } .tour-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; } .tour-actions .btn { padding: 4px 12px; font-size: 12px; } .tour-actions .hidden { display: none; } @@ -861,10 +1008,11 @@ input[type="range"] { } #guard-overlay.hidden { display: none; } #guard-modal { - width: 400px; max-width: 92vw; - background: var(--panel); border: 1px solid var(--border); border-radius: 8px; + width: 360px; max-width: 92vw; + background: var(--panel); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 12px 48px rgba(0,0,0,0.55); } +#guard-title i { color: #ffd27a; } .guard-body { padding: 16px 20px 8px; } .guard-body p { font-size: 13px; line-height: 1.55; color: var(--text); margin: 0; } .guard-actions { @@ -883,19 +1031,32 @@ input[type="range"] { #welcome-overlay.hidden { display: none; } #welcome-modal { width: 640px; max-width: 92vw; max-height: 88vh; overflow: auto; - background: var(--panel); border: 1px solid var(--border); border-radius: 8px; + background: var(--panel); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 12px 48px rgba(0,0,0,0.55); } .welcome-body { padding: 16px 20px 4px; } .welcome-lede { font-size: 14px; line-height: 1.5; color: var(--text); margin-bottom: 16px; } +.welcome-lede b { color: var(--text-bright); } .welcome-cols { display: flex; gap: 28px; } .welcome-col { flex: 1; min-width: 0; } .welcome-col h3 { - font-size: 11px; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.7px; color: var(--text-dim); margin: 0 0 10px; + font-size: 10px; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.08em; color: var(--text-faint); margin: 0 0 10px; +} +/* The 3-step loop as numbered lime mono tiles. */ +.welcome-col ol { margin: 0; padding: 0; list-style: none; counter-reset: wstep; } +.welcome-col ol li { + font-size: 12px; line-height: 1.6; color: var(--text); margin-bottom: 10px; + position: relative; padding-left: 34px; counter-increment: wstep; min-height: 24px; +} +.welcome-col ol li::before { + content: counter(wstep); + position: absolute; left: 0; top: 1px; + width: 24px; height: 24px; border-radius: 6px; + display: flex; align-items: center; justify-content: center; + background: var(--accent-tint); border: 1px solid var(--accent); + color: var(--accent); font-family: var(--mono); font-weight: 600; font-size: 12px; } -.welcome-col ol { margin: 0; padding-left: 18px; } -.welcome-col ol li { font-size: 12px; line-height: 1.6; color: var(--text); margin-bottom: 6px; } .welcome-note { font-size: 11px; color: var(--text-dim); margin-top: 10px; line-height: 1.5; } .welcome-col dl { display: grid; grid-template-columns: auto 1fr; gap: 7px 12px; margin: 0; } .welcome-col dt { font-size: 12px; font-weight: 600; color: var(--accent); white-space: nowrap; } @@ -908,7 +1069,7 @@ input[type="range"] { /* Formula field helper: available variables + the state-connection tip */ .formula-hint { font-size: 11px; color: var(--text-dim); margin: -4px 0 8px; line-height: 1.6; } .formula-hint code { - font-family: monospace; font-size: 11px; color: var(--text); + font-family: var(--mono); font-size: 11px; color: var(--text); background: var(--panel2); border: 1px solid var(--border); border-radius: 3px; padding: 0 4px; margin-right: 2px; } @@ -918,15 +1079,16 @@ input[type="range"] { font: inherit; color: var(--accent); text-decoration: underline; } .formula-hint-link:hover { color: var(--text); } -.mc-controls input { width: 70px; background: var(--bg); border: 1px solid var(--border); - border-radius: 4px; color: var(--text); padding: 4px 6px; } -#mc-results { padding: 0 16px 16px; } +.mc-controls input { width: 70px; height: 32px; background: var(--canvas); border: 1px solid var(--border); + border-radius: 6px; color: var(--text-bright); padding: 4px 8px; font-family: var(--font); } +#mc-results { padding: 0 18px 16px; } #mc-results .mc-summary { font-size: 12px; color: var(--text); margin: 6px 0 10px; } -.mc-stale-badge { font-size: 11px; color: #ffa726; margin: 0 0 8px; padding: 4px 8px; background: rgba(255,167,38,0.1); border-radius: 4px; border-left: 2px solid #ffa726; } +.mc-stale-badge { font-size: 11px; color: var(--accent2); margin: 0 0 8px; padding: 4px 8px; background: rgba(255,167,38,0.1); border-radius: 4px; border-left: 2px solid var(--accent2); } #mc-results table { width: 100%; border-collapse: collapse; font-size: 12px; } -#mc-results th, #mc-results td { text-align: right; padding: 4px 8px; border-bottom: 1px solid var(--border); } -#mc-results th:first-child, #mc-results td:first-child { text-align: left; } -#mc-results th { color: var(--text-dim); font-weight: 600; } +#mc-results th, #mc-results td { text-align: right; padding: 4px 8px; border-bottom: 1px solid var(--border-soft); } +#mc-results td { font-family: var(--mono); } +#mc-results th:first-child, #mc-results td:first-child { text-align: left; font-family: var(--font); } +#mc-results th { color: var(--text-dim); font-weight: 600; font-family: var(--font); } #mc-results td.swatch-cell { text-align: left; } .mc-empty { color: var(--text-dim); font-size: 12px; padding: 8px 0; } @@ -946,52 +1108,84 @@ input[type="range"] { /* Chart visualization-style picker (chart properties panel). */ .chart-type-chips { margin-bottom: 12px; flex-wrap: wrap; } .chart-type-chips .var-chip { flex: 1 1 45%; padding: 5px 8px; } -.chart-type-chips .var-chip span { font-family: monospace; margin-right: 2px; } +.chart-type-chips .var-chip span { font-family: var(--mono); margin-right: 2px; } -/* ── Library modal ── */ +/* ── Library modal (tabbed: Your diagrams / Components / Templates) ── */ #lib-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); display: flex; align-items: center; justify-content: center; z-index: 100; } #lib-overlay.hidden { display: none; } #lib-modal { - width: 500px; max-width: 92vw; max-height: 86vh; overflow: auto; - background: var(--panel); border: 1px solid var(--border); border-radius: 8px; + width: 520px; max-width: 92vw; max-height: 86vh; overflow: auto; + background: var(--panel); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 12px 48px rgba(0,0,0,0.5); } -#lib-list, #lib-templates { padding: 0 16px; } +.lib-tabs { + display: flex; gap: 4px; padding: 0 18px; + border-bottom: 1px solid var(--border); +} +.lib-tab { + background: none; border: none; border-bottom: 2px solid transparent; + color: var(--text-dim); font-size: 13px; font-family: var(--font); + padding: 10px 12px; cursor: pointer; white-space: nowrap; +} +.lib-tab:hover { color: var(--text); } +.lib-tab.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; } +.lib-tab .lib-tab-count { font-family: var(--mono); font-size: 11px; color: var(--text-faint); margin-left: 5px; } +.lib-tab.active .lib-tab-count { color: var(--accent); } +.lib-pane.hidden { display: none; } +#lib-list, #lib-templates, #lib-components { padding: 0 18px; } /* The save-name input shares .mc-controls styling but should fill the row, not inherit the Monte Carlo modal's tiny 70px number-input width. */ -#lib-name { flex: 1; width: auto; min-width: 0; } +#lib-name, #comp-name { flex: 1; width: auto; min-width: 0; } .lib-section { padding: 4px 0 12px; } -.lib-section + .lib-section { border-top: 1px solid var(--border); } .lib-section-title { - font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; - color: var(--text-dim); padding: 12px 16px 2px; + font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; + font-weight: 700; color: var(--text-faint); padding: 12px 18px 2px; display: flex; align-items: center; justify-content: space-between; } .lib-row { display: flex; align-items: center; justify-content: space-between; - padding: 8px 0; border-bottom: 1px solid var(--border); gap: 10px; + padding: 8px 0; border-bottom: 1px solid var(--border-soft); gap: 10px; } .lib-row:last-child { border-bottom: none; } +.lib-thumb { + width: 44px; height: 30px; flex-shrink: 0; + background: var(--canvas); border: 1px solid var(--border); border-radius: 5px; + overflow: hidden; +} +.lib-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } .lib-info { flex: 1; min-width: 0; font-size: 12px; } -.lib-info b { display: block; } +.lib-info b { display: block; color: var(--text-bright); } .lib-date { color: var(--text-dim); font-size: 11px; } .lib-desc { display: block; color: var(--text-dim); font-size: 11px; margin-top: 1px; } /* ── Artificial-player rule box ── */ .ai-rule { - border: 1px solid var(--border); border-radius: 6px; - padding: 8px 8px 2px; margin-bottom: 8px; background: var(--panel2); + border: 1px solid var(--border); border-radius: 8px; + padding: 8px 8px 2px; margin-bottom: 8px; background: var(--canvas); } .ai-rule .prop-row label { flex: 0 0 70px; } +/* ── Recovery banner (16h): amber notice after an autosave restore ── */ +.recovery-banner { + position: absolute; top: 12px; left: 50%; transform: translateX(-50%); + display: flex; align-items: center; gap: 10px; z-index: 8; + background: var(--panel); border: 1px solid #ffd27a; border-radius: 10px; + padding: 8px 14px; font-size: 12px; color: var(--text); + box-shadow: 0 6px 24px rgba(0,0,0,0.4); white-space: nowrap; + transition: opacity 1s; +} +.recovery-banner > i { color: #ffd27a; } +.recovery-banner .btn { padding: 3px 10px; min-height: 26px; font-size: 12px; } +.recovery-banner.fade { opacity: 0; } + /* ── Toast ── */ #app-toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%) translateY(8px); background: var(--panel2); color: var(--text); border: 1px solid var(--accent); - border-radius: 6px; padding: 8px 16px; font-size: 12px; z-index: 200; + border-radius: 17px; padding: 8px 18px; font-size: 12px; z-index: 200; box-shadow: 0 6px 24px rgba(0,0,0,0.4); opacity: 0; pointer-events: none; transition: opacity 0.2s, transform 0.2s; } @@ -999,9 +1193,9 @@ input[type="range"] { /* ── Sticky note textarea (props panel) ── */ .note-textarea { - flex: 1; min-height: 70px; background: var(--bg); - border: 1px solid var(--border); border-radius: 4px; - color: var(--text); padding: 4px 8px; font-size: 12px; + flex: 1; min-height: 70px; background: var(--canvas); + border: 1px solid var(--border); border-radius: 6px; + color: var(--text); padding: 6px 10px; font-size: 12px; font-family: var(--font); resize: vertical; outline: none; transition: border-color 0.15s; } @@ -1014,25 +1208,209 @@ input[type="range"] { /* ── Per-type resource readouts ── */ .type-readout { margin-bottom: 8px; } -.type-row { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; } +.type-row { display: flex; align-items: center; gap: 9px; margin-bottom: 7px; } .type-swatch { - flex: 0 0 12px; width: 12px; height: 12px; border-radius: 2px; + flex: 0 0 11px; width: 11px; height: 11px; border-radius: 3px; border: 1px solid var(--border); } .type-name { flex: 1; font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.type-count { font-family: monospace; font-size: 12px; color: var(--accent); } +.type-count { font-family: var(--mono); font-size: 12px; color: var(--accent); } -/* Live queue metrics readout (throughput / waiting time / peak line). */ -.queue-metrics { margin-top: 6px; } +/* Gate output weight cards (18a): slider + mono value + computed share. */ +.gate-out-card { + background: var(--canvas); border: 1px solid var(--border); border-radius: 8px; + padding: 9px 11px; margin-bottom: 8px; +} +.gate-out-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; } +.gate-out-name { + font-size: 12px; color: var(--text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.gate-out-pct { font-family: var(--mono); font-size: 12px; color: var(--accent); flex-shrink: 0; } +.gate-out-row { display: flex; align-items: center; gap: 10px; } +.gate-out-row input[type="range"] { flex: 1; width: auto; accent-color: #ba68c8; } +.gate-out-num { + width: 52px; flex-shrink: 0; background: var(--panel); border: 1px solid var(--border); + border-radius: 5px; color: var(--text-bright); padding: 4px 6px; + font-size: 12px; font-family: var(--mono); text-align: center; outline: none; +} +.gate-out-num:focus { border-color: var(--accent); } +.gate-out-formula { font-family: var(--mono); font-size: 11px; color: #8fe08f; } + +/* Converter consumes → emits pair card (18b). */ +.conv-pair { + display: flex; align-items: center; justify-content: center; gap: 14px; + background: var(--canvas); border: 1px solid var(--border); border-radius: 8px; + padding: 12px; margin-bottom: 10px; +} +.conv-pair-cell { text-align: center; } +.conv-pair-num { + width: 52px; height: 38px; background: var(--panel); border: 1px solid var(--border); + border-radius: 8px; color: var(--text-bright); font-size: 17px; + font-family: var(--mono); text-align: center; outline: none; +} +.conv-pair-num:focus { border-color: var(--accent); } +.conv-pair-ro { + display: inline-flex; align-items: center; justify-content: center; + color: var(--accent); +} +.conv-pair-label { font-size: 10px; color: var(--text-dim); margin-top: 4px; } +.conv-pair-arrow { color: var(--text-faint); font-size: 13px; } + +/* Delay in-flight batch rows (18d): units + maturing progress + release step. */ +.delay-batch { + background: var(--canvas); border: 1px solid var(--border); border-radius: 8px; + padding: 8px 10px; margin-bottom: 6px; +} +.delay-batch-head { + display: flex; align-items: baseline; justify-content: space-between; + gap: 8px; margin-bottom: 6px; +} +.delay-batch-units { font-family: var(--mono); font-size: 12px; color: var(--text-bright); } +.delay-batch-release { font-family: var(--mono); font-size: 10px; color: var(--text-dim); } +.delay-batch-bar { height: 5px; border-radius: 3px; background: var(--border-soft); overflow: hidden; } +.delay-batch-bar > div { height: 100%; background: #26c6da; border-radius: 3px; } + +/* Activator live status card (16b): green while allowing, red while blocking. */ +.act-status { + border: 1px solid var(--green); border-radius: 8px; background: rgba(76,175,80,0.08); + color: var(--green); font-size: 12px; padding: 8px 11px; margin-bottom: 10px; +} +.act-status.blocking { border-color: var(--red); background: rgba(239,83,80,0.08); color: var(--red); } +.act-status:empty { display: none; } + +/* Role glyphs on the state-connection role chips keep their fixed colors. */ +.role-chips .var-chip:nth-child(2) .role-glyph { color: #ffd27a; } +.role-chips .var-chip:nth-child(3) .role-glyph { color: #8fe08f; } +.role-chips .var-chip:nth-child(4) .role-glyph { color: #7cc7ff; } +.role-glyph { font-family: var(--mono); } + +/* Selectable mode cards (14a Time panel): the active card wears lime. */ +.mode-card { + display: block; width: 100%; text-align: left; cursor: pointer; + background: var(--canvas); border: 1px solid var(--border); border-radius: 10px; + padding: 11px 13px; margin-bottom: 8px; font-family: var(--font); + transition: border-color 0.15s, background 0.15s; +} +.mode-card:hover { border-color: var(--text-dim); } +.mode-card.active { border-color: var(--accent); background: var(--accent-tint); } +.mode-card-title { font-size: 13px; font-weight: 600; color: var(--text-bright); margin-bottom: 3px; } +.mode-card.active .mode-card-title { color: var(--accent); } +.mode-card-desc { font-size: 11px; color: var(--text-dim); line-height: 1.45; } + +/* Watch panel rows (10c): mono name, tiny sparkline, mono lime value. */ +.watch-row { + display: flex; align-items: center; gap: 10px; + padding: 6px 0; border-bottom: 1px solid var(--border-soft); +} +.watch-row:last-child { border-bottom: none; } +.watch-name { + flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; color: var(--text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.watch-spark { flex-shrink: 0; display: block; } +.watch-val { flex-shrink: 0; font-family: var(--mono); font-size: 12px; color: var(--accent); } + +/* Live queue metrics readout (18c): a canvas-dark card of mono stats. */ +.queue-metrics { + margin-top: 6px; background: var(--canvas); border: 1px solid var(--border); + border-radius: 8px; padding: 6px 11px; +} .queue-stat-row { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; padding: 2px 0; font-size: 12px; } .queue-stat-label { color: var(--text-dim); } -.queue-stat-val { font-family: monospace; color: var(--text); } +.queue-stat-val { font-family: var(--mono); color: var(--text); } + +/* ── Touch layout (17a/b) ───────────────────────────────────────────────── + Under 768px the shell reorganises: a collapsed topbar (logo, 44px Run, + step chip, ☰ overflow), the palette as a bottom bar of 52px tiles, the + props panel as a bottom sheet sliding over the canvas, and a floating + status pill. Desktop is untouched. */ +.mobile-only { display: none; } + +@media (max-width: 768px) { + .mobile-only { display: inline-flex; align-items: center; justify-content: center; } + + /* Collapsed topbar: logo mark, Run, Step, step chip, ☰. */ + #topbar { gap: 8px; min-height: 56px; padding: 4px 10px; flex-wrap: nowrap; } + .logo-word { display: none; } + .speed-cluster, .analysis-controls, .file-controls, #btn-reset { display: none; } + .sim-cluster { height: 52px; border-radius: 10px; } + #btn-run { min-height: 44px; min-width: 88px; font-size: 14px; } + #btn-step { min-height: 44px; } + #step-counter.tb-chip { height: 44px; border-radius: 9px; } + #btn-mobile-menu { width: 44px; min-height: 44px; margin-left: auto; flex-shrink: 0; } + + /* Status becomes a floating pill over the canvas (17a). */ + #sim-status { + position: fixed; top: 68px; left: 12px; z-index: 15; + background: var(--panel); border: 1px solid var(--border); + border-radius: 15px; padding: 5px 12px; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); + } + #sim-status:empty { display: none; } + + /* Canvas on top, palette as a horizontally scrolling bottom bar. */ + #main { flex-direction: column; } + #canvas-wrap { order: 1; } + #diagram-rail { display: none; } + #props-panel { order: 3; } + #palette { + order: 2; position: relative; width: 100%; flex-shrink: 0; + flex-direction: row; align-items: center; gap: 4px; + overflow-x: auto; overflow-y: hidden; + border-right: none; border-top: 1px solid var(--border); + padding: 16px 10px 10px; + scrollbar-width: none; + } + #palette::before { + content: ''; position: absolute; top: 5px; left: 50%; transform: translateX(-50%); + width: 36px; height: 4px; border-radius: 2px; background: var(--border); + } + .palette-section { width: auto; flex-direction: row; align-items: center; flex-shrink: 0; } + .palette-section + .palette-section { border-top: none; border-left: 1px solid var(--border-soft); } + .palette-header { display: none; } + .palette-items, .palette-header[aria-expanded="false"] + .palette-items { + display: flex; flex-direction: row; gap: 6px; padding: 0 8px; width: auto; + } + [data-tool] { width: 52px; min-height: 52px; padding: 6px 2px; border-radius: 12px; } + [data-section="tools"] [data-tool], [data-section="connect"] [data-tool] { width: 56px; min-height: 56px; } + .pal-kbd { display: none; } + + /* Props panel becomes a bottom sheet sliding over the canvas (17b). The + body.props-open class is set whenever there is a selection or an active + rail feature to show. */ + #props-panel { + position: fixed; left: 0; right: 0; bottom: 0; width: auto; max-height: 58vh; + border-left: none; border-top: 1px solid var(--border); + border-radius: 18px 18px 0 0; z-index: 30; + transform: translateY(105%); transition: transform 0.25s ease; + box-shadow: 0 -12px 40px rgba(0,0,0,0.5); padding-top: 12px; + } + body.props-open #props-panel { transform: translateY(0); } + #props-panel::before { + content: ''; position: absolute; top: 6px; left: 50%; transform: translateX(-50%); + width: 36px; height: 4px; border-radius: 2px; background: var(--border); + } + /* Touch targets inside the sheet. */ + #props-panel .btn { min-height: 44px; } + #props-panel .prop-row input, #props-panel .prop-row select, + #props-panel .wide-input, #props-panel .var-wide-input { min-height: 42px; } + + /* The timeline gives the canvas room back on small screens. */ + #timeline { height: 150px; } +} + +/* Coarse pointers get 44px-class touch targets even above the breakpoint. */ +@media (pointer: coarse) { + .btn { min-height: 40px; } + [data-tool], .rail-btn { min-height: 52px; } +} /* ── Embed mode (clean, shareable view) ── */ body.embed #palette, @@ -1040,3 +1418,9 @@ body.embed #props-panel, body.embed #diagram-rail, body.embed .file-controls, body.embed .analysis-controls { display: none; } +body.embed #topbar { min-height: 44px; } +.embed-open { + margin-left: auto; font-size: 11px; color: var(--text-faint); white-space: nowrap; +} +.embed-open a { color: var(--text-dim); text-decoration: none; } +.embed-open a:hover { color: var(--accent); } diff --git a/favicon.svg b/favicon.svg index 0c87fd6..e5041cf 100644 --- a/favicon.svg +++ b/favicon.svg @@ -1,9 +1,9 @@ - - - - - - - + + + + + + + diff --git a/index.html b/index.html index 6dbab1a..a824f44 100644 --- a/index.html +++ b/index.html @@ -15,16 +15,18 @@
@@ -34,10 +36,11 @@
+
- Step: 0 + Step 0 @@ -55,13 +58,13 @@ - - + +
@@ -137,8 +143,8 @@ -
-
Starter templates
-
+
+ + +
-
-
Components
-
- - +
+
+
+ + +
+
+
+
+ +
+
+
Reusable node clusters
+
+ + +
+
-
-
-
Your diagrams
-
- - +
+
+
Starter templates
+
-
diff --git a/js/app-analysis.js b/js/app-analysis.js index 9443610..ee78f74 100644 --- a/js/app-analysis.js +++ b/js/app-analysis.js @@ -53,8 +53,13 @@ class AppAnalysis { // Sets this._mcCancel, which every runner passes to the engine as shouldCancel. _mcBeginProgress(out, label) { this._mcCancel = false; - out.innerHTML = '

' - + '

'; + // Mono status line + slim lime progress bar + a red-outline Cancel. The + // block is rendered once; updates only touch the text and the bar width, + // so the click handler survives. + out.innerHTML = '

' + + '' + + '

' + + '
'; out.querySelector('.mc-prog-text').textContent = label; document.getElementById('mc-cancel').addEventListener('click', () => { this._mcCancel = true; @@ -64,10 +69,14 @@ class AppAnalysis { this._mcSetRunning(true); } - _mcSetProgress(out, label) { + _mcSetProgress(out, label, done = null, total = null) { const t = out.querySelector('.mc-prog-text'); if (t) t.textContent = label; else out.innerHTML = `

${this._esc(label)}

`; + if (done != null && total > 0) { + const bar = out.querySelector('.mc-progress-bar > div'); + if (bar) bar.style.width = `${Math.round(done / total * 100)}%`; + } } // Disable the three run buttons during a batch (and restore each to its prior @@ -101,7 +110,7 @@ class AppAnalysis { const res = await this.engine.runMonteCarloAsync(runs, steps, { seed: this._mcSeed() || null, shouldCancel: () => this._mcCancel, - onProgress: (done, total) => this._mcSetProgress(out, `Running… ${done} / ${total}`), + onProgress: (done, total) => this._mcSetProgress(out, `Running… ${done} / ${total}`, done, total), }); if (!res) { out.innerHTML = '

Cancelled.

'; return; } const ms = Math.round(performance.now() - t0); @@ -227,7 +236,8 @@ class AppAnalysis { seed, shouldCancel: () => this._mcCancel, onProgress: (done, total) => this._mcSetProgress(out, - `Sweeping ${name} = ${values[i]} (${i + 1}/${values.length}), run ${done}/${total}`), + `Sweeping ${name} = ${values[i]} (${i + 1}/${values.length}), run ${done}/${total}`, + i * total + done, values.length * total), }); if (!res) { out.innerHTML = '

Cancelled.

'; return; } results.push(res); @@ -302,7 +312,8 @@ class AppAnalysis { const totalBatches = 1 + params.length * 2; let batch = 0; const prog = (label) => (done, total) => this._mcSetProgress(out, - `Sensitivity: ${label} (batch ${batch}/${totalBatches}), run ${done}/${total}`); + `Sensitivity: ${label} (batch ${batch}/${totalBatches}), run ${done}/${total}`, + (batch - 1) * total + done, totalBatches * total); const cancelled = () => { out.innerHTML = '

Cancelled.

'; }; try { @@ -466,7 +477,7 @@ class AppAnalysis { inp.type = 'text'; inp.value = src; inp.placeholder = 'always gold < 500'; - inp.style.cssText = 'flex:1;font-family:monospace;font-size:11px;min-width:0;'; + inp.style.cssText = 'flex:1;font-family:var(--mono);font-size:11px;min-width:0;'; const validate = () => { let ok = true; try { parseAssertion(inp.value); } catch { ok = false; } @@ -663,7 +674,7 @@ class AppAnalysis { chain.style.cssText = 'font-size:11px;word-break:break-word;'; chain.textContent = [...loop.labels, loop.labels[0]].join(' → '); const detail = document.createElement('div'); - detail.style.cssText = 'font-size:10px;color:var(--text-dim);font-family:monospace;'; + detail.style.cssText = 'font-size:10px;color:var(--text-dim);font-family:var(--mono);'; detail.textContent = loop.links.map(l => `${signGlyph(l.sign)} ${l.kinds.join('/')}`).join(', '); body.appendChild(chain); body.appendChild(detail); @@ -704,8 +715,8 @@ class AppAnalysis { pop.id = 'why-popover'; pop.setAttribute('role', 'dialog'); pop.setAttribute('aria-label', `Change breakdown for ${data.label}`); - pop.style.cssText = 'position:fixed;z-index:1000;max-width:300px;min-width:190px;' - + 'background:var(--panel);border:1px solid var(--border);border-radius:8px;' + pop.style.cssText = 'position:fixed;z-index:1000;max-width:300px;min-width:230px;' + + 'background:var(--panel);border:1px solid var(--accent);border-radius:10px;' + 'padding:10px 12px;font-size:11px;color:var(--text);box-shadow:0 8px 24px rgba(0,0,0,.45);'; const head = document.createElement('div'); @@ -720,7 +731,7 @@ class AppAnalysis { pop.appendChild(head); const deltaLine = document.createElement('div'); - deltaLine.style.cssText = 'font-family:monospace;font-size:11px;margin-bottom:6px;'; + deltaLine.style.cssText = 'font-family:var(--mono);font-size:11px;margin-bottom:6px;'; deltaLine.textContent = data.initial ? `starts at ${fmt(data.to)}` : `${fmt(data.from)} → ${fmt(data.to)} (Δ ${signed(data.delta)})`; @@ -730,7 +741,7 @@ class AppAnalysis { const r = document.createElement('div'); r.style.cssText = 'display:flex;gap:8px;align-items:baseline;margin:2px 0;'; const a = document.createElement('span'); - a.style.cssText = 'font-family:monospace;min-width:44px;text-align:right;flex-shrink:0;' + a.style.cssText = 'font-family:var(--mono);min-width:44px;text-align:right;flex-shrink:0;' + `color:${amount > 0 ? 'var(--green)' : (amount < 0 ? 'var(--red)' : 'var(--text-dim)')};`; a.textContent = signed(amount); const t = document.createElement('span'); @@ -821,7 +832,7 @@ class AppAnalysis { r.style.cssText = 'display:flex;align-items:baseline;gap:6px;margin:3px 0;'; r.appendChild(chip(pass, pass ? 'PASS' : 'FAIL')); const t = document.createElement('span'); - t.style.cssText = 'font-family:monospace;font-size:11px;word-break:break-word;'; + t.style.cssText = 'font-family:var(--mono);font-size:11px;word-break:break-word;'; t.textContent = label; r.appendChild(t); container.appendChild(r); diff --git a/js/app-clipboard.js b/js/app-clipboard.js index 2e48b41..e3c73a0 100644 --- a/js/app-clipboard.js +++ b/js/app-clipboard.js @@ -61,7 +61,10 @@ class AppClipboard { // old right-click-to-delete gesture with a discoverable menu, and surfaces the // otherwise keyboard-only actions (copy, paste, duplicate, save-as-component). - _showContextMenu(ctx, x, y) { + // Generic popup on the shared ctx-menu chrome: `build(add, sep)` fills the + // items, then the menu is positioned, focus-managed and dismissed exactly + // like the canvas context menu. Also used by the Library row "…" overflow. + _openMenu(x, y, build) { this._hideContextMenu(); // clear any prior instance (and its listeners) const menu = document.getElementById('ctx-menu'); menu.innerHTML = ''; @@ -82,25 +85,8 @@ class AppClipboard { menu.appendChild(b); }; const sep = () => { const d = document.createElement('div'); d.className = 'menu-sep'; d.setAttribute('role', 'separator'); menu.appendChild(d); }; - const hasClip = !!(this._clipboard && this._clipboard.nodes && this._clipboard.nodes.length); - if (ctx.kind === 'node') { - const n = ctx.count || 1; - const noun = n > 1 ? `${n} nodes` : 'node'; - add('Duplicate', 'clone', () => this._duplicate(), { shortcut: 'Ctrl+D' }); - add('Copy', 'copy', () => this._copy(), { shortcut: 'Ctrl+C' }); - add('Save as component…', 'shapes', () => this._saveComponentPrompt()); - sep(); - add(`Delete ${noun}`, 'trash-can', () => this._contextDelete(ctx), { shortcut: 'Del', danger: true }); - } else if (ctx.kind === 'element') { - const nouns = { conn: 'connection', group: 'group', note: 'note', chart: 'chart' }; - add(`Delete ${nouns[ctx.type] || 'item'}`, 'trash-can', () => this._contextDelete(ctx), { shortcut: 'Del', danger: true }); - } else { - add('Paste', 'paste', () => this._paste(), { shortcut: 'Ctrl+V', disabled: !hasClip }); - add('Select all', 'object-group', () => this._selectAll(), { shortcut: 'Ctrl+A' }); - sep(); - add('Fit to view', 'expand', () => this.renderer.fitView(), { shortcut: 'Ctrl+0' }); - } + build(add, sep); // When opened by keyboard (Shift+F10 / Menu key), x and y are 0 — centre on the canvas. if (!x && !y) { @@ -148,6 +134,29 @@ class AppClipboard { menu.addEventListener('keydown', this._ctxKeyNav); } + _showContextMenu(ctx, x, y) { + const hasClip = !!(this._clipboard && this._clipboard.nodes && this._clipboard.nodes.length); + this._openMenu(x, y, (add, sep) => { + if (ctx.kind === 'node') { + const n = ctx.count || 1; + const noun = n > 1 ? `${n} nodes` : 'node'; + add('Duplicate', 'clone', () => this._duplicate(), { shortcut: 'Ctrl+D' }); + add('Copy', 'copy', () => this._copy(), { shortcut: 'Ctrl+C' }); + add('Save as component…', 'shapes', () => this._saveComponentPrompt()); + sep(); + add(`Delete ${noun}`, 'trash-can', () => this._contextDelete(ctx), { shortcut: 'Del', danger: true }); + } else if (ctx.kind === 'element') { + const nouns = { conn: 'connection', group: 'group', note: 'note', chart: 'chart' }; + add(`Delete ${nouns[ctx.type] || 'item'}`, 'trash-can', () => this._contextDelete(ctx), { shortcut: 'Del', danger: true }); + } else { + add('Paste', 'paste', () => this._paste(), { shortcut: 'Ctrl+V', disabled: !hasClip }); + add('Select all', 'object-group', () => this._selectAll(), { shortcut: 'Ctrl+A' }); + sep(); + add('Fit to view', 'expand', () => this.renderer.fitView(), { shortcut: 'Ctrl+0' }); + } + }); + } + _hideContextMenu() { const menu = document.getElementById('ctx-menu'); if (!menu || menu.classList.contains('hidden')) return; @@ -192,6 +201,7 @@ class AppClipboard { _saveComponentPrompt() { if (!this.editor.selection.size) { this._toast('Select nodes first to save a component.'); return; } this._openLibrary(); + this._setLibraryTab('components'); const input = document.getElementById('comp-name'); if (input) { input.scrollIntoView({ block: 'center' }); input.focus(); } } diff --git a/js/app-export.js b/js/app-export.js index de44220..d68b954 100644 --- a/js/app-export.js +++ b/js/app-export.js @@ -49,7 +49,7 @@ class AppExport { // The grid/dot patterns track the live pan/zoom; content exports untransformed. for (const p of defs.querySelectorAll('pattern')) p.removeAttribute('patternTransform'); out.appendChild(defs); - const bg = r._bgRect.getAttribute('fill') || '#0f1117'; + const bg = r._bgRect.getAttribute('fill') || '#0d0e11'; out.appendChild(svgEl('rect', { x, y, width: w, height: h, fill: bg })); out.appendChild(svgEl('rect', { x, y, width: w, height: h, fill: 'url(#grid)' })); out.appendChild(content); diff --git a/js/app-fields.js b/js/app-fields.js index 0ed28f9..c359e6e 100644 --- a/js/app-fields.js +++ b/js/app-fields.js @@ -62,6 +62,46 @@ class AppFields { panel.appendChild(wrap); } + // Hero readout card (design 4a): the node's headline number, big and mono on + // a canvas-dark card, with a context line and the history sparkline below. + _heroCard(panel, node) { + const card = document.createElement('div'); + card.className = 'hero-card'; + + const label = document.createElement('div'); + label.className = 'hero-card-label'; + label.textContent = + node.type === NodeType.DRAIN ? 'Consumed' : + node.type === NodeType.TRADER ? 'Trades' : + node.type === NodeType.REGISTER ? 'Value' : + node.type === NodeType.QUEUE ? 'In line' : 'Current'; + card.appendChild(label); + + const val = document.createElement('div'); + val.className = 'hero-card-value'; + val.id = 'props-hero-value'; + val.textContent = String(node.displayCount); + card.appendChild(val); + + let subText = ''; + if (node.capacity !== undefined && isFinite(node.capacity)) subText = `of ${node.capacity} capacity`; + else if (node.type === NodeType.DRAIN || node.type === NodeType.TRADER) subText = 'this run'; + else if (node.type === NodeType.REGISTER && node.formula) subText = `ƒx ${node.formula}`; + if (subText) { + const sub = document.createElement('div'); + sub.className = 'hero-card-sub'; + sub.textContent = subText; + card.appendChild(sub); + } + + panel.appendChild(card); + + const sl = new Sparkline(card, node.id, this.engine); + this._sparklines.set(node.id, sl); + sl.update(); + return card; + } + _info(panel, text) { const p = document.createElement('p'); p.className = 'props-info'; @@ -300,6 +340,28 @@ class AppFields { return picker; } + // A row of mode chips (16a): one always active, click to switch. `options` + // is [value, label] pairs or plain strings. + _chipRow(panel, options, value, onChange, ariaLabel = 'Mode') { + const row = document.createElement('div'); + row.className = 'var-chip-group chip-row'; + row.setAttribute('role', 'radiogroup'); + row.setAttribute('aria-label', ariaLabel); + for (const opt of options) { + const [v, t] = Array.isArray(opt) ? opt : [opt, opt.charAt(0).toUpperCase() + opt.slice(1)]; + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'var-chip' + (v === value ? ' active' : ''); + b.setAttribute('role', 'radio'); + b.setAttribute('aria-checked', String(v === value)); + b.textContent = t; + b.addEventListener('click', () => { if (v !== value) onChange(v); }); + row.appendChild(b); + } + panel.appendChild(row); + return row; + } + _select2(panel, label, options, value, onChange) { const row = document.createElement('div'); row.className = 'prop-row'; @@ -328,17 +390,10 @@ class AppFields { const node = this.diagram.nodes.get(this._selectedId); if (!node || node.type === NodeType.SOURCE) return; - if (node.type === NodeType.REGISTER) { - const rv = document.querySelector('#props-content .reg-value'); - if (rv) rv.textContent = `= ${node.displayCount}`; - return; - } - - if (node.type === NodeType.DRAIN) { - const el = document.querySelector('#props-content .drain-stat'); - if (el) el.textContent = `${node.drained || 0}`; - return; - } + // Big hero readout tracks the live value for every node kind. + const hero = document.getElementById('props-hero-value'); + if (hero) hero.textContent = String(node.displayCount); + if (node.type === NodeType.REGISTER || node.type === NodeType.DRAIN) return; // First number input is always the Resources field. const inp = document.querySelector('#props-content input[type="number"]'); diff --git a/js/app-library.js b/js/app-library.js index bf6725c..ce8d2e8 100644 --- a/js/app-library.js +++ b/js/app-library.js @@ -35,15 +35,26 @@ class AppLibrary { this._modalize('lib-overlay'); document.getElementById('lib-save').addEventListener('click', () => { const name = document.getElementById('lib-name').value.trim() || 'Untitled'; - const lib = this._getLibrary(); - lib.push({ name, date: new Date().toLocaleString(), json: this._snapshot() }); - if (!this._saveLibrary(lib)) { - this._toast(`Could not save “${name}”. Browser storage is full or blocked.`); - return; - } - document.getElementById('lib-name').value = ''; - this._renderLibraryList(); - this._toast(`Saved “${name}” to your Library`); + // Capture a small canvas snapshot so the row is recognisable at a glance + // (15b). Falls back to a blank thumb when rasterizing is unavailable. + this._captureThumbnail((thumb) => { + const lib = this._getLibrary(); + const entry = { name, date: new Date().toLocaleString(), json: this._snapshot(), nodes: this.diagram.nodes.size }; + if (thumb) entry.thumb = thumb; + lib.push(entry); + if (!this._saveLibrary(lib)) { + // Thumbnails are the bulkiest part of an entry; retry without one + // before declaring storage full. + delete entry.thumb; + if (!this._saveLibrary(lib)) { + this._toast(`Could not save “${name}”. Browser storage is full or blocked.`); + return; + } + } + document.getElementById('lib-name').value = ''; + this._renderLibraryList(); + this._toast(`Saved “${name}” to your Library`); + }); }); document.getElementById('comp-save').addEventListener('click', () => this._saveComponent()); // Enter in either name field commits its save, so the right-click @@ -54,6 +65,30 @@ class AppLibrary { document.getElementById('lib-name').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); document.getElementById('lib-save').click(); } }); + + // Tabs: one focused list per view. Counts update on every open/save. + for (const key of ['mine', 'components', 'templates']) { + document.getElementById(`lib-tab-${key}`) + .addEventListener('click', () => this._setLibraryTab(key)); + } + } + + _setLibraryTab(key) { + this._libTab = key; + for (const k of ['mine', 'components', 'templates']) { + const active = k === key; + const tab = document.getElementById(`lib-tab-${k}`); + tab.classList.toggle('active', active); + tab.setAttribute('aria-selected', active ? 'true' : 'false'); + document.getElementById(`lib-pane-${k}`).classList.toggle('hidden', !active); + } + } + + _updateLibraryCounts() { + const set = (k, n) => { document.getElementById(`lib-count-${k}`).textContent = n ? String(n) : ''; }; + set('mine', this._getLibrary().length); + set('components', this._getComponents().length); + set('templates', this._templates.length); } _getLibrary() { @@ -122,6 +157,7 @@ class AppLibrary { _renderComponentsList() { const list = this._getComponents(); const el = document.getElementById('lib-components'); + this._updateLibraryCounts(); el.innerHTML = ''; if (!list.length) { el.innerHTML = '

No components yet. Select nodes on the canvas, then click Save component.

'; @@ -163,6 +199,10 @@ class AppLibrary { this._renderTemplates(); this._renderComponentsList(); this._renderLibraryList(); + this._updateLibraryCounts(); + // First run (nothing saved yet) opens on Templates; otherwise keep the + // last-used tab, defaulting to the user's own diagrams. + this._setLibraryTab(this._libTab || (this._getLibrary().length ? 'mine' : 'templates')); this._showModal('lib-overlay'); } @@ -201,6 +241,7 @@ class AppLibrary { _renderLibraryList() { const lib = this._getLibrary(); const el = document.getElementById('lib-list'); + this._updateLibraryCounts(); el.innerHTML = ''; if (!lib.length) { el.innerHTML = '

No saved diagrams yet. Save the current diagram with a name above.

'; @@ -210,56 +251,129 @@ class AppLibrary { const entry = lib[i]; const row = document.createElement('div'); row.className = 'lib-row'; + + // Thumbnail (44×30) makes each saved diagram recognisable (15b). + const thumb = document.createElement('div'); + thumb.className = 'lib-thumb'; + if (entry.thumb) { + const img = document.createElement('img'); + img.src = entry.thumb; img.alt = ''; + thumb.appendChild(img); + } + row.appendChild(thumb); + const info = document.createElement('div'); info.className = 'lib-info'; - info.innerHTML = `${this._esc(entry.name)} ${this._esc(entry.date)}`; + const sub = entry.nodes != null + ? `${entry.nodes} node${entry.nodes !== 1 ? 's' : ''} · ${this._esc(entry.date)}` + : this._esc(entry.date); + info.innerHTML = `${this._esc(entry.name)}${sub}`; const btns = document.createElement('div'); btns.style.cssText = 'display:flex;gap:6px;flex-shrink:0'; const loadBtn = document.createElement('button'); loadBtn.textContent = 'Load'; loadBtn.className = 'btn'; - loadBtn.addEventListener('click', async () => { - if (!await this._confirmGuard(`Load "${entry.name}"? Your current diagram will be replaced (Ctrl+Z to undo).`, 'Load from library')) return; - // Parse + validate on a throwaway Diagram BEFORE wiping the current - // one, so a corrupt entry can't leave a wrecked diagram behind. - let data; - try { - data = JSON.parse(entry.json); - new Diagram().loadJSON(data); - } catch (err) { - this._toast(`Could not load "${entry.name}": ${err.message}. Your current diagram is unchanged.`); - return; - } - const prev = this._snapshot(); - this._clearAll(); - this.diagram.loadJSON(data); - this._applyMeta(); - this.engine.reset(); - this.renderer.balls.clear(); - this.renderer.flowFx.clear(); - this._clearSparklines(); - this.editor._select(null, null); - this.renderer.render(); - this.renderer.fitView(); - this._commitReplace(prev); - this._hideModal('lib-overlay'); - }); - const delBtn = document.createElement('button'); - delBtn.appendChild(this._faIcon('xmark')); - delBtn.setAttribute('aria-label', 'Delete saved diagram'); - delBtn.className = 'btn'; - delBtn.addEventListener('click', () => { - lib.splice(i, 1); - if (!this._saveLibrary(lib)) this._toast('Could not update the Library. Browser storage is blocked.'); - this._renderLibraryList(); + loadBtn.addEventListener('click', () => this._loadLibraryEntry(entry)); + + // Row overflow: the less-common per-entry actions live behind "…" (15b). + const moreBtn = document.createElement('button'); + moreBtn.appendChild(this._faIcon('ellipsis')); + moreBtn.setAttribute('aria-label', `More actions for "${entry.name}"`); + moreBtn.className = 'btn'; + moreBtn.addEventListener('click', (e) => { + const r = moreBtn.getBoundingClientRect(); + this._openMenu(r.left, r.bottom + 4, (add, sep) => { + add('Rename…', 'pen', () => this._renameLibraryEntry(row, entry, i)); + add('Duplicate', 'clone', () => { + const copy = { ...entry, name: `${entry.name} copy`, date: new Date().toLocaleString() }; + lib.splice(i + 1, 0, copy); + if (!this._saveLibrary(lib)) this._toast('Could not update the Library. Browser storage is blocked.'); + this._renderLibraryList(); + }); + add('Export as JSON', 'download', () => { + const a = Object.assign(document.createElement('a'), { + href: URL.createObjectURL(new Blob([entry.json], { type: 'application/json' })), + download: `${(entry.name || 'diagram').replace(/[^\w\-]+/g, '_')}.json`, + }); + a.click(); + }); + sep(); + add('Delete', 'trash-can', () => { + lib.splice(i, 1); + if (!this._saveLibrary(lib)) this._toast('Could not update the Library. Browser storage is blocked.'); + this._renderLibraryList(); + }, { danger: true }); + }); + e.stopPropagation(); }); + btns.appendChild(loadBtn); - btns.appendChild(delBtn); + btns.appendChild(moreBtn); row.appendChild(info); row.appendChild(btns); el.appendChild(row); } } + + async _loadLibraryEntry(entry) { + if (!await this._confirmGuard(`Load "${entry.name}"? Your current diagram will be replaced (Ctrl+Z to undo).`, 'Load from library')) return; + // Parse + validate on a throwaway Diagram BEFORE wiping the current + // one, so a corrupt entry can't leave a wrecked diagram behind. + let data; + try { + data = JSON.parse(entry.json); + new Diagram().loadJSON(data); + } catch (err) { + this._toast(`Could not load "${entry.name}": ${err.message}. Your current diagram is unchanged.`); + return; + } + const prev = this._snapshot(); + this._clearAll(); + this.diagram.loadJSON(data); + this._applyMeta(); + this.engine.reset(); + this.renderer.balls.clear(); + this.renderer.flowFx.clear(); + this._clearSparklines(); + this.editor._select(null, null); + this.renderer.render(); + this.renderer.fitView(); + this._commitReplace(prev); + this._hideModal('lib-overlay'); + } + + // Rename in place: the row's name swaps for an input; Enter or blur commits, + // Escape cancels. No native prompt() — matches the app's styled dialogs rule. + _renameLibraryEntry(row, entry, index) { + const nameEl = row.querySelector('.lib-info b'); + if (!nameEl) return; + const input = document.createElement('input'); + input.type = 'text'; + input.value = entry.name; + input.className = 'wide-input'; + input.style.marginBottom = '0'; + input.setAttribute('aria-label', 'New name'); + nameEl.replaceWith(input); + input.focus(); input.select(); + let done = false; + const commit = (save) => { + if (done) return; + done = true; + const name = input.value.trim(); + if (save && name && name !== entry.name) { + const lib = this._getLibrary(); + if (lib[index]) { lib[index].name = name; } + if (!this._saveLibrary(lib)) this._toast('Could not update the Library. Browser storage is blocked.'); + } + this._renderLibraryList(); + }; + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); commit(true); } + else if (e.key === 'Escape') { e.preventDefault(); commit(false); } + e.stopPropagation(); + }); + input.addEventListener('blur', () => commit(true)); + } } for (const [k, d] of Object.entries(Object.getOwnPropertyDescriptors(AppLibrary.prototype))) { diff --git a/js/app-props.js b/js/app-props.js index e9927f2..8e21b3e 100644 --- a/js/app-props.js +++ b/js/app-props.js @@ -27,6 +27,11 @@ class AppProps { panel.innerHTML = ''; this._clearSparklines(); + // Touch layout: the props panel is a bottom sheet that only slides up when + // there is something to show (a selection or an active rail feature). + document.body.classList.toggle('props-open', + !!(this._selectedId || this._activeFeature || this._selCount > 1)); + // A diagram-rail feature takes over the panel when active, replacing the // selection view until it's toggled off (or a node/connection is selected). if (this._activeFeature) { @@ -101,13 +106,15 @@ class AppProps { document.head.appendChild(link); } if (link.getAttribute('href') !== href) link.setAttribute('href', href); - rootStyle.setProperty('--font', `'${meta.font}', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`); + rootStyle.setProperty('--font', `'${meta.font}', 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`); } else { if (link) link.remove(); rootStyle.removeProperty('--font'); } document.title = meta.name ? `${meta.name} (Simulations)` : 'Simulations Economy Designer'; + const embedTitle = document.getElementById('embed-title'); + if (embedTitle) embedTitle.textContent = meta.name ? `${meta.name} · ` : ''; } // Rasterize the live SVG canvas into a small data-URL thumbnail. Async: @@ -231,7 +238,7 @@ class AppProps { const bgRow = document.createElement('div'); bgRow.className = 'sim-bg-row'; const bg = document.createElement('input'); - bg.type = 'color'; bg.value = meta.bgColor || '#0f1117'; + bg.type = 'color'; bg.value = meta.bgColor || '#0d0e11'; bg.addEventListener('input', () => { meta.bgColor = bg.value; this.renderer.setBackground(meta.bgColor); @@ -241,7 +248,7 @@ class AppProps { bgReset.className = 'btn'; bgReset.textContent = 'Reset'; bgReset.addEventListener('click', () => { meta.bgColor = ''; - bg.value = '#0f1117'; + bg.value = '#0d0e11'; this.renderer.setBackground(''); this._commit(); }); @@ -252,7 +259,7 @@ class AppProps { const fontSel = document.createElement('select'); fontSel.className = 'wide-input'; const defOpt = document.createElement('option'); - defOpt.value = ''; defOpt.textContent = 'Inter (default)'; + defOpt.value = ''; defOpt.textContent = 'Space Grotesk (default)'; fontSel.appendChild(defOpt); for (const f of GOOGLE_FONTS) { const o = document.createElement('option'); @@ -405,15 +412,32 @@ class AppProps { }); } - // Time mode (synchronous turn-based vs asynchronous per-node rhythm). + // Time mode (synchronous turn-based vs asynchronous per-node rhythm), + // presented as two selectable mode cards (14a). _timeModeEditor(panel) { const tm = this.diagram.timeMode || 'sync'; - this._select2(panel, 'Time mode', ['sync', 'async'], tm, v => { - this.diagram.timeMode = v; this._renderProps(); this._commit(); - }); - this._info(panel, tm === 'async' - ? 'Asynchronous: each automatic node fires on its own "Fire every" rhythm (set per node).' - : 'Synchronous (turn-based): every automatic node fires once per step.'); + for (const [key, title, desc] of [ + ['sync', 'Synchronous', 'Turn-based: every automatic node fires once per step.'], + ['async', 'Asynchronous', 'Each automatic node fires on its own "Fire every" rhythm, set per node.'], + ]) { + const card = document.createElement('button'); + card.type = 'button'; + card.className = 'mode-card' + (tm === key ? ' active' : ''); + card.setAttribute('role', 'radio'); + card.setAttribute('aria-checked', String(tm === key)); + const h = document.createElement('div'); + h.className = 'mode-card-title'; + h.textContent = title; + const p = document.createElement('div'); + p.className = 'mode-card-desc'; + p.textContent = desc; + card.append(h, p); + card.addEventListener('click', () => { + if (this.diagram.timeMode === key) return; + this.diagram.timeMode = key; this._renderProps(); this._commit(); + }); + panel.appendChild(card); + } } // ── Scenario branching panel ────────────────────────────────────────────── @@ -577,7 +601,7 @@ class AppProps { kept = this._saveBranch(); } this.engine.restoreState(cp.state); - document.getElementById('step-counter').textContent = `Step: ${this.engine.step}`; + document.getElementById('step-counter').textContent = `Step ${this.engine.step}`; document.getElementById('sim-status').textContent = ''; this.renderer.balls.clear(); this.renderer.flowFx.clear(); @@ -654,14 +678,48 @@ class AppProps { panel.appendChild(p); return; } + // A variable published by a node (register output, named state connection) + // has a history series to sparkline; parameters and custom vars may not. + const seriesNodeId = (name) => { + for (const n of this.diagram.nodes.values()) + if (n.type === NodeType.REGISTER && n.label === name) return n.id; + for (const c of this.diagram.connections.values()) + if (c.type === ConnectionType.STATE && c.variableName === name) return c.sourceId; + return null; + }; + const hist = this.engine.history || []; for (const [k, v] of vars) { const row = document.createElement('div'); - row.className = 'prop-row'; - const kl = document.createElement('label'); kl.textContent = k; + row.className = 'watch-row'; + const kl = document.createElement('span'); + kl.className = 'watch-name'; + kl.textContent = k; + row.appendChild(kl); + + // 64×18 sparkline when the variable has a recorded series. + const nid = seriesNodeId(k); + if (nid && hist.length >= 2) { + const cv = document.createElement('canvas'); + cv.width = 64; cv.height = 18; + cv.className = 'watch-spark'; + const vals = hist.map(h => h.snap[nid] ?? 0); + const min = Math.min(...vals), max = Math.max(...vals); + const ctx = cv.getContext('2d'); + ctx.strokeStyle = '#b6e94d'; ctx.lineWidth = 1; + ctx.beginPath(); + vals.forEach((val, i) => { + const x = (i / (vals.length - 1)) * 63 + 0.5; + const y = max - min < 1e-9 ? 9 : 16.5 - ((val - min) / (max - min)) * 15; + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + }); + ctx.stroke(); + row.appendChild(cv); + } + const vl = document.createElement('span'); - vl.style.cssText = 'color:var(--accent);font-family:monospace;font-size:12px;'; - vl.textContent = typeof v === 'number' ? (+v.toFixed(3)) : v; - row.appendChild(kl); row.appendChild(vl); + vl.className = 'watch-val'; + vl.textContent = typeof v === 'number' ? String(+v.toFixed(3)) : String(v); + row.appendChild(vl); panel.appendChild(row); } } @@ -1040,14 +1098,18 @@ class AppProps { ['Max wait', processed > 0 ? `${node.maxWait || 0} steps` : 'n/a'], ['Peak line', String(node.maxLen || 0)], ]; - if (node.maxLine > 0) rows.push(['Balked (line full)', String(node.balked || 0)]); - if (node.patience > 0) rows.push(['Reneged (gave up)', String(node.reneged || 0)]); + if (node.maxLine > 0) rows.push(['Balked (line full)', String(node.balked || 0), 'loss']); + if (node.patience > 0) rows.push(['Reneged (gave up)', String(node.reneged || 0), 'loss']); + // Throughput leads in lime; losses read in red (18c). + rows[2] = ['Processed', String(processed), 'lead']; container.innerHTML = ''; - for (const [label, val] of rows) { + for (const [label, val, tone] of rows) { const row = document.createElement('div'); row.className = 'queue-stat-row'; const l = document.createElement('span'); l.className = 'queue-stat-label'; l.textContent = label; const v = document.createElement('span'); v.className = 'queue-stat-val'; v.textContent = val; + if (tone === 'lead') v.style.color = 'var(--accent)'; + else if (tone === 'loss' && val !== '0') v.style.color = 'var(--red)'; row.appendChild(l); row.appendChild(v); container.appendChild(row); } @@ -1089,6 +1151,49 @@ class AppProps { } const totEl = document.getElementById('diagram-totals'); if (totEl) this._fillTotals(totEl); + + // Connection readouts (live flow, activator status, delay batches). + if (this._selectedType === 'conn') { + const conn = this.diagram.connections.get(this._selectedId); + const liveEl = document.getElementById('conn-live'); + if (conn && liveEl) this._fillConnLive(liveEl, conn); + const actEl = document.getElementById('act-status'); + if (conn && actEl) this._fillActStatus(actEl, conn); + } + const dbEl = document.getElementById('delay-batches'); + if (dbEl && this._selectedType === 'node') { + const node = this.diagram.nodes.get(this._selectedId); + if (node && node.type === NodeType.DELAY) this._fillDelayBatches(dbEl, node); + } + } + + // In-flight batches held by a delay (18d): units, a progress bar filling as + // the batch matures, and the step it will release on. + _fillDelayBatches(container, node) { + container.innerHTML = ''; + const q = node._queue || []; + if (!q.length) { container.innerHTML = '

Nothing in flight.

'; return; } + for (const b of q) { + const row = document.createElement('div'); + row.className = 'delay-batch'; + const head = document.createElement('div'); + head.className = 'delay-batch-head'; + const units = document.createElement('span'); + units.className = 'delay-batch-units'; + units.textContent = `${b.amount} unit${b.amount !== 1 ? 's' : ''}`; + const rel = document.createElement('span'); + rel.className = 'delay-batch-release'; + rel.textContent = `releases step ${this.engine.step + (b.stepsLeft || 0)}`; + head.appendChild(units); head.appendChild(rel); + const bar = document.createElement('div'); + bar.className = 'delay-batch-bar'; + const fillEl = document.createElement('div'); + const p = node.delay > 0 ? Math.max(0, Math.min(1, 1 - (b.stepsLeft || 0) / node.delay)) : 1; + fillEl.style.width = `${Math.round(p * 100)}%`; + bar.appendChild(fillEl); + row.appendChild(head); row.appendChild(bar); + container.appendChild(row); + } } _groupProps(panel, group) { @@ -1247,6 +1352,10 @@ class AppProps { const typeColor = (typeof NODE_STROKE !== 'undefined' && NODE_STROKE[node.type]) || 'var(--accent)'; this._titleTyped(panel, `${node.type} node`, node.label || '(unnamed)', typeColor, `node-${node.type}`); + // Hero readout: the node's headline value, front and centre (4a). + if (node.type !== NodeType.SOURCE) this._heroCard(panel, node); + + this._section(panel, 'Properties'); this._field(panel, 'Label', 'text', node.label, v => { node.label = v; this.renderer.render(); }); // Type-specific fields @@ -1328,13 +1437,60 @@ class AppProps { if (!hasRecipe) { // ── Legacy single-input mode ────────────────────────────────────────── - this._field(panel, 'Input / conversion', 'number', node.inputAmount, - v => { node.inputAmount = Math.max(1, parseInt(v) || 1); }); + // Consumes → emits pair card (18b): the conversion in one glance. + const pair = document.createElement('div'); + pair.className = 'conv-pair'; + const mk = (labelTxt) => { + const cell = document.createElement('div'); + cell.className = 'conv-pair-cell'; + const lab = document.createElement('div'); + lab.className = 'conv-pair-label'; + lab.textContent = labelTxt; + return { cell, lab }; + }; + const inCell = mk('consumes'); + const inInp = document.createElement('input'); + inInp.type = 'number'; inInp.min = '1'; + inInp.className = 'conv-pair-num'; + inInp.value = String(node.inputAmount); + inInp.setAttribute('aria-label', 'Resources consumed per conversion'); + inInp.addEventListener('input', () => { + node.inputAmount = Math.max(1, parseInt(inInp.value) || 1); + this.renderer.render(); + }); + inCell.cell.appendChild(inInp); inCell.cell.appendChild(inCell.lab); + const arrow = document.createElement('div'); + arrow.className = 'conv-pair-arrow'; + arrow.appendChild(this._faIcon('arrow-right')); + const emits = [...this.diagram.connections.values()] + .filter(c => c.sourceId === node.id && c.type === ConnectionType.RESOURCE) + .reduce((s, c) => s + (Number(c.rate) || 0), 0); + const outCell = mk('emits'); + const outVal = document.createElement('div'); + outVal.className = 'conv-pair-num conv-pair-ro'; + outVal.textContent = String(emits || 0); + outVal.title = 'Sum of the output connections\' rates'; + outCell.cell.appendChild(outVal); outCell.cell.appendChild(outCell.lab); + pair.appendChild(inCell.cell); pair.appendChild(arrow); pair.appendChild(outCell.cell); + panel.appendChild(pair); + this._colorField(panel, 'Output color', node.outputColor || '#ffa726', v => { node.outputColor = v; this.renderer.render(); }, false, true); this._info(panel, 'Consumes this many held resources per conversion, then emits each output connection\'s rate in the output color.'); + // Held-inputs readout: progress toward the next conversion. + const held = document.createElement('div'); + held.className = 'sim-meta'; + const heldRow = document.createElement('div'); + heldRow.className = 'sim-meta-row'; + const hl = document.createElement('span'); hl.textContent = 'Held inputs'; + const hv = document.createElement('b'); + hv.textContent = `${node.resources || 0} / ${node.inputAmount}`; + heldRow.appendChild(hl); heldRow.appendChild(hv); + held.appendChild(heldRow); + panel.appendChild(held); + const addRecipeBtn = document.createElement('button'); addRecipeBtn.className = 'btn'; addRecipeBtn.style.cssText = 'margin-top:6px;width:100%;font-size:11px;'; @@ -1437,19 +1593,10 @@ class AppProps { } if (node.type === NodeType.DRAIN) { - const stat = document.createElement('div'); - stat.className = 'reg-value drain-stat'; - stat.textContent = `${node.drained || 0}`; - panel.appendChild(stat); - this._info(panel, 'Total resources consumed (drained) this run.'); + this._info(panel, 'The hero number above is the total consumed (drained) this run.'); } if (node.type === NodeType.TRADER) { - const stat = document.createElement('div'); - stat.className = 'reg-value'; - stat.replaceChildren(this._faIcon('right-left'), - document.createTextNode(` ${node.trades || 0}`)); - panel.appendChild(stat); this._info(panel, 'Completed exchanges this run. Wire A → Trader → B: ' + 'when the trader fires, A pays the incoming connection\'s rate to B and B pays ' + 'the outgoing connection\'s rate back to A, all or nothing. Extra in/out pairs ' @@ -1458,6 +1605,11 @@ class AppProps { if (node.type === NodeType.DELAY) { this._field(panel, 'Delay steps', 'number', node.delay, v => { node.delay = Math.max(1, parseInt(v) || 1); }); + this._section(panel, 'In flight'); + const db = document.createElement('div'); + db.className = 'type-readout'; db.id = 'delay-batches'; + panel.appendChild(db); + this._fillDelayBatches(db, node); } if (node.type === NodeType.QUEUE) { @@ -1471,6 +1623,7 @@ class AppProps { this._field(panel, 'Patience', 'number', node.patience || 0, v => { node.patience = Math.max(0, parseInt(v) || 0); }, '0 = infinite; steps before a waiting unit gives up'); this._info(panel, 'Model lost demand: with a Max line, arrivals that find the line full are turned away (balk); with a Patience, a unit that waits that many steps without a server gives up (renege). Both are counted as losses below. (This drops the units, unlike a Capacity, which makes the source hold them and retry.)'); + this._section(panel, 'Live metrics'); const qm = document.createElement('div'); qm.className = 'type-readout queue-metrics'; qm.id = 'queue-metrics'; panel.appendChild(qm); @@ -1479,17 +1632,76 @@ class AppProps { if (node.type === NodeType.GATE) { if (node.gateMode === 'random') node.gateMode = 'probabilistic'; - this._select2(panel, 'Gate mode', ['deterministic', 'probabilistic', 'all'], node.gateMode, - v => { node.gateMode = v; }); - this._info(panel, 'Outputs split by each connection\'s Weight. Deterministic gives a proportional share; probabilistic is weighted random per unit; all gives each output its full weight per firing.'); + this._section(panel, 'Routing'); + this._chipRow(panel, [ + ['deterministic', 'Split'], ['probabilistic', 'Random'], ['all', 'All'], + ], node.gateMode, v => { node.gateMode = v; this._renderProps(); this.renderer.render(); }, 'Gate mode'); + this._info(panel, 'Outputs split by each connection\'s Weight. Split gives a proportional share; random is weighted chance per unit; all gives each output its full weight per firing.'); + + // Per-output weight cards (18a): slider + mono value + the computed + // share each output currently gets. + const outs = [...this.diagram.connections.values()] + .filter(c => c.sourceId === node.id && c.type === ConnectionType.RESOURCE); + if (outs.length) { + this._section(panel, 'Outputs'); + const getW = c => { + if (c.weightFormula) { const w = evalFormula(c.weightFormula, this.diagram.variables); return isFinite(w) && w >= 0 ? w : 0; } + const w = Number(c.weight); return isFinite(w) && w >= 0 ? w : 1; + }; + const totalW = outs.reduce((s, c) => s + getW(c), 0); + for (const c of outs) { + const tgtNode = this.diagram.nodes.get(c.targetId); + const card = document.createElement('div'); + card.className = 'gate-out-card'; + + const head = document.createElement('div'); + head.className = 'gate-out-head'; + const name = document.createElement('span'); + name.className = 'gate-out-name'; + name.textContent = `→ ${tgtNode ? (tgtNode.label || tgtNode.type) : '?'}`; + const pct = document.createElement('span'); + pct.className = 'gate-out-pct'; + pct.textContent = totalW > 0 ? `${Math.round(getW(c) / totalW * 100)}%` : '0%'; + head.appendChild(name); head.appendChild(pct); + card.appendChild(head); + + if (c.weightFormula) { + const f = document.createElement('div'); + f.className = 'gate-out-formula'; + f.textContent = `⚖ ${c.weightFormula}`; + f.title = 'Formula weight. Select the connection to edit it.'; + card.appendChild(f); + } else { + const row = document.createElement('div'); + row.className = 'gate-out-row'; + const slider = document.createElement('input'); + slider.type = 'range'; + slider.min = '0'; slider.max = String(Math.max(10, Math.ceil(getW(c)))); slider.step = '1'; + slider.value = String(getW(c)); + slider.setAttribute('aria-label', `Weight for ${tgtNode ? tgtNode.label : 'output'}`); + const num = document.createElement('input'); + num.type = 'number'; num.min = '0'; + num.className = 'gate-out-num'; + num.value = String(c.weight ?? 1); + num.setAttribute('aria-label', 'Weight value'); + const apply = (v) => { + const n = parseFloat(v); + c.weight = isFinite(n) ? Math.max(0, n) : 0; + this.renderer.render(); + this._renderProps(); // recompute every card's % + }; + slider.addEventListener('change', () => apply(slider.value)); + slider.addEventListener('input', () => { num.value = slider.value; }); + num.addEventListener('change', () => apply(num.value)); + row.appendChild(slider); row.appendChild(num); + card.appendChild(row); + } + panel.appendChild(card); + } + } } if (node.type === NodeType.REGISTER) { - const valRow = document.createElement('div'); - valRow.className = 'reg-value'; - valRow.textContent = `= ${node.displayCount}`; - panel.appendChild(valRow); - this._formulaField(panel, node.formula, v => { node.formula = v; this.renderer.render(); }, { showTip: false }); @@ -1540,16 +1752,7 @@ class AppProps { this._info(panel, 'Halt the simulation when this node\'s value meets the condition.'); } - // Chart - if (node.type !== NodeType.SOURCE) { - this._section(panel, 'History'); - const sec = document.createElement('div'); - sec.className = 'chart-section'; - panel.appendChild(sec); - const sl = new Sparkline(sec, node.id, this.engine); - this._sparklines.set(node.id, sl); - sl.update(); - } + // History sparkline lives in the hero card at the top of the panel. } _connProps(panel, conn) { @@ -1557,7 +1760,7 @@ class AppProps { const tgt = this.diagram.nodes.get(conn.targetId); const isRes = conn.type === ConnectionType.RESOURCE; this._titleTyped(panel, `${isRes ? 'Resource' : 'State'} connection`, - `${src?.label || '?'} → ${tgt?.label || '?'}`, isRes ? '#ffa726' : '#78909c', + `${src?.label || '?'} → ${tgt?.label || '?'}`, isRes ? '#ffa726' : '#7f879c', isRes ? 'conn-resource' : 'conn-state'); this._section(panel, 'Appearance'); @@ -1666,12 +1869,15 @@ class AppProps { rateField(); this._info(panel, 'When resources mature, each output\'s rate is its share among the delay\'s outputs.'); } else { - // Source / Pool / Converter: rate (fixed / dice / formula). + // Source / Pool / Converter: rate (fixed / dice / formula / dist). this._section(panel, 'Rate'); - this._select2(panel, 'Rate mode', Object.values(RateMode), conn.rateMode, v => { + this._chipRow(panel, [ + [RateMode.FIXED, 'Fixed'], [RateMode.DICE, 'Dice'], + [RateMode.FORMULA, 'Formula'], [RateMode.DISTRIBUTION, 'Dist'], + ], conn.rateMode, v => { conn.rateMode = v; this._renderProps(); // re-render to show the matching rate field - }); + }, 'Rate mode'); if (conn.rateMode === RateMode.FIXED) { rateField(); @@ -1767,6 +1973,13 @@ class AppProps { } } + // Live flow readout: what actually moved down this wire (16a). + this._section(panel, 'Live'); + const live = document.createElement('div'); + live.className = 'sim-meta'; live.id = 'conn-live'; + panel.appendChild(live); + this._fillConnLive(live, conn); + } else { // State connection: one primary role, picked up front so the panel only // shows the controls that matter. (Model flags stay independent, so old @@ -1783,15 +1996,21 @@ class AppProps { const chips = document.createElement('div'); chips.className = 'var-chip-group role-chips'; - for (const [key, label] of [ - ['variable', 'Sets a variable'], - ['modify', 'Modifies target'], - ['trigger', 'Triggers target'], - ['activate', 'Activates target'], + for (const [key, glyph, label] of [ + ['variable', '', 'Variable'], + ['trigger', '✷', 'Trigger'], + ['activate', '⊢', 'Activator'], + ['modify', 'Δ', 'Modifier'], ]) { const chip = document.createElement('button'); chip.className = 'var-chip' + (role === key ? ' active' : ''); - chip.textContent = label; + if (glyph) { + const g = document.createElement('span'); + g.className = 'role-glyph'; + g.textContent = glyph + ' '; + chip.appendChild(g); + } + chip.appendChild(document.createTextNode(label)); chip.addEventListener('click', () => { if (key === role) return; // First time the modifier is enabled, default to the simplest mode: @@ -1932,9 +2151,57 @@ class AppProps { this._conditionRow(panel, 'Condition', conn, { enabled: 'activator', op: 'actOperator', val: 'actValue', val2: 'actValue2', lead: 'enable target when source' }); this._info(panel, 'The target node may only fire while the source value satisfies this condition. The a..b operator is an inclusive range.'); + + // Live status: is this activator currently allowing the target (16b)? + const status = document.createElement('div'); + status.className = 'act-status'; status.id = 'act-status'; + panel.appendChild(status); + this._fillActStatus(status, conn); } } } + + // "Moved last step / total this run" rows for a resource connection, from + // the flow records the engine attaches to history entries. + _fillConnLive(container, conn) { + const hist = this.engine.history || []; + let last = 0, total = 0; + for (let i = 0; i < hist.length; i++) { + const f = hist[i].flows && hist[i].flows.conns && hist[i].flows.conns[conn.id]; + const amt = typeof f === 'number' ? f : 0; + total += amt; + if (i === hist.length - 1) last = amt; + } + container.innerHTML = ''; + for (const [label, val, lime] of [ + ['Moved last step', this._fmtFlow(last), true], + ['Total this run', this._fmtFlow(total), false], + ]) { + const row = document.createElement('div'); + row.className = 'sim-meta-row'; + const l = document.createElement('span'); l.textContent = label; + const b = document.createElement('b'); b.textContent = val; + if (lime) b.style.color = 'var(--accent)'; + row.appendChild(l); row.appendChild(b); + container.appendChild(row); + } + } + + // Green "allowing" / red "blocking" card for an activator connection. + _fillActStatus(container, conn) { + const src = this.diagram.nodes.get(conn.sourceId); + const tgt = this.diagram.nodes.get(conn.targetId); + if (!conn.activator || !src || !tgt) { container.innerHTML = ''; return; } + let ok = true; + try { + const val = this.engine._stateValueOf(src); + ok = this.engine._evalCond(val, conn.actOperator, conn.actValue, conn.actValue2); + } catch { return; } + container.classList.toggle('blocking', !ok); + container.textContent = ok + ? `Allowing: ${tgt.label || tgt.type} may fire` + : `Blocking: ${tgt.label || tgt.type} cannot fire`; + } } for (const [k, d] of Object.entries(Object.getOwnPropertyDescriptors(AppProps.prototype))) { diff --git a/js/app.js b/js/app.js index fdf087d..7d41a23 100644 --- a/js/app.js +++ b/js/app.js @@ -1,7 +1,7 @@ // UI accent color schemes selectable per simulation (meta.scheme). Each remaps // the two accent CSS variables; 'default' restores the stylesheet values. const COLOR_SCHEMES = { - default: { label: 'Ocean (default)', accent: '#4a9eff', accent2: '#ffa726' }, + default: { label: 'Lime (default)', accent: '#b6e94d', accent2: '#ffa726' }, forest: { label: 'Forest', accent: '#66bb6a', accent2: '#ffca28' }, sunset: { label: 'Sunset', accent: '#ff7043', accent2: '#ab47bc' }, candy: { label: 'Candy', accent: '#ec407a', accent2: '#26c6da' }, @@ -80,7 +80,7 @@ class App { this._initDiagramRail(); this.engine.onStep = (step, fired, transfers) => { - document.getElementById('step-counter').textContent = `Step: ${step}`; + document.getElementById('step-counter').textContent = `Step ${step}`; // Animate balls for each transfer const ballDur = Math.max(150, Math.min(1200, 700 / this.engine.speed)); @@ -109,6 +109,9 @@ class App { } } + // Dash-march the connections that actually carried resources this step. + this.renderer.setFlowing(transfers.filter(t => t.amount > 0).map(t => t.connId)); + if (fired.length) this.renderer.setFiring(fired); // ponytail: a tick with no fires and no transfers changes no node value, so // render() would only re-walk the DOM to repaint identical numbers. Skip it. @@ -448,6 +451,13 @@ class App { b.replaceChildren(this._faIcon(on ? 'pause' : 'play'), document.createTextNode(on ? ' Pause' : ' Run')); b.classList.toggle('running', on); + // Live status readout beside the step chip. Only touch it for the plain + // Running state so richer messages (goal reached) set elsewhere survive. + const status = document.getElementById('sim-status'); + if (status) { + if (on) status.textContent = 'Running'; + else if (status.textContent === 'Running') status.textContent = ''; + } } // Begin a fresh history baseline (after the initial boot / shared-link load). @@ -597,7 +607,7 @@ class App { const entry = hist[i]; this.renderer.setScrub(entry.snap); if (this._timelineVisible) this.timeline.setScrub(entry.step); - document.getElementById('step-counter').textContent = `Step: ${entry.step} (replay)`; + document.getElementById('step-counter').textContent = `Step ${entry.step} (replay)`; this._refreshScrubber(); } @@ -610,7 +620,7 @@ class App { this.timeline.setScrub(null); this._syncScrubPlayButton(); if (wasScrubbing) { - document.getElementById('step-counter').textContent = `Step: ${this.engine.step}`; + document.getElementById('step-counter').textContent = `Step ${this.engine.step}`; this.renderer.render(); if (this._activeFeature === 'monitor') this._renderProps(); } @@ -696,6 +706,18 @@ class App { const params = new URLSearchParams(location.search); if (params.has('embed') || /(^|[#&])embed\b/.test(location.hash)) { document.body.classList.add('embed'); + // Mini topbar tail: the diagram's name and a way back to the full app. + const tail = document.createElement('span'); + tail.className = 'embed-open'; + const name = document.createElement('span'); + name.id = 'embed-title'; + tail.appendChild(name); + const link = document.createElement('a'); + link.href = location.href.replace(/([?&])embed(=[^&]*)?/, '$1').replace(/[?&]$/, ''); + link.target = '_blank'; link.rel = 'noopener'; + link.textContent = 'open in Simulations ↗'; + tail.appendChild(link); + document.getElementById('topbar').appendChild(tail); } // A diagram encoded in the URL hash (#d=…) takes precedence over autosave. @@ -730,6 +752,10 @@ class App { this.renderer.fitView(); this._resetHistory(); this._renderProps(); + // Recovery banner (16h): say what came back, offer a fresh start. Only + // when there is real content to recover, and never in embed mode. + if (this.diagram.nodes.size && !document.body.classList.contains('embed')) + this._showRecoveryBanner(); return; } @@ -744,6 +770,42 @@ class App { this._renderProps(); } + // Amber banner shown after an autosave restore: keep working (dismiss) or + // discard the recovered diagram and start clean. + _showRecoveryBanner() { + const bar = document.createElement('div'); + bar.className = 'recovery-banner'; + bar.setAttribute('role', 'status'); + const icon = this._faIcon('clock-rotate-left'); + const msg = document.createElement('span'); + const name = this.diagram.meta && this.diagram.meta.name; + msg.textContent = name ? `Restored "${name}" from your last session` : 'Restored your last session'; + const keep = document.createElement('button'); + keep.className = 'btn btn-primary'; + keep.textContent = 'Keep working'; + const discard = document.createElement('button'); + discard.className = 'btn'; + discard.textContent = 'Discard'; + const close = () => bar.remove(); + keep.addEventListener('click', close); + discard.addEventListener('click', async () => { + if (!await this._confirmGuard('Discard the restored diagram and start with an empty canvas?', 'Discard restored work')) return; + close(); + const prev = this._snapshot(); + this._clearAll(); + this._applyMeta(); + this.renderer.render(); + this.renderer.resetView(); + this._commitReplace(prev); + this._renderProps(); + }); + bar.append(icon, msg, keep, discard); + document.getElementById('canvas-wrap').appendChild(bar); + // Quietly fades once the user starts editing anyway. + setTimeout(() => { if (bar.isConnected) bar.classList.add('fade'); }, 12000); + setTimeout(() => { if (bar.isConnected) bar.remove(); }, 13000); + } + // Load the built-in predator-prey demo on demand (welcome "Explore the demo"). // Undoable: Ctrl+Z returns to the empty canvas you started from. _loadDemo() { @@ -1322,6 +1384,28 @@ class App { document.getElementById('mc-sweep-run').addEventListener('click', () => this._runSweep()); document.getElementById('mc-sens-run').addEventListener('click', () => this._runSensitivity()); + // Touch layout ☰ overflow: the controls the collapsed topbar hides + // (analysis, zoom, file, help), routed to the same handlers. + document.getElementById('btn-mobile-menu').addEventListener('click', (e) => { + const r = e.currentTarget.getBoundingClientRect(); + this._openMenu(r.right - 210, r.bottom + 6, (add, sep) => { + add('Timeline chart', 'chart-line', () => document.getElementById('btn-timeline').click()); + add('Batch (Monte Carlo)…', 'dice', () => document.getElementById('btn-batch').click()); + sep(); + add('Fit to view', 'expand', () => this.renderer.fitView()); + add('Undo', 'rotate-left', () => document.getElementById('btn-undo').click()); + add('Redo', 'rotate-right', () => document.getElementById('btn-redo').click()); + sep(); + add('Library', 'book', () => this._openLibrary()); + add('New diagram', 'file', () => document.getElementById('btn-new').click()); + add('Open file…', 'folder-open', () => document.getElementById('btn-load').click()); + add('Save as JSON', 'download', () => document.getElementById('btn-save').click()); + add('Copy share link', 'link', () => document.getElementById('btn-share').click()); + sep(); + add('Help', 'circle-question', () => this._showModal('help-overlay')); + }); + }); + // Help / shortcuts overlay (also on the "?" key) document.getElementById('btn-help').addEventListener('click', () => this._showModal('help-overlay')); document.getElementById('help-close').addEventListener('click', () => this._hideModal('help-overlay')); diff --git a/js/charts.js b/js/charts.js index 760daf8..af57402 100644 --- a/js/charts.js +++ b/js/charts.js @@ -19,11 +19,11 @@ class Sparkline { ctx.clearRect(0, 0, w, h); const max = Math.max(...values, 1); - ctx.fillStyle = '#0f1117'; + ctx.fillStyle = '#0d0e11'; ctx.fillRect(0, 0, w, h); // Grid line at midpoint - ctx.strokeStyle = '#1e2535'; + ctx.strokeStyle = '#22252e'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2); ctx.stroke(); @@ -31,7 +31,7 @@ class Sparkline { const step = w / (values.length - 1); ctx.beginPath(); - ctx.strokeStyle = '#4a9eff'; + ctx.strokeStyle = '#b6e94d'; ctx.lineWidth = 1.5; values.forEach((v, i) => { const x = i * step; @@ -44,15 +44,15 @@ class Sparkline { ctx.lineTo((values.length - 1) * step, h); ctx.lineTo(0, h); ctx.closePath(); - ctx.fillStyle = 'rgba(74,158,255,0.12)'; + ctx.fillStyle = 'rgba(182,233,77,0.10)'; ctx.fill(); // Current value label - ctx.fillStyle = '#4a9eff'; - ctx.font = '11px monospace'; + ctx.fillStyle = '#b6e94d'; + ctx.font = "11px 'JetBrains Mono', monospace"; const last = values[values.length - 1]; ctx.fillText(`${last}`, w - 30, 12); - ctx.fillStyle = '#95a3bc'; + ctx.fillStyle = '#8a90a0'; ctx.fillText(`max: ${max}`, 4, 12); } @@ -287,7 +287,7 @@ class TimelineChart { const h = cv.height = cv.clientHeight || 180; const ctx = cv.getContext('2d'); ctx.clearRect(0, 0, w, h); - ctx.fillStyle = '#0f1117'; + ctx.fillStyle = '#16181d'; ctx.fillRect(0, 0, w, h); const hist = this.engine.history; @@ -328,7 +328,7 @@ class TimelineChart { const nodes = allNodes.filter(n => !this._hidden.has(n.id)); if ((hist.length < 2 && !branches.length) || !nodes.length) { - ctx.fillStyle = '#95a3bc'; + ctx.fillStyle = '#8a90a0'; ctx.font = chartFont(12); ctx.textBaseline = 'middle'; ctx.textAlign = 'left'; @@ -406,7 +406,7 @@ class TimelineChart { this._geom.yOf = yOf; // Horizontal grid lines - ctx.strokeStyle = '#1e2535'; + ctx.strokeStyle = '#22252e'; ctx.lineWidth = 1; ctx.setLineDash([3, 4]); for (const g of guides) { @@ -415,18 +415,18 @@ class TimelineChart { ctx.setLineDash([]); // Axes - ctx.strokeStyle = '#2a3550'; ctx.lineWidth = 1; + ctx.strokeStyle = '#2a2e38'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(padL, padT); ctx.lineTo(padL, padT + plotH); ctx.lineTo(padL + plotW, padT + plotH); ctx.stroke(); // Y-axis labels - ctx.font = '11px monospace'; ctx.fillStyle = '#95a3bc'; + ctx.font = "11px 'JetBrains Mono', monospace"; ctx.fillStyle = '#8a90a0'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; for (const g of guides) ctx.fillText(g.label, padL - 4, g.y); // X-axis labels at round step values - ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = '#95a3bc'; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = '#8a90a0'; const maxLabels = Math.max(2, Math.floor(plotW / 45)); const tickStep = Math.max(1, Math.ceil(maxStep / maxLabels)); // The final step gets its own label below, so skip any tick close enough @@ -436,12 +436,12 @@ class TimelineChart { const x = xAt(s); if (lastLabelX - x < 30) continue; ctx.fillText(String(s), x, padT + plotH + 5); - ctx.strokeStyle = '#2a3550'; ctx.lineWidth = 1; + ctx.strokeStyle = '#2a2e38'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, padT + plotH); ctx.lineTo(x, padT + plotH + 3); ctx.stroke(); } // Always label the last step if it wasn't already hit if (maxStep % tickStep !== 0) { - ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = '#95a3bc'; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = '#8a90a0'; ctx.fillText(String(maxStep), xAt(maxStep), padT + plotH + 5); } @@ -476,9 +476,11 @@ class TimelineChart { // Scrub playhead: a solid accent line marking the step being previewed. if (this._scrubStep != null) { const px = xAt(Math.max(0, Math.min(maxStep, this._scrubStep))); - ctx.strokeStyle = '#4a9eff'; ctx.lineWidth = 1.5; + ctx.strokeStyle = '#b6e94d'; ctx.lineWidth = 1.5; + ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(px, padT); ctx.lineTo(px, padT + plotH); ctx.stroke(); - ctx.fillStyle = '#4a9eff'; + ctx.setLineDash([]); + ctx.fillStyle = '#b6e94d'; ctx.beginPath(); ctx.moveTo(px - 4, padT); ctx.lineTo(px + 4, padT); ctx.lineTo(px, padT + 5); ctx.closePath(); ctx.fill(); @@ -488,9 +490,9 @@ class TimelineChart { if (this._drag && this._drag.moved) { const l = Math.max(padL, Math.min(padL + plotW, Math.min(this._drag.x0, this._drag.x1))); const r = Math.max(padL, Math.min(padL + plotW, Math.max(this._drag.x0, this._drag.x1))); - ctx.fillStyle = 'rgba(74,158,255,0.12)'; + ctx.fillStyle = 'rgba(182,233,77,0.10)'; ctx.fillRect(l, padT, r - l, plotH); - ctx.strokeStyle = 'rgba(74,158,255,0.6)'; ctx.lineWidth = 1; + ctx.strokeStyle = 'rgba(182,233,77,0.6)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(l, padT); ctx.lineTo(l, padT + plotH); ctx.moveTo(r, padT); ctx.lineTo(r, padT + plotH); @@ -508,15 +510,15 @@ class TimelineChart { ctx.fillStyle = 'rgba(8,10,15,0.55)'; ctx.fillRect(padL, padT, l - padL, plotH); ctx.fillRect(r, padT, padL + plotW - r, plotH); - ctx.fillStyle = 'rgba(74,158,255,0.08)'; + ctx.fillStyle = 'rgba(182,233,77,0.08)'; ctx.fillRect(l, padT, r - l, plotH); - ctx.strokeStyle = '#4a9eff'; ctx.lineWidth = 1.5; + ctx.strokeStyle = '#b6e94d'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(xa, padT); ctx.lineTo(xa, padT + plotH); ctx.moveTo(xb, padT); ctx.lineTo(xb, padT + plotH); ctx.stroke(); - ctx.font = '10px monospace'; ctx.textBaseline = 'top'; ctx.textAlign = 'center'; - ctx.fillStyle = '#4a9eff'; + ctx.font = "10px 'JetBrains Mono', monospace"; ctx.textBaseline = 'top'; ctx.textAlign = 'center'; + ctx.fillStyle = '#b6e94d'; ctx.fillText('A·' + snapA.step, Math.max(padL + 12, Math.min(padL + plotW - 12, xa)), padT + 1); ctx.fillText('B·' + snapB.step, Math.max(padL + 12, Math.min(padL + plotW - 12, xb)), padT + 1); this._drawComparePanel(ctx, w, padL, padT, plotW, plotH, l, r, snapA, snapB, nodes); @@ -552,21 +554,21 @@ class TimelineChart { const v = snap.snap[node.id] ?? 0; return `${node.label || node.type}: ${v}`; })]; - ctx.font = '10px monospace'; + ctx.font = "10px 'JetBrains Mono', monospace"; const tw = Math.max(...lines.map(l => ctx.measureText(l).width)) + 18; const th = lines.length * 14 + 10; let tx = cx + 10; if (tx + tw > w - 4) tx = cx - tw - 10; const ty = padT + 2; - ctx.fillStyle = 'rgba(12,14,20,0.93)'; - ctx.strokeStyle = '#2a3550'; ctx.lineWidth = 1; + ctx.fillStyle = 'rgba(13,14,17,0.93)'; + ctx.strokeStyle = '#2a2e38'; ctx.lineWidth = 1; this._roundRect(ctx, tx, ty, tw, th, 4); ctx.fill(); ctx.stroke(); ctx.textAlign = 'left'; ctx.textBaseline = 'top'; lines.forEach((line, i) => { - ctx.fillStyle = i === 0 ? '#9aa3b2' : this._colorOf(nodes[i - 1]); + ctx.fillStyle = i === 0 ? '#8a90a0' : this._colorOf(nodes[i - 1]); ctx.fillText(line, tx + 9, ty + 5 + i * 14); }); } @@ -586,12 +588,12 @@ class TimelineChart { color: this._colorOf(node), main: `${node.label || node.type}: ${fmt(vA)} → ${fmt(vB)}`, delta: ` ${arrow} ${d > 0 ? '+' : ''}${fmt(d)}${pctStr}`, - dcolor: d > 0 ? '#4caf50' : d < 0 ? '#ef5350' : '#95a3bc', + dcolor: d > 0 ? '#4caf50' : d < 0 ? '#ef5350' : '#8a90a0', }; }); const header = `Step ${snapA.step} → ${snapB.step} · Δ${snapB.step - snapA.step} steps`; - ctx.font = '10px monospace'; + ctx.font = "10px 'JetBrains Mono', monospace"; const rowW = rows.map(r => ctx.measureText(r.main).width + ctx.measureText(r.delta).width); const tw = Math.max(ctx.measureText(header).width, ...rowW, 0) + 18; const th = (rows.length + 1) * 14 + 10; @@ -601,13 +603,13 @@ class TimelineChart { tx = Math.max(padL + 4, Math.min(padL + plotW - tw - 4, tx)); const ty = padT + 2; - ctx.fillStyle = 'rgba(12,14,20,0.95)'; - ctx.strokeStyle = '#2a3550'; ctx.lineWidth = 1; + ctx.fillStyle = 'rgba(13,14,17,0.95)'; + ctx.strokeStyle = '#2a2e38'; ctx.lineWidth = 1; this._roundRect(ctx, tx, ty, tw, th, 4); ctx.fill(); ctx.stroke(); ctx.textAlign = 'left'; ctx.textBaseline = 'top'; - ctx.fillStyle = '#9aa3b2'; + ctx.fillStyle = '#8a90a0'; ctx.fillText(header, tx + 9, ty + 5); rows.forEach((r, i) => { const y = ty + 5 + (i + 1) * 14; diff --git a/js/model.js b/js/model.js index 62c8aef..cf4165f 100644 --- a/js/model.js +++ b/js/model.js @@ -73,14 +73,14 @@ const SimRandom = { const DEFAULT_COLOR = '#9e9e9e'; const NODE_FILL = { - pool: '#1a3a6b', source: '#1a4a2a', drain: '#4a1a1a', - gate: '#3a1a5a', converter: '#4a2a00', register: '#1a2a38', delay: '#004a4a', - queue: '#2a2a4a', trader: '#3a1530', + pool: '#10233f', source: '#10240f', drain: '#2b0f0f', + gate: '#23102f', converter: '#2b1a05', register: '#161b21', delay: '#06262a', + queue: '#141632', trader: '#2d0f1c', }; const NODE_STROKE = { pool: '#4a9eff', source: '#4caf50', drain: '#ef5350', - gate: '#ba68c8', converter: '#ffa726', register: '#78909c', delay: '#26c6da', + gate: '#ba68c8', converter: '#ffa726', register: '#90a4bc', delay: '#26c6da', queue: '#7c83ff', trader: '#f06292', }; diff --git a/js/renderer.js b/js/renderer.js index d2310a3..390f868 100644 --- a/js/renderer.js +++ b/js/renderer.js @@ -230,8 +230,12 @@ class BallSystem { const now = performance.now(); const stagger = Math.min(durationMs * 0.07, 80); + // Uncolored/default flows travel as warm amber dots (the design's #ffd27a); + // explicitly typed resources keep their own color so the type still reads. + if (color === '#ffa726' || color === '#9e9e9e') color = '#ffd27a'; + for (let i = 0; i < capped; i++) { - const el = svgEl('circle', { r: '5', fill: color, opacity: '0', 'pointer-events': 'none' }); + const el = svgEl('circle', { r: '3.5', fill: color, opacity: '0', 'pointer-events': 'none' }); // Darker stroke so balls are visible against light backgrounds el.setAttribute('stroke', this._darken(color)); el.setAttribute('stroke-width', '1'); @@ -311,10 +315,11 @@ class FlowFx { } catch { return; } const g = svgEl('g', { 'pointer-events': 'none', opacity: '0' }); - const rect = svgEl('rect', { rx: '7', ry: '7', fill: 'rgba(12,14,20,0.9)', stroke: color, 'stroke-width': '1' }); + // Live readout pill: opaque lime-tint fill so it stays readable over lines. + const rect = svgEl('rect', { rx: '9', ry: '9', fill: '#242d18', stroke: color, 'stroke-width': '1' }); const t = svgEl('text', { 'text-anchor': 'middle', 'dominant-baseline': 'central', - 'font-size': '11', 'font-family': 'monospace', 'font-weight': '600', fill: color, + 'font-size': '11', 'font-family': "'JetBrains Mono', monospace", 'font-weight': '600', fill: color, }); t.textContent = text; g.appendChild(rect); g.appendChild(t); @@ -382,7 +387,7 @@ class Minimap { const H = cv.height = cv.clientHeight || 120; const ctx = cv.getContext('2d'); ctx.clearRect(0, 0, W, H); - ctx.fillStyle = 'rgba(10,12,18,0.94)'; + ctx.fillStyle = 'rgba(13,14,17,0.94)'; ctx.fillRect(0, 0, W, H); const box = this.renderer._contentBounds(); @@ -402,7 +407,7 @@ class Minimap { const wx = x => x * scale + offX, wy = y => y * scale + offY; // Groups as faint rects. - ctx.fillStyle = 'rgba(74,158,255,0.10)'; + ctx.fillStyle = 'rgba(138,144,160,0.10)'; for (const g of this.diagram.groups.values()) ctx.fillRect(wx(g.x), wy(g.y), g.w * scale, g.h * scale); @@ -429,9 +434,9 @@ class Minimap { _drawViewport(ctx, vp, scale, offX, offY) { const x = vp.x0 * scale + offX, y = vp.y0 * scale + offY; const w = (vp.x1 - vp.x0) * scale, h = (vp.y1 - vp.y0) * scale; - ctx.fillStyle = 'rgba(74,158,255,0.12)'; + ctx.fillStyle = 'rgba(182,233,77,0.08)'; ctx.fillRect(x, y, w, h); - ctx.strokeStyle = '#4a9eff'; ctx.lineWidth = 1.2; + ctx.strokeStyle = '#b6e94d'; ctx.lineWidth = 1.2; ctx.strokeRect(x, y, w, h); } @@ -462,6 +467,8 @@ class Renderer { this.selectedId = null; // primary selection (node or connection) this.selectedIds = new Set(); // multi-selected node ids this._firing = new Set(); + this._flowing = new Set(); // conn ids that carried flow this step (dash-march) + this._flowingTimer = null; this._firingTimer = null; // pending setFiring clear (see setFiring) this._nodeEls = new Map(); this._connEls = new Map(); @@ -486,27 +493,28 @@ class Renderer { // Grid — kept fixed to the viewport but its patternTransform tracks the // pan/zoom (see _updateTransform), so it visually moves and scales with the // content while always covering the screen. - const pat = svgEl('pattern', { id: 'grid', width: '40', height: '40', patternUnits: 'userSpaceOnUse' }); - pat.appendChild(svgEl('path', { d: 'M40 0L0 0 0 40', fill: 'none', stroke: '#1a2035', 'stroke-width': '0.6' })); + const pat = svgEl('pattern', { id: 'grid', width: '26', height: '26', patternUnits: 'userSpaceOnUse' }); + pat.appendChild(svgEl('path', { d: 'M26 0L0 0 0 26', fill: 'none', stroke: '#1a1c22', 'stroke-width': '1' })); defs.appendChild(pat); this._gridPat = pat; // Dot overlay - const pat2 = svgEl('pattern', { id: 'dots', width: '40', height: '40', patternUnits: 'userSpaceOnUse' }); - pat2.appendChild(svgEl('circle', { cx: '0', cy: '0', r: '1', fill: '#1e2840' })); + const pat2 = svgEl('pattern', { id: 'dots', width: '26', height: '26', patternUnits: 'userSpaceOnUse' }); + pat2.appendChild(svgEl('circle', { cx: '0', cy: '0', r: '1', fill: '#22252e' })); defs.appendChild(pat2); this._dotPat = pat2; - // Arrow markers + // Arrow markers. userSpaceOnUse keeps arrowheads a constant size regardless + // of each path's stroke width. const mkArrow = (id, color) => { - const m = svgEl('marker', { id, markerWidth: '8', markerHeight: '6', refX: '7', refY: '3', orient: 'auto' }); - m.appendChild(svgEl('polygon', { points: '0 0,8 3,0 6', fill: color })); + const m = svgEl('marker', { id, markerUnits: 'userSpaceOnUse', markerWidth: '9', markerHeight: '9', refX: '6.5', refY: '4.5', orient: 'auto' }); + m.appendChild(svgEl('path', { d: 'M0,1 L8,4.5 L0,8 Z', fill: color })); defs.appendChild(m); }; mkArrow('arrow-resource', '#ffa726'); - mkArrow('arrow-state', '#78909c'); - mkArrow('arrow-trigger', '#66bb6a'); - mkArrow('arrow-sel', '#fff'); + mkArrow('arrow-state', '#7f879c'); + mkArrow('arrow-trigger', '#7f879c'); + mkArrow('arrow-sel', '#b6e94d'); // Glow const filt = svgEl('filter', { id: 'glow', x: '-40%', y: '-40%', width: '180%', height: '180%' }); @@ -518,18 +526,28 @@ class Renderer { defs.appendChild(filt); this.svg.appendChild(defs); - this._bgRect = svgEl('rect', { width: '100%', height: '100%', fill: '#0f1117' }); + this._bgRect = svgEl('rect', { width: '100%', height: '100%', fill: '#0d0e11' }); this._gridStroke = pat.firstChild; this.svg.appendChild(this._bgRect); this.svg.appendChild(svgEl('rect', { width: '100%', height: '100%', fill: 'url(#grid)' })); - // Onboarding hint, shown only while the canvas is completely empty. + // Onboarding hint, shown only while the canvas is completely empty: + // a ghost of the chase-loop mark above the copy (12c). // Lives outside the pan/zoom root so it stays centred in the viewport. this._emptyHint = svgEl('g', { 'pointer-events': 'none', visibility: 'hidden' }); + // Nested anchored at the viewport centre; overflow:visible lets the + // mark draw around that point without knowing the canvas size. + const ghostWrap = svgEl('svg', { x: '50%', y: '50%', width: '1', height: '1', overflow: 'visible' }); + const ghostMark = svgEl('g', { transform: 'translate(0,-96) scale(1.7) translate(-16,-16)' }); + for (const d of ['M6.6,12.6 A10,10 0 0 1 25.4,12.6', 'M25.4,19.4 A10,10 0 0 1 6.6,19.4']) + ghostMark.appendChild(svgEl('path', { d, fill: 'none', stroke: '#2f3542', 'stroke-width': '3', 'stroke-linecap': 'round' })); + ghostMark.appendChild(svgEl('circle', { cx: '16', cy: '16', r: '3.4', fill: '#2f3542' })); + ghostWrap.appendChild(ghostMark); + this._emptyHint.appendChild(ghostWrap); const hintLines = [ - ['Empty canvas', '16', '600', '#8291aa'], - ['Pick a node from the left palette and click to place it, then switch to Resource (R) or State (T) to connect.', '12', '400', '#6b7a96'], - ['Or load a starter template from Library.', '12', '400', '#6b7a96'], + ['An empty economy', '16', '600', '#8a90a0'], + ['Pick a node from the left palette and click to place it, then switch to Resource (R) or State (T) to connect.', '12', '400', '#6b7180'], + ['Or browse the starter templates in Library.', '12', '400', '#6b7180'], ]; hintLines.forEach(([txt, size, weight, fill], i) => { const t = svgEl('text', { @@ -562,7 +580,7 @@ class Renderer { // Canvas background override (simulation meta). Empty string restores the // theme default. The grid stroke flips dark/light to stay visible. setBackground(color) { - const bg = color || '#0f1117'; + const bg = color || '#0d0e11'; this._bgRect.setAttribute('fill', bg); let light = false; const m = /^#([0-9a-f]{6})$/i.exec(bg); @@ -571,7 +589,7 @@ class Renderer { const lum = 0.299 * (v >> 16 & 255) + 0.587 * (v >> 8 & 255) + 0.114 * (v & 255); light = lum > 140; } - if (this._gridStroke) this._gridStroke.setAttribute('stroke', light ? 'rgba(0,0,0,0.13)' : '#1a2035'); + if (this._gridStroke) this._gridStroke.setAttribute('stroke', light ? 'rgba(0,0,0,0.13)' : '#1a1c22'); } _updateTransform() { @@ -656,9 +674,36 @@ class Renderer { return this._connEls.get(connId)?.querySelector('.conn-path') || null; } + // Mark connections that carried resources this step: they get the dash-march + // animation until the next step replaces the set (empty array clears it). + setFlowing(ids) { + this._flowing = new Set(ids); + clearTimeout(this._flowingTimer); + // Fade the march out if the run stops feeding new steps. + this._flowingTimer = setTimeout(() => { this._flowing.clear(); this.render(); }, 900); + } + setFiring(ids) { this._firing = new Set(ids); this.render(); + // 18e: besides the white flash (CSS), each firing node emits a brief + // expanding ring. Skipped under prefers-reduced-motion. + const reduce = typeof matchMedia === 'function' + && matchMedia('(prefers-reduced-motion: reduce)').matches; + if (!reduce) { + for (const id of this._firing) { + const node = this.diagram.nodes.get(id); + if (!node) continue; + const r = (NODE_R[node.type] || 32) + 2; + const ring = svgEl('circle', { + class: 'fire-ring', cx: node.x, cy: node.y, r: String(r), + fill: 'none', stroke: '#fff', 'stroke-width': '2', 'pointer-events': 'none', + style: `transform-origin:${node.x}px ${node.y}px`, + }); + this.ballLayer.appendChild(ring); + setTimeout(() => ring.remove(), 300); + } + } // Cancel the previous flash's timer so a stale one can't clear this set early. clearTimeout(this._firingTimer); this._firingTimer = setTimeout(() => { this._firing.clear(); this.render(); }, 250); @@ -859,7 +904,7 @@ class Renderer { if (w > 5 && h > 5) { this.tempLayer.appendChild(svgEl('rect', { x, y, width: w, height: h, rx: '8', - fill: 'rgba(74,158,255,0.06)', stroke: '#4a9eff', + fill: 'rgba(182,233,77,0.06)', stroke: '#b6e94d', 'stroke-width': '1.5', 'stroke-dasharray': '6,4', 'pointer-events': 'none', })); } @@ -918,16 +963,15 @@ class Renderer { bg.setAttribute('y', chart.y); bg.setAttribute('width', chart.w); bg.setAttribute('height', chart.h); - bg.setAttribute('fill', '#0f1117'); - bg.setAttribute('stroke', isSel ? '#fff' : '#2a3550'); - if (isSel) bg.setAttribute('filter', 'url(#glow)'); - else bg.removeAttribute('filter'); + bg.setAttribute('fill', '#0d0e11'); + bg.setAttribute('stroke', isSel ? '#b6e94d' : '#2a2e38'); + bg.setAttribute('stroke-dasharray', isSel ? '5,4' : ''); const title = el.querySelector('.chart-title'); title.setAttribute('x', String(chart.x + 8)); title.setAttribute('y', String(chart.y + 14)); title.textContent = chart.label || 'Chart'; - title.setAttribute('fill', '#9aa3b2'); + title.setAttribute('fill', '#8a90a0'); this._updateResizeHandles(el, chart, isSel); @@ -944,7 +988,7 @@ class Renderer { clearPlot(); const t = svgEl('text', { x: String(chart.x + chart.w / 2), y: String(chart.y + chart.h / 2 + 6), - 'text-anchor': 'middle', 'font-size': '10', 'font-family': 'var(--font)', fill: '#556070', + 'text-anchor': 'middle', 'font-size': '10', 'font-family': 'var(--font)', fill: '#6b7180', }); t.textContent = msg; plot.appendChild(t); @@ -999,7 +1043,7 @@ class Renderer { plot.appendChild(svgEl('path', { d: `M ${x0},${y0} L ${x0},${baseY} L ${x0 + plotW},${baseY}`, - fill: 'none', stroke: '#1e2535', 'stroke-width': '1', + fill: 'none', stroke: '#22252e', 'stroke-width': '1', })); // Axis extents (raw data range) and axis-node labels in series colors. const lbl = (x, y, text, fill, anchor = 'start') => { @@ -1007,10 +1051,10 @@ class Renderer { t.textContent = text; plot.appendChild(t); }; - lbl(chart.x + 3, y0 + 6, fmtv(rawY[1]), '#556070'); - lbl(chart.x + 3, baseY, fmtv(rawY[0]), '#556070'); - lbl(x0, baseY + 8, fmtv(rawX[0]), '#556070'); - lbl(x0 + plotW, baseY + 8, fmtv(rawX[1]), '#556070', 'end'); + lbl(chart.x + 3, y0 + 6, fmtv(rawY[1]), '#6b7180'); + lbl(chart.x + 3, baseY, fmtv(rawY[0]), '#6b7180'); + lbl(x0, baseY + 8, fmtv(rawX[0]), '#6b7180'); + lbl(x0 + plotW, baseY + 8, fmtv(rawX[1]), '#6b7180', 'end'); const xNode = this.diagram.nodes.get(xId), yNode = this.diagram.nodes.get(yId); lbl(x0 + plotW, baseY - 4, (xNode && (xNode.label || xNode.type)) || '', xColor, 'end'); lbl(x0 + 4, y0 + 6, (yNode && (yNode.label || yNode.type)) || '', yColor); @@ -1047,13 +1091,13 @@ class Renderer { // Axes (left + baseline) with min/max labels. const axis = svgEl('path', { d: `M ${x0},${y0} L ${x0},${y0 + plotH} L ${x0 + plotW},${y0 + plotH}`, - fill: 'none', stroke: '#1e2535', 'stroke-width': '1', + fill: 'none', stroke: '#22252e', 'stroke-width': '1', }); plot.appendChild(axis); - const maxLbl = svgEl('text', { x: String(chart.x + 3), y: String(y0 + 6), 'font-size': '8', 'font-family': 'monospace', fill: '#556070' }); + const maxLbl = svgEl('text', { x: String(chart.x + 3), y: String(y0 + 6), 'font-size': '8', 'font-family': 'monospace', fill: '#6b7180' }); maxLbl.textContent = String(+max.toFixed(max < 10 ? 1 : 0)); plot.appendChild(maxLbl); - const zeroLbl = svgEl('text', { x: String(chart.x + 3), y: String(y0 + plotH), 'font-size': '8', 'font-family': 'monospace', fill: '#556070' }); + const zeroLbl = svgEl('text', { x: String(chart.x + 3), y: String(y0 + plotH), 'font-size': '8', 'font-family': 'monospace', fill: '#6b7180' }); zeroLbl.textContent = '0'; plot.appendChild(zeroLbl); @@ -1202,7 +1246,7 @@ class Renderer { hov.appendChild(svgEl('rect', { x: tx.toFixed(1), y: ty.toFixed(1), width: tw.toFixed(1), height: th.toFixed(1), rx: '3', - fill: 'rgba(12,14,20,0.94)', stroke: '#2a3550', 'stroke-width': '1', + fill: 'rgba(13,14,17,0.94)', stroke: '#2a2e38', 'stroke-width': '1', })); rows.forEach((r, k) => { const t = svgEl('text', { @@ -1235,9 +1279,12 @@ class Renderer { g.appendChild(svgEl('path', { class: 'conn-hitbox', fill: 'none', stroke: 'transparent', 'stroke-width': '24', cursor: 'pointer' })); g.appendChild(svgEl('path', { class: 'conn-path', fill: 'none', 'stroke-width': '2' })); const lg = svgEl('g', { class: 'conn-label-g', 'data-conn-id': conn.id, cursor: 'grab' }); - lg.appendChild(svgEl('rect', { class: 'conn-label-bg', rx: '7', ry: '7', 'pointer-events': 'all' })); - lg.appendChild(svgEl('text', { class: 'conn-label', 'text-anchor': 'middle', 'dominant-baseline': 'central', 'font-size': '11', 'font-family': 'var(--font)', 'pointer-events': 'none' })); + lg.appendChild(svgEl('rect', { class: 'conn-label-bg', rx: '11', ry: '11', 'pointer-events': 'all' })); + lg.appendChild(svgEl('text', { class: 'conn-label', 'text-anchor': 'middle', 'dominant-baseline': 'central', 'font-size': '11', 'pointer-events': 'none' })); g.appendChild(lg); + // State-role badge (✷ trigger / ⊢ activator / Δ modifier), sitting on the + // line just before the arrowhead. + g.appendChild(svgEl('g', { class: 'conn-role', 'pointer-events': 'none' })); g.appendChild(svgEl('g', { class: 'conn-handles' })); return g; } @@ -1251,37 +1298,107 @@ class Renderer { const d = connPathD(conn, src, tgt); const lp = connLabelPos(conn, src, tgt); - const baseColor = isTrigger ? '#66bb6a' : (isModifier ? '#ffb74d' : (isRes ? '#ffa726' : '#78909c')); - const color = isSel ? '#fff' : baseColor; + // One line language: resource = solid orange 2.5px, state = dashed slate + // 2px (role carried by the badge, not the line color), selected = lime. + const baseColor = isRes ? '#ffa726' : '#7f879c'; + const color = isSel ? '#b6e94d' : baseColor; + + // Pull mode: the target draws resources along this connection, so the line + // reads as fine dots driven from the far end, and the pill says so. + const isPull = isRes && tgt && (tgt.flowMode === 'pull') + && (tgt.type === NodeType.POOL || tgt.type === NodeType.DRAIN) + && src && (src.type === NodeType.POOL || src.type === NodeType.SOURCE); + // Blocked: an activator is currently disallowing the source node, so + // nothing can flow here until the condition holds again. + let isBlocked = false; + if (isRes && !isPull && this.engine && src) { + try { isBlocked = !this.engine._nodeEnabled(src, true); } catch (_) {} + } el.querySelector('.conn-hitbox').setAttribute('d', d); const path = el.querySelector('.conn-path'); path.setAttribute('d', d); path.setAttribute('stroke', color); - if (isTrigger) path.setAttribute('stroke-dasharray', '2,4'); - else if (!isRes) path.setAttribute('stroke-dasharray', '7,4'); + path.setAttribute('stroke-width', isRes ? '2.5' : '2'); + if (this._flowing && this._flowing.has(conn.id)) path.setAttribute('stroke-dasharray', '6,5'); + else if (!isRes) path.setAttribute('stroke-dasharray', '5,4'); else path.removeAttribute('stroke-dasharray'); - const marker = isSel ? 'arrow-sel' : (isTrigger ? 'arrow-trigger' : (isRes ? 'arrow-resource' : 'arrow-state')); + const marker = isSel ? 'arrow-sel' : (isRes ? 'arrow-resource' : 'arrow-state'); path.setAttribute('marker-end', `url(#${marker})`); + // Runtime states (11a): pull = fine dots driven by the target; blocked = + // the line falls back to 30% while an activator holds the source shut. + if (isPull && !isSel) { path.setAttribute('stroke-dasharray', '2.5,4'); path.setAttribute('opacity', '0.55'); } + else if (isBlocked && !isSel) path.setAttribute('opacity', '0.3'); + else path.removeAttribute('opacity'); + + // Role badge: a small stamped circle 16px before the arrowhead. + const roleG = el.querySelector('.conn-role'); + while (roleG.firstChild) roleG.removeChild(roleG.firstChild); + const role = isTrigger ? { glyph: '✷', color: '#ffd27a' } + : isActivator ? { glyph: '⊢', color: '#8fe08f' } + : isModifier ? { glyph: 'Δ', color: '#7cc7ff' } + : null; + if (role) { + try { + const len = path.getTotalLength(); + if (len > 40) { + const p = path.getPointAtLength(len - 16); + roleG.appendChild(svgEl('circle', { + cx: p.x, cy: p.y, r: '9', + fill: '#0d0e11', stroke: isSel ? '#b6e94d' : role.color, 'stroke-width': '1.5', + })); + const t = svgEl('text', { + x: p.x, y: p.y, 'text-anchor': 'middle', 'dominant-baseline': 'central', + 'font-size': '10', 'font-family': "'JetBrains Mono', monospace", fill: role.color, + }); + t.textContent = role.glyph; + roleG.appendChild(t); + } + } catch (_) {} + } const labelG = el.querySelector('.conn-label-g'); const label = labelG.querySelector('.conn-label'); const labelBg = labelG.querySelector('.conn-label-bg'); let txt = conn.label || ''; + // Pill segments: the rate value first, then one glyph per modifier, each in + // its fixed color (interval cyan, chance purple, condition amber, formula + // green, modifier blue) so a pill reads the same everywhere (11a). + const SEG = { + base: '#e8ebf2', interval: '#26c6da', chance: '#ba68c8', + cond: '#ffd27a', formula: '#8fe08f', modifier: '#7cc7ff', dim: '#8a90a0', + }; + let segs = null; // [{ t, color }] — set for resource pills below if (isRes) { const fromGate = src && src.type === NodeType.GATE; // Show the configured rate at rest so every wire communicates its flow // strength — including the default 1, which used to be hidden and left new // users unsure a rate was even editable. Gate outputs distribute by weight // (shown below), not rate, so they're excluded. - if (conn.rateMode === RateMode.DICE) txt = txt || conn.dice; - else if (conn.rateMode === RateMode.FORMULA) txt = txt || conn.formula; - else if (conn.rateMode === RateMode.DISTRIBUTION) txt = txt || (conn.distType || 'dist'); - else if (!fromGate) txt = txt || String(conn.rate); - if (conn.interval > 1) txt += (txt ? ' ' : '') + `/${conn.interval}`; - if (conn.chance < 100) txt += (txt ? ' ' : '') + `${conn.chance}%`; - if (conn.colorFilter) txt += (txt ? ' ' : '') + '●'; + segs = []; + if (txt) segs.push({ t: txt, color: SEG.base }); + else if (conn.rateMode === RateMode.DICE) segs.push({ t: conn.dice, color: SEG.base }); + else if (conn.rateMode === RateMode.FORMULA) { + const f = conn.formula.length > 18 ? conn.formula.slice(0, 17) + '…' : conn.formula; + segs.push({ t: `ƒ ${f}`, color: SEG.formula }); + } else if (conn.rateMode === RateMode.DISTRIBUTION) { + const p1 = conn.distParam1 ?? '', p2 = conn.distParam2; + const d = conn.distType === 'normal' ? `~N(${p1}, ${p2 ?? 1})` + : conn.distType === 'uniform' ? `~U(${p1}, ${p2 ?? p1})` + : `~${conn.distType || 'dist'}(${p1})`; + segs.push({ t: d, color: SEG.base }); + } else if (!fromGate) segs.push({ t: String(conn.rate), color: SEG.base }); + if (conn.interval > 1) segs.push({ t: `⏱${conn.interval}`, color: SEG.interval }); + if (conn.chance < 100) segs.push({ t: `${conn.chance}%`, color: SEG.chance }); + if (conn.colorFilter) segs.push({ t: '●', color: conn.colorFilter }); + if (conn.condEnabled) { + const ref = conn.condRefMode === 'variable' ? (conn.condVariable || 'var') : 'src'; + const cond = conn.condOperator === 'between' + ? `if ${ref} in ${Math.min(conn.condValue, conn.condValue2)}..${Math.max(conn.condValue, conn.condValue2)}` + : `if ${ref}${conn.condOperator}${conn.condValue}`; + segs.push({ t: cond, color: SEG.cond }); + } if (fromGate) { const gmode = src.gateMode === 'random' ? 'probabilistic' : src.gateMode; // Mirror engine._connWeight so formula weights make the labels live. @@ -1295,18 +1412,24 @@ class Renderer { const totalW = allOuts.reduce((s, c) => s + getW(c), 0); if (totalW > 0) { const pct = Math.round(getW(conn) / totalW * 100); - txt = (txt ? txt + ' ' : '') + `${pct}%`; + segs.push({ t: `${pct}%`, color: SEG.chance }); } } else if (conn.weightFormula) { - txt += (txt ? ' ' : '') + `⚖${conn.weightFormula}`; + segs.push({ t: `⚖${conn.weightFormula}`, color: SEG.formula }); } else if (Number(conn.weight) !== 1) { - txt += (txt ? ' ' : '') + `⚖${conn.weight}`; + segs.push({ t: `⚖${conn.weight}`, color: SEG.base }); } } + // The pull pill replaces the rate story: the target decides what moves. + if (isPull) segs = [{ t: `pull · ${tgt.pullPolicy || 'any'}`, color: SEG.dim }]; + txt = segs.map(s => s.t).join(' '); } else if (isTrigger) { - txt = conn.label ? `✷ ${conn.label}` : '✷'; - if ((conn.triggerEvery || 1) > 1) txt += `/${conn.triggerEvery}`; - if (conn.triggerChance != null && conn.triggerChance < 100) txt += ` ${conn.triggerChance}%`; + // The role badge already stamps ✷ on the line; the pill only appears when + // there is more to say (a label, an every-N rhythm, or a chance). + txt = conn.label ? `✷ ${conn.label}` : ''; + if ((conn.triggerEvery || 1) > 1) txt += (txt ? '' : '✷') + `/${conn.triggerEvery}`; + if (conn.triggerChance != null && conn.triggerChance < 100) txt += (txt ? ' ' : '✷ ') + `${conn.triggerChance}%`; + if (txt) segs = [{ t: txt, color: SEG.cond }]; } else if (isModifier) { const mode = conn.modMode || 'rate'; if (conn.modFormula) { @@ -1321,19 +1444,29 @@ class Renderer { else if (mode === 'delta') txt = `${sign}${conn.modFactor}×Δ`; else txt = `Δ ${sign}${conn.modFactor}×`; } + segs = [{ t: txt, color: SEG.modifier }]; } else if (isActivator) { txt = conn.actOperator === 'between' ? `⊢ ${Math.min(conn.actValue, conn.actValue2)}..${Math.max(conn.actValue, conn.actValue2)}` : `⊢ ${conn.actOperator}${conn.actValue}`; + segs = [{ t: txt, color: SEG.formula }]; } else { txt = conn.variableName || conn.label || ''; } if (txt) { labelG.style.display = ''; - label.textContent = txt; + // Multi-color pills: one tspan per segment. Selection and blocked states + // override every segment with a single voice (lime / muted). + while (label.firstChild) label.removeChild(label.firstChild); + const renderSegs = (segs && segs.length) ? segs : [{ t: txt, color: SEG.base }]; + renderSegs.forEach((s, i) => { + const ts = document.createElementNS(SVG_NS, 'tspan'); + ts.textContent = (i ? ' ' : '') + s.t; + ts.setAttribute('fill', isSel ? '#b6e94d' : (isBlocked ? '#565c68' : s.color)); + label.appendChild(ts); + }); label.setAttribute('x', lp.x); label.setAttribute('y', lp.y); - label.setAttribute('fill', color); try { // ponytail: getBBox() forces a synchronous layout flush; the label's size // depends only on its text (anchor=middle/central, so it's centred on lp). @@ -1343,13 +1476,14 @@ class Renderer { const bb = label.getBBox(); sz = label._sizeCache = { txt, w: bb.width, h: bb.height }; } - const px = 6, py = 3; + // 22px-tall pill, radius 11, always opaque and painted above the line. + const px = 8, h = 22; labelBg.setAttribute('x', lp.x - sz.w / 2 - px); - labelBg.setAttribute('y', lp.y - sz.h / 2 - py); + labelBg.setAttribute('y', lp.y - h / 2); labelBg.setAttribute('width', sz.w + px * 2); - labelBg.setAttribute('height', sz.h + py * 2); - labelBg.setAttribute('fill', isSel ? 'rgba(255,255,255,0.14)' : 'rgba(18,18,18,0.85)'); - labelBg.setAttribute('stroke', color); + labelBg.setAttribute('height', h); + labelBg.setAttribute('fill', isSel ? '#242d18' : (isBlocked ? '#16181d' : '#1d2027')); + labelBg.setAttribute('stroke', isSel ? '#b6e94d' : (isBlocked ? '#22252e' : '#2a2e38')); labelBg.setAttribute('stroke-width', '1'); } catch (_) {} } else { @@ -1357,7 +1491,8 @@ class Renderer { labelG.style.display = 'none'; } - el.setAttribute('class', `conn${isSel ? ' selected' : ''}`); + const isFlowing = this._flowing && this._flowing.has(conn.id); + el.setAttribute('class', `conn${isSel ? ' selected' : ''}${isFlowing ? ' flowing' : ''}`); // Reshape handles — shown only while selected (and never on a self-loop). const hg = el.querySelector('.conn-handles'); @@ -1366,7 +1501,7 @@ class Renderer { for (const h of this.getConnHandles(conn.id)) { hg.appendChild(svgEl('circle', { class: 'conn-cp-handle', r: '6', cx: h.x, cy: h.y, - fill: 'rgba(74,158,255,0.25)', stroke: '#4a9eff', 'stroke-width': '1.5', cursor: 'move', + fill: 'rgba(182,233,77,0.25)', stroke: '#b6e94d', 'stroke-width': '1.5', cursor: 'move', })); } } @@ -1433,7 +1568,7 @@ class Renderer { hg.appendChild(svgEl('rect', { class: 'resize-handle', 'data-corner': c.corner, x: c.x - 5, y: c.y - 5, width: '10', height: '10', rx: '2', - fill: 'rgba(74,158,255,0.9)', stroke: '#fff', 'stroke-width': '1.5', cursor, + fill: 'rgba(182,233,77,0.9)', stroke: '#14151a', 'stroke-width': '1.5', cursor, })); } } @@ -1455,42 +1590,41 @@ class Renderer { _makeNodeEl(node) { const g = svgEl('g', { 'data-id': node.id, cursor: 'pointer' }); // nd = node-decoration: functional motif, pointer-events off, never overridden by _updateNodeEl + // Sculpted-shape identity (3a): mono glyphs in a lighter tint of each + // node's stroke color, fill-level arcs on pools, fading dots on queues. + const glyph = (txt, attrs) => { + const t = svgEl('text', { + class: 'n-glyph nd', 'text-anchor': 'middle', 'pointer-events': 'none', ...attrs, + }); + t.textContent = txt; + return t; + }; if (node.type === NodeType.POOL) { g.appendChild(svgEl('circle', { class: 'ns', r: '32' })); + // Fill-level chord segment: how full the pool is, at a glance. + g.appendChild(svgEl('path', { class: 'ns-fill nd', d: '', 'pointer-events': 'none' })); g.appendChild(svgEl('circle', { class: 'ns-color-ring', r: '26', fill: 'none', 'stroke-width': '4', opacity: '0.5' })); } else if (node.type === NodeType.SOURCE) { - g.appendChild(svgEl('polygon', { class: 'ns', points: '0,-32 28,16 -28,16' })); - // Emit motif: upward arrow pointing toward apex (resources generated, flowing out) - g.appendChild(svgEl('path', { class: 'nd', d: 'M 0,14 V 4 M -4,8 L 0,4 L 4,8', - fill: 'none', stroke: NODE_STROKE.source, 'stroke-width': '2', - 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'pointer-events': 'none' })); + g.appendChild(svgEl('polygon', { class: 'ns', points: '0,-32 28,16 -28,16', 'stroke-linejoin': 'round' })); + g.appendChild(glyph('∞', { y: '10', 'font-size': '20', fill: '#8fe08f' })); } else if (node.type === NodeType.DRAIN) { - g.appendChild(svgEl('polygon', { class: 'ns', points: '0,32 -28,-16 28,-16' })); - // Absorb motif: downward arrow pointing toward drain tip (resources consumed) - g.appendChild(svgEl('path', { class: 'nd', d: 'M 0,8 V 18 M -4,14 L 0,18 L 4,14', - fill: 'none', stroke: NODE_STROKE.drain, 'stroke-width': '2', - 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'pointer-events': 'none' })); + g.appendChild(svgEl('polygon', { class: 'ns', points: '0,32 -28,-16 28,-16', 'stroke-linejoin': 'round' })); + g.appendChild(glyph('×', { y: '10', 'font-size': '20', fill: '#ff9e9c' })); } else if (node.type === NodeType.GATE) { - g.appendChild(svgEl('polygon', { class: 'ns', points: '0,-34 34,0 0,34 -34,0' })); - // Fork motif: Y-split showing one-in, many-out routing - g.appendChild(svgEl('path', { class: 'nd', d: 'M 0,10 V 16 M 0,16 L -7,22 M 0,16 L 7,22', - fill: 'none', stroke: NODE_STROKE.gate, 'stroke-width': '2', - 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'pointer-events': 'none' })); + g.appendChild(svgEl('polygon', { class: 'ns', points: '0,-34 34,0 0,34 -34,0', 'stroke-linejoin': 'round' })); + g.appendChild(glyph('%', { y: '5', 'font-size': '15', fill: '#d79ce2' })); } else if (node.type === NodeType.CONVERTER) { g.appendChild(svgEl('circle', { class: 'ns ns-back', cx: '-14', r: '24' })); g.appendChild(svgEl('circle', { class: 'ns', cx: '14', r: '24' })); - g.appendChild(svgEl('line', { x1: '0', y1: '-18', x2: '0', y2: '18', stroke: '#667', 'stroke-width': '1.5' })); - // Transform motif: right-pointing arrow across the divider (input left → output right) + // Transform motif: small arrow between the overlapping cells (in → out). g.appendChild(svgEl('path', { class: 'nd', d: 'M -6,14 H 4 M 1,11 L 4,14 L 1,17', - fill: 'none', stroke: NODE_STROKE.converter, 'stroke-width': '2', + fill: 'none', stroke: '#ffd27a', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'pointer-events': 'none' })); + // Recipe caption (e.g. 2:1) under the label. + g.appendChild(svgEl('text', { class: 'n-caption nd', 'text-anchor': 'middle', y: '64', 'pointer-events': 'none' })); } else if (node.type === NodeType.REGISTER) { - g.appendChild(svgEl('rect', { class: 'ns', x: '-44', y: '-30', width: '88', height: '60', rx: '6' })); - // Value-store motif: two stacked bars (stored variable rows) - g.appendChild(svgEl('path', { class: 'nd', d: 'M -12,13 H 12 M -8,20 H 8', - fill: 'none', stroke: NODE_STROKE.register, 'stroke-width': '2', - 'stroke-linecap': 'round', 'pointer-events': 'none' })); + g.appendChild(svgEl('rect', { class: 'ns', x: '-44', y: '-30', width: '88', height: '60', rx: '9' })); } else if (node.type === NodeType.DELAY) { g.appendChild(svgEl('circle', { class: 'ns', r: '32' })); g.appendChild(svgEl('circle', { class: 'delay-ring', r: '24', fill: 'none', 'stroke-dasharray': '5,3', 'stroke-width': '1.5' })); @@ -1502,24 +1636,34 @@ class Renderer { 'stroke-linecap': 'round', 'pointer-events': 'none' })); } else if (node.type === NodeType.QUEUE) { g.appendChild(svgEl('circle', { class: 'ns', r: '32' })); - // FIFO motif: entry arrow feeding into three waiting items - const qc = NODE_STROKE.queue; - g.appendChild(svgEl('path', { class: 'nd', d: 'M -20,14 H -13 M -16,11 L -13,14 L -16,17', - fill: 'none', stroke: qc, 'stroke-width': '1.8', - 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'pointer-events': 'none' })); - for (const x of [-6, 2, 10]) - g.appendChild(svgEl('circle', { class: 'q-dot nd', cx: x, cy: '14', r: '2.5', - fill: qc, 'pointer-events': 'none' })); + // FIFO motif: three fading dots — the line itself. + [1, 0.7, 0.4].forEach((op, i) => { + g.appendChild(svgEl('circle', { class: 'q-dot nd', cx: String(-8 + i * 8), cy: '14', r: '2.8', + fill: '#a9adff', opacity: String(op), 'pointer-events': 'none' })); + }); } else if (node.type === NodeType.TRADER) { g.appendChild(svgEl('circle', { class: 'ns', r: '32' })); // Exchange motif (⇄): two opposing arrows - const tc = NODE_STROKE.trader; - g.appendChild(svgEl('path', { d: 'M -11,11 H 9 M 5,7 L 9,11 L 5,15', - fill: 'none', stroke: tc, 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' })); - g.appendChild(svgEl('path', { d: 'M 11,19 H -9 M -5,15 L -9,19 L -5,23', - fill: 'none', stroke: tc, 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' })); + const tc = '#ffa8c5'; + g.appendChild(svgEl('path', { class: 'nd', d: 'M -11,11 H 9 M 5,7 L 9,11 L 5,15', + fill: 'none', stroke: tc, 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'pointer-events': 'none' })); + g.appendChild(svgEl('path', { class: 'nd', d: 'M 11,19 H -9 M -5,15 L -9,19 L -5,23', + fill: 'none', stroke: tc, 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'pointer-events': 'none' })); } + // Selection = dashed lime orbit around the node (no glow, no halo). + const orbitR = (NODE_R[node.type] || 32) + 7; + const orbit = node.type === NodeType.REGISTER + ? svgEl('rect', { class: 'sel-orbit', x: '-51', y: '-37', width: '102', height: '74', rx: '14' }) + : svgEl('circle', { class: 'sel-orbit', r: String(orbitR) }); + orbit.setAttribute('fill', 'none'); + orbit.setAttribute('stroke', '#b6e94d'); + orbit.setAttribute('stroke-width', '1.8'); + orbit.setAttribute('stroke-dasharray', '5,4'); + orbit.setAttribute('pointer-events', 'none'); + orbit.setAttribute('visibility', 'hidden'); + g.appendChild(orbit); + g.appendChild(svgEl('text', { class: 'n-count', 'text-anchor': 'middle', 'dominant-baseline': 'central', 'pointer-events': 'none' })); g.appendChild(svgEl('text', { class: 'n-label', 'text-anchor': 'middle', y: '50', 'pointer-events': 'none' })); g.appendChild(svgEl('text', { class: 'n-badge', 'text-anchor': 'middle', y: '-42', 'pointer-events': 'none' })); @@ -1534,15 +1678,36 @@ class Renderer { el.setAttribute('class', ['node', `n-${node.type}`, isSel && 'selected', isFiring && 'firing'] .filter(Boolean).join(' ')); - const fill = NODE_FILL[node.type] || '#1a2a3a'; + const fill = NODE_FILL[node.type] || '#10233f'; const stroke = NODE_STROKE[node.type] || '#4a9eff'; for (const s of el.querySelectorAll('.ns')) { s.setAttribute('fill', fill); s.setAttribute('stroke', stroke); - s.setAttribute('stroke-width', isSel ? '3' : '2'); + s.setAttribute('stroke-width', '2.5'); + } + // Selection = dashed lime orbit (no glow filter, no halo). + const orbit = el.querySelector('.sel-orbit'); + if (orbit) orbit.setAttribute('visibility', isSel ? 'visible' : 'hidden'); + + // Pool fill level: a chord segment whose height tracks resources/capacity. + if (node.type === NodeType.POOL) { + const fillPath = el.querySelector('.ns-fill'); + if (fillPath) { + const cap = node.capacity; + let p = (isFinite(cap) && cap > 0) ? node.resources / cap : 0; + p = Math.max(0, Math.min(1, p)); + if (p > 0.02) { + const r = 32; + const dy = r - 2 * r * p; // chord height (down = +) + const w = Math.sqrt(Math.max(0, r * r - dy * dy)); + const largeArc = p > 0.5 ? 1 : 0; + fillPath.setAttribute('d', `M ${-w},${dy} A ${r},${r} 0 ${largeArc} 0 ${w},${dy} Z`); + fillPath.setAttribute('fill', this._hexToRgba(node.displayColor || stroke, 0.22)); + } else { + fillPath.setAttribute('d', ''); + } + } } - if (isSel) el.querySelectorAll('.ns').forEach(s => s.setAttribute('filter', 'url(#glow)')); - else el.querySelectorAll('.ns').forEach(s => s.removeAttribute('filter')); // Color ring on pool showing dominant resource color const ring = el.querySelector('.ns-color-ring'); @@ -1569,9 +1734,9 @@ class Renderer { const back = el.querySelector('.ns-back'); const front = [...el.querySelectorAll('.ns')].find(s => !s.classList.contains('ns-back')); const inColor = dominantColor(node.colorMap); - if (back && inColor) back.setAttribute('fill', this._tintFill(NODE_FILL.converter, inColor, 0.4)); + if (back && inColor) back.setAttribute('fill', this._tintFill(NODE_FILL.converter, inColor, 0.25)); if (front && node.outputColor) { - front.setAttribute('fill', this._tintFill(NODE_FILL.converter, node.outputColor, 0.4)); + front.setAttribute('fill', this._tintFill(NODE_FILL.converter, node.outputColor, 0.25)); front.setAttribute('stroke', node.outputColor); } } @@ -1580,17 +1745,38 @@ class Renderer { // of the live count (falls back to the live count for nodes not recorded, // e.g. unlimited sources). const countEl = el.querySelector('.n-count'); + let countTxt; if (this._scrubSnap && node.id in this._scrubSnap) { const v = this._scrubSnap[node.id]; - countEl.textContent = Number.isInteger(v) ? v : +Number(v).toFixed(2); + countTxt = String(Number.isInteger(v) ? v : +Number(v).toFixed(2)); } else { - countEl.textContent = node.displayCount; + countTxt = String(node.displayCount); } + // Glyph-bearing shapes show their identity glyph (∞ × %) until there is a + // live number worth reading; then the number takes the spot. + const glyphEl = el.querySelector('.n-glyph'); + if (node.type === NodeType.SOURCE) { + if (countTxt === '∞') countTxt = ''; + } else if (node.type === NodeType.DRAIN || node.type === NodeType.GATE) { + if (countTxt === '0') countTxt = ''; + } else if (node.type === NodeType.REGISTER && countTxt) { + countTxt = `ƒx ${countTxt}`; + } + if (glyphEl) glyphEl.setAttribute('opacity', countTxt ? '0' : '1'); + countEl.textContent = countTxt; const lbl = el.querySelector('.n-label'); lbl.textContent = node.label; - if (node.type === NodeType.REGISTER && node.formula) { - lbl.textContent = `${node.label} (${node.formula})`; + + // Converter recipe caption (e.g. 2:1) in mono under the label. + if (node.type === NodeType.CONVERTER) { + const cap = el.querySelector('.n-caption'); + if (cap) { + const inN = (node.inputRecipe && node.inputRecipe.length) + ? node.inputRecipe.reduce((s, r) => s + (Number(r.amount) || 0), 0) + : (Number(node.inputAmount) || 1); + cap.textContent = `${inN}:1`; + } } const badge = el.querySelector('.n-badge'); @@ -1622,7 +1808,7 @@ class Renderer { setTempConn(x1, y1, x2, y2, type = ConnectionType.RESOURCE) { this.tempLayer.innerHTML = ''; this._connectHoverEl = null; // cleared with innerHTML above - const color = type === ConnectionType.RESOURCE ? '#ffa726' : '#78909c'; + const color = type === ConnectionType.RESOURCE ? '#ffa726' : '#7f879c'; this.tempLayer.appendChild(svgEl('line', { x1, y1, x2, y2, stroke: color, 'stroke-width': '2', 'stroke-dasharray': '8,5', 'marker-end': `url(#arrow-${type === ConnectionType.RESOURCE ? 'resource' : 'state'})`, @@ -1644,7 +1830,7 @@ class Renderer { if (!nodeId) return; const node = this.diagram.nodes.get(nodeId); if (!node) return; - const color = type === ConnectionType.STATE ? '#78909c' : '#ffa726'; + const color = type === ConnectionType.STATE ? '#7f879c' : '#ffa726'; const r = (NODE_R[node.type] || 32) + 7; this._connectHoverEl = svgEl('circle', { cx: node.x, cy: node.y, r, @@ -1659,7 +1845,7 @@ class Renderer { setMarquee(x0, y0, x1, y1) { if (!this._marqueeEl) { this._marqueeEl = svgEl('rect', { - fill: 'rgba(74,158,255,0.12)', stroke: '#4a9eff', + fill: 'rgba(182,233,77,0.10)', stroke: '#b6e94d', 'stroke-width': '1', 'stroke-dasharray': '4,3', 'pointer-events': 'none', }); this.tempLayer.appendChild(this._marqueeEl); diff --git a/test/smoke.js b/test/smoke.js index 130fb81..0148480 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -174,8 +174,8 @@ const URL = process.env.SMOKE_URL || 'http://localhost:8080/'; conn.modifier = false; conn.trigger = false; conn.reverseTrigger = false; conn.activator = false; window.app._onSelect(conn.id, 'conn'); const panel = document.getElementById('props-content'); - if (!panel.textContent.includes('Triggers target')) return 'missing role chips'; - const modChip = [...panel.querySelectorAll('.var-chip')].find(c => c.textContent === 'Modifies target'); + if (!panel.querySelector('.role-chips')) return 'missing role chips'; + const modChip = [...panel.querySelectorAll('.role-chips .var-chip')].find(c => c.textContent.includes('Modifier')); if (!modChip) return 'no modify chip'; modChip.click(); if (!conn.modifier || conn.modMode !== 'step') return `bad default mode: ${conn.modMode}`; @@ -420,8 +420,8 @@ const URL = process.env.SMOKE_URL || 'http://localhost:8080/'; const a = d.addNode(new MNode(NodeType.POOL, 600, 400)); const sc = d.addConnection(new MConnection(a.id, a.id, ConnectionType.STATE)); window.app._onSelect(sc.id, 'conn'); - const modChip = [...document.querySelectorAll('#props-content .var-chip')] - .find(c => c.textContent === 'Modifies target'); + const modChip = [...document.querySelectorAll('#props-content .role-chips .var-chip')] + .find(c => c.textContent.includes('Modifier')); const modToggled = !!modChip; if (modChip) modChip.click(); @@ -688,7 +688,8 @@ const URL = process.env.SMOKE_URL || 'http://localhost:8080/'; // Node panel reads as labelled sections. window.app._onSelect(p.id, 'node'); const secs = [...document.querySelectorAll('#props-content .props-sec')].map(el => el.textContent); - r.sections = secs.includes('Behavior') && secs.includes('Goal') && secs.includes('History'); + // History lives in the hero card now, so the sections are Properties / Behavior / Goal. + r.sections = secs.includes('Properties') && secs.includes('Behavior') && secs.includes('Goal'); r.typedTitle = !!document.querySelector('#props-content .props-overline'); return r; }); @@ -842,20 +843,19 @@ const URL = process.env.SMOKE_URL || 'http://localhost:8080/'; const hasLabel = (t) => [...document.querySelectorAll('#props-content .prop-row label')] .some(l => l.textContent.trim() === t); - // Gate mode should include 'all' option + // Gate routing chips should include 'All' const g = d.addNode(new MNode(NodeType.GATE, 300, 300)); window.app._onSelect(g.id, 'node'); - const modeOpts = [...document.querySelectorAll('#props-content select option')] - .map(o => o.value); - const hasAll = modeOpts.includes('all'); + const hasAll = [...document.querySelectorAll('#props-content .chip-row .var-chip')] + .some(c => c.textContent.trim() === 'All'); // State connection: trigger role exposes the fire/fail "On" select const a = d.addNode(new MNode(NodeType.POOL, 100, 100)); const b = d.addNode(new MNode(NodeType.POOL, 200, 100)); const sc = d.addConnection(new MConnection(a.id, b.id, ConnectionType.STATE)); window.app._onSelect(sc.id, 'conn'); - const trigChip = [...document.querySelectorAll('#props-content .var-chip')] - .find(c => c.textContent === 'Triggers target'); + const trigChip = [...document.querySelectorAll('#props-content .role-chips .var-chip')] + .find(c => c.textContent.includes('Trigger')); if (trigChip) trigChip.click(); const hasFailTrigger = [...document.querySelectorAll('#props-content select option')] .some(o => o.textContent.includes('fails to act')); @@ -863,14 +863,10 @@ const URL = process.env.SMOKE_URL || 'http://localhost:8080/'; // Resource connection: distribution rate mode exists const rc = d.addConnection(new MConnection(a.id, b.id)); window.app._onSelect(rc.id, 'conn'); - // Change rate mode to distribution - for (const row of document.querySelectorAll('#props-content .prop-row')) { - const lbl = row.querySelector('label'); - if (lbl && lbl.textContent.trim() === 'Rate mode') { - const sel = row.querySelector('select'); sel.value = 'distribution'; - sel.dispatchEvent(new Event('change', { bubbles: true })); - } - } + // Change rate mode to distribution via the mode chips + const distChip = [...document.querySelectorAll('#props-content .chip-row .var-chip')] + .find(c => c.textContent.trim() === 'Dist'); + if (distChip) distChip.click(); const hasDist = hasLabel('Distribution'); return { hasAll, hasFailTrigger, hasDist }; @@ -1272,8 +1268,10 @@ const URL = process.env.SMOKE_URL || 'http://localhost:8080/'; window.app._clearAll(); window.app._closeFeature(); document.querySelector('#diagram-rail .rail-btn[data-feature="time"]').click(); // time panel + // Time mode renders as two selectable cards (Synchronous / Asynchronous). const diagText = document.getElementById('props-content').textContent; - const hasTimeMode = diagText.includes('Time mode'); + const hasTimeMode = !!document.querySelector('#props-content .mode-card') + && diagText.includes('Synchronous') && diagText.includes('Asynchronous'); window.app.diagram.timeMode = 'async'; const s = window.app.diagram.addNode(new MNode(NodeType.SOURCE, 200, 200)); diff --git a/vendor/fonts/jetbrains-mono-latin-ext.woff2 b/vendor/fonts/jetbrains-mono-latin-ext.woff2 new file mode 100644 index 0000000..82f9668 Binary files /dev/null and b/vendor/fonts/jetbrains-mono-latin-ext.woff2 differ diff --git a/vendor/fonts/jetbrains-mono-latin.woff2 b/vendor/fonts/jetbrains-mono-latin.woff2 new file mode 100644 index 0000000..4d09cda Binary files /dev/null and b/vendor/fonts/jetbrains-mono-latin.woff2 differ diff --git a/vendor/fonts/space-grotesk-latin-ext.woff2 b/vendor/fonts/space-grotesk-latin-ext.woff2 new file mode 100644 index 0000000..db732c2 Binary files /dev/null and b/vendor/fonts/space-grotesk-latin-ext.woff2 differ diff --git a/vendor/fonts/space-grotesk-latin.woff2 b/vendor/fonts/space-grotesk-latin.woff2 new file mode 100644 index 0000000..0f3474e Binary files /dev/null and b/vendor/fonts/space-grotesk-latin.woff2 differ