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.
This commit is contained in:
Codeman maintainer
2026-08-10 10:58:05 +02:00
parent 752374abc7
commit a6cf4c2b2a
5 changed files with 604 additions and 189 deletions
+44 -2
View File
@@ -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(/<section class="set-section" id="([a-z-]+)"/g)].map((m) => 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(/<label class="set-chip"([^>]*)>([\s\S]*?)<\/label>/g)];
const previewed = chips.filter(([, attrs]) => attrs.includes('data-preview='));
expect(previewed.length).toBeGreaterThanOrEqual(15);
for (const [, attrs, body] of previewed) {
const kind = attrs.match(/data-preview="([a-z]+)"/)?.[1];
expect(['header', 'panel', 'toolbar', 'float']).toContain(kind);
expect(attrs, `chip ${body} needs a preview order`).toMatch(/data-preview-order="\d+"/);
// A text token replaces the icon for readouts (plan usage, CPU, font size).
const hasIcon = body.includes('class="set-chip-ico') || attrs.includes('data-preview-text=');
expect(hasIcon, `chip ${body} has nothing to render in the preview`).toBe(true);
}
for (const id of [
'appSettingsPreviewHeader',
'appSettingsPreviewPanels',
'appSettingsPreviewToolbar',
'appSettingsPreviewFloats',
]) {
expect(layout).toContain(`id="${id}"`);
expect(settingsUi).toContain(`'${id}'`);
}
});
it('models: keeps the 1M variants as select options behind the context switch', () => {
const modal = settingsModal();
const select = modal.match(/id="appSettingsClaudeModel"([\s\S]*?)<\/select>/)?.[1] ?? '';