From 031fc2cb3e1ceb3f86943cc7324ebd91e0854b71 Mon Sep 17 00:00:00 2001 From: Pavel Grinchenko Date: Sun, 17 Aug 2025 15:07:21 +0100 Subject: [PATCH 1/9] Allow passing either number | string to numbers values in hue & level stores, and convert them into brand types by internal parsing with schemas --- .../src/components/Grid/GridCellHueHeader.tsx | 8 +++----- .../components/Grid/GridCellLevelHeader.tsx | 20 ++++--------------- packages/core/src/schemas/color.ts | 18 ++++++++++++----- packages/core/src/stores/colors.ts | 15 +++++--------- packages/core/src/stores/utils.ts | 12 +++++------ .../src/utils/stores/isValidationStore.ts | 2 +- packages/core/src/utils/stores/types.ts | 4 ++-- .../core/src/utils/stores/validationStore.ts | 14 +++++++------ 8 files changed, 42 insertions(+), 51 deletions(-) diff --git a/packages/core/src/components/Grid/GridCellHueHeader.tsx b/packages/core/src/components/Grid/GridCellHueHeader.tsx index 6efc6595..173e8493 100644 --- a/packages/core/src/components/Grid/GridCellHueHeader.tsx +++ b/packages/core/src/components/Grid/GridCellHueHeader.tsx @@ -18,7 +18,7 @@ import { updateHueName, } from "@core/stores/colors"; import { $bgColorModeLeft } from "@core/stores/settings"; -import { HueAngle, HueName, type HueId } from "@core/types"; +import type { HueId } from "@core/types"; import type { AnyProps } from "@core/utils/react/types"; import { DATA_ATTR_CELL_HUE_ID } from "./constants"; @@ -101,7 +101,7 @@ const NameInput = memo(function NameInput({ hueId }: HueComponentProps) { /> ) } - onChange={(e) => updateHueName(hueId, HueName(e.target.value))} + onChange={(e) => updateHueName(hueId, e.target.value)} /> ); }); @@ -125,9 +125,7 @@ const AngleInput = memo(function AngleInput({ hueId }: HueComponentProps) { value={angle} title={HINT_DEGREE} error={error} - onChange={(e) => - updateHueAngle(hueId, HueAngle(e.target.value ? Number.parseFloat(e.target.value) : 0)) - } + onChange={(e) => updateHueAngle(hueId, e.target.value)} /> ); }); diff --git a/packages/core/src/components/Grid/GridCellLevelHeader.tsx b/packages/core/src/components/Grid/GridCellLevelHeader.tsx index af7cbec2..bdc4a8ed 100644 --- a/packages/core/src/components/Grid/GridCellLevelHeader.tsx +++ b/packages/core/src/components/Grid/GridCellLevelHeader.tsx @@ -29,7 +29,7 @@ import { contrastModelStore, directionModeStore, } from "@core/stores/settings"; -import { LevelChroma, LevelContrast, LevelName, type LevelId } from "@core/types"; +import type { LevelId } from "@core/types"; import { formatOklch } from "@core/utils/colors/formatOklch"; import type { AnyProps } from "@core/utils/react/types"; @@ -101,7 +101,7 @@ const NameInput = memo(function NameInput({ levelId }: LevelComponentProps) { value={name} title={HINT_LEVEL} error={error} - onChange={(e) => updateLevelName(levelId, LevelName(e.target.value))} + onChange={(e) => updateLevelName(levelId, e.target.value)} /> ); }); @@ -148,13 +148,7 @@ const ContrastInput = memo(function ContrastInput({ value={contrast} title={directionMode === "fgToBg" ? HINT_FG_TO_BG_CONTRAST : HINT_BG_TO_FG_CONTRAST} error={error} - onChange={(e) => { - let newValue = Number.parseFloat(e.target.value); - if (Number.isNaN(newValue)) { - newValue = 0; - } - updateLevelContrast(levelId, LevelContrast(newValue)); - }} + onChange={(e) => updateLevelContrast(levelId, e.target.value)} /> ); }); @@ -183,13 +177,7 @@ const ChromaInput = memo(function ChromaInput({ levelId }: LevelComponentProps) title={HINT_CHROMA} error={error} disabled - onChange={(e) => { - let newValue = Number.parseFloat(e.target.value); - if (Number.isNaN(newValue)) { - newValue = 0; - } - updateLevelChroma(levelId, LevelChroma(newValue)); - }} + onChange={(e) => updateLevelChroma(levelId, e.target.value)} /> ); }); diff --git a/packages/core/src/schemas/color.ts b/packages/core/src/schemas/color.ts index 853990a6..34925176 100644 --- a/packages/core/src/schemas/color.ts +++ b/packages/core/src/schemas/color.ts @@ -16,6 +16,14 @@ import { LightnessLevel, } from "./brand"; +const numberOrStringInputSchema = v.union([ + v.number(), + v.pipe( + v.string(), + v.transform((v) => Number.parseFloat(v)), + ), +]); + export const levelIndexSchema = v.pipe(v.number(), v.transform(LevelIndex)); export const levelNameSchema = v.pipe( v.string(), @@ -33,15 +41,15 @@ export function getContrastMaxLevel(contrastModel: ContrastModel) { export function getContrastStep(contrastModel: ContrastModel) { return contrastModel === "apca" ? 1 : 0.1; } -export const baseContrastSchema = v.pipe(v.number(), v.transform(LevelContrast)); +export const baseContrastSchema = v.pipe(numberOrStringInputSchema, v.transform(LevelContrast)); export const levelApcaContrastSchema = v.pipe( - v.number(), + numberOrStringInputSchema, v.minValue(CONTRAST_MIN), v.maxValue(CONTRAST_APCA_MAX), v.transform(LevelContrast), ); export const levelWcagContrastSchema = v.pipe( - v.number(), + numberOrStringInputSchema, v.minValue(CONTRAST_MIN), v.maxValue(CONTRAST_WCAG_MAX), v.transform(LevelContrast), @@ -61,7 +69,7 @@ export const getLevelContrastModel = (contrastModel: ContrastModel) => { }; export const levelChromaSchema = v.pipe( - v.number(), + numberOrStringInputSchema, v.minValue(0), v.maxValue(0.38), v.transform(LevelChroma), @@ -84,7 +92,7 @@ export const lightnessLevelSchema = v.pipe( export const HUE_MIN_ANGLE = 0; export const HUE_MAX_ANGLE = 360; export const hueAngleSchema = v.pipe( - v.number(), + numberOrStringInputSchema, v.minValue(HUE_MIN_ANGLE), v.maxValue(HUE_MAX_ANGLE), v.transform(HueAngle), diff --git a/packages/core/src/stores/colors.ts b/packages/core/src/stores/colors.ts index f122a8ed..4b055b65 100644 --- a/packages/core/src/stores/colors.ts +++ b/packages/core/src/stores/colors.ts @@ -6,16 +6,11 @@ import { BgRightStart, type ColorCellData, type ColorIdentifier, - type HueAngle, type HueId, HueIndex, - type HueName, type LchColor, - type LevelChroma, - type LevelContrast, type LevelId, LevelIndex, - type LevelName, } from "@core/types"; import { assertUnreachable } from "@core/utils/assertions/assertUnreachable"; import { invariant } from "@core/utils/assertions/invariant"; @@ -245,16 +240,16 @@ export function removeLevel(levelId: LevelId) { }); } -export function updateLevelName(id: LevelId, name: LevelName) { +export function updateLevelName(id: LevelId, name: string) { getLevel(id).name.$raw.set(name); } -export function updateLevelContrast(id: LevelId, contrast: LevelContrast) { +export function updateLevelContrast(id: LevelId, contrast: string | number) { getLevel(id).contrast.$raw.set(contrast); requestColorsRecalculation([id]); } -export function updateLevelChroma(id: LevelId, chroma: LevelChroma) { +export function updateLevelChroma(id: LevelId, chroma: string | number) { getLevel(id).chroma.$raw.set(chroma); requestColorsRecalculation([id]); } @@ -285,7 +280,7 @@ export function removeHue(hueId: HueId) { requestColorsRecalculation(); } -export function updateHueName(id: HueId, name: HueName) { +export function updateHueName(id: HueId, name: string) { getHue(id).name.$raw.set(name); } @@ -296,7 +291,7 @@ export function resetHueName(id: HueId) { hue.name.$raw.set(closestColorName); } -export function updateHueAngle(id: HueId, angle: HueAngle) { +export function updateHueAngle(id: HueId, angle: string | number) { getHue(id).angle.$raw.set(angle); requestColorsRecalculation(); } diff --git a/packages/core/src/stores/utils.ts b/packages/core/src/stores/utils.ts index b4dfcbb0..241ab5a6 100644 --- a/packages/core/src/stores/utils.ts +++ b/packages/core/src/stores/utils.ts @@ -208,7 +208,7 @@ export function cleanupColors( export function getNameValidationSchemaSignal< Id extends AnyId, Name extends string, - Item extends { id: Id; name: ValidationStore }, + Item extends { id: Id; name: ValidationStore }, >(id: Id, nameSchema: BaseSchema>, store: IndexedStore) { return signal((get) => v.pipe( @@ -228,9 +228,9 @@ export function getNameValidationSchemaSignal< export type LevelStore = { id: LevelId; - name: ValidationStore; - contrast: ValidationStore; - chroma: ValidationStore; + name: ValidationStore; + contrast: ValidationStore; + chroma: ValidationStore; $tintColor: WritableSignal; }; @@ -261,8 +261,8 @@ export function getLevelStore(data: PartialOptional) { export type HueStore = { id: HueId; - name: ValidationStore; - angle: ValidationStore; + name: ValidationStore; + angle: ValidationStore; $tintColor: WritableSignal; $closestColorName: Signal; }; diff --git a/packages/core/src/utils/stores/isValidationStore.ts b/packages/core/src/utils/stores/isValidationStore.ts index 9d54df8a..0f6c9850 100644 --- a/packages/core/src/utils/stores/isValidationStore.ts +++ b/packages/core/src/utils/stores/isValidationStore.ts @@ -1,6 +1,6 @@ import type { ValidationStore } from "./validationStore"; -export function isValidationStore(store: unknown): store is ValidationStore { +export function isValidationStore(store: unknown): store is ValidationStore { if (typeof store !== "object" || store === null) { return false; } diff --git a/packages/core/src/utils/stores/types.ts b/packages/core/src/utils/stores/types.ts index 0ee2ecb0..5f91ba69 100644 --- a/packages/core/src/utils/stores/types.ts +++ b/packages/core/src/utils/stores/types.ts @@ -14,7 +14,7 @@ export type StoreReactivePaths = { ? Key extends SignalKey ? SK : never - : T[Key] extends ValidationStore + : T[Key] extends ValidationStore ? Key : never; }[keyof T] & @@ -23,7 +23,7 @@ export type StoreReactivePaths = { export type StoreReactiveValue = T[Key] extends Signal ? Value - : T[Key] extends ValidationStore + : T[Key] extends ValidationStore ? Value : never; diff --git a/packages/core/src/utils/stores/validationStore.ts b/packages/core/src/utils/stores/validationStore.ts index 6c500954..d437e180 100644 --- a/packages/core/src/utils/stores/validationStore.ts +++ b/packages/core/src/utils/stores/validationStore.ts @@ -4,13 +4,13 @@ import * as v from "valibot"; import { toSignal } from "@core/utils/spred/toSignal"; -export type ValidationStore = { - $raw: WritableSignal; +export type ValidationStore = { + $raw: WritableSignal; $lastValidValue: Signal; $validationError: Signal; }; -export function isValidationStore(store: unknown): store is ValidationStore { +export function isValidationStore(store: unknown): store is ValidationStore { if (typeof store !== "object" || store === null) { return false; } @@ -20,14 +20,16 @@ export function isValidationStore(store: unknown): store is ValidationStore( // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - initialValue: Exclude, + initialValue: Exclude, validationSchema: | BaseSchema> | Signal>>, -): ValidationStore { +): ValidationStore { const $raw = signal(initialValue); - const $lastValidValue = signal(initialValue); const $validationSchema = toSignal(validationSchema); + const initialValueValue = v.safeParse($validationSchema.value, initialValue); + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + const $lastValidValue = signal(initialValueValue.success as Exclude); const $validationError = signal(null); effect((get) => { From 9ac8125bea7bd3182b10449f6d5fbfc7e25c01dd Mon Sep 17 00:00:00 2001 From: Pavel Grinchenko Date: Sun, 17 Aug 2025 17:17:15 +0100 Subject: [PATCH 2/9] Format input's value in withNumericIncrementControls on blur event. Add input validation for 'numeric' & 'decimal' inputMode --- .../withNumericIncrementControls.tsx | 69 ++++++++++++++++--- .../Input/enhancers/withValidation.tsx | 2 +- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/packages/core/src/components/Input/enhancers/withNumericIncrementControls.tsx b/packages/core/src/components/Input/enhancers/withNumericIncrementControls.tsx index d25c60fb..9315ce6a 100644 --- a/packages/core/src/components/Input/enhancers/withNumericIncrementControls.tsx +++ b/packages/core/src/components/Input/enhancers/withNumericIncrementControls.tsx @@ -1,6 +1,7 @@ import { type ChangeEvent, type ComponentType, + type FocusEvent, useCallback, useEffect, useMemo, @@ -16,9 +17,30 @@ type WithNumericIncrementControlsProps = { step?: number; }; -function formatWithFractional(value: string, fractionalLength: number): string { +function formatWithFractional(value: string | number, fractionalLength: number): string { const number = Number(value); - return Number.isNaN(number) || fractionalLength === 0 ? value : number.toFixed(fractionalLength); + return (Number.isNaN(number) ? 0 : number).toFixed(fractionalLength); +} + +const NUMERIC_REGEX = /^\d*$/; +const DECIMAL_REGEX = /^\d*(?:[.,])?\d*$/; + +function replaceDecimalDelimiter(value: string): string { + return value.replace(",", "."); +} + +function isInputValid(inputMode: string | undefined, value: string) { + switch (inputMode) { + case "numeric": { + return NUMERIC_REGEX.test(value); + } + case "decimal": { + return DECIMAL_REGEX.test(value); + } + default: { + return true; + } + } } export function withNumericIncrementControls

( @@ -28,12 +50,11 @@ export function withNumericIncrementControls

( step = 1, value, ...props - }: P & WithNumericIncrementControlsProps) => { - const { onChange } = props; + }: WithNumericIncrementControlsProps & P) => { + const { onChange, onBlur } = props; const inputRef = useRef(null); const labelRef = useRef(null); const fractionalLength = -Math.log10(step); - const formattedValue = formatWithFractional(String(value), fractionalLength); const refCallback = useMemo(() => mergeRefs(inputRef, props.ref), []); const labelRefCallback = useMemo(() => mergeRefs(labelRef, props.labelRef), []); @@ -66,7 +87,7 @@ export function withNumericIncrementControls

( return; } - input.value = Number(newValue.toFixed(fractionalLength)).toString(); + input.value = formatWithFractional(newValue, fractionalLength); if (onChange) { const nativeEvent = new Event("change", { bubbles: true }); @@ -132,15 +153,47 @@ export function withNumericIncrementControls

( return () => label.removeEventListener("wheel", handleWheel); }, [step, updateValue]); + const handleOnChange = useCallback( + (e: ChangeEvent) => { + const input = inputRef.current; + + if (!input) return; + + if (isInputValid(input.inputMode, input.value)) { + e.target.value = replaceDecimalDelimiter(e.target.value); + onChange?.(e); + } + }, + [onChange], + ); + + const handleBlur = useCallback( + (e: FocusEvent) => { + const input = inputRef.current; + + if (!input?.value) return; + + const formattedValue = formatWithFractional(input.value, fractionalLength); + if (input.value !== formattedValue) { + input.value = formattedValue; + } + + onBlur?.(e); + }, + [onBlur], + ); + return ( diff --git a/packages/core/src/components/Input/enhancers/withValidation.tsx b/packages/core/src/components/Input/enhancers/withValidation.tsx index 38bd0f76..1e96dcbe 100644 --- a/packages/core/src/components/Input/enhancers/withValidation.tsx +++ b/packages/core/src/components/Input/enhancers/withValidation.tsx @@ -30,5 +30,5 @@ export function withValidation

(WrappedComponent: Component ); }; - return ValidationInput as React.ComponentType

; + return ValidationInput as ComponentType

; } From 62c291312e72a06e309122a5a8afbfd8a61c7e78 Mon Sep 17 00:00:00 2001 From: Pavel Grinchenko Date: Mon, 18 Aug 2025 00:26:38 +0100 Subject: [PATCH 3/9] Improve select's control alignment a bit --- packages/core/src/components/Select/Select.module.css | 4 ++++ packages/core/src/components/Select/Select.tsx | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/components/Select/Select.module.css b/packages/core/src/components/Select/Select.module.css index fd1a2573..bae6c6ef 100644 --- a/packages/core/src/components/Select/Select.module.css +++ b/packages/core/src/components/Select/Select.module.css @@ -36,6 +36,10 @@ background-color: var(--color-universal-elevation); } +.control { + display: inline-flex; +} + .content { display: flex; align-items: center; diff --git a/packages/core/src/components/Select/Select.tsx b/packages/core/src/components/Select/Select.tsx index 8027e636..840e019f 100644 --- a/packages/core/src/components/Select/Select.tsx +++ b/packages/core/src/components/Select/Select.tsx @@ -55,7 +55,7 @@ export function Select({ ))} -

+
+ )} ); }); diff --git a/packages/core/src/components/Grid/GridCellLevelHeader.tsx b/packages/core/src/components/Grid/GridCellLevelHeader.tsx index bdc4a8ed..39abb7bc 100644 --- a/packages/core/src/components/Grid/GridCellLevelHeader.tsx +++ b/packages/core/src/components/Grid/GridCellLevelHeader.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useRef } from "react"; +import { type CSSProperties, memo, useCallback, useRef } from "react"; import { useSignal, useSubscribe } from "@spred/react"; import clsx from "clsx"; @@ -11,12 +11,18 @@ import { withNumericIncrementControls } from "@core/components/Input/enhancers/w import { withValidation } from "@core/components/Input/enhancers/withValidation"; import { Input } from "@core/components/Input/Input"; import { useAppEvent } from "@core/hooks/useFocusRefOnEvent"; -import { CONTRAST_MIN, getContrastMaxLevel, getContrastStep } from "@core/schemas/color"; +import { + CHROMA_MAX, + CHROMA_MIN, + CONTRAST_MIN, + getContrastMaxLevel, + getContrastStep, +} from "@core/schemas/color"; import { $levelIds, getLevel, insertLevel, - updateLevelChroma, + updateLevelchromaCap, updateLevelContrast, updateLevelName, } from "@core/stores/colors"; @@ -41,10 +47,11 @@ import styles from "./GridCellLevelHeader.module.css"; type LevelComponentProps

= { levelId: LevelId } & P; const LABEL_NAME = "Level name"; +const LABEL_CHROMA_CAP_SET = "Set cap"; +const LABEL_CHROMA_CAP_DEFINED = "Cap"; const LABEL_CHROMA = "Level chroma"; const PLACEHOLDER_LEVEL = "Level"; const PLACEHOLDER_CONTRAST = "CR"; -const PLACEHOLDER_CHROMA = "Chroma"; const HINT_LEVEL = "Color level name"; const HINT_FG_TO_BG_CONTRAST = "Contrast of text color to the background"; @@ -125,7 +132,6 @@ const ContrastInput = memo(function ContrastInput({ return ( { + const $chromaPlaceholder = useSignal((get) => { return get(chromaModeStore.$lastValidValue) === "even" ? get(level.$tintColor).referencedC.toFixed(2) : "max"; }); - const chroma = useSubscribe($chroma); - const error = useSubscribe(level.chroma.$validationError); + const $chromaLabel = useSignal((get) => { + const chromaCap = get(level.chromaCap.$raw); + + return chromaCap ? LABEL_CHROMA_CAP_DEFINED : LABEL_CHROMA_CAP_SET; + }); + + const level = getLevel(levelId); + const chromaCap = useSubscribe(level.chromaCap.$raw); + const chromaLabel = useSubscribe($chromaLabel); + const chromaPlaceholder = useSubscribe($chromaPlaceholder); + const error = useSubscribe(level.chromaCap.$validationError); return ( updateLevelChroma(levelId, e.target.value)} + onChange={(e) => updateLevelchromaCap(levelId, e.target.value)} /> ); }); diff --git a/packages/core/src/components/Grid/GridLeftTopCell.module.css b/packages/core/src/components/Grid/GridLeftTopCell.module.css index 284b9a87..9dbc49ff 100644 --- a/packages/core/src/components/Grid/GridLeftTopCell.module.css +++ b/packages/core/src/components/Grid/GridLeftTopCell.module.css @@ -1,6 +1,6 @@ .cell { composes: gridTopLeftCell from "./Grid.module.css"; - gap: calc(var(--spacing) * 4.5); + gap: calc(var(--spacing) * 3.75); align-items: flex-start; justify-content: flex-start; } @@ -18,7 +18,6 @@ .bottom { translate: 0 -1px; - margin-top: auto; } .contrastModelButton { diff --git a/packages/core/src/components/Grid/GridLeftTopCell.tsx b/packages/core/src/components/Grid/GridLeftTopCell.tsx index d12fac2f..fb20f5f2 100644 --- a/packages/core/src/components/Grid/GridLeftTopCell.tsx +++ b/packages/core/src/components/Grid/GridLeftTopCell.tsx @@ -66,13 +66,6 @@ export const GridLeftTopCell = memo(function GridLeftTopCell() {

- updateChromaMode(parseChromaMode(e.target.value))} + title={LABEL_CHROMA_MODE} + />
); diff --git a/packages/core/src/schemas/color.ts b/packages/core/src/schemas/color.ts index 34925176..107c8cba 100644 --- a/packages/core/src/schemas/color.ts +++ b/packages/core/src/schemas/color.ts @@ -68,12 +68,16 @@ export const getLevelContrastModel = (contrastModel: ContrastModel) => { } }; +export const CHROMA_MIN = 0; +export const CHROMA_MAX = 0.4; + export const levelChromaSchema = v.pipe( numberOrStringInputSchema, - v.minValue(0), - v.maxValue(0.38), + v.minValue(CHROMA_MIN), + v.maxValue(CHROMA_MAX), v.transform(LevelChroma), ); +export const levelChromaCapSchema = v.union([v.null(), levelChromaSchema]); export const hueIdSchema = v.pipe(v.string(), v.transform(HueId)); export const hueIndexSchema = v.pipe(v.number(), v.transform(HueIndex)); export const hueNameSchema = v.pipe( diff --git a/packages/core/src/schemas/exportConfig.ts b/packages/core/src/schemas/exportConfig.ts index 408b30f6..11632791 100644 --- a/packages/core/src/schemas/exportConfig.ts +++ b/packages/core/src/schemas/exportConfig.ts @@ -3,12 +3,14 @@ import * as v from "valibot"; import { formatValidationError, safeParse } from "@core/schemas"; import { ValidationError } from "@core/utils/errors/ValidationError"; +import { LevelChroma } from "./brand"; import { baseContrastSchema, colorStringSchema, getLevelContrastModel, hueAngleSchema, hueNameSchema, + levelChromaCapSchema, levelChromaSchema, levelNameSchema, } from "./color"; @@ -23,7 +25,12 @@ import { export const exportConfigSchema = v.pipe( v.object({ levels: v.array( - v.object({ name: levelNameSchema, contrast: baseContrastSchema, chroma: levelChromaSchema }), + v.object({ + name: levelNameSchema, + contrast: baseContrastSchema, + chroma: levelChromaSchema, + chromaCap: v.optional(levelChromaCapSchema), + }), ), hues: v.array(v.object({ name: hueNameSchema, angle: hueAngleSchema })), settings: v.object({ @@ -64,8 +71,8 @@ export function parseExportConfig(configString: string | Record export const compactExportConfigSchema = v.pipe( v.tuple([ v.pipe( - v.array(v.union([v.string(), v.number()])), - v.description("Level name, contrast and chroma as a plain array"), + v.array(v.union([v.string(), v.number(), v.null()])), + v.description("Level name, contrast and chroma cap as a plain array"), ), v.pipe( v.array(v.union([v.string(), v.number()])), @@ -94,7 +101,11 @@ export function parseCompactExportConfig(value: unknown): CompactExportConfig { export function toCompactExportConfig(config: ExportConfig): CompactExportConfig { return [ - config.levels.flatMap((level) => [level.name, level.contrast, level.chroma]), + config.levels.flatMap((level) => [ + level.name, + level.contrast, + level.chromaCap ?? null, + ]), config.hues.flatMap((hue) => [hue.name, hue.angle]), [ config.settings.contrastModel, @@ -116,9 +127,9 @@ export function toExportConfig(compactConfig: CompactExportConfig): ExportConfig for (let i = 0; i < compactConfig[0].length; i += 3) { const name = v.parse(levelNameSchema, compactConfig[0][i]); const contrast = v.parse(getLevelContrastModel(contrastModel), compactConfig[0][i + 1]); - const chroma = v.parse(levelChromaSchema, compactConfig[0][i + 2]); + const chromaCap = v.parse(levelChromaCapSchema, compactConfig[0][i + 2]); - levels.push({ name: name, contrast, chroma }); + levels.push({ name, contrast, chroma: LevelChroma(0), chromaCap }); } for (let i = 0; i < compactConfig[1].length; i += 2) { diff --git a/packages/core/src/stores/colors.ts b/packages/core/src/stores/colors.ts index 4b055b65..d87e9ff9 100644 --- a/packages/core/src/stores/colors.ts +++ b/packages/core/src/stores/colors.ts @@ -93,6 +93,14 @@ export const $areHuesValid = signal((get) => { }); }); +export const $isAnyChromaCapSet = signal((get) => { + return get($levelIds).some((levelId) => { + const level = getLevel(levelId); + + return get(level.chromaCap.$raw) !== null; + }); +}); + const colorsMap = new Map>(); workerChannel.on("generated:color", handleGeneratedColor); @@ -152,10 +160,15 @@ function collectColorCalculationData(recalcOnlyLevels?: LevelId[]): GenerateColo return { directionMode: directionModeStore.$lastValidValue.value, contrastModel: contrastModelStore.$lastValidValue.value, - levels: $levelIds.value.map((id) => ({ - id, - contrast: getLevel(id).contrast.$lastValidValue.value, - })), + levels: $levelIds.value.map((id) => { + const level = getLevel(id); + + return { + id, + contrast: level.contrast.$lastValidValue.value, + chromaCap: level.chromaCap.$lastValidValue.value, + }; + }), recalcOnlyLevels, hues: $hueIds.value.map((id) => ({ id, angle: getHue(id).angle.$lastValidValue.value })), bgColorRight: bgColorRightStore.$lastValidValue.value, @@ -249,11 +262,21 @@ export function updateLevelContrast(id: LevelId, contrast: string | number) { requestColorsRecalculation([id]); } -export function updateLevelChroma(id: LevelId, chroma: string | number) { - getLevel(id).chroma.$raw.set(chroma); +export function updateLevelchromaCap(id: LevelId, chroma: string | number) { + getLevel(id).chromaCap.$raw.set(chroma); requestColorsRecalculation([id]); } +export function resetAllChroma() { + batch(() => { + for (const levelId of $levelIds.value) { + const level = getLevel(levelId); + level.chromaCap.$raw.set(null); + } + }); + requestColorsRecalculation(); +} + // Hue methods export const insertHue = getInsertMethod({ main: huesStore, diff --git a/packages/core/src/stores/config.ts b/packages/core/src/stores/config.ts index 2ef9059d..79ae5a90 100644 --- a/packages/core/src/stores/config.ts +++ b/packages/core/src/stores/config.ts @@ -49,6 +49,7 @@ export const $exportConfig = signal((get) => { name: get(level.name.$lastValidValue), contrast: get(level.contrast.$lastValidValue), chroma: get(level.chroma.$lastValidValue), + chromaCap: get(level.chromaCap.$lastValidValue), }; }), hues: get($hueIds).map((hueId) => { diff --git a/packages/core/src/stores/constants.ts b/packages/core/src/stores/constants.ts index d795f515..1657c010 100644 --- a/packages/core/src/stores/constants.ts +++ b/packages/core/src/stores/constants.ts @@ -23,7 +23,7 @@ export const FALLBACK_CELL_COLOR = { export const FALLBACK_LEVEL_DATA = { name: "", contrast: 50, - chroma: 0.2, + chroma: 0, tintColor: FALLBACK_LEVEL_TINT_COLOR, } as LevelData; diff --git a/packages/core/src/stores/utils.ts b/packages/core/src/stores/utils.ts index 241ab5a6..a68fd962 100644 --- a/packages/core/src/stores/utils.ts +++ b/packages/core/src/stores/utils.ts @@ -13,6 +13,7 @@ import { hueAngleSchema, hueNameSchema, levelChromaSchema, + levelChromaCapSchema, levelNameSchema, } from "@core/schemas/color"; import { @@ -231,6 +232,7 @@ export type LevelStore = { name: ValidationStore; contrast: ValidationStore; chroma: ValidationStore; + chromaCap: ValidationStore; $tintColor: WritableSignal; }; @@ -248,6 +250,7 @@ export function getLevelStore(data: PartialOptional) { const name = validationStore(data.name, $levelNameUniqueSchema); const contrast = validationStore(data.contrast, $levelConstrastSchema); const chroma = validationStore(data.chroma, levelChromaSchema); + const chromaCap = validationStore(data.chromaCap ?? null, levelChromaCapSchema); const $tintColor = signal(data.tintColor ?? FALLBACK_LEVEL_TINT_COLOR); return { @@ -255,6 +258,7 @@ export function getLevelStore(data: PartialOptional) { name, contrast, chroma, + chromaCap, $tintColor, }; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c5c4796b..b43961d1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -38,6 +38,7 @@ export type LevelData = { name: LevelName; contrast: LevelContrast; chroma: LevelChroma; + chromaCap?: LevelChroma | null; // Before we hadn't this field, so for back compatibility it's optional tintColor: ColorLevelTintData; }; export type Level = { id: LevelId } & LevelData; diff --git a/packages/core/src/utils/colors/calculateColors.ts b/packages/core/src/utils/colors/calculateColors.ts index 4e386d4f..50bf7415 100644 --- a/packages/core/src/utils/colors/calculateColors.ts +++ b/packages/core/src/utils/colors/calculateColors.ts @@ -22,7 +22,7 @@ import { getBgMode } from "./getBgMode"; import { maxCommonChroma } from "./maxCommonChroma"; export type GenerateColorsPayload = { - levels: { id: LevelId; contrast: LevelContrast }[]; + levels: { id: LevelId; contrast: LevelContrast; chromaCap: LevelChroma | null }[]; recalcOnlyLevels: LevelId[] | undefined; hues: { id: HueId; angle: HueAngle }[]; bgColorLeft: ColorString; @@ -81,14 +81,18 @@ export function calculateColors( contrastModel, } as const; + const chromaCap = level.chromaCap ?? undefined; const chroma = chromaMode === "even" - ? maxCommonChroma({ - ...commonApcacheOptions, - contrastLevel: level.contrast, - hueAngles: hues.map((hue) => hue.angle), - }) - : maxChroma(); + ? maxCommonChroma( + { + ...commonApcacheOptions, + contrastLevel: level.contrast, + hueAngles: hues.map((hue) => hue.angle), + }, + chromaCap, + ) + : maxChroma(chromaCap); // Calculate hue tint based only on the 0 index level if (levelIndex === 0) { @@ -140,7 +144,7 @@ export function calculateColors( ...commonApcacheOptions, hueAngle: hue.angle, contrastLevel: level.contrast, - chroma, + chroma: chroma, }); cells[hue.id] = cellColor; @@ -155,7 +159,7 @@ export function calculateColors( ...commonApcacheOptions, hueAngle: hue.angle, contrastLevel: MIN_LEVEL_TINT_CR, - chroma, + chroma: chroma, }), referencedC: cellColor.c, }; diff --git a/packages/core/src/utils/colors/maxCommonChroma.ts b/packages/core/src/utils/colors/maxCommonChroma.ts index 72da1961..2d519dd5 100644 --- a/packages/core/src/utils/colors/maxCommonChroma.ts +++ b/packages/core/src/utils/colors/maxCommonChroma.ts @@ -9,16 +9,16 @@ export type MaxCommonChromaOptions = Omit Date: Mon, 18 Aug 2025 01:00:28 +0100 Subject: [PATCH 7/9] Add baseValue property to Input using withNumericIncrementControls. It allows setting the value which will be used as a base for arrows & wheel value changes, when the input isn't set. It is might be useful where we have implicit value and we wan't to provide ability to set explicit one. --- .../core/src/components/Grid/GridCellLevelHeader.tsx | 2 ++ .../Input/enhancers/withNumericIncrementControls.tsx | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/core/src/components/Grid/GridCellLevelHeader.tsx b/packages/core/src/components/Grid/GridCellLevelHeader.tsx index 39abb7bc..a5ce04e4 100644 --- a/packages/core/src/components/Grid/GridCellLevelHeader.tsx +++ b/packages/core/src/components/Grid/GridCellLevelHeader.tsx @@ -175,6 +175,7 @@ const ChromaInput = memo(function ChromaInput({ levelId }: LevelComponentProps) }); const level = getLevel(levelId); + const chroma = useSubscribe(level.chroma.$raw); const chromaCap = useSubscribe(level.chromaCap.$raw); const chromaLabel = useSubscribe($chromaLabel); const chromaPlaceholder = useSubscribe($chromaPlaceholder); @@ -195,6 +196,7 @@ const ChromaInput = memo(function ChromaInput({ levelId }: LevelComponentProps) } min={CHROMA_MIN} max={CHROMA_MAX} + baseValue={chroma} step={0.01} label={chromaLabel} aria-label={LABEL_CHROMA} diff --git a/packages/core/src/components/Input/enhancers/withNumericIncrementControls.tsx b/packages/core/src/components/Input/enhancers/withNumericIncrementControls.tsx index 1963dccb..a5d1265f 100644 --- a/packages/core/src/components/Input/enhancers/withNumericIncrementControls.tsx +++ b/packages/core/src/components/Input/enhancers/withNumericIncrementControls.tsx @@ -18,6 +18,10 @@ import type { InputProps } from "../Input"; import styles from "./enhancers.module.css"; type WithNumericIncrementControlsProps = { + /** + * The base value for arrows & wheel value manipulations. Will be used only when value isn't defined. + */ + baseValue?: number | string; step?: number; }; @@ -53,6 +57,7 @@ export function withNumericIncrementControls

( const NumberKeyboardInput = ({ step = 1, value, + baseValue, ...props }: WithNumericIncrementControlsProps & P) => { const { onChange, onBlur } = props; @@ -78,7 +83,9 @@ export function withNumericIncrementControls

( return value; } - const currentValue = Number.parseFloat(input.value); + const currentValue = Number.parseFloat( + input.value || (baseValue ? String(baseValue) : ""), + ); if (Number.isNaN(currentValue)) { return; From 24556ad1e9a34ca22097931a08bce3f3034f4412 Mon Sep 17 00:00:00 2001 From: Pavel Grinchenko Date: Mon, 18 Aug 2025 01:31:38 +0100 Subject: [PATCH 8/9] Update level.chroma store when colors recalculated and use it in UI instead of 's chroma. Get rid of referencedC hack --- .../core/src/components/Grid/GridCellLevelHeader.tsx | 11 +++++------ packages/core/src/stores/colors.ts | 1 + packages/core/src/stores/constants.ts | 1 - packages/core/src/types.ts | 2 +- packages/core/src/utils/colors/calculateColors.ts | 4 +--- 5 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/core/src/components/Grid/GridCellLevelHeader.tsx b/packages/core/src/components/Grid/GridCellLevelHeader.tsx index a5ce04e4..f3d65f55 100644 --- a/packages/core/src/components/Grid/GridCellLevelHeader.tsx +++ b/packages/core/src/components/Grid/GridCellLevelHeader.tsx @@ -163,9 +163,13 @@ const ContrastInput = memo(function ContrastInput({ const LevelChromaInput = withValidation(withNumericIncrementControls(Input)); const ChromaInput = memo(function ChromaInput({ levelId }: LevelComponentProps) { + const level = getLevel(levelId); + const chroma = useSubscribe(level.chroma.$raw); + const chromaCap = useSubscribe(level.chromaCap.$raw); + const error = useSubscribe(level.chromaCap.$validationError); const $chromaPlaceholder = useSignal((get) => { return get(chromaModeStore.$lastValidValue) === "even" - ? get(level.$tintColor).referencedC.toFixed(2) + ? get(level.chroma.$lastValidValue).toFixed(2) : "max"; }); const $chromaLabel = useSignal((get) => { @@ -173,13 +177,8 @@ const ChromaInput = memo(function ChromaInput({ levelId }: LevelComponentProps) return chromaCap ? LABEL_CHROMA_CAP_DEFINED : LABEL_CHROMA_CAP_SET; }); - - const level = getLevel(levelId); - const chroma = useSubscribe(level.chroma.$raw); - const chromaCap = useSubscribe(level.chromaCap.$raw); const chromaLabel = useSubscribe($chromaLabel); const chromaPlaceholder = useSubscribe($chromaPlaceholder); - const error = useSubscribe(level.chromaCap.$validationError); return ( Date: Mon, 18 Aug 2025 13:32:01 +0100 Subject: [PATCH 9/9] Allow setting custom precision in the withNumericIncrementControls Input enhancer. Set it to 3 for level chroma input --- .../src/components/Grid/GridCellLevelHeader.tsx | 6 ++++-- .../enhancers/withNumericIncrementControls.tsx | 16 ++++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/core/src/components/Grid/GridCellLevelHeader.tsx b/packages/core/src/components/Grid/GridCellLevelHeader.tsx index f3d65f55..d03b4ce8 100644 --- a/packages/core/src/components/Grid/GridCellLevelHeader.tsx +++ b/packages/core/src/components/Grid/GridCellLevelHeader.tsx @@ -161,6 +161,7 @@ const ContrastInput = memo(function ContrastInput({ ); }); +const CHROMA_INPUT_PRECISION = 3; const LevelChromaInput = withValidation(withNumericIncrementControls(Input)); const ChromaInput = memo(function ChromaInput({ levelId }: LevelComponentProps) { const level = getLevel(levelId); @@ -169,7 +170,7 @@ const ChromaInput = memo(function ChromaInput({ levelId }: LevelComponentProps) const error = useSubscribe(level.chromaCap.$validationError); const $chromaPlaceholder = useSignal((get) => { return get(chromaModeStore.$lastValidValue) === "even" - ? get(level.chroma.$lastValidValue).toFixed(2) + ? get(level.chroma.$lastValidValue).toFixed(CHROMA_INPUT_PRECISION) : "max"; }); const $chromaLabel = useSignal((get) => { @@ -195,8 +196,9 @@ const ChromaInput = memo(function ChromaInput({ levelId }: LevelComponentProps) } min={CHROMA_MIN} max={CHROMA_MAX} + precision={CHROMA_INPUT_PRECISION} baseValue={chroma} - step={0.01} + step={0.001} label={chromaLabel} aria-label={LABEL_CHROMA} showLabel={chromaCap ? "always" : "hover"} diff --git a/packages/core/src/components/Input/enhancers/withNumericIncrementControls.tsx b/packages/core/src/components/Input/enhancers/withNumericIncrementControls.tsx index a5d1265f..6df52f7d 100644 --- a/packages/core/src/components/Input/enhancers/withNumericIncrementControls.tsx +++ b/packages/core/src/components/Input/enhancers/withNumericIncrementControls.tsx @@ -22,12 +22,16 @@ type WithNumericIncrementControlsProps = { * The base value for arrows & wheel value manipulations. Will be used only when value isn't defined. */ baseValue?: number | string; + /** + * How many decimal places to display. + */ + precision?: number; step?: number; }; -function formatWithFractional(value: string | number, fractionalLength: number): string { +function formatWithPrecision(value: string | number, precision: number): string { const number = Number(value); - return (Number.isNaN(number) ? 0 : number).toFixed(fractionalLength); + return (Number.isNaN(number) ? 0 : number).toFixed(precision); } const NUMERIC_REGEX = /^\d*$/; @@ -58,12 +62,12 @@ export function withNumericIncrementControls

( step = 1, value, baseValue, + precision = -Math.log10(step), ...props }: WithNumericIncrementControlsProps & P) => { const { onChange, onBlur } = props; const inputRef = useRef(null); const labelRef = useRef(null); - const fractionalLength = -Math.log10(step); const refCallback = useMemo(() => mergeRefs(inputRef, props.ref), []); const labelRefCallback = useMemo(() => mergeRefs(labelRef, props.labelRef), []); @@ -98,7 +102,7 @@ export function withNumericIncrementControls

( return; } - input.value = formatWithFractional(newValue, fractionalLength); + input.value = formatWithPrecision(newValue, precision); if (onChange) { const nativeEvent = new Event("change", { bubbles: true }); @@ -111,7 +115,7 @@ export function withNumericIncrementControls

( } as unknown as ChangeEvent); } }, - [onChange, fractionalLength], + [onChange, precision], ); useEffect(() => { @@ -184,7 +188,7 @@ export function withNumericIncrementControls

( if (!input?.value) return; - const formattedValue = formatWithFractional(input.value, fractionalLength); + const formattedValue = formatWithPrecision(input.value, precision); if (input.value !== formattedValue) { input.value = formattedValue; }