From a6cf4c2b2ab6f0564af36d7f0975961a31a23249 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 10 Aug 2026 10:58:05 +0200 Subject: [PATCH] feat(settings): reorder App Settings, tighten the rows, add a live layout preview The document side of the settings modal was wider than it needed to be: every row is text on the left and a switch pinned to the right, so a 960px shell plus a 62ch cap on the description left a dead gap of ~350px between the two. The shell is now 840px, the rail 196px, and descriptions run to 78ch, which closes the gap and makes the right side sit proportionally with the rail. Section order now leads with what you look at first: System (the version this install runs and whether an update is waiting, with Updates promoted above Paths/Automation/Remote access), then Terminal & Input, then Header & Panels. The modal opens scrolled to System instead of Terminal & Input. Header & Panels gains two things: - every chip carries the icon of the button it switches on, so the list reads as the header itself rather than as a column of names (File Viewer shows the folder button, Cron the clock, and so on); - a live preview above the chips: a scale model of the app with a header bar, right-docked panels, a toolbar and floating windows, rebuilt on every chip change so "what does this add" is answered in place, before saving. The preview owns no icons of its own - it CLONES `.set-chip-ico` out of the chip - so each icon has exactly one copy in index.html and a chip can never drift from the button it previews. A chip joins the preview by carrying `data-preview` (which slot) and `data-preview-order` (where in it); readouts that are not buttons (plan usage, CPU, font size) use `data-preview-text` instead. The frame is painted from skin tokens only, since hardcoded black alphas turned it into a grey slab on the four light skins, and it is marked `data-i18n-skip`: the mock tab names are decoration, and the labels inside are copies of chip text i18n has already translated. Cron moved into its own Scheduling group (it is a toolbar button, not a header one, and the preview places it accordingly). test/app-settings-structure.test.ts pins the new contract: the rail and the document agree on order, System leads with the version above the paths, and every previewed chip has both an icon to clone and a slot that exists. --- src/web/public/index.html | 387 +++++++++++++++------------- src/web/public/mobile.css | 9 + src/web/public/settings-ui.js | 83 +++++- src/web/public/styles.css | 268 ++++++++++++++++++- test/app-settings-structure.test.ts | 46 +++- 5 files changed, 604 insertions(+), 189 deletions(-) diff --git a/src/web/public/index.html b/src/web/public/index.html index 6bc54df3..97069654 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1312,18 +1312,22 @@
- + - + -

Settings marked device stay on this browser. The rest sync to every device.

@@ -1364,6 +1364,96 @@ + +
+
+ +

System

+
+

What this install is running, plus paths, automation and remote access.

+ +
+

Updates

+
+
+
Current version
+ — +
+
+
+ Check for updates + Looks at GitHub for a newer Codeman release. +
+ +
+ + + + +
+
+ +
+

Paths

synced
+
+
+
+ CLAUDE.md template + Used when creating new cases. Leave empty for the built-in template. +
+ +
+
+
+ Default working directory + Starting directory for new sessions. +
+ +
+
+
+ +
+

Automation

synced
+
+
+
+ Image watcher + Detect and pop up new images found in session directories. +
+ +
+
+
+ +
+

Remote access

synced
+
+
+
+ Cloudflare Tunnel + Expose Codeman on a public URL. Set CODEMAN_PASSWORD first. +
+ +
+ + +
+
+ +
@@ -1427,6 +1517,121 @@
+ +
+
+ +

Header & Panels

+
+

Pick the buttons and panels you actually use. Everything else stays out of the way.

+ + +
+
+ Live preview + Updates as you pick, before you save. +
+
+
+ + w1-codeman + w2-docs + + + +
+
+
❯ npm run dev
+
+
+
+
+ Run + +
+
+ +
+ +
+

Header buttons

device
+

Tap to show a control in the header. Multi-monitor is the one entry here that syncs across devices.

+
+
+ + + + + + + + + + + +
+
+
+ +
+

Panels

device
+
+
+ + + + +
+
+
+ +
+

Cross-session features

synced
+

Approvals answers prompts from any session in one place. Read My Mind captures your prompts and predicts the next one on demand; both cost tokens and never auto-send.

+
+
+ + + + +
+
+
+ +
+

Scheduling

device
+
+
+ +
+
+
+ +
+

Subagent windows

+
+
+
+ Track background agents synced + Follow Claude Code subagents in real time. +
+ +
+
+
+ Active tab only device + Only show agent windows for the selected session. +
+ +
+
+
+
+
@@ -1525,81 +1730,6 @@
- -
-
- -

Header & Panels

-
-

Pick the buttons and panels you actually use. Everything else stays out of the way.

- -
-

Header buttons

device
-

Tap to show a control in the header. Multi-monitor is the one entry here that syncs across devices.

-
-
- - - - - - - - - - - - -
-
-
- -
-

Panels

device
-
-
- - - - -
-
-
- -
-

Cross-session features

synced
-

Approvals answers prompts from any session in one place. Read My Mind captures your prompts and predicts the next one on demand; both cost tokens and never auto-send.

-
-
- - - - -
-
-
- -
-

Subagent windows

-
-
-
- Track background agents synced - Follow Claude Code subagents in real time. -
- -
-
-
- Active tab only device - Only show agent windows for the selected session. -
- -
-
-
-
-
@@ -2032,95 +2162,6 @@
- -
-
- -

System

-
-

Paths, automation, remote access and updates.

- -
-

Paths

synced
-
-
-
- CLAUDE.md template - Used when creating new cases. Leave empty for the built-in template. -
- -
-
-
- Default working directory - Starting directory for new sessions. -
- -
-
-
- -
-

Automation

synced
-
-
-
- Image watcher - Detect and pop up new images found in session directories. -
- -
-
-
- -
-

Remote access

synced
-
-
-
- Cloudflare Tunnel - Expose Codeman on a public URL. Set CODEMAN_PASSWORD first. -
- -
- - -
-
- -
-

Updates

-
-
-
Current version
- — -
-
-
- Check for updates - Looks at GitHub for a newer Codeman release. -
- -
- - - - -
-
-
diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 222b02ac..ae1a7e20 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -3378,6 +3378,15 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat display: none; } + /* ── live layout preview ───────────────────────────────────────────── */ + #appSettingsModal .set-preview { + margin-bottom: 12px; + } + + #appSettingsModal .set-preview-stage { + min-height: 62px; + } + /* ── inset grouped list ────────────────────────────────────────────── */ #appSettingsModal .set-group { margin-top: 14px; diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 2c5f1357..24331c11 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -503,8 +503,10 @@ Object.assign(CodemanApp.prototype, { this._syncSettingsChips(); this._syncModelCards(); this._syncEffortSegment(); - // Back to the top of the document (one scroll, not a tab reset). - this.switchSettingsTab('settings-terminal'); + // Back to the top of the document (one scroll, not a tab reset). System is + // first now: the version this install is running, and whether an update is + // waiting, are the two things worth seeing before anything is configured. + this.switchSettingsTab('settings-system'); const modal = document.getElementById('appSettingsModal'); modal.classList.add('active'); @@ -692,6 +694,79 @@ Object.assign(CodemanApp.prototype, { document.querySelectorAll('#appSettingsModal .set-chip').forEach(chip => { chip.classList.toggle('is-on', !!chip.querySelector('input')?.checked); }); + this._syncLayoutPreview(); + }, + + /** + * Redraw the Header & Panels live preview from the chips above it. + * + * The preview is a scale model of the app, not a second list of settings, so + * every icon is CLONED from the chip that owns it (`.set-chip-ico`): each icon + * has exactly ONE copy in index.html and a chip can never drift from the button + * it previews. A chip joins the preview purely by carrying `data-preview` + * (which slot) and `data-preview-order` (where in that slot); nothing here + * needs to know the setting's name. + * + * `data-preview-text` replaces the icon with a text token for the header + * entries that are readouts rather than buttons (plan usage, CPU, font size). + */ + _syncLayoutPreview() { + const modal = document.getElementById('appSettingsModal'); + if (!modal || typeof modal.querySelectorAll !== 'function') return; + const slots = { + header: document.getElementById('appSettingsPreviewHeader'), + panel: document.getElementById('appSettingsPreviewPanels'), + toolbar: document.getElementById('appSettingsPreviewToolbar'), + float: document.getElementById('appSettingsPreviewFloats'), + }; + if (!slots.header) return; + Object.values(slots).forEach(el => { + if (el) el.innerHTML = ''; + }); + + const chips = [...modal.querySelectorAll('.set-chip[data-preview]')] + .filter(chip => chip.querySelector('input')?.checked) + .sort((a, b) => (Number(a.dataset.previewOrder) || 0) - (Number(b.dataset.previewOrder) || 0)); + + let shown = 0; + for (const chip of chips) { + const kind = chip.dataset.preview; + const slot = slots[kind]; + if (!slot) continue; + // The label is the chip's own text; the icon span (if any) is skipped by + // taking the LAST span, which is always the label. + const spans = chip.querySelectorAll('span'); + const label = (spans[spans.length - 1]?.textContent || '').trim(); + const el = document.createElement('span'); + el.title = label; + if (kind === 'header') { + const text = chip.dataset.previewText; + el.className = text ? 'set-preview-chip' : 'set-preview-btn'; + if (text) el.textContent = text; + else this._appendPreviewIcon(el, chip); + } else { + el.className = `set-preview-${kind}`; + this._appendPreviewIcon(el, chip); + const name = document.createElement('span'); + name.textContent = label; + el.appendChild(name); + } + slot.appendChild(el); + shown++; + } + + const empty = document.getElementById('appSettingsPreviewEmpty'); + if (empty) empty.hidden = shown > 0; + }, + + /** Clone a chip's icon into a preview element (see _syncLayoutPreview). */ + _appendPreviewIcon(target, chip) { + const icon = chip.querySelector('.set-chip-ico'); + if (!icon) return; + const clone = icon.cloneNode(true); + clone.classList.remove('set-chip-ico'); + clone.classList.add('set-preview-ico'); + target.appendChild(clone); }, /** @@ -860,6 +935,10 @@ Object.assign(CodemanApp.prototype, { section.classList.toggle('set-hit-hidden', !!q && !hasVisible); }); + // The live preview sits outside any group, so it survives the sweep above; + // a search is asking for one row, not for the scale model around it. + doc.querySelectorAll('.set-preview').forEach(pv => pv.classList.toggle('set-hit-hidden', !!q)); + const empty = document.getElementById('appSettingsSearchEmpty'); if (empty) empty.hidden = !q || anyVisible; if (!q) doc.querySelectorAll('.set-group-advanced').forEach(g => g.classList.remove('open')); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 47c199ad..5dbbfdeb 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -14572,9 +14572,12 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { introduces its own `set-*` classes rather than restyling those. ═══════════════════════════════════════════════════════════════════════════ */ +/* Deliberately narrower than the other .modal-lg users: the document is a + single column of rows whose control sits at the far right, so extra width + buys nothing but a gap between a row's text and its switch. */ #appSettingsModal .modal-content.modal-lg { - max-width: min(960px, 96vw); - width: min(960px, 96vw); + max-width: min(840px, 96vw); + width: min(840px, 96vw); height: min(700px, 88vh); max-height: 88vh; } @@ -14609,7 +14612,7 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { #appSettingsModal .set-body { display: grid; - grid-template-columns: 212px minmax(0, 1fr); + grid-template-columns: 196px minmax(0, 1fr); flex: 1; min-height: 0; } @@ -14729,19 +14732,19 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { /* ── document ─────────────────────────────────────────────────────────── */ #appSettingsModal .set-doc { overflow-y: auto; - padding: 0 22px 40px; + padding: 0 18px 32px; min-width: 0; min-height: 0; position: relative; } #appSettingsModal .set-section { - padding-top: 20px; + padding-top: 16px; } #appSettingsModal .set-section + .set-section { border-top: 1px solid var(--border); - margin-top: 26px; + margin-top: 20px; } #appSettingsModal .set-section-head { @@ -14770,7 +14773,7 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { } #appSettingsModal .set-section-blurb { - margin: 0 0 14px 36px; + margin: 0 0 11px 34px; font-size: 0.69rem; color: var(--text-muted); } @@ -14784,7 +14787,7 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { /* ── groups ───────────────────────────────────────────────────────────── */ #appSettingsModal .set-group + .set-group { - margin-top: 18px; + margin-top: 14px; } #appSettingsModal .set-group-head { @@ -14849,15 +14852,15 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { #appSettingsModal .set-group-body { display: flex; flex-direction: column; - gap: 5px; + gap: 4px; } /* ── rows ─────────────────────────────────────────────────────────────── */ #appSettingsModal .set-row { display: flex; align-items: center; - gap: 14px; - padding: 9px 12px; + gap: 10px; + padding: 7px 11px; border-radius: 9px; background: rgba(255, 255, 255, 0.028); border: 1px solid rgba(255, 255, 255, 0.055); @@ -14893,11 +14896,13 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { letter-spacing: -0.005em; } +/* Wide enough that a description runs most of the way to its switch: the + complaint about this panel was the dead gap a 62ch cap left behind. */ #appSettingsModal .set-row-desc { font-size: 0.66rem; line-height: 1.45; color: var(--text-muted); - max-width: 62ch; + max-width: 78ch; } #appSettingsModal .set-row-actions { @@ -14980,6 +14985,14 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { color: var(--text-dim); } +/* The version is the first value in the first section now, so it reads as an + answer to "what am I running", not as one more right-aligned field. */ +#appSettingsModal .set-static-strong { + font-size: 0.82rem; + font-weight: 620; + color: var(--text); +} + #appSettingsModal .mono { font-family: var(--font-mono); } @@ -15054,6 +15067,24 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { pointer-events: none; } +/* The chip carries the icon of the button it switches on, so the list reads as + the header rather than as a list of names. It is also the ONLY copy of that + icon: the live preview clones it (settings-ui.js: _syncLayoutPreview). */ +#appSettingsModal .set-chip-ico { + flex-shrink: 0; + opacity: 0.75; +} + +#appSettingsModal .set-chip-emoji { + font-size: 0.85em; + line-height: 1; +} + +#appSettingsModal .set-chip.is-on .set-chip-ico, +#appSettingsModal .set-chip:has(input:checked) .set-chip-ico { + opacity: 1; +} + #appSettingsModal .set-chip::before { content: '\2713'; font-size: 0.8em; @@ -15089,6 +15120,219 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { outline-offset: 1px; } +/* ── live layout preview ────────────────────────────────────────────────── + A scale model of the app at the top of Header & Panels, rebuilt from the + chips on every change (settings-ui.js: _syncLayoutPreview). Everything here + is decoration around cloned chip icons, so it carries no icon of its own. + ─────────────────────────────────────────────────────────────────────────── */ +#appSettingsModal .set-preview { + margin: 0 0 14px; +} + +#appSettingsModal .set-preview-head { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 6px; +} + +#appSettingsModal .set-preview-title { + font-size: 0.63rem; + font-weight: 640; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--text-muted); +} + +#appSettingsModal .set-preview-hint { + font-size: 0.62rem; + color: var(--text-muted); + opacity: 0.72; +} + +/* Skin tokens only, no black/white alphas: on the four light skins a + hardcoded rgba(0,0,0,…) turned the whole model into a grey slab. */ +#appSettingsModal .set-preview-frame { + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + background: var(--bg-dark); +} + +#appSettingsModal .set-preview-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-bottom: 1px solid var(--border); + background: var(--bg-card); +} + +#appSettingsModal .set-preview-logo { + display: grid; + place-items: center; + width: 16px; + height: 16px; + border-radius: 5px; + font-size: 0.55rem; + font-weight: 700; + color: var(--accent-ink); + background: linear-gradient(180deg, var(--accent-grad-a), var(--accent-grad-b)); + flex-shrink: 0; +} + +#appSettingsModal .set-preview-tab { + font-size: 0.56rem; + padding: 2px 7px; + border-radius: 5px; + background: var(--control-bg); + color: var(--text-dim); + white-space: nowrap; +} + +#appSettingsModal .set-preview-tab-dim { + opacity: 0.5; +} + +#appSettingsModal .set-preview-gap { + flex: 1; + min-width: 6px; +} + +/* Wraps rather than overflows: every button switched on has to stay visible, + and the frame is narrower than the real header it stands in for. */ +#appSettingsModal .set-preview-btns { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 3px; +} + +#appSettingsModal .set-preview-btn { + display: inline-grid; + place-items: center; + width: 19px; + height: 19px; + border-radius: 5px; + color: var(--text-dim); + background: var(--control-bg); +} + +#appSettingsModal .set-preview-btn-fixed { + color: var(--text-muted); + background: transparent; +} + +#appSettingsModal .set-preview-chip { + font-size: 0.55rem; + padding: 3px 6px; + border-radius: 5px; + color: var(--text-dim); + background: var(--control-bg); + white-space: nowrap; +} + +#appSettingsModal svg.set-preview-ico { + width: 11px; + height: 11px; + flex-shrink: 0; +} + +#appSettingsModal span.set-preview-ico { + font-size: 9px; + line-height: 1; +} + +#appSettingsModal .set-preview-stage { + position: relative; + display: flex; + min-height: 76px; + padding: 9px 10px; +} + +#appSettingsModal .set-preview-term { + flex: 1; + font-family: var(--font-mono, monospace); + font-size: 0.58rem; + color: var(--text-muted); +} + +#appSettingsModal .set-preview-caret { + color: var(--accent); +} + +/* Right-docked, like every real panel in the app (monitor, subagents, file + browser and project insights are all `position: fixed; right: …`). */ +#appSettingsModal .set-preview-panels { + position: absolute; + right: 9px; + top: 9px; + bottom: 9px; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +#appSettingsModal .set-preview-panel, +#appSettingsModal .set-preview-float, +#appSettingsModal .set-preview-toolbar { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.55rem; + padding: 3px 7px; + border-radius: 6px; + color: var(--text-dim); + background: var(--control-bg); + border: 1px solid var(--border); + white-space: nowrap; +} + +#appSettingsModal .set-preview-floats { + position: absolute; + left: 12px; + bottom: 10px; + display: flex; + gap: 5px; +} + +#appSettingsModal .set-preview-float { + border-style: dashed; + color: var(--text-muted); +} + +#appSettingsModal .set-preview-tray { + display: flex; + align-items: center; + gap: 5px; + padding: 5px 8px; + border-top: 1px solid var(--border); + background: var(--bg-card); +} + +#appSettingsModal .set-preview-run { + font-size: 0.55rem; + font-weight: 640; + padding: 3px 9px; + border-radius: 5px; + color: var(--accent-ink); + background: linear-gradient(180deg, var(--accent-grad-a), var(--accent-grad-b)); +} + +#appSettingsModal .set-preview-tools { + display: flex; + align-items: center; + gap: 4px; +} + +#appSettingsModal .set-preview-empty { + margin: 6px 0 0; + font-size: 0.62rem; + color: var(--text-muted); +} + /* ── model cards ──────────────────────────────────────────────────────── */ #appSettingsModal .set-modelgrid { display: grid; diff --git a/test/app-settings-structure.test.ts b/test/app-settings-structure.test.ts index b71c8096..5a69a25e 100644 --- a/test/app-settings-structure.test.ts +++ b/test/app-settings-structure.test.ts @@ -59,8 +59,23 @@ describe('App Settings modal structure', () => { } }); - it('opens on Terminal & Input, so Local Echo is the first thing in reach', () => { - expect(settingsUi).toContain("this.switchSettingsTab('settings-terminal')"); + it('opens on System, with the version and the updater above everything else', () => { + expect(settingsUi).toContain("this.switchSettingsTab('settings-system')"); + const modal = settingsModal(); + const order = [...modal.matchAll(/
m[1]); + // Rail and document must agree, or scroll-spy paints the wrong entry. + const rail = [...modal.matchAll(/data-section="([a-z-]+)"/g)].map((m) => m[1]); + expect(rail.slice(0, 3)).toEqual(['settings-system', 'settings-terminal', 'settings-layout']); + expect(order.slice(0, 3)).toEqual(['settings-system', 'settings-terminal', 'settings-layout']); + // Updates leads the System section: version first, then the update action. + const system = modal.match(/id="settings-system"([\s\S]*?)<\/section>/)?.[1] ?? ''; + const version = system.indexOf('id="updateCurrentVersion"'); + expect(version).toBeGreaterThan(-1); + expect(version).toBeLessThan(system.indexOf('id="appSettingsClaudeMdPath"')); + expect(system.indexOf('id="updateCheckBtn"')).toBeLessThan(system.indexOf('id="appSettingsClaudeMdPath"')); + }); + + it('keeps Local Echo the first row of the second section', () => { const terminal = settingsModal().match(/id="settings-terminal"([\s\S]*?)<\/section>/); const localEcho = terminal?.[1].indexOf('appSettingsLocalEcho') ?? -1; const cjk = terminal?.[1].indexOf('appSettingsCjkInput') ?? -1; @@ -68,6 +83,33 @@ describe('App Settings modal structure', () => { expect(localEcho).toBeLessThan(cjk); }); + it('gives every previewed chip an icon to clone, and a slot that exists', () => { + // _syncLayoutPreview clones `.set-chip-ico` out of the chip, so a chip that + // opts into the preview without an icon renders as an empty button, and one + // pointing at a slot id that does not exist renders as nothing at all. + const layout = settingsModal().match(/id="settings-layout"([\s\S]*?)<\/section>/)?.[1] ?? ''; + const chips = [...layout.matchAll(/