Scale stage 1: everything the UI sizes with is now rem

Groundwork for the continuous interface scale (a root font-size lever): every
size the UI derives from must be root-relative, or it freezes while the rest
grows. Gate for this commit: at the default 16px root the app renders
pixel-identical to before — every conversion is exact (11px = 0.6875rem …).

- The 8 panel-chrome tokens in styles.css: px -> rem, and the data-text
  compact/large tiers are DELETED — a continuous scale replaces discrete
  tiers. Same for --w7-fs (12px -> 0.75rem) and the win7 --radius (5px ->
  0.3125rem, still landing rounded-md exactly on --w7-el-bdr's 3px).
  --radius-sm/md subtrahends: px -> rem, or corners would sharpen as the UI
  grew.
- All 93 text-[11px]/text-[10px] arbitraries -> rem equivalents, plus the two
  inline padding:24px empty-state cells in the 7like file panel.
- 7like.css gains a scale bridge in skinfix: 7.css is 370 px values with zero
  rem, and --w7-font reaches only 15 rules' TYPOGRAPHY — under a root lever
  win7 text would grow inside boxes frozen at 23px/14px/15px. The bridge
  re-expresses 7.css's size vars and its hardcoded box metrics (buttons via
  .w7-btn only, inputs, table rows/headers, title-bar controls, menu items,
  tabs, the 10pt check glyph) in rem at identical scale-1 pixels. 1px
  hairlines and focus rects stay px deliberately — crisp at any scale, and
  the tr border exists precisely to stop selection shifting rows.
- Two skinfix traps found while writing it, one by reasoning and one by
  pixels: (a) NO blanket `.win7 button` restatement — skinfix outranks the
  skin-layer neutralizer that strips 7.css's 75x23 metrics off the ~84 icon
  buttons, so a blanket rule would re-inflate them all; (b) restating a base
  rule in skinfix also outranks 7.css's own VARIANTS of it by layer — the
  title-bar rule silently shrank the close button from 48px to 29px until the
  harness pixel diff caught it, and the active tab's padding-bottom needed
  the same treatment. Both variants are restated alongside their base rules.
- TS px constants that mirror rem CSS are the latent-bug half of this work:
  both context menus' flip/clamp math converts through a new @core/metrics
  remPx() helper reading the LIVE root font-size (the shadlike menu's
  MENU_W=224 diverged from its w-56 box the moment scale != 1; the 7like
  menu's [style.width.px] pinned the box at 200px while text grew — it now
  binds width in rem, so math and box cannot disagree). The editors'
  cachedCharWidth is now keyed on the computed font string, self-invalidating
  under any font change, and both || px fallbacks derive from the element's
  font size instead of assuming a 16px root.

Verified: tsc + build clean; modern and win7 live captures and the harness
all diff to zero against pre-change baselines in their opaque regions (the
harness to exactly zero end to end; modern's only delta is the native GTK
titlebar focus state; win7's is the translucent Aero corner over the
desktop).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-16 18:02:41 +02:00
co-authored by Claude
parent c8ccc6b918
commit 9f2a076e08
21 changed files with 330 additions and 181 deletions
+16
View File
@@ -0,0 +1,16 @@
/**
* Rem-to-pixel conversion at the CURRENT root font size.
*
* The interface scale (settings.uiScale) works by setting the root font-size,
* so any TypeScript that positions or measures against rem-sized CSS must
* convert through the LIVE root — never through an assumed 16. Both context
* menus' flip/clamp math is the canonical consumer: their widths and row
* heights are rem in CSS, and hardcoding their px mirrors is exactly the bug
* that made the shadlike menu flip at the wrong edge once scaling landed.
*
* Read per call, not cached: the scale can change mid-session (stepper,
* Ctrl+/-) and a menu opened after that must measure against the new root.
*/
export function remPx(rem: number): number {
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
}
+126 -23
View File
@@ -74,12 +74,13 @@
:root.win7 { :root.win7 {
/* Real Segoe first (Windows), bundled substitute everywhere else. */ /* Real Segoe first (Windows), bundled substitute everywhere else. */
--w7-family: 'Segoe UI', 'SegoeUI', 'Noto Sans App', system-ui, sans-serif; --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. /* ~7.css's 9pt, in REM so it follows the interface scale (the root
Restating the `font` SHORTHAND (not just the family) is deliberate: font-size lever) like every other size in the app — 0.75rem = 12px at the
7.css applies `font: var(--w7-font)` on 12 component rules, and the 16px root. Restating the `font` SHORTHAND (not just the family) is
shorthand also resets weight and line-height — pinning 400/1.4 here stops deliberate: 7.css applies `font: var(--w7-font)` on 15 component rules,
those rules from flattening the app's `font-medium` / leading. */ and the shorthand also resets weight and line-height — pinning 400/1.4
--w7-fs: 12px; here stops those rules from flattening the app's `font-medium` / leading. */
--w7-fs: 0.75rem;
--w7-font: 400 var(--w7-fs) / 1.4 var(--w7-family); --w7-font: 400 var(--w7-fs) / 1.4 var(--w7-family);
/* Surface ramp, Win7 Explorer. This INVERTS the modern ramp's direction: /* Surface ramp, Win7 Explorer. This INVERTS the modern ramp's direction:
@@ -104,21 +105,15 @@
--destructive-foreground: #ffffff; --destructive-foreground: #ffffff;
--success: #06b025; /* the Win7 progress-bar green */ --success: #06b025; /* the Win7 progress-bar green */
/* 5px, NOT 3px: `rounded-md` is 74 of the 112 radius utilities in the /* 0.3125rem = 5px at the 16px root — NOT 3px: `rounded-md` is 74 of the 112
templates and compiles to calc(var(--radius) - 2px), so 5 lands it exactly radius utilities in the templates and compiles to
on --w7-el-bdr (3px). --radius itself feeds `rounded-lg` (12 uses) at 5px, calc(var(--radius) - 0.125rem), so 5 lands it exactly on --w7-el-bdr (3px).
near the 6px window radius. Bare `rounded` (26 uses) is a Tailwind literal --radius itself feeds `rounded-lg` (12 uses) at 5px, near the 6px window
0.25rem and is unaffected. */ radius. Bare `rounded` (26 uses) is a Tailwind literal 0.25rem. Rem so the
--radius: 5px; corners follow the interface scale with everything else. */
--radius: 0.3125rem;
} }
/* 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; }
/* Light values for every 7.css literal the skinfix rules below restate through /* Light values for every 7.css literal the skinfix rules below restate through
vars (7.css hardcodes these outside its var system). The values are 7.css's vars (7.css hardcodes these outside its var system). The values are 7.css's
own, verbatim — light must render exactly as stock. Dark overrides live in own, verbatim — light must render exactly as stock. Dark overrides live in
@@ -420,12 +415,120 @@ html.win7 body {
/* Above `skin`, for rules that must state a value rather than defer to a /* Above `skin`, for rules that must state a value rather than defer to a
neighbouring layer. */ neighbouring layer. */
@layer skinfix { @layer skinfix {
/* ---- Scale bridge: re-express 7.css's px metrics in rem ----
7.css sizes everything in px (370 of them, zero rem) and its --w7-font
shorthand reaches only 15 rules' TYPOGRAPHY — the boxes stay frozen. Under
the interface scale (root font-size lever) win7 text would grow inside
23px buttons, 14px table rows and a 15px-line-height caption. Every value
below is 7.css's own, converted at the 16px root (23px -> 1.4375rem …),
so scale 1.0 renders pixel-identical to stock and every other scale keeps
the boxes proportional to the text.
Deliberately NOT converted: 1px hairlines and focus rects (the tr border
that stops selection shifting rows, .is-cursor's 1px dotted outline, menu
bevels, the frame's inset glass ring) — those stay crisp at any scale. */
/* 7.css's own size vars — defined on .win7 in px; rem lands on the same
pixels at scale 1. Covers checkbox/radio/slider/tooltip/treeview/menu
gutters and both window metrics in one shot. */
.win7 {
--w7-cb-size: 0.875rem; /* 14px */
--w7-rd-size: 0.875rem;
--w7-rdd-size: 0.5rem; /* 8px */
--w7-rdl-space: 0.375rem; /* 6px */
--w7-sdt-w: 0.625rem; /* 10px */
--w7-sdt-h: 1.125rem; /* 18px */
--w7-blt-size: 1.125rem;
--w7-tvb-size: 0.5rem;
--w7-tv-left: 1.25rem; /* 20px */
--w7-clc-size: 0.3125rem; /* 5px */
--w7-lvc-size: 0.3125rem;
--w7-mn-left: 1.75rem; /* 28px */
--w7-el-bdr: 0.1875rem; /* 3px */
--w7-w-space: 0.375rem; /* 6px */
--w7-w-bdr: 0.375rem;
}
/* The hardcoded box metrics the vars do not reach, restated in rem; skinfix
outranks skin by layer, so these win over 7.css.
NO blanket `.win7 button` rule here, deliberately: the `skin`-layer
neutralizer strips 7.css's 75x23 metrics off every button (most are icon
squares sized by utilities), and a skinfix restatement would outrank that
neutralizer by LAYER and re-inflate all of them. The only buttons that
legitimately wear Win7 push-button metrics are .w7-btn (opted in below)
and the title-bar controls (which beat the neutralizer on specificity
inside `skin` and are restated here). */
.win7 input[type='email'],
.win7 input[type='number'],
.win7 input[type='password'],
.win7 input[type='search'],
.win7 input[type='text'],
.win7 input[type='url'] {
height: 1.4375rem;
}
.win7 input[type='email'],
.win7 input[type='number'],
.win7 input[type='password'],
.win7 input[type='search'],
.win7 input[type='text'],
.win7 input[type='url'],
.win7 textarea {
padding: 0.1875rem 0.25rem 0.3125rem; /* 3px 4px 5px */
}
.win7 table > thead > tr > * {
height: 1.375rem; /* 22px */
padding: 0 0.5rem; /* 0 8px */
}
.win7 table > tbody > tr > * {
height: 0.875rem; /* 14px */
padding: 0.125rem 0.5rem; /* 2px 8px */
}
.win7 .title-bar-text {
line-height: 0.9375rem; /* 15px */
}
.win7 .title-bar-controls button {
min-height: 1.1875rem; /* 19px */
min-width: 1.8125rem; /* 29px */
padding: 0;
}
/* BOTH of 7.css's wide-close selectors, not just .is-close: the app's title
bars key the buttons on aria-label, and the base rule above outranks the
`skin`-layer variant BY LAYER — restating only one arm shrank the close
button from 48px to 29px (caught by the harness pixel diff). */
.win7 .title-bar-controls button.is-close,
.win7 .title-bar-controls button[aria-label='Close'] {
min-width: 3rem; /* 48px */
}
.win7 ul[role='menu'] {
min-width: 9.375rem; /* 150px */
padding: 0.125rem; /* 2px */
}
.win7 ul[role='menu'] > [role='menuitem'] > a,
.win7 ul[role='menu'] > [role='menuitem'] > button,
.win7 ul[role='menu'] > [role='menuitem'] > label {
padding: 0.25rem 0.625rem 0.25rem 2rem; /* 4px 10px 4px 32px */
}
.win7 menu[role='tablist'] button {
padding: 0.125rem 0.375rem; /* 2px 6px */
}
/* The raised active tab: 7.css gives it padding-bottom 4px at (0,2,2) inside
`skin`, but the shorthand above outranks that BY LAYER and would flatten
the raise — so the variant is restated here too. */
.win7 menu[role='tablist'] button[aria-selected='true'] {
padding-bottom: 0.25rem; /* 4px */
}
/* The 10pt checkmark glyph in menu checkables — one of five pt sizes in
7.css that bypass --w7-font entirely. 10pt = 13.333px ≈ 0.8333rem. */
.win7 ul [role='menuitem'] > input[type='checkbox']:checked + label:after {
font-size: 0.8333rem;
}
/* Opt back IN to real Win7 push-button metrics, for the dialog and options /* Opt back IN to real Win7 push-button metrics, for the dialog and options
buttons where they are right. */ buttons where they are right. Same rem values as the button rule above —
this class exists to undo the `skin`-layer neutralizer, not to differ. */
.win7 .w7-btn { .win7 .w7-btn {
min-width: 75px; min-width: 4.6875rem;
min-height: 23px; min-height: 1.4375rem;
padding: 0 12px; padding: 0 0.75rem;
} }
/* ---- ListView (file panels) ---- /* ---- ListView (file panels) ----
@@ -17,7 +17,7 @@
(click)="importWinScp()" (click)="importWinScp()"
[disabled]="importing()" [disabled]="importing()"
[title]="i18n.t('connMgr.importWinscpHint')" [title]="i18n.t('connMgr.importWinscpHint')"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50"
> >
@if (importing()) { @if (importing()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" /> <ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
@@ -72,14 +72,14 @@
size="0.8rem" size="0.8rem"
/> />
</button> </button>
<span class="truncate text-[11px] font-medium text-muted-foreground uppercase"> <span class="truncate text-[0.6875rem] font-medium text-muted-foreground uppercase">
{{ group.stack.name }} {{ group.stack.name }}
</span> </span>
<div class="min-w-0 flex-1"></div> <div class="min-w-0 flex-1"></div>
<!-- count and actions swap on hover: they never share the space, <!-- count and actions swap on hover: they never share the space,
so a long stack name can't collide with the buttons --> so a long stack name can't collide with the buttons -->
<span <span
class="shrink-0 text-[10px] text-muted-foreground/70 tabular-nums group-hover:hidden" class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-hover:hidden"
> >
{{ group.connections.length }} {{ group.connections.length }}
</span> </span>
@@ -124,7 +124,7 @@
<span class="truncate">{{ connection.name }}</span> <span class="truncate">{{ connection.name }}</span>
</button> </button>
} @empty { } @empty {
<div class="py-1 pl-6 text-[11px] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div> <div class="py-1 pl-6 text-[0.6875rem] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div>
} }
} }
</div> </div>
@@ -142,7 +142,7 @@
[class.ring-primary]="dropTarget() === 'loose'" [class.ring-primary]="dropTarget() === 'loose'"
> >
@if (store.grouped().stacks.length && store.grouped().loose.length) { @if (store.grouped().stacks.length && store.grouped().loose.length) {
<div class="px-2 pt-2 pb-0.5 text-[11px] font-medium text-muted-foreground uppercase"> <div class="px-2 pt-2 pb-0.5 text-[0.6875rem] font-medium text-muted-foreground uppercase">
{{ i18n.t('connMgr.connectionsHeader') }} {{ i18n.t('connMgr.connectionsHeader') }}
</div> </div>
} }
@@ -163,10 +163,10 @@
</div> </div>
@if (!store.connections().length && !store.stacks().length) { @if (!store.connections().length && !store.stacks().length) {
<p class="px-3 py-2 text-[11px] text-muted-foreground"> <p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('connMgr.noneTitle') }} {{ i18n.t('connMgr.noneTitle') }}
</p> </p>
<p class="px-3 py-2 text-[11px] text-muted-foreground"> <p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('connMgr.noneBody') }} {{ i18n.t('connMgr.noneBody') }}
</p> </p>
} }
@@ -294,7 +294,7 @@
<button <button
type="button" type="button"
(click)="pickKeyFile()" (click)="pickKeyFile()"
class="h-7 shrink-0 rounded-md border border-border px-2 text-[11px] hover:bg-accent" class="h-7 shrink-0 rounded-md border border-border px-2 text-[0.6875rem] hover:bg-accent"
> >
{{ i18n.t('common.choose') }} {{ i18n.t('common.choose') }}
</button> </button>
@@ -321,7 +321,7 @@
spellcheck="false" spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/> />
<span class="text-[10px] text-muted-foreground/80"> <span class="text-[0.625rem] text-muted-foreground/80">
{{ i18n.t('connMgr.localDirHint') }} {{ i18n.t('connMgr.localDirHint') }}
</span> </span>
</label> </label>
+23 -10
View File
@@ -1,12 +1,18 @@
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core'; import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
import { ContextMenuService, type MenuItem } from '@core/context-menu.service'; import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
import { FocusService } from '@core/focus.service'; import { FocusService } from '@core/focus.service';
import { remPx } from '@core/metrics';
/** Rough per-item metrics, used only to decide whether the menu must flip. */ /** Rough per-item metrics in REM, used to size the menu and decide whether it
const ITEM_H = 24; // 7.css: 4px padding x2 + ~15px line * must flip. Rem because the interface scale is a root font-size lever: the
const DIVIDER_H = 7; // .has-divider:after: 2px bevel + margins * old `MENU_W = 200` was pinned into the DOM via [style.width.px], so the box
const MENU_W = 200; * stayed 200px while the text inside grew with the scale and overflowed.
const EDGE_GAP = 6; * Values mirror the skinfix restatement of 7.css's menu metrics in 7like.css
* (item padding 0.25rem x2 + the ~0.9375rem line). */
const ITEM_H_REM = 1.5; // 24px @16
const DIVIDER_H_REM = 0.4375; // 7px @16: 2px bevel + 3px/2px margins
const MENU_W_REM = 12.5; // 200px @16
const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
/** One rendered <li>: separators collapse onto the item BEFORE them. */ /** One rendered <li>: separators collapse onto the item BEFORE them. */
interface MenuRow { interface MenuRow {
@@ -44,7 +50,7 @@ interface MenuRow {
(keydown)="onKeydown($event)" (keydown)="onKeydown($event)"
[style.left.px]="position().x" [style.left.px]="position().x"
[style.top.px]="position().y" [style.top.px]="position().y"
[style.width.px]="menuWidth" [style.width.rem]="menuWidthRem"
class="fixed z-50 outline-none" class="fixed z-50 outline-none"
> >
@for (row of rows(); track $index) { @for (row of rows(); track $index) {
@@ -80,7 +86,9 @@ export class ContextMenu {
private readonly focus = inject(FocusService); private readonly focus = inject(FocusService);
private readonly menuEl = viewChild<ElementRef<HTMLUListElement>>('menuEl'); private readonly menuEl = viewChild<ElementRef<HTMLUListElement>>('menuEl');
protected readonly menuWidth = MENU_W; /** Rendered width AND math input are the same rem value, so the flip point
* and the box can never disagree — the shadlike menu's old bug. */
protected readonly menuWidthRem = MENU_W_REM;
/** Model items → rendered rows: a separator becomes has-divider on its predecessor. */ /** Model items → rendered rows: a separator becomes has-divider on its predecessor. */
readonly rows = computed<MenuRow[]>(() => { readonly rows = computed<MenuRow[]>(() => {
@@ -96,15 +104,20 @@ export class ContextMenu {
return rows; return rows;
}); });
/** Click point, flipped/clamped so the menu never overflows the viewport. */ /** Click point, flipped/clamped so the menu never overflows the viewport.
* UI metrics convert through the live root font-size (remPx); the click
* point and window.inner* are both viewport CSS px already. */
readonly position = computed(() => { readonly position = computed(() => {
const req = this.menu.request(); const req = this.menu.request();
if (!req) return { x: 0, y: 0 }; if (!req) return { x: 0, y: 0 };
const rows = this.rows(); const rows = this.rows();
const menuW = remPx(MENU_W_REM);
const height = const height =
rows.length * ITEM_H + rows.filter((r) => r.hasDivider).length * DIVIDER_H + 6; rows.length * remPx(ITEM_H_REM) +
rows.filter((r) => r.hasDivider).length * remPx(DIVIDER_H_REM) +
remPx(0.25); // ul padding 0.125rem x2
const x = const x =
req.x + MENU_W > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - MENU_W) : req.x; req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : req.x;
const y = const y =
req.y + height > window.innerHeight - EDGE_GAP ? Math.max(EDGE_GAP, req.y - height) : req.y; req.y + height > window.innerHeight - EDGE_GAP ? Math.max(EDGE_GAP, req.y - height) : req.y;
return { x, y }; return { x, y };
@@ -30,7 +30,7 @@ import { FocusService } from '@core/focus.service';
</h2> </h2>
@if (request().total > 1) { @if (request().total > 1) {
<span <span
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground" class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[0.625rem] font-medium text-muted-foreground"
> >
{{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }} {{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }}
</span> </span>
@@ -49,11 +49,11 @@ import { FocusService } from '@core/focus.service';
@for (side of comparison(); track side.label) { @for (side of comparison(); track side.label) {
<div class="rounded-md border border-border p-2.5"> <div class="rounded-md border border-border p-2.5">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-[10px] font-medium tracking-wide text-muted-foreground uppercase"> <span class="text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
{{ i18n.t('dialogs.' + side.label) }} {{ i18n.t('dialogs.' + side.label) }}
</span> </span>
@if (side.newer) { @if (side.newer) {
<span class="rounded-full bg-success/15 px-1.5 text-[10px] font-medium text-success"> <span class="rounded-full bg-success/15 px-1.5 text-[0.625rem] font-medium text-success">
{{ i18n.t('dialogs.newer') }} {{ i18n.t('dialogs.newer') }}
</span> </span>
} }
@@ -68,7 +68,7 @@ import { FocusService } from '@core/focus.service';
{{ isDir() ? '—' : i18n.size(side.entry.size) }} {{ isDir() ? '—' : i18n.size(side.entry.size) }}
</span> </span>
</div> </div>
<div class="mt-1 font-mono text-[11px] tabular-nums text-muted-foreground"> <div class="mt-1 font-mono text-[0.6875rem] tabular-nums text-muted-foreground">
{{ i18n.date(side.entry.mtime) }} {{ i18n.date(side.entry.mtime) }}
</div> </div>
</div> </div>
@@ -22,12 +22,12 @@
<h1 class="min-w-0 truncate font-mono text-sm font-semibold"> <h1 class="min-w-0 truncate font-mono text-sm font-semibold">
{{ ctx.name }}{{ dirty() ? ' •' : '' }} {{ ctx.name }}{{ dirty() ? ' •' : '' }}
</h1> </h1>
<span class="shrink-0 text-[11px] text-muted-foreground">{{ ctx.sessionTitle }}</span> <span class="shrink-0 text-[0.6875rem] text-muted-foreground">{{ ctx.sessionTitle }}</span>
<div class="flex-1"></div> <div class="flex-1"></div>
<button <button
(click)="toggleWrap()" (click)="toggleWrap()"
[title]="i18n.t('editor.wrapHint', { alt: altLabel })" [title]="i18n.t('editor.wrapHint', { alt: altLabel })"
class="rounded-md px-2 py-1 text-[11px] electrobun-webkit-app-region-no-drag hover:bg-accent" class="rounded-md px-2 py-1 text-[0.6875rem] electrobun-webkit-app-region-no-drag hover:bg-accent"
[class.bg-accent]="wrap()" [class.bg-accent]="wrap()"
[class.text-foreground]="wrap()" [class.text-foreground]="wrap()"
[class.text-muted-foreground]="!wrap()" [class.text-muted-foreground]="!wrap()"
@@ -56,7 +56,7 @@
class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring" class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
/> />
<span <span
class="text-[11px] tabular-nums" class="text-[0.6875rem] tabular-nums"
[class.text-destructive]="findQuery() && !matches().length" [class.text-destructive]="findQuery() && !matches().length"
[class.text-muted-foreground]="!findQuery() || matches().length" [class.text-muted-foreground]="!findQuery() || matches().length"
> >
@@ -89,7 +89,7 @@
@if (sessionDead()) { @if (sessionDead()) {
<div <div
class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive" class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[0.6875rem] text-destructive"
> >
<ng-icon name="mynaXCircle" size="0.85rem" class="shrink-0" /> <ng-icon name="mynaXCircle" size="0.85rem" class="shrink-0" />
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }} {{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
@@ -139,9 +139,9 @@
</div> </div>
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2"> <footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2">
<span class="text-[11px] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span> <span class="text-[0.6875rem] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
<span class="text-[11px] text-muted-foreground">·</span> <span class="text-[0.6875rem] text-muted-foreground">·</span>
<span class="text-[11px] tabular-nums text-muted-foreground"> <span class="text-[0.6875rem] tabular-nums text-muted-foreground">
{{ i18n.t('editor.lines', { count: lineCount() }) }} {{ i18n.t('editor.lines', { count: lineCount() }) }}
</span> </span>
<div class="flex-1"></div> <div class="flex-1"></div>
@@ -101,6 +101,7 @@ export class EditorWindow {
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter'); private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox'); private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
private cachedCharWidth = 0; private cachedCharWidth = 0;
private cachedCharFont = '';
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService); private readonly toast = inject(ToastService);
private readonly document = inject(DOCUMENT); private readonly document = inject(DOCUMENT);
@@ -221,7 +222,11 @@ export class EditorWindow {
el.setSelectionRange(start, start + this.findQuery().length); el.setSelectionRange(start, start + this.findQuery().length);
const text = this.text(); const text = this.text();
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 20; // Fallback derives from the element's own font size, not a hardcoded 20 —
// the interface scale moves the root font-size, so any px literal here
// would be a 16px-root assumption that miscentres at every other scale.
const style = getComputedStyle(el);
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.625;
let line = 0; let line = 0;
let i = text.indexOf('\n'); let i = text.indexOf('\n');
while (i !== -1 && i < start) { while (i !== -1 && i < start) {
@@ -236,12 +241,17 @@ export class EditorWindow {
this.onScroll(); this.onScroll();
} }
/** Monospace advance width, measured once per window. */ /** Monospace advance width, cached per computed FONT rather than per window:
* the interface scale changes the root font-size live, and a cache measured
* once would keep positioning finds with the old advance width. Keying on
* the font string makes it self-invalidating under any font change. */
private charWidth(el: HTMLTextAreaElement): number { private charWidth(el: HTMLTextAreaElement): number {
if (!this.cachedCharWidth) { const font = getComputedStyle(el).font;
if (font !== this.cachedCharFont) {
const canvas = this.document.createElement('canvas').getContext('2d')!; const canvas = this.document.createElement('canvas').getContext('2d')!;
canvas.font = getComputedStyle(el).font; canvas.font = font;
this.cachedCharWidth = canvas.measureText('0').width || 7; this.cachedCharWidth = canvas.measureText('0').width || parseFloat(getComputedStyle(el).fontSize) * 0.6;
this.cachedCharFont = font;
} }
return this.cachedCharWidth; return this.cachedCharWidth;
} }
@@ -125,7 +125,7 @@
</button> </button>
} }
@for (group of connections.grouped().stacks; track group.stack.id) { @for (group of connections.grouped().stacks; track group.stack.id) {
<div class="px-3 pt-1.5 pb-0.5 text-[10px] font-medium tracking-wide text-muted-foreground uppercase"> <div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
{{ group.stack.name }} {{ group.stack.name }}
</div> </div>
@for (connection of group.connections; track connection.id) { @for (connection of group.connections; track connection.id) {
@@ -141,7 +141,7 @@
@if (connections.sshHosts().length) { @if (connections.sshHosts().length) {
<div class="my-1 border-t border-border"></div> <div class="my-1 border-t border-border"></div>
<div class="px-3 pt-1.5 pb-0.5 text-[10px] font-medium tracking-wide text-muted-foreground uppercase"> <div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
~/.ssh/config ~/.ssh/config
</div> </div>
@for (host of connections.sshHosts(); track host.name) { @for (host of connections.sshHosts(); track host.name) {
@@ -205,7 +205,7 @@
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1"> <div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span> <span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
<span class="shrink-0 text-[11px] text-muted-foreground"> <span class="shrink-0 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('panel.matches', { count: filterMatches() }) }} {{ i18n.t('panel.matches', { count: filterMatches() }) }}
</span> </span>
<button <button
@@ -325,14 +325,14 @@
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 24px"> <td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 1.5rem">
{{ i18n.t('panel.emptyDir') }} {{ i18n.t('panel.emptyDir') }}
</td> </td>
</tr> </tr>
} }
@if (panel().filter() && filterMatches() === 0) { @if (panel().filter() && filterMatches() === 0) {
<tr> <tr>
<td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 24px"> <td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 1.5rem">
{{ i18n.t('panel.noMatches') }} {{ i18n.t('panel.noMatches') }}
</td> </td>
</tr> </tr>
@@ -12,7 +12,7 @@ import { TransfersService } from '@core/transfers.service';
imports: [NgIcon], imports: [NgIcon],
template: ` template: `
<div <div
class="flex h-6 items-center justify-between gap-4 border-t border-border bg-chrome px-3 text-[11px] text-muted-foreground select-none" class="flex h-6 items-center justify-between gap-4 border-t border-border bg-chrome px-3 text-[0.6875rem] text-muted-foreground select-none"
> >
<!-- transfers summary; click toggles the detailed list --> <!-- transfers summary; click toggles the detailed list -->
<!-- w7-flat: this is a text toggle, not a push button. Without it 7.css <!-- w7-flat: this is a text toggle, not a push button. Without it 7.css
+20 -20
View File
@@ -108,7 +108,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
(click)="onUpdateClick()" (click)="onUpdateClick()"
[disabled]="updater.state() === 'downloading'" [disabled]="updater.state() === 'downloading'"
[title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })" [title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })"
class="relative mr-1 grid h-6 place-items-center overflow-hidden rounded-md px-2.5 text-[11px] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default" class="relative mr-1 grid h-6 place-items-center overflow-hidden rounded-md px-2.5 text-[0.6875rem] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default"
> >
@if (updater.state() === 'downloading') { @if (updater.state() === 'downloading') {
<span <span
@@ -136,7 +136,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<button <button
(click)="openManager()" (click)="openManager()"
[title]="i18n.t('tabBar.manageConnections')" [title]="i18n.t('tabBar.manageConnections')"
class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground" class="grid h-6 px-2 py-1 place-items-center rounded-md text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
> >
<!-- <ng-icon name="mynaCog" size="0.9rem" /> --> <!-- <ng-icon name="mynaCog" size="0.9rem" /> -->
{{ i18n.t('tabBar.connections') }} {{ i18n.t('tabBar.connections') }}
@@ -162,7 +162,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<span class="text-xs text-muted-foreground">{{ i18n.t('options.language') }}</span> <span class="text-xs text-muted-foreground">{{ i18n.t('options.language') }}</span>
<select <select
(change)="settings.setLocale($any($event.target).value)" (change)="settings.setLocale($any($event.target).value)"
class="h-7 rounded-md border border-input bg-background px-1.5 text-[11px] outline-none focus:ring-2 focus:ring-ring" class="h-7 rounded-md border border-input bg-background px-1.5 text-[0.6875rem] outline-none focus:ring-2 focus:ring-ring"
> >
<option value="system" [selected]="settings.locale() === 'system'"> <option value="system" [selected]="settings.locale() === 'system'">
{{ i18n.t('options.system') }} {{ i18n.t('options.system') }}
@@ -189,7 +189,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<button <button
(click)="settings.setSkin(s)" (click)="settings.setSkin(s)"
[title]="i18n.t('options.skin.restartHint')" [title]="i18n.t('options.skin.restartHint')"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.skin() === s" [class.bg-accent]="settings.skin() === s"
[class.text-foreground]="settings.skin() === s" [class.text-foreground]="settings.skin() === s"
[class.text-muted-foreground]="settings.skin() !== s" [class.text-muted-foreground]="settings.skin() !== s"
@@ -200,7 +200,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</div> </div>
</div> </div>
@if (skinPending()) { @if (skinPending()) {
<p class="mt-1 text-right text-[10px] text-muted-foreground"> <p class="mt-1 text-right text-[0.625rem] text-muted-foreground">
{{ i18n.t('options.skin.restartHint') }} {{ i18n.t('options.skin.restartHint') }}
</p> </p>
} }
@@ -211,7 +211,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@for (theme of themes; track theme) { @for (theme of themes; track theme) {
<button <button
(click)="settings.setTheme(theme)" (click)="settings.setTheme(theme)"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.theme() === theme" [class.bg-accent]="settings.theme() === theme"
[class.text-foreground]="settings.theme() === theme" [class.text-foreground]="settings.theme() === theme"
[class.text-muted-foreground]="settings.theme() !== theme" [class.text-muted-foreground]="settings.theme() !== theme"
@@ -229,7 +229,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@for (tone of darkTones; track tone) { @for (tone of darkTones; track tone) {
<button <button
(click)="settings.setDarkTone(tone)" (click)="settings.setDarkTone(tone)"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.darkTone() === tone" [class.bg-accent]="settings.darkTone() === tone"
[class.text-foreground]="settings.darkTone() === tone" [class.text-foreground]="settings.darkTone() === tone"
[class.text-muted-foreground]="settings.darkTone() !== tone" [class.text-muted-foreground]="settings.darkTone() !== tone"
@@ -265,7 +265,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@for (mode of iconColorModes; track mode) { @for (mode of iconColorModes; track mode) {
<button <button
(click)="settings.setIconColors(mode)" (click)="settings.setIconColors(mode)"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.iconColors() === mode" [class.bg-accent]="settings.iconColors() === mode"
[class.text-foreground]="settings.iconColors() === mode" [class.text-foreground]="settings.iconColors() === mode"
[class.text-muted-foreground]="settings.iconColors() !== mode" [class.text-muted-foreground]="settings.iconColors() !== mode"
@@ -282,7 +282,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@for (size of textSizes; track size) { @for (size of textSizes; track size) {
<button <button
(click)="settings.setTextSize(size)" (click)="settings.setTextSize(size)"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.textSize() === size" [class.bg-accent]="settings.textSize() === size"
[class.text-foreground]="settings.textSize() === size" [class.text-foreground]="settings.textSize() === size"
[class.text-muted-foreground]="settings.textSize() !== size" [class.text-muted-foreground]="settings.textSize() !== size"
@@ -299,7 +299,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@for (unit of speedUnits; track unit.value) { @for (unit of speedUnits; track unit.value) {
<button <button
(click)="settings.setSpeedUnit(unit.value)" (click)="settings.setSpeedUnit(unit.value)"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.speedUnit() === unit.value" [class.bg-accent]="settings.speedUnit() === unit.value"
[class.text-foreground]="settings.speedUnit() === unit.value" [class.text-foreground]="settings.speedUnit() === unit.value"
[class.text-muted-foreground]="settings.speedUnit() !== unit.value" [class.text-muted-foreground]="settings.speedUnit() !== unit.value"
@@ -316,7 +316,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@for (mode of transferModes; track mode) { @for (mode of transferModes; track mode) {
<button <button
(click)="settings.setTransferMode(mode)" (click)="settings.setTransferMode(mode)"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.transferMode() === mode" [class.bg-accent]="settings.transferMode() === mode"
[class.text-foreground]="settings.transferMode() === mode" [class.text-foreground]="settings.transferMode() === mode"
[class.text-muted-foreground]="settings.transferMode() !== mode" [class.text-muted-foreground]="settings.transferMode() !== mode"
@@ -334,7 +334,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@for (count of concurrencyChoices; track count) { @for (count of concurrencyChoices; track count) {
<button <button
(click)="settings.setTransferConcurrency(count)" (click)="settings.setTransferConcurrency(count)"
class="rounded px-2 py-0.5 text-[11px] tabular-nums" class="rounded px-2 py-0.5 text-[0.6875rem] tabular-nums"
[class.bg-accent]="settings.transferConcurrency() === count" [class.bg-accent]="settings.transferConcurrency() === count"
[class.text-foreground]="settings.transferConcurrency() === count" [class.text-foreground]="settings.transferConcurrency() === count"
[class.text-muted-foreground]="settings.transferConcurrency() !== count" [class.text-muted-foreground]="settings.transferConcurrency() !== count"
@@ -351,7 +351,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<div class="flex rounded-md border border-border p-0.5"> <div class="flex rounded-md border border-border p-0.5">
<button <button
(click)="settings.setEditor('builtin')" (click)="settings.setEditor('builtin')"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.editor() === 'builtin'" [class.bg-accent]="settings.editor() === 'builtin'"
[class.text-foreground]="settings.editor() === 'builtin'" [class.text-foreground]="settings.editor() === 'builtin'"
[class.text-muted-foreground]="settings.editor() !== 'builtin'" [class.text-muted-foreground]="settings.editor() !== 'builtin'"
@@ -360,7 +360,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</button> </button>
<button <button
(click)="settings.setEditor('custom')" (click)="settings.setEditor('custom')"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.editor() === 'custom'" [class.bg-accent]="settings.editor() === 'custom'"
[class.text-foreground]="settings.editor() === 'custom'" [class.text-foreground]="settings.editor() === 'custom'"
[class.text-muted-foreground]="settings.editor() !== 'custom'" [class.text-muted-foreground]="settings.editor() !== 'custom'"
@@ -378,7 +378,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<div class="flex shrink-0 items-center gap-1"> <div class="flex shrink-0 items-center gap-1">
<button <button
(click)="pickEditorApp()" (click)="pickEditorApp()"
class="rounded-md border border-border px-2 py-0.5 text-[11px] hover:bg-accent" class="rounded-md border border-border px-2 py-0.5 text-[0.6875rem] hover:bg-accent"
> >
{{ i18n.t('common.choose') }} {{ i18n.t('common.choose') }}
</button> </button>
@@ -400,7 +400,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<div class="flex rounded-md border border-border p-0.5"> <div class="flex rounded-md border border-border p-0.5">
<button <button
(click)="settings.setAutoClearCompleted(true)" (click)="settings.setAutoClearCompleted(true)"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.autoClearCompleted()" [class.bg-accent]="settings.autoClearCompleted()"
[class.text-foreground]="settings.autoClearCompleted()" [class.text-foreground]="settings.autoClearCompleted()"
[class.text-muted-foreground]="!settings.autoClearCompleted()" [class.text-muted-foreground]="!settings.autoClearCompleted()"
@@ -409,7 +409,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</button> </button>
<button <button
(click)="settings.setAutoClearCompleted(false)" (click)="settings.setAutoClearCompleted(false)"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="!settings.autoClearCompleted()" [class.bg-accent]="!settings.autoClearCompleted()"
[class.text-foreground]="!settings.autoClearCompleted()" [class.text-foreground]="!settings.autoClearCompleted()"
[class.text-muted-foreground]="settings.autoClearCompleted()" [class.text-muted-foreground]="settings.autoClearCompleted()"
@@ -424,11 +424,11 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<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">Auto-refresh</span> <span class="text-xs text-muted-foreground">Auto-refresh</span>
<div class="flex rounded-md border border-border p-0.5"> <div class="flex rounded-md border border-border p-0.5">
<button (click)="settings.setAutoRefresh(true)" class="rounded px-2 py-0.5 text-[11px]" <button (click)="settings.setAutoRefresh(true)" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.autoRefresh()" [class.bg-accent]="settings.autoRefresh()"
[class.text-foreground]="settings.autoRefresh()" [class.text-foreground]="settings.autoRefresh()"
[class.text-muted-foreground]="!settings.autoRefresh()">On</button> [class.text-muted-foreground]="!settings.autoRefresh()">On</button>
<button (click)="settings.setAutoRefresh(false)" class="rounded px-2 py-0.5 text-[11px]" <button (click)="settings.setAutoRefresh(false)" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="!settings.autoRefresh()" [class.bg-accent]="!settings.autoRefresh()"
[class.text-foreground]="!settings.autoRefresh()" [class.text-foreground]="!settings.autoRefresh()"
[class.text-muted-foreground]="settings.autoRefresh()">Off</button> [class.text-muted-foreground]="settings.autoRefresh()">Off</button>
@@ -439,7 +439,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<!-- about footer --> <!-- about footer -->
<button <button
(click)="showAbout()" (click)="showAbout()"
class="mt-3 w-full border-t border-border pt-2 text-center text-[11px] text-muted-foreground hover:text-foreground" class="mt-3 w-full border-t border-border pt-2 text-center text-[0.6875rem] text-muted-foreground hover:text-foreground"
> >
{{ appName }} · {{ buildLabel }} {{ appName }} · {{ buildLabel }}
</button> </button>
@@ -1,5 +1,5 @@
@if (transfers.expanded() && transfers.hasJobs()) { @if (transfers.expanded() && transfers.hasJobs()) {
<div class="flex items-center justify-between border-t border-border bg-chrome px-3 py-1 text-[11px] text-muted-foreground"> <div class="flex items-center justify-between border-t border-border bg-chrome px-3 py-1 text-[0.6875rem] text-muted-foreground">
<span> <span>
{{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }} {{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }}
</span> </span>
@@ -17,7 +17,7 @@
<ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" /> <ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" />
<span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span> <span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="truncate text-[11px] text-muted-foreground">{{ progressLabel(job) }}</div> <div class="truncate text-[0.6875rem] text-muted-foreground">{{ progressLabel(job) }}</div>
@if (isActive(job)) { @if (isActive(job)) {
<!-- role+aria are 7.css's hook for the Win7 progress bar <!-- role+aria are 7.css's hook for the Win7 progress bar
([role=progressbar] > div); under the modern skin they are ([role=progressbar] > div); under the modern skin they are
@@ -16,7 +16,7 @@
(click)="importWinScp()" (click)="importWinScp()"
[disabled]="importing()" [disabled]="importing()"
[title]="i18n.t('connMgr.importWinscpHint')" [title]="i18n.t('connMgr.importWinscpHint')"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50"
> >
@if (importing()) { @if (importing()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" /> <ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
@@ -71,14 +71,14 @@
size="0.8rem" size="0.8rem"
/> />
</button> </button>
<span class="truncate text-[11px] font-medium text-muted-foreground uppercase"> <span class="truncate text-[0.6875rem] font-medium text-muted-foreground uppercase">
{{ group.stack.name }} {{ group.stack.name }}
</span> </span>
<div class="min-w-0 flex-1"></div> <div class="min-w-0 flex-1"></div>
<!-- count and actions swap on hover: they never share the space, <!-- count and actions swap on hover: they never share the space,
so a long stack name can't collide with the buttons --> so a long stack name can't collide with the buttons -->
<span <span
class="shrink-0 text-[10px] text-muted-foreground/70 tabular-nums group-hover:hidden" class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-hover:hidden"
> >
{{ group.connections.length }} {{ group.connections.length }}
</span> </span>
@@ -123,7 +123,7 @@
<span class="truncate">{{ connection.name }}</span> <span class="truncate">{{ connection.name }}</span>
</button> </button>
} @empty { } @empty {
<div class="py-1 pl-6 text-[11px] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div> <div class="py-1 pl-6 text-[0.6875rem] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div>
} }
} }
</div> </div>
@@ -141,7 +141,7 @@
[class.ring-primary]="dropTarget() === 'loose'" [class.ring-primary]="dropTarget() === 'loose'"
> >
@if (store.grouped().stacks.length && store.grouped().loose.length) { @if (store.grouped().stacks.length && store.grouped().loose.length) {
<div class="px-2 pt-2 pb-0.5 text-[11px] font-medium text-muted-foreground uppercase"> <div class="px-2 pt-2 pb-0.5 text-[0.6875rem] font-medium text-muted-foreground uppercase">
{{ i18n.t('connMgr.connectionsHeader') }} {{ i18n.t('connMgr.connectionsHeader') }}
</div> </div>
} }
@@ -162,10 +162,10 @@
</div> </div>
@if (!store.connections().length && !store.stacks().length) { @if (!store.connections().length && !store.stacks().length) {
<p class="px-3 py-2 text-[11px] text-muted-foreground"> <p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('connMgr.noneTitle') }} {{ i18n.t('connMgr.noneTitle') }}
</p> </p>
<p class="px-3 py-2 text-[11px] text-muted-foreground"> <p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('connMgr.noneBody') }} {{ i18n.t('connMgr.noneBody') }}
</p> </p>
} }
@@ -293,7 +293,7 @@
<button <button
type="button" type="button"
(click)="pickKeyFile()" (click)="pickKeyFile()"
class="h-7 shrink-0 rounded-md border border-border px-2 text-[11px] hover:bg-accent" class="h-7 shrink-0 rounded-md border border-border px-2 text-[0.6875rem] hover:bg-accent"
> >
{{ i18n.t('common.choose') }} {{ i18n.t('common.choose') }}
</button> </button>
@@ -320,7 +320,7 @@
spellcheck="false" spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/> />
<span class="text-[10px] text-muted-foreground/80"> <span class="text-[0.625rem] text-muted-foreground/80">
{{ i18n.t('connMgr.localDirHint') }} {{ i18n.t('connMgr.localDirHint') }}
</span> </span>
</label> </label>
+19 -10
View File
@@ -10,12 +10,17 @@ import {
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { ContextMenuService, type MenuItem } from '@core/context-menu.service'; import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
import { FocusService } from '@core/focus.service'; import { FocusService } from '@core/focus.service';
import { remPx } from '@core/metrics';
/** Rough per-item metrics, used only to decide whether the menu must flip. */ /** Rough per-item metrics in REM, used only to decide whether the menu must
const ITEM_H = 26; * flip. Converted through remPx() at open time so the math tracks the same
const SEPARATOR_H = 7; * root font-size the CSS scales with — the old px constants (`MENU_W = 224`)
const MENU_W = 224; // w-56 * were 16px-root mirrors of rem classes, so the box grew with the interface
const EDGE_GAP = 6; * scale while the flip math still believed 224 and fired at the wrong edge. */
const ITEM_H_REM = 1.625; // px-3 py-1 row + --path-font line ≈ 26px @16
const SEPARATOR_H_REM = 0.4375; // my-1 + 1px border ≈ 7px @16
const MENU_W_REM = 14; // w-56
const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
/** /**
* The panel right-click menu. Replaces electrobun's native ContextMenu, which * The panel right-click menu. Replaces electrobun's native ContextMenu, which
@@ -81,16 +86,20 @@ export class ContextMenu {
readonly activeIndex = signal(-1); readonly activeIndex = signal(-1);
/** Click point, flipped/clamped so the menu never overflows the viewport. */ /** Click point, flipped/clamped so the menu never overflows the viewport.
* All UI metrics convert through the live root font-size; window.inner* is
* viewport CSS px, so both sides of every comparison share units. */
readonly position = computed(() => { readonly position = computed(() => {
const req = this.menu.request(); const req = this.menu.request();
if (!req) return { x: 0, y: 0 }; if (!req) return { x: 0, y: 0 };
const itemH = remPx(ITEM_H_REM);
const separatorH = remPx(SEPARATOR_H_REM);
const menuW = remPx(MENU_W_REM);
const height = const height =
req.items.reduce((sum, i) => sum + (i.type === 'separator' ? SEPARATOR_H : ITEM_H), 0) + 8; req.items.reduce((sum, i) => sum + (i.type === 'separator' ? separatorH : itemH), 0) +
remPx(0.5); // the menu's py-1 (0.25rem x2)
const x = const x =
req.x + MENU_W > window.innerWidth - EDGE_GAP req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : req.x;
? Math.max(EDGE_GAP, req.x - MENU_W)
: req.x;
const y = const y =
req.y + height > window.innerHeight - EDGE_GAP req.y + height > window.innerHeight - EDGE_GAP
? Math.max(EDGE_GAP, req.y - height) ? Math.max(EDGE_GAP, req.y - height)
@@ -30,7 +30,7 @@ import { FocusService } from '@core/focus.service';
</h2> </h2>
@if (request().total > 1) { @if (request().total > 1) {
<span <span
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground" class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[0.625rem] font-medium text-muted-foreground"
> >
{{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }} {{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }}
</span> </span>
@@ -49,11 +49,11 @@ import { FocusService } from '@core/focus.service';
@for (side of comparison(); track side.label) { @for (side of comparison(); track side.label) {
<div class="rounded-md border border-border p-2.5"> <div class="rounded-md border border-border p-2.5">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-[10px] font-medium tracking-wide text-muted-foreground uppercase"> <span class="text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
{{ i18n.t('dialogs.' + side.label) }} {{ i18n.t('dialogs.' + side.label) }}
</span> </span>
@if (side.newer) { @if (side.newer) {
<span class="rounded-full bg-success/15 px-1.5 text-[10px] font-medium text-success"> <span class="rounded-full bg-success/15 px-1.5 text-[0.625rem] font-medium text-success">
{{ i18n.t('dialogs.newer') }} {{ i18n.t('dialogs.newer') }}
</span> </span>
} }
@@ -68,7 +68,7 @@ import { FocusService } from '@core/focus.service';
{{ isDir() ? '—' : i18n.size(side.entry.size) }} {{ isDir() ? '—' : i18n.size(side.entry.size) }}
</span> </span>
</div> </div>
<div class="mt-1 font-mono text-[11px] tabular-nums text-muted-foreground"> <div class="mt-1 font-mono text-[0.6875rem] tabular-nums text-muted-foreground">
{{ i18n.date(side.entry.mtime) }} {{ i18n.date(side.entry.mtime) }}
</div> </div>
</div> </div>
@@ -24,12 +24,12 @@
<h1 class="min-w-0 truncate font-mono text-sm font-semibold"> <h1 class="min-w-0 truncate font-mono text-sm font-semibold">
{{ ctx.name }}{{ dirty() ? ' •' : '' }} {{ ctx.name }}{{ dirty() ? ' •' : '' }}
</h1> </h1>
<span class="shrink-0 text-[11px] text-muted-foreground">{{ ctx.sessionTitle }}</span> <span class="shrink-0 text-[0.6875rem] text-muted-foreground">{{ ctx.sessionTitle }}</span>
<div class="flex-1"></div> <div class="flex-1"></div>
<button <button
(click)="toggleWrap()" (click)="toggleWrap()"
[title]="i18n.t('editor.wrapHint', { alt: altLabel })" [title]="i18n.t('editor.wrapHint', { alt: altLabel })"
class="rounded-md px-2 py-1 text-[11px] electrobun-webkit-app-region-no-drag hover:bg-accent" class="rounded-md px-2 py-1 text-[0.6875rem] electrobun-webkit-app-region-no-drag hover:bg-accent"
[class.bg-accent]="wrap()" [class.bg-accent]="wrap()"
[class.text-foreground]="wrap()" [class.text-foreground]="wrap()"
[class.text-muted-foreground]="!wrap()" [class.text-muted-foreground]="!wrap()"
@@ -58,7 +58,7 @@
class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring" class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
/> />
<span <span
class="text-[11px] tabular-nums" class="text-[0.6875rem] tabular-nums"
[class.text-destructive]="findQuery() && !matches().length" [class.text-destructive]="findQuery() && !matches().length"
[class.text-muted-foreground]="!findQuery() || matches().length" [class.text-muted-foreground]="!findQuery() || matches().length"
> >
@@ -91,7 +91,7 @@
@if (sessionDead()) { @if (sessionDead()) {
<div <div
class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive" class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[0.6875rem] text-destructive"
> >
<ng-icon name="mynaXCircle" size="0.85rem" class="shrink-0" /> <ng-icon name="mynaXCircle" size="0.85rem" class="shrink-0" />
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }} {{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
@@ -141,9 +141,9 @@
</div> </div>
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2"> <footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2">
<span class="text-[11px] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span> <span class="text-[0.6875rem] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
<span class="text-[11px] text-muted-foreground">·</span> <span class="text-[0.6875rem] text-muted-foreground">·</span>
<span class="text-[11px] tabular-nums text-muted-foreground"> <span class="text-[0.6875rem] tabular-nums text-muted-foreground">
{{ i18n.t('editor.lines', { count: lineCount() }) }} {{ i18n.t('editor.lines', { count: lineCount() }) }}
</span> </span>
<div class="flex-1"></div> <div class="flex-1"></div>
@@ -94,6 +94,7 @@ export class EditorWindow {
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter'); private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox'); private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
private cachedCharWidth = 0; private cachedCharWidth = 0;
private cachedCharFont = '';
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService); private readonly toast = inject(ToastService);
private readonly document = inject(DOCUMENT); private readonly document = inject(DOCUMENT);
@@ -214,7 +215,11 @@ export class EditorWindow {
el.setSelectionRange(start, start + this.findQuery().length); el.setSelectionRange(start, start + this.findQuery().length);
const text = this.text(); const text = this.text();
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 20; // Fallback derives from the element's own font size, not a hardcoded 20 —
// the interface scale moves the root font-size, so any px literal here
// would be a 16px-root assumption that miscentres at every other scale.
const style = getComputedStyle(el);
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.625;
let line = 0; let line = 0;
let i = text.indexOf('\n'); let i = text.indexOf('\n');
while (i !== -1 && i < start) { while (i !== -1 && i < start) {
@@ -229,12 +234,17 @@ export class EditorWindow {
this.onScroll(); this.onScroll();
} }
/** Monospace advance width, measured once per window. */ /** Monospace advance width, cached per computed FONT rather than per window:
* the interface scale changes the root font-size live, and a cache measured
* once would keep positioning finds with the old advance width. Keying on
* the font string makes it self-invalidating under any font change. */
private charWidth(el: HTMLTextAreaElement): number { private charWidth(el: HTMLTextAreaElement): number {
if (!this.cachedCharWidth) { const font = getComputedStyle(el).font;
if (font !== this.cachedCharFont) {
const canvas = this.document.createElement('canvas').getContext('2d')!; const canvas = this.document.createElement('canvas').getContext('2d')!;
canvas.font = getComputedStyle(el).font; canvas.font = font;
this.cachedCharWidth = canvas.measureText('0').width || 7; this.cachedCharWidth = canvas.measureText('0').width || parseFloat(getComputedStyle(el).fontSize) * 0.6;
this.cachedCharFont = font;
} }
return this.cachedCharWidth; return this.cachedCharWidth;
} }
@@ -121,7 +121,7 @@
</button> </button>
} }
@for (group of connections.grouped().stacks; track group.stack.id) { @for (group of connections.grouped().stacks; track group.stack.id) {
<div class="px-3 pt-1.5 pb-0.5 text-[10px] font-medium tracking-wide text-muted-foreground uppercase"> <div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
{{ group.stack.name }} {{ group.stack.name }}
</div> </div>
@for (connection of group.connections; track connection.id) { @for (connection of group.connections; track connection.id) {
@@ -137,7 +137,7 @@
@if (connections.sshHosts().length) { @if (connections.sshHosts().length) {
<div class="my-1 border-t border-border"></div> <div class="my-1 border-t border-border"></div>
<div class="px-3 pt-1.5 pb-0.5 text-[10px] font-medium tracking-wide text-muted-foreground uppercase"> <div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
~/.ssh/config ~/.ssh/config
</div> </div>
@for (host of connections.sshHosts(); track host.name) { @for (host of connections.sshHosts(); track host.name) {
@@ -195,7 +195,7 @@
<div <div
(contextmenu)="headerContextMenu($event)" (contextmenu)="headerContextMenu($event)"
[style.grid-template-columns]="gridTemplate()" [style.grid-template-columns]="gridTemplate()"
class="grid border-b border-border text-[11px] font-medium text-muted-foreground" class="grid border-b border-border text-[0.6875rem] font-medium text-muted-foreground"
> >
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')"> <button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')">
{{ i18n.t('columns.name') }} {{ sortIndicator('name') }} {{ i18n.t('columns.name') }} {{ sortIndicator('name') }}
@@ -217,7 +217,7 @@
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1"> <div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span> <span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
<span class="shrink-0 text-[11px] text-muted-foreground"> <span class="shrink-0 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('panel.matches', { count: filterMatches() }) }} {{ i18n.t('panel.matches', { count: filterMatches() }) }}
</span> </span>
<button <button
@@ -12,7 +12,7 @@ import { TransfersService } from '@core/transfers.service';
imports: [NgIcon], imports: [NgIcon],
template: ` template: `
<div <div
class="flex h-6 items-center justify-between gap-4 border-t border-border bg-chrome px-3 text-[11px] text-muted-foreground select-none" class="flex h-6 items-center justify-between gap-4 border-t border-border bg-chrome px-3 text-[0.6875rem] text-muted-foreground select-none"
> >
<!-- transfers summary; click toggles the detailed list --> <!-- transfers summary; click toggles the detailed list -->
<button <button
+20 -20
View File
@@ -114,7 +114,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
(click)="onUpdateClick()" (click)="onUpdateClick()"
[disabled]="updater.state() === 'downloading'" [disabled]="updater.state() === 'downloading'"
[title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })" [title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })"
class="relative mr-1 grid h-6 place-items-center overflow-hidden rounded-md px-2.5 text-[11px] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default" class="relative mr-1 grid h-6 place-items-center overflow-hidden rounded-md px-2.5 text-[0.6875rem] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default"
> >
@if (updater.state() === 'downloading') { @if (updater.state() === 'downloading') {
<span <span
@@ -142,7 +142,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<button <button
(click)="openManager()" (click)="openManager()"
[title]="i18n.t('tabBar.manageConnections')" [title]="i18n.t('tabBar.manageConnections')"
class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground" class="grid h-6 px-2 py-1 place-items-center rounded-md text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
> >
<!-- <ng-icon name="mynaCog" size="0.9rem" /> --> <!-- <ng-icon name="mynaCog" size="0.9rem" /> -->
{{ i18n.t('tabBar.connections') }} {{ i18n.t('tabBar.connections') }}
@@ -168,7 +168,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<span class="text-xs text-muted-foreground">{{ i18n.t('options.language') }}</span> <span class="text-xs text-muted-foreground">{{ i18n.t('options.language') }}</span>
<select <select
(change)="settings.setLocale($any($event.target).value)" (change)="settings.setLocale($any($event.target).value)"
class="h-7 rounded-md border border-input bg-background px-1.5 text-[11px] outline-none focus:ring-2 focus:ring-ring" class="h-7 rounded-md border border-input bg-background px-1.5 text-[0.6875rem] outline-none focus:ring-2 focus:ring-ring"
> >
<option value="system" [selected]="settings.locale() === 'system'"> <option value="system" [selected]="settings.locale() === 'system'">
{{ i18n.t('options.system') }} {{ i18n.t('options.system') }}
@@ -195,7 +195,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<button <button
(click)="settings.setSkin(s)" (click)="settings.setSkin(s)"
[title]="i18n.t('options.skin.restartHint')" [title]="i18n.t('options.skin.restartHint')"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.skin() === s" [class.bg-accent]="settings.skin() === s"
[class.text-foreground]="settings.skin() === s" [class.text-foreground]="settings.skin() === s"
[class.text-muted-foreground]="settings.skin() !== s" [class.text-muted-foreground]="settings.skin() !== s"
@@ -206,7 +206,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</div> </div>
</div> </div>
@if (skinPending()) { @if (skinPending()) {
<p class="mt-1 text-right text-[10px] text-muted-foreground"> <p class="mt-1 text-right text-[0.625rem] text-muted-foreground">
{{ i18n.t('options.skin.restartHint') }} {{ i18n.t('options.skin.restartHint') }}
</p> </p>
} }
@@ -217,7 +217,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@for (theme of themes; track theme) { @for (theme of themes; track theme) {
<button <button
(click)="settings.setTheme(theme)" (click)="settings.setTheme(theme)"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.theme() === theme" [class.bg-accent]="settings.theme() === theme"
[class.text-foreground]="settings.theme() === theme" [class.text-foreground]="settings.theme() === theme"
[class.text-muted-foreground]="settings.theme() !== theme" [class.text-muted-foreground]="settings.theme() !== theme"
@@ -235,7 +235,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@for (tone of darkTones; track tone) { @for (tone of darkTones; track tone) {
<button <button
(click)="settings.setDarkTone(tone)" (click)="settings.setDarkTone(tone)"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.darkTone() === tone" [class.bg-accent]="settings.darkTone() === tone"
[class.text-foreground]="settings.darkTone() === tone" [class.text-foreground]="settings.darkTone() === tone"
[class.text-muted-foreground]="settings.darkTone() !== tone" [class.text-muted-foreground]="settings.darkTone() !== tone"
@@ -271,7 +271,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@for (mode of iconColorModes; track mode) { @for (mode of iconColorModes; track mode) {
<button <button
(click)="settings.setIconColors(mode)" (click)="settings.setIconColors(mode)"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.iconColors() === mode" [class.bg-accent]="settings.iconColors() === mode"
[class.text-foreground]="settings.iconColors() === mode" [class.text-foreground]="settings.iconColors() === mode"
[class.text-muted-foreground]="settings.iconColors() !== mode" [class.text-muted-foreground]="settings.iconColors() !== mode"
@@ -288,7 +288,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@for (size of textSizes; track size) { @for (size of textSizes; track size) {
<button <button
(click)="settings.setTextSize(size)" (click)="settings.setTextSize(size)"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.textSize() === size" [class.bg-accent]="settings.textSize() === size"
[class.text-foreground]="settings.textSize() === size" [class.text-foreground]="settings.textSize() === size"
[class.text-muted-foreground]="settings.textSize() !== size" [class.text-muted-foreground]="settings.textSize() !== size"
@@ -305,7 +305,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@for (unit of speedUnits; track unit.value) { @for (unit of speedUnits; track unit.value) {
<button <button
(click)="settings.setSpeedUnit(unit.value)" (click)="settings.setSpeedUnit(unit.value)"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.speedUnit() === unit.value" [class.bg-accent]="settings.speedUnit() === unit.value"
[class.text-foreground]="settings.speedUnit() === unit.value" [class.text-foreground]="settings.speedUnit() === unit.value"
[class.text-muted-foreground]="settings.speedUnit() !== unit.value" [class.text-muted-foreground]="settings.speedUnit() !== unit.value"
@@ -322,7 +322,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@for (mode of transferModes; track mode) { @for (mode of transferModes; track mode) {
<button <button
(click)="settings.setTransferMode(mode)" (click)="settings.setTransferMode(mode)"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.transferMode() === mode" [class.bg-accent]="settings.transferMode() === mode"
[class.text-foreground]="settings.transferMode() === mode" [class.text-foreground]="settings.transferMode() === mode"
[class.text-muted-foreground]="settings.transferMode() !== mode" [class.text-muted-foreground]="settings.transferMode() !== mode"
@@ -340,7 +340,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@for (count of concurrencyChoices; track count) { @for (count of concurrencyChoices; track count) {
<button <button
(click)="settings.setTransferConcurrency(count)" (click)="settings.setTransferConcurrency(count)"
class="rounded px-2 py-0.5 text-[11px] tabular-nums" class="rounded px-2 py-0.5 text-[0.6875rem] tabular-nums"
[class.bg-accent]="settings.transferConcurrency() === count" [class.bg-accent]="settings.transferConcurrency() === count"
[class.text-foreground]="settings.transferConcurrency() === count" [class.text-foreground]="settings.transferConcurrency() === count"
[class.text-muted-foreground]="settings.transferConcurrency() !== count" [class.text-muted-foreground]="settings.transferConcurrency() !== count"
@@ -357,7 +357,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<div class="flex rounded-md border border-border p-0.5"> <div class="flex rounded-md border border-border p-0.5">
<button <button
(click)="settings.setEditor('builtin')" (click)="settings.setEditor('builtin')"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.editor() === 'builtin'" [class.bg-accent]="settings.editor() === 'builtin'"
[class.text-foreground]="settings.editor() === 'builtin'" [class.text-foreground]="settings.editor() === 'builtin'"
[class.text-muted-foreground]="settings.editor() !== 'builtin'" [class.text-muted-foreground]="settings.editor() !== 'builtin'"
@@ -366,7 +366,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</button> </button>
<button <button
(click)="settings.setEditor('custom')" (click)="settings.setEditor('custom')"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.editor() === 'custom'" [class.bg-accent]="settings.editor() === 'custom'"
[class.text-foreground]="settings.editor() === 'custom'" [class.text-foreground]="settings.editor() === 'custom'"
[class.text-muted-foreground]="settings.editor() !== 'custom'" [class.text-muted-foreground]="settings.editor() !== 'custom'"
@@ -384,7 +384,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<div class="flex shrink-0 items-center gap-1"> <div class="flex shrink-0 items-center gap-1">
<button <button
(click)="pickEditorApp()" (click)="pickEditorApp()"
class="rounded-md border border-border px-2 py-0.5 text-[11px] hover:bg-accent" class="rounded-md border border-border px-2 py-0.5 text-[0.6875rem] hover:bg-accent"
> >
{{ i18n.t('common.choose') }} {{ i18n.t('common.choose') }}
</button> </button>
@@ -406,7 +406,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<div class="flex rounded-md border border-border p-0.5"> <div class="flex rounded-md border border-border p-0.5">
<button <button
(click)="settings.setAutoClearCompleted(true)" (click)="settings.setAutoClearCompleted(true)"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.autoClearCompleted()" [class.bg-accent]="settings.autoClearCompleted()"
[class.text-foreground]="settings.autoClearCompleted()" [class.text-foreground]="settings.autoClearCompleted()"
[class.text-muted-foreground]="!settings.autoClearCompleted()" [class.text-muted-foreground]="!settings.autoClearCompleted()"
@@ -415,7 +415,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</button> </button>
<button <button
(click)="settings.setAutoClearCompleted(false)" (click)="settings.setAutoClearCompleted(false)"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="!settings.autoClearCompleted()" [class.bg-accent]="!settings.autoClearCompleted()"
[class.text-foreground]="!settings.autoClearCompleted()" [class.text-foreground]="!settings.autoClearCompleted()"
[class.text-muted-foreground]="settings.autoClearCompleted()" [class.text-muted-foreground]="settings.autoClearCompleted()"
@@ -430,11 +430,11 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<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">Auto-refresh</span> <span class="text-xs text-muted-foreground">Auto-refresh</span>
<div class="flex rounded-md border border-border p-0.5"> <div class="flex rounded-md border border-border p-0.5">
<button (click)="settings.setAutoRefresh(true)" class="rounded px-2 py-0.5 text-[11px]" <button (click)="settings.setAutoRefresh(true)" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.autoRefresh()" [class.bg-accent]="settings.autoRefresh()"
[class.text-foreground]="settings.autoRefresh()" [class.text-foreground]="settings.autoRefresh()"
[class.text-muted-foreground]="!settings.autoRefresh()">On</button> [class.text-muted-foreground]="!settings.autoRefresh()">On</button>
<button (click)="settings.setAutoRefresh(false)" class="rounded px-2 py-0.5 text-[11px]" <button (click)="settings.setAutoRefresh(false)" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="!settings.autoRefresh()" [class.bg-accent]="!settings.autoRefresh()"
[class.text-foreground]="!settings.autoRefresh()" [class.text-foreground]="!settings.autoRefresh()"
[class.text-muted-foreground]="settings.autoRefresh()">Off</button> [class.text-muted-foreground]="settings.autoRefresh()">Off</button>
@@ -445,7 +445,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<!-- about footer --> <!-- about footer -->
<button <button
(click)="showAbout()" (click)="showAbout()"
class="mt-3 w-full border-t border-border pt-2 text-center text-[11px] text-muted-foreground hover:text-foreground" class="mt-3 w-full border-t border-border pt-2 text-center text-[0.6875rem] text-muted-foreground hover:text-foreground"
> >
{{ appName }} · {{ buildLabel }} {{ appName }} · {{ buildLabel }}
</button> </button>
@@ -1,5 +1,5 @@
@if (transfers.expanded() && transfers.hasJobs()) { @if (transfers.expanded() && transfers.hasJobs()) {
<div class="flex items-center justify-between border-t border-border bg-chrome px-3 py-1 text-[11px] text-muted-foreground"> <div class="flex items-center justify-between border-t border-border bg-chrome px-3 py-1 text-[0.6875rem] text-muted-foreground">
<span> <span>
{{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }} {{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }}
</span> </span>
@@ -17,7 +17,7 @@
<ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" /> <ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" />
<span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span> <span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="truncate text-[11px] text-muted-foreground">{{ progressLabel(job) }}</div> <div class="truncate text-[0.6875rem] text-muted-foreground">{{ progressLabel(job) }}</div>
@if (isActive(job)) { @if (isActive(job)) {
<div class="mt-0.5 h-1 overflow-hidden rounded-full bg-muted"> <div class="mt-0.5 h-1 overflow-hidden rounded-full bg-muted">
<div <div
+21 -33
View File
@@ -181,45 +181,33 @@
--color-icon-media: var(--icon-media); --color-icon-media: var(--icon-media);
--color-icon-archive: var(--icon-archive); --color-icon-archive: var(--icon-archive);
--color-icon-code: var(--icon-code); --color-icon-code: var(--icon-code);
--radius-sm: calc(var(--radius) - 4px); /* rem subtrahends (4px/2px at the 16px root), NOT px: --radius is rem, and a
--radius-md: calc(var(--radius) - 2px); mixed-unit calc would stop the derived radii from following the interface
scale — corners would sharpen as the UI grew. */
--radius-sm: calc(var(--radius) - 0.25rem);
--radius-md: calc(var(--radius) - 0.125rem);
--radius-lg: var(--radius); --radius-lg: var(--radius);
} }
/* Interface-size setting (data-text set by SettingsService). Scales the whole /* Panel-chrome metrics: file list, path bar, action buttons, source picker.
panel chrome: file list, path bar, action buttons, and the source picker ALL REM, deliberately — the interface scale (settings.uiScale) works by
with its dropdown. The tab bar (including the options button) stays fixed. setting the root font-size, so every rem value in the app scales from that
Default-tier values match the old hardcoded classes exactly. */ one lever. A px value here would freeze while everything around it grew,
which is exactly what happened to the win7 boxes before stage 1c. There is
only one tier now: the old data-text compact/large tiers are gone, replaced
by the continuous scale. Values are the old default tier verbatim
(13px -> 0.8125rem etc. at the 16px root), so scale 1.0 is pixel-identical
to the pre-scale app. */
:root { :root {
--list-font: 13px; --list-font: 0.8125rem;
--list-meta-font: 12px; --list-meta-font: 0.75rem;
--path-font: 12px; --path-font: 0.75rem;
--row-h: 28px; --row-h: 1.75rem;
--path-ctl-h: 28px; --path-ctl-h: 1.75rem;
--action-h: 24px; --action-h: 1.5rem;
--action-font: 11px; --action-font: 0.6875rem;
--action-icon: 0.9rem; --action-icon: 0.9rem;
} }
:root[data-text='compact'] {
--list-font: 12px;
--list-meta-font: 11px;
--path-font: 11px;
--row-h: 24px;
--path-ctl-h: 24px;
--action-h: 22px;
--action-font: 10px;
--action-icon: 0.85rem;
}
:root[data-text='large'] {
--list-font: 14px;
--list-meta-font: 13px;
--path-font: 13px;
--row-h: 32px;
--path-ctl-h: 32px;
--action-h: 28px;
--action-font: 12px;
--action-icon: 1.05rem;
}
/* Soft entrance for the panel busy states (connecting placeholder, load overlay). */ /* Soft entrance for the panel busy states (connecting placeholder, load overlay). */
@keyframes panel-fade-in { @keyframes panel-fade-in {