Add a toggleable Windows 7 (7.css) skin behind a new skin setting
Evaluation branch: a full Aero skin, including frameless windows with our own title bar, switchable against the existing modern look. The app already had a four-axis runtime skinning mechanism and zero component stylesheets, so the skin is one new setting plus one stylesheet section rather than a parallel UI. The key move is cascade order: 7.css is imported into a `skin` layer ABOVE Tailwind's `utilities`, so its bare element rules outrank the ~1900 inline utility classes already in the templates. Template changes are limited to additive, skin-inert hooks (ARIA roles, `has-scrollbar`, `w7-toolbar`, `w7-btn`). - styles.css: explicit layer order, `7.css/dist/7.scoped.css` via `layer(skin)`, a token bridge re-pointing the existing semantic tokens at Win7 values (no new tokens, so `@theme inline` is untouched), a hand-authored dark Win7 palette, and neutralizers that `revert-layer` 7.css's dialog-button metrics back to the utility layer. Neutralizers must live inside `skin`, not `skinfix` — `revert-layer` only reaches `utilities` from one layer up. - Frameless chrome: `chromeOptions(skin)` is now the single place either skin's frame is decided. `titleBarStyle: "hidden"` on every platform, which is also the only way to drop the macOS traffic lights. New `title-bar` component mounted by all three window roots. - Window controls over RPC (minimize/toggle-maximize/close/is-maximized), served per window kind so the X reuses each one's existing close path — editors still get their dirty prompt. electrobun has no maximize event, so the state is derived from `resize`; double-click-to-maximize is implemented here too. - Font: Noto Sans vendored (OFL-1.1) under ui/src/fonts, since Segoe UI is absent off Windows and not redistributable. Verified: backend `tsc --noEmit` clean, `bun run build:ui` clean (486 kB initial, under the 500 kB budget), app launches with the skin active and no console errors. The rendered result has not been eyeballed — screen capture and accessibility are both denied to the shell on this machine. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,8 @@
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- win7 skin only: the window is frameless, so this IS the chrome -->
|
||||
<app-title-bar [title]="appName" (closeRequested)="closeWindow()" />
|
||||
<app-tab-bar />
|
||||
|
||||
<main class="flex min-h-0 flex-1 divide-x divide-border">
|
||||
|
||||
@@ -17,6 +17,7 @@ import { RpcService } from './rpc.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { StatusBar } from './status-bar/status-bar';
|
||||
import { TabBar } from './tab-bar/tab-bar';
|
||||
import { TitleBar } from './title-bar/title-bar';
|
||||
import { ToastService } from './toast.service';
|
||||
import { Toasts } from './toasts/toasts';
|
||||
import { TransferBar } from './transfer-bar/transfer-bar';
|
||||
@@ -36,6 +37,7 @@ import { UpdaterService } from './updater.service';
|
||||
OverwriteDialog,
|
||||
Toasts,
|
||||
TransferBar,
|
||||
TitleBar,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
host: {
|
||||
@@ -58,6 +60,18 @@ export class App {
|
||||
/** The connection-manager window is open — this window is modal-blocked. */
|
||||
readonly managerOpen = signal(false);
|
||||
|
||||
/** Shown in the win7 title bar (no native titlebar to carry it). */
|
||||
readonly appName = BRANDING.appName;
|
||||
|
||||
/**
|
||||
* win7 title bar's X. Closing the main window quits, and the app-level
|
||||
* before-quit guard (dirty editors + running transfers) still runs — it is
|
||||
* the one close path electrobun lets us cancel.
|
||||
*/
|
||||
closeWindow(): void {
|
||||
void this.rpc.request.windowClose({});
|
||||
}
|
||||
|
||||
constructor() {
|
||||
document.title = BRANDING.appName;
|
||||
if (this.rpc.available) {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<div class="flex h-screen flex-col bg-background text-foreground">
|
||||
<!-- win7 skin only: frameless window. Fixed-size modal, so Close only. -->
|
||||
<app-title-bar
|
||||
[title]="i18n.t('connMgr.title')"
|
||||
[resizable]="false"
|
||||
(closeRequested)="close()"
|
||||
/>
|
||||
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
|
||||
elsewhere the native titlebar sits above and this is a compact strip -->
|
||||
<header
|
||||
class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
|
||||
class="w7-toolbar flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
|
||||
[class.h-10]="isMacChrome"
|
||||
[class.pl-24]="isMacChrome"
|
||||
[class.electrobun-webkit-app-region-drag]="isMacChrome"
|
||||
@@ -47,7 +53,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto py-1">
|
||||
<div class="has-scrollbar flex-1 overflow-y-auto py-1">
|
||||
<!-- stacks -->
|
||||
@for (group of store.grouped().stacks; track group.stack.id) {
|
||||
<div
|
||||
@@ -175,7 +181,7 @@
|
||||
<!-- edit form -->
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
@if (draft(); as d) {
|
||||
<div class="grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5">
|
||||
<div class="has-scrollbar grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5">
|
||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
|
||||
<input
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DialogsService } from '../dialogs.service';
|
||||
import { blockNativeContextMenu, IS_MAC_CHROME } from '../platform';
|
||||
import { RpcService } from '../rpc.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { TitleBar } from '../title-bar/title-bar';
|
||||
import { Toasts } from '../toasts/toasts';
|
||||
|
||||
interface Draft {
|
||||
@@ -72,7 +73,7 @@ function fromDraft(d: Draft): SavedConnection {
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts],
|
||||
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts, TitleBar],
|
||||
templateUrl: './connection-manager.html',
|
||||
host: {
|
||||
'(window:keydown.escape)': 'onEscape()',
|
||||
|
||||
@@ -60,13 +60,13 @@ const PERMS = ['r', 'w', 'x'] as const;
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
(click)="cancel()"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="submit()"
|
||||
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{{ i18n.t('common.apply') }}
|
||||
</button>
|
||||
|
||||
@@ -17,7 +17,7 @@ import { FocusService } from '../focus.service';
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
(click)="cancel()"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('common.cancel') }}
|
||||
</button>
|
||||
@@ -26,8 +26,8 @@ import { FocusService } from '../focus.service';
|
||||
(click)="submit()"
|
||||
[class]="
|
||||
request().destructive
|
||||
? 'rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90'
|
||||
: 'rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90'
|
||||
? 'w7-btn rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90'
|
||||
: 'w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90'
|
||||
"
|
||||
>
|
||||
{{ request().confirmLabel }}
|
||||
|
||||
@@ -79,7 +79,7 @@ import { FocusService } from '../focus.service';
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
(click)="pick('cancel')"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{{ i18n.t('common.cancel') }}
|
||||
</button>
|
||||
@@ -101,14 +101,14 @@ import { FocusService } from '../focus.service';
|
||||
}
|
||||
<button
|
||||
(click)="pick('skip')"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('dialogs.skip') }}
|
||||
</button>
|
||||
<button
|
||||
#primaryBtn
|
||||
(click)="pick('overwrite')"
|
||||
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{{ isDir() ? i18n.t('dialogs.merge') : i18n.t('dialogs.replace') }}
|
||||
</button>
|
||||
|
||||
@@ -33,14 +33,14 @@ import { FocusService } from '../focus.service';
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
(click)="cancel()"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="submit()"
|
||||
[disabled]="!value().trim()"
|
||||
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{{ request().confirmLabel }}
|
||||
</button>
|
||||
|
||||
@@ -10,10 +10,13 @@
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- win7 skin only: the window is frameless. The X runs the SAME dirty
|
||||
prompt as ⌘W and the Close button — never a raw window close. -->
|
||||
<app-title-bar [title]="ctx.name + (dirty() ? ' *' : '')" (closeRequested)="requestClose()" />
|
||||
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
|
||||
elsewhere the native titlebar sits above and this is a compact strip -->
|
||||
<header
|
||||
class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
|
||||
class="w7-toolbar flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
|
||||
[class.h-10]="isMacChrome"
|
||||
[class.pl-24]="isMacChrome"
|
||||
[class.electrobun-webkit-app-region-drag]="isMacChrome"
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_L
|
||||
import { RpcService } from '../rpc.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { ToastService } from '../toast.service';
|
||||
import { TitleBar } from '../title-bar/title-bar';
|
||||
import { Toasts } from '../toasts/toasts';
|
||||
|
||||
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
|
||||
@@ -30,7 +31,7 @@ const DRAFT_DEBOUNCE_MS = 400;
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [NgIcon, Toasts],
|
||||
imports: [NgIcon, Toasts, TitleBar],
|
||||
templateUrl: './editor-window.html',
|
||||
host: {
|
||||
'(window:keydown)': 'onKeydown($event)',
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
@if (menuOpen()) {
|
||||
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
|
||||
<div
|
||||
class="absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl"
|
||||
class="has-scrollbar absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl"
|
||||
>
|
||||
<button
|
||||
(click)="pickLocal()"
|
||||
@@ -238,7 +238,7 @@
|
||||
(keydown)="onKeydown($event)"
|
||||
(mousedown)="activate()"
|
||||
(contextmenu)="listContextMenu($event)"
|
||||
class="h-full overflow-y-auto outline-none"
|
||||
class="has-scrollbar h-full overflow-y-auto outline-none"
|
||||
>
|
||||
@if (panel().connecting()) {
|
||||
<div class="panel-fade-in grid h-full place-items-center">
|
||||
|
||||
+26
-5
@@ -8,6 +8,9 @@ declare global {
|
||||
__APP_PLATFORM?: string;
|
||||
/** Which root component this window boots. */
|
||||
__APP_MODE?: 'main' | 'editor' | 'manager';
|
||||
/** Persisted skin — decides the window's native chrome, so it has to be
|
||||
* known before first paint (see chromeOptions in src/bun/windows.ts). */
|
||||
__APP_SKIN?: 'modern' | 'win7';
|
||||
/** Window-kind specific context (EditorOpenContext for editor windows). */
|
||||
__APP_CTX?: unknown;
|
||||
}
|
||||
@@ -15,14 +18,32 @@ declare global {
|
||||
|
||||
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
|
||||
|
||||
/** True when the window uses macOS hiddenInset chrome (header fused with titlebar). */
|
||||
export const IS_MAC_CHROME = PLATFORM === 'darwin';
|
||||
const IS_MAC = PLATFORM === 'darwin';
|
||||
|
||||
/**
|
||||
* The win7 skin makes the window frameless on EVERY platform and draws its own
|
||||
* Aero title bar, so this is a window-chrome fact, not just a settings value —
|
||||
* it is baked into the preload rather than read from SettingsService, which
|
||||
* resolves too late for layout. Toggling the setting only affects windows
|
||||
* opened afterwards.
|
||||
*/
|
||||
export const IS_WIN7_CHROME =
|
||||
typeof window !== 'undefined' && window.__APP_SKIN === 'win7';
|
||||
|
||||
/**
|
||||
* True when macOS traffic lights overlay the app's header row (the fused
|
||||
* hiddenInset layout, which reserves pl-24 for them). False under the win7
|
||||
* skin even on macOS: titleBarStyle 'hidden' removes the traffic lights, so
|
||||
* reserving space for them would leave a gap.
|
||||
*/
|
||||
export const IS_MAC_CHROME = IS_MAC && !IS_WIN7_CHROME;
|
||||
|
||||
export const IS_WINDOWS = PLATFORM === 'win32';
|
||||
|
||||
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
|
||||
export const MOD_LABEL = IS_MAC_CHROME ? '⌘' : 'Ctrl+';
|
||||
export const ALT_LABEL = IS_MAC_CHROME ? '⌥' : 'Alt+';
|
||||
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. Keyed on the real
|
||||
* platform, NOT IS_MAC_CHROME — the keyboard does not change with the skin. */
|
||||
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
|
||||
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
|
||||
|
||||
export const APP_MODE: 'main' | 'editor' | 'manager' =
|
||||
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
||||
|
||||
@@ -32,6 +32,7 @@ export class RpcService {
|
||||
private managerStateListeners = new Set<(open: boolean) => void>();
|
||||
private connectionsListeners = new Set<() => void>();
|
||||
private updateStatusListeners = new Set<(status: UpdateStatus) => void>();
|
||||
private windowStateListeners = new Set<(maximized: boolean) => void>();
|
||||
|
||||
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
||||
private ready: Promise<void> = Promise.resolve();
|
||||
@@ -71,6 +72,9 @@ export class RpcService {
|
||||
updateStatus: (status) => {
|
||||
for (const listener of this.updateStatusListeners) listener(status);
|
||||
},
|
||||
windowState: ({ maximized }) => {
|
||||
for (const listener of this.windowStateListeners) listener(maximized);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -123,6 +127,12 @@ export class RpcService {
|
||||
this.updateStatusListeners.add(listener);
|
||||
}
|
||||
|
||||
/** win7 skin: the frameless window's maximize state changed (derived from
|
||||
* the native resize event, so Aero Snap and Win+Up land here too). */
|
||||
onWindowState(listener: (maximized: boolean) => void): void {
|
||||
this.windowStateListeners.add(listener);
|
||||
}
|
||||
|
||||
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
|
||||
get request() {
|
||||
const rpc = this.rpc;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RpcService } from './rpc.service';
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SettingsService {
|
||||
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 darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
|
||||
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
|
||||
@@ -40,6 +41,7 @@ export class SettingsService {
|
||||
.addEventListener('change', (e) => this.systemDark.set(e.matches));
|
||||
// the default accent is not the CSS default — apply before first paint
|
||||
this.applyAccent();
|
||||
this.applySkin();
|
||||
if (!this.rpc.available) return;
|
||||
void this.rpc.request.getSettings({}).then((res) => {
|
||||
if (res.ok) this.applyAll(res.settings);
|
||||
@@ -50,6 +52,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 +65,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 +79,12 @@ export class SettingsService {
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
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 +152,16 @@ export class SettingsService {
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* The odd one out: a CLASS, not a data-* attribute. 7.scoped.css is
|
||||
* precompiled to require a literal `.win7` ancestor, so the class is not
|
||||
* ours to choose. Default ("modern") = class absent, matching the
|
||||
* delete-when-default rule the other axes follow.
|
||||
*/
|
||||
private applySkin(): void {
|
||||
document.documentElement.classList.toggle('win7', this.skin() === 'win7');
|
||||
}
|
||||
|
||||
private applyTheme(): void {
|
||||
const theme = this.theme();
|
||||
if (theme === 'system') {
|
||||
@@ -185,6 +205,7 @@ export class SettingsService {
|
||||
protected settings(): AppSettings {
|
||||
return {
|
||||
locale: this.locale(),
|
||||
skin: this.skin(),
|
||||
theme: this.theme(),
|
||||
darkTone: this.darkTone(),
|
||||
accent: this.accent(),
|
||||
|
||||
@@ -5,11 +5,12 @@ import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
||||
import type { AccentTone } from '@shared/types';
|
||||
import { I18nService } from '../i18n.service';
|
||||
import { PanelsService } from '../panels.service';
|
||||
import { IS_MAC_CHROME, MOD_LABEL } from '../platform';
|
||||
import { IS_MAC_CHROME, IS_WIN7_CHROME, MOD_LABEL } from '../platform';
|
||||
import { RpcService } from '../rpc.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { UpdaterService } from '../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
|
||||
@@ -41,7 +42,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
<!-- On macOS this row IS the titlebar (hiddenInset): taller, padded past the
|
||||
traffic lights, and draggable. Elsewhere it's a normal compact row. -->
|
||||
<div
|
||||
class="relative flex items-center gap-0.5 border-b border-border bg-chrome pr-1.5 electrobun-webkit-app-region-drag"
|
||||
class="w7-toolbar relative flex items-center gap-0.5 border-b border-border bg-chrome pr-1.5 electrobun-webkit-app-region-drag"
|
||||
[class.h-10]="isMacChrome"
|
||||
[class.pl-24]="isMacChrome"
|
||||
[class.pl-1.5]="!isMacChrome"
|
||||
@@ -180,6 +181,30 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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 (option of skins; track option) {
|
||||
<button
|
||||
(click)="settings.setSkin(option)"
|
||||
class="rounded px-2 py-0.5 text-[11px]"
|
||||
[class.bg-accent]="settings.skin() === option"
|
||||
[class.text-foreground]="settings.skin() === option"
|
||||
[class.text-muted-foreground]="settings.skin() !== option"
|
||||
>
|
||||
{{ i18n.t('options.skin.' + option) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- The CSS half of the skin flips live; the window frame does not
|
||||
(titleBarStyle is fixed at construction — see chromeOptions). -->
|
||||
@if (settings.skin() !== bootSkin) {
|
||||
<p class="mt-1 text-right text-[10px] text-muted-foreground">
|
||||
{{ i18n.t('options.skin.restartHint') }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<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>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@@ -436,6 +461,10 @@ export class TabBar {
|
||||
readonly modLabel = MOD_LABEL;
|
||||
readonly appName = BRANDING.appName;
|
||||
readonly buildLabel = BRANDING.buildLabel;
|
||||
readonly skins = SKINS;
|
||||
/** What the window was BUILT with, not the current setting — the frameless
|
||||
* chrome only changes on reopen, so the hint compares against this. */
|
||||
readonly bootSkin: (typeof SKINS)[number] = IS_WIN7_CHROME ? 'win7' : 'modern';
|
||||
readonly themes = THEMES;
|
||||
readonly darkTones = DARK_TONES;
|
||||
readonly accents = ACCENTS;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Component, inject, input, output, signal } from '@angular/core';
|
||||
import { IS_WIN7_CHROME } from '../platform';
|
||||
import { RpcService } from '../rpc.service';
|
||||
|
||||
/**
|
||||
* The win7 skin's Aero title bar. Rendered by all three window kinds, and only
|
||||
* when `IS_WIN7_CHROME` — under that skin the native frame is gone
|
||||
* (titleBarStyle 'hidden'), so this row IS the window's chrome.
|
||||
*
|
||||
* The markup is 7.css's contract, not ours: `.title-bar` > `.title-bar-text` +
|
||||
* `.title-bar-controls` > buttons keyed by `aria-label` (Minimize / Maximize /
|
||||
* Restore / Close each get their own glyph). Nothing here is styled with
|
||||
* Tailwind on purpose — the whole row comes from @layer skin.
|
||||
*
|
||||
* Dragging rides on electrobun's preload drag-region classes. Two things
|
||||
* electrobun does NOT provide and this component implements:
|
||||
* - double-click to maximize (it only wires mousedown/mouseup)
|
||||
* - the maximize/restore glyph state, which has no event; bun derives it
|
||||
* from the native resize and pushes `windowState` (see trackMaximizeState).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-title-bar',
|
||||
template: `
|
||||
@if (enabled) {
|
||||
<div class="title-bar electrobun-webkit-app-region-drag" (dblclick)="toggle()">
|
||||
<div class="title-bar-text">{{ title() }}</div>
|
||||
<div class="title-bar-controls electrobun-webkit-app-region-no-drag">
|
||||
@if (resizable()) {
|
||||
<button aria-label="Minimize" (click)="minimize()"></button>
|
||||
<button
|
||||
[attr.aria-label]="maximized() ? 'Restore' : 'Maximize'"
|
||||
(click)="toggle()"
|
||||
></button>
|
||||
}
|
||||
<button aria-label="Close" (click)="closeRequested.emit()"></button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class TitleBar {
|
||||
readonly title = input('');
|
||||
/** The manager window is a fixed-size modal — it gets Close only. */
|
||||
readonly resizable = input(true);
|
||||
/** Hosts decide what closing means (quit / dirty prompt / managerClose). */
|
||||
readonly closeRequested = output<void>();
|
||||
|
||||
protected readonly enabled = IS_WIN7_CHROME;
|
||||
protected readonly maximized = signal(false);
|
||||
|
||||
private readonly rpc = inject(RpcService);
|
||||
|
||||
constructor() {
|
||||
if (!this.enabled || !this.rpc.available) return;
|
||||
// No maximize event exists, so seed from a one-shot read and then follow
|
||||
// the resize-derived push (which also covers Aero Snap and Win+Up).
|
||||
void this.rpc.request.windowIsMaximized({}).then((res) => {
|
||||
if (res.ok) this.maximized.set(res.maximized);
|
||||
});
|
||||
this.rpc.onWindowState((maximized) => this.maximized.set(maximized));
|
||||
}
|
||||
|
||||
protected minimize(): void {
|
||||
void this.rpc.request.windowMinimize({});
|
||||
}
|
||||
|
||||
protected toggle(): void {
|
||||
void this.rpc.request.windowToggleMaximize({}).then((res) => {
|
||||
if (res.ok) this.maximized.set(res.maximized);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
<ng-icon name="mynaTrash" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-40 overflow-y-auto border-t border-border bg-chrome">
|
||||
<div class="has-scrollbar max-h-40 overflow-y-auto border-t border-border bg-chrome">
|
||||
@for (job of transfers.jobs(); track job.id) {
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 text-xs">
|
||||
<ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" />
|
||||
@@ -19,7 +19,16 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[11px] text-muted-foreground">{{ progressLabel(job) }}</div>
|
||||
@if (isActive(job)) {
|
||||
<div class="mt-0.5 h-1 overflow-hidden rounded-full bg-muted">
|
||||
<!-- role+aria are 7.css's hook for the Win7 progress bar
|
||||
([role=progressbar] > div); under the modern skin they are
|
||||
inert and the Tailwind classes below still draw the bar. -->
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
[attr.aria-valuenow]="jobPercent(job)"
|
||||
class="mt-0.5 h-1 overflow-hidden rounded-full bg-muted"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-[width] duration-100"
|
||||
[style.width.%]="jobPercent(job)"
|
||||
|
||||
Reference in New Issue
Block a user