feat(defaults): Compact header, Tiles button on, Classic tab layout

The owner's picks after testing the 1.36.0 beta:

- Header Stats Style defaults to Compact (two pills with rings) instead of
  Tiles. The resolver, the pre-paint stamp and the App Settings option all
  agree; an unknown value now reads as compact.
- The Tiles header button is ON by default everywhere but handhelds (their
  defaults object keeps it off, and the button still needs a 1180px window).
  The Ctrl+Shift+G gate in tileShortcutFor() now resolves an absent key
  through the device defaults too, so the chord and the button cannot
  disagree; before, it required a stored true.
- Tab Layout defaults to Classic (the single strip, as before). By state, By
  case and Ledger stay available as opt-ins. The tile-grid beta the owner used
  last had only this layout, and it is the one they wanted back.

zh-CN option labels follow the new "(default)" markers; docs and wiki updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-08 22:17:27 +02:00
parent 1568e489eb
commit 6644962d70
17 changed files with 121 additions and 71 deletions
+26 -13
View File
@@ -1,11 +1,11 @@
/**
* @fileoverview The header-stats styles (`headerStatsStyle`, Discussion #426
* option G): 'classic' (as before), 'compact' (one system pill with rings
* plus a plan-ring pill) and 'tiles' (label over value, bar underneath; the
* default).
* plus a plan-ring pill; the default, the owner's pick on the 1.36.0 beta) and
* 'tiles' (label over value, bar underneath).
*
* Pinned here:
* - The default is 'tiles', and only the three known values are honoured.
* - The default is 'compact', and only the three known values are honoured.
* - The two clustered styles move the connection indicator INTO the system
* stats pill and the plan chip right after it, on the REAL header markup
* from index.html, and 'classic' puts both back exactly where the template
@@ -69,8 +69,7 @@ function makeApp() {
}
/** Element children of the header's right side, by id (or first class). */
const rightSide = () =>
[...document.getElementById('headerRight')!.children].map((el) => el.id || el.classList[0]);
const rightSide = () => [...document.getElementById('headerRight')!.children].map((el) => el.id || el.classList[0]);
const statsChildren = () =>
[...document.getElementById('headerSystemStats')!.children].map((el) => el.id || el.classList[0]);
@@ -80,14 +79,27 @@ beforeEach(() => {
});
describe('resolveHeaderStatsStyle', () => {
it("defaults to 'tiles' and honours only the three known styles", () => {
it("defaults to 'compact' and honours only the three known styles", () => {
const app = makeApp();
expect(app.resolveHeaderStatsStyle({})).toBe('tiles');
expect(app.resolveHeaderStatsStyle(undefined)).toBe('tiles');
expect(app.resolveHeaderStatsStyle({})).toBe('compact');
expect(app.resolveHeaderStatsStyle(undefined)).toBe('compact');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'classic' })).toBe('classic');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'compact' })).toBe('compact');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'tiles' })).toBe('tiles');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'neon' })).toBe('tiles');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'neon' })).toBe('compact');
});
});
describe('resolveTabArrangement', () => {
it("defaults to 'classic' and honours only the four known layouts", () => {
// Lives here because this file already loads settings-ui.js's resolvers.
const app = makeApp();
expect(app.resolveTabArrangement({})).toBe('classic');
expect(app.resolveTabArrangement(undefined)).toBe('classic');
for (const value of ['state', 'case', 'ledger', 'classic']) {
expect(app.resolveTabArrangement({ tabArrangement: value })).toBe(value);
}
expect(app.resolveTabArrangement({ tabArrangement: 'neon' })).toBe('classic');
});
});
@@ -260,15 +272,16 @@ describe('header stats wiring (static)', () => {
expect([...found.keys()].filter((s) => /(tiles|split)-open/.test(s) && s.includes(':is('))).toEqual([]);
});
it('stamps data-header-stats before first paint, tiles by default and classic on narrow screens', () => {
it('stamps data-header-stats before first paint, compact by default and classic on narrow screens', () => {
expect(INDEX).toContain(
"dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='compact')?H:'tiles'"
"dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact'"
);
});
it('offers the three styles with tiles marked as the default', () => {
it('offers the three styles with compact marked as the default', () => {
expect(INDEX).toMatch(
/<select id="appSettingsHeaderStatsStyle"[^>]*>\s*<option value="classic">[^<]+<\/option>\s*<option value="compact">[^<]+<\/option>\s*<option value="tiles">Tiles \(default\)<\/option>/
/<select id="appSettingsHeaderStatsStyle"[^>]*>\s*<option value="classic">[^<]+<\/option>\s*<option value="compact">Compact \(default\)<\/option>\s*<option value="tiles">[^<]+<\/option>/
);
expect(INDEX).not.toContain('Tiles (default)');
});
});
+2 -1
View File
@@ -71,7 +71,8 @@ afterAll(() => {
describe('the Tab Layout settings rows in zh-CN', () => {
it('finds the three rows and all their options (the check is not vacuous)', () => {
expect(english.size).toBe(15);
expect(english.get('appSettingsHeaderStatsStyle option tiles')).toBe('Tiles (default)');
expect(english.get('appSettingsHeaderStatsStyle option compact')).toBe('Compact (default)');
expect(english.get('appSettingsHeaderStatsStyle option tiles')).toBe('Tiles (label over value)');
expect(english.get('appSettingsTabStateOrder option urgent-last')).toBe('Needs you at the bottom');
});
+6 -4
View File
@@ -615,17 +615,19 @@ describe('tab grouping wiring (static)', () => {
const mobileCss = read('mobile.css');
it('stamps data-tab-arrangement and data-tab-state-order before first paint', () => {
expect(html).toContain("dataset.tabArrangement=(T==='case'||T==='ledger'||T==='classic')?T:'state'");
expect(html).toContain("dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic'");
expect(html).toContain("dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first'");
// The catch branch (localStorage threw) must set both too.
expect(html).toContain(
"document.documentElement.dataset.tabArrangement='state';document.documentElement.dataset.tabStateOrder='urgent-first';"
"document.documentElement.dataset.tabArrangement='classic';document.documentElement.dataset.tabStateOrder='urgent-first';"
);
});
it('offers the four layouts with "By state" as the default, and the state order', () => {
it('offers the four layouts with "Classic" as the default, and the state order', () => {
// Classic (the single strip, as before) is the default: the owner's pick on
// the 1.36.0 beta. By state, By case and Ledger are opt-in.
expect(html).toMatch(
/<select id="appSettingsTabArrangement"[^>]*>\s*<option value="state">By state \(default\)<\/option>\s*<option value="case">[^<]+<\/option>\s*<option value="ledger">[^<]+<\/option>\s*<option value="classic">Classic \(as before\)<\/option>/
/<select id="appSettingsTabArrangement"[^>]*>\s*<option value="state">By state \(rows per state\)<\/option>\s*<option value="case">[^<]+<\/option>\s*<option value="ledger">[^<]+<\/option>\s*<option value="classic">Classic \(default\)<\/option>/
);
expect(html).toMatch(
/<select id="appSettingsTabStateOrder"[^>]*>\s*<option value="urgent-first">Needs you on top \(default\)<\/option>\s*<option value="urgent-last">/
+9 -3
View File
@@ -2,7 +2,7 @@
// Port: none (pure static analysis, runs in CI, no browser/server).
//
// `showTileGridButton` (the header Tiles button, and the Ctrl+Shift+G chord) is
// a PER-DEVICE setting, default OFF, with the same three-way rule as
// a PER-DEVICE setting (default ON on desktop, OFF on handhelds) with the same three-way rule as
// showSplitButton (test/split-pane-per-device-setting.test.ts, read it for the
// history): in settings-ui.js's displayKeys merge policy, stripped out of the
// object saveAppSettings() PUTs (SettingsUpdateSchema is .strict(), so sending
@@ -38,10 +38,16 @@ describe('showTileGridButton stays per-device: display key, stripped from the PU
expect(schemas).not.toContain('showTileGridButton');
});
it('defaults OFF on handhelds (and reads as OFF when absent everywhere)', () => {
it('defaults ON on desktop (an absent key reads as ON) and OFF on handhelds', () => {
// Owner's pick on the 1.36.0 beta: the Tiles button ships visible on desktop.
// Handhelds keep it OFF in their defaults object, and the button never shows
// below 1180px wide anyway.
expect(settingsUi).toMatch(/showSplitButton: false,\s*showTileGridButton: false,/);
expect(settingsUi).toContain(
"document.getElementById('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? false;"
"document.getElementById('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? true;"
);
expect(settingsUi).toContain(
'const showTileGridButton = settings.showTileGridButton ?? defaults.showTileGridButton ?? true;'
);
});
+23 -4
View File
@@ -51,6 +51,11 @@ const withTilesSetting = (app: GridApp) => {
app.loadAppSettingsFromStorage = () => ({ showTileGridButton: true });
return app;
};
/** The per-device Tiles setting explicitly off on this device. */
const withTilesSettingOff = (app: GridApp) => {
app.loadAppSettingsFromStorage = () => ({ showTileGridButton: false });
return app;
};
const RIGHT = { key: 'ArrowRight', code: 'ArrowRight', altKey: true, shiftKey: true };
beforeEach(() => {
@@ -88,13 +93,27 @@ describe('when a chord applies', () => {
expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid');
});
it('with the Tiles setting off (the default) the toggle is inert, like an unbound key', () => {
const app = makeGridApp(IDS);
it('with the Tiles setting off the toggle is inert, like an unbound key', () => {
const app = withTilesSettingOff(makeGridApp(IDS));
expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull();
});
it('with no stored value the device default decides: ON on desktop, OFF on a handheld', () => {
// Desktop: nothing stored and no device default, so the chord follows the
// button's own `?? true` (settings-ui.js) and applies.
const desktop = makeGridApp(IDS);
desktop.loadAppSettingsFromStorage = () => ({});
desktop.getDefaultSettings = () => ({});
expect(desktop.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid');
// Handheld: the device defaults say OFF, so the chord stays inert.
const handheld = makeGridApp(IDS);
handheld.loadAppSettingsFromStorage = () => ({});
handheld.getDefaultSettings = () => ({ showTileGridButton: false });
expect(handheld.tileShortcutFor(chord(TOGGLE))).toBeNull();
});
it('with the setting off, a grid opened another way (Ctrl+click, a drop) still has its chords, toggle included', () => {
const app = makeGridApp(IDS);
const app = withTilesSettingOff(makeGridApp(IDS));
app.openTileGrid(IDS);
expect(app.tileShortcutFor(chord(RIGHT))).toBe('focus-tile-right');
expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid');
@@ -176,7 +195,7 @@ describe('the capture-phase handler', () => {
}
it('setting off: Ctrl+Shift+G is left alone (no preventDefault), the grid stays closed', () => {
const app = makeGridApp(IDS);
const app = withTilesSettingOff(makeGridApp(IDS));
const onKeydown = handlerFor(app);
const e = chord(TOGGLE);
onKeydown(e);