diff --git a/src/bun/index.ts b/src/bun/index.ts index a1a3ecb..e192f41 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -86,8 +86,9 @@ const win = new BrowserWindow({ x: 120, y: 120, }, - // Frame decision lives in chromeOptions — see windows.ts. - ...chromeOptions(), + // Frame decision lives in chromeOptions — see windows.ts. Constructor-only, + // 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 preload: buildPreload("main", currentSettings), rpc, diff --git a/src/bun/windows.ts b/src/bun/windows.ts index 6c2b5c0..200716b 100644 --- a/src/bun/windows.ts +++ b/src/bun/windows.ts @@ -20,13 +20,16 @@ export function buildPreload( ): string { let script = `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)};`; // 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 - // setting any more, this build IS the Windows 7 build. - script += `document.documentElement.classList.add("win7");`; + // literal `.win7` ancestor, so the name is not ours to choose. Mirrors + // SettingsService.applySkin, which owns it once the page is live. + if (settings.skin === "win7") { + script += `document.documentElement.classList.add("win7");`; + } const dataset: Record = {}; 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 — * both the main window (src/bun/index.ts) and openSecondaryWindow spread this. * - * The webview draws its own Aero title bar (drag region + our own - * 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). + * Both values are BrowserWindow CONSTRUCTOR options, which is exactly why a + * skin change only reaches the frame on the next window open: there is no API + * to re-decorate a live window. SettingsService.applySkin flips the CSS half + * immediately, and the options row tells the user about the gap. */ -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 - // radius and its outer drop shadow are drawn by the webview OUTSIDE the - // window's own rectangle, so on an opaque surface they are simply clipped — - // square corners and no shadow. The UI reserves the shadow's gutter (see - // the .window margin in styles.css). - // PORTABILITY: on Linux this needs a compositor granting an RGBA visual; - // without one the frame still draws, just without the shadow falloff. - return { titleBarStyle: "hidden" as const, transparent: true }; + // radius is drawn by the webview OUTSIDE the window's own rectangle, so on + // an opaque surface the corners are simply clipped square. + // PORTABILITY: on Linux this needs a compositor granting an RGBA visual. + if (skin === "win7") return { titleBarStyle: "hidden" as const, transparent: true }; + + // modern: the native frame stays. On macOS it fuses with the app's own + // 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, url: opts.url, frame: opts.frame, - ...chromeOptions(), + ...chromeOptions(opts.settings.skin), preload: buildPreload(opts.mode, opts.settings, opts.ctx), rpc: opts.rpc, hidden: true, diff --git a/src/shared/i18n/de.ts b/src/shared/i18n/de.ts index 5145a1c..ccf6490 100644 --- a/src/shared/i18n/de.ts +++ b/src/shared/i18n/de.ts @@ -33,6 +33,12 @@ export const de = { title: "Optionen", language: "Sprache", 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" }, tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" }, accent: { label: "Akzent" }, diff --git a/src/shared/i18n/en.ts b/src/shared/i18n/en.ts index 0d32123..cb71fa3 100644 --- a/src/shared/i18n/en.ts +++ b/src/shared/i18n/en.ts @@ -37,6 +37,12 @@ export const en = { title: "Options", language: "Language", 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" }, tone: { label: "Tone", dark: "Dark", darker: "Darker" }, accent: { label: "Accent" }, diff --git a/src/shared/i18n/es.ts b/src/shared/i18n/es.ts index 1ff289a..c52464e 100644 --- a/src/shared/i18n/es.ts +++ b/src/shared/i18n/es.ts @@ -33,6 +33,12 @@ export const es = { title: "Opciones", language: "Idioma", 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" }, tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" }, accent: { label: "Acento" }, diff --git a/src/shared/i18n/eu.ts b/src/shared/i18n/eu.ts index a2de41c..e045bea 100644 --- a/src/shared/i18n/eu.ts +++ b/src/shared/i18n/eu.ts @@ -35,6 +35,12 @@ export const eu = { title: "Aukerak", language: "Hizkuntza", 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" }, tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" }, accent: { label: "Azentua" }, diff --git a/src/shared/i18n/fr.ts b/src/shared/i18n/fr.ts index 8dd7626..4d6263a 100644 --- a/src/shared/i18n/fr.ts +++ b/src/shared/i18n/fr.ts @@ -33,6 +33,12 @@ export const fr = { title: "Options", language: "Langue", 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" }, tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" }, accent: { label: "Accent" }, diff --git a/src/shared/i18n/ja.ts b/src/shared/i18n/ja.ts index 5339b97..9c599ad 100644 --- a/src/shared/i18n/ja.ts +++ b/src/shared/i18n/ja.ts @@ -34,6 +34,12 @@ export const ja = { title: "オプション", language: "言語", system: "システム", + skin: { + label: "外観", + modern: "モダン", + win7: "Windows 7", + restartHint: "ウィンドウの枠を変更するには開き直してください", + }, theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" }, tone: { label: "トーン", dark: "ダーク", darker: "より暗く" }, accent: { label: "アクセント" }, diff --git a/src/shared/i18n/zh.ts b/src/shared/i18n/zh.ts index 388dd14..4d838ad 100644 --- a/src/shared/i18n/zh.ts +++ b/src/shared/i18n/zh.ts @@ -34,6 +34,12 @@ export const zh = { title: "选项", language: "语言", system: "系统", + skin: { + label: "外观", + modern: "现代", + win7: "Windows 7", + restartHint: "重新打开窗口以更改窗口边框", + }, theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" }, tone: { label: "色调", dark: "深色", darker: "更深" }, accent: { label: "强调色" }, diff --git a/src/shared/types.ts b/src/shared/types.ts index 3fbb4a2..4fac792 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -78,6 +78,18 @@ export type AccentTone = export interface AppSettings { /** UI language; "system" resolves to the OS locale (fallback English). */ 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"; /** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */ darkTone: "dark" | "darker"; @@ -108,6 +120,7 @@ export interface AppSettings { export const DEFAULT_SETTINGS: AppSettings = { locale: "system", + skin: "modern", theme: "system", darkTone: "dark", accent: "blue", diff --git a/ui/src/app/core/settings.service.ts b/ui/src/app/core/settings.service.ts index e33bdf7..33697f6 100644 --- a/ui/src/app/core/settings.service.ts +++ b/ui/src/app/core/settings.service.ts @@ -7,6 +7,7 @@ import { RpcService } from './rpc.service'; @Injectable({ providedIn: 'root' }) export class SettingsService { readonly locale = signal(DEFAULT_SETTINGS.locale); + readonly skin = signal(DEFAULT_SETTINGS.skin); readonly theme = signal(DEFAULT_SETTINGS.theme); readonly darkTone = signal(DEFAULT_SETTINGS.darkTone); readonly accent = signal(DEFAULT_SETTINGS.accent); @@ -50,6 +51,7 @@ export class SettingsService { private applyAll(settings: AppSettings): void { this.locale.set(settings.locale); + this.skin.set(settings.skin); this.theme.set(settings.theme); this.darkTone.set(settings.darkTone); this.accent.set(settings.accent); @@ -62,6 +64,7 @@ export class SettingsService { this.autoClearCompleted.set(settings.autoClearCompleted); this.editor.set(settings.editor); this.editorApp.set(settings.editorApp); + this.applySkin(); this.applyTheme(); this.applyDarkTone(); this.applyAccent(); @@ -75,6 +78,19 @@ export class SettingsService { 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 { this.theme.set(theme); this.applyTheme(); @@ -142,6 +158,13 @@ export class SettingsService { 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 { const theme = this.theme(); if (theme === 'system') { @@ -185,6 +208,7 @@ export class SettingsService { protected settings(): AppSettings { return { locale: this.locale(), + skin: this.skin(), theme: this.theme(), darkTone: this.darkTone(), accent: this.accent(), diff --git a/ui/src/app/skins/7like/tab-bar/tab-bar.ts b/ui/src/app/skins/7like/tab-bar/tab-bar.ts index ffa6c78..b7fca3b 100644 --- a/ui/src/app/skins/7like/tab-bar/tab-bar.ts +++ b/ui/src/app/skins/7like/tab-bar/tab-bar.ts @@ -10,6 +10,7 @@ import { RpcService } from '@core/rpc.service'; import { SettingsService } from '@core/settings.service'; import { UpdaterService } from '@core/updater.service'; +const SKINS = ['modern', 'win7'] as const; const THEMES = ['system', 'light', 'dark'] as const; const DARK_TONES = ['dark', 'darker'] as const; // 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; + +
+ {{ i18n.t('options.skin.label') }} +
+ @for (s of skins; track s) { + + } +
+
+
{{ i18n.t('options.theme.label') }}
@@ -431,6 +454,7 @@ export class TabBar { readonly buildLabel = BRANDING.buildLabel; /** What the window was BUILT with, not the current setting — the frameless * chrome only changes on reopen, so the hint compares against this. */ + readonly skins = SKINS; readonly themes = THEMES; readonly darkTones = DARK_TONES; readonly accents = ACCENTS; diff --git a/ui/src/app/skins/shadlike/tab-bar/tab-bar.ts b/ui/src/app/skins/shadlike/tab-bar/tab-bar.ts index 5a20c0f..62ca01f 100644 --- a/ui/src/app/skins/shadlike/tab-bar/tab-bar.ts +++ b/ui/src/app/skins/shadlike/tab-bar/tab-bar.ts @@ -10,6 +10,7 @@ import { RpcService } from '@core/rpc.service'; import { SettingsService } from '@core/settings.service'; import { UpdaterService } from '@core/updater.service'; +const SKINS = ['modern', 'win7'] as const; const THEMES = ['system', 'light', 'dark'] as const; const DARK_TONES = ['dark', 'darker'] as const; // 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;
+ +
+ {{ i18n.t('options.skin.label') }} +
+ @for (s of skins; track s) { + + } +
+
+
{{ i18n.t('options.theme.label') }}
@@ -436,6 +459,7 @@ export class TabBar { readonly modLabel = MOD_LABEL; readonly appName = BRANDING.appName; readonly buildLabel = BRANDING.buildLabel; + readonly skins = SKINS; readonly themes = THEMES; readonly darkTones = DARK_TONES; readonly accents = ACCENTS; diff --git a/ui/src/main.ts b/ui/src/main.ts index cf8180e..28c38c3 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -1,10 +1,19 @@ import { bootstrapApplication } from '@angular/platform-browser'; 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 { SHAD_ROOTS } from './app/skins/shadlike'; -// One bundle, three window kinds: the preload global picks the root component. -// A second axis (skin) joins APP_MODE here once the shadlike tree lands. -const tree = SEVEN_ROOTS; +// One bundle, two skins x three window kinds. Both globals are baked into the +// preload (src/bun/windows.ts buildPreload) because both are needed before the +// 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; bootstrapApplication(root, appConfig).catch((err) => console.error(err));