From 7ec8ff251f98ae4480d9d597ddd107e2350a74f3 Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Thu, 8 Oct 2026 17:41:04 +0800 Subject: [PATCH] fix(sidebar): ink the panel glyph at the harness line weight (#124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell's own panel rows draw their glyphs with the icon set's one-unit stroke in a 16-unit box — 6.25% of the icon edge — while the sheet's bars measure 98-101 units of its 1024 artboard (9.6-9.9%), so the row read half again as heavy as the Plugins and Automation tasks rows beside it; at that weight antialiasing also filled the strokes enough to look darker than neighbours that ride the very same `--dsw-alias-label-primary` currentColor in both themes. The mono seat now derives the thin glyph from the SAME single artwork instead of shipping a second SVG: the sheet masks down to the harness weight by 17 units a side (98 -> 64) as the intersection of the drawing with its own copies shifted along each axis — a white flood minus one black shift per direction, every shift painted through the drawing's complement mask, which stays the only copy of the artwork on the page. Compositing, not a filter: `feMorphology` rounds its radius to whole device pixels, so it measured as a no-op at 1x and an over-thin at 2x. The polychrome identity seats (tab chip, /context entry, guide capsule) are untouched. Verified: live strokes measure 17px against the clock's 17px and the pinwheel's 19px at 256px; screenshots in light and dark; lint:fix + test (100% per-file coverage) + build. --- src/client/icon.tsx | 67 +++++++++++++++++++++++++++++++++------ tests/client/icon.spec.ts | 39 ++++++++++++++++++++--- 2 files changed, 92 insertions(+), 14 deletions(-) diff --git a/src/client/icon.tsx b/src/client/icon.tsx index 2753900..af2dacc 100644 --- a/src/client/icon.tsx +++ b/src/client/icon.tsx @@ -9,13 +9,13 @@ * re-renders it at every requested size. The identity seats (tab chip, * command, sidebar registration) keep the sheet's fixed fills — * deliberately polychrome on both light and dark chrome — while the - * sidebar's panel-list seat (insightPage.ts) opts into `mono`, trading - * every fill for `currentColor` so the glyph follows the shell-owned row's - * own state colors, exactly as the shipped panel glyphs do. + * sidebar's panel-list seat (insightPage.ts) opts into `mono`: the sheet in + * the surrounding text colour at the harness's own line weight, so the + * shell-owned row weighs what the shipped rows beside it weigh. */ import sheetMarkup from '../../icon.svg?raw' -import type { ReactElement } from 'react' +import { useId, type ReactElement } from 'react' import type { Translate } from './i18n' /** Everything between the file's `` tags: the sheet's strokes in paint order, whitespace-folded. */ @@ -24,8 +24,54 @@ const SHEET_MARKUP = sheetMarkup .trim() .replace(/>\s+<') -/** The mono seat's strokes: same geometry, every palette fill traded for the surrounding text colour. */ -const SHEET_MARKUP_MONO = SHEET_MARKUP.replace(/fill="#[0-9A-Fa-f]{6}"/g, 'fill="currentColor"') +/** The same strokes with their palette dropped — the shape alone, which the mono seat's masks paint. */ +const SHEET_OUTLINE = SHEET_MARKUP.replace(/fill="#[0-9A-Fa-f]{6}"/g, '') + +/** The harness icon set's line weight: one unit of its 16-unit box, which this artboard spells as 64. */ +const HARNESS_STROKE = 1024 / 16 + +/** The source artboard's own bar weight: every pill and the dot measures 98 to 101 units. */ +const SHEET_STROKE = 98 + +/** What the mono seat's mask takes off each side of every stroke to reach the harness weight. */ +const INSET = (SHEET_STROKE - HARNESS_STROKE) / 2 + +/** The mask canvas: the artboard grown past every shifted copy of itself, so no edge clips. */ +const CANVAS = { x: -2 * INSET, y: -2 * INSET, width: 1024 + 4 * INSET, height: 1024 + 4 * INSET } +const CANVAS_ATTRS = `x="${CANVAS.x}" y="${CANVAS.y}" width="${CANVAS.width}" height="${CANVAS.height}"` + +/** One canvas-covering rect — the mask flood, a shift's paint, or the mono seat's own ink. */ +const canvasRect = (fill: string, rest = ''): string => `` + +/** The axes the mono seat erodes along, one shift per direction. */ +const SHIFTS: [number, number][] = [[INSET, 0], [-INSET, 0], [0, INSET], [0, -INSET]] + +/** + * The mono seat's markup: the sheet masked down to the harness line weight + * rather than redrawn — the plugin ships ONE artwork, and the shell's rows + * want thin strokes. Erosion is the intersection of a shape with its own + * copies shifted along each axis; a mask intersects as a white flood with + * one black shift per direction, every shift painted through the sheet's + * own complement (the `-hole` mask), which is the only copy of the drawing. + * Compositing rather than a filter: `feMorphology` rounds its radius to + * whole device pixels, so it would thin the glyph on a 2× display and leave + * it untouched (or over-thin it) at 1×. + * @param id - this instance's def prefix; two emblems on a page may not share mask ids. + */ +function monoMarkup(id: string): string { + const hole = `${id}-hole` + const thin = `${id}-thin` + const shifts = SHIFTS + .map(([dx, dy]) => canvasRect('#000', ` mask="url(#${hole})" transform="translate(${dx} ${dy})"`)) + .join('') + return '' + + `` + + `${canvasRect('#fff')}${SHEET_OUTLINE}` + + '' + + `${canvasRect('#fff')}${shifts}` + + '' + + canvasRect('currentColor', ` mask="url(#${thin})"`) +} /** The emblem's props, matching the harness `IconProps` the guide capsule hands it. */ export interface ContextIconProps { @@ -33,12 +79,15 @@ export interface ContextIconProps { size?: number /** Extra class for layout placement. */ className?: string - /** Render in the surrounding text colour instead of the palette (the sidebar panel-list seat). */ + /** The sidebar panel-list seat: the surrounding text colour at the harness line weight, not the palette. */ mono?: boolean } -/** The document sheet at the requested square edge — polychrome by default, text-coloured in `mono`. */ +/** The document sheet at the requested square edge — polychrome by default, the shell row's own weight in `mono`. */ export function ContextIcon({ size = 20, className, mono = false }: ContextIconProps): ReactElement { + // Mask defs are per instance: a second emblem on the page would otherwise + // resolve `url(#…)` against this one's defs, and lose them when it unmounts. + const id = `dsh-context-sheet-${useId().replaceAll(':', '')}` return ( ) } diff --git a/tests/client/icon.spec.ts b/tests/client/icon.spec.ts index 2b4fdca..ba30eb0 100644 --- a/tests/client/icon.spec.ts +++ b/tests/client/icon.spec.ts @@ -40,15 +40,44 @@ describe('ContextIcon', () => { await m.unmount() }) - test('the mono seat trades every palette fill for the current colour', async () => { + test('the mono seat inks the sheet in the current colour at the harness line weight', async () => { const m = await mount(h(ContextIcon, { mono: true })) - const paths = queryAll(m.container, 'path') - assert.equal(paths.length, 10) - const fills = paths.map(p => p.getAttribute('fill')) - assert.ok(fills.every(f => f === 'currentColor')) + const svg = query(m.container, 'svg') + assert.equal(svg.innerHTML.match(/fill="#[0-9A-Fa-f]{6}"/g), null, 'no palette fill survives') + const rects = queryAll(m.container, 'rect') + assert.equal( + rects.filter(r => r.getAttribute('fill') === 'currentColor').length, + 1, + 'one current-colour shape carries the whole glyph', + ) + // The plugin ships one artwork: the drawing appears exactly once, in the + // complement mask that each eroding shift is painted through. + assert.equal(queryAll(m.container, 'path').length, 10) + // The inset itself: a white flood minus one black shift per axis + // direction, 17 units each — the source's 98-unit bars less the harness + // weight's 64 makes for two 17s. + const [hole, thin] = queryAll(m.container, 'mask') + assert.match(hole.id, /-hole$/) + assert.match(thin.id, /-thin$/) + const shifts = queryAll(thin, 'rect').filter(r => r.getAttribute('fill') === '#000') + assert.deepEqual(shifts.map(r => r.getAttribute('transform')), [ + 'translate(17 0)', 'translate(-17 0)', 'translate(0 17)', 'translate(0 -17)', + ]) + assert.ok(shifts.every(r => r.getAttribute('mask') === `url(#${hole.id})`)) await m.unmount() }) + test('emblem instances never share mask defs', async () => { + const a = await mount(h(ContextIcon, { mono: true })) + const b = await mount(h(ContextIcon, { mono: true })) + const idsOf = (m: { container: HTMLElement }): string[] => + queryAll(m.container, 'mask').map(mask => mask.id) + const ids = [...idsOf(a), ...idsOf(b)] + assert.equal(new Set(ids).size, ids.length, 'a duplicate id would strand one emblem on the other defs') + await a.unmount() + await b.unmount() + }) + test('the package-root icon.svg stays in lockstep with the component', async () => { // The static file is what the Host's package-meta reader serves to the // Plugins page (package.json `icon`), so a component edit that skips the