Redraw every icon on the pixel grid it renders at

On a 1x screen TDesign's glyphs smeared at 14 and 16px: their 24-unit
strokes land between pixels, so every ~1px line covered two at half ink.
All 37 icon/size pairs are now TDesign's shapes redrawn on their own grid
(viewBox 0 0 N N, so one unit is one CSS pixel):

- Straight lines are runs of whole pixels that end on pixel edges, with
  no caps or joins. WebKit drops square caps on a path of several
  subpaths, and Skia strokes 1px lines as hairlines that leave a joined
  corner pixel three-quarters inked.
- Slants and curves are 1.25px so they read as heavy as the crisp runs.
  Their ends, vertices and circle extremes sit on pixel centres.
- server24 keeps TDesign's 24px geometry; only its dots move onto whole
  pixels.

Only closeCircle24 (TDesign's own 24px grid) and the spinner still come
from @ng-icons/tdesign-icons. This also fixes add14 from 5d23809, whose
square-capped ends rendered half-inked in WebKit. icons.spec.ts now also
checks that every grid name's glyph uses that grid's viewBox.

Checked at 1x and 2x in WebKit (the app's macOS engine) and in Chrome
(Skia, as in WebView2 and WebKitGTK); straight runs fill whole pixels in
both. In WebKit at 1x, a copy of the tab bar, toolbar, file rows, menu
and shortcut-row markup rendered every straight-line glyph with zero
blur, including icons laid out on fractional pixels.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-10-09 13:49:42 +02:00
co-authored by Claude
parent 5d23809d48
commit 5be41db032
6 changed files with 123 additions and 90 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu
| Desktop | Electrobun 2.0 via Hutch (native webview, typed RPC bridge) | | Desktop | Electrobun 2.0 via Hutch (native webview, typed RPC bridge) |
| Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` | | Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` |
| Styling | Tailwind CSS v4, Oxide-derived tokens (no component library) | | Styling | Tailwind CSS v4, Oxide-derived tokens (no component library) |
| Icons | @ng-icons/tdesign-icons | | Icons | TDesign shapes redrawn on 14/16px pixel grids (`@ng-icons`) |
| SSH/SFTP | `ssh2` (pure-JS under Bun) | | SSH/SFTP | `ssh2` (pure-JS under Bun) |
The repo is a Bun workspace: the Electrobun app at the root, the Angular app in The repo is a Bun workspace: the Electrobun app at the root, the Angular app in
+6 -3
View File
@@ -376,8 +376,10 @@ component; tokens → `ui/src/styles.css`.
A name is `<icon><px>`: the pixel grid its glyph is drawn for, which must match A name is `<icon><px>`: the pixel grid its glyph is drawn for, which must match
the size token where it renders (`server14` at `--icon-size-sm`, `server16` at the size token where it renders (`server14` at `--icon-size-sm`, `server16` at
`-md`, `server24` at `-xl`). The one exception is `tdesignLoading`, which spins. `-md`, `server24` at `-xl`). The one exception is `tdesignLoading`, which spins.
`pixel-icons.ts` holds the glyphs redrawn on their grids, and names not yet Every glyph is TDesign's shape redrawn on its pixel grid in `pixel-icons.ts`,
redrawn alias TDesign's (`@ng-icons/tdesign-icons`). Templates can't type-check as whole-pixel runs plus 1.25px slants (rules in gotchas). Only `closeCircle24`
(TDesign's own 24px grid) and the spinner still come from
`@ng-icons/tdesign-icons`. Templates can't type-check
icon names, and an unregistered one renders nothing (a console warning only), so icon names, and an unregistered one renders nothing (a console warning only), so
`icons.spec.ts` checks every `<ng-icon>` in the skins. TS code that picks an icon `icons.spec.ts` checks every `<ng-icon>` in the skins. TS code that picks an icon
takes the `Icon14` / `Icon16` types. takes the `Icon14` / `Icon16` types.
@@ -517,7 +519,8 @@ TDesign's rule (an icon is 2px larger than its text, in whole pixels) through
four rem tokens, and every `<ng-icon>` takes one: `--icon-size-sm` 14px (beside four rem tokens, and every `<ng-icon>` takes one: `--icon-size-sm` 14px (beside
11–12px text, small icon buttons), `-md` 16px (13–14px text: file rows, window 11–12px text, small icon buttons), `-md` 16px (13–14px text: file rows, window
titles; the path-bar buttons), `-lg` 20px and `-xl` 24px (lone spinners and titles; the path-bar buttons), `-lg` 20px and `-xl` 24px (lone spinners and
status marks). **Interface scale** (`settings.uiScale`, status marks). The glyphs are drawn on the sm, md and xl grids (`icons.ts`).
**Interface scale** (`settings.uiScale`,
0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets 0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets
`html { font-size: 16px * scale }`, and since EVERY size in the app is rem `html { font-size: 16px * scale }`, and since EVERY size in the app is rem
(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes), the (panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes), the
+19 -8
View File
@@ -82,14 +82,25 @@ touching the adjacent code.
it. Per-template picks had drifted to 12 sizes, and neighbours stopped it. Per-template picks had drifted to 12 sizes, and neighbours stopped
matching: a 14.4px cog beside a 12.8px +, and a 12px + beside 9.6px × in matching: a 14.4px cog beside a 12.8px +, and a 12px + beside 9.6px × in
the shortcut chips. the shortcut chips.
- **On a 1x screen, a TDesign line through the icon's centre smears at any - **On a 1x screen, TDesign's glyphs smear at 14 and 16px, so every icon is
small size.** At 12 or 14px the centre is a pixel boundary, so a ~1px bar redrawn on its pixel grid (`pixel-icons.ts`).** Their 24-unit strokes land
covers two pixels at half ink. A 1px stroke alone doesn't help, because the between pixels, and a ~1px line covers two pixels at half ink. This is
bar stays centred on the boundary. This is geometry, not Electrobun: geometry, not Electrobun: headless Chrome renders the same. The drawing rules
headless Chrome renders the same. So the + is `add14` (`pixel-icons.ts`), each cost a debugging round:
redrawn on the 14px grid with 1px bars at 7.5, which fill whole pixels at 1x - **Straight lines are runs of whole pixels** (`h`, `v`, `box`) that end on
and at 2x. It's tuned for `--icon-size-sm` only. Arrow shafts (↑ →) have pixel edges. Never rely on caps or joins:
the same issue if it ever shows. - WebKit drops square caps on a `<path>` with several subpaths.
- Skia (WebView2, WebKitGTK) strokes 1px lines as hairlines at 1x, which
leave a joined corner pixel three-quarters inked.
- **Slants and curves (`slant`) are 1.25px**, so they read as heavy as the
crisp 1px runs. Put their ends, vertices and a circle's extremes on pixel
centres; a 45° line is cleanest where x−y or x+y is whole.
- **A glyph is exact only on its own grid**: a 14px glyph blurs at 16px, so
the name's suffix must match the size token (`icons.spec.ts`). It is also
exact only at UI scale 100/200/300%.
- **Check new glyphs at 1x in WebKit and Chrome, not resvg.** resvg skews a
shape's closing edge and leaves path corners partly inked, so its pixels
don't predict either browser.
## The interface scale (root font-size lever) ## The interface scale (root font-size lever)
+16
View File
@@ -1,5 +1,6 @@
import { expect, test } from 'bun:test'; import { expect, test } from 'bun:test';
import { APP_ICONS } from './icons'; import { APP_ICONS } from './icons';
import { PIXEL_ICONS } from './pixel-icons';
// The px grid of each size token (styles.css); lg only carries the spinner. // 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 GRID: Record<string, string> = { sm: '14', md: '16', lg: '20', xl: '24' };
@@ -31,3 +32,18 @@ test('every template icon is registered and drawn for the size it renders at', a
} }
expect(problems).toEqual([]); expect(problems).toEqual([]);
}); });
// A grid name's glyph is drawn on that grid (a TDesign glyph registered as
// foo14 would be a 24-unit viewBox), and the redrawn ones use plain attributes
// only: TDesign's stroke-width var or any style would bypass the grid.
test('every glyph is drawn on the grid its name says', () => {
const problems: string[] = [];
for (const [name, svg] of Object.entries(APP_ICONS)) {
const grid = name.match(/(\d+)$/)?.[1];
if (grid && !svg.includes(`viewBox="0 0 ${grid} ${grid}"`)) problems.push(`${name}: not drawn on a ${grid}px grid`);
}
for (const [name, svg] of Object.entries(PIXEL_ICONS)) {
if (/var\(|style=/.test(svg)) problems.push(`${name}: uses a CSS var or a style`);
}
expect(problems).toEqual([]);
});
+1 -71
View File
@@ -3,81 +3,11 @@
// the size token where it renders (pixel-icons.ts). Templates can't type-check // 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 // 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. // icons.spec.ts checks every template name; TS code takes the Icon14/Icon16 types.
import { import { tdesignCloseCircle, tdesignLoading } from "@ng-icons/tdesign-icons";
tdesignArrowLeft,
tdesignArrowRight,
tdesignArrowUp,
tdesignCheck,
tdesignCheckCircle,
tdesignChevronDown,
tdesignChevronRight,
tdesignChevronUp,
tdesignClose,
tdesignCloseCircle,
tdesignCode,
tdesignDelete,
tdesignDownload,
tdesignEdit,
tdesignFile,
tdesignFile1,
tdesignFileAdd,
tdesignFileZip,
tdesignFolder,
tdesignFolderAdd,
tdesignHardDrive,
tdesignImage,
tdesignLink,
tdesignLoading,
tdesignLockOn,
tdesignMusic,
tdesignRefresh,
tdesignSearch,
tdesignServer,
tdesignSetting,
tdesignVideo,
} from "@ng-icons/tdesign-icons";
import { PIXEL_ICONS } from "./pixel-icons"; import { PIXEL_ICONS } from "./pixel-icons";
export const APP_ICONS = { export const APP_ICONS = {
...PIXEL_ICONS, ...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 // TDesign's own 24px grid: as sharp as a circle and its diagonals can be
closeCircle24: tdesignCloseCircle, closeCircle24: tdesignCloseCircle,
// spins, so no pixel grid holds; any size // spins, so no pixel grid holds; any size
+80 -7
View File
@@ -2,15 +2,88 @@
// SVG with 2-unit strokes on whole units, so at 14 or 16px every straight line // 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 // 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 // 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 // unit = one CSS pixel at 100% scale) with 1px strokes, so straight lines fill
// straight lines fill whole pixels at 1x and 2x. The name's suffix is that // whole pixels at 1x and 2x. The name's suffix is that grid and must match the
// grid and must match the size token where it renders: 14 = --icon-size-sm, // size token where it renders: 14 = --icon-size-sm, 16 = -md, 24 = -xl
// 16 = -md, 24 = -xl (icons.spec.ts checks). Drawing rules: docs/gotchas.md. // (icons.spec.ts checks). Drawing rules: docs/gotchas.md.
/** One glyph on an n-px grid: 1px square-capped strokes in the text colour. */ /** One glyph on an n-px grid: 1px strokes in the text colour. */
const svg = (n: number, body: string) => 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>`; `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n} ${n}" fill="none" stroke="currentColor">${body}</svg>`;
// Straight lines are runs of whole pixels, in pixel coordinates (inclusive),
// and end exactly on pixel edges. Nothing relies on caps or joins: WebKit
// drops square caps on a path of several subpaths, and Skia (WebView2,
// WebKitGTK) strokes 1px lines as hairlines that leave a joined corner pixel
// three-quarters inked.
/** Pixels x0..x1 of row y. */
const h = (y: number, x0: number, x1: number) => `M${x0} ${y + 0.5}H${x1 + 1}`;
/** Pixels y0..y1 of column x. */
const v = (x: number, y0: number, y1: number) => `M${x + 0.5} ${y0}V${y1 + 1}`;
/** The outline of the box from pixel (x0, y0) to (x1, y1). */
const box = (x0: number, y0: number, x1: number, y1: number) => h(y0, x0, x1) + h(y1, x0, x1) + v(x0, y0, y1) + v(x1, y0, y1);
/** Filled shapes in the text colour. */
const fill = (...d: string[]) => `<path fill="currentColor" stroke="none" d="${d.join('')}"/>`;
/** Filled pixels (indicator dots, zipper teeth), size × size each. */
const dots = (at: [number, number][], size = 1) => fill(...at.map(([x, y]) => `M${x} ${y}h${size}v${size}h-${size}z`));
const path = (...d: string[]) => `<path d="${d.join('')}"/>`;
/** Slanted and curved strokes: anti-aliased wherever they sit, so 1.25px to read
* as heavy as the crisp 1px runs. Their ends and vertices sit on pixel centres. */
const slant = (...d: string[]) => `<path stroke-width="1.25" d="${d.join('')}"/>`;
/** A circle as path data, for slant() or fill(). A whole-pixel centre with a half-pixel
* radius puts its top, bottom and sides on pixel centres. */
const circle = (cx: number, cy: number, r: number) => `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0`;
/** A page with its top-right corner folded: the outline, the fold and the cut. */
const FILE14 = path(h(1, 2, 9), v(2, 1, 12), h(12, 2, 11), v(11, 3, 12), v(8, 1, 4), h(4, 8, 11)) + slant('M9.5 1.5l2 2');
const FILE16 = path(h(1, 2, 10), v(2, 1, 14), h(14, 2, 13), v(13, 4, 14), v(9, 1, 5), h(5, 9, 13)) + slant('M10.5 1.5l3 3');
export const PIXEL_ICONS = { export const PIXEL_ICONS = {
add14: svg(14, '<path d="M7.5 3.5v8M3.5 7.5h8"/>'), add14: svg(14, path(v(7, 3, 11), h(7, 3, 11))),
// ---- boxes ----
server14: svg(14, path(box(1, 1, 12, 5), box(1, 8, 12, 12)) + dots([[3, 3], [3, 10]])),
server16: svg(16, path(box(1, 1, 14, 6), box(1, 9, 14, 14)) + dots([[4, 3], [4, 11]])),
// TDesign's own geometry, already on the 24px grid; only the dots move onto whole pixels
server24: svg(24, '<g stroke-width="2"><rect x="3" y="3" width="18" height="7"/><rect x="3" y="14" width="18" height="7"/></g>' + dots([[5, 5], [5, 16]], 2)),
hardDrive14: svg(14, path(box(1, 1, 12, 12), h(8, 1, 12)) + dots([[3, 10], [5, 10]])),
file14: svg(14, FILE14),
file16: svg(16, FILE16),
fileText14: svg(14, FILE14 + path(h(7, 4, 9), h(9, 4, 9))),
fileText16: svg(16, FILE16 + path(h(8, 5, 10), h(11, 5, 10))),
fileAdd14: svg(14, path(h(1, 2, 9), v(2, 1, 12), h(12, 2, 6), v(11, 3, 6), v(8, 1, 4), h(4, 8, 11), v(11, 9, 13), h(11, 9, 13)) + slant('M9.5 1.5l2 2')),
fileZip16: svg(16, FILE16 + dots([[6, 2], [5, 3], [6, 4], [5, 5], [6, 6], [5, 7], [6, 8], [5, 9]])),
folder14: svg(14, path(h(2, 1, 5), h(4, 7, 12), v(1, 2, 11), v(12, 4, 11), h(11, 1, 12)) + slant('M5.5 2.5l2 2')),
folder16: svg(16, path(h(2, 1, 6), h(4, 8, 14), v(1, 2, 13), v(14, 4, 13), h(13, 1, 14)) + slant('M6.5 2.5l2 2')),
folderAdd14: svg(14, path(h(2, 1, 5), h(4, 7, 12), v(12, 4, 6), v(1, 2, 11), h(11, 1, 7), v(11, 8, 12), h(10, 9, 13)) + slant('M5.5 2.5l2 2')),
delete14: svg(14, path(h(3, 2, 12), v(3, 3, 12), h(12, 3, 11), v(11, 3, 12), v(5, 1, 3), h(1, 5, 9), v(9, 1, 3), v(7, 5, 10))),
lockOn14: svg(14, path(box(2, 6, 11, 12), v(4, 4, 5), v(9, 4, 5), h(9, 5, 8)) + slant('M4.5 4a2.5 2.5 0 0 1 5 0')),
video16: svg(16, path(box(1, 1, 14, 14), v(6, 4, 11)) + slant('M6.5 4.5L12 8L6.5 11.5')),
// ---- 45° ----
chevronDown14: svg(14, slant('M3.5 5.5l3 3 3-3')),
chevronUp14: svg(14, slant('M3.5 8.5l3-3 3 3')),
chevronRight14: svg(14, slant('M5.5 3.5l3 3-3 3')),
arrowRight14: svg(14, path(h(7, 2, 10)) + slant('M7.5 4.5l3 3-3 3')),
arrowLeft14: svg(14, path(h(7, 3, 11)) + slant('M6.5 4.5l-3 3 3 3')),
arrowUp16: svg(16, path(v(7, 3, 13)) + slant('M3.5 6.5l4-4 4 4')),
close14: svg(14, slant('M4 4l6 6M10 4l-6 6')),
check14: svg(14, slant('M3.5 6.5l3 3 5-5')),
download14: svg(14, path(v(7, 2, 8), v(2, 8, 11), v(12, 8, 11), h(11, 2, 12)) + slant('M4.5 5.5l3 3 3-3')),
code16: svg(16, slant('M4 5L1 8l3 3M12 5l3 3-3 3M9.5 2.5l-3 11')),
// ---- circles and arcs ----
checkCircle14: svg(14, slant(circle(7, 7, 5.5), 'M4.5 6.5l2 2 3-3')),
closeCircle14: svg(14, slant(circle(7, 7, 5.5), 'M5 5l4 4M9 5l-4 4')),
search14: svg(14, slant(circle(6, 6, 4.5), 'M9.5 9.5l3 3')),
setting14: svg(14, path(v(2, 4, 9), v(11, 4, 9)) + slant('M2.5 4.5L7 1.5l4.5 3M2.5 9.5L7 12.5l4.5-3', circle(7, 7, 2.5))),
setting16: svg(16, path(v(2, 4, 11), v(13, 4, 11)) + slant('M2.5 4.5L8 1.5l5.5 3M2.5 11.5L8 14.5l5.5-3', circle(8, 8, 2.5))),
refresh14: svg(14, path(v(1, 9, 12), h(9, 1, 3), v(12, 1, 4), h(4, 10, 12)) + slant('M12.47 7.58A5.5 5.5 0 0 1 2.1 9.5M1.53 6.42A5.5 5.5 0 0 1 11.9 4.5')),
refresh16: svg(16, path(v(1, 10, 13), h(10, 1, 4), v(14, 2, 5), h(5, 11, 14)) + slant('M14.46 8.68A6.5 6.5 0 0 1 2 10.5M1.54 7.32A6.5 6.5 0 0 1 14 5.5')),
// ---- the rest ----
link16: svg(16, slant('M7.7 4.8L9.23 3.23A2.5 2.5 0 0 1 12.77 6.77L11.2 8.3M8.3 11.2L6.77 12.77A2.5 2.5 0 0 1 3.23 9.23L4.8 7.7M9.5 6.5l-3 3')),
music16: svg(16, path(h(2, 4, 12), h(5, 4, 12), v(4, 2, 12), v(12, 2, 12)) + fill(circle(3, 13, 2), circle(11, 13, 2))),
image16: svg(16, path(box(1, 1, 14, 14)) + slant('M1.5 10.5l4-4 8 8', circle(10.5, 5.5, 1))),
edit14: svg(14, path(v(2, 9, 11), h(11, 2, 4)) + slant('M2.5 8.5l7-7 2 2-7 7M7.5 3.5l2 2')),
}; };