diff --git a/README.md b/README.md index b7e024b..f17568a 100644 --- a/README.md +++ b/README.md @@ -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) | | Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` | | 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) | The repo is a Bun workspace: the Electrobun app at the root, the Angular app in diff --git a/docs/architecture.md b/docs/architecture.md index ad8015d..cf34214 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -376,8 +376,10 @@ component; tokens → `ui/src/styles.css`. A name is ``: the pixel grid its glyph is drawn for, which must match the size token where it renders (`server14` at `--icon-size-sm`, `server16` at `-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 - redrawn alias TDesign's (`@ng-icons/tdesign-icons`). Templates can't type-check + Every glyph is TDesign's shape redrawn on its pixel grid in `pixel-icons.ts`, + 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 `icons.spec.ts` checks every `` in the skins. TS code that picks an icon 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 `` takes one: `--icon-size-sm` 14px (beside 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 -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 `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 diff --git a/docs/gotchas.md b/docs/gotchas.md index 9313458..a04a08b 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -82,14 +82,25 @@ touching the adjacent code. 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 the shortcut chips. -- **On a 1x screen, a TDesign line through the icon's centre smears at any - small size.** At 12 or 14px the centre is a pixel boundary, so a ~1px bar - covers two pixels at half ink. A 1px stroke alone doesn't help, because the - bar stays centred on the boundary. This is geometry, not Electrobun: - headless Chrome renders the same. So the + is `add14` (`pixel-icons.ts`), - redrawn on the 14px grid with 1px bars at 7.5, which fill whole pixels at 1x - and at 2x. It's tuned for `--icon-size-sm` only. Arrow shafts (↑ →) have - the same issue if it ever shows. +- **On a 1x screen, TDesign's glyphs smear at 14 and 16px, so every icon is + redrawn on its pixel grid (`pixel-icons.ts`).** Their 24-unit strokes land + between pixels, and a ~1px line covers two pixels at half ink. This is + geometry, not Electrobun: headless Chrome renders the same. The drawing rules + each cost a debugging round: + - **Straight lines are runs of whole pixels** (`h`, `v`, `box`) that end on + pixel edges. Never rely on caps or joins: + - WebKit drops square caps on a `` 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) diff --git a/ui/src/app/core/icons.spec.ts b/ui/src/app/core/icons.spec.ts index a4c50d9..a300cd4 100644 --- a/ui/src/app/core/icons.spec.ts +++ b/ui/src/app/core/icons.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from 'bun:test'; 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. const GRID: Record = { 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([]); }); + +// 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([]); +}); diff --git a/ui/src/app/core/icons.ts b/ui/src/app/core/icons.ts index bb282ce..f35acd5 100644 --- a/ui/src/app/core/icons.ts +++ b/ui/src/app/core/icons.ts @@ -3,81 +3,11 @@ // 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, - 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 { tdesignCloseCircle, tdesignLoading } from "@ng-icons/tdesign-icons"; import { PIXEL_ICONS } from "./pixel-icons"; export const APP_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 closeCircle24: tdesignCloseCircle, // spins, so no pixel grid holds; any size diff --git a/ui/src/app/core/pixel-icons.ts b/ui/src/app/core/pixel-icons.ts index fdab2fe..97ebab3 100644 --- a/ui/src/app/core/pixel-icons.ts +++ b/ui/src/app/core/pixel-icons.ts @@ -2,15 +2,88 @@ // 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. +// unit = one CSS pixel at 100% scale) with 1px strokes, 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. */ +/** One glyph on an n-px grid: 1px strokes in the text colour. */ const svg = (n: number, body: string) => - `${body}`; + `${body}`; + +// 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[]) => ``; +/** 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[]) => ``; +/** 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[]) => ``; +/** 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 = { - add14: svg(14, ''), + 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, '' + 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')), };