Name every icon by the pixel grid it is drawn for

Groundwork for redrawing the icons on their pixel grids. A name is now
<icon><px> (server14, server16, server24) and must match the size token
where it renders: 14 at --icon-size-sm, 16 at -md, 24 at -xl.
tdesignLoading is the one exception, since it spins. Every name not yet
redrawn aliases its TDesign glyph, so nothing changes on screen except
the manager-open overlay icon, which moves from lg to xl ("a status
mark on a card").

- pixel-icons.ts holds the redrawn glyphs, so far add14 (the + from
  3f7145d, renamed from pixelAdd).
- TS code that picks icons takes Icon14 / Icon16 (menu items, file
  glyphs, the copy arrow, toasts, transfer states), so build:ui
  rejects a wrong name.
- icons.spec.ts checks every <ng-icon> in the skins: it has a size
  token, and each literal name, both sides of a [name] ternary
  included, is registered and drawn for that token's grid.
- TDesign's file1 is now fileText, since file1 plus a suffix would
  read file114.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-10-09 13:24:38 +02:00
co-authored by Claude
parent 3f7145d69a
commit 5d23809d48
21 changed files with 208 additions and 138 deletions
+10 -9
View File
@@ -1,6 +1,7 @@
import { Injectable, inject, signal } from '@angular/core';
import type { ColumnKey, EntryType } from '@shared/types';
import type { FileCommand } from './file-actions.service';
import type { Icon14 } from './icons';
import { I18nService } from './i18n.service';
import { stepIndex } from './menu-nav';
import type { Side } from './panels.service';
@@ -23,8 +24,8 @@ export interface MenuItem {
/** The keymap command the item runs: its first key shows as the soft hint,
* and pressing that key while the menu is open picks the item. */
shortcut?: ShortcutId;
/** A registered icon (core/icons.ts), drawn in the check column. */
icon?: string;
/** A 14px icon (core/icons.ts), drawn in the check column. */
icon?: Icon14;
/** Greyed and unclickable. Menus disable rather than hide, so the set of
* commands stays in the same place from one right-click to the next. */
disabled?: boolean;
@@ -123,7 +124,7 @@ export class ContextMenuService {
if (!ctx.hasEntry || ctx.name === '..') {
// empty space (or the parent row): folder-level actions
items.push(cmd('new-folder', t('ctx.newFolder'), { icon: 'tdesignFolderAdd' }));
items.push(cmd('new-folder', t('ctx.newFolder'), { icon: 'folderAdd14' }));
items.push(cmd('new-file', t('ctx.newFile')));
items.push({ type: 'separator' });
items.push(cmd('refresh', t('ctx.refresh')));
@@ -135,7 +136,7 @@ export class ContextMenuService {
}
// the arrow points at the other panel, as on the toolbar's Send button
const sendIcon = ctx.side === 'left' ? 'tdesignArrowRight' : 'tdesignArrowLeft';
const sendIcon = ctx.side === 'left' ? 'arrowRight14' : 'arrowLeft14';
items.push(cmd('send', t('ctx.copyToOther'), { icon: sendIcon }));
if (ctx.type === 'dir') {
items.push(cmd('open', t('ctx.open')));
@@ -143,9 +144,9 @@ export class ContextMenuService {
if (ctx.isLocal) {
items.push(cmd('open-default', t('ctx.open'), { tooltip: t('ctx.openDefault') }));
}
items.push(cmd('edit', t('ctx.edit'), { icon: 'tdesignFile1' }));
items.push(cmd('edit', t('ctx.edit'), { icon: 'fileText14' }));
}
items.push(cmd('rename', t('ctx.rename'), { icon: 'tdesignEdit' }));
items.push(cmd('rename', t('ctx.rename'), { icon: 'edit14' }));
// chmod on Windows local files only flips the read-only bit — hide the lie
if (!(ctx.isLocal && IS_WINDOWS)) {
items.push(cmd('chmod', t('ctx.permissions')));
@@ -159,7 +160,7 @@ export class ContextMenuService {
if (ctx.isLocal) {
items.push(cmd('trash', t(trashKey()), IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}));
}
items.push(cmd('delete', t('ctx.delete'), { icon: 'tdesignDelete' }));
items.push(cmd('delete', t('ctx.delete'), { icon: 'delete14' }));
return items;
}
@@ -180,13 +181,13 @@ export class ContextMenuService {
const t = (key: string) => this.i18n.t(key);
const last = ctx.tabCount <= 1;
return [
{ label: t('ctx.tabNew'), action: 'tab:new', shortcut: 'new-tab', icon: 'pixelAdd' },
{ label: t('ctx.tabNew'), action: 'tab:new', shortcut: 'new-tab', icon: 'add14' },
{ type: 'separator' },
{
label: t('ctx.tabClose'),
action: `tab:close:${ctx.id}`,
shortcut: 'close-tab',
icon: 'tdesignClose',
icon: 'close14',
disabled: last,
},
{
+13 -12
View File
@@ -3,6 +3,7 @@
// Only the icon glyph is colored; row text stays neutral. Docs and unknown
// files stay muted on purpose — sparing color is what keeps it scannable.
import type { FileEntry } from '@shared/types';
import type { Icon16 } from './icons';
type Category = 'image' | 'audio' | 'video' | 'archive' | 'code' | 'doc';
@@ -11,29 +12,29 @@ const MUTED = 'shrink-0 text-muted-foreground';
const FOLDER = 'shrink-0 text-icon-folder';
const LINK = 'shrink-0 text-icon-link';
const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: string[] }> = {
const CATEGORIES: Record<Category, { glyph: Icon16; class: string; extensions: string[] }> = {
image: {
glyph: 'tdesignImage',
glyph: 'image16',
class: 'shrink-0 text-icon-image',
extensions: 'png jpg jpeg gif webp avif svg ico icns bmp tiff tif heic heif raw'.split(' '),
},
audio: {
glyph: 'tdesignMusic',
glyph: 'music16',
class: 'shrink-0 text-icon-media',
extensions: 'mp3 wav flac aac ogg oga m4a opus wma aiff aif mid midi'.split(' '),
},
video: {
glyph: 'tdesignVideo',
glyph: 'video16',
class: 'shrink-0 text-icon-media',
extensions: 'mp4 mkv mov avi webm m4v mpg mpeg wmv flv 3gp'.split(' '),
},
archive: {
glyph: 'tdesignFileZip',
glyph: 'fileZip16',
class: 'shrink-0 text-icon-archive',
extensions: 'zip tar gz tgz bz2 xz zst 7z rar dmg iso jar deb rpm pkg apk'.split(' '),
},
code: {
glyph: 'tdesignCode',
glyph: 'code16',
class: 'shrink-0 text-icon-code',
// ".ts" is TypeScript here, not an MPEG transport stream (deliberate)
extensions: (
@@ -45,7 +46,7 @@ const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: s
).split(' '),
},
doc: {
glyph: 'tdesignFile1',
glyph: 'fileText16',
class: MUTED, // docs keep the neutral color; only the glyph changes
extensions: 'md markdown txt rtf pdf doc docx xls xlsx ppt pptx csv tsv log odt ods odp epub'.split(' '),
},
@@ -84,12 +85,12 @@ export function fileKindKey(entry: FileEntry): string {
return category ? KIND_KEYS[category] : 'file';
}
export function fileGlyph(entry: FileEntry): string {
if (entry.name === '..') return 'tdesignArrowUp';
if (entry.type === 'dir') return 'tdesignFolder';
if (entry.type === 'symlink') return 'tdesignLink';
export function fileGlyph(entry: FileEntry): Icon16 {
if (entry.name === '..') return 'arrowUp16';
if (entry.type === 'dir') return 'folder16';
if (entry.type === 'symlink') return 'link16';
const category = categoryOf(entry.name);
return category ? CATEGORIES[category].glyph : 'tdesignFile';
return category ? CATEGORIES[category].glyph : 'file16';
}
export function fileIconClass(entry: FileEntry): string {
+33
View File
@@ -0,0 +1,33 @@
import { expect, test } from 'bun:test';
import { APP_ICONS } from './icons';
// The px grid of each size token (styles.css); lg only carries the spinner.
const GRID: Record<string, string> = { sm: '14', md: '16', lg: '20', xl: '24' };
const SKINS = `${import.meta.dir}/../skins`;
// Templates can't type-check icon names, so read them: every <ng-icon> has a
// size token, and every literal name (both sides of a [name] ternary too) is
// registered and drawn for that token's grid. Names computed in TS are typed.
test('every template icon is registered and drawn for the size it renders at', async () => {
const problems: string[] = [];
for (const file of new Bun.Glob('**/*.{html,ts}').scanSync(SKINS)) {
const src = (await Bun.file(`${SKINS}/${file}`).text()).replace(/<!--[\s\S]*?-->/g, '');
for (const [tag] of src.matchAll(/<ng-icon\b[^>]*>/g)) {
const token = tag.match(/size="var\(--icon-size-(\w+)\)"/)?.[1];
if (!token || !GRID[token]) {
problems.push(`${file}: no size token in ${tag}`);
continue;
}
const bound = tag.match(/\[name\]="([^"]*)"/)?.[1] ?? '';
// a ternary's results, not its condition ('local' in `id === 'local' ? …`)
const names = [...tag.matchAll(/\bname="(\w+)"/g), ...bound.matchAll(/[?:]\s*'(\w+)'/g)].map((m) => m[1]);
for (const name of names) {
if (!Object.hasOwn(APP_ICONS, name)) problems.push(`${file}: ${name} is not registered`);
else if (name !== 'tdesignLoading' && !name.endsWith(GRID[token])) {
problems.push(`${file}: ${name} renders at ${token} (${GRID[token]}px)`);
}
}
}
}
expect(problems).toEqual([]);
});
+51 -41
View File
@@ -1,7 +1,8 @@
// Single icon registry for the whole app, registered once in app.config.ts.
// TDesign's package root exports every icon; named imports tree-shake. Names are
// plain strings in templates — the build never checks them, so every name used
// anywhere must be listed here (an unregistered one renders nothing).
// A name is <icon><px>: the pixel grid the glyph is drawn for, which must match
// the size token where it renders (pixel-icons.ts). Templates can't type-check
// names and an unregistered one renders nothing (a console warning only), so
// icons.spec.ts checks every template name; TS code takes the Icon14/Icon16 types.
import {
tdesignArrowLeft,
tdesignArrowRight,
@@ -35,45 +36,54 @@ import {
tdesignSetting,
tdesignVideo,
} from "@ng-icons/tdesign-icons";
// TDesign's + redrawn on the pixel grid of --icon-size-sm (14px). Its own glyph
// centres 24-grid strokes on the box's middle, a pixel boundary at 14px, so on a
// 1x screen both bars smear over two pixels at half ink. These bars are 1px wide
// on a half pixel (7.5): whole pixels at 1x and 2x, half a pixel off-centre.
const pixelAdd =
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14"><path stroke="currentColor" d="M7.5 3v9M3 7.5h9"/></svg>';
import { PIXEL_ICONS } from "./pixel-icons";
export const APP_ICONS = {
pixelAdd,
tdesignArrowLeft,
tdesignArrowRight,
tdesignArrowUp,
tdesignCheck,
tdesignCheckCircle,
tdesignChevronDown,
tdesignChevronRight,
tdesignChevronUp,
tdesignClose,
tdesignCloseCircle,
tdesignCode,
tdesignDelete,
tdesignDownload,
tdesignEdit,
tdesignFile,
tdesignFile1,
tdesignFileAdd,
tdesignFileZip,
tdesignFolder,
tdesignFolderAdd,
tdesignHardDrive,
tdesignImage,
tdesignLink,
...PIXEL_ICONS,
// TDesign's glyphs under their grid names, until each is redrawn in pixel-icons.ts
arrowLeft14: tdesignArrowLeft,
arrowRight14: tdesignArrowRight,
check14: tdesignCheck,
checkCircle14: tdesignCheckCircle,
chevronDown14: tdesignChevronDown,
chevronRight14: tdesignChevronRight,
chevronUp14: tdesignChevronUp,
close14: tdesignClose,
closeCircle14: tdesignCloseCircle,
delete14: tdesignDelete,
download14: tdesignDownload,
edit14: tdesignEdit,
file14: tdesignFile,
fileAdd14: tdesignFileAdd,
fileText14: tdesignFile1,
folder14: tdesignFolder,
folderAdd14: tdesignFolderAdd,
hardDrive14: tdesignHardDrive,
lockOn14: tdesignLockOn,
refresh14: tdesignRefresh,
search14: tdesignSearch,
server14: tdesignServer,
setting14: tdesignSetting,
arrowUp16: tdesignArrowUp,
code16: tdesignCode,
file16: tdesignFile,
fileText16: tdesignFile1,
fileZip16: tdesignFileZip,
folder16: tdesignFolder,
image16: tdesignImage,
link16: tdesignLink,
music16: tdesignMusic,
refresh16: tdesignRefresh,
server16: tdesignServer,
setting16: tdesignSetting,
video16: tdesignVideo,
server24: tdesignServer,
// TDesign's own 24px grid: as sharp as a circle and its diagonals can be
closeCircle24: tdesignCloseCircle,
// spins, so no pixel grid holds; any size
tdesignLoading,
tdesignLockOn,
tdesignMusic,
tdesignRefresh,
tdesignSearch,
tdesignServer,
tdesignSetting,
tdesignVideo,
};
export type IconName = keyof typeof APP_ICONS;
export type Icon14 = Extract<IconName, `${string}14`>;
export type Icon16 = Extract<IconName, `${string}16`>;
+16
View File
@@ -0,0 +1,16 @@
// Icons drawn on the pixel grid they render at. A TDesign glyph is a 24-unit
// SVG with 2-unit strokes on whole units, so at 14 or 16px every straight line
// lands between pixels and smears over two at half ink on a 1x screen. Each
// glyph here is drawn on its own grid (viewBox 0 0 N N for an N px icon, one
// unit = one CSS pixel at 100% scale) with 1px strokes on half pixels, so
// straight lines fill whole pixels at 1x and 2x. The name's suffix is that
// grid and must match the size token where it renders: 14 = --icon-size-sm,
// 16 = -md, 24 = -xl (icons.spec.ts checks). Drawing rules: docs/gotchas.md.
/** One glyph on an n-px grid: 1px square-capped strokes in the text colour. */
const svg = (n: number, body: string) =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n} ${n}" fill="none" stroke="currentColor" stroke-linecap="square">${body}</svg>`;
export const PIXEL_ICONS = {
add14: svg(14, '<path d="M7.5 3.5v8M3.5 7.5h8"/>'),
};
+2 -2
View File
@@ -2,7 +2,7 @@
@if (!rpc.available) {
<div class="flex flex-1 items-center justify-center">
<div class="max-w-sm border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="tdesignCloseCircle" size="var(--icon-size-xl)" class="text-destructive" />
<ng-icon name="closeCircle24" size="var(--icon-size-xl)" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ i18n.t('app.notConnectedBody') }}
@@ -28,7 +28,7 @@
(mousedown)="focusManager()"
>
<div class="border border-border bg-card px-5 py-4 text-center shadow-xl">
<ng-icon name="tdesignServer" size="var(--icon-size-lg)" class="text-muted-foreground" />
<ng-icon name="server24" size="var(--icon-size-xl)" class="text-muted-foreground" />
<p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p>
<button
(click)="focusManager()"
@@ -9,7 +9,7 @@
[class.h-9]="!isMacChrome"
[class.pl-3]="!isMacChrome"
>
<ng-icon name="tdesignServer" size="var(--icon-size-md)" class="shrink-0 text-muted-foreground" />
<ng-icon name="server16" size="var(--icon-size-md)" class="shrink-0 text-muted-foreground" />
<h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1>
<div class="flex-1"></div>
<button
@@ -36,14 +36,14 @@
[title]="shortcuts.tip(i18n.t('connMgr.newConnectionHint'), 'conn-new')"
class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="pixelAdd" size="var(--icon-size-sm)" /> {{ i18n.t('connMgr.newConnection') }}
<ng-icon name="add14" size="var(--icon-size-sm)" /> {{ i18n.t('connMgr.newConnection') }}
</button>
<button
(click)="newStack()"
[title]="shortcuts.tip(i18n.t('connMgr.newStackHint'), 'conn-new-stack')"
class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="tdesignFolderAdd" size="var(--icon-size-sm)" /> {{ i18n.t('connMgr.newStack') }}
<ng-icon name="folderAdd14" size="var(--icon-size-sm)" /> {{ i18n.t('connMgr.newStack') }}
</button>
</div>
@@ -68,7 +68,7 @@
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
>
<ng-icon
[name]="group.stack.collapsed ? 'tdesignChevronRight' : 'tdesignChevronDown'"
[name]="group.stack.collapsed ? 'chevronRight14' : 'chevronDown14'"
size="var(--icon-size-sm)"
/>
</button>
@@ -90,14 +90,14 @@
[title]="shortcuts.tip(i18n.t('connMgr.renameStack'), 'conn-rename-stack')"
class="grid size-4 place-items-center text-muted-foreground hover:text-foreground"
>
<ng-icon name="tdesignEdit" size="var(--icon-size-sm)" />
<ng-icon name="edit14" size="var(--icon-size-sm)" />
</button>
<button
(click)="deleteStack(group.stack)"
[title]="shortcuts.tip(i18n.t('connMgr.deleteStack'), 'conn-delete')"
class="grid size-4 place-items-center text-muted-foreground hover:text-destructive"
>
<ng-icon name="tdesignDelete" size="var(--icon-size-sm)" />
<ng-icon name="delete14" size="var(--icon-size-sm)" />
</button>
@if (group.connections.length) {
<button
@@ -105,7 +105,7 @@
[title]="shortcuts.tip(i18n.t('connMgr.openStack'), 'conn-connect')"
class="grid size-4 place-items-center text-muted-foreground hover:text-primary"
>
<ng-icon name="tdesignArrowRight" size="var(--icon-size-sm)" />
<ng-icon name="arrowRight14" size="var(--icon-size-sm)" />
</button>
}
</div>
@@ -123,7 +123,7 @@
[class.bg-accent]="draft()?.id === connection.id"
[class.opacity-50]="draggedId() === connection.id"
>
<ng-icon name="tdesignServer" size="var(--icon-size-sm)" class="shrink-0 text-muted-foreground" />
<ng-icon name="server14" size="var(--icon-size-sm)" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span>
</button>
} @empty {
@@ -161,7 +161,7 @@
[class.bg-accent]="draft()?.id === connection.id"
[class.opacity-50]="draggedId() === connection.id"
>
<ng-icon name="tdesignServer" size="var(--icon-size-sm)" class="shrink-0 text-muted-foreground" />
<ng-icon name="server14" size="var(--icon-size-sm)" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span>
</button>
}
@@ -342,7 +342,7 @@
[title]="shortcuts.tip(i18n.t('common.delete'), 'conn-delete')"
class="flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
>
<ng-icon name="tdesignDelete" size="var(--icon-size-sm)" /> {{ i18n.t('common.delete') }}
<ng-icon name="delete14" size="var(--icon-size-sm)" /> {{ i18n.t('common.delete') }}
</button>
<button
(click)="moreOpen.set(!moreOpen())"
@@ -374,7 +374,7 @@
} @else {
<div class="grid flex-1 place-items-center">
<div class="text-center">
<ng-icon name="tdesignServer" size="var(--icon-size-xl)" class="text-muted-foreground" />
<ng-icon name="server24" size="var(--icon-size-xl)" class="text-muted-foreground" />
<p class="mt-2 text-xs text-muted-foreground">
{{ i18n.t('connMgr.selectPrompt') }}
</p>
+1 -1
View File
@@ -77,7 +77,7 @@ const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
<!-- the check column: a checkmark, else the item's icon -->
<span class="grid w-4 shrink-0 place-items-center">
@if (item.checked) {
<ng-icon name="tdesignCheck" size="var(--icon-size-sm)" class="text-primary" />
<ng-icon name="check14" size="var(--icon-size-sm)" class="text-primary" />
} @else if (item.icon) {
<ng-icon
[name]="item.icon"
@@ -63,7 +63,7 @@ import { FocusService } from '@core/focus.service';
</div>
<div class="mt-1.5 flex items-center gap-1.5 text-xs">
<ng-icon
[name]="isDir() ? 'tdesignFolder' : 'tdesignFile'"
[name]="isDir() ? 'folder14' : 'file14'"
size="var(--icon-size-sm)"
class="shrink-0 text-muted-foreground"
/>
@@ -2,7 +2,7 @@
@if (!ctx) {
<div class="flex flex-1 items-center justify-center">
<div class="max-w-sm border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="tdesignCloseCircle" size="var(--icon-size-xl)" class="text-destructive" />
<ng-icon name="closeCircle24" size="var(--icon-size-xl)" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ i18n.t('editor.noFileBody') }}
@@ -20,7 +20,7 @@
[class.h-9]="!isMacChrome"
[class.pl-3]="!isMacChrome"
>
<ng-icon name="tdesignFile1" size="var(--icon-size-md)" class="shrink-0 text-muted-foreground" />
<ng-icon name="fileText16" size="var(--icon-size-md)" class="shrink-0 text-muted-foreground" />
<h1 class="min-w-0 truncate font-mono text-sm font-semibold">
{{ ctx.name }}{{ dirty() ? ' •' : '' }}
</h1>
@@ -41,13 +41,13 @@
[title]="shortcuts.tip(i18n.t('editor.findHint'), 'find')"
class="grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="tdesignSearch" size="var(--icon-size-sm)" />
<ng-icon name="search14" size="var(--icon-size-sm)" />
</button>
</header>
@if (findOpen()) {
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-toolbar px-3 py-1.5">
<ng-icon name="tdesignSearch" size="var(--icon-size-sm)" class="shrink-0 text-muted-foreground" />
<ng-icon name="search14" size="var(--icon-size-sm)" class="shrink-0 text-muted-foreground" />
<input
#findBox
[value]="findQuery()"
@@ -69,14 +69,14 @@
[title]="shortcuts.tipFixed(i18n.t('editor.prevMatch'), 'Shift+Enter')"
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="tdesignChevronUp" size="var(--icon-size-sm)" />
<ng-icon name="chevronUp14" size="var(--icon-size-sm)" />
</button>
<button
(click)="findStep(1)"
[title]="shortcuts.tipFixed(i18n.t('editor.nextMatch'), 'Enter')"
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="tdesignChevronDown" size="var(--icon-size-sm)" />
<ng-icon name="chevronDown14" size="var(--icon-size-sm)" />
</button>
<div class="flex-1"></div>
<button
@@ -84,7 +84,7 @@
[title]="i18n.t('editor.closeHint')"
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="tdesignClose" size="var(--icon-size-sm)" />
<ng-icon name="close14" size="var(--icon-size-sm)" />
</button>
</div>
}
@@ -93,7 +93,7 @@
<div
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="tdesignCloseCircle" size="var(--icon-size-sm)" class="shrink-0" />
<ng-icon name="closeCircle14" size="var(--icon-size-sm)" class="shrink-0" />
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
</div>
}
@@ -105,7 +105,7 @@
} @else if (loadError(); as error) {
<div class="flex flex-1 items-center justify-center">
<div class="max-w-md border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="tdesignCloseCircle" size="var(--icon-size-xl)" class="text-destructive" />
<ng-icon name="closeCircle24" size="var(--icon-size-xl)" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ error }}</p>
<button
@@ -13,14 +13,14 @@
<ng-icon name="tdesignLoading" class="shrink-0 animate-spin text-primary" size="var(--icon-size-sm)" />
} @else {
<ng-icon
[name]="isRemote() ? 'tdesignServer' : 'tdesignHardDrive'"
[name]="isRemote() ? 'server14' : 'hardDrive14'"
[class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
size="var(--icon-size-sm)"
/>
}
<span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span>
<ng-icon
name="tdesignChevronDown"
name="chevronDown14"
class="shrink-0 text-muted-foreground"
size="var(--icon-size-sm)"
/>
@@ -42,7 +42,7 @@
[title]="shortcuts.tip(i18n.t('panel.editFile'), 'edit')"
class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="tdesignFile1" size="var(--icon-size-sm)" />
<ng-icon name="fileText14" size="var(--icon-size-sm)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span>
</button>
<button
@@ -50,7 +50,7 @@
[title]="shortcuts.tip(i18n.t('panel.rename'), 'rename')"
class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="tdesignEdit" size="var(--icon-size-sm)" />
<ng-icon name="edit14" size="var(--icon-size-sm)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span>
</button>
<button
@@ -58,14 +58,14 @@
[title]="shortcuts.tip(i18n.t('panel.newFolder'), 'new-folder')"
class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="tdesignFolderAdd" size="var(--icon-size-sm)" />
<ng-icon name="folderAdd14" size="var(--icon-size-sm)" />
</button>
<button
(click)="run('new-file')"
[title]="shortcuts.tip(i18n.t('panel.newFile'), 'new-file')"
class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="tdesignFileAdd" size="var(--icon-size-sm)" />
<ng-icon name="fileAdd14" size="var(--icon-size-sm)" />
</button>
@if (chmodAvailable()) {
<button
@@ -73,7 +73,7 @@
[title]="shortcuts.tip(i18n.t('panel.permissions'), 'chmod')"
class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="tdesignLockOn" size="var(--icon-size-sm)" />
<ng-icon name="lockOn14" size="var(--icon-size-sm)" />
</button>
}
<button
@@ -81,7 +81,7 @@
[title]="shortcuts.tip(i18n.t('common.delete'), 'delete')"
class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-destructive"
>
<ng-icon name="tdesignDelete" size="var(--icon-size-sm)" />
<ng-icon name="delete14" size="var(--icon-size-sm)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span>
</button>
@@ -95,7 +95,7 @@
[title]="shortcuts.tip(i18n.t('panel.parentDir'), 'parent')"
class="grid size-(--path-ctl-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
>
<ng-icon name="tdesignArrowUp" size="var(--icon-size-md)" />
<ng-icon name="arrowUp16" size="var(--icon-size-md)" />
</button>
<!-- no blanket stopPropagation: the window handler lets only ⌘/Ctrl chords
through from a text field, and its WebView2 reload guard must see F5 -->
@@ -119,7 +119,7 @@
@if (panel().loading()) {
<ng-icon name="tdesignLoading" size="var(--icon-size-md)" class="animate-spin" />
} @else {
<ng-icon name="tdesignRefresh" size="var(--icon-size-md)" />
<ng-icon name="refresh16" size="var(--icon-size-md)" />
}
</button>
</div>
@@ -153,7 +153,7 @@
<!-- type-to-filter bar (appears while a query is active) -->
@if (panel().filter(); as query) {
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
<ng-icon name="tdesignSearch" size="var(--icon-size-sm)" class="shrink-0 text-muted-foreground" />
<ng-icon name="search14" size="var(--icon-size-sm)" class="shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
<span class="shrink-0 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('panel.matches', { count: filterMatches() }) }}
@@ -163,7 +163,7 @@
[title]="i18n.t('panel.clearFilter')"
class="grid size-5 shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="tdesignClose" size="var(--icon-size-sm)" />
<ng-icon name="close14" size="var(--icon-size-sm)" />
</button>
</div>
}
@@ -18,6 +18,7 @@ import { DialogsService } from '@core/dialogs.service';
import { FileActionsService, type FileCommand, type FileTarget } from '@core/file-actions.service';
import { fileGlyph, fileIconClass, fileKindKey } from '@core/file-icons';
import { formatMode } from '@core/format';
import type { Icon14, Icon16 } from '@core/icons';
import { remPx } from '@core/metrics';
import { PanelsService, type Side, type SortKey } from '@core/panels.service';
import { IS_WINDOWS } from '@core/platform';
@@ -91,7 +92,7 @@ export class FilePanel {
/** Input is ignored while the panel is connecting or a listing is in flight. */
readonly blocked = computed(() => this.panel().busy());
/** The copy button points toward the other panel. */
readonly copyIcon = computed(() => (this.side() === 'left' ? 'tdesignArrowRight' : 'tdesignArrowLeft'));
readonly copyIcon = computed<Icon14>(() => (this.side() === 'left' ? 'arrowRight14' : 'arrowLeft14'));
readonly pathDraft = signal('');
/** Windows drive roots for the source picker; always empty elsewhere. */
@@ -192,20 +193,20 @@ export class FilePanel {
{
label: this.i18n.t('common.local'),
action: 'local',
icon: 'tdesignHardDrive',
icon: 'hardDrive14',
checked: !this.isRemote(),
},
];
// Windows drive roots (empty list elsewhere)
for (const drive of this.drives()) {
items.push({ label: drive, action: `drive:${drive}`, icon: 'tdesignHardDrive' });
items.push({ label: drive, action: `drive:${drive}`, icon: 'hardDrive14' });
}
const { loose, stacks } = this.connections.grouped();
if (loose.length || stacks.length) items.push({ type: 'separator' });
const server = (connection: { id: string; name: string }): MenuItem => ({
label: connection.name,
action: `conn:${connection.id}`,
icon: 'tdesignServer',
icon: 'server14',
});
items.push(...loose.map(server));
for (const group of stacks) {
@@ -218,7 +219,7 @@ export class FilePanel {
items.push({
label: host.name,
action: `ssh:${host.name}`,
icon: 'tdesignServer',
icon: 'server14',
disabled: !!host.unsupported,
tooltip: this.sshHostTitle(host),
});
@@ -267,7 +268,7 @@ export class FilePanel {
return `${host.username}@${host.hostname}:${host.port}`;
}
iconFor(entry: FileEntry): string {
iconFor(entry: FileEntry): Icon16 {
return fileGlyph(entry);
}
@@ -9,7 +9,7 @@
[class.h-9]="!isMacChrome"
[class.pl-3]="!isMacChrome"
>
<ng-icon name="tdesignSetting" size="var(--icon-size-md)" class="shrink-0 text-muted-foreground" />
<ng-icon name="setting16" size="var(--icon-size-md)" class="shrink-0 text-muted-foreground" />
<h1 class="text-sm font-semibold">{{ i18n.t('options.title') }}</h1>
</header>
@@ -250,7 +250,7 @@
[title]="i18n.t('options.resetDefault')"
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="tdesignClose" size="var(--icon-size-sm)" />
<ng-icon name="close14" size="var(--icon-size-sm)" />
</button>
}
</div>
@@ -305,7 +305,7 @@
<div class="flex items-center gap-2">
<div class="relative min-w-0 flex-1">
<ng-icon
name="tdesignSearch"
name="search14"
size="var(--icon-size-sm)"
class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-muted-foreground"
/>
@@ -367,7 +367,7 @@
[title]="i18n.t('keys.ui.remove', { key: shortcuts.format(combo) })"
class="grid size-4 place-items-center text-muted-foreground hover:text-foreground"
>
<ng-icon name="tdesignClose" size="var(--icon-size-sm)" />
<ng-icon name="close14" size="var(--icon-size-sm)" />
</button>
</span>
}
@@ -387,7 +387,7 @@
class="grid size-5 shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
[class.text-primary]="capturing() === binding.id"
>
<ng-icon name="pixelAdd" size="var(--icon-size-sm)" />
<ng-icon name="add14" size="var(--icon-size-sm)" />
</button>
<button
(click)="resetRow(binding)"
@@ -396,7 +396,7 @@
[title]="i18n.t('keys.ui.reset')"
class="grid size-5 shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:invisible"
>
<ng-icon name="tdesignRefresh" size="var(--icon-size-sm)" />
<ng-icon name="refresh14" size="var(--icon-size-sm)" />
</button>
</div>
@if (note()?.id === binding.id) {
@@ -30,12 +30,12 @@ import { TransfersService } from '@core/transfers.service';
}
</span>
} @else if (transfers.failedJobs().length; as failed) {
<ng-icon name="tdesignCloseCircle" size="var(--icon-size-sm)" class="shrink-0 text-destructive" />
<ng-icon name="closeCircle14" size="var(--icon-size-sm)" class="shrink-0 text-destructive" />
<span class="text-destructive">
{{ i18n.t('transfer.failed', { count: failed }) }}
</span>
} @else if (transfers.hasJobs()) {
<ng-icon name="tdesignCheckCircle" size="var(--icon-size-sm)" class="shrink-0 text-success" />
<ng-icon name="checkCircle14" size="var(--icon-size-sm)" class="shrink-0 text-success" />
<span>{{ i18n.t('transfer.finished') }}</span>
} @else {
<span>{{ i18n.t('transfer.ready') }}</span>
@@ -45,7 +45,7 @@ import { TransfersService } from '@core/transfers.service';
<!-- active panel info -->
<div class="flex shrink-0 items-center gap-1.5">
<ng-icon
[name]="activePanel().sessionId() === 'local' ? 'tdesignHardDrive' : 'tdesignServer'"
[name]="activePanel().sessionId() === 'local' ? 'hardDrive14' : 'server14'"
size="var(--icon-size-sm)"
[class.text-primary]="activePanel().sessionId() !== 'local'"
/>
+7 -7
View File
@@ -52,7 +52,7 @@ import { UpdaterService } from '@core/updater.service';
[title]="panels.workspaceName(ws)"
>
<ng-icon
[name]="panels.workspaceIsRemote(ws) ? 'tdesignServer' : 'tdesignHardDrive'"
[name]="panels.workspaceIsRemote(ws) ? 'server14' : 'hardDrive14'"
size="var(--icon-size-sm)"
class="shrink-0"
[class.text-primary]="panels.workspaceIsRemote(ws)"
@@ -67,7 +67,7 @@ import { UpdaterService } from '@core/updater.service';
[title]="i18n.t('tabBar.closeTab')"
class="mr-1 grid size-4 shrink-0 place-items-center text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground"
>
<ng-icon name="tdesignClose" size="var(--icon-size-sm)" />
<ng-icon name="close14" size="var(--icon-size-sm)" />
</button>
} @else {
<span class="mr-1 size-4 shrink-0"></span>
@@ -83,7 +83,7 @@ import { UpdaterService } from '@core/updater.service';
[title]="shortcuts.tip(i18n.t('tabBar.newTab'), 'new-tab')"
class="ml-0.5 grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="pixelAdd" size="var(--icon-size-sm)" />
<ng-icon name="add14" size="var(--icon-size-sm)" />
</button>
<div class="flex-1"></div>
@@ -106,13 +106,13 @@ import { UpdaterService } from '@core/updater.service';
<span class="relative flex items-center gap-1.5">
@switch (updater.state()) {
@case ('available') {
<ng-icon name="tdesignDownload" size="var(--icon-size-sm)" />{{ i18n.t('tabBar.update') }}
<ng-icon name="download14" size="var(--icon-size-sm)" />{{ i18n.t('tabBar.update') }}
}
@case ('downloading') {
<ng-icon name="tdesignLoading" size="var(--icon-size-sm)" class="animate-spin" />{{ i18n.t('tabBar.updating') }} {{ updater.progress() }}%
}
@case ('ready') {
<ng-icon name="tdesignRefresh" size="var(--icon-size-sm)" />{{ i18n.t('tabBar.restartToUpdate') }}
<ng-icon name="refresh14" size="var(--icon-size-sm)" />{{ i18n.t('tabBar.restartToUpdate') }}
}
}
</span>
@@ -125,7 +125,7 @@ import { UpdaterService } from '@core/updater.service';
[title]="shortcuts.tip(i18n.t('tabBar.manageConnections'), 'connection-manager')"
class="grid h-6 px-2 py-1 place-items-center text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<!-- <ng-icon name="tdesignSetting" size="var(--icon-size-sm)" /> -->
<!-- <ng-icon name="setting14" size="var(--icon-size-sm)" /> -->
{{ i18n.t('tabBar.connections') }}
</button>
@@ -135,7 +135,7 @@ import { UpdaterService } from '@core/updater.service';
[title]="shortcuts.tip(i18n.t('tabBar.options'), 'open-settings')"
class="grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="tdesignSetting" size="var(--icon-size-sm)" />
<ng-icon name="setting14" size="var(--icon-size-sm)" />
</button>
</div>
`,
+6 -5
View File
@@ -1,6 +1,7 @@
import { Component, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import type { Icon14 } from '@core/icons';
import { ToastService } from '@core/toast.service';
/** Toast stack overlay; shared by the main window and editor windows. */
@@ -26,7 +27,7 @@ import { ToastService } from '@core/toast.service';
[attr.aria-label]="i18n.t('transfer.dismiss')"
class="shrink-0 text-muted-foreground hover:text-foreground"
>
<ng-icon name="tdesignClose" size="var(--icon-size-sm)" />
<ng-icon name="close14" size="var(--icon-size-sm)" />
</button>
<!-- auto-dismiss countdown; freezes with the timer while hovered -->
<div
@@ -45,9 +46,9 @@ export class Toasts {
readonly toast = inject(ToastService);
readonly i18n = inject(I18nService);
toastIcon(kind: 'error' | 'info' | 'success'): string {
if (kind === 'error') return 'tdesignCloseCircle';
if (kind === 'success') return 'tdesignCheckCircle';
return 'tdesignCheck';
toastIcon(kind: 'error' | 'info' | 'success'): Icon14 {
if (kind === 'error') return 'closeCircle14';
if (kind === 'success') return 'checkCircle14';
return 'check14';
}
}
@@ -8,7 +8,7 @@
[disabled]="!transfers.finishedJobs().length"
class="flex items-center gap-1 px-1.5 py-0.5 hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
<ng-icon name="tdesignDelete" size="var(--icon-size-sm)" /> {{ i18n.t('transfer.clearFinished') }}
<ng-icon name="delete14" size="var(--icon-size-sm)" /> {{ i18n.t('transfer.clearFinished') }}
</button>
</div>
<div class="max-h-40 overflow-y-auto border-t border-border bg-chrome">
@@ -40,7 +40,7 @@
[title]="i18n.t('transfer.cancel')"
class="shrink-0 p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
>
<ng-icon name="tdesignClose" size="var(--icon-size-sm)" />
<ng-icon name="close14" size="var(--icon-size-sm)" />
</button>
} @else {
<button
@@ -48,7 +48,7 @@
[title]="i18n.t('transfer.dismiss')"
class="shrink-0 p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="tdesignClose" size="var(--icon-size-sm)" />
<ng-icon name="close14" size="var(--icon-size-sm)" />
</button>
}
</div>
@@ -4,6 +4,7 @@ import type { TransferSnapshot } from '@shared/types';
import { cycleFocus } from '@core/focus-cycle';
import { formatEta } from '@core/format';
import { I18nService } from '@core/i18n.service';
import type { Icon14 } from '@core/icons';
import { PanelsService } from '@core/panels.service';
import { SettingsService } from '@core/settings.service';
import { TransfersService } from '@core/transfers.service';
@@ -56,13 +57,13 @@ export class TransferBar {
return parts.join(' · ');
}
stateIcon(job: TransferSnapshot): string {
stateIcon(job: TransferSnapshot): Icon14 | 'tdesignLoading' {
switch (job.state) {
case 'completed':
return 'tdesignCheckCircle';
return 'checkCircle14';
case 'failed':
case 'cancelled':
return 'tdesignCloseCircle';
return 'closeCircle14';
default:
return 'tdesignLoading';
}
+3 -1
View File
@@ -204,7 +204,9 @@
the text beside it, in whole pixels (small button: 12px text, 14px icon).
Every <ng-icon> takes one of these four: a one-off size like 0.9rem (14.4px)
puts every stroke between pixels, and one-offs drift until neighbours never
match. Rem like the rest, so the interface scale moves them too. */
match. Rem like the rest, so the interface scale moves them too.
sm, md and xl are also the pixel grids the glyphs in core/pixel-icons.ts
are drawn on (14, 16, 24): change one and those glyphs blur until redrawn. */
:root {
--icon-size-sm: 0.875rem; /* 14px: beside 11-12px text, and on small icon buttons */
--icon-size-md: 1rem; /* 16px: beside 13-14px text (file rows, window titles), path-bar buttons */