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:
2026-08-14 15:02:27 +02:00
co-authored by Claude
parent fc03fa6216
commit 07825c1bd1
33 changed files with 828 additions and 48 deletions
+2
View File
@@ -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">
+14
View File
@@ -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()',
+2 -2
View File
@@ -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>
+3 -3
View File
@@ -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 }}
+3 -3
View File
@@ -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>
+2 -2
View File
@@ -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>
+4 -1
View File
@@ -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"
+2 -1
View File
@@ -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)',
+2 -2
View File
@@ -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
View File
@@ -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';
+10
View File
@@ -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;
+21
View File
@@ -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(),
+31 -2
View File
@@ -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;
+72
View File
@@ -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 -2
View File
@@ -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)"
Binary file not shown.
Binary file not shown.
+11
View File
@@ -20,6 +20,17 @@
html:not([data-theme='light']) { background: oklch(0.2178 0.002 107); }
html:not([data-theme='light'])[data-dark-tone='darker'] { background: oklch(0 0 0); }
}
/* win7 skin: same four states, mirroring the .win7 --background values
in styles.css. The preload adds this class before any page script, so
it is already on <html> here. Omitting this arm makes every win7
window flash white before styles.css parses. */
html.win7 { background: #ffffff; }
html.win7[data-theme='dark'] { background: #1f1f1f; }
html.win7[data-theme='dark'][data-dark-tone='darker'] { background: #121212; }
@media (prefers-color-scheme: dark) {
html.win7:not([data-theme='light']) { background: #1f1f1f; }
html.win7:not([data-theme='light'])[data-dark-tone='darker'] { background: #121212; }
}
</style>
</head>
<body>
+316
View File
@@ -1,4 +1,23 @@
/* Layer order — declared BEFORE the imports so it wins (a @layer statement is
one of the only two rule types allowed above @import). `skin` sorts after
`utilities`, so every 7.css rule outranks every Tailwind utility on the
elements it targets; that inversion is what lets the win7 skin restyle
~1900 inline utility classes without touching the templates. `skinfix` is
above both, and uses `revert-layer` to hand individual properties back to
`utilities` where 7.css's Win7-dialog metrics are wrong for this app.
Tailwind re-declares the first four names, a no-op once ordered here.
See the "Windows 7 skin" section at the bottom of this file. */
@layer theme, base, components, utilities, skin, skinfix;
@import "tailwindcss";
/* The SCOPED 7.css build: postcss-prefix-selector rewrote every selector to
`.win7` (root rules) or `.win7 <sel>` (the rest), so the whole framework is
inert until SettingsService.applySkin puts that class on <html>. All 61
icons are data URIs, so nothing needs copying into assets — and NOT the
`7.inline.css` build, which flattens away the --w7-* vars the dark palette
below depends on. The deep path is required: 7.css ships no `exports` map
and Tailwind's resolver only reads `style`/`mainFields`, so a bare
`@import "7.css"` finds no entry. */
@import "7.css/dist/7.scoped.css" layer(skin);
/* shadcn-style theme tokens. Light stays the zinc family; the dark neutrals are a
warm gray family (hue ~107) anchored on #1a1a19 — see the dark blocks below.
@@ -276,3 +295,300 @@ body {
user-select: none;
cursor: default;
}
/* ==========================================================================
Windows 7 skin — SettingsService.applySkin puts `.win7` on <html>
==========================================================================
Three tiers, and the split matters:
- `@layer skin` (the 7.css import at the top of this file) owns the
CONTROLS: buttons, inputs, tabs, progress bars, tree views, the Aero
title bar. It sits ABOVE `utilities`, so a bare `button` rule in 7.css
beats `.rounded-md` on that same button. That inversion is the whole
trick — it restyles ~1900 inline utility classes with no template edits.
- This unlayered section owns the BRIDGE: it re-points the app's own
semantic tokens at Win7 values, so utilities Tailwind still controls
(bg-card, border-border, text-muted-foreground) also come out
Win7-correct. It adds NO new tokens on purpose — a new colour token
would need a matching `--color-*` line in `@theme inline`.
- `@layer skinfix` (last, above `skin`) hands individual properties BACK to
the utilities layer with `revert-layer`, for the places where 7.css's
Win7-dialog metrics are wrong for this app.
WARNING for future edits: because `skin` outranks `utilities`, any Tailwind
utility added later to a <button>, <input>, <select>, <textarea> or <table>
silently loses under this skin. `skinfix` is the only place to fix that.
Specificity notes — the blocks below must beat the token blocks above:
:root.win7 (0,2,0) beats base :root (0,1,0)
:root.win7[data-theme='dark'] (0,3,0) beats :root[data-theme=…] (0,2,0)
and ties the darker-tone block (0,3,0),
winning on source order (it is last).
The accent formula blocks are deliberately NOT overridden: they only set
--primary/--ring/--cursor-tint, and letting them through is what makes the
accent picker recolour the Aero chrome — authentic, Win7 shipped exactly
that ("Window Color" personalization). */
/* Segoe UI is not on macOS/Linux and is not redistributable. Noto Sans is the
substitute 7.css itself names in its --w7-font fallback stack. Vendored
(OFL-1.1) from @fontsource/noto-sans@5.3.0, files/noto-sans-latin-{400,700}
-normal.woff2 -> ui/src/fonts/. Vendored rather than @imported from
node_modules because Tailwind inlines imported CSS, which would leave
@fontsource's relative url() rebased against the wrong directory. They live
under src/ (not public/) so esbuild emits them as hashed assets and rewrites
the url() — a public/ path is not resolvable at build time. */
@font-face {
font-family: 'Noto Sans App';
font-style: normal;
font-weight: 400;
font-display: block;
src: url('./fonts/noto-sans-latin-400-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Noto Sans App';
font-style: normal;
font-weight: 700;
font-display: block;
src: url('./fonts/noto-sans-latin-700-normal.woff2') format('woff2');
}
:root.win7 {
/* Real Segoe first (Windows), bundled substitute everywhere else. */
--w7-family: 'Segoe UI', 'SegoeUI', 'Noto Sans App', system-ui, sans-serif;
/* ~7.css's 9pt, but in px so it tracks the interface-size axis below.
Restating the `font` SHORTHAND (not just the family) is deliberate:
7.css applies `font: var(--w7-font)` on 12 component rules, and the
shorthand also resets weight and line-height — pinning 400/1.4 here stops
those rules from flattening the app's `font-medium` / leading. */
--w7-fs: 12px;
--w7-font: 400 var(--w7-fs) / 1.4 var(--w7-family);
/* Surface ramp, Win7 Explorer. This INVERTS the modern ramp's direction:
there --chrome (frame strips) sits below --background (content panes);
here the content pane is the brightest thing on screen and the frame is
the #f0f0f0 window surface. Deliberate — that is what Explorer was. */
--background: #ffffff;
--chrome: #f0f0f0;
--card: #f0f0f0;
--foreground: #000000;
--card-foreground: #000000;
--muted-foreground: #6d6d6d;
--secondary: #e8e8e8;
--secondary-foreground: #000000;
--muted: #e8e8e8;
/* Win7's hover is a pale blue wash, not a grey — matches --w7-el-grad-h */
--accent: #eaf6fd;
--accent-foreground: #000000;
--border: #d5d5d5;
--input: #abadb3; /* 7.css's own text-input border */
--destructive: #c62828;
--destructive-foreground: #ffffff;
--success: #06b025; /* the Win7 progress-bar green */
/* 5px, NOT 3px: `rounded-md` is 74 of the 112 radius utilities in the
templates and compiles to calc(var(--radius) - 2px), so 5 lands it exactly
on --w7-el-bdr (3px). --radius itself feeds `rounded-lg` (12 uses) at 5px,
near the 6px window radius. Bare `rounded` (26 uses) is a Tailwind literal
0.25rem and is unaffected. */
--radius: 5px;
}
/* Interface size: Win7 shipped this exact axis ("Smaller / Medium / Larger"),
so scaling is more authentic than disabling it. One indirection covers all
three tiers because var substitution is lazy. The --row-h / --list-font
block further up scales the file list independently and is untouched. */
:root.win7[data-text='compact'] { --w7-fs: 11px; }
:root.win7[data-text='large'] { --w7-fs: 13px; }
/* Hand-authored dark Win7. Microsoft never shipped one — this exists so the
theme and tone settings stay live under the skin. Only the colour-carrying
--w7-* vars are listed: --w7-el-grad and --w7-w-grad are DERIVED from the
values below and recolour for free, while --w7-el-grad-h/-a and
--w7-li-bg-hl are literal hex in 7.css and so must be restated. The ~25
geometry vars and ~20 base64 icon vars are theme-independent.
DUPLICATED for forced-dark and system-dark, per the rule the token blocks
above set: every dark value goes in BOTH. */
:root.win7[data-theme='dark'] {
--w7-surface: #2b2b2b;
--w7-el-bg: #3a3a3a;
--w7-el-bg-d: #333333;
--w7-el-bg-s-1: #333333;
--w7-el-bg-s-2: #262626;
--w7-el-bd: #565656;
--w7-el-bd-h: #4a8cbe;
--w7-el-bd-a: #3f6c8c;
--w7-el-bd-d: #3f3f3f;
--w7-el-c: #f0f0f0;
--w7-el-c-d: #6f6f6f;
--w7-el-sd: inset 0 0 0 1px #ffffff14;
--w7-el-grad-h: linear-gradient(#3c5a6b 45%, #315062 45%, #2b4557);
--w7-el-grad-a: linear-gradient(#2c4657, #24384a 30% 50%, #1d2f3f 50%, #16242f);
--w7-li-bd-hl: #3f6c8c;
--w7-li-bg-hl: linear-gradient(#ffffff14, #4a6b8433 90%, #ffffff1a);
--w7-w-bd: #000000d9;
--w7-w-bg: #1f3a52;
--w7-tab-bg: #2b2b2b;
--w7-title-c: #8ab4e8;
--w7-link-c: #6fb3ff;
--w7-link-c-h: #9acbff;
--background: #1f1f1f;
--chrome: #2b2b2b;
--card: #2b2b2b;
--foreground: #f0f0f0;
--card-foreground: #f0f0f0;
--muted-foreground: #a0a0a0;
--secondary: #383838;
--secondary-foreground: #f0f0f0;
--muted: #383838;
--accent: #2f4a5c;
--accent-foreground: #f0f0f0;
--border: #3c3c3c;
--input: #55585c;
}
@media (prefers-color-scheme: dark) {
:root.win7:not([data-theme='light']) {
--w7-surface: #2b2b2b;
--w7-el-bg: #3a3a3a;
--w7-el-bg-d: #333333;
--w7-el-bg-s-1: #333333;
--w7-el-bg-s-2: #262626;
--w7-el-bd: #565656;
--w7-el-bd-h: #4a8cbe;
--w7-el-bd-a: #3f6c8c;
--w7-el-bd-d: #3f3f3f;
--w7-el-c: #f0f0f0;
--w7-el-c-d: #6f6f6f;
--w7-el-sd: inset 0 0 0 1px #ffffff14;
--w7-el-grad-h: linear-gradient(#3c5a6b 45%, #315062 45%, #2b4557);
--w7-el-grad-a: linear-gradient(#2c4657, #24384a 30% 50%, #1d2f3f 50%, #16242f);
--w7-li-bd-hl: #3f6c8c;
--w7-li-bg-hl: linear-gradient(#ffffff14, #4a6b8433 90%, #ffffff1a);
--w7-w-bd: #000000d9;
--w7-w-bg: #1f3a52;
--w7-tab-bg: #2b2b2b;
--w7-title-c: #8ab4e8;
--w7-link-c: #6fb3ff;
--w7-link-c-h: #9acbff;
--background: #1f1f1f;
--chrome: #2b2b2b;
--card: #2b2b2b;
--foreground: #f0f0f0;
--card-foreground: #f0f0f0;
--muted-foreground: #a0a0a0;
--secondary: #383838;
--secondary-foreground: #f0f0f0;
--muted: #383838;
--accent: #2f4a5c;
--accent-foreground: #f0f0f0;
--border: #3c3c3c;
--input: #55585c;
}
}
/* Dark tone "darker": Aero has no meaningful true black, so rather than leave
the Tone row a dead control the skin just goes a step deeper. Duplicated
forced + system like everything else. */
:root.win7[data-theme='dark'][data-dark-tone='darker'] {
--w7-surface: #1a1a1a;
--w7-el-bg: #262626;
--w7-w-bg: #12202c;
--background: #121212;
--chrome: #1a1a1a;
--card: #1a1a1a;
}
@media (prefers-color-scheme: dark) {
:root.win7:not([data-theme='light'])[data-dark-tone='darker'] {
--w7-surface: #1a1a1a;
--w7-el-bg: #262626;
--w7-w-bg: #12202c;
--background: #121212;
--chrome: #1a1a1a;
--card: #1a1a1a;
}
}
/* Accent -> Aero window colour, so the existing accent picker keeps meaning
under the skin. --w7-w-bg is the one colour 7.css documents as settable. */
:root.win7[data-accent] {
--w7-w-bg: var(--primary);
}
/* Unlayered on purpose: the html/body rule above sets font-family unlayered,
so a layered override would lose. Family only — NOT font-size: the app's
rem-based utilities are root-relative, and shifting the root would move
every line box for no gain. */
.win7 body {
font-family: var(--w7-family);
}
/* Neutralizers live in `skin`, NOT `skinfix`, and this is load-bearing:
`revert-layer` rolls a property back to the previous LAYER, so from inside
`skin` it lands on `utilities` (the Tailwind classes already in the
templates) — which is the whole point. From `skinfix` it would land back on
7.css and do nothing. Within `skin` these beat the imported rules on equal
specificity by source order, since the import is at the top of this file. */
@layer skin {
/* 7.css sizes every <button> like a Win7 dialog push button
(min-width 75px, min-height 23px, padding 0 12px). Most of this app's 84
buttons are ~24px icon squares sized by utilities, so without this the tab
bar wraps and the options popover overflows. Hand the BOX MODEL back to
the utilities layer and keep 7.css's paint. The title bar's own controls
are matched by a MORE specific 7.css selector (.title-bar-controls button)
and so keep their Aero glyph metrics untouched. */
.win7 button {
min-width: 0;
min-height: 0;
padding: revert-layer;
text-align: revert-layer;
color: revert-layer;
}
/* 7.css hardcodes #fff / #abadb3 / #222 on form controls and tables, outside
its own var system. Reverting routes them back through the bridged tokens
above, which is what makes dark mode nearly free. */
.win7 input[type='text'],
.win7 input[type='password'],
.win7 input[type='number'],
.win7 input[type='search'],
.win7 textarea {
background-color: revert-layer;
border-color: revert-layer;
}
.win7 table {
background-color: revert-layer;
color: revert-layer;
}
/* Win7 toolbar buttons are FLAT with a hover glow, not Aero slabs —
Explorer's command bar worked exactly that way. `w7-toolbar` marks the
three header rows; a plain `.title-bar` sibling is deliberately NOT one of
them, so its controls keep the real Aero treatment. `w7-flat` is the
per-button escape hatch. Both classes are inert under the modern skin. */
.win7 .w7-toolbar button,
.win7 .w7-flat {
background: revert-layer;
border-color: transparent;
box-shadow: none;
}
.win7 .w7-toolbar button::before,
.win7 .w7-toolbar button::after,
.win7 .w7-flat::before,
.win7 .w7-flat::after {
content: none;
}
}
/* Above `skin`, for rules that must state a value rather than defer to a
neighbouring layer. */
@layer skinfix {
/* Opt back IN to real Win7 push-button metrics, for the dialog and options
buttons where they are right. Inert under the modern skin. */
.win7 .w7-btn {
min-width: 75px;
min-height: 23px;
padding: 0 12px;
}
}