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:
1 parent
d4d5f52734
commit
7ec8ff251f
2 files changed
+92
-14
No files matched your search
+58
-9
@@ -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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user