feat(vue): consolidate color model

- Add a reactive useColorModel API for format state, channel edits, Reka bridges, and slider presentation

- Move the app color panel and ColorInputRoot onto the shared model and remove superseded picker helpers

- Document the public contract and cover precise RGB plus OkHCL intent round trips
This commit is contained in:
Danila Poyarkov 2026-07-13 21:48:36 +03:00
parent fcdf7f1d4c
commit bf90a70d05
22 changed files with 819 additions and 475 deletions

View file

@ -20,6 +20,7 @@
- Refine variable-bound number fields with a quiet identity pill, one picker affordance, an accessible variable combobox, and non-destructive focus behavior.
- Redesign Position and Appearance controls with aligned panel grids, SDK-owned independent-corner state, and compact type-icon selection headers.
- Rebuild Layout size fields with shared variable binding, inline sizing modes, semantic field anatomy, and one-step Hug/Fill-to-Fixed editing.
- Replace the fragmented Vue SDK color-picker model helpers with `useColorModel()`, providing precise Reka bridges, extensible formats, and shared RGB, HSL, HSB, and OkHCL channel behavior.
- Upgrade Vue SDK documentation with shared Tailwind demos, source-generated component API tables, and type-aware Twoslash examples in VitePress.
- Add desktop image drag-and-drop into the Tauri app window.
- Add open-document discovery for live CLI and MCP automation so agents can target the intended document and page.

View file

@ -40,6 +40,7 @@ const SDK_COMPOSABLE_PAGES = [
{ text: 'usePosition', slug: 'use-position' },
{ text: 'useLayout', slug: 'use-layout' },
{ text: 'useAppearance', slug: 'use-appearance' },
{ text: 'useColorModel', slug: 'use-color-model', canonical: true },
{ text: 'useTypography', slug: 'use-typography' },
{ text: 'useExport', slug: 'use-export' },
{ text: 'useFillControls', slug: 'use-fill-controls' },
@ -101,7 +102,7 @@ export const sdkSidebar = (prefix: string): DefaultTheme.SidebarItem[] => [
{ text: 'Overview', link: `${prefix}/programmable/sdk/api/composables/` },
...SDK_COMPOSABLE_PAGES.map((page) => ({
text: page.text,
link: `${prefix}/programmable/sdk/api/composables/${page.slug}`
link: `${'canonical' in page ? '' : prefix}/programmable/sdk/api/composables/${page.slug}`
}))
]
},

View file

@ -1,13 +1,17 @@
---
title: useOkHCL
description: Work with RGBA and OkHCL color models for fills and strokes.
description: Persist OkHCL intent for selected node fills and strokes.
---
# useOkHCL
`useOkHCL()` exposes helpers for reading, enabling, disabling, and updating OkHCL color values on node fills and strokes.
`useOkHCL()` is the editor-aware adapter for OkHCL fill and stroke metadata. It reads stored color
intent, updates nodes with undo, reports preview gamut information, and remembers the selected
field format for each fill or stroke.
Use it when you are building advanced color tooling that needs to switch between standard RGBA editing and perceptual OkHCL editing.
Use [`useColorModel()`](../composables/use-color-model) for framework-agnostic conversion, channel
editing, and slider presentation. Use `useOkHCL()` only where those edits need to be persisted to an
OpenPencil editor.
## Usage
@ -15,24 +19,43 @@ Use it when you are building advanced color tooling that needs to switch between
import { useOkHCL } from '@open-pencil/vue'
const okhcl = useOkHCL()
const color = okhcl.getFillOkHCLColor(node, 0)
okhcl.updateFillOkHCL(node, 0, { c: 0.2 })
```
## Format state
```ts
const format = okhcl.getFieldFormat(node, 0, 'fill')
okhcl.setFillFieldFormat(node, 0, 'okhcl')
```
Selecting `okhcl` initializes intent from the fill or stroke's current RGBA color. The returned
`fieldOptions` can be used to build a format selector.
## Preview information
```ts
const preview = okhcl.getFillPreviewInfo(node, 0)
// { previewColorSpace, clipped }
```
Preview information reflects the document render color space and whether the stored OkHCL intent
needed gamut mapping.
## Returns
- `getFillColorModel()`
- `getStrokeColorModel()`
- `getFillOkHCLColor()`
- `getStrokeOkHCLColor()`
- `enableFillOkHCL()`
- `disableFillOkHCL()`
- `enableStrokeOkHCL()`
- `disableStrokeOkHCL()`
- `updateFillOkHCL()`
- `updateStrokeOkHCL()`
- `modelOptions`
- `getFillOkHCLColor()` / `getStrokeOkHCLColor()`
- `getFillPreviewInfo()` / `getStrokePreviewInfo()`
- `getFieldFormat()`
- `setFillFieldFormat()` / `setStrokeFieldFormat()`
- `updateFillOkHCL()` / `updateStrokeOkHCL()`
- `fieldOptions`
## Related APIs
- [useColorModel](../composables/use-color-model)
- [useFillControls](../composables/use-fill-controls)
- [useStrokeControls](../composables/use-stroke-controls)
- [ColorPickerRoot](../components/color-picker-root)

View file

@ -27,6 +27,7 @@ These are the main composables most `@open-pencil/vue` consumers will use.
- [usePosition](./use-position)
- [useLayout](./use-layout)
- [useAppearance](./use-appearance)
- [useColorModel](./use-color-model)
- [useMask](./use-mask)
- [useTypography](./use-typography)
- [useExport](./use-export)

View file

@ -0,0 +1,96 @@
---
title: useColorModel
description: Build precise reactive color controls across RGB, HSL, HSB, and OkHCL.
---
# useColorModel
`useColorModel()` is the shared color-state and conversion layer for custom color pickers. It accepts
a scene-graph color, exposes Reka-compatible RGB/HSL/HSB values, and keeps optional OkHCL intent
separate from the gamut-mapped preview color.
The composable does not require an editor context and does not mutate the scene graph directly.
Pass callbacks to connect it to your own state or persistence layer.
## Basic usage
```ts
import { ref } from 'vue'
import { useColorModel } from '@open-pencil/vue'
import type { Color } from '@open-pencil/scene-graph'
const color = ref<Color>({ r: 0.25, g: 0.5, b: 0.9, a: 1 })
const model = useColorModel({
color,
onUpdate: (nextColor) => {
color.value = nextColor
},
})
model.updateHSLChannel('s', 72)
model.updateAlpha(0.8)
```
The returned `hex`, `rekaColor`, `rgb`, `hsl`, and `hsb` values are computed refs. Use
`updateHex()` for hex input; it preserves the current alpha. RGB conversion keeps fractional
channel precision rather than rounding through 8-bit values.
## Format state
The built-in format identifiers are `hex`, `rgb`, `hsl`, `hsb`, and `okhcl`. The type remains
extensible so applications can add another presentation format without changing the model.
```ts
const model = useColorModel({
color,
defaultFormat: 'hsl',
onFormatChange: (format) => savePreferredFormat(format),
})
model.setFormat('okhcl')
```
Pass `format` for controlled state. Without it, `setFormat()` updates local reactive state.
## OkHCL intent
A scene-graph color contains a renderable RGBA value, but it cannot retain an out-of-gamut OkHCL
source by itself. Pass the source intent separately and persist patches through `onUpdateOkHCL`:
```ts
const model = useColorModel({
color,
okhcl: () => storedOkhcl.value,
onUpdateOkHCL: (patch) => {
storedOkhcl.value = { ...storedOkhcl.value, ...patch }
},
})
model.updateOkHCLChannel('c', 0.24)
```
When no OkHCL callback is supplied, OkHCL edits emit a gamut-mapped scene color through
`onUpdate`. Conversion and gamut handling reuse OpenPencil's culori-backed core color APIs.
## Slider presentation
`sliderPreview`, `sliderGradient`, `okhclSliderPreview`, and `okhclSliderGradient` provide the
colors and CSS gradients needed by slider skins. They contain presentation data only; keyboard,
pointer, and ARIA behavior belongs to the slider primitive.
## Update behavior
- RGB values use the `0255` presentation range.
- HSL and HSB saturation/lightness/brightness use `0100`.
- OkHCL lightness and alpha use `01`; chroma is non-negative; hue wraps into `0360`.
- Alpha is preserved across color-space conversions.
- No-op updates do not call mutation callbacks.
- Editing hue on an achromatic color creates a visible color rather than remaining neutral.
## Related APIs
- [ColorPickerRoot](../components/color-picker-root)
- [ColorInputRoot](../components/color-input-root)
- [useFillControls](./use-fill-controls)
- [useStrokeControls](./use-stroke-controls)

View file

@ -42,6 +42,7 @@ These contain structural/headless primitives and local helpers.
- `usePosition`
- `useLayout`
- `useAppearance`
- `useColorModel`
- `useTypography`
- `useExport`
- `useFillControls`

View file

@ -102,7 +102,8 @@ dimension can switch that axis to Fixed inside the same provider transaction. `A
exposes selection-derived independent-corner presentation state so consumers do not need parallel
expansion heuristics. `PropertyListRoot` is controlled and
editor-agnostic; OpenPencil panels connect it to selection and undo through
`useEditorPropertyList()`.
`useEditorPropertyList()`. `useColorModel()` provides precise scene-color/Reka bridges, reactive
RGB/HSL/HSB/OkHCL channels, extensible format state, and shared slider presentation data.
## Public API tiers
@ -130,6 +131,7 @@ These are the main APIs most SDK consumers should start with.
- `usePosition()`
- `useLayout()`
- `useAppearance()`
- `useColorModel()`
- `useMask()`
- `useTypography()`
- `useExport()`

View file

@ -0,0 +1,14 @@
export { BUILT_IN_COLOR_FORMATS, useColorModel } from '#vue/controls/color-model/use'
export {
applySolidFillColor,
applySolidStrokeColor,
fromPercent,
toPercent
} from '#vue/controls/color-model/model'
export type {
BuiltInColorFormat,
ColorFieldFormat,
ColorFieldOption,
OkHCLControls,
UseColorModelOptions
} from '#vue/controls/color-model/types'

View file

@ -0,0 +1,214 @@
import { convertToHsb, convertToHsl, convertToRgb } from 'reka-ui'
import type { Color as RekaColor, HSBColor, HSLColor, RGBColor } from 'reka-ui'
import { colorToCSS, okhclToRGBA, rgba255ToColor, rgbaToOkHCL } from '@open-pencil/core/color'
import type { OkHCLColor } from '@open-pencil/core/color'
import type { Fill, Stroke } from '@open-pencil/scene-graph'
import type { Color } from '@open-pencil/scene-graph/primitives'
import type {
HSBChannel,
HSLChannel,
OkHCLChannel,
OkHCLSliderGradientModel,
OkHCLSliderPreviewModel,
RGBChannel,
SliderGradientModel,
SliderPreviewModel
} from '#vue/controls/color-model/types'
export interface ColorModelValue {
rekaColor: RekaColor
rgb: RGBColor
hsl: HSLColor
hsb: HSBColor
okhcl: OkHCLColor
}
const OKHCL_CHROMA_MAX = 0.4
const OKHCL_LIGHTNESS_MID = 0.5
const OKHCL_HUE_PREVIEW_MIN_CHROMA = 0.15
const OKHCL_HUE_PREVIEW_FALLBACK_LIGHTNESS = 0.7
export function createColorModelValue(color: Color, okhcl?: OkHCLColor | null): ColorModelValue {
const rekaColor = sceneToRekaColor(color)
return {
rekaColor,
rgb: convertToRgb(rekaColor),
hsl: convertToHsl(rekaColor),
hsb: convertToHsb(rekaColor),
okhcl: applyOkHCLPatch(okhcl ?? rgbaToOkHCL(color), {})
}
}
export function sceneToRekaColor(color: Color): RGBColor {
return {
space: 'rgb',
r: color.r * 255,
g: color.g * 255,
b: color.b * 255,
alpha: color.a
}
}
export function rekaToSceneColor(color: RekaColor): Color {
const rgb = convertToRgb(color)
return rgba255ToColor(rgb.r, rgb.g, rgb.b, rgb.alpha)
}
export function withHue(model: ColorModelValue, hue: number): Color {
return rekaToSceneColor({
...model.hsb,
h: normalizeHue(hue),
s: model.hsb.s === 0 ? 100 : model.hsb.s,
b: model.hsb.b === 0 ? 100 : model.hsb.b
})
}
export function withAlpha(color: Color, alpha: number): Color {
return { ...color, a: clampUnit(alpha) }
}
export function withRGBChannel(color: Color, channel: RGBChannel, value255: number): Color {
return { ...color, [channel]: clampUnit(value255 / 255) }
}
export function withHSLChannel(model: ColorModelValue, channel: HSLChannel, value: number): Color {
const next = {
...model.hsl,
[channel]: channel === 'h' ? normalizeHue(value) : clampPercent(value)
}
if (channel === 's' && model.hsl.s === 0 && clampPercent(value) > 0) {
if (model.hsl.l >= 100 || model.hsl.l <= 0) next.l = 50
}
return rekaToSceneColor(next)
}
export function withHSBChannel(model: ColorModelValue, channel: HSBChannel, value: number): Color {
return rekaToSceneColor({
...model.hsb,
[channel]: channel === 'h' ? normalizeHue(value) : clampPercent(value)
})
}
export function normalizeOkHCLPatch(channel: OkHCLChannel, value: number): Partial<OkHCLColor> {
switch (channel) {
case 'h':
return { h: normalizeHue(value) }
case 'c':
return { c: Math.max(0, value) }
case 'l':
return { l: clampUnit(value) }
case 'a':
return { a: clampUnit(value) }
default:
throw new Error('Unsupported OkHCL channel')
}
}
export function applyOkHCLPatch(color: OkHCLColor, patch: Partial<OkHCLColor>): OkHCLColor {
return {
h: normalizeHue(patch.h ?? color.h),
c: Math.max(0, patch.c ?? color.c),
l: clampUnit(patch.l ?? color.l),
a: clampUnit(patch.a ?? color.a ?? 1)
}
}
export function createSliderPreviewModel(model: ColorModelValue): SliderPreviewModel {
return {
hue: rekaToSceneColor({ ...model.hsb, s: 100, b: 100 }),
hslSaturation: rekaToSceneColor(model.hsl),
hslLightness: rekaToSceneColor(model.hsl),
hsbSaturation: rekaToSceneColor(model.hsb),
hsbBrightness: rekaToSceneColor(model.hsb)
}
}
export function createOkHCLSliderPreviewModel(color: OkHCLColor): OkHCLSliderPreviewModel {
return {
okhclHue: okhclToRGBA({
...color,
c: Math.max(color.c, OKHCL_HUE_PREVIEW_MIN_CHROMA),
l: color.l <= 0 || color.l >= 1 ? OKHCL_HUE_PREVIEW_FALLBACK_LIGHTNESS : color.l
}),
okhclChroma: okhclToRGBA(color),
okhclLightness: okhclToRGBA(color)
}
}
export function createSliderGradientModel(model: ColorModelValue): SliderGradientModel {
const hslGray = rekaToSceneColor({ ...model.hsl, s: 0 })
const hslColor = rekaToSceneColor({ ...model.hsl, s: 100 })
const hslBlack = rekaToSceneColor({ ...model.hsl, l: 0 })
const hslMid = rekaToSceneColor({ ...model.hsl, l: 50 })
const hslWhite = rekaToSceneColor({ ...model.hsl, l: 100 })
const hsbGray = rekaToSceneColor({ ...model.hsb, s: 0 })
const hsbColor = rekaToSceneColor({ ...model.hsb, s: 100 })
const hsbBlack = rekaToSceneColor({ ...model.hsb, b: 0 })
const hsbBright = rekaToSceneColor({ ...model.hsb, b: 100 })
return {
hslSaturation: gradient(hslGray, hslColor),
hslLightness: gradient(hslBlack, hslMid, hslWhite),
hsbSaturation: gradient(hsbGray, hsbColor),
hsbBrightness: gradient(hsbBlack, hsbBright)
}
}
export function createOkHCLSliderGradientModel(color: OkHCLColor): OkHCLSliderGradientModel {
return {
okhclChroma: gradient(
okhclToRGBA({ ...color, c: 0 }),
okhclToRGBA({ ...color, c: OKHCL_CHROMA_MAX })
),
okhclLightness: gradient(
okhclToRGBA({ ...color, l: 0 }),
okhclToRGBA({ ...color, l: OKHCL_LIGHTNESS_MID }),
okhclToRGBA({ ...color, l: 1 })
)
}
}
export function colorsEqual(left: Color, right: Color): boolean {
return left.r === right.r && left.g === right.g && left.b === right.b && left.a === right.a
}
export function okhclPatchChangesColor(color: OkHCLColor, patch: Partial<OkHCLColor>): boolean {
return Object.entries(patch).some(([key, value]) => color[key as keyof OkHCLColor] !== value)
}
export function applySolidFillColor(fill: Fill, color: Color): Fill {
return { ...fill, color, opacity: color.a }
}
export function applySolidStrokeColor(color: Color): Partial<Stroke> {
return { color, opacity: color.a }
}
export function toPercent(value: number): number {
return Math.round(value * 100)
}
export function fromPercent(value: number): number {
return clampUnit(value / 100)
}
function gradient(...colors: Color[]): string {
return `background: linear-gradient(to right, ${colors.map(colorToCSS).join(', ')});`
}
function clampUnit(value: number): number {
return Math.max(0, Math.min(1, value))
}
function clampPercent(value: number): number {
return Math.max(0, Math.min(100, value))
}
function normalizeHue(value: number): number {
const hue = value % 360
return hue < 0 ? hue + 360 : hue
}

View file

@ -0,0 +1,66 @@
import type { Color as RekaColor } from 'reka-ui'
import type { MaybeRefOrGetter } from 'vue'
import type { OkHCLColor, RenderColorSpace } from '@open-pencil/core/color'
import type { Color } from '@open-pencil/scene-graph/primitives'
export type BuiltInColorFormat = 'hex' | 'rgb' | 'hsl' | 'hsb' | 'okhcl'
export type ColorFieldFormat = BuiltInColorFormat | (string & {})
export type RGBChannel = 'r' | 'g' | 'b'
export type HSLChannel = 'h' | 's' | 'l'
export type HSBChannel = 'h' | 's' | 'b'
export type OkHCLChannel = 'h' | 'c' | 'l' | 'a'
export interface ColorFieldOption {
value: ColorFieldFormat
label: string
}
export interface OkHCLControls {
fieldFormat: ColorFieldFormat
fieldOptions: ColorFieldOption[]
okhcl: OkHCLColor | null
previewColorSpace?: RenderColorSpace
clipped?: boolean
setFieldFormat: (format: ColorFieldFormat) => void
updateOkHCL: (patch: Partial<OkHCLColor>) => void
}
export interface UseColorModelOptions {
color: MaybeRefOrGetter<Color>
okhcl?: MaybeRefOrGetter<OkHCLColor | null | undefined>
format?: MaybeRefOrGetter<ColorFieldFormat | undefined>
defaultFormat?: ColorFieldFormat
onUpdate?: (color: Color) => void
onUpdateOkHCL?: (patch: Partial<OkHCLColor>) => void
onFormatChange?: (format: ColorFieldFormat) => void
}
export interface SliderPreviewModel {
hue: Color
hslSaturation: Color
hslLightness: Color
hsbSaturation: Color
hsbBrightness: Color
}
export interface OkHCLSliderPreviewModel {
okhclHue: Color
okhclChroma: Color
okhclLightness: Color
}
export interface SliderGradientModel {
hslSaturation: string
hslLightness: string
hsbSaturation: string
hsbBrightness: string
}
export interface OkHCLSliderGradientModel {
okhclChroma: string
okhclLightness: string
}
export type RekaColorValue = RekaColor

View file

@ -0,0 +1,130 @@
import { computed, ref, toValue } from 'vue'
import { colorToHexRaw, okhclToRGBA, parseColor } from '@open-pencil/core/color'
import type { Color } from '@open-pencil/scene-graph/primitives'
import {
applyOkHCLPatch,
colorsEqual,
createColorModelValue,
createOkHCLSliderGradientModel,
createOkHCLSliderPreviewModel,
createSliderGradientModel,
createSliderPreviewModel,
normalizeOkHCLPatch,
okhclPatchChangesColor,
rekaToSceneColor,
withAlpha,
withHSBChannel,
withHSLChannel,
withHue,
withRGBChannel
} from '#vue/controls/color-model/model'
import type {
ColorFieldFormat,
HSBChannel,
HSLChannel,
OkHCLChannel,
RGBChannel,
RekaColorValue,
UseColorModelOptions
} from '#vue/controls/color-model/types'
/** Built-in presentation formats understood by the color model. */
export const BUILT_IN_COLOR_FORMATS = ['hex', 'rgb', 'hsl', 'hsb', 'okhcl'] as const
/**
* Creates reactive color-space values, channel actions, and slider presentation data without
* requiring an editor context.
*/
export function useColorModel(options: UseColorModelOptions) {
const localFormat = ref<ColorFieldFormat>(options.defaultFormat ?? 'rgb')
const color = computed(() => toValue(options.color))
const sourceOkHCL = computed(() => toValue(options.okhcl))
const value = computed(() => createColorModelValue(color.value, sourceOkHCL.value))
const format = computed(() => toValue(options.format) ?? localFormat.value)
const hex = computed(() => colorToHexRaw(color.value))
const sliderPreview = computed(() => createSliderPreviewModel(value.value))
const sliderGradient = computed(() => createSliderGradientModel(value.value))
const okhclSliderPreview = computed(() => createOkHCLSliderPreviewModel(value.value.okhcl))
const okhclSliderGradient = computed(() => createOkHCLSliderGradientModel(value.value.okhcl))
function emitColor(nextColor: Color): Color {
if (!colorsEqual(color.value, nextColor)) options.onUpdate?.(nextColor)
return nextColor
}
function updateHex(input: string) {
const parsed = parseColor(input.startsWith('#') ? input : `#${input}`)
return emitColor({ ...parsed, a: color.value.a })
}
function setFormat(nextFormat: ColorFieldFormat) {
if (format.value === nextFormat) return
localFormat.value = nextFormat
options.onFormatChange?.(nextFormat)
}
function updateFromReka(nextColor: RekaColorValue) {
return emitColor(rekaToSceneColor(nextColor))
}
function updateHue(hue: number) {
return emitColor(withHue(value.value, hue))
}
function updateAlpha(alpha: number) {
return emitColor(withAlpha(color.value, alpha))
}
function updateRGBChannel(channel: RGBChannel, channelValue: number) {
return emitColor(withRGBChannel(color.value, channel, channelValue))
}
function updateHSLChannel(channel: HSLChannel, channelValue: number) {
return emitColor(withHSLChannel(value.value, channel, channelValue))
}
function updateHSBChannel(channel: HSBChannel, channelValue: number) {
return emitColor(withHSBChannel(value.value, channel, channelValue))
}
function updateOkHCLChannel(channel: OkHCLChannel, channelValue: number) {
const patch = normalizeOkHCLPatch(channel, channelValue)
if (!okhclPatchChangesColor(value.value.okhcl, patch)) return value.value.okhcl
if (options.onUpdateOkHCL) {
options.onUpdateOkHCL(patch)
return applyOkHCLPatch(value.value.okhcl, patch)
}
const next = applyOkHCLPatch(value.value.okhcl, patch)
emitColor(okhclToRGBA(next))
return next
}
return {
color,
format,
hex,
rekaColor: computed(() => value.value.rekaColor),
rgb: computed(() => value.value.rgb),
hsl: computed(() => value.value.hsl),
hsb: computed(() => value.value.hsb),
okhcl: computed(() => value.value.okhcl),
sliderPreview,
sliderGradient,
okhclSliderPreview,
okhclSliderGradient,
setFormat,
updateColor: emitColor,
updateHex,
updateFromReka,
updateHue,
updateAlpha,
updateRGBChannel,
updateHSLChannel,
updateHSBChannel,
updateOkHCLChannel
}
}

View file

@ -13,7 +13,7 @@ import { BLACK } from '@open-pencil/core/constants'
import type { Editor } from '@open-pencil/core/editor'
import type { SceneNode } from '@open-pencil/scene-graph'
import type { ColorFieldFormat } from '#vue/primitives/ColorPicker/types'
import type { ColorFieldFormat } from '#vue/controls/color-model/types'
type ColorKind = 'fill' | 'stroke'

View file

@ -1,5 +1,6 @@
import { ref } from 'vue'
import type { ColorFieldFormat } from '#vue/controls/color-model/types'
import {
OKHCL_FIELD_OPTIONS,
createOkHCLActions,
@ -9,7 +10,6 @@ import {
getStrokeOkHCLColor
} from '#vue/controls/okhcl/helpers'
import { useEditor } from '#vue/editor/context'
import type { ColorFieldFormat } from '#vue/primitives/ColorPicker/types'
export function useOkHCL() {
const editor = useEditor()

View file

@ -91,6 +91,21 @@ export type {
} from '#vue/controls/variable-binding/use'
export { useEffectsControls } from '#vue/controls/effects/use'
export { useStrokeControls } from '#vue/controls/stroke/use'
export {
applySolidFillColor,
applySolidStrokeColor,
BUILT_IN_COLOR_FORMATS,
fromPercent,
toPercent,
useColorModel
} from '#vue/controls/color-model'
export type {
BuiltInColorFormat,
ColorFieldFormat,
ColorFieldOption,
OkHCLControls,
UseColorModelOptions
} from '#vue/controls/color-model'
export { useOkHCL } from '#vue/controls/okhcl/use'
/** Variables, page navigation, and picker helpers. */
@ -106,26 +121,7 @@ export { useFontPicker } from '#vue/primitives/FontPicker/useFontPicker'
/** Headless structural primitives and their local contexts. */
export { CanvasRoot, CanvasSurface, useCanvasContext } from '#vue/canvas'
export type { CanvasContext } from '#vue/canvas'
export {
ColorInputRoot,
ColorPickerRoot,
createColorPickerModel,
createOkHCLSliderGradientModel,
createOkHCLSliderPreviewModel,
createSliderGradientModel,
createSliderPreviewModel,
fromPercent,
rekaToAppColor,
toPercent,
updateAlpha,
updateHSBChannel,
updateHSLChannel,
updateHue,
updateRGBChannel,
applySolidFillColor,
applySolidStrokeColor
} from '#vue/primitives/ColorPicker'
export type { ColorFieldFormat, OkHCLControls } from '#vue/primitives/ColorPicker'
export { ColorInputRoot, ColorPickerRoot } from '#vue/primitives/ColorPicker'
export { FillPickerRoot } from '#vue/primitives/FillPicker'
export { FontPickerRoot } from '#vue/primitives/FontPicker'
export type { FontFamilyOption, FontPickerUI } from '#vue/primitives/FontPicker'

View file

@ -1,9 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import { colorToHexRaw, parseColor } from '@open-pencil/core/color'
import { useColorModel } from '#vue/controls/color-model/use'
import type { OkHCLControls } from '#vue/controls/color-model/types'
import type { Color } from '@open-pencil/scene-graph/primitives'
import type { OkHCLControls } from '#vue/primitives/ColorPicker/types'
const {
color,
@ -16,20 +15,23 @@ const {
}>()
const emit = defineEmits<{ update: [color: Color] }>()
const hex = computed(() => colorToHexRaw(color))
function updateFromHex(value: string) {
const parsed = parseColor(value.startsWith('#') ? value : `#${value}`)
emit('update', { ...parsed, a: color.a })
}
const model = useColorModel({
color: () => color,
onUpdate: (nextColor) => emit('update', nextColor)
})
const actions = {
updateFromHex,
updateColor: (nextColor: Color) => emit('update', nextColor)
updateFromHex: model.updateHex,
updateColor: model.updateColor
}
</script>
<template>
<slot :color="color" :editable="editable" :hex="hex" :actions="actions" :okhcl="okhcl" />
<slot
:color="color"
:editable="editable"
:hex="model.hex.value"
:actions="actions"
:okhcl="okhcl"
/>
</template>

View file

@ -1,20 +1,2 @@
export { default as ColorInputRoot } from '#vue/primitives/ColorPicker/ColorInputRoot.vue'
export { default as ColorPickerRoot } from '#vue/primitives/ColorPicker/ColorPickerRoot.vue'
export {
createColorPickerModel,
createOkHCLSliderGradientModel,
createOkHCLSliderPreviewModel,
createSliderGradientModel,
createSliderPreviewModel,
fromPercent,
rekaToAppColor,
toPercent,
updateAlpha,
updateHSBChannel,
updateHSLChannel,
updateHue,
updateRGBChannel,
applySolidFillColor,
applySolidStrokeColor
} from '#vue/primitives/ColorPicker/model'
export type { ColorFieldFormat, OkHCLControls } from '#vue/primitives/ColorPicker/types'

View file

@ -1,239 +0,0 @@
import { convertToHsb, convertToHsl, convertToRgb } from 'reka-ui'
import type { Color as RekaColor, HSBColor, HSLColor, RGBColor } from 'reka-ui'
import { colorToCSS, okhclToRGBA, rgba255ToColor } from '@open-pencil/core/color'
import type { OkHCLColor } from '@open-pencil/core/color'
import type { Fill, Stroke } from '@open-pencil/scene-graph'
import type { Color } from '@open-pencil/scene-graph/primitives'
export interface ColorPickerModel {
rekaColor: RekaColor
rgb: RGBColor
hsl: HSLColor
hsb: HSBColor
}
export interface SliderPreviewModel {
hue: Color
hslSaturation: Color
hslLightness: Color
hsbSaturation: Color
hsbBrightness: Color
}
export interface OkHCLSliderPreviewModel {
okhclHue: Color
okhclChroma: Color
okhclLightness: Color
}
export interface SliderGradientModel {
hslSaturation: string
hslLightness: string
hsbSaturation: string
hsbBrightness: string
}
export interface OkHCLSliderGradientModel {
okhclChroma: string
okhclLightness: string
}
const OKHCL_CHROMA_MAX = 0.4
const OKHCL_LIGHTNESS_MID = 0.5
const OKHCL_HUE_PREVIEW_MIN_CHROMA = 0.15
const OKHCL_HUE_PREVIEW_FALLBACK_LIGHTNESS = 0.7
export function createColorPickerModel(color: Color): ColorPickerModel {
const rekaColor = {
space: 'rgb' as const,
r: Math.round(color.r * 255),
g: Math.round(color.g * 255),
b: Math.round(color.b * 255),
alpha: color.a
}
return {
rekaColor,
rgb: convertToRgb(rekaColor),
hsl: convertToHsl(rekaColor),
hsb: convertToHsb(rekaColor)
}
}
export function rekaToAppColor(color: RekaColor): Color {
const rgb = convertToRgb(color)
return rgba255ToColor(rgb.r, rgb.g, rgb.b, rgb.alpha)
}
export function updateHue(model: ColorPickerModel, hue: number): Color {
const nextSaturation = model.hsb.s === 0 ? 100 : model.hsb.s
const nextBrightness = model.hsb.b === 0 ? 100 : model.hsb.b
return rekaToAppColor({
...model.hsb,
h: hue,
s: nextSaturation,
b: nextBrightness
})
}
export function updateAlpha(color: Color, alpha: number): Color {
return {
...color,
a: clampUnit(alpha)
}
}
export function updateRGBChannel(color: Color, channel: 'r' | 'g' | 'b', value255: number): Color {
return {
...color,
[channel]: clampUnit(value255 / 255)
}
}
export function updateHSLChannel(
model: ColorPickerModel,
channel: 'h' | 's' | 'l',
value: number
): Color {
const next = {
...model.hsl,
[channel]: channel === 'h' ? value : clampPercent(value)
}
if (channel === 's' && model.hsl.s === 0 && clampPercent(value) > 0) {
next.h = model.hsl.h
if (model.hsl.l >= 100) next.l = 50
if (model.hsl.l <= 0) next.l = 50
}
return rekaToAppColor(next)
}
export function updateHSBChannel(
model: ColorPickerModel,
channel: 'h' | 's' | 'b',
value: number
): Color {
return rekaToAppColor({
...model.hsb,
[channel]: channel === 'h' ? value : clampPercent(value)
})
}
export function createSliderPreviewModel(model: ColorPickerModel): SliderPreviewModel {
return {
hue: rekaToAppColor({
...model.hsb,
s: 100,
b: 100
}),
hslSaturation: rekaToAppColor(model.hsl),
hslLightness: rekaToAppColor(model.hsl),
hsbSaturation: rekaToAppColor(model.hsb),
hsbBrightness: rekaToAppColor(model.hsb)
}
}
export function createOkHCLSliderPreviewModel(color: OkHCLColor): OkHCLSliderPreviewModel {
return {
okhclHue: okhclToRGBA({
...color,
c: Math.max(color.c, OKHCL_HUE_PREVIEW_MIN_CHROMA),
l: color.l <= 0 || color.l >= 1 ? OKHCL_HUE_PREVIEW_FALLBACK_LIGHTNESS : color.l
}),
okhclChroma: okhclToRGBA(color),
okhclLightness: okhclToRGBA(color)
}
}
export function createOkHCLSliderGradientModel(color: OkHCLColor): OkHCLSliderGradientModel {
const lowChroma = okhclToRGBA({ ...color, c: 0 })
const highChroma = okhclToRGBA({ ...color, c: OKHCL_CHROMA_MAX })
const lowLightness = okhclToRGBA({ ...color, l: 0 })
const midLightness = okhclToRGBA({ ...color, l: OKHCL_LIGHTNESS_MID })
const highLightness = okhclToRGBA({ ...color, l: 1 })
return {
okhclChroma: `background: linear-gradient(to right, ${colorToCSS(lowChroma)}, ${colorToCSS(highChroma)});`,
okhclLightness: `background: linear-gradient(to right, ${colorToCSS(lowLightness)}, ${colorToCSS(midLightness)}, ${colorToCSS(highLightness)});`
}
}
export function createSliderGradientModel(model: ColorPickerModel): SliderGradientModel {
const hslGray = rekaToAppColor({
...model.hsl,
s: 0
})
const hslColor = rekaToAppColor({
...model.hsl,
s: 100
})
const hslBlack = rekaToAppColor({
...model.hsl,
l: 0
})
const hslMid = rekaToAppColor({
...model.hsl,
l: 50
})
const hslWhite = rekaToAppColor({
...model.hsl,
l: 100
})
const hsbGray = rekaToAppColor({
...model.hsb,
s: 0
})
const hsbColor = rekaToAppColor({
...model.hsb,
s: 100
})
const hsbBlack = rekaToAppColor({
...model.hsb,
b: 0
})
const hsbBright = rekaToAppColor({
...model.hsb,
b: 100
})
return {
hslSaturation: `background: linear-gradient(to right, ${colorToCSS(hslGray)}, ${colorToCSS(hslColor)});`,
hslLightness: `background: linear-gradient(to right, ${colorToCSS(hslBlack)}, ${colorToCSS(hslMid)}, ${colorToCSS(hslWhite)});`,
hsbSaturation: `background: linear-gradient(to right, ${colorToCSS(hsbGray)}, ${colorToCSS(hsbColor)});`,
hsbBrightness: `background: linear-gradient(to right, ${colorToCSS(hsbBlack)}, ${colorToCSS(hsbBright)});`
}
}
export function toPercent(value: number): number {
return Math.round(value * 100)
}
export function fromPercent(value: number): number {
return clampUnit(value / 100)
}
function clampUnit(value: number): number {
return Math.max(0, Math.min(1, value))
}
function clampPercent(value: number): number {
return Math.max(0, Math.min(100, value))
}
export function applySolidFillColor(fill: Fill, color: Color): Fill {
return {
...fill,
color,
opacity: color.a
}
}
export function applySolidStrokeColor(color: Color): Partial<Stroke> {
return {
color,
opacity: color.a
}
}

View file

@ -1,18 +0,0 @@
import type { OkHCLColor, RenderColorSpace } from '@open-pencil/core/color'
export type ColorFieldFormat = 'rgb' | 'hsl' | 'hsb' | 'okhcl'
export interface ColorFieldOption {
value: ColorFieldFormat
label: string
}
export interface OkHCLControls {
fieldFormat: ColorFieldFormat
fieldOptions: ColorFieldOption[]
okhcl: OkHCLColor | null
previewColorSpace?: RenderColorSpace
clipped?: boolean
setFieldFormat: (format: ColorFieldFormat) => void
updateOkHCL: (patch: Partial<OkHCLColor>) => void
}

View file

@ -2,48 +2,28 @@ import { computed, inject, provide, proxyRefs } from 'vue'
import type { InjectionKey, ShallowUnwrapRef } from 'vue'
import type { Color } from '@open-pencil/scene-graph/primitives'
import {
createColorPickerModel,
createOkHCLSliderGradientModel,
createOkHCLSliderPreviewModel,
createSliderGradientModel,
createSliderPreviewModel,
rekaToAppColor,
updateAlpha,
updateHSBChannel,
updateHSLChannel,
updateHue,
updateRGBChannel,
useI18n
} from '@open-pencil/vue'
import type { OkHCLControls } from '@open-pencil/vue'
import { useColorModel, useI18n } from '@open-pencil/vue'
import type { ColorFieldFormat, OkHCLControls } from '@open-pencil/vue'
type ColorPanelProps = {
interface ColorPanelProps {
color: Color
okhcl?: OkHCLControls | null
}
type ColorPanelEmit = (event: 'update', color: Color) => void
type RekaColor = ReturnType<typeof createColorPickerModel>['rekaColor']
function createColorPickerPanelContext(props: ColorPanelProps, emit: ColorPanelEmit) {
const { panels } = useI18n()
const color = computed(() => props.color)
const okhcl = computed(() => props.okhcl ?? null)
const pickerModel = computed(() => createColorPickerModel(color.value))
const rekaColor = computed(() => pickerModel.value.rekaColor)
const hslColor = computed(() => pickerModel.value.hsl)
const hsbColor = computed(() => pickerModel.value.hsb)
const rgbColor = computed(() => pickerModel.value.rgb)
const sliderPreview = computed(() => createSliderPreviewModel(pickerModel.value))
const sliderGradient = computed(() => createSliderGradientModel(pickerModel.value))
const okhclSliderPreview = computed(() =>
okhcl.value?.okhcl ? createOkHCLSliderPreviewModel(okhcl.value.okhcl) : null
)
const okhclSliderGradient = computed(() =>
okhcl.value?.okhcl ? createOkHCLSliderGradientModel(okhcl.value.okhcl) : null
)
const colorModel = useColorModel({
color,
okhcl: () => okhcl.value?.okhcl,
format: () => okhcl.value?.fieldFormat,
onUpdate: (nextColor) => emit('update', nextColor),
onUpdateOkHCL: (patch) => okhcl.value?.updateOkHCL(patch),
onFormatChange: (format) => okhcl.value?.setFieldFormat(format)
})
const fieldOptions = computed(
() =>
okhcl.value?.fieldOptions ?? [
@ -52,69 +32,35 @@ function createColorPickerPanelContext(props: ColorPanelProps, emit: ColorPanelE
{ value: 'hsb', label: panels.value.colorFormatHsb }
]
)
const fieldFormat = computed(() => okhcl.value?.fieldFormat ?? 'rgb')
const isOkHCLFormat = computed(() => fieldFormat.value === 'okhcl' && okhcl.value)
function updateColor(nextColor: Color) {
emit('update', nextColor)
}
function onRekaColorUpdate(colorValue: RekaColor) {
updateColor(rekaToAppColor(colorValue))
}
const isOkHCLFormat = computed(() => colorModel.format.value === 'okhcl' && okhcl.value)
function setFieldFormat(value: string) {
okhcl.value?.setFieldFormat(value as NonNullable<OkHCLControls>['fieldFormat'])
}
function updateRGBAHue(value: number) {
updateColor(updateHue(pickerModel.value, value))
}
function updateRGBAAlpha(value: number) {
updateColor(updateAlpha(color.value, value))
}
function updateRGBChannelValue(channel: 'r' | 'g' | 'b', value: number) {
updateColor(updateRGBChannel(color.value, channel, value))
}
function updateHSLChannelValue(channel: 'h' | 's' | 'l', value: number) {
updateColor(updateHSLChannel(pickerModel.value, channel, value))
}
function updateHSBChannelValue(channel: 'h' | 's' | 'b', value: number) {
updateColor(updateHSBChannel(pickerModel.value, channel, value))
}
function updateOkHCLChannel(channel: 'h' | 'c' | 'l' | 'a', value: number) {
okhcl.value?.updateOkHCL({ [channel]: value })
colorModel.setFormat(value as ColorFieldFormat)
}
return {
panels,
color,
okhcl,
pickerModel,
rekaColor,
hslColor,
hsbColor,
rgbColor,
sliderPreview,
sliderGradient,
okhclSliderPreview,
okhclSliderGradient,
rekaColor: colorModel.rekaColor,
hslColor: colorModel.hsl,
hsbColor: colorModel.hsb,
rgbColor: colorModel.rgb,
sliderPreview: colorModel.sliderPreview,
sliderGradient: colorModel.sliderGradient,
okhclSliderPreview: colorModel.okhclSliderPreview,
okhclSliderGradient: colorModel.okhclSliderGradient,
fieldOptions,
fieldFormat,
fieldFormat: colorModel.format,
isOkHCLFormat,
onRekaColorUpdate,
onRekaColorUpdate: colorModel.updateFromReka,
setFieldFormat,
updateRGBAHue,
updateRGBAAlpha,
updateRGBChannelValue,
updateHSLChannelValue,
updateHSBChannelValue,
updateOkHCLChannel
updateRGBAHue: colorModel.updateHue,
updateRGBAAlpha: colorModel.updateAlpha,
updateRGBChannelValue: colorModel.updateRGBChannel,
updateHSLChannelValue: colorModel.updateHSLChannel,
updateHSBChannelValue: colorModel.updateHSBChannel,
updateOkHCLChannel: colorModel.updateOkHCLChannel
}
}

View file

@ -1,5 +1,7 @@
import { expect, test, type Page } from '@playwright/test'
import type { OkHCLPayload } from '@open-pencil/core/color'
import { CanvasHelper } from '#tests/helpers/canvas'
let page: Page
@ -29,6 +31,22 @@ async function getSelectedFill() {
})
}
async function getSelectedFillOkHCL() {
return page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = [...store.state.selectedIds][0]
if (!id) return null
const node = store.graph.getNode(id)
const entry = node?.pluginData.find(
(value) => value.pluginId === 'open-pencil' && value.key === 'okhcl'
)
if (!entry) return null
const payload = JSON.parse(entry.value) as Partial<OkHCLPayload>
return payload.kind === 'fill' && payload.index === 0 ? (payload.color ?? null) : null
})
}
async function openFillPicker() {
const solidTab = page.getByTestId('fill-picker-tab-solid')
if (await solidTab.isVisible().catch(() => false)) return
@ -138,3 +156,19 @@ test('hsb saturation and brightness sliders both affect fill color', async () =>
beforeB?.color.b !== afterB?.color.b
).toBe(true)
})
test('okhcl channels preserve intent metadata while updating the fill', async () => {
await openFillPicker()
await chooseFormat('OkHCL')
await dragSlider('color-slider-okhcl-c', 0.6)
const afterChroma = await getSelectedFill()
const chromaIntent = await getSelectedFillOkHCL()
expect(afterChroma).not.toBeNull()
expect(chromaIntent?.c).toBeGreaterThan(0)
await dragSlider('color-slider-okhcl-l', 0.75)
const lightnessIntent = await getSelectedFillOkHCL()
expect(lightnessIntent?.l).toBeCloseTo(0.75, 1)
expect(lightnessIntent?.c).toBeCloseTo(chromaIntent?.c ?? 0, 3)
})

View file

@ -0,0 +1,158 @@
import { describe, expect, test } from 'bun:test'
import { ref } from 'vue'
import type { Color, OkHCLColor } from '@open-pencil/core'
import type { ColorFieldFormat } from '@open-pencil/vue'
import { BUILT_IN_COLOR_FORMATS, fromPercent, toPercent, useColorModel } from '@open-pencil/vue'
function expectColorClose(actual: Color, expected: Color, precision = 5) {
expect(actual.r).toBeCloseTo(expected.r, precision)
expect(actual.g).toBeCloseTo(expected.g, precision)
expect(actual.b).toBeCloseTo(expected.b, precision)
expect(actual.a).toBeCloseTo(expected.a, precision)
}
describe('useColorModel', () => {
const base: Color = { r: 0.4, g: 0.2, b: 0.6, a: 0.75 }
test('bridges scene and Reka RGB without quantizing channels', () => {
const updates: Color[] = []
const model = useColorModel({ color: base, onUpdate: (color) => updates.push(color) })
expect(model.rekaColor.value).toEqual({
space: 'rgb',
r: 102,
g: 51,
b: 153,
alpha: 0.75
})
const next = model.updateFromReka(model.rekaColor.value)
expectColorClose(next, base)
expect(updates).toHaveLength(0)
})
test('preserves fractional RGB precision through the Reka bridge', () => {
const precise: Color = { r: 0.1234, g: 0.5678, b: 0.9012, a: 0.3456 }
const model = useColorModel({ color: precise })
expectColorClose(model.updateFromReka(model.rekaColor.value), precise, 8)
})
test('updates hue from grayscale colors without staying neutral', () => {
const grayscale: Color = { r: 1, g: 1, b: 1, a: 1 }
const model = useColorModel({ color: grayscale })
const updated = model.updateHue(220)
expect(updated.r === updated.g && updated.g === updated.b).toBe(false)
expect(updated.a).toBe(1)
})
test('formats and updates hex while preserving alpha', () => {
const updates: Color[] = []
const model = useColorModel({ color: base, onUpdate: (color) => updates.push(color) })
expect(model.hex.value).toBe('663399')
const updated = model.updateHex('FF8040')
expect(updated).toEqual({ r: 1, g: 128 / 255, b: 64 / 255, a: base.a })
expect(updates).toEqual([updated])
})
test('normalizes hue and clamps alpha and RGB channels', () => {
const model = useColorModel({ color: base })
expect(model.updateHue(-20)).toBeDefined()
expect(model.updateAlpha(2).a).toBe(1)
expect(model.updateRGBChannel('r', -20).r).toBe(0)
expect(model.updateRGBChannel('b', 300).b).toBe(1)
})
test('updates HSL saturation from white without staying neutral', () => {
const white: Color = { r: 1, g: 1, b: 1, a: 1 }
const model = useColorModel({ color: white })
const updated = model.updateHSLChannel('s', 40)
expect(updated.r === updated.g && updated.g === updated.b).toBe(false)
})
test('updates HSL and HSB channels while preserving alpha', () => {
const model = useColorModel({ color: base })
expect(model.updateHSLChannel('l', 12.3).a).toBeCloseTo(base.a, 5)
expect(model.updateHSBChannel('b', 45.6).a).toBeCloseTo(base.a, 5)
})
test('uses supplied OkHCL intent and emits normalized patches', () => {
const okhcl: OkHCLColor = { h: 250, c: 0.18, l: 0.62, a: 0.75 }
const patches: Partial<OkHCLColor>[] = []
const model = useColorModel({
color: base,
okhcl,
onUpdateOkHCL: (patch) => {
patches.push(patch)
}
})
expect(model.okhcl.value).toEqual(okhcl)
expect(model.updateOkHCLChannel('h', 370).h).toBe(10)
expect(model.updateOkHCLChannel('c', -1).c).toBe(0)
expect(model.updateOkHCLChannel('l', 2).l).toBe(1)
expect(model.updateOkHCLChannel('a', -1).a).toBe(0)
expect(patches).toEqual([{ h: 10 }, { c: 0 }, { l: 1 }, { a: 0 }])
})
test('updates scene color when an OkHCL adapter is absent', () => {
const updates: Color[] = []
const model = useColorModel({ color: base, onUpdate: (color) => updates.push(color) })
model.updateOkHCLChannel('h', model.okhcl.value.h + 20)
expect(updates).toHaveLength(1)
expect(updates[0].a).toBeCloseTo(base.a, 5)
})
test('does not emit no-op color or OkHCL updates', () => {
const updates: Color[] = []
const patches: Partial<OkHCLColor>[] = []
const okhcl: OkHCLColor = { h: 250, c: 0.18, l: 0.62, a: 0.75 }
const model = useColorModel({
color: base,
okhcl,
onUpdate: (color) => updates.push(color),
onUpdateOkHCL: (patch) => {
patches.push(patch)
}
})
model.updateAlpha(base.a)
model.updateOkHCLChannel('c', okhcl.c)
expect(updates).toHaveLength(0)
expect(patches).toHaveLength(0)
})
test('supports local and controlled format state', () => {
const local = useColorModel({ color: base, defaultFormat: 'hsl' })
expect(local.format.value).toBe('hsl')
local.setFormat('okhcl')
expect(local.format.value).toBe('okhcl')
const controlledFormat = ref<ColorFieldFormat>('rgb')
const changes: string[] = []
const controlled = useColorModel({
color: base,
format: controlledFormat,
onFormatChange: (format) => changes.push(format)
})
controlled.setFormat('hsb')
expect(controlled.format.value).toBe('rgb')
expect(changes).toEqual(['hsb'])
controlledFormat.value = 'hsb'
expect(controlled.format.value).toBe('hsb')
})
test('exposes built-in formats and culori-backed gradient models', () => {
const model = useColorModel({ color: base })
expect(BUILT_IN_COLOR_FORMATS).toEqual(['hex', 'rgb', 'hsl', 'hsb', 'okhcl'])
expect(model.sliderGradient.value.hslLightness).toContain('linear-gradient')
expect(model.sliderGradient.value.hsbBrightness).toContain('linear-gradient')
expect(model.okhclSliderGradient.value.okhclChroma).toContain('linear-gradient')
expect(model.okhclSliderPreview.value.okhclHue.a).toBeCloseTo(base.a, 5)
})
test('percent helpers roundtrip safely', () => {
expect(fromPercent(toPercent(0.347))).toBeCloseTo(0.35, 2)
})
})

View file

@ -1,66 +0,0 @@
import { describe, expect, test } from 'bun:test'
import type { Color } from '@open-pencil/core'
import {
createColorPickerModel,
fromPercent,
toPercent,
updateAlpha,
updateHSBChannel,
updateHSLChannel,
updateHue,
updateRGBChannel
} from '@open-pencil/vue'
describe('color picker model', () => {
const base: Color = { r: 0.4, g: 0.2, b: 0.6, a: 0.75 }
test('updates hue from the shared slider', () => {
const model = createColorPickerModel(base)
const updated = updateHue(model, 180)
expect(updated).not.toEqual(base)
expect(updated.a).toBeCloseTo(base.a, 5)
})
test('updates hue from grayscale colors without staying neutral', () => {
const grayscale: Color = { r: 1, g: 1, b: 1, a: 1 }
const updated = updateHue(createColorPickerModel(grayscale), 220)
expect(updated.r === updated.g && updated.g === updated.b).toBe(false)
})
test('updates alpha independently', () => {
const updated = updateAlpha(base, 0.25)
expect(updated.a).toBeCloseTo(0.25, 5)
expect(updated.r).toBeCloseTo(base.r, 5)
})
test('updates rgb channels in 0-255 space', () => {
const updated = updateRGBChannel(base, 'r', 255)
expect(updated.r).toBeCloseTo(1, 5)
expect(updated.g).toBeCloseTo(base.g, 5)
})
test('updates hsl normalized channels smoothly', () => {
const model = createColorPickerModel(base)
const updated = updateHSLChannel(model, 's', 12.3)
expect(updated.a).toBeCloseTo(base.a, 5)
expect(updated).not.toEqual(base)
})
test('updates hsl saturation from grayscale colors without staying neutral', () => {
const grayscale: Color = { r: 1, g: 1, b: 1, a: 1 }
const updated = updateHSLChannel(createColorPickerModel(grayscale), 's', 40)
expect(updated.r === updated.g && updated.g === updated.b).toBe(false)
})
test('updates hsb normalized channels smoothly', () => {
const model = createColorPickerModel(base)
const updated = updateHSBChannel(model, 'b', 45.6)
expect(updated.a).toBeCloseTo(base.a, 5)
expect(updated).not.toEqual(base)
})
test('percent helpers roundtrip safely', () => {
expect(fromPercent(toPercent(0.347))).toBeCloseTo(0.35, 2)
})
})