diff --git a/ui/src/app/core/metrics.ts b/ui/src/app/core/metrics.ts new file mode 100644 index 0000000..62028f6 --- /dev/null +++ b/ui/src/app/core/metrics.ts @@ -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); +} diff --git a/ui/src/app/skins/7like/7like.css b/ui/src/app/skins/7like/7like.css index 19cba8a..5ceb8d3 100644 --- a/ui/src/app/skins/7like/7like.css +++ b/ui/src/app/skins/7like/7like.css @@ -74,12 +74,13 @@ :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; + /* ~7.css's 9pt, in REM so it follows the interface scale (the root + font-size lever) like every other size in the app — 0.75rem = 12px at the + 16px root. Restating the `font` SHORTHAND (not just the family) is + deliberate: 7.css applies `font: var(--w7-font)` on 15 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: 0.75rem; --w7-font: 400 var(--w7-fs) / 1.4 var(--w7-family); /* Surface ramp, Win7 Explorer. This INVERTS the modern ramp's direction: @@ -104,21 +105,15 @@ --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; + /* 0.3125rem = 5px at the 16px root — NOT 3px: `rounded-md` is 74 of the 112 + radius utilities in the templates and compiles to + calc(var(--radius) - 0.125rem), 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. Rem so the + 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 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 @@ -420,12 +415,120 @@ html.win7 body { /* Above `skin`, for rules that must state a value rather than defer to a neighbouring layer. */ @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 - 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 { - min-width: 75px; - min-height: 23px; - padding: 0 12px; + min-width: 4.6875rem; + min-height: 1.4375rem; + padding: 0 0.75rem; } /* ---- ListView (file panels) ---- diff --git a/ui/src/app/skins/7like/connection-manager/connection-manager.html b/ui/src/app/skins/7like/connection-manager/connection-manager.html index fac8b25..aed9f8c 100644 --- a/ui/src/app/skins/7like/connection-manager/connection-manager.html +++ b/ui/src/app/skins/7like/connection-manager/connection-manager.html @@ -17,7 +17,7 @@ (click)="importWinScp()" [disabled]="importing()" [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()) { @@ -72,14 +72,14 @@ size="0.8rem" /> - + {{ group.stack.name }}
{{ group.connections.length }} @@ -124,7 +124,7 @@ {{ connection.name }} } @empty { -
{{ i18n.t('connMgr.empty') }}
+
{{ i18n.t('connMgr.empty') }}
} } @@ -142,7 +142,7 @@ [class.ring-primary]="dropTarget() === 'loose'" > @if (store.grouped().stacks.length && store.grouped().loose.length) { -
+
{{ i18n.t('connMgr.connectionsHeader') }}
} @@ -163,10 +163,10 @@
@if (!store.connections().length && !store.stacks().length) { -

+

{{ i18n.t('connMgr.noneTitle') }}

-

+

{{ i18n.t('connMgr.noneBody') }}

} @@ -294,7 +294,7 @@ @@ -321,7 +321,7 @@ spellcheck="false" class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" /> - + {{ i18n.t('connMgr.localDirHint') }} diff --git a/ui/src/app/skins/7like/context-menu.ts b/ui/src/app/skins/7like/context-menu.ts index b7cfea9..0e51edb 100644 --- a/ui/src/app/skins/7like/context-menu.ts +++ b/ui/src/app/skins/7like/context-menu.ts @@ -1,12 +1,18 @@ import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core'; import { ContextMenuService, type MenuItem } from '@core/context-menu.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. */ -const ITEM_H = 24; // 7.css: 4px padding x2 + ~15px line -const DIVIDER_H = 7; // .has-divider:after: 2px bevel + margins -const MENU_W = 200; -const EDGE_GAP = 6; +/** Rough per-item metrics in REM, used to size the menu and decide whether it + * must flip. Rem because the interface scale is a root font-size lever: the + * old `MENU_W = 200` was pinned into the DOM via [style.width.px], so the box + * stayed 200px while the text inside grew with the scale and overflowed. + * 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
  • : separators collapse onto the item BEFORE them. */ interface MenuRow { @@ -44,7 +50,7 @@ interface MenuRow { (keydown)="onKeydown($event)" [style.left.px]="position().x" [style.top.px]="position().y" - [style.width.px]="menuWidth" + [style.width.rem]="menuWidthRem" class="fixed z-50 outline-none" > @for (row of rows(); track $index) { @@ -80,7 +86,9 @@ export class ContextMenu { private readonly focus = inject(FocusService); private readonly menuEl = viewChild>('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. */ readonly rows = computed(() => { @@ -96,15 +104,20 @@ export class ContextMenu { 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(() => { const req = this.menu.request(); if (!req) return { x: 0, y: 0 }; const rows = this.rows(); + const menuW = remPx(MENU_W_REM); 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 = - 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 = req.y + height > window.innerHeight - EDGE_GAP ? Math.max(EDGE_GAP, req.y - height) : req.y; return { x, y }; diff --git a/ui/src/app/skins/7like/dialogs/overwrite-dialog.ts b/ui/src/app/skins/7like/dialogs/overwrite-dialog.ts index a1e35a4..b42d236 100644 --- a/ui/src/app/skins/7like/dialogs/overwrite-dialog.ts +++ b/ui/src/app/skins/7like/dialogs/overwrite-dialog.ts @@ -30,7 +30,7 @@ import { FocusService } from '@core/focus.service'; @if (request().total > 1) { {{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }} @@ -49,11 +49,11 @@ import { FocusService } from '@core/focus.service'; @for (side of comparison(); track side.label) {
    - + {{ i18n.t('dialogs.' + side.label) }} @if (side.newer) { - + {{ i18n.t('dialogs.newer') }} } @@ -68,7 +68,7 @@ import { FocusService } from '@core/focus.service'; {{ isDir() ? '—' : i18n.size(side.entry.size) }}
    -
    +
    {{ i18n.date(side.entry.mtime) }}
    diff --git a/ui/src/app/skins/7like/editor-window/editor-window.html b/ui/src/app/skins/7like/editor-window/editor-window.html index fcc404a..2b774ba 100644 --- a/ui/src/app/skins/7like/editor-window/editor-window.html +++ b/ui/src/app/skins/7like/editor-window/editor-window.html @@ -22,12 +22,12 @@

    {{ ctx.name }}{{ dirty() ? ' •' : '' }}

    - {{ ctx.sessionTitle }} + {{ ctx.sessionTitle }}
    } @for (group of connections.grouped().stacks; track group.stack.id) { -
    +
    {{ group.stack.name }}
    @for (connection of group.connections; track connection.id) { @@ -141,7 +141,7 @@ @if (connections.sshHosts().length) {
    -
    +
    ~/.ssh/config
    @for (host of connections.sshHosts(); track host.name) { @@ -205,7 +205,7 @@
    {{ query }} - + {{ i18n.t('panel.matches', { count: filterMatches() }) }}
    @if (skinPending()) { -

    +

    {{ i18n.t('options.skin.restartHint') }}

    } @@ -217,7 +217,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; @for (theme of themes; track theme) { @@ -406,7 +406,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
    - @@ -445,7 +445,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; diff --git a/ui/src/app/skins/shadlike/transfer-bar/transfer-bar.html b/ui/src/app/skins/shadlike/transfer-bar/transfer-bar.html index afd26a6..bcb8851 100644 --- a/ui/src/app/skins/shadlike/transfer-bar/transfer-bar.html +++ b/ui/src/app/skins/shadlike/transfer-bar/transfer-bar.html @@ -1,5 +1,5 @@ @if (transfers.expanded() && transfers.hasJobs()) { -
    +
    {{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }} @@ -17,7 +17,7 @@ {{ job.name }}
    -
    {{ progressLabel(job) }}
    +
    {{ progressLabel(job) }}
    @if (isActive(job)) {
    0.8125rem etc. at the 16px root), so scale 1.0 is pixel-identical + to the pre-scale app. */ :root { - --list-font: 13px; - --list-meta-font: 12px; - --path-font: 12px; - --row-h: 28px; - --path-ctl-h: 28px; - --action-h: 24px; - --action-font: 11px; + --list-font: 0.8125rem; + --list-meta-font: 0.75rem; + --path-font: 0.75rem; + --row-h: 1.75rem; + --path-ctl-h: 1.75rem; + --action-h: 1.5rem; + --action-font: 0.6875rem; --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). */ @keyframes panel-fade-in {