fix(sidebar): ink the panel glyph at the harness line weight (#124)

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.
This commit is contained in:
bowenliang123 committed 2026-10-08 17:41:04 +08:00
1 parent d4d5f52734
commit 7ec8ff251f
2 files changed
+92 -14

No files matched your search

+58 -9
View File
@@ -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 `<svg>` tags: the sheet's strokes in paint order, whitespace-folded. */
@@ -24,8 +24,54 @@ const SHEET_MARKUP = sheetMarkup
.trim()
.replace(/>\s+</g, '><')
/** 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 => `<rect ${CANVAS_ATTRS} fill="${fill}"${rest}/>`
/** 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 '<defs>'
+ `<mask id="${hole}" maskUnits="userSpaceOnUse" ${CANVAS_ATTRS}>`
+ `${canvasRect('#fff')}<g fill="#000">${SHEET_OUTLINE}</g>`
+ '</mask>'
+ `<mask id="${thin}" maskUnits="userSpaceOnUse" ${CANVAS_ATTRS}>${canvasRect('#fff')}${shifts}</mask>`
+ '</defs>'
+ 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 (
<svg
width={size}
@@ -47,7 +96,7 @@ export function ContextIcon({ size = 20, className, mono = false }: ContextIconP
className={className}
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
dangerouslySetInnerHTML={{ __html: mono ? SHEET_MARKUP_MONO : SHEET_MARKUP }}
dangerouslySetInnerHTML={{ __html: mono ? monoMarkup(id) : SHEET_MARKUP }}
/>
)
}
+34 -5
View File
@@ -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<SVGPathElement>(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<SVGSVGElement>(m.container, 'svg')
assert.equal(svg.innerHTML.match(/fill="#[0-9A-Fa-f]{6}"/g), null, 'no palette fill survives')
const rects = queryAll<SVGRectElement>(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<SVGPathElement>(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<SVGMaskElement>(m.container, 'mask')
assert.match(hole.id, /-hole$/)
assert.match(thin.id, /-thin$/)
const shifts = queryAll<SVGRectElement>(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<SVGMaskElement>(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