From 10e5a29ff6b15e321888a3514969dd613aa59c9a Mon Sep 17 00:00:00 2001 From: Endri Bezati Date: Thu, 10 Sep 2026 17:23:48 +0200 Subject: [PATCH] Node families can carry a full-colour image A family's icon is a single-colour watermark drawn from SVG paths, so a mark with its own palette - the one a palette entry already shows - could not be drawn in a node. NodeFamilySpec.image { dark, light? } is that mark, one image per theme; the node view lays both down and CSS shows the one for the theme, as the palette does. The image comes from the product's declaration, so the platform still names no product. --- .../diagram-client/src/diagram-client.css | 22 +++++++++ packages/diagram-client/src/views.ts | 45 ++++++++++++++++++- .../diagram-client/test/node-families.test.ts | 34 ++++++++++++++ packages/shared/src/diagram-seams.ts | 9 ++++ 4 files changed, 108 insertions(+), 2 deletions(-) diff --git a/packages/diagram-client/src/diagram-client.css b/packages/diagram-client/src/diagram-client.css index b5ac416..578e2c5 100644 --- a/packages/diagram-client/src/diagram-client.css +++ b/packages/diagram-client/src/diagram-client.css @@ -774,6 +774,28 @@ i.cal-toggle-feedback-edges.cal-feedback-hidden-icon { opacity: 0.10; } +/* ── Node center images (a family's own mark, in its own colours) ── + * Light and dark are separate images, as on the palette; VS Code stamps the + * theme on the body. The dark one is the base, so a mark with only one image + * shows it in every theme. */ +.node-center-image { + pointer-events: none; +} + +.node-center-image-light { + display: none; +} + +body.vscode-light .node-center-image.has-light .node-center-image-dark, +body.vscode-high-contrast-light .node-center-image.has-light .node-center-image-dark { + display: none; +} + +body.vscode-light .node-center-image-light, +body.vscode-high-contrast-light .node-center-image-light { + display: inline; +} + /* External actor node styles - header fill handled via header-compartment */ .external-actor-node .node-header.external-actor-header { fill: var(--workflow-color-external-default); diff --git a/packages/diagram-client/src/views.ts b/packages/diagram-client/src/views.ts index 1ab7b47..bb8fbeb 100644 --- a/packages/diagram-client/src/views.ts +++ b/packages/diagram-client/src/views.ts @@ -180,8 +180,47 @@ function renderRunRing(width: number, height: number): VNode { ); } -/** Internals reached by the family and ring tests; not part of the module's surface. */ -export const __testables = { resolveNodeFamily, renderRunRing, RUN_RING_BLEED_PX }; +/** Internals reached by the family, mark and ring tests; not part of the module's surface. */ +export const __testables = { resolveNodeFamily, renderRunRing, renderNodeCenterImage, RUN_RING_BLEED_PX }; + +/** + * Draw a family's own full-colour mark centred in the node body, where an icon + * family draws its watermark — the image its palette entry shows. + * + * Light and dark are separate images, as on the palette, so both are laid down + * and CSS shows the one for the theme (`has-light` says there is a light one + * to switch to). + */ +function renderNodeCenterImage( + family: NodeFamilySpec, + nodeWidth: number, + nodeHeight: number +): VNode[] { + const image = family.image; + if (!image?.dark) { + return []; + } + const size = NODE_ICON_SIZE; + const bodyTop = HEADER_HEIGHT; + const x = nodeWidth / 2 - size / 2; + const y = bodyTop + (nodeHeight - bodyTop) / 2 - size / 2; + const hasLight = Boolean(image.light); + const place = (href: string, theme: 'dark' | 'light'): VNode => svg('image', { + class: { [`node-center-image-${theme}`]: true }, + attrs: { href, x, y, width: size, height: size, preserveAspectRatio: 'xMidYMid meet' } + }); + + return [svg('g', { + class: { + 'node-center-image': true, + [`node-image-${family.id ?? family.annotation}`]: true, + 'has-light': hasLight + } + }, + place(image.dark, 'dark'), + ...(hasLight ? [place(image.light!, 'light')] : []) + )]; +} /** * Render a semi-transparent SVG icon centered in the node body (below the header). @@ -749,6 +788,7 @@ export class ActorNodeView extends ShapeView { // Above the body so the travelling head is not painted over by it. ...(isExecuting ? [renderRunRing(width, bodyHeight)] : []), ...(family?.icon ? [renderNodeCenterIcon(family, width, bodyHeight)] : []), + ...(family?.image ? renderNodeCenterImage(family, width, bodyHeight) : []), ...context.renderChildren(node), ...(footerLabelNode ? [footerLabelNode] : []) ); @@ -817,6 +857,7 @@ export class ExternalActorNodeView extends ShapeView { // Above the body so the travelling head is not painted over by it. ...(isExecuting ? [renderRunRing(width, bodyHeight)] : []), ...(family?.icon ? [renderNodeCenterIcon(family, width, bodyHeight)] : []), + ...(family?.image ? renderNodeCenterImage(family, width, bodyHeight) : []), ...context.renderChildren(node), ...(footerLabelNode ? [footerLabelNode] : []) ); diff --git a/packages/diagram-client/test/node-families.test.ts b/packages/diagram-client/test/node-families.test.ts index 1d0d18e..1830de6 100644 --- a/packages/diagram-client/test/node-families.test.ts +++ b/packages/diagram-client/test/node-families.test.ts @@ -120,3 +120,37 @@ describe('the CSS a declaration generates', () => { expect(nodeFamilyCss(undefined)).toBe(''); }); }); + +/** + * A family can wear its own full-colour mark — the image its palette entry + * shows — where an icon family draws a watermark. + */ +describe('a family drawn with its own mark', () => { + const { renderNodeCenterImage } = __testables; + const DARK = 'data:image/svg+xml,dark'; + const LIGHT = 'data:image/svg+xml,light'; + const family = { annotation: 'streamblocks', color: '#818a94', image: { dark: DARK, light: LIGHT } }; + + // The same reading as the ring tests: the factory keeps its data on `data` + // or `props` depending on where it runs. + const dataOf = (vnode: any): any => vnode.data ?? vnode.props; + const classesOf = (vnode: any) => + Object.entries(dataOf(vnode)?.class ?? {}).filter(([, on]) => on).map(([name]) => name); + const hrefOf = (vnode: any) => dataOf(vnode).attrs.href; + + it('lays down both images for the theme to choose between', () => { + const [group] = renderNodeCenterImage(family, 200, 100) as any[]; + const images = group.children as any[]; + + expect(classesOf(group)).toContain('has-light'); + expect(images.map(hrefOf)).toEqual([DARK, LIGHT]); + expect(images.map(classesOf)).toEqual([['node-center-image-dark'], ['node-center-image-light']]); + }); + + it('shows the dark one everywhere when there is no light one', () => { + const [group] = renderNodeCenterImage({ ...family, image: { dark: DARK } }, 200, 100) as any[]; + + expect(classesOf(group)).not.toContain('has-light'); + expect((group.children as any[]).map(hrefOf)).toEqual([DARK]); + }); +}); diff --git a/packages/shared/src/diagram-seams.ts b/packages/shared/src/diagram-seams.ts index 6ec9013..ba5f9b0 100644 --- a/packages/shared/src/diagram-seams.ts +++ b/packages/shared/src/diagram-seams.ts @@ -194,6 +194,15 @@ export interface NodeFamilySpec { viewBox: number; paths: string[]; }; + /** + * The family's own full-colour mark, drawn in the node body where an icon + * would be — the image a palette entry shows, one per theme (`dark` is the + * base, `light` is optional). + */ + image?: { + dark: string; + light?: string; + }; } export interface EntityPaletteItemSpec {