diff --git a/lint/plugin.js b/lint/plugin.js index d2e43fe62..a7af77d7d 100644 --- a/lint/plugin.js +++ b/lint/plugin.js @@ -533,6 +533,44 @@ const noMathRandom = { } } +function isNumericLiteral(node, value) { + return node?.type === 'Literal' && node.value === value +} + +function colorObjectLiteral(node, color) { + if (node?.type !== 'ObjectExpression') return false + const props = new Map() + for (const prop of node.properties ?? []) { + if (prop.type !== 'Property') return false + const key = prop.key.type === 'Identifier' ? prop.key.name : prop.key.value + props.set(key, prop.value) + } + return Object.entries(color).every(([key, value]) => isNumericLiteral(props.get(key), value)) +} + +const noHardcodedColorConstants = { + meta: { + docs: { + description: 'Use named color constants instead of inline Color object literals for shared colors' + } + }, + create(context) { + const file = normalizedFilename(context) + if (file.includes('/tests/') || file.endsWith('/packages/core/src/constants.ts')) return {} + + return { + ObjectExpression(node) { + if (colorObjectLiteral(node, { r: 0, g: 0, b: 0, a: 1 })) { + context.report({ node, message: 'Use BLACK from constants instead of an inline black Color literal.' }) + } + if (colorObjectLiteral(node, { r: 0, g: 0, b: 0, a: 0 })) { + context.report({ node, message: 'Use TRANSPARENT from constants instead of an inline transparent Color literal.' }) + } + } + } + } +} + const noHandRolledColor = { meta: { docs: { @@ -1553,6 +1591,7 @@ const plugin = { 'no-document-query-selector-in-vue': noDocumentQuerySelectorInVue, 'no-direct-selection-tool-state-mutation': noDirectSelectionToolStateMutation, 'no-math-random': noMathRandom, + 'no-hardcoded-color-constants': noHardcodedColorConstants, 'no-hand-rolled-color': noHandRolledColor, 'no-raw-console-format': noRawConsoleFormat, 'no-silent-catch': noSilentCatch, diff --git a/oxlint.json b/oxlint.json index d7e7c4ada..b9889dbec 100644 --- a/oxlint.json +++ b/oxlint.json @@ -122,6 +122,7 @@ "open-pencil/no-document-query-selector-in-vue": "error", "open-pencil/no-direct-selection-tool-state-mutation": "error", "open-pencil/no-math-random": "error", + "open-pencil/no-hardcoded-color-constants": "error", "open-pencil/no-hand-rolled-color": "error", "open-pencil/no-raw-console-format": "off", "open-pencil/no-silent-catch": "error", diff --git a/packages/core/src/editor/clipboard/images.ts b/packages/core/src/editor/clipboard/images.ts index f5bcf93a2..9cf5e0774 100644 --- a/packages/core/src/editor/clipboard/images.ts +++ b/packages/core/src/editor/clipboard/images.ts @@ -1,3 +1,4 @@ +import { TRANSPARENT } from '#core/constants' import { resolvePasteTarget } from '#core/editor/clipboard/paste-target' import type { EditorContext } from '#core/editor/types' import { computeImageHash } from '#core/figma-api' @@ -44,7 +45,7 @@ export function createClipboardImageActions(ctx: EditorContext) { type: 'IMAGE', imageHash: hash, imageScaleMode: 'FILL', - color: { r: 0, g: 0, b: 0, a: 0 }, + color: TRANSPARENT, opacity: 1, visible: true } diff --git a/packages/core/src/editor/shapes.ts b/packages/core/src/editor/shapes.ts index 7153b1039..f8b00f88c 100644 --- a/packages/core/src/editor/shapes.ts +++ b/packages/core/src/editor/shapes.ts @@ -1,4 +1,5 @@ import { + BLACK, DEFAULT_FRAME_FILL, DEFAULT_SHAPE_FILL, SECTION_DEFAULT_FILL, @@ -13,7 +14,7 @@ export type { PenDragOptions } from './shapes/pen' const BLACK_FILL: Fill = { type: 'SOLID', - color: { r: 0, g: 0, b: 0, a: 1 }, + color: BLACK, opacity: 1, visible: true } diff --git a/packages/core/src/editor/shapes/pen.ts b/packages/core/src/editor/shapes/pen.ts index 7d8e7b3c8..9befdf2bb 100644 --- a/packages/core/src/editor/shapes/pen.ts +++ b/packages/core/src/editor/shapes/pen.ts @@ -1,3 +1,4 @@ +import { BLACK } from '#core/constants' import type { EditorContext } from '#core/editor/types' import type { SceneNode, VectorNetwork, VectorRegion, VectorSegment } from '#core/scene-graph' import type { Vector } from '#core/types' @@ -19,7 +20,7 @@ type CreateShape = ( ) => string const PEN_DEFAULT_STROKE: SceneNode['strokes'][number] = { - color: { r: 0, g: 0, b: 0, a: 1 }, + color: BLACK, weight: 2, opacity: 1, visible: true, diff --git a/packages/core/src/io/formats/pen/convert.ts b/packages/core/src/io/formats/pen/convert.ts index 87468c8fc..1cd424c9f 100644 --- a/packages/core/src/io/formats/pen/convert.ts +++ b/packages/core/src/io/formats/pen/convert.ts @@ -1,4 +1,5 @@ import { parseColor } from '#core/color' +import { BLACK } from '#core/constants' import { generateId } from '#core/scene-graph' import type { Color, @@ -136,7 +137,7 @@ function penValueToSceneValue(raw: string | number, type: VariableType): Variabl } function defaultForType(type: VariableType): VariableValue { - if (type === 'COLOR') return { r: 0, g: 0, b: 0, a: 1 } + if (type === 'COLOR') return { ...BLACK } if (type === 'FLOAT') return 0 if (type === 'BOOLEAN') return false return '' @@ -252,7 +253,7 @@ export function buildVarContext( if (val === undefined) return parseColor(ref) if (typeof val === 'object' && 'r' in val) return val if (typeof val === 'string') return parseColor(val) - return { r: 0, g: 0, b: 0, a: 1 } + return { ...BLACK } }, resolveNumber(ref: string): number { const val = resolveVal(ref) diff --git a/packages/core/src/kiwi/fig/import.ts b/packages/core/src/kiwi/fig/import.ts index 31229bbb0..36c9b94aa 100644 --- a/packages/core/src/kiwi/fig/import.ts +++ b/packages/core/src/kiwi/fig/import.ts @@ -1,3 +1,4 @@ +import { BLACK } from '#core/constants' import type { NodeChange, VariableDataValuesEntry, Color, GUID } from '#core/kiwi/binary/codec' import { populateAndApplyOverrides } from '#core/kiwi/instance-overrides' import type { InstanceNodeChange } from '#core/kiwi/instance-overrides' @@ -164,7 +165,7 @@ function resolveVariableValue( function resolveDefaultValue(type: VariableType): VariableValue { if (type === 'BOOLEAN') return false if (type === 'STRING') return '' - if (type === 'COLOR') return { r: 0, g: 0, b: 0, a: 1 } + if (type === 'COLOR') return { ...BLACK } return 0 } diff --git a/packages/core/src/scene-graph/node-defaults.ts b/packages/core/src/scene-graph/node-defaults.ts index c140cfee1..590194476 100644 --- a/packages/core/src/scene-graph/node-defaults.ts +++ b/packages/core/src/scene-graph/node-defaults.ts @@ -1,4 +1,4 @@ -import { DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT } from '#core/constants' +import { BLACK, DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT } from '#core/constants' import type { NodeType, SceneNode } from './types' @@ -21,7 +21,7 @@ export function createDefaultNode( figmaDerivedLayout: null, fills: type === 'TEXT' - ? [{ type: 'SOLID' as const, color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + ? [{ type: 'SOLID' as const, color: BLACK, opacity: 1, visible: true }] : [], strokes: [], effects: [], diff --git a/packages/core/src/tools/modify/effects.ts b/packages/core/src/tools/modify/effects.ts index 5b51299c1..d17471b38 100644 --- a/packages/core/src/tools/modify/effects.ts +++ b/packages/core/src/tools/modify/effects.ts @@ -1,5 +1,5 @@ import { parseColor } from '#core/color' -import { DEFAULT_SHADOW_COLOR } from '#core/constants' +import { DEFAULT_SHADOW_COLOR, TRANSPARENT } from '#core/constants' import type { Effect } from '#core/scene-graph' import { defineTool, nodeNotFound } from '#core/tools/schema' @@ -28,7 +28,7 @@ export const setEffects = defineTool({ const isBlur = args.type === 'FOREGROUND_BLUR' || args.type === 'BACKGROUND_BLUR' let color = { ...DEFAULT_SHADOW_COLOR } - if (isBlur) color = { r: 0, g: 0, b: 0, a: 0 } + if (isBlur) color = { ...TRANSPARENT } else if (args.color) color = parseColor(args.color) const effect: Effect = { type: args.type as Effect['type'], diff --git a/packages/core/src/tools/modify/paint.ts b/packages/core/src/tools/modify/paint.ts index 05fca8176..a747e020a 100644 --- a/packages/core/src/tools/modify/paint.ts +++ b/packages/core/src/tools/modify/paint.ts @@ -1,4 +1,5 @@ import { parseColor } from '#core/color' +import { BLACK } from '#core/constants' import { defineTool } from '#core/tools/schema' import type { Matrix } from '#core/types' @@ -116,7 +117,7 @@ export const setImageFill = defineTool({ node.fills = [ { type: 'IMAGE', - color: { r: 0, g: 0, b: 0, a: 1 }, + color: BLACK, opacity: 1, visible: true, imageHash: image.hash, diff --git a/packages/vue/src/controls/okhcl/helpers.ts b/packages/vue/src/controls/okhcl/helpers.ts index a15215f0e..52d0bb6ad 100644 --- a/packages/vue/src/controls/okhcl/helpers.ts +++ b/packages/vue/src/controls/okhcl/helpers.ts @@ -1,5 +1,6 @@ import type { Ref } from 'vue' +import { BLACK } from '@open-pencil/core/constants' import { getFillOkHCL, getStrokeOkHCL, @@ -31,14 +32,14 @@ export function getStrokeOkHCLColor(node: SceneNode | null, index: number): OkHC function fallbackFillOkHCL(node: SceneNode, index: number) { return ( getFillOkHCLColor(node, index) ?? - rgbaToOkHCL(node.fills[index]?.color ?? { r: 0, g: 0, b: 0, a: 1 }) + rgbaToOkHCL(node.fills[index]?.color ?? BLACK) ) } function fallbackStrokeOkHCL(node: SceneNode, index: number) { return ( getStrokeOkHCLColor(node, index) ?? - rgbaToOkHCL(node.strokes[index]?.color ?? { r: 0, g: 0, b: 0, a: 1 }) + rgbaToOkHCL(node.strokes[index]?.color ?? BLACK) ) } diff --git a/packages/vue/src/controls/stroke/helpers.ts b/packages/vue/src/controls/stroke/helpers.ts index c600ea438..c8f5b23be 100644 --- a/packages/vue/src/controls/stroke/helpers.ts +++ b/packages/vue/src/controls/stroke/helpers.ts @@ -1,5 +1,6 @@ import type { Ref } from 'vue' +import { BLACK } from '@open-pencil/core/constants' import type { Editor } from '@open-pencil/core/editor' import type { SceneNode, Stroke } from '@open-pencil/core/scene-graph' @@ -16,7 +17,7 @@ export const SIDE_OPTIONS: { value: StrokeSides; label: string }[] = [ export const BORDER_SIDES = ['top', 'right', 'bottom', 'left'] as const export const DEFAULT_STROKE: Stroke = { - color: { r: 0, g: 0, b: 0, a: 1 }, + color: BLACK, weight: 1, opacity: 1, visible: true, diff --git a/packages/vue/src/variables/helpers.ts b/packages/vue/src/variables/helpers.ts index c4a17ee20..9b0102481 100644 --- a/packages/vue/src/variables/helpers.ts +++ b/packages/vue/src/variables/helpers.ts @@ -1,5 +1,6 @@ import type { Ref } from 'vue' +import { BLACK } from '@open-pencil/core/constants' import { colorToHexRaw, parseColor } from '@open-pencil/core/color' import type { Editor } from '@open-pencil/core/editor' import { randomHex } from '@open-pencil/core/random' @@ -93,7 +94,7 @@ export function createVariableValueActions( getActiveCollection: () => VariableCollection | null ) { function defaultVariableValue(type: VariableType): VariableValue { - if (type === 'COLOR') return { r: 0, g: 0, b: 0, a: 1 } + if (type === 'COLOR') return { ...BLACK } if (type === 'FLOAT') return 0 if (type === 'BOOLEAN') return false return '' diff --git a/src/app/demo/colors.ts b/src/app/demo/colors.ts index 999213029..8f3f9631c 100644 --- a/src/app/demo/colors.ts +++ b/src/app/demo/colors.ts @@ -1,7 +1,9 @@ +import { BLACK } from '@open-pencil/core/constants' import type { Color, Fill, GradientStop, Stroke } from '@open-pencil/core/scene-graph' +export { BLACK } + export const WHITE: Color = { r: 1, g: 1, b: 1, a: 1 } -export const BLACK: Color = { r: 0, g: 0, b: 0, a: 1 } export const GRAY_50: Color = { r: 0.98, g: 0.98, b: 0.98, a: 1 } export const GRAY_100: Color = { r: 0.96, g: 0.96, b: 0.97, a: 1 } export const GRAY_200: Color = { r: 0.9, g: 0.9, b: 0.92, a: 1 } diff --git a/src/app/demo/effects.ts b/src/app/demo/effects.ts index ffead691d..536f9809a 100644 --- a/src/app/demo/effects.ts +++ b/src/app/demo/effects.ts @@ -1,3 +1,4 @@ +import { TRANSPARENT } from '@open-pencil/core/constants' import type { Color, Effect } from '@open-pencil/core/scene-graph' export function dropShadow( @@ -37,7 +38,7 @@ export function innerShadow( export function blurEffect(type: Effect['type'], radius: number): Effect { return { type, - color: { r: 0, g: 0, b: 0, a: 0 }, + color: TRANSPARENT, offset: { x: 0, y: 0 }, radius, spread: 0, diff --git a/tests/engine/render/canvas/boolean.test.ts b/tests/engine/render/canvas/boolean.test.ts index 635934484..e19a76ee1 100644 --- a/tests/engine/render/canvas/boolean.test.ts +++ b/tests/engine/render/canvas/boolean.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { initCanvasKit } from '#cli/headless' +import { BLACK } from '#core/constants' import { makeBooleanOperationPath } from '#core/canvas/boolean' import { makeNodeShapePath, makePolygonPath, makeRRect } from '#core/canvas/shapes' import type { SkiaRenderer } from '#core/canvas/renderer' @@ -144,8 +145,8 @@ describe('boolean operation paths', () => { expect(firstNode).toBeDefined() expect(secondNode).toBeDefined() if (!firstNode || !secondNode) return - firstNode.strokes = [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, weight: 12, align: 'CENTER' }] - secondNode.strokes = [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, weight: 12, align: 'CENTER' }] + firstNode.strokes = [{ type: 'SOLID', color: BLACK, opacity: 1, visible: true, weight: 12, align: 'CENTER' }] + secondNode.strokes = [{ type: 'SOLID', color: BLACK, opacity: 1, visible: true, weight: 12, align: 'CENTER' }] const booleanNode = api.union([first, second], api.currentPage) const node = api.graph.getNode(booleanNode.id)