Stage 5: wire the skin switch — both UIs now reachable

The reunion is complete: one build contains both UIs and picks between them at
bootstrap.

- src/shared/types.ts regains `skin: "modern" | "win7"` on AppSettings.
- SettingsService regains the skin signal, setSkin() and applySkin(), which
  toggles the `.win7` class on <html>.
- buildPreload emits __APP_SKIN and adds `.win7` conditionally again, instead
  of unconditionally as it had since fe028a9.
- chromeOptions() regains its parameter: win7 gets frameless + transparent,
  modern gets the macOS hiddenInset fusion or the plain native frame.
- core/platform.ts regains APP_SKIN / IS_WIN7_CHROME (added in stage 4).
- main.ts now selects on both axes — skin picks the tree, APP_MODE picks the
  root within it.
- Both tab-bars get a skin row, with the reopen caveat as its tooltip.

DEFAULT_SETTINGS.skin is "modern", matching the original design and `main`.
Worth flagging: this branch has booted win7 unconditionally since fe028a9, so
after this commit it starts in the modern UI unless the setting says otherwise.
One word in types.ts flips that if the win7 look should be the default.

i18n: en.ts is the source of truth and everything else falls back to it, so
only en was strictly required — but es/eu/fr/de/zh/ja got real translations
too, each matching the terminology that locale already uses for the Theme row.

Verified both skins on live single-instance captures. Modern boots and renders
fully styled, which also proves Tailwind scans skins/shadlike/** — a purge
would have produced an unstyled page rather than this one. Win7, selected by
setting skin in the persisted settings, is still pixel-identical to the
pre-refactor baseline across all five stages. The persisted settings file was
backed up and restored, so it still carries no skin key and resolves to the
default through the backend's merge.

Not verified: clicking the new skin row itself, which needs an interaction pass
— no window automation is available here. Its bindings compile and the built
bundle contains options.skin, setSkin and the translated labels.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-16 05:01:57 +02:00
co-authored by Claude
parent bb97e5418f
commit eafe426c8e
14 changed files with 174 additions and 25 deletions
+3 -2
View File
@@ -86,8 +86,9 @@ const win = new BrowserWindow({
x: 120, x: 120,
y: 120, y: 120,
}, },
// Frame decision lives in chromeOptions — see windows.ts. // Frame decision lives in chromeOptions — see windows.ts. Constructor-only,
...chromeOptions(), // so a skin change reaches the frame on the NEXT open, not live.
...chromeOptions(currentSettings.skin),
// lets the UI pick the fused layout + theme before first paint // lets the UI pick the fused layout + theme before first paint
preload: buildPreload("main", currentSettings), preload: buildPreload("main", currentSettings),
rpc, rpc,
+30 -18
View File
@@ -20,13 +20,16 @@ export function buildPreload(
): string { ): string {
let script = let script =
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` + `window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
`window.__APP_MODE=${JSON.stringify(mode)};`; `window.__APP_MODE=${JSON.stringify(mode)};` +
`window.__APP_SKIN=${JSON.stringify(settings.skin)};`;
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`; if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
// A class, not a data-* attribute: 7.scoped.css is precompiled to require a // A class, not a data-* attribute: 7.scoped.css is precompiled to require a
// literal `.win7` ancestor. Unconditional on this branch — the skin is not a // literal `.win7` ancestor, so the name is not ours to choose. Mirrors
// setting any more, this build IS the Windows 7 build. // SettingsService.applySkin, which owns it once the page is live.
if (settings.skin === "win7") {
script += `document.documentElement.classList.add("win7");`; script += `document.documentElement.classList.add("win7");`;
}
const dataset: Record<string, string> = {}; const dataset: Record<string, string> = {};
if (settings.theme !== "system") dataset["theme"] = settings.theme; if (settings.theme !== "system") dataset["theme"] = settings.theme;
@@ -43,22 +46,31 @@ export function buildPreload(
* Native chrome options for a window. The ONE place the frame decision is made — * Native chrome options for a window. The ONE place the frame decision is made —
* both the main window (src/bun/index.ts) and openSecondaryWindow spread this. * both the main window (src/bun/index.ts) and openSecondaryWindow spread this.
* *
* The webview draws its own Aero title bar (drag region + our own * Both values are BrowserWindow CONSTRUCTOR options, which is exactly why a
* minimize/maximize/close), so the window is frameless on EVERY platform. * skin change only reaches the frame on the next window open: there is no API
* `titleBarStyle: "hidden"` clears `Titled` from the Cocoa style mask, which is * to re-decorate a live window. SettingsService.applySkin flips the CSS half
* also what removes the macOS traffic lights — there is no separate * immediately, and the options row tells the user about the gap.
* hide-traffic-lights API. Linux honours it too: the GTK wrapper calls
* gtk_window_set_decorated(false).
*/ */
export function chromeOptions() { export function chromeOptions(skin: AppSettings["skin"]) {
// win7 draws its own Aero title bar (drag region + our minimize/maximize/
// close), so the window is frameless on EVERY platform. `titleBarStyle:
// "hidden"` clears `Titled` from the Cocoa style mask, which is also what
// removes the macOS traffic lights — there is no separate
// hide-traffic-lights API. Linux honours it too: the GTK wrapper calls
// gtk_window_set_decorated(false).
//
// `transparent` is what lets the Aero frame be a real frame: .window's 6px // `transparent` is what lets the Aero frame be a real frame: .window's 6px
// radius and its outer drop shadow are drawn by the webview OUTSIDE the // radius is drawn by the webview OUTSIDE the window's own rectangle, so on
// window's own rectangle, so on an opaque surface they are simply clipped — // an opaque surface the corners are simply clipped square.
// square corners and no shadow. The UI reserves the shadow's gutter (see // PORTABILITY: on Linux this needs a compositor granting an RGBA visual.
// the .window margin in styles.css). if (skin === "win7") return { titleBarStyle: "hidden" as const, transparent: true };
// PORTABILITY: on Linux this needs a compositor granting an RGBA visual;
// without one the frame still draws, just without the shadow falloff. // modern: the native frame stays. On macOS it fuses with the app's own
return { titleBarStyle: "hidden" as const, transparent: true }; // header row (hiddenInset), and the traffic lights overlay it — which is
// what IS_MAC_CHROME reserves pl-24 for in the shadlike tree.
return process.platform === "darwin"
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
: {};
} }
/** /**
@@ -129,7 +141,7 @@ export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow
title: opts.title, title: opts.title,
url: opts.url, url: opts.url,
frame: opts.frame, frame: opts.frame,
...chromeOptions(), ...chromeOptions(opts.settings.skin),
preload: buildPreload(opts.mode, opts.settings, opts.ctx), preload: buildPreload(opts.mode, opts.settings, opts.ctx),
rpc: opts.rpc, rpc: opts.rpc,
hidden: true, hidden: true,
+6
View File
@@ -33,6 +33,12 @@ export const de = {
title: "Optionen", title: "Optionen",
language: "Sprache", language: "Sprache",
system: "System", system: "System",
skin: {
label: "Optik",
modern: "Modern",
win7: "Windows 7",
restartHint: "Fenster neu öffnen, um den Rahmen zu ändern",
},
theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" }, theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" },
tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" }, tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" },
accent: { label: "Akzent" }, accent: { label: "Akzent" },
+6
View File
@@ -37,6 +37,12 @@ export const en = {
title: "Options", title: "Options",
language: "Language", language: "Language",
system: "System", system: "System",
skin: {
label: "Skin",
modern: "Modern",
win7: "Windows 7",
restartHint: "Reopen the window to change its frame",
},
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" }, theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
tone: { label: "Tone", dark: "Dark", darker: "Darker" }, tone: { label: "Tone", dark: "Dark", darker: "Darker" },
accent: { label: "Accent" }, accent: { label: "Accent" },
+6
View File
@@ -33,6 +33,12 @@ export const es = {
title: "Opciones", title: "Opciones",
language: "Idioma", language: "Idioma",
system: "Sistema", system: "Sistema",
skin: {
label: "Aspecto",
modern: "Moderno",
win7: "Windows 7",
restartHint: "Vuelve a abrir la ventana para cambiar su marco",
},
theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" }, theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" }, tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" },
accent: { label: "Acento" }, accent: { label: "Acento" },
+6
View File
@@ -35,6 +35,12 @@ export const eu = {
title: "Aukerak", title: "Aukerak",
language: "Hizkuntza", language: "Hizkuntza",
system: "Sistema", system: "Sistema",
skin: {
label: "Itxura",
modern: "Modernoa",
win7: "Windows 7",
restartHint: "Ireki leihoa berriro bere markoa aldatzeko",
},
theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" }, theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" },
tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" }, tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" },
accent: { label: "Azentua" }, accent: { label: "Azentua" },
+6
View File
@@ -33,6 +33,12 @@ export const fr = {
title: "Options", title: "Options",
language: "Langue", language: "Langue",
system: "Système", system: "Système",
skin: {
label: "Apparence",
modern: "Moderne",
win7: "Windows 7",
restartHint: "Rouvrez la fenêtre pour changer son cadre",
},
theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" }, theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" }, tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" },
accent: { label: "Accent" }, accent: { label: "Accent" },
+6
View File
@@ -34,6 +34,12 @@ export const ja = {
title: "オプション", title: "オプション",
language: "言語", language: "言語",
system: "システム", system: "システム",
skin: {
label: "外観",
modern: "モダン",
win7: "Windows 7",
restartHint: "ウィンドウの枠を変更するには開き直してください",
},
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" }, theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
tone: { label: "トーン", dark: "ダーク", darker: "より暗く" }, tone: { label: "トーン", dark: "ダーク", darker: "より暗く" },
accent: { label: "アクセント" }, accent: { label: "アクセント" },
+6
View File
@@ -34,6 +34,12 @@ export const zh = {
title: "选项", title: "选项",
language: "语言", language: "语言",
system: "系统", system: "系统",
skin: {
label: "外观",
modern: "现代",
win7: "Windows 7",
restartHint: "重新打开窗口以更改窗口边框",
},
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" }, theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
tone: { label: "色调", dark: "深色", darker: "更深" }, tone: { label: "色调", dark: "深色", darker: "更深" },
accent: { label: "强调色" }, accent: { label: "强调色" },
+13
View File
@@ -78,6 +78,18 @@ export type AccentTone =
export interface AppSettings { export interface AppSettings {
/** UI language; "system" resolves to the OS locale (fallback English). */ /** UI language; "system" resolves to the OS locale (fallback English). */
locale: "system" | SupportedLocale; locale: "system" | SupportedLocale;
/**
* Visual skin. "modern" is the shadcn-style look; "win7" swaps in 7.css
* (Windows 7 Aero) and switches the window to custom frameless chrome.
* Each skin is a whole component tree under ui/src/app/skins/ — "modern" is
* shadlike/, "win7" is 7like/ — selected at bootstrap in ui/src/main.ts.
* The value is NOT named after its folder on purpose: it is persisted in
* user settings, and "win7" is also the literal class 7.scoped.css is
* precompiled to require, so neither can be renamed freely.
* Changing this needs a window reopen to take effect on the frame itself —
* the CSS half applies live.
*/
skin: "modern" | "win7";
theme: "system" | "light" | "dark"; theme: "system" | "light" | "dark";
/** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */ /** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */
darkTone: "dark" | "darker"; darkTone: "dark" | "darker";
@@ -108,6 +120,7 @@ export interface AppSettings {
export const DEFAULT_SETTINGS: AppSettings = { export const DEFAULT_SETTINGS: AppSettings = {
locale: "system", locale: "system",
skin: "modern",
theme: "system", theme: "system",
darkTone: "dark", darkTone: "dark",
accent: "blue", accent: "blue",
+24
View File
@@ -7,6 +7,7 @@ import { RpcService } from './rpc.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SettingsService { export class SettingsService {
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale); readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
readonly skin = signal<AppSettings['skin']>(DEFAULT_SETTINGS.skin);
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme); readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone); readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent); readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
@@ -50,6 +51,7 @@ export class SettingsService {
private applyAll(settings: AppSettings): void { private applyAll(settings: AppSettings): void {
this.locale.set(settings.locale); this.locale.set(settings.locale);
this.skin.set(settings.skin);
this.theme.set(settings.theme); this.theme.set(settings.theme);
this.darkTone.set(settings.darkTone); this.darkTone.set(settings.darkTone);
this.accent.set(settings.accent); this.accent.set(settings.accent);
@@ -62,6 +64,7 @@ export class SettingsService {
this.autoClearCompleted.set(settings.autoClearCompleted); this.autoClearCompleted.set(settings.autoClearCompleted);
this.editor.set(settings.editor); this.editor.set(settings.editor);
this.editorApp.set(settings.editorApp); this.editorApp.set(settings.editorApp);
this.applySkin();
this.applyTheme(); this.applyTheme();
this.applyDarkTone(); this.applyDarkTone();
this.applyAccent(); this.applyAccent();
@@ -75,6 +78,19 @@ export class SettingsService {
void this.persist(); void this.persist();
} }
/**
* Only the CSS half of a skin change lands here. The component TREE and the
* native frame are both fixed at window construction (main.ts reads the
* preload-baked __APP_SKIN; titleBarStyle/transparent are BrowserWindow
* constructor options), so the switch is not complete until the window is
* reopened. The options row says so.
*/
setSkin(skin: AppSettings['skin']): void {
this.skin.set(skin);
this.applySkin();
void this.persist();
}
setTheme(theme: AppSettings['theme']): void { setTheme(theme: AppSettings['theme']): void {
this.theme.set(theme); this.theme.set(theme);
this.applyTheme(); this.applyTheme();
@@ -142,6 +158,13 @@ export class SettingsService {
void this.persist(); void this.persist();
} }
/** A CLASS, not a data-* attribute: 7.scoped.css is precompiled to require a
* literal `.win7` ancestor, so the name is not ours to choose. Mirrors what
* buildPreload writes for the first paint. */
private applySkin(): void {
document.documentElement.classList.toggle('win7', this.skin() === 'win7');
}
private applyTheme(): void { private applyTheme(): void {
const theme = this.theme(); const theme = this.theme();
if (theme === 'system') { if (theme === 'system') {
@@ -185,6 +208,7 @@ export class SettingsService {
protected settings(): AppSettings { protected settings(): AppSettings {
return { return {
locale: this.locale(), locale: this.locale(),
skin: this.skin(),
theme: this.theme(), theme: this.theme(),
darkTone: this.darkTone(), darkTone: this.darkTone(),
accent: this.accent(), accent: this.accent(),
+24
View File
@@ -10,6 +10,7 @@ import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { UpdaterService } from '@core/updater.service'; import { UpdaterService } from '@core/updater.service';
const SKINS = ['modern', 'win7'] as const;
const THEMES = ['system', 'light', 'dark'] as const; const THEMES = ['system', 'light', 'dark'] as const;
const DARK_TONES = ['dark', 'darker'] as const; const DARK_TONES = ['dark', 'darker'] as const;
// Swatch colors are fixed mid-tones so every dot reads on both light and dark // Swatch colors are fixed mid-tones so every dot reads on both light and dark
@@ -174,6 +175,28 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</select> </select>
</div> </div>
<!-- Skin: swaps the WHOLE component tree (ui/src/app/skins/) plus the
native frame. Only the CSS half applies live — titleBarStyle and
transparent are BrowserWindow constructor options and main.ts
reads the preload-baked __APP_SKIN — so the hint is not optional. -->
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.skin.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (s of skins; track s) {
<button
(click)="settings.setSkin(s)"
[title]="i18n.t('options.skin.restartHint')"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.skin() === s"
[class.text-foreground]="settings.skin() === s"
[class.text-muted-foreground]="settings.skin() !== s"
>
{{ i18n.t('options.skin.' + s) }}
</button>
}
</div>
</div>
<div class="mt-2.5 flex items-center justify-between gap-2"> <div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.theme.label') }}</span> <span class="text-xs text-muted-foreground">{{ i18n.t('options.theme.label') }}</span>
<div class="flex rounded-md border border-border p-0.5"> <div class="flex rounded-md border border-border p-0.5">
@@ -431,6 +454,7 @@ export class TabBar {
readonly buildLabel = BRANDING.buildLabel; readonly buildLabel = BRANDING.buildLabel;
/** What the window was BUILT with, not the current setting — the frameless /** What the window was BUILT with, not the current setting — the frameless
* chrome only changes on reopen, so the hint compares against this. */ * chrome only changes on reopen, so the hint compares against this. */
readonly skins = SKINS;
readonly themes = THEMES; readonly themes = THEMES;
readonly darkTones = DARK_TONES; readonly darkTones = DARK_TONES;
readonly accents = ACCENTS; readonly accents = ACCENTS;
@@ -10,6 +10,7 @@ import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { UpdaterService } from '@core/updater.service'; import { UpdaterService } from '@core/updater.service';
const SKINS = ['modern', 'win7'] as const;
const THEMES = ['system', 'light', 'dark'] as const; const THEMES = ['system', 'light', 'dark'] as const;
const DARK_TONES = ['dark', 'darker'] as const; const DARK_TONES = ['dark', 'darker'] as const;
// Swatch colors are fixed mid-tones so every dot reads on both light and dark // Swatch colors are fixed mid-tones so every dot reads on both light and dark
@@ -180,6 +181,28 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</select> </select>
</div> </div>
<!-- Skin: swaps the WHOLE component tree (ui/src/app/skins/) plus the
native frame. Only the CSS half applies live — titleBarStyle and
transparent are BrowserWindow constructor options and main.ts
reads the preload-baked __APP_SKIN — so the hint is not optional. -->
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.skin.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (s of skins; track s) {
<button
(click)="settings.setSkin(s)"
[title]="i18n.t('options.skin.restartHint')"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.skin() === s"
[class.text-foreground]="settings.skin() === s"
[class.text-muted-foreground]="settings.skin() !== s"
>
{{ i18n.t('options.skin.' + s) }}
</button>
}
</div>
</div>
<div class="mt-2.5 flex items-center justify-between gap-2"> <div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.theme.label') }}</span> <span class="text-xs text-muted-foreground">{{ i18n.t('options.theme.label') }}</span>
<div class="flex rounded-md border border-border p-0.5"> <div class="flex rounded-md border border-border p-0.5">
@@ -436,6 +459,7 @@ export class TabBar {
readonly modLabel = MOD_LABEL; readonly modLabel = MOD_LABEL;
readonly appName = BRANDING.appName; readonly appName = BRANDING.appName;
readonly buildLabel = BRANDING.buildLabel; readonly buildLabel = BRANDING.buildLabel;
readonly skins = SKINS;
readonly themes = THEMES; readonly themes = THEMES;
readonly darkTones = DARK_TONES; readonly darkTones = DARK_TONES;
readonly accents = ACCENTS; readonly accents = ACCENTS;
+13 -4
View File
@@ -1,10 +1,19 @@
import { bootstrapApplication } from '@angular/platform-browser'; import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from '@core/app.config'; import { appConfig } from '@core/app.config';
import { APP_MODE } from '@core/platform'; import { APP_MODE, APP_SKIN } from '@core/platform';
import { SEVEN_ROOTS } from './app/skins/7like'; import { SEVEN_ROOTS } from './app/skins/7like';
import { SHAD_ROOTS } from './app/skins/shadlike';
// One bundle, three window kinds: the preload global picks the root component. // One bundle, two skins x three window kinds. Both globals are baked into the
// A second axis (skin) joins APP_MODE here once the shadlike tree lands. // preload (src/bun/windows.ts buildPreload) because both are needed before the
const tree = SEVEN_ROOTS; // first paint: settings arrive over an async RPC, far too late to choose a
// component tree without flashing the wrong UI.
//
// Every skin's barrel exports the same { main, manager, editor } shape, so this
// file never learns anything about either tree's internals. Both are imported
// statically and ship in one bundle — the inactive skin costs bundle size but
// no runtime work, which is the right trade for a local desktop app and keeps
// bootstrap synchronous.
const tree = APP_SKIN === 'win7' ? SEVEN_ROOTS : SHAD_ROOTS;
const root = APP_MODE === 'editor' ? tree.editor : APP_MODE === 'manager' ? tree.manager : tree.main; const root = APP_MODE === 'editor' ? tree.editor : APP_MODE === 'manager' ? tree.manager : tree.main;
bootstrapApplication(root, appConfig).catch((err) => console.error(err)); bootstrapApplication(root, appConfig).catch((err) => console.error(err));