build(lint): enforce shared black color constants

This commit is contained in:
Danila Poyarkov 2026-05-17 18:05:07 +03:00
parent 2eab8f9a50
commit 92328a21d8
16 changed files with 72 additions and 19 deletions

View file

@ -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,

View file

@ -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",

View file

@ -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
}

View file

@ -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
}

View file

@ -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,

View file

@ -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)

View file

@ -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
}

View file

@ -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: [],

View file

@ -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'],

View file

@ -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,

View file

@ -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)
)
}

View file

@ -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,

View file

@ -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 ''

View file

@ -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 }

View file

@ -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,

View file

@ -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)