feat(settings): rebuild App Settings as a rail over one scrolling document

The modal had grown to 8 tabs that wrapped onto two rows on desktop and
became a horizontal scroller on phones, with a "Display" mega-tab holding
11 sections and ~35 controls. Local Echo sat 60% down it, and the model
settings were split across two tabs whose three controls fought each
other (the 1M Opus toggle's own hint said it was "ignored when a Claude
Model is selected above").

Replaced with a left rail that is a TABLE OF CONTENTS over one scrolling
document: every section stays mounted, the rail follows the scroll, and
find-in-page works across the whole thing. Nine sections:

  Terminal & Input (Local Echo is the first row of the first section)
  Appearance, Header & Panels, Models, Agents & CLIs,
  Notifications, Voice, Shortcuts, System

Models are now one page. The picker is a card grid of BASE models with a
single "1M context window" switch; context becomes a property of the
chosen model and composes back into `claudeModel` as `base + [1m]`, which
retires the precedence trap. Thinking effort is a segmented control on
the same page, and the old Models tab (task routing) becomes a collapsed
Advanced block under it.

The 12 header-button toggles and the 8 panel toggles become chip grids,
which is most of the old Display tab reclaimed. Rows now say whether a
setting is per-device or synced, stated once per group.

Phones drop the rail for a sticky jump pill that names the current
section and opens a jump list, move Save into the header (the bottom
action bar cost 60px), and render groups as one inset rounded list with
hairline dividers instead of a stack of bordered cards.

Load and save are untouched: every control keeps its id, so
openAppSettings()/saveAppSettings() work as before. Model cards and the
effort segment are views over hidden <select>s that stay the source of
truth. test/app-settings-structure.test.ts pins that contract, plus the
rail hooks admin-ui.js injects the multi-user Users section into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-10 03:59:43 +02:00
parent c942bb5dfb
commit 45ad9de89e
10 changed files with 2397 additions and 863 deletions
+35 -20
View File
@@ -110,34 +110,49 @@
}
// ── Admin Users panel (injected into the App Settings modal) ──────────────
// The settings modal is a rail (table of contents) over ONE scrolling
// document, so this appends a rail entry plus a real section rather than a
// tab button plus a hidden panel.
function injectUsersTab() {
const modal = document.getElementById('appSettingsModal');
if (!modal || modal.querySelector('[data-tab="settings-users"]')) return;
const tabs = modal.querySelector('.modal-tabs');
const body = modal.querySelector('.modal-body');
if (!tabs || !body) return;
if (!modal || modal.querySelector('[data-section="settings-users"]')) return;
const rail = modal.querySelector('.set-rail-items');
const body = modal.querySelector('.set-doc');
if (!rail || !body) return;
const btn = document.createElement('button');
btn.className = 'modal-tab-btn';
btn.dataset.tab = 'settings-users';
btn.textContent = 'Users';
tabs.appendChild(btn);
const content = document.createElement('div');
content.className = 'modal-tab-content hidden';
btn.type = 'button';
btn.className = 'set-rail-item';
btn.dataset.section = 'settings-users';
btn.innerHTML =
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/></svg><span>Users</span>';
rail.appendChild(btn);
const content = document.createElement('section');
content.className = 'set-section';
content.id = 'settings-users';
content.dataset.label = 'Users';
content.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<strong>Users</strong>
<span>
<button class="btn btn-sm" id="adminOpenPanel">Open Admin Panel</button>
<button class="btn btn-sm" id="adminAddUser">+ Add user</button>
</span>
<div class="set-section-head">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/></svg>
<h2>Users</h2>
</div>
<p class="form-hint">Users share the host account; this separates workspaces, it does not sandbox
<p class="set-section-blurb">Users share the host account; this separates workspaces, it does not sandbox
users from each other. Pair with Docker cases for isolation.</p>
<div id="adminUsersTable"></div>
<p id="adminUsersMsg" style="min-height:1.2em;color:var(--muted,#888)"></p>`;
<div class="set-group">
<div class="set-group-head"><h4>Accounts</h4></div>
<div class="set-group-body">
<div class="set-row">
<div class="set-row-text"><span class="set-row-label">Manage users</span></div>
<div class="set-row-actions">
<button class="btn-toolbar btn-sm" id="adminOpenPanel">Open Admin Panel</button>
<button class="btn-toolbar btn-sm" id="adminAddUser">+ Add user</button>
</div>
</div>
<div id="adminUsersTable"></div>
<p id="adminUsersMsg" style="min-height:1.2em;color:var(--text-muted)"></p>
</div>
</div>`;
body.appendChild(content);
// Render whenever the tab is shown (the shared switchSettingsTab toggles it).
// Render whenever the entry is used (the shared switchSettingsTab scrolls to it).
btn.addEventListener('click', renderUsers);
content.querySelector('#adminAddUser').onclick = addUserFlow;
content.querySelector('#adminOpenPanel').onclick = openAdminPanel;