Skip to content

Commit 3583b60

Browse files
committed
feat(design-context): P3.1 surface + dedup strokes & effects
A/B testing surfaced a lost drop-shadow: get_design_context never emitted strokes or effects (serializeFlatSync captured them, project() dropped them). Surface strokes / strokeWeight / strokeAlign / effects, and dedupe stroke paint arrays + effect arrays into globalVars (`stroke` / `effect` refs); effect colors become hex, scalar strokeWeight/align stay inline. Verified live on card 15226:2448: the drop-shadow now resolves to effect_…: [{DROP_SHADOW, color:#40, offset:{0,4}, radius:4}] — i.e. box-shadow: 0 4px 4px rgba(0,0,0,.25).
1 parent 69ccea9 commit 3583b60

4 files changed

Lines changed: 88 additions & 4 deletions

File tree

packages/plugin/src/handlers/get-design-context.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ const project = (node: SceneNode, detail: DetailLevel): DesignContextNode => {
3434
if (flat.opacity !== undefined) out.opacity = flat.opacity;
3535
if (flat.cornerRadius !== undefined) out.cornerRadius = flat.cornerRadius;
3636
if (flat.fills !== undefined) out.fills = flat.fills;
37+
if (flat.strokes !== undefined) out.strokes = flat.strokes;
38+
if (flat.strokeWeight !== undefined) out.strokeWeight = flat.strokeWeight;
39+
if (flat.strokeAlign !== undefined) out.strokeAlign = flat.strokeAlign;
40+
if (flat.effects !== undefined) out.effects = flat.effects;
3741
if (flat.characters !== undefined) out.characters = flat.characters;
3842
if (flat.fontSize !== undefined) out.fontSize = flat.fontSize;
3943
if (flat.fontName !== undefined) out.fontName = flat.fontName;

packages/shared/src/design-context-dedupe.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import type {
44
GetDesignContextResult,
55
GlobalVars,
66
} from './design-context.js';
7-
import type { SerializedColor, SerializedColorStop, SerializedPaint } from './serialized-node.js';
7+
import type {
8+
SerializedColor,
9+
SerializedColorStop,
10+
SerializedEffect,
11+
SerializedPaint,
12+
} from './serialized-node.js';
813

914
/** JSON with sorted object keys, so equal-but-differently-ordered values hash identically. */
1015
const stableStringify = (value: unknown): string =>
@@ -64,6 +69,26 @@ const simplifyPaint = (paint: SerializedPaint): SimplifiedPaint => {
6469
return out;
6570
};
6671

72+
interface SimplifiedEffect {
73+
type: string;
74+
color?: string;
75+
offset?: { x: number; y: number };
76+
radius?: number;
77+
spread?: number;
78+
visible?: false;
79+
}
80+
81+
/** Structured shadow/blur: color → hex, drops the always-true `visible`. */
82+
const simplifyEffect = (e: SerializedEffect): SimplifiedEffect => {
83+
const out: SimplifiedEffect = { type: e.type };
84+
if (e.color !== undefined) out.color = toHex(e.color, e.color.a);
85+
if (e.offset !== undefined) out.offset = e.offset;
86+
if (e.radius !== undefined) out.radius = e.radius;
87+
if (e.spread !== undefined) out.spread = e.spread;
88+
if (e.visible === false) out.visible = false;
89+
return out;
90+
};
91+
6792
interface TextStyleBundle {
6893
fontFamily: string;
6994
fontStyle: string;
@@ -103,6 +128,16 @@ export const dedupeStyles = (
103128
delete out.fills;
104129
}
105130

131+
if (Array.isArray(n.strokes) && n.strokes.length > 0) {
132+
out.stroke = register(n.strokes.map(simplifyPaint), 'stroke');
133+
delete out.strokes;
134+
}
135+
136+
if (Array.isArray(n.effects) && n.effects.length > 0) {
137+
out.effect = register(n.effects.map(simplifyEffect), 'effect');
138+
delete out.effects;
139+
}
140+
106141
if (
107142
typeof n.fontSize === 'number' &&
108143
n.fontName !== undefined &&

packages/shared/src/design-context.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@ import * as v from 'valibot';
33
import {
44
MIXED,
55
SerializedComponentPropertySchema,
6+
SerializedEffectSchema,
67
SerializedFontNameSchema,
78
SerializedMainComponentSchema,
89
SerializedPaintSchema,
910
SerializedStyleIdsSchema,
1011
} from './serialized-node.js';
1112
import type {
1213
SerializedComponentProperty,
14+
SerializedEffect,
1315
SerializedMainComponent,
16+
SerializedPaint,
1417
SerializedStyleIds,
1518
} from './serialized-node.js';
1619

@@ -44,6 +47,10 @@ export interface DesignContextNode {
4447
opacity?: number;
4548
cornerRadius?: number | typeof MIXED;
4649
fills?: readonly v.InferOutput<typeof SerializedPaintSchema>[] | typeof MIXED;
50+
strokes?: readonly SerializedPaint[];
51+
strokeWeight?: number | typeof MIXED;
52+
strokeAlign?: string;
53+
effects?: readonly SerializedEffect[];
4754
characters?: string;
4855
fontSize?: number | typeof MIXED;
4956
fontName?: v.InferOutput<typeof SerializedFontNameSchema> | typeof MIXED;
@@ -53,11 +60,14 @@ export interface DesignContextNode {
5360
mainComponent?: SerializedMainComponent;
5461
mainComponentId?: string;
5562
/**
56-
* globalVars refs (P3): when style dedup runs (full detail), the inline `fills` / `fontSize` +
57-
* `fontName` are replaced by these refs into `globalVars.styles` — a style shared by N nodes
58-
* costs one entry + N refs. `fill` points at a paint array, `textStyle` at a typography bundle.
63+
* globalVars refs (P3): when style dedup runs (full detail), inline `fills` / `strokes` /
64+
* `effects` / (`fontSize` + `fontName`) are replaced by these refs into `globalVars.styles` —
65+
* a style shared by N nodes costs one entry + N refs. `fill` / `stroke` point at paint arrays,
66+
* `effect` at a shadow/blur array, `textStyle` at a typography bundle.
5967
*/
6068
fill?: string;
69+
stroke?: string;
70+
effect?: string;
6171
textStyle?: string;
6272
deduped?: boolean;
6373
truncated?: boolean;
@@ -78,6 +88,10 @@ export const DesignContextNodeSchema: v.GenericSchema<DesignContextNode> = v.laz
7888
opacity: v.exactOptional(v.number()),
7989
cornerRadius: v.exactOptional(v.union([v.number(), v.literal(MIXED)])),
8090
fills: v.exactOptional(v.union([v.array(SerializedPaintSchema), v.literal(MIXED)])),
91+
strokes: v.exactOptional(v.array(SerializedPaintSchema)),
92+
strokeWeight: v.exactOptional(v.union([v.number(), v.literal(MIXED)])),
93+
strokeAlign: v.exactOptional(v.string()),
94+
effects: v.exactOptional(v.array(SerializedEffectSchema)),
8195
characters: v.exactOptional(v.string()),
8296
fontSize: v.exactOptional(v.union([v.number(), v.literal(MIXED)])),
8397
fontName: v.exactOptional(v.union([SerializedFontNameSchema, v.literal(MIXED)])),
@@ -87,6 +101,8 @@ export const DesignContextNodeSchema: v.GenericSchema<DesignContextNode> = v.laz
87101
mainComponent: v.exactOptional(SerializedMainComponentSchema),
88102
mainComponentId: v.exactOptional(v.string()),
89103
fill: v.exactOptional(v.string()),
104+
stroke: v.exactOptional(v.string()),
105+
effect: v.exactOptional(v.string()),
90106
textStyle: v.exactOptional(v.string()),
91107
deduped: v.exactOptional(v.boolean()),
92108
truncated: v.exactOptional(v.boolean()),

packages/shared/test/design-context-dedupe.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,35 @@ describe('dedupeStyles', () => {
4141
expect(globalVars.styles[nodes[0]!.fill!]).toEqual([{ type: 'SOLID', color: '#FFFFFF80' }]);
4242
});
4343

44+
it('hoists effects (drop-shadow) and strokes into refs, converting colors to hex', () => {
45+
const n: DesignContextNode = {
46+
id: 'card',
47+
name: 'card',
48+
type: 'FRAME',
49+
strokes: [solid(0.9, 0.9, 0.9)],
50+
strokeWeight: 1,
51+
effects: [
52+
{
53+
type: 'DROP_SHADOW',
54+
visible: true,
55+
radius: 8,
56+
spread: 0,
57+
offset: { x: 0, y: 2 },
58+
color: { r: 0, g: 0, b: 0, a: 0.25 },
59+
},
60+
],
61+
};
62+
const { nodes, globalVars } = dedupeStyles([n]);
63+
64+
expect(nodes[0]?.strokes).toBeUndefined();
65+
expect(nodes[0]?.effects).toBeUndefined();
66+
expect(nodes[0]?.strokeWeight).toBe(1); // scalar stays inline
67+
expect(globalVars.styles[nodes[0]!.stroke!]).toEqual([{ type: 'SOLID', color: '#E6E6E6' }]);
68+
expect(globalVars.styles[nodes[0]!.effect!]).toEqual([
69+
{ type: 'DROP_SHADOW', color: '#00000040', offset: { x: 0, y: 2 }, radius: 8, spread: 0 },
70+
]);
71+
});
72+
4473
it('deduplicates identical styles to one entry shared by many refs (the 100-buttons case)', () => {
4574
const items = ['m1', 'm2', 'm3', 'm4', 'm5', 'm6'].map(id =>
4675
textNode(id, 'Noto Sans JP', 'Regular', 16),

0 commit comments

Comments
 (0)