diff --git a/packages/core/src/components/BetaMenu/BetaMenu.module.css b/packages/core/src/components/BetaMenu/BetaMenu.module.css
new file mode 100644
index 0000000..466816e
--- /dev/null
+++ b/packages/core/src/components/BetaMenu/BetaMenu.module.css
@@ -0,0 +1,20 @@
+.button {
+ /* No positioning needed - will be positioned by parent FloatingActions */
+}
+
+.iconContainer {
+ display: flex;
+ gap: calc(var(--spacing) * 0.5);
+ align-items: center;
+}
+
+.flaskIcon {
+ width: 16px;
+ height: 16px;
+}
+
+.caretIcon {
+ width: 12px;
+ height: 12px;
+ opacity: 0.7;
+}
diff --git a/packages/core/src/components/BetaMenu/BetaMenu.tsx b/packages/core/src/components/BetaMenu/BetaMenu.tsx
new file mode 100644
index 0000000..367aa92
--- /dev/null
+++ b/packages/core/src/components/BetaMenu/BetaMenu.tsx
@@ -0,0 +1,63 @@
+import { useSubscribe } from "@spred/react";
+
+import { Button } from "@core/components/Button/Button";
+import { MFlask } from "@core/components/Icon/MFlask";
+import { MTriangleDown } from "@core/components/Icon/MTriangleDown";
+import { Menu } from "@core/components/Menu/Menu";
+import { MenuItemButton } from "@core/components/Menu/MenuItemButton";
+import { MenuItemGroup } from "@core/components/Menu/MenuItemGroup";
+import { MenuItemSeparator } from "@core/components/Menu/MenuItemSeparator";
+import { levelStepsPresets } from "@core/levelStepsPresets";
+import { LEVEL_STEPS_PRESETS, type LevelStepsPreset } from "@core/schemas/brand";
+import { resetToInitialState } from "@core/stores/config";
+import {
+ applyLevelStepsPreset,
+ distributeContrastEvenly,
+ levelStepsPresetStore,
+} from "@core/stores/settings";
+
+import styles from "./BetaMenu.module.css";
+
+export function BetaMenu() {
+ const currentPreset = useSubscribe(levelStepsPresetStore.$lastValidValue);
+
+ return (
+
+ );
+}
diff --git a/packages/core/src/components/Grid/GridCellHueHeader.tsx b/packages/core/src/components/Grid/GridCellHueHeader.tsx
index 3c76d34..a790795 100644
--- a/packages/core/src/components/Grid/GridCellHueHeader.tsx
+++ b/packages/core/src/components/Grid/GridCellHueHeader.tsx
@@ -1,4 +1,4 @@
-import { memo, useCallback, useRef } from "react";
+import { type ClipboardEvent, memo, useCallback, useRef } from "react";
import { useSubscribe } from "@spred/react";
@@ -19,6 +19,7 @@ import {
} from "@core/stores/colors";
import { $bgColorModeLeft } from "@core/stores/settings";
import type { HueId } from "@core/types";
+import { hexToHueAngle } from "@core/utils/colors/hexToHueAngle";
import type { AnyProps } from "@core/utils/react/types";
import { DATA_ATTR_CELL_HUE_ID } from "./constants";
@@ -70,6 +71,20 @@ const NameInput = memo(function NameInput({ hueId }: HueComponentProps) {
}
});
+ const handlePaste = useCallback(
+ (e: ClipboardEvent) => {
+ const pastedText = e.clipboardData.getData("text");
+ const hueAngle = hexToHueAngle(pastedText);
+
+ if (hueAngle !== null) {
+ e.preventDefault();
+ updateHueAngle(hueId, String(Math.round(hueAngle)));
+ resetHueName(hueId);
+ }
+ },
+ [hueId],
+ );
+
return (
updateLevelContrast(levelId, e.target.value)}
- />
+
+ updateLevelContrast(levelId, e.target.value)}
+ />
+ }
+ onClick={() => toggleLevelLocked(levelId)}
+ title={
+ locked
+ ? "Unlock contrast (allow automatic changes)"
+ : "Lock contrast (prevent automatic changes)"
+ }
+ aria-label={locked ? "Unlock contrast" : "Lock contrast"}
+ />
+
);
});
@@ -174,32 +196,47 @@ const ChromaInput = memo(function ChromaInput({ levelId }: LevelComponentProps)
const chromaPlaceholder = useSubscribe($chromaPlaceholder);
return (
- updateLevelchromaCap(levelId, e.target.value || null)}
- />
+
+ updateLevelchromaCap(levelId, e.target.value || null)}
+ />
+
+
);
});
diff --git a/packages/core/src/components/Grid/GridLeftTopCell.tsx b/packages/core/src/components/Grid/GridLeftTopCell.tsx
index f86619d..907d9e7 100644
--- a/packages/core/src/components/Grid/GridLeftTopCell.tsx
+++ b/packages/core/src/components/Grid/GridLeftTopCell.tsx
@@ -4,9 +4,12 @@ import { useSubscribe } from "@spred/react";
import clsx from "clsx";
import { Button } from "@core/components/Button/Button";
-import { Select } from "@core/components/Select/Select";
+import { Menu } from "@core/components/Menu/Menu";
+import { MenuItemButton } from "@core/components/Menu/MenuItemButton";
+import { MenuItemSeparator } from "@core/components/Menu/MenuItemSeparator";
import { Text } from "@core/components/Text/Text";
-import { parseChromaMode } from "@core/schemas/settings";
+import { ChromaMode } from "@core/schemas/brand";
+import { $isAnyChromaCapSet, resetAllChroma } from "@core/stores/colors";
import {
chromaModeStore,
colorSpaceStore,
@@ -43,6 +46,7 @@ export const GridLeftTopCell = memo(function GridLeftTopCell() {
const chromaModeValue = useSubscribe(chromaModeStore.$lastValidValue);
const colorSpace = useSubscribe(colorSpaceStore.$lastValidValue);
const isColorSpaceLocked = useSubscribe($isColorSpaceLocked);
+ const hasChromaCaps = useSubscribe($isAnyChromaCapSet);
return (
@@ -79,13 +83,34 @@ export const GridLeftTopCell = memo(function GridLeftTopCell() {
colors
-
);
diff --git a/packages/core/src/components/Icon/MFlask.tsx b/packages/core/src/components/Icon/MFlask.tsx
new file mode 100644
index 0000000..562f257
--- /dev/null
+++ b/packages/core/src/components/Icon/MFlask.tsx
@@ -0,0 +1,14 @@
+import type { SVGProps } from "react";
+
+export type MFlaskProps = SVGProps;
+
+export function MFlask(props: MFlaskProps) {
+ return (
+
+ );
+}
diff --git a/packages/core/src/createApp.tsx b/packages/core/src/createApp.tsx
index fe9d372..91ada30 100644
--- a/packages/core/src/createApp.tsx
+++ b/packages/core/src/createApp.tsx
@@ -3,6 +3,7 @@ import { StrictMode, type ReactNode } from "react";
import { batch } from "@spred/core";
import { createRoot } from "react-dom/client";
+import { BetaMenu } from "./components/BetaMenu/BetaMenu";
import { FloatingActions } from "./components/FloatingActions/FloatingActions";
import { Grid, type GridBanner } from "./components/Grid/Grid";
import { MainContainer } from "./components/MainContainer/MainContainer";
@@ -20,12 +21,13 @@ type AppOptions = {
afterGridContent?: ReactNode;
};
precalculateColors?: boolean;
+ enableBetaMenu?: boolean;
};
export function createApp(
element: HTMLElement | null,
dependencies: AppDependencies,
- { customUI, precalculateColors }: AppOptions,
+ { customUI, precalculateColors, enableBetaMenu = true }: AppOptions,
) {
invariant(element, "Mount element not found");
@@ -47,7 +49,12 @@ export function createApp(
- {customUI?.actions && {customUI.actions}}
+ {(enableBetaMenu || customUI?.actions) && (
+
+ {enableBetaMenu && }
+ {customUI?.actions}
+
+ )}
{customUI?.afterGridContent}
diff --git a/packages/core/src/levelStepsPresets.ts b/packages/core/src/levelStepsPresets.ts
new file mode 100644
index 0000000..692c382
--- /dev/null
+++ b/packages/core/src/levelStepsPresets.ts
@@ -0,0 +1,56 @@
+export const levelStepsPresets = {
+ default: {
+ label: "Default (9 steps: 100-900)",
+ steps: [
+ { name: "100", contrast: 100, chroma: 0 },
+ { name: "200", contrast: 90, chroma: 0 },
+ { name: "300", contrast: 77, chroma: 0 },
+ { name: "400", contrast: 65, chroma: 0 },
+ { name: "500", contrast: 51, chroma: 0 },
+ { name: "600", contrast: 65, chroma: 0 },
+ { name: "700", contrast: 77, chroma: 0 },
+ { name: "800", contrast: 90, chroma: 0 },
+ { name: "900", contrast: 100, chroma: 0 },
+ ],
+ },
+ extended: {
+ label: "Extended (11 steps: 50-950)",
+ steps: [
+ { name: "50", contrast: 106, chroma: 0 },
+ { name: "100", contrast: 100, chroma: 0 },
+ { name: "200", contrast: 90, chroma: 0 },
+ { name: "300", contrast: 77, chroma: 0 },
+ { name: "400", contrast: 65, chroma: 0 },
+ { name: "500", contrast: 51, chroma: 0 },
+ { name: "600", contrast: 65, chroma: 0 },
+ { name: "700", contrast: 77, chroma: 0 },
+ { name: "800", contrast: 90, chroma: 0 },
+ { name: "900", contrast: 100, chroma: 0 },
+ { name: "950", contrast: 106, chroma: 0 },
+ ],
+ },
+ minimal: {
+ label: "Minimal (5 steps: 100-900)",
+ steps: [
+ { name: "100", contrast: 100, chroma: 0 },
+ { name: "300", contrast: 77, chroma: 0 },
+ { name: "500", contrast: 51, chroma: 0 },
+ { name: "700", contrast: 77, chroma: 0 },
+ { name: "900", contrast: 100, chroma: 0 },
+ ],
+ },
+ compact: {
+ label: "Compact (7 steps: 200-800)",
+ steps: [
+ { name: "200", contrast: 90, chroma: 0 },
+ { name: "300", contrast: 77, chroma: 0 },
+ { name: "400", contrast: 65, chroma: 0 },
+ { name: "500", contrast: 51, chroma: 0 },
+ { name: "600", contrast: 65, chroma: 0 },
+ { name: "700", contrast: 77, chroma: 0 },
+ { name: "800", contrast: 90, chroma: 0 },
+ ],
+ },
+} as const;
+
+export type LevelStepsPreset = keyof typeof levelStepsPresets;
diff --git a/packages/core/src/schemas/brand.ts b/packages/core/src/schemas/brand.ts
index 1e8fe7a..6fbea9b 100644
--- a/packages/core/src/schemas/brand.ts
+++ b/packages/core/src/schemas/brand.ts
@@ -58,3 +58,10 @@ export type BgRightStart = Brand;
export const CONTRAST_MODELS = ["apca", "wcag"] as const;
export const ContrastModel = createBrand<(typeof CONTRAST_MODELS)[number], "ContrastModel">;
export type ContrastModel = Brand;
+
+export const LEVEL_STEPS_PRESETS = ["minimal", "compact", "default", "extended"] as const;
+export const LevelStepsPreset = createBrand<
+ (typeof LEVEL_STEPS_PRESETS)[number],
+ "LevelStepsPreset"
+>;
+export type LevelStepsPreset = Brand;
diff --git a/packages/core/src/schemas/exportConfig.ts b/packages/core/src/schemas/exportConfig.ts
index 1163279..d426e00 100644
--- a/packages/core/src/schemas/exportConfig.ts
+++ b/packages/core/src/schemas/exportConfig.ts
@@ -30,6 +30,7 @@ export const exportConfigSchema = v.pipe(
contrast: baseContrastSchema,
chroma: levelChromaSchema,
chromaCap: v.optional(levelChromaCapSchema),
+ locked: v.optional(v.boolean()),
}),
),
hues: v.array(v.object({ name: hueNameSchema, angle: hueAngleSchema })),
@@ -71,8 +72,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.null()])),
- v.description("Level name, contrast and chroma cap as a plain array"),
+ v.array(v.union([v.string(), v.number(), v.null(), v.boolean()])),
+ v.description("Level name, contrast, chroma cap and locked as a plain array"),
),
v.pipe(
v.array(v.union([v.string(), v.number()])),
@@ -101,10 +102,11 @@ export function parseCompactExportConfig(value: unknown): CompactExportConfig {
export function toCompactExportConfig(config: ExportConfig): CompactExportConfig {
return [
- config.levels.flatMap((level) => [
+ config.levels.flatMap((level) => [
level.name,
level.contrast,
level.chromaCap ?? null,
+ level.locked ?? false,
]),
config.hues.flatMap((hue) => [hue.name, hue.angle]),
[
@@ -124,12 +126,19 @@ export function toExportConfig(compactConfig: CompactExportConfig): ExportConfig
const levels: ExportConfig["levels"] = [];
const hues: ExportConfig["hues"] = [];
- 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 chromaCap = v.parse(levelChromaCapSchema, compactConfig[0][i + 2]);
+ // Determine step size for backward compatibility
+ // Old format: [name, contrast, chromaCap] = 3 items per level
+ // New format: [name, contrast, chromaCap, locked] = 4 items per level
+ const levelsData = compactConfig[0];
+ const stepSize = levelsData.length % 4 === 0 ? 4 : 3;
- levels.push({ name, contrast, chroma: LevelChroma(0), chromaCap });
+ for (let i = 0; i < levelsData.length; i += stepSize) {
+ const name = v.parse(levelNameSchema, levelsData[i]);
+ const contrast = v.parse(getLevelContrastModel(contrastModel), levelsData[i + 1]);
+ const chromaCap = v.parse(levelChromaCapSchema, levelsData[i + 2]);
+ const locked = stepSize === 4 ? (levelsData[i + 3] as boolean) : undefined;
+
+ levels.push({ name, contrast, chroma: LevelChroma(0), chromaCap, locked });
}
for (let i = 0; i < compactConfig[1].length; i += 2) {
diff --git a/packages/core/src/schemas/settings.ts b/packages/core/src/schemas/settings.ts
index 38dfdd2..bb08224 100644
--- a/packages/core/src/schemas/settings.ts
+++ b/packages/core/src/schemas/settings.ts
@@ -10,6 +10,8 @@ import {
ContrastModel,
DIRECTION_MODES,
DirectionMode,
+ LEVEL_STEPS_PRESETS,
+ LevelStepsPreset,
} from "./brand";
export const chromaModeSchema = v.pipe(
@@ -40,3 +42,10 @@ export const directionModeSchema = v.pipe(
v.transform(DirectionMode),
);
export const parseDirectionMode = (value: string) => v.parse(directionModeSchema, value);
+
+export const levelStepsPresetSchema = v.pipe(
+ v.string(),
+ v.picklist(LEVEL_STEPS_PRESETS),
+ v.transform(LevelStepsPreset),
+);
+export const parseLevelStepsPreset = (value: string) => v.parse(levelStepsPresetSchema, value);
diff --git a/packages/core/src/stores/colors.ts b/packages/core/src/stores/colors.ts
index 4a093b3..2fb1ea2 100644
--- a/packages/core/src/stores/colors.ts
+++ b/packages/core/src/stores/colors.ts
@@ -158,16 +158,27 @@ function upsertColor(levelId: LevelId, hueId: HueId, color: ColorCellData) {
}
function collectColorCalculationData(recalcOnlyLevels?: LevelId[]): GenerateColorsPayload {
+ const previewSourceLevelId = $chromaPreviewSourceLevel.value;
+
return {
directionMode: directionModeStore.$lastValidValue.value,
contrastModel: contrastModelStore.$lastValidValue.value,
levels: $levelIds.value.map((id) => {
const level = getLevel(id);
+ let chromaCap = level.chromaCap.$lastValidValue.value;
+
+ // Use preview chroma for all levels except the source level during preview
+ if (previewSourceLevelId && id !== previewSourceLevelId) {
+ const sourceLevel = getLevel(previewSourceLevelId);
+ const sourceChromaCap = sourceLevel.chromaCap.$lastValidValue.value;
+ const previewChromaValue = sourceChromaCap ?? sourceLevel.chroma.$lastValidValue.value;
+ chromaCap = previewChromaValue;
+ }
return {
id,
contrast: level.contrast.$lastValidValue.value,
- chromaCap: level.chromaCap.$lastValidValue.value,
+ chromaCap,
};
}),
recalcOnlyLevels,
@@ -268,6 +279,11 @@ export function updateLevelchromaCap(id: LevelId, chroma: string | number | null
requestColorsRecalculation([id]);
}
+export function toggleLevelLocked(id: LevelId) {
+ const level = getLevel(id);
+ level.$locked.set(!level.$locked.value);
+}
+
export function resetAllChroma() {
batch(() => {
for (const levelId of $levelIds.value) {
@@ -278,6 +294,64 @@ export function resetAllChroma() {
requestColorsRecalculation();
}
+export function copyChromaFromHue(hueId: HueId) {
+ const levelIds = $levelIds.value;
+
+ batch(() => {
+ for (const levelId of levelIds) {
+ const color = getColor$(levelId, hueId);
+ const chromaValue = color.value.c;
+ const level = getLevel(levelId);
+ level.chromaCap.$raw.set(chromaValue);
+ }
+ });
+ requestColorsRecalculation();
+}
+
+export function copyChromaCapToAllLevels(sourceLevelId: LevelId) {
+ const sourceLevel = getLevel(sourceLevelId);
+ const sourceChromaCap = sourceLevel.chromaCap.$raw.value;
+
+ // Use chromaCap if set, otherwise use the calculated chroma value
+ const chromaValue = sourceChromaCap ?? sourceLevel.chroma.$lastValidValue.value;
+
+ // Round to 3 decimal places to match the input precision
+ const roundedChromaValue = Number.parseFloat(Number(chromaValue).toFixed(3));
+
+ const levelIds = $levelIds.value;
+
+ batch(() => {
+ for (const levelId of levelIds) {
+ if (levelId === sourceLevelId) continue;
+ const level = getLevel(levelId);
+ level.chromaCap.$raw.set(roundedChromaValue);
+ }
+ });
+ requestColorsRecalculation();
+}
+
+// Preview chroma copy
+export const $chromaPreviewSourceLevel = signal(null);
+
+const executePreviewRecalculation = debounce(
+ () => {
+ requestColorsRecalculation.cancel();
+ workerChannel.emit("generate:colors", collectColorCalculationData());
+ },
+ 50,
+ { leading: true, trailing: true },
+);
+
+export function startChromaCopyPreview(sourceLevelId: LevelId) {
+ $chromaPreviewSourceLevel.set(sourceLevelId);
+ executePreviewRecalculation();
+}
+
+export function stopChromaCopyPreview() {
+ $chromaPreviewSourceLevel.set(null);
+ executePreviewRecalculation();
+}
+
// 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 79ae5a9..5b1455d 100644
--- a/packages/core/src/stores/config.ts
+++ b/packages/core/src/stores/config.ts
@@ -50,6 +50,7 @@ export const $exportConfig = signal((get) => {
contrast: get(level.contrast.$lastValidValue),
chroma: get(level.chroma.$lastValidValue),
chromaCap: get(level.chromaCap.$lastValidValue),
+ locked: get(level.$locked),
};
}),
hues: get($hueIds).map((hueId) => {
@@ -205,3 +206,16 @@ export function downloadConfigTarget(type: ExportTarget) {
data: targetConfig.getFileData(),
});
}
+
+export function resetToInitialState() {
+ updateConfig(parseExportConfig(defaultConfig));
+}
+
+export function resetToEmptyState() {
+ const emptyConfigRaw = {
+ levels: [...defaultConfig.levels],
+ hues: [],
+ settings: { ...defaultConfig.settings },
+ };
+ updateConfig(parseExportConfig(emptyConfigRaw));
+}
diff --git a/packages/core/src/stores/settings.ts b/packages/core/src/stores/settings.ts
index c8081c9..6a2618b 100644
--- a/packages/core/src/stores/settings.ts
+++ b/packages/core/src/stores/settings.ts
@@ -1,12 +1,14 @@
import { batch, signal } from "@spred/core";
import { defaultConfig } from "@core/defaultConfig";
+import { levelStepsPresets } from "@core/levelStepsPresets";
import { colorStringSchema } from "@core/schemas/color";
import {
chromaModeSchema,
colorSpaceSchema,
contrastModelSchema,
directionModeSchema,
+ levelStepsPresetSchema,
} from "@core/schemas/settings";
import {
BgRightStart,
@@ -15,7 +17,10 @@ import {
ColorString,
ContrastModel,
DirectionMode,
+ LevelChroma,
LevelContrast,
+ LevelName,
+ LevelStepsPreset,
} from "@core/types";
import { apcaToWcag } from "@core/utils/colors/apcaToWcag";
import { getBgMode } from "@core/utils/colors/getBgMode";
@@ -23,12 +28,19 @@ import { wcagToApca } from "@core/utils/colors/wcagToApca";
import { validationStore } from "@core/utils/stores/validationStore";
import {
+ $hueIds,
$levelIds,
$levelsCount,
+ copyChromaFromHue,
+ getHue,
levels,
+ overwriteLevels,
+ pregenerateFallbackColorsMap,
requestColorsRecalculation,
requestColorsRecalculationWithLevelsAccumulation,
+ resetAllChroma,
} from "./colors";
+import { getLevelStore } from "./utils";
import { getBgValueLeft, getBgValueRight, isSingleBgLeft, isSingleBgRight } from "./utils/bg";
export const contrastModelStore = validationStore(
@@ -144,7 +156,7 @@ export function toggleColorSpace() {
export function updateChromaMode(mode: ChromaMode) {
chromaModeStore.$raw.set(mode);
- requestColorsRecalculation();
+ resetAllChroma();
}
export function updateBgColorLeft(color: ColorString) {
@@ -195,3 +207,149 @@ export function enableDualBg() {
updateBgRightStart(BgRightStart(Math.floor(levelsCount / 2)));
}
+
+export const levelStepsPresetStore = validationStore(
+ LevelStepsPreset("default"),
+ levelStepsPresetSchema,
+);
+
+export function applyLevelStepsPreset(preset: LevelStepsPreset) {
+ const presetData = levelStepsPresets[preset as keyof typeof levelStepsPresets];
+ const oldLevelIds = $levelIds.value;
+ const oldLevelsCount = oldLevelIds.length;
+
+ // Capture existing contrast values by position
+ const existingContrasts = oldLevelIds.map((id) => {
+ const level = levels.get(id);
+ return level ? level.contrast.$lastValidValue.value : null;
+ });
+
+ const newLevels = presetData.steps.map(
+ (step: { name: string; contrast: number; chroma: number }, index: number) => {
+ // Try to preserve contrast value from the same relative position
+ // Map old positions to new positions proportionally
+ let preservedContrast: number | null = null;
+ if (oldLevelsCount > 0) {
+ const oldIndex =
+ oldLevelsCount === 1
+ ? 0
+ : Math.round((index / (presetData.steps.length - 1)) * (oldLevelsCount - 1));
+ preservedContrast = existingContrasts[oldIndex] ?? null;
+ }
+
+ return getLevelStore({
+ name: LevelName(step.name),
+ contrast: LevelContrast(preservedContrast ?? step.contrast),
+ chroma: LevelChroma(step.chroma),
+ });
+ },
+ );
+
+ const newLevelsCount = newLevels.length;
+ const oldBgRightStart = $bgRightStart.value;
+
+ // Calculate proportional bgRightStart for new levels count
+ const proportionalBgRightStart =
+ oldLevelsCount > 0
+ ? Math.round((oldBgRightStart / oldLevelsCount) * newLevelsCount)
+ : Math.floor(newLevelsCount / 2);
+ const newBgRightStart = BgRightStart(
+ Math.max(0, Math.min(newLevelsCount, proportionalBgRightStart)),
+ );
+
+ batch(() => {
+ levelStepsPresetStore.$raw.set(preset);
+ overwriteLevels(newLevels);
+ $bgRightStart.set(newBgRightStart);
+
+ // Clear and regenerate colors map with new level IDs
+ const newLevelIds = newLevels.map((level) => level.id);
+ pregenerateFallbackColorsMap(newLevelIds, $hueIds.value);
+ });
+
+ requestColorsRecalculation();
+}
+
+export function distributeContrastEvenly() {
+ const levelIds = $levelIds.value;
+ const levelCount = levelIds.length;
+
+ // Need at least 2 levels to distribute
+ if (levelCount < 2) return;
+
+ const contrastModel = contrastModelStore.$lastValidValue.value;
+
+ // Find all anchor points (locked levels, first, and last)
+ const anchorIndices: number[] = [];
+
+ for (const [index, levelId] of levelIds.entries()) {
+ const level = levels.get(levelId);
+ if (!level) continue;
+
+ // First and last are always anchors, plus any locked level
+ const isAnchor = index === 0 || index === levelCount - 1 || level.$locked.value;
+
+ if (isAnchor) {
+ anchorIndices.push(index);
+ }
+ }
+
+ // Need at least 2 anchors to create segments
+ if (anchorIndices.length < 2) return;
+
+ // Distribute within each segment
+ batch(() => {
+ for (let segmentIndex = 0; segmentIndex < anchorIndices.length - 1; segmentIndex++) {
+ const startIndex = anchorIndices[segmentIndex];
+ const endIndex = anchorIndices[segmentIndex + 1];
+
+ if (startIndex === undefined || endIndex === undefined) continue;
+
+ const startLevelId = levelIds[startIndex];
+ const endLevelId = levelIds[endIndex];
+
+ if (!startLevelId || !endLevelId) continue;
+
+ const startLevel = levels.get(startLevelId);
+ const endLevel = levels.get(endLevelId);
+
+ if (!startLevel || !endLevel) continue;
+
+ const startContrast = startLevel.contrast.$lastValidValue.value;
+ const endContrast = endLevel.contrast.$lastValidValue.value;
+
+ const segmentLength = endIndex - startIndex;
+
+ // Distribute levels between anchors (excluding anchors themselves)
+ for (let i = startIndex + 1; i < endIndex; i++) {
+ const levelId = levelIds[i];
+ if (!levelId) continue;
+
+ const level = levels.get(levelId);
+ if (!level) continue;
+
+ // Skip if this level is locked (shouldn't happen since we're between anchors)
+ if (level.$locked.value) continue;
+
+ // Calculate position within this segment (0 to 1)
+ const normalizedPosition = (i - startIndex) / segmentLength;
+
+ // Linear distribution from start to end contrast
+ const contrastValue = startContrast + normalizedPosition * (endContrast - startContrast);
+
+ // Round APCA to whole numbers, WCAG to 1 decimal place
+ const roundedValue =
+ contrastModel === "apca"
+ ? Math.round(contrastValue)
+ : Math.round(contrastValue * 10) / 10;
+
+ level.contrast.$raw.set(LevelContrast(roundedValue));
+ }
+ }
+ });
+
+ requestColorsRecalculation();
+}
+
+// Re-export chroma utilities
+export { copyChromaFromHue, $hueIds, getHue };
diff --git a/packages/core/src/stores/utils.ts b/packages/core/src/stores/utils.ts
index a68fd96..e3e02eb 100644
--- a/packages/core/src/stores/utils.ts
+++ b/packages/core/src/stores/utils.ts
@@ -233,6 +233,7 @@ export type LevelStore = {
contrast: ValidationStore;
chroma: ValidationStore;
chromaCap: ValidationStore;
+ $locked: WritableSignal;
$tintColor: WritableSignal;
};
@@ -251,6 +252,7 @@ export function getLevelStore(data: PartialOptional) {
const contrast = validationStore(data.contrast, $levelConstrastSchema);
const chroma = validationStore(data.chroma, levelChromaSchema);
const chromaCap = validationStore(data.chromaCap ?? null, levelChromaCapSchema);
+ const $locked = signal(data.locked ?? false);
const $tintColor = signal(data.tintColor ?? FALLBACK_LEVEL_TINT_COLOR);
return {
@@ -259,6 +261,7 @@ export function getLevelStore(data: PartialOptional) {
contrast,
chroma,
chromaCap,
+ $locked,
$tintColor,
};
}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 5a9dea8..25d0d50 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -31,6 +31,7 @@ export {
LevelId,
LevelIndex,
LevelName,
+ LevelStepsPreset,
LightnessLevel,
} from "@core/schemas/brand";
@@ -39,6 +40,7 @@ export type LevelData = {
contrast: LevelContrast;
chroma: LevelChroma;
chromaCap?: LevelChroma | null; // Before we hadn't this field, so for back compatibility it's optional
+ locked?: boolean; // Prevents automatic contrast changes (e.g., from distribute evenly)
tintColor: ColorLevelTintData;
};
export type Level = { id: LevelId } & LevelData;
diff --git a/packages/core/src/utils/colors/calculateApcach.ts b/packages/core/src/utils/colors/calculateApcach.ts
index 7c38839..8439fb0 100644
--- a/packages/core/src/utils/colors/calculateApcach.ts
+++ b/packages/core/src/utils/colors/calculateApcach.ts
@@ -1,5 +1,6 @@
import { type Apcach, crToBg, crToFg, apcach } from "apcach";
+import { calculateSubtleColor, SUBTLE_CONTRAST_THRESHOLD } from "./calculateSubtleColor";
import type { ColorCalculationOptions } from "./types";
export function calculateApcach({
@@ -12,8 +13,24 @@ export function calculateApcach({
chroma,
hueAngle,
}: ColorCalculationOptions): Apcach {
+ // For very low contrast values (< 8), use custom subtle color calculation
+ // because apcach library converts these to a single color (white)
+ if (contrastLevel < SUBTLE_CONTRAST_THRESHOLD) {
+ return calculateSubtleColor(
+ toColor,
+ contrastLevel,
+ hueAngle,
+ chroma,
+ searchDirection as "lighter" | "darker",
+ colorSpace,
+ directionMode,
+ contrastModel,
+ );
+ }
+
+ // Use apcach library for normal contrast values
const method = directionMode === "fgToBg" ? crToBg : crToFg;
- const bg = method(toColor, contrastLevel, contrastModel, searchDirection);
+ const bg = method(toColor, contrastLevel, contrastModel, searchDirection as "lighter" | "darker");
return apcach(bg, chroma, hueAngle, 100, colorSpace);
}
diff --git a/packages/core/src/utils/colors/calculateColors.ts b/packages/core/src/utils/colors/calculateColors.ts
index b7168cb..b1f1b0b 100644
--- a/packages/core/src/utils/colors/calculateColors.ts
+++ b/packages/core/src/utils/colors/calculateColors.ts
@@ -18,7 +18,6 @@ import {
import { ensureNonNullable } from "@core/utils/assertions/ensureNonNullable";
import { calculateColorCell } from "./calculateColorCell";
-import { getBgMode } from "./getBgMode";
import { maxCommonChroma } from "./maxCommonChroma";
export type GenerateColorsPayload = {
@@ -70,8 +69,7 @@ export function calculateColors(
for (const [levelIndex, level] of levels.entries()) {
const isBgLeft = bgRightStart > levelIndex;
const toColor = isBgLeft ? bgColorLeft : bgColorRight;
- const bgMode = isBgLeft ? getBgMode(bgColorLeft) : getBgMode(bgColorRight);
- const searchDirection = bgMode === "dark" ? "lighter" : "darker";
+ const searchDirection = "auto";
const commonApcacheOptions = {
colorSpace,
diff --git a/packages/core/src/utils/colors/calculateSubtleColor.ts b/packages/core/src/utils/colors/calculateSubtleColor.ts
new file mode 100644
index 0000000..183ce2c
--- /dev/null
+++ b/packages/core/src/utils/colors/calculateSubtleColor.ts
@@ -0,0 +1,65 @@
+import type { Apcach, ChromaFunction } from "apcach";
+import { apcach, crToBg, crToFg } from "apcach";
+import { converter, parse } from "culori";
+
+import type {
+ ColorString,
+ ContrastModel,
+ DirectionMode,
+ HueAngle,
+ LevelContrast,
+} from "@core/types";
+
+export const SUBTLE_CONTRAST_THRESHOLD = 8;
+
+/**
+ * Calculate subtle color variations for very low contrast values (< 8).
+ * The apcach library converts colors below contrast 8 to a single color (white),
+ * making it impossible to create subtle variations. This function provides
+ * a custom implementation by blending between the base color and what
+ * apcach would produce at the threshold (contrast 8).
+ */
+export function calculateSubtleColor(
+ baseColor: ColorString,
+ targetContrast: LevelContrast,
+ hueAngle: HueAngle,
+ chroma: number | ChromaFunction,
+ searchDirection: "lighter" | "darker",
+ colorSpace: "srgb" | "p3",
+ directionMode: DirectionMode,
+ contrastModel: ContrastModel,
+): Apcach {
+ const parsed = parse(baseColor);
+ if (!parsed) {
+ throw new Error(`Invalid base color: ${baseColor}`);
+ }
+
+ const toOklch = converter("oklch");
+ const baseOklch = toOklch(parsed);
+
+ // Calculate what apcach would return at the threshold (contrast 8)
+ const method = directionMode === "fgToBg" ? crToBg : crToFg;
+ const bgAtThreshold = method(
+ baseColor,
+ SUBTLE_CONTRAST_THRESHOLD,
+ contrastModel,
+ searchDirection,
+ );
+ const apcachAtThreshold = apcach(bgAtThreshold, chroma, hueAngle, 100, colorSpace);
+
+ // Blend factor: 0 at contrast 0, approaching 1 at contrast 8
+ const blendFactor = targetContrast / SUBTLE_CONTRAST_THRESHOLD;
+
+ // Interpolate between base color and threshold color
+ const newLightness = baseOklch.l + (apcachAtThreshold.lightness - baseOklch.l) * blendFactor;
+ const baseChroma = baseOklch.c || 0;
+ const newChroma = baseChroma + (apcachAtThreshold.chroma - baseChroma) * blendFactor;
+
+ return {
+ lightness: newLightness,
+ chroma: newChroma,
+ hue: hueAngle,
+ alpha: 1,
+ colorSpace,
+ };
+}
diff --git a/packages/core/src/utils/colors/hexToHueAngle.ts b/packages/core/src/utils/colors/hexToHueAngle.ts
new file mode 100644
index 0000000..21b2eeb
--- /dev/null
+++ b/packages/core/src/utils/colors/hexToHueAngle.ts
@@ -0,0 +1,30 @@
+import { toOklch } from "./toOklch";
+
+/**
+ * Parses a hex color string and returns its hue angle in OKLch color space.
+ * Accepts hex colors with or without # prefix.
+ * Returns null if the input is not a valid hex color.
+ */
+export function hexToHueAngle(input: string): number | null {
+ // Remove whitespace
+ const trimmed = input.trim();
+
+ // Check if it looks like a hex color (with or without #)
+ const hexPattern = /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/;
+ if (!hexPattern.test(trimmed)) {
+ return null;
+ }
+
+ // Add # prefix if missing
+ const hex = trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
+
+ // Convert to OKLch
+ const oklch = toOklch(hex);
+
+ // Return null if conversion failed or hue is undefined (achromatic)
+ if (oklch?.h === undefined) {
+ return null;
+ }
+
+ return oklch.h;
+}
diff --git a/packages/core/src/utils/colors/types.ts b/packages/core/src/utils/colors/types.ts
index 2ee1609..208a703 100644
--- a/packages/core/src/utils/colors/types.ts
+++ b/packages/core/src/utils/colors/types.ts
@@ -10,7 +10,7 @@ import type {
ColorSpace,
} from "@core/types";
-export type SearchDirection = "lighter" | "darker";
+export type SearchDirection = "lighter" | "darker" | "auto";
export type ColorCalculationOptions = {
directionMode: DirectionMode;
contrastModel: ContrastModel;