From 40359b1779f51fa3e138df14db338d223d68f20c Mon Sep 17 00:00:00 2001 From: Andrew McNutt Date: Wed, 14 Feb 2024 12:18:41 -0800 Subject: [PATCH] Lint worker (#16) * todo updates * most of linter story * fix tests * moving some stuff around, the way that the suggest fixes needs to get clean up stull, and paramoptions is in the process of being ripped out * add tests, convert almost all the rest of the lints * size discrim as lang * add in gamut * fix types * catch a bug --- LintLanguageDocs.md | 3 + README.md | 17 +- package.json | 1 + public/lint-schema.json | 58 +++- src/lib/ColorLint.test.ts | 242 +++++++++++++-- src/lib/ColorLint.ts | 52 ++++ src/lib/{lints => }/CustomLint.ts | 22 +- src/lib/__snapshots__/ColorLint.test.ts.snap | 40 ++- src/lib/api-calls.ts | 46 ++- src/lib/blindness.ts | 9 +- src/lib/color-stats.ts | 293 ------------------ src/lib/lint-language/LintLanguage.test.ts | 4 +- src/lib/lint-language/lint-language.ts | 74 +++-- src/lib/lint-language/lint-type.ts | 5 +- src/lib/linter-tools/lint-fixer.ts | 58 ++++ src/lib/linter-tools/lint-worker.worker.ts | 83 +++++ src/lib/linter.ts | 67 ++-- src/lib/lints/ColorLint.ts | 103 ------ src/lib/lints/avoid-extremes.ts | 33 ++ src/lib/lints/background-contrast.ts | 67 ++++ src/lib/lints/background-differentiability.ts | 53 ---- src/lib/lints/cat-order-similarity.ts | 35 +++ src/lib/lints/color-blindness.ts | 49 +++ src/lib/lints/color-similarity.ts | 44 --- src/lib/lints/contrast.ts | 42 --- src/lib/lints/diverging-order.ts | 83 ++--- src/lib/lints/even-distribution.ts | 4 +- src/lib/lints/fair.ts | 128 +++----- src/lib/lints/in-gamut.ts | 34 ++ src/lib/lints/max-colors.ts | 27 ++ src/lib/lints/mutually-distinct.ts | 29 ++ src/lib/lints/name-discrim.ts | 94 ++---- src/lib/lints/sequential-order.ts | 75 +++-- src/lib/lints/size-discrim.ts | 129 ++++---- src/lib/lints/ugly-colors.ts | 35 +++ src/lib/utils.ts | 10 + src/linting/Eval.svelte | 18 +- src/linting/EvalResponse.svelte | 70 ++--- src/linting/ExplanationViewer.svelte | 2 +- src/linting/LintCustomizationModal.svelte | 22 +- src/linting/LintDisplay.svelte | 2 +- src/stores/built-in-lints.ts | 160 ---------- src/stores/lint-store.ts | 17 +- vite.config.ts | 3 + vitest.config.ts | 2 + yarn.lock | 7 + 46 files changed, 1289 insertions(+), 1162 deletions(-) create mode 100644 src/lib/ColorLint.ts rename src/lib/{lints => }/CustomLint.ts (78%) delete mode 100644 src/lib/color-stats.ts create mode 100644 src/lib/linter-tools/lint-fixer.ts create mode 100644 src/lib/linter-tools/lint-worker.worker.ts delete mode 100644 src/lib/lints/ColorLint.ts create mode 100644 src/lib/lints/avoid-extremes.ts create mode 100644 src/lib/lints/background-contrast.ts delete mode 100644 src/lib/lints/background-differentiability.ts create mode 100644 src/lib/lints/cat-order-similarity.ts create mode 100644 src/lib/lints/color-blindness.ts delete mode 100644 src/lib/lints/color-similarity.ts delete mode 100644 src/lib/lints/contrast.ts create mode 100644 src/lib/lints/in-gamut.ts create mode 100644 src/lib/lints/max-colors.ts create mode 100644 src/lib/lints/mutually-distinct.ts create mode 100644 src/lib/lints/ugly-colors.ts delete mode 100644 src/stores/built-in-lints.ts diff --git a/LintLanguageDocs.md b/LintLanguageDocs.md index a696cd1d..4228034e 100644 --- a/LintLanguageDocs.md +++ b/LintLanguageDocs.md @@ -24,6 +24,7 @@ Math Operations: +: {left: Number | Variable, right: Number | Variable} /: {left: Number | Variable, right: Number | Variable} -: {left: Number | Variable, right: Number | Variable} +absDiff: {left: Number | Variable, right: Number | Variable} Value Comparisons: {dist: {left: Color | Variable, right: Color | Variable}, space: COLOR_SPACE } @@ -44,10 +45,12 @@ Color Manipulations: {toColor: variableName, space: 'lab' | 'hsl' | etc, channel: 'a' | 'b' | 'l' | etc} {cvdSim: variableName, type: 'protanomaly' | 'deuteranomaly' | 'tritanopia' | 'grayscale'} {name: variableName} +{inGamut: variableName | Color} Maps: {map: Variable | Value[], func: Operation, varb: Variable} {sort: Variable | Value[], func: Operation, varb: Variable} +{reverse: Variable | Value[]} {filter: Variable | Value[], func: EXPR, varb: Variable} ```yaml diff --git a/README.md b/README.md index a14db188..1ca300f0 100644 --- a/README.md +++ b/README.md @@ -12,36 +12,47 @@ First time you start it up you should also run `yarn prep data` # User study burn down -- [ ] Make lints fast / non blocking as much as possible -- [ ] Get most of the lints converted - [ ] Tour? - [ ] roles, palette level semantics - [ ] Design adjustments for smaller screens +- [ ] Language Docs?? +- [x] Get most of the lints converted +- [x] Make lints fast / non blocking as much as possible # Language todos +- [ ] Clone Rule +- [ ] Affect rules - [ ] Add more blame robustness, may pay to try to reason across all of the operator families (insight: keep a list of the blamable variables in the environment to support tracing) - [ ] per cols 4 all: color blindness metric should maybe be sensitive to task? - [ ] Sequential check fix is incorrect for things with equi-ligthness - [ ] Macros story: "not similar", "sequences", "where": { "!=": {"left": "index(a)", "right": "index(b)"} }, +- [x] "No out of gamut" # General Todos +- [ ] Its really annoying to have to update schema, types, docs whenever a lang change is made. Can this be automated? +- [ ] Compact more compact +- [ ] Merge the info and fixes tooltips in the lints +- [ ] Color Space selections should persist +- [ ] Make name discrim hueristc fix more resilient, see switching to basic colors - [ ] Search palettes-by-lint screen - [ ] Drag to re-order points? - [ ] off by one persistance error in undo/redo - [ ] Changing spaces is pretty bad on lab <-> oklab, cf ("#35ffbf", "#87b995", "#e84f82") - [ ] "Easy on ramp" progressive disclosure - [ ] Labels, tooltips, etc +- [ ] Handles get stuck on channel picker again, ugh - [ ] Bug: Color channel usage slightly cursed (doesn't update positions correctly) - [ ] Bug: rotate in polar coordinates doesn't work right - [ ] Performance stuff for linter (separate message generation into something very throttled, maybe move to web workers? Cache as hard as possible) +- [ ] LCH colors upside down god damn it Parameterization epic: - [ ] Allow no palettes, allows renaming of non-current palettes, which would be enable by: - [ ] Parameterize the scatter column completely, allow edits to the compare stuff -- [ ] Hover broken on compare +- [ ] Hover broken on compare, also make sure it takes the right stringify (same as the color channel) - [ ] Select compare from palettes drop down menu # Nice to have diff --git a/package.json b/package.json index 812e8b93..1b0cd44a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@types/color-namer": "^1.3.3", "@types/d3": "^7.4.3", "@vitest/coverage-v8": "^1.2.2", + "@vitest/web-worker": "^1.2.2", "autoprefixer": "^10.4.16", "jsdom": "^24.0.0", "netlify-cli": "^17.10.1", diff --git a/public/lint-schema.json b/public/lint-schema.json index eb2e98c1..351b5d56 100644 --- a/public/lint-schema.json +++ b/public/lint-schema.json @@ -16,7 +16,7 @@ "type": "object", "additionalProperties": false, "patternProperties": { - "^(\\+|\\-|\\*|\\/)$": { + "^(\\+|\\-|\\*|\\/|absDiff)$": { "anyOf": [ { "type": "number" }, { "$ref": "#/definitions/LintVariable" } @@ -70,6 +70,7 @@ "contrast": { "type": "object", "additionalProperties": false, + "description": "Compute the contrast between two colors using a given algorithm. The algorithm can be APCA, WCAG21, Michelson, Weber, Lstar or DeltaPhi.", "properties": { "left": { "$ref": "#/definitions/LintRef" }, "right": { "$ref": "#/definitions/LintRef" }, @@ -92,13 +93,46 @@ ] }, "LintMap": { - "type": "object", - "additionalProperties": false, - "properties": { - "filter": { "$ref": "#/definitions/LintVariable" }, - "func": { "$ref": "#/definitions/LintExpression" } - }, - "required": ["filter", "func"] + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "filter": { "$ref": "#/definitions/LintVariable" }, + "func": { "$ref": "#/definitions/LintExpression" }, + "varb": { "$ref": "#/definitions/LintVariable" } + }, + "required": ["filter", "func", "varb"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "map": { "$ref": "#/definitions/LintVariable" }, + "func": { "$ref": "#/definitions/LintExpression" }, + "varb": { "$ref": "#/definitions/LintVariable" } + }, + "required": ["map", "func", "varb"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "reduce": { "$ref": "#/definitions/LintVariable" } + }, + "required": ["reverse"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "sort": { "$ref": "#/definitions/LintVariable" }, + "func": { "$ref": "#/definitions/LintExpression" }, + "varb": { "$ref": "#/definitions/LintVariable" } + }, + "required": ["sort", "func", "varb"] + } + ] }, "LintReduce": { "type": "object", @@ -118,6 +152,7 @@ { "type": "object", "additionalProperties": false, + "description": "Simulate color vision deficiency. The type can be protanomaly, deuteranomaly, tritanopia or grayscale.", "properties": { "cvdSim": { "$ref": "#/definitions/LintVariable" }, "type": { @@ -131,7 +166,12 @@ }, "required": ["cvdSim", "type"] }, - { "$ref": "#/definitions/LintVariable" }, + { + "type": "object", + "additionalProperties": false, + "properties": { "inGamut": { "$ref": "#/definitions/LintVariable" } }, + "required": ["inGamut"] + }, { "type": "object", "additionalProperties": false, diff --git a/src/lib/ColorLint.test.ts b/src/lib/ColorLint.test.ts index ed777a43..e5385b09 100644 --- a/src/lib/ColorLint.test.ts +++ b/src/lib/ColorLint.test.ts @@ -3,10 +3,22 @@ import { expect, test } from "vitest"; import { Color } from "./Color"; import type { Palette } from "../stores/color-store"; +import { CreateCustomLint } from "./CustomLint"; +import { suggestLintFix } from "./linter-tools/lint-fixer"; + +// Lints +import AvoidExtremes from "./lints/avoid-extremes"; +import BackgroundContrast from "./lints/background-contrast"; +import CatOrderSimilarity from "./lints/cat-order-similarity"; +import ColorBlindness from "./lints/color-blindness"; import ColorNameDiscriminability, { getName } from "./lints/name-discrim"; -import BackgroundDifferentiability from "./lints/background-differentiability"; -import BUILT_INS from "../stores/built-in-lints"; -import { CreateCustomLint } from "./lints/CustomLint"; +import Discrims from "./lints/size-discrim"; +import Fair from "./lints/fair"; +import Gamut from "./lints/in-gamut"; +import MaxColors from "./lints/max-colors"; +import MutuallyDistinct from "./lints/mutually-distinct"; +import SequentialOrder from "./lints/sequential-order"; +import UglyColors from "./lints/ugly-colors"; function makePalFromHexes(hexes: string[]): Palette { return { @@ -18,17 +30,166 @@ function makePalFromHexes(hexes: string[]): Palette { colorSpace: "lab", }; } - +function makePalFromString(strings: string[]): Palette { + return { + colors: strings.map((str) => Color.colorFromString(str, "lab")), + background: Color.colorFromString("#ffffff", "lab"), + name: "test", + type: "categorical", + evalConfig: {}, + colorSpace: "lab", + }; +} const unique = (arr: T[]): T[] => [...new Set(arr)]; +test("ColorLint - AvoidExtremes", () => { + const examplePal = makePalFromHexes([ + "#000000", + "#ffffff", + "#ff7e0e", + "#00ff00", + "#0084a9", + "#0000ff", + ]); + const newLint = CreateCustomLint(AvoidExtremes); + const exampleLint = new newLint(examplePal).run(); + expect(exampleLint.passes).toBe(false); + expect(exampleLint.message).toBe( + "Colors at either end of the lightness spectrum #000, #fff, #0f0, #00f are hard to discriminate in some contexts, and are sometimes advised against" + ); +}); + +test("ColorLint - MutuallyDistinct", () => { + const examplePal = makePalFromHexes([ + "#000000", + "#ffffff", + "#ff0000", + "#00ff00", + "#0000ff", + ]); + const newLint = CreateCustomLint(MutuallyDistinct); + const exampleLint = new newLint(examplePal).run(); + expect(exampleLint.passes).toBe(true); + expect(exampleLint.message).toMatchSnapshot(); + + // TODO add a failing case + const examplePal2 = makePalFromHexes(["#d2b48c", "#f5f5dc", "#d7fcef"]); + const exampleLint2 = new newLint(examplePal2).run(); + expect(exampleLint2.passes).toBe(false); + expect(exampleLint2.message).toMatchSnapshot(); +}); + +test("ColorLint - MaxColors", () => { + const examplePal = makePalFromHexes(["#000000"]); + const newLint = CreateCustomLint(MaxColors); + const exampleLint = new newLint(examplePal).run(); + expect(exampleLint.passes).toBe(true); + expect(exampleLint.message).toMatchSnapshot(); + + const examplePal2 = makePalFromHexes([...new Array(20)].map(() => "#000000")); + const exampleLint2 = new newLint(examplePal2).run(); + expect(exampleLint2.passes).toBe(false); + expect(exampleLint2.message).toMatchSnapshot(); +}); + +test("ColorLint - UglyColors", () => { + const examplePal = makePalFromHexes(["#000000"]); + const newLint = CreateCustomLint(UglyColors); + const exampleLint = new newLint(examplePal).run(); + expect(exampleLint.passes).toBe(true); + expect(exampleLint.message).toMatchSnapshot(); + + const examplePal2 = makePalFromHexes(["#000000", "#56FF22"]); + const exampleLint2 = new newLint(examplePal2).run(); + expect(exampleLint2.passes).toBe(false); + expect(exampleLint2.message).toMatchSnapshot(); +}); + +test("ColorLint - Fair Nominal", () => { + const examplePal = makePalFromHexes(["#000000"]); + const newLint = CreateCustomLint(Fair[0]); + const exampleLint = new newLint(examplePal).run(); + expect(exampleLint.passes).toBe(true); + expect(exampleLint.message).toMatchSnapshot(); + + const examplePal2 = makePalFromHexes(["#debdb5", "#2a2a2a", "#76fc00"]); + const exampleLint2 = new newLint(examplePal2).run(); + expect(exampleLint2.passes).toBe(false); + expect(exampleLint2.message).toMatchSnapshot(); +}); + +test("ColorLint - Fair Sequential", () => { + const examplePal = makePalFromHexes(["#000000"]); + const newLint = CreateCustomLint(Fair[1]); + const exampleLint = new newLint(examplePal).run(); + expect(exampleLint.passes).toBe(true); + expect(exampleLint.message).toMatchSnapshot(); + + const examplePal2 = makePalFromHexes(["#debdb5", "#2a2a2a", "#76fc00"]); + const exampleLint2 = new newLint(examplePal2).run(); + expect(exampleLint2.passes).toBe(false); + expect(exampleLint2.message).toMatchSnapshot(); +}); + +test("ColorLint - SequentialOrder", () => { + const examplePal = makePalFromHexes([ + "#0084a9", + "#009de5", + "#5fb1ff", + "#bbc3ff", + "#ecddff", + ]); + const newLint = CreateCustomLint(SequentialOrder); + const exampleLint = new newLint(examplePal).run(); + expect(exampleLint.passes).toBe(true); + expect(exampleLint.message).toMatchSnapshot(); + + const examplePal2 = makePalFromHexes([ + "#0084a9", + "#009de5", + "#5fb1ff", + "#ecddff", + "#bbc3ff", + ]); + const exampleLint2 = new newLint(examplePal2).run(); + expect(exampleLint2.passes).toBe(false); + expect(exampleLint2.message).toMatchSnapshot(); +}); + +test("ColorLint - CatOrderSimilarity", () => { + const examplePal = makePalFromHexes([ + "#0084a9", + "#009de5", + "#8ca9fa", + "#bbc3ff", + "#ecddff", + ]); + const newLint = CreateCustomLint(CatOrderSimilarity); + const exampleLint = new newLint(examplePal).run(); + expect(exampleLint.passes).toBe(true); + expect(exampleLint.message).toMatchSnapshot(); + + const examplePal2 = makePalFromHexes([ + "#0084a9", + "#009de5", + "#5fb1ff", + "#bbc3ff", + "#ecddff", + ]); + const exampleLint2 = new newLint(examplePal2).run(); + expect(exampleLint2.passes).toBe(false); + expect(exampleLint2.message).toMatchSnapshot(); +}); + test("ColorLint - ColorNameDiscriminability", async () => { const examplePal = makePalFromHexes(["#5260d1", "#005ebe"]); - const exampleLint = new ColorNameDiscriminability(examplePal).run(); + const lint = CreateCustomLint(ColorNameDiscriminability); + const exampleLint = new lint(examplePal).run(); expect(exampleLint.passes).toBe(false); expect(exampleLint.message).toBe( - "Color Name discriminability check failed. The following color names are repeated: Royalblue (#5260d1, #005ebe)" + "The following pairs of colors have the same name: #5260d1 and #005ebe" ); - const fix = await exampleLint.suggestFix(); + const fix = await suggestLintFix(examplePal, exampleLint); const oldColorNames = unique( examplePal.colors.map((x) => getName(x)) ); @@ -37,6 +198,42 @@ test("ColorLint - ColorNameDiscriminability", async () => { expect(colorNames.length).toBe(2); }); +test("ColorLint - SizeDiscrim (Thin)", () => { + const examplePal = makePalFromHexes(["#0084a9", "#bad", "#008000"]); + const newLint = CreateCustomLint(Discrims[0]); + const exampleLint = new newLint(examplePal).run(); + expect(exampleLint.passes).toBe(true); + expect(exampleLint.message).toMatchSnapshot(); + + const examplePal2 = makePalFromHexes(["#0084a9", "#009de5", "#8ca9fa"]); + const exampleLint2 = new newLint(examplePal2).run(); + expect(exampleLint2.passes).toBe(false); + expect(exampleLint2.message).toMatchSnapshot(); +}); + +test("ColorLint - Gamut", async () => { + const examplePal = makePalFromString(["lab(50.625% -91.737 -88.303)"]); + const newLint = CreateCustomLint(Gamut); + const exampleLint = new newLint(examplePal).run(); + expect(exampleLint.passes).toBe(false); + expect(exampleLint.message).toMatchSnapshot(); + + const fix = await suggestLintFix(examplePal, exampleLint); + expect(fix[0].colors.map((x) => x.toString())).toStrictEqual([ + "lab(51.296% -23.327 -25.373)", + ]); + + const examplePal2 = makePalFromString([ + "#0084a9", + "#009de5", + "#8ca9fa", + "#ff0000", + ]); + const exampleLint2 = new newLint(examplePal2).run(); + expect(exampleLint2.passes).toBe(true); + expect(exampleLint2.message).toMatchSnapshot(); +}); + test("ColorLint - ColorBlind", async () => { const tableau10 = [ "#0078b4", @@ -51,52 +248,45 @@ test("ColorLint - ColorBlind", async () => { "#00becf", ]; const examplePal = makePalFromHexes(tableau10); - const cbDeuteranopia = CreateCustomLint( - BUILT_INS.find((x) => x.id === "colorblind-friendly-deuteranopia-built-in")! - ); + const cbDeuteranopia = CreateCustomLint(ColorBlindness[0]); const exampleLint1 = new cbDeuteranopia(examplePal).run(); expect(exampleLint1.passes).toBe(false); expect(exampleLint1.message).toMatchSnapshot(); - const cbProtanopia = CreateCustomLint( - BUILT_INS.find((x) => x.id === "colorblind-friendly-protanopia-built-in")! - ); + const cbProtanopia = CreateCustomLint(ColorBlindness[1]); const exampleLint2 = new cbProtanopia(examplePal).run(); expect(exampleLint2.passes).toBe(false); expect(exampleLint2.message).toMatchSnapshot(); - const cbTritanopia = CreateCustomLint( - BUILT_INS.find((x) => x.id === "colorblind-friendly-tritanopia-built-in")! - ); + const cbTritanopia = CreateCustomLint(ColorBlindness[2]); const exampleLint3 = new cbTritanopia(examplePal).run(); expect(exampleLint3.passes).toBe(false); expect(exampleLint3.message).toMatchSnapshot(); - const cbGrayscale = CreateCustomLint( - BUILT_INS.find((x) => x.id === "colorblind-friendly-grayscale-built-in")! - ); + const cbGrayscale = CreateCustomLint(ColorBlindness[3]); const exampleLint4 = new cbGrayscale(examplePal).run(); expect(exampleLint4.passes).toBe(false); expect(exampleLint4.message).toMatchSnapshot(); }); const ughWhat = ["#00ffff", "#00faff", "#00e4ff", "#fdfdfc", "#00ffff"]; -test("ColorLint - BackgroundDifferentiability", async () => { +test("ColorLint - Background Contrast", async () => { const examplePal = makePalFromHexes(ughWhat); - const exampleLint = new BackgroundDifferentiability(examplePal).run(); + const BackgroundContrastLint = CreateCustomLint(BackgroundContrast); + const exampleLint = new BackgroundContrastLint(examplePal).run(); expect(exampleLint.passes).toBe(false); expect(exampleLint.message).toBe( - "This palette has some colors (#fdfdfc) that are close to the background color" + "These colors (#fdfdfc) do not have a sufficient contrast ratio with the background and may be hard to discriminate in some contexts." ); - const fix = await exampleLint.suggestFix().then((x) => x[0]); + const fix = await suggestLintFix(examplePal, exampleLint).then((x) => x[0]); expect(fix.colors.map((x) => x.toHex())).toMatchSnapshot(); examplePal.background = Color.colorFromHex("#00e4ff", "lab"); - const exampleLint2 = new BackgroundDifferentiability(examplePal).run(); + const exampleLint2 = new BackgroundContrastLint(examplePal).run(); expect(exampleLint2.passes).toBe(false); expect(exampleLint2.message).toBe( - "This palette has some colors (#0ff, #00faff, #00e4ff, #0ff) that are close to the background color" + "These colors (#00e4ff) do not have a sufficient contrast ratio with the background and may be hard to discriminate in some contexts." ); - const fix2 = await exampleLint2.suggestFix().then((x) => x[0]); + const fix2 = await suggestLintFix(examplePal, exampleLint2).then((x) => x[0]); expect(fix2.colors.map((x) => x.toHex())).toMatchSnapshot(); }); diff --git a/src/lib/ColorLint.ts b/src/lib/ColorLint.ts new file mode 100644 index 00000000..05179837 --- /dev/null +++ b/src/lib/ColorLint.ts @@ -0,0 +1,52 @@ +import type { Palette } from "../stores/color-store"; + +export type TaskType = "sequential" | "diverging" | "categorical"; +export type LintLevel = "error" | "warning"; +export interface LintResult { + name: string; + passes: boolean; + message: string; + level: LintLevel; + group: string; + description: string; + isCustom: false | string; + taskTypes: TaskType[]; + subscribedFix: string; +} + +export class ColorLint { + name: string = ""; + taskTypes: TaskType[] = []; + passes: boolean; + checkData: CheckData; + palette: Palette; + message: string = ""; + isCustom: false | string = false; + group: string = ""; + description: string = ""; + blameMode: "pair" | "single" | "none" = "none"; + level: LintLevel = "error"; + subscribedFix: string = "none"; + + constructor(Palette: Palette) { + this.palette = Palette; + this.checkData = undefined as CheckData; + this.passes = false; + } + + run(options: any = {}) { + const { passCheck, data } = this._runCheck(options); + this.passes = passCheck; + this.checkData = data as CheckData; + this.message = this.buildMessage(); + return this; + } + + _runCheck(_options: any): { passCheck: boolean; data: CheckData } { + return { passCheck: true, data: {} as CheckData }; + } + // Fail Message + buildMessage(): string { + return ""; + } +} diff --git a/src/lib/lints/CustomLint.ts b/src/lib/CustomLint.ts similarity index 78% rename from src/lib/lints/CustomLint.ts rename to src/lib/CustomLint.ts index f0fade5e..268b4b00 100644 --- a/src/lib/lints/CustomLint.ts +++ b/src/lib/CustomLint.ts @@ -1,11 +1,10 @@ -import type { LintProgram } from "../lint-language/lint-type"; import { ColorLint } from "./ColorLint"; import type { TaskType } from "./ColorLint"; import { LLEval, prettyPrintLL, permutativeBlame, -} from "../lint-language/lint-language"; +} from "./lint-language/lint-language"; import * as Json from "jsonc-parser"; export interface CustomLint { @@ -18,6 +17,7 @@ export interface CustomLint { failMessage: string; id: string; blameMode: "pair" | "single" | "none"; + subscribedFix?: string; } export function CreateCustomLint(props: CustomLint) { @@ -27,24 +27,24 @@ export function CreateCustomLint(props: CustomLint) { level = props.level; group = props.group; description = props.description; - hasHeuristicFix = false; isCustom = props.id; blameMode = props.blameMode; + subscribedFix = props.subscribedFix || "none"; - _runCheck() { + _runCheck(options: any) { const prog = Json.parse(props.program); const { blame, result } = LLEval(prog, this.palette, { debugCompare: false, + ...options, }); if (result) return { passCheck: true, data: blame }; - return { - passCheck: result, - data: - props.blameMode !== "none" - ? permutativeBlame(prog, this.palette, props.blameMode) - : [], - }; + let newBlame: number[] | number[][] = []; + if (this.blameMode !== "none") { + newBlame = permutativeBlame(prog, this.palette, this.blameMode); + } + + return { passCheck: result, data: newBlame }; } buildMessage() { diff --git a/src/lib/__snapshots__/ColorLint.test.ts.snap b/src/lib/__snapshots__/ColorLint.test.ts.snap index 33493975..493a8935 100644 --- a/src/lib/__snapshots__/ColorLint.test.ts.snap +++ b/src/lib/__snapshots__/ColorLint.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`ColorLint - BackgroundDifferentiability 1`] = ` +exports[`ColorLint - Background Contrast 1`] = ` [ "#0ff", "#00faff", @@ -10,7 +10,7 @@ exports[`ColorLint - BackgroundDifferentiability 1`] = ` ] `; -exports[`ColorLint - BackgroundDifferentiability 2`] = ` +exports[`ColorLint - Background Contrast 2`] = ` [ "#007070", "#006f72", @@ -20,6 +20,10 @@ exports[`ColorLint - BackgroundDifferentiability 2`] = ` ] `; +exports[`ColorLint - CatOrderSimilarity 1`] = `"Some sequences of colors are too similar based on dE scores: . Try reordering them or making them more distinguishable"`; + +exports[`ColorLint - CatOrderSimilarity 2`] = `"Some sequences of colors are too similar based on dE scores: #009de5 and #5fb1ff. Try reordering them or making them more distinguishable"`; + exports[`ColorLint - ColorBlind 1`] = `"This palette is not colorblind friendly for deuteranopia color blindness (ie can't see green). The following pairs are undifferentiable: (#0078b4 and #8c69bc, #ff7e0e and #c4bc27, #3d9f2f and #da2827, #e179c1 and #00becf)"`; exports[`ColorLint - ColorBlind 2`] = `"This palette is not colorblind friendly for protanopia color blindness (ie can't see red). The following pairs are undifferentiable: (#0078b4 and #8c69bc, #ff7e0e and #3d9f2f, #da2827 and #8e564b)"`; @@ -27,3 +31,35 @@ exports[`ColorLint - ColorBlind 2`] = `"This palette is not colorblind friendly exports[`ColorLint - ColorBlind 3`] = `"This palette is not colorblind friendly for tritanopia color blindness (ie can't see blue). The following pairs are undifferentiable: (#ff7e0e and #e179c1, #8c69bc and #7f7f7f)"`; exports[`ColorLint - ColorBlind 4`] = `"This palette is not colorblind friendly for grayscale color blindness . The following pairs are undifferentiable: (#0078b4 and #da2827, #0078b4 and #8c69bc, #0078b4 and #8e564b, #0078b4 and #7f7f7f, #ff7e0e and #3d9f2f, #ff7e0e and #e179c1, #ff7e0e and #c4bc27, #ff7e0e and #00becf, #3d9f2f and #8c69bc, #3d9f2f and #e179c1, #3d9f2f and #7f7f7f, #da2827 and #8c69bc, #da2827 and #8e564b, #da2827 and #7f7f7f, #8c69bc and #8e564b, #8c69bc and #7f7f7f, #e179c1 and #c4bc27, #e179c1 and #00becf, #c4bc27 and #00becf)"`; + +exports[`ColorLint - Fair Nominal 1`] = `"This palette is unfair (meaning that some values may unduely stand out). Note that this check is naturally at odds with color blind friendly palettes. Maximum chroma range: 80, maximum luminance range: 50."`; + +exports[`ColorLint - Fair Nominal 2`] = `"This palette is unfair (meaning that some values may unduely stand out). Note that this check is naturally at odds with color blind friendly palettes. Maximum chroma range: 80, maximum luminance range: 50."`; + +exports[`ColorLint - Fair Sequential 1`] = `"This palette is unfair (meaning that some values may unduely stand out). Note that this check is naturally at odds with color blind friendly palettes. Maximum chroma range: 50."`; + +exports[`ColorLint - Fair Sequential 2`] = `"This palette is unfair (meaning that some values may unduely stand out). Note that this check is naturally at odds with color blind friendly palettes. Maximum chroma range: 50."`; + +exports[`ColorLint - Gamut 1`] = `"The color is not in the sRGB gamut. Please choose a color that is in the sRGB gamut."`; + +exports[`ColorLint - Gamut 2`] = `"The color is not in the sRGB gamut. Please choose a color that is in the sRGB gamut."`; + +exports[`ColorLint - MaxColors 1`] = `"This palette has too many colors and may be hard to discriminate in some contexts. Maximum: 10."`; + +exports[`ColorLint - MaxColors 2`] = `"This palette has too many colors and may be hard to discriminate in some contexts. Maximum: 10."`; + +exports[`ColorLint - MutuallyDistinct 1`] = `"Some colors in this palette () are not differentiable from each other."`; + +exports[`ColorLint - MutuallyDistinct 2`] = `"Some colors in this palette (#f5f5dc and #d7fcef) are not differentiable from each other."`; + +exports[`ColorLint - SequentialOrder 1`] = `"This pal should be ordered by lightness if being used as a sequential palette. may be to blame."`; + +exports[`ColorLint - SequentialOrder 2`] = `"This pal should be ordered by lightness if being used as a sequential palette. #ecddff, #bbc3ff may be to blame."`; + +exports[`ColorLint - SizeDiscrim (Thin) 1`] = `"This palette has some colors () that are close to each other in perceptual space and will not be resolvable for Thin areas."`; + +exports[`ColorLint - SizeDiscrim (Thin) 2`] = `"This palette has some colors (#0084a9 and #009de5) that are close to each other in perceptual space and will not be resolvable for Thin areas."`; + +exports[`ColorLint - UglyColors 1`] = `"This palette has some colors (specifically ) that are close to what are known as ugly colors"`; + +exports[`ColorLint - UglyColors 2`] = `"This palette has some colors (specifically #56ff22) that are close to what are known as ugly colors"`; diff --git a/src/lib/api-calls.ts b/src/lib/api-calls.ts index 29bc165d..5eb4f0c9 100644 --- a/src/lib/api-calls.ts +++ b/src/lib/api-calls.ts @@ -1,6 +1,6 @@ -import { Color } from "./Color"; import type { Palette } from "../stores/color-store"; import * as Json from "jsonc-parser"; +import LintWorker from "./linter-tools/lint-worker.worker?worker"; type Engine = "openai" | "google"; type SimplePal = { background: string; colors: string[] }; @@ -125,3 +125,47 @@ export function suggestLintMetadata(lintProgram: string, engine: Engine) { name: string; }>(`suggest-lint-metadata`, body, true); } + +// instantiate the worker +const ViteWorker = new LintWorker(); + +// send and receive messages from the worker +type Message = { type: string; content: string; id?: string }; +const randID = () => Math.random().toString(36).substring(7); +function workerDispatch() { + const waitingCallbacks: { [key: string]: (msg: string) => void } = {}; + ViteWorker.addEventListener("message", (msg: MessageEvent) => { + const { id, content } = msg.data; + if (id && waitingCallbacks[id]) { + waitingCallbacks[id](content); + delete waitingCallbacks[id]; + } + }); + + return async function caller(msg: Message) { + const id = randID(); + ViteWorker.postMessage({ ...msg, id }); + return new Promise((resolve) => { + waitingCallbacks[id] = resolve; + }); + }; +} +const dispatch = workerDispatch(); + +export function lint(pal: Palette) { + // this may be too deep a copy? + return dispatch({ + type: "run-lint", + content: JSON.stringify({ + ...pal, + background: pal.background.toString(), + colors: pal.colors.map((x) => x.toString()), + }), + }).then((x) => { + return x as unknown as any[]; + }); +} + +export function loadLints() { + return dispatch({ type: "load-lints", content: "" }); +} diff --git a/src/lib/blindness.ts b/src/lib/blindness.ts index 2cf7667e..b93c0d92 100644 --- a/src/lib/blindness.ts +++ b/src/lib/blindness.ts @@ -111,10 +111,15 @@ function dl_simulate_cvd( return brettelFunctions[deficiency](color); } +const simulationCache = new Map(); export default function simulate_cvd( deficiency: DLDeficiency, color: Color ): Color { + const key = deficiency + color.toString(); + if (simulationCache.has(key)) { + return simulationCache.get(key)!; + } const colorIOcolor = color.toColorIO(); const isachroma = deficiency == "achromatopsia" || deficiency == "achromatomaly"; @@ -124,5 +129,7 @@ export default function simulate_cvd( const newCoords = dl_simulate_cvd(deficiency, coords); const newColorIO = new ColorIO(spaceName, newCoords).to(color.spaceName); - return color.fromChannels(newColorIO.coords); + const result = color.fromChannels(newColorIO.coords); + simulationCache.set(key, result); + return result; } diff --git a/src/lib/color-stats.ts b/src/lib/color-stats.ts deleted file mode 100644 index 77fdd4e9..00000000 --- a/src/lib/color-stats.ts +++ /dev/null @@ -1,293 +0,0 @@ -/////////// JEFF'S CODE /////////////////////// - -// // const jsonData = require("../assets/c3-data.json"); -// import jsonData from "../assets/c3-data.json"; - -// function c3_api() { -// const C = c3.color.length; -// const W = c3.terms.length; -// const T = c3.T; -// const A = c3.A; -// const ccount = c3.color.count; -// const tcount = c3.terms.count; - -// c3.count = (c: number, w: number) => T[c * W + w] || 0; -// c3.terms.prob = (w: number, c: number) => (T[c * W + w] || 0) / tcount[w]; - -// c3.terms.entropy = function (w: number) { -// let H = 0; -// let p; -// for (let c = 0; c < C; ++c) { -// p = (T[c * W + w] || 0) / tcount[w]; -// if (p > 0) H += (p * Math.log(p)) / Math.LN2; -// } -// return H; -// }; - -// c3.terms.perplexity = (w: any) => Math.pow(2, -c3.terms.entropy(w)); - -// c3.terms.cosine = function (a: number, b: number) { -// let sa = 0; -// let sb = 0; -// let sc = 0; -// let ta: number; -// let tb: number; -// for (let c = 0; c < C; ++c) { -// ta = T[c * W + a] || 0; -// tb = T[c * W + b] || 0; -// sa += ta * ta; -// sb += tb * tb; -// sc += ta * tb; -// } -// return sc / Math.sqrt(sa * sb); -// }; - -// c3.color.cosine = function (a: number, b: number) { -// let sa = 0; -// let sb = 0; -// let sc = 0; -// let ta: number; -// let tb: number; -// for (let w = 0; w < W; ++w) { -// ta = T[a * W + w] || 0; -// tb = T[b * W + w] || 0; -// sa += ta * ta; -// sb += tb * tb; -// sc += ta * tb; -// } -// return sc / Math.sqrt(sa * sb); -// }; - -// c3.color.prob = (c: number, w: number) => (T[c * W + w] || 0) / ccount[c]; - -// c3.color.entropy = function (c: number) { -// let H = 0; -// let p: number; -// for (let w = 0; w < W; ++w) { -// p = (T[c * W + w] || 0) / ccount[c]; -// if (p > 0) H += (p * Math.log(p)) / Math.LN2; -// } -// return H; -// }; - -// c3.terms.hellinger = function (a: number, b: number) { -// let bc = 0; -// let pa: number; -// let pb: number; -// let z = Math.sqrt(tcount[a] * tcount[b]); -// for (let c = 0; c < C; ++c) { -// pa = T[c * W + a] || 0; -// pb = T[c * W + b] || 0; -// bc += Math.sqrt(pa * pb); -// } -// return Math.sqrt(1 - bc / z); -// }; - -// c3.color.perplexity = (c: any) => Math.pow(2, -c3.color.entropy(c)); - -// c3.color.hellinger = function (a: number, b: number) { -// let bc = 0; -// let pa: number; -// let pb: number; -// let z = Math.sqrt(ccount[a] * ccount[b]); -// for (let w = 0; w < W; ++w) { -// pa = T[a * W + w] || 0; -// pb = T[b * W + w] || 0; -// bc += Math.sqrt(pa * pb); -// } -// return Math.sqrt(1 - bc / z); -// }; - -// c3.terms.relatedTerms = function (w: number, limit: number | undefined) { -// const c = c3.terms.center[w]; -// const list = []; -// for (let i = 0; i < W; ++i) { -// if (i != w) list.push({ index: i, score: A[i * W + w] }); -// } -// list.sort(function (a, b) { -// let ca: { de00: (arg0: any) => number }; -// let cb: { de00: (arg0: any) => number }; - -// let cmp = b.score - a.score; -// if (Math.abs(cmp) < 0.00005) { -// // break near ties by distance between centers -// ca = c3.terms.center[a.index]; -// cb = c3.terms.center[b.index]; -// cmp = ca.de00(c) - cb.de00(c); -// } -// return cmp; -// }); -// list.unshift({ index: w, score: A[w * W + w] }); -// return limit ? list.slice(0, limit) : list; -// }; - -// c3.terms.relatedColors = function (w: number, limit: number | undefined) { -// const list = []; -// for (let c = 0; c < C; ++c) { -// const s = (T[c * W + w] || 0) / ccount[c]; -// if (s > 0) list.push({ index: c, score: s }); -// } -// list.sort(function (a, b) { -// return b.score - a.score; -// }); -// return limit ? list.slice(0, limit) : list; -// }; - -// c3.color.relatedTerms = function ( -// c: number, -// limit: number | undefined, -// minCount: number -// ) { -// const cc = c * W; -// let list = []; -// let sum = 0; -// let s: any; -// let cnt = c3.terms.count; -// for (let w = 0; w < W; ++w) { -// if ((s = T[cc + w]) !== undefined) { -// list.push({ index: w, score: s }); -// sum += s; -// } -// } -// if (minCount) { -// list = list.filter(function (d) { -// return cnt[d.index] > minCount; -// }); -// } -// list.sort(function (a, b) { -// return b.score - a.score; -// }); -// list.forEach(function (d) { -// d.score /= sum; -// }); -// return limit ? list.slice(0, limit) : list; -// }; - -// // compute representative colors -// c3.terms.center = Array.from({ length: W }, (_, i) => i).map(function (w) { -// const list = c3.terms -// .relatedColors(w, 5) -// .map(function (d: { index: string | number }) { -// return c3.color[d.index]; -// }); -// let L = 0; -// let a = 0; -// let b = 0; -// let N = list.length; -// list.forEach(function (c: { L: any; a: any; b: any }) { -// L += c.L; -// a += c.a; -// b += c.b; -// }); -// return chroma.lab(Math.round(L / N), Math.round(a / N), Math.round(b / N)); -// // return d3.lab(Math.round(L / N), Math.round(a / N), Math.round(b / N)); -// }); -// } - -// function c3_init(json: { -// color: string | any[]; -// terms: any; -// T: string | any[]; -// A: any; -// }) { -// let i: number; -// let C: number; -// let W: number; -// let T: any[]; -// let A: any; -// let ccount: any[]; -// let tcount: any[]; - -// // parse colors -// c3.color = []; -// for (i = 0; i < json.color.length; i += 3) { -// c3.color[i / 3] = chroma.lab( -// json.color[i], -// json.color[i + 1], -// json.color[i + 2] -// ); -// } -// C = c3.color.length; - -// // parse terms -// c3.terms = json.terms; -// W = c3.terms.length; - -// // parse count table -// c3.T = T = []; -// for (let i = 0; i < json.T.length; i += 2) { -// T[json.T[i]] = json.T[i + 1]; -// } - -// // construct counts -// c3.color.count = ccount = []; -// for (i = 0; i < C; ++i) ccount[i] = 0; -// c3.terms.count = tcount = []; -// for (i = 0; i < W; ++i) tcount[i] = 0; -// T.forEach((_x, idx) => { -// const c = Math.floor(idx / W); -// const w = Math.floor(idx % W); -// const v = T[idx] || 0; -// ccount[c] += v; -// tcount[w] += v; -// }); - -// // parse word association matrix -// c3.A = A = json.A; - -// const labToString = (L: number, a: number, b: number) => -// [5 * Math.round(L / 5), 5 * Math.round(a / 5), 5 * Math.round(b / 5)].join( -// "," -// ); -// var map: Record = {}; -// for (var c = 0; c < c3.color.length; ++c) { -// const [L, a, b] = chroma(c3.color[c]).lab(); -// map[labToString(L, a, b)] = c; -// } - -// function index(c: string) { -// const [L, a, b] = chroma(c).lab(); -// const s = labToString(L, a, b); -// return map[s]; -// } - -// c3.colorIdentity = (colorString: string) => { -// // NOTE: entropy min/max currently hard-wired to XKCD results -// const minE = -4.5; -// const maxE = 0; -// var c = index(colorString); -// var h = (c3.color.entropy(c) - minE) / (maxE - minE); -// var t = c3.color -// .relatedTerms(c, 1) -// .map((x: any) => ({ score: x.score, word: c3.terms[x.index] })); -// var [L, a, b] = chroma(colorString).lab(); -// const z = ~~L + ", " + ~~a + ", " + ~~b; - -// return { x: colorString, c: c, h: h, terms: t, z: z }; -// }; -// } - -// export function colorNameDiscrimCheck( -// colorNames: { word: string }[] -// ): false | string { -// const colorNameCounts = colorNames.reduce((acc, colorName) => { -// if (!acc[colorName.word]) { -// acc[colorName.word] = 0; -// } -// acc[colorName.word] += 1; -// return acc; -// }, {} as Record); - -// const remaining = Object.entries(colorNameCounts as Record) -// .filter((x) => x[1] > 1) -// .map((x) => x[0]); -// if (remaining.length === 0) { -// return false; -// } -// const counts = remaining.map((x) => `${x} (${colorNameCounts[x]})`); -// return `Color Name discriminability check failed. The following color names are repeated: ${counts}`; -// } - -// export const c3: any = {}; -// c3_init(jsonData); -// c3_api(); diff --git a/src/lib/lint-language/LintLanguage.test.ts b/src/lib/lint-language/LintLanguage.test.ts index 1732bea9..b6dd1653 100644 --- a/src/lib/lint-language/LintLanguage.test.ts +++ b/src/lib/lint-language/LintLanguage.test.ts @@ -475,12 +475,12 @@ test("LintLanguage Measure Contrast", () => { contrast: { left: colors[0], right: colors[1] }, algorithm: "APCA", }, - right: -107.88473522404158, + right: 107.88473522404158, }, }; const result = LLEval(program, [] as any); expect(prettyPrintLL(program)).toBe( - "contrast(#000, #fff, APCA) == -107.88473522404158" + "contrast(#000, #fff, APCA) == 107.88473522404158" ); expect(result.result).toBe(true); expect(result.blame).toStrictEqual([]); diff --git a/src/lib/lint-language/lint-language.ts b/src/lib/lint-language/lint-language.ts index ff74eb5d..087960bd 100644 --- a/src/lib/lint-language/lint-language.ts +++ b/src/lib/lint-language/lint-language.ts @@ -331,7 +331,7 @@ export class LLNumber extends LLNode { } } -const LLNumberOpTypes = ["+", "-", "*", "/"] as const; +const LLNumberOpTypes = ["+", "-", "*", "/", "absDiff"] as const; export class LLNumberOp extends LLNode { constructor( private type: (typeof LLNumberOpTypes)[number], @@ -353,6 +353,8 @@ export class LLNumberOp extends LLNode { return { result: left * right, env }; case "/": return { result: left / right, env }; + case "absDiff": + return { result: Math.abs(left - right), env }; } } static tryToConstruct(node: any, options: OptionsConfig) { @@ -523,6 +525,11 @@ const VFTypes = [ op: (val: Color, params: Params) => val.toColorSpace(params.space as any).getChannel(params.channel), }, + { + primaryKey: "inGamut", + params: [], + op: (val: Color, _params: Params) => val.inGamut(), + }, ]; Object.entries(colorPickerConfig).map(([colorSpace, value]) => { @@ -583,10 +590,27 @@ const getOp = ops.find((x) => { const pk = node[x.primaryKey]; if (!pk) return false; + // special short circuit bc theres a collision between Color and name + if (node.spaceName) { + return false; + } const allParamsFound = x.params.every((key) => key in node); const noExtraParams = Object.keys(node).every( - (key) => x.params.includes(key) || key === x.primaryKey + (key) => (x.params as string[]).includes(key) || key === x.primaryKey ); + const allowedParamsMessage = x.params.length + ? `Allowed params are ${x.params.map((x) => `"${x}"`).join(", ")}` + : "No params allowed" + Object.keys(node); + if (!allParamsFound) { + throw new Error( + `Missing params for ${x.primaryKey}. ${allowedParamsMessage}` + ); + } + if (!noExtraParams) { + throw new Error( + `Extra params for ${x.primaryKey}. ${allowedParamsMessage}` + ); + } return allParamsFound && noExtraParams; }); const getParams = (op: any, node: any) => @@ -603,13 +627,16 @@ const LLPairFunctionTypes = [ primaryKey: "deltaE", params: ["algorithm"] as string[], op: (valA: Color, valB: Color, params: Params) => - valA.deltaE(valB, params.algorithm as any), + valA.symmetricDeltaE(valB, params.algorithm as any), }, { primaryKey: "contrast", params: ["algorithm"] as string[], - op: (valA: Color, valB: Color, params: Params) => - valA.toColorIO().contrast(valB.toColorIO(), params.algorithm as any), + op: (valA: Color, valB: Color, params: Params) => { + const a = valA.toColorIO(); + const b = valB.toColorIO(); + return Math.abs(a.contrast(b, params.algorithm as any)); + }, }, ]; export class LLPairFunction extends LLNode { @@ -681,7 +708,7 @@ export class LLQuantifier extends LLNode { evaluate(env: Environment) { this.evalCheck(env); // weird restoration of type information to make the forward stuff work - const type = tryTypes([LLValueArray, LLValue], env.options); + const type = tryTypes([LLValueArray, LLValue, LLMap], env.options); const idxType = tryTypes([LLValue], env.options); // materialize all combinations of the variables for each of the inputs const inputData = this.input @@ -801,7 +828,7 @@ const reduceTypes = [ export class LLReduces extends LLNode { constructor( private type: (typeof reduceTypes)[number], - private children: LLValueArray | LLVariable + private children: LLValueArray | LLVariable | LLMap ) { super(); } @@ -855,13 +882,13 @@ export class LLReduces extends LLNode { } } -const mapTypes = ["map", "filter", "sort"] as const; +const mapTypes = ["map", "filter", "sort", "reverse"] as const; // example syntax // {map: colors, func: {cvdSim: {type: "protanomaly"}}, varb: "x"} export class LLMap extends LLNode { constructor( private type: (typeof mapTypes)[number], - private children: LLValueArray | LLVariable, + private children: LLValueArray | LLVariable | LLMap, private func: LLValueFunction | LLPairFunction | LLNumberOp, private varb: string ) { @@ -882,26 +909,33 @@ export class LLMap extends LLNode { // implicitly ignore the pass back i guess? switch (this.type) { case "map": - return { result: children.map(evalFunc), env }; + return { result: children.map(evalFunc) as RawValues[], env }; case "filter": return { result: children.filter(evalFunc), env }; case "sort": - return { - result: children.map(evalFunc).sort(), - env, - }; + const childrenCopy = [...children].map(evalFunc) as RawValues[]; + childrenCopy.sort(); + return { result: childrenCopy, env }; + case "reverse": + const childrenCopy2 = [...children]; + childrenCopy2.reverse(); + return { result: childrenCopy2, env }; } } static tryToConstruct(node: any, options: OptionsConfig) { const op = mapTypes.find((x) => node[x]); if (!op) return false; - const childType = - node[op] && tryTypes([LLValueArray, LLVariable], options)(node[op]); - const varb = node.varb; + node[op] && + tryTypes([LLValueArray, LLVariable, LLMap], options)(node[op]); + let varb = node.varb; let func; if (op === "filter") { func = tryTypes([LLExpression], options)(node.func); + } else if (op === "reverse") { + // reverse doesn't take any arguments besides the target + varb = " "; + func = " "; } else { func = tryTypes( [LLValueFunction, LLPairFunction, LLNumberOp], @@ -932,7 +966,6 @@ function parseToAST(root: any, options: OptionsConfig) { const DEFAULT_OPTIONS = { debugParse: false, debugEval: false, - stages: false, debugCompare: false, }; export function LLEval( @@ -944,10 +977,7 @@ export function LLEval( const inputEnv = new Environment(palette, {}, opts, {}); const ast = parseToAST({ id: [root] }, opts); - if (options.stages) { - console.log(ast.toString()); - console.log("EVALUATION EVALUATION EVALUATION EVALUATION"); - } + const { result, env } = ast.evaluate(inputEnv); const blame = Object.entries(env.colorBlame) .filter((x) => x[1]) diff --git a/src/lib/lint-language/lint-type.ts b/src/lib/lint-language/lint-type.ts index 724afd4c..1e0be7ec 100644 --- a/src/lib/lint-language/lint-type.ts +++ b/src/lib/lint-language/lint-type.ts @@ -39,6 +39,7 @@ type LintComparison = | { "!=": LintComparisonBase } | { "<": LintComparisonBase } | { ">": LintComparisonBase } + | { absDiff: LintComparisonBase } | { similar: { left: LintRef; right: LintRef; threshold: number } }; type MathOperations = "+" | "-" | "*" | "/"; type LintMathOps = Record; @@ -61,6 +62,7 @@ type LintPairOps = type LintMap = // | { map: LintVariable | LintValue[]; func: LintColorFunction | LintPairOps } // | { sort: LintVariable | LintValue[]; func: LintColorFunction | LintPairOps } + // | {reverse: LintVariable | LintValue[]} { filter: LintVariable | LintValue[]; func: LintExpression }; type LintReduce = Record< @@ -77,10 +79,11 @@ type LintReduce = Record< >; type LintColorFunction = | { - cvd_sim: LintVariable | LintColor; + cvdSim: LintVariable | LintColor; type: "protanomaly" | "deuteranomaly" | "tritanopia" | "grayscale"; } | { name: LintVariable | LintColor } + | { inGamut: LintVariable | LintColor } | { toColor: LintVariable | LintColor; space: ColorSpace; diff --git a/src/lib/linter-tools/lint-fixer.ts b/src/lib/linter-tools/lint-fixer.ts new file mode 100644 index 00000000..f34148e5 --- /dev/null +++ b/src/lib/linter-tools/lint-fixer.ts @@ -0,0 +1,58 @@ +import type { Palette } from "../../stores/color-store"; +import { suggestFix } from "../api-calls"; +import type { LintResult } from "../ColorLint"; +import { Color } from "../Color"; + +export async function suggestLintAIFix( + palette: Palette, + lint: LintResult, + engine: string +) { + const colorSpace = palette.colorSpace; + return suggestFix(palette, lint.message, engine as any).then((x) => { + if (x.length === 0) { + throw new Error("No suggestions"); + } + return x.map((el) => { + try { + return { + ...palette, + colors: el.colors.map((x) => + Color.colorFromHex(x.replace("##", "#"), colorSpace) + ), + }; + } catch (e) { + console.log(e); + return palette; + } + }); + }); +} + +export type LintFixer = (pal: Palette, lint: LintResult) => Promise; +import { fixBackgroundDifferentiability } from "../lints/background-contrast"; +import { fixColorNameDiscriminability } from "../lints/name-discrim"; +import { fixDivergingOrder } from "../lints/diverging-order"; +import { fixGamut } from "../lints/in-gamut"; +import { fixMaxColors } from "../lints/max-colors"; +import { fixSequentialOrder } from "../lints/sequential-order"; +const fixDirectory: Record = { + fixBackgroundDifferentiability, + fixColorNameDiscriminability, + fixDivergingOrder, + fixGamut, + fixMaxColors, + fixSequentialOrder, +}; + +export async function suggestLintFix( + palette: Palette, + lint: LintResult, + _engine?: string +): Promise { + if (fixDirectory[lint.subscribedFix]) { + return fixDirectory[lint.subscribedFix](palette, lint); + } + console.log("check failed", lint, fixDirectory); + return []; +} diff --git a/src/lib/linter-tools/lint-worker.worker.ts b/src/lib/linter-tools/lint-worker.worker.ts new file mode 100644 index 00000000..88056f80 --- /dev/null +++ b/src/lib/linter-tools/lint-worker.worker.ts @@ -0,0 +1,83 @@ +import * as idb from "idb-keyval"; +import { runLintChecks } from "../linter"; +import type { CustomLint } from "../CustomLint"; +import type { Palette } from "../../stores/color-store"; +import { Color } from "../Color"; +import type { LintResult } from "../ColorLint"; + +type Command = + | { type: "load-lints"; content: ""; id: string } + | { type: "run-lint"; content: ""; id: string }; + +const cache: Record = {}; +const getColor = (hex: string, space: string): Color => { + const key = `${hex}-${space}`; + if (cache[key]) { + return cache[key]; + } + const result = Color.colorFromString(hex, space as any); + cache[key] = result; + return result; +}; + +const hydratePal = (pal: string): Palette => { + const parsed = JSON.parse(pal); + return { + background: getColor(parsed.background, parsed.colorSpace), + colors: parsed.colors.map((x: string) => getColor(x, parsed.colorSpace)), + type: parsed.type, + colorSpace: parsed.colorSpace, + name: parsed.name, + evalConfig: parsed.evalConfig, + }; +}; + +let lintStore: CustomLint[] = []; +let storeLoaded = false; +const storeName = "color-pal-lints"; +const simpleLintCache = new Map(); +async function dispatch(cmd: Command) { + switch (cmd.type) { + case "load-lints": + idb.get(storeName).then((x) => { + lintStore = x.lints; + storeLoaded = true; + return ""; + }); + return ""; + case "run-lint": + if (simpleLintCache.has(cmd.content)) { + return simpleLintCache.get(cmd.content); + } + const pal = hydratePal(cmd.content); + // if store not loaded, wait + while (!storeLoaded) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + const result: LintResult[] = runLintChecks(pal, lintStore).map((x) => { + return { + name: x.name, + passes: x.passes, + message: x.message, + level: x.level, + group: x.group, + description: x.description, + isCustom: x.isCustom, + taskTypes: x.taskTypes as any, + subscribedFix: x.subscribedFix, + }; + }); + simpleLintCache.set(cmd.content, result); + return result; + default: + return "no-op"; + } +} + +self.onmessage = async (event: MessageEvent) => { + const result = await dispatch(event.data); + self.postMessage({ id: event.data.id, content: result }); +}; + +export {}; // this is to make typescript happy diff --git a/src/lib/linter.ts b/src/lib/linter.ts index d8c198e2..71ed6886 100644 --- a/src/lib/linter.ts +++ b/src/lib/linter.ts @@ -1,40 +1,51 @@ -import { ColorLint } from "./lints/ColorLint"; +import { ColorLint } from "./ColorLint"; import type { Palette } from "../stores/color-store"; +import type { CustomLint } from "./CustomLint"; +import { CreateCustomLint } from "./CustomLint"; -import NameDiscrim from "./lints/name-discrim"; -import Discrims from "./lints/size-discrim"; -import ColorSimilarity from "./lints/color-similarity"; -import BackgroundDifferentiability from "./lints/background-differentiability"; -import SequentialOrder from "./lints/sequential-order"; +// manual lints import DivergingOrder from "./lints/diverging-order"; -import BackgroundContrast from "./lints/contrast"; -import Fair from "./lints/fair"; import EvenDistribution from "./lints/even-distribution"; -import type { CustomLint } from "./lints/CustomLint"; -import { CreateCustomLint } from "./lints/CustomLint"; + +// custom lints +import AvoidExtremes from "./lints/avoid-extremes"; +import BackgroundDifferentiability from "./lints/background-contrast"; +import CatOrderSimilarity from "./lints/cat-order-similarity"; +import ColorBlindness from "./lints/color-blindness"; +import SizeDiscrim from "./lints/size-discrim"; +import Fair from "./lints/fair"; +import Gamut from "./lints/in-gamut"; +import MaxColors from "./lints/max-colors"; +import MutuallyDistinct from "./lints/mutually-distinct"; +import SequentialOrder from "./lints/sequential-order"; +import UglyColors from "./lints/ugly-colors"; + +export const BUILT_INS: CustomLint[] = [ + ...ColorBlindness, + ...Fair, + ...SizeDiscrim, + AvoidExtremes, + BackgroundDifferentiability, + CatOrderSimilarity, + Gamut, + MaxColors, + MutuallyDistinct, + SequentialOrder, + UglyColors, +]; export function runLintChecks( palette: Palette, - palType: any, - customLints: CustomLint[], - ignoreList: Record + customLints: CustomLint[] ): ColorLint[] { - return ( - [ - NameDiscrim, - ...Discrims, - ColorSimilarity, - BackgroundDifferentiability, - SequentialOrder, - DivergingOrder, - BackgroundContrast, - ...Fair, - // EvenDistribution, - ...customLints.map((x) => CreateCustomLint(x)), - ] as (typeof ColorLint)[] - ) + const ignoreList = palette.evalConfig; + const lints = [ + DivergingOrder, + ...customLints.map((x) => CreateCustomLint(x)), + ] as (typeof ColorLint)[]; + return lints .map((x) => new x(palette)) - .filter((x) => x.taskTypes.includes(palType)) + .filter((x) => x.taskTypes.includes(palette.type)) .map((x) => { if (ignoreList[x.name] && ignoreList[x.name].ignore) { return x; diff --git a/src/lib/lints/ColorLint.ts b/src/lib/lints/ColorLint.ts deleted file mode 100644 index b06e0c3a..00000000 --- a/src/lib/lints/ColorLint.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { Palette } from "../../stores/color-store"; -import { suggestFix } from "../api-calls"; -import { Color } from "../Color"; - -export type TaskType = "sequential" | "diverging" | "categorical"; - -function AIFix(palette: Palette, message: string, engine: string) { - const colorSpace = palette.colorSpace; - return suggestFix(palette, message, engine as any).then((x) => { - if (x.length === 0) { - throw new Error("No suggestions"); - } - return x.map((el) => { - try { - return { - ...palette, - colors: el.colors.map((x) => - Color.colorFromHex(x.replace("##", "#"), colorSpace) - ), - }; - } catch (e) { - console.log(e); - return palette; - } - }); - }); -} - -export class ColorLint { - name: string = ""; - taskTypes: TaskType[] = []; - passes: boolean; - checkData: CheckData; - palette: Palette; - message: string = ""; - hasParam: boolean = false; - hasHeuristicFix: boolean = false; - config: { val?: ParamType } = {}; - defaultParam: ParamType = false as any; - isCustom: false | string = false; - group: string = ""; - description: string = ""; - blameMode: "pair" | "single" | "none" = "none"; - paramOptions: - | { type: "number"; min: number; max: number; step: number } - | { type: "enum"; options: string[] } - | { type: "none" } = { type: "none" }; - level: "error" | "warning" = "error"; - - constructor(Palette: Palette) { - this.palette = Palette; - this.checkData = undefined as CheckData; - this.passes = false; - } - - copy() { - const copy = new ColorLint(this.palette); - copy.name = this.name; - copy.taskTypes = this.taskTypes; - copy.passes = this.passes; - copy.checkData = this.checkData; - copy.message = this.message; - copy.hasParam = this.hasParam; - copy.config = this.config; - copy.defaultParam = this.defaultParam; - copy.hasHeuristicFix = this.hasHeuristicFix; - copy.level = this.level; - copy.group = this.group; - copy.description = this.description; - copy.paramOptions = this.paramOptions; - copy.isCustom = this.isCustom; - return copy; - } - - run() { - const { evalConfig } = this.palette; - this.config = { - ...evalConfig[this.name], - val: evalConfig[this.name]?.val || this.defaultParam, - }; - - const { passCheck, data } = this._runCheck(); - this.passes = passCheck; - this.checkData = data as CheckData; - this.message = this.buildMessage(); - return this; - } - - _runCheck(): { passCheck: boolean; data: CheckData } { - return { passCheck: true, data: {} as CheckData }; - } - // Fail Message - buildMessage(): string { - return ""; - } - - async suggestFix(engine?: string): Promise { - return AIFix(this.palette, this.message, engine || "openai"); - } - async suggestAIFix(engine?: string): Promise { - return AIFix(this.palette, this.message, engine || "openai"); - } -} diff --git a/src/lib/lints/avoid-extremes.ts b/src/lib/lints/avoid-extremes.ts new file mode 100644 index 00000000..3aa882d6 --- /dev/null +++ b/src/lib/lints/avoid-extremes.ts @@ -0,0 +1,33 @@ +import { JSONToPrettyString } from "../utils"; +import type { CustomLint } from "../CustomLint"; + +// https://www.sciencedirect.com/science/article/pii/S0167947308005549?casa_token=s8jmZqboaYgAAAAA:7lsAu7YUHVBTQA_eaKJ_3FFGv309684j_NTisGO9mIr3UZNIJ6hlAlxPQo04xzsowG7-dH0vzm4 +const lint: CustomLint = { + name: "Avoid extreme colors", + program: JSONToPrettyString({ + // @ts-ignore + $schema: `${location.href}lint-schema.json`, + all: { + in: "colors", + varb: "a", + predicate: { + all: { + in: ["#000000", "#ffffff", "#0000ff", "#ff0000", "#00ff00"], + varb: "b", + predicate: { + not: { similar: { left: "a", right: "b", threshold: 12 } }, + }, + }, + }, + }, + }), + + taskTypes: ["sequential", "diverging", "categorical"] as const, + level: "warning", + group: "design", + description: `Colors at either end of the lightness spectrum can be hard to discriminate in some contexts, and are sometimes advised against.`, + failMessage: `Colors at either end of the lightness spectrum {{blame}} are hard to discriminate in some contexts, and are sometimes advised against`, + id: "extreme-colors-built-in", + blameMode: "single", +}; +export default lint; diff --git a/src/lib/lints/background-contrast.ts b/src/lib/lints/background-contrast.ts new file mode 100644 index 00000000..a640af91 --- /dev/null +++ b/src/lib/lints/background-contrast.ts @@ -0,0 +1,67 @@ +import { JSONToPrettyString } from "../utils"; +import type { CustomLint } from "../CustomLint"; +import { Color } from "../Color"; +import type { LintFixer } from "../linter-tools/lint-fixer"; + +const getColorsCloseToBackground = (colors: Color[], background: Color) => { + return colors.reduce((acc, x, idx) => { + const pass = x.symmetricDeltaE(background) < 15; + return pass ? [...acc, idx] : acc; + }, [] as number[]); +}; + +const lint: CustomLint = { + program: JSONToPrettyString({ + // @ts-ignore + $schema: `${location.href}lint-schema.json`, + all: { + in: "colors", + varb: "a", + + predicate: { + ">": { + left: { + contrast: { left: "a", right: "background" }, + algorithm: "WCAG21", + }, + right: 1.1, + }, + }, + }, + }), + name: "Background Contrast", + taskTypes: ["sequential", "diverging", "categorical"] as const, + level: "error", + group: "accessibility", + description: `All colors in a palette should have a sufficient contrast ratio with the background color. This is because if they are not, then they will not be differentiable from each other in some contexts. Valid algorithms are "APCA", "WCAG21", "Michelson", "Weber", "Lstar", "DeltaPhi".`, + failMessage: `These colors ({{blame}}) do not have a sufficient contrast ratio with the background and may be hard to discriminate in some contexts.`, + id: "background-contrast-built-in", + blameMode: "single" as const, + subscribedFix: "fixBackgroundDifferentiability", +}; +export default lint; + +export const fixBackgroundDifferentiability: LintFixer = async (palette) => { + const { colors, background, colorSpace } = palette; + const backgroundL = background.toColorIO().to("lab").coords[0]; + const bgCloserToWhite = backgroundL > 50; + const clamp = (x: number) => Math.max(0, Math.min(100, x)); + const newL = clamp(!bgCloserToWhite ? backgroundL * 1.5 : backgroundL * 0.5); + const colorsCloseToBackground = getColorsCloseToBackground( + colors, + background + ); + const newColors = colors.map((x, idx) => { + if (!colorsCloseToBackground.includes(idx)) { + return x; + } + const color = colors[idx]; + const newColor = Color.toColorSpace(color, "lab"); + const [_l, a, b] = newColor.toChannels(); + return Color.toColorSpace( + newColor.fromChannels([newL, a, b]), + colorSpace as any + ); + }); + return [{ ...palette, colors: newColors }]; +}; diff --git a/src/lib/lints/background-differentiability.ts b/src/lib/lints/background-differentiability.ts deleted file mode 100644 index 45eb6606..00000000 --- a/src/lib/lints/background-differentiability.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { ColorLint } from "./ColorLint"; -import type { TaskType } from "./ColorLint"; -import { Color } from "../Color"; - -const hexJoin = (colors: Color[]) => colors.map((x) => x.toHex()).join(", "); - -export default class BackgroundDifferentiability extends ColorLint< - number[], - false -> { - name = "All colors differentiable from background"; - taskTypes = ["sequential", "diverging", "categorical"] as TaskType[]; - group = "accessibility"; - description: string = `All colors in a palette should be differentiable from the background color. This is because if they are not, then they will not be differentiable from each other in some contexts.`; - _runCheck() { - const { colors, background } = this.palette; - const colorsCloseToBackground = colors.reduce((acc, x, idx) => { - const pass = x.symmetricDeltaE(background) < 15; - return pass ? [...acc, idx] : acc; - }, [] as number[]); - - const passCheck = colorsCloseToBackground.length === 0; - return { passCheck, data: colorsCloseToBackground }; - } - buildMessage(): string { - const colors = this.palette.colors; - const str = hexJoin(this.checkData.map((x) => colors[x])); - return `This palette has some colors (${str}) that are close to the background color`; - } - hasHeuristicFix = true; - async suggestFix() { - const { colors, background, colorSpace } = this.palette; - const backgroundL = background.toColorIO().to("lab").coords[0]; - const bgCloserToWhite = backgroundL > 50; - const clamp = (x: number) => Math.max(0, Math.min(100, x)); - const newL = clamp( - !bgCloserToWhite ? backgroundL * 1.5 : backgroundL * 0.5 - ); - const newColors = colors.map((x, idx) => { - if (!this.checkData.includes(idx)) { - return x; - } - const color = colors[idx]; - const newColor = Color.toColorSpace(color, "lab"); - const [_l, a, b] = newColor.toChannels(); - return Color.toColorSpace( - newColor.fromChannels([newL, a, b]), - colorSpace as any - ); - }); - return [{ ...this.palette, colors: newColors }]; - } -} diff --git a/src/lib/lints/cat-order-similarity.ts b/src/lib/lints/cat-order-similarity.ts new file mode 100644 index 00000000..dd4a6896 --- /dev/null +++ b/src/lib/lints/cat-order-similarity.ts @@ -0,0 +1,35 @@ +import { JSONToPrettyString } from "../utils"; +import type { CustomLint } from "../CustomLint"; + +const lint: CustomLint = { + name: "Colors distinguishable in order", + program: JSONToPrettyString({ + // @ts-ignore + $schema: `${location.href}lint-schema.json`, + all: { + in: "colors", + varbs: ["a", "b"], + where: { + "==": { + left: "index(a)", + right: { "-": { left: "index(b)", right: 1 } }, + }, + }, + predicate: { + ">": { + left: { deltaE: { left: "a", right: "b" }, algorithm: "2000" }, + right: 10, + }, + }, + }, + }), + taskTypes: ["categorical"] as const, + level: "warning", + group: "design", + description: + "Opt for colors that are perceptually distinguishable in a logical sequence when designing visual elements like charts or graphs. This ensures that viewers can easily recognize the order or progression of data points. For categorical this means that when only a small number of colors are used, they should be as different as possible. For sequential and diverging, this means that the colors should be as different as possible in order.", + failMessage: `Some sequences of colors are too similar based on dE scores: {{blame}}. Try reordering them or making them more distinguishable`, + id: "cat-order-similarity-built-in", + blameMode: "pair", +}; +export default lint; diff --git a/src/lib/lints/color-blindness.ts b/src/lib/lints/color-blindness.ts new file mode 100644 index 00000000..2e787fcd --- /dev/null +++ b/src/lib/lints/color-blindness.ts @@ -0,0 +1,49 @@ +import { JSONToPrettyString } from "../utils"; +import type { CustomLint } from "../CustomLint"; + +// old algorithm - https://github.dev/gka/palettes +// let distanceNorm = colorA.symmetricDeltaE(colorB); +// if (distanceNorm < smallestPerceivableDistance) continue; +// let distanceSim = colorA.symmetricDeltaE(colorB); +// let isNotOk = +// distanceNorm / distanceSim > ratioThreshold && +// distanceSim < smallestPerceivableDistance; + +const blindnessLabels = { + deuteranopia: "(ie can't see green)", + protanopia: "(ie can't see red)", + tritanopia: "(ie can't see blue)", + grayscale: "", +}; +const blindTypes = Object.keys( + blindnessLabels +) as (keyof typeof blindnessLabels)[]; +const lints: CustomLint[] = blindTypes.map((type) => ({ + program: JSONToPrettyString({ + // @ts-ignore + $schema: `${location.href}lint-schema.json`, + all: { + in: "colors", + varbs: ["a", "b"], + where: { "!=": { left: "index(a)", right: "index(b)" } }, + predicate: { + not: { + similar: { + left: { cvdSim: "a", type }, + right: { cvdSim: "b", type }, + threshold: 9, + }, + }, + }, + }, + }), + name: `Colorblind Friendly for ${type}`, + taskTypes: ["sequential", "diverging", "categorical"], + group: "accessibility", + description: `All colors in a palette should be differentiable by people with ${type} ${blindnessLabels[type]}. This is because if they are not, then they will not be differentiable from each other in some contexts.`, + level: "error" as const, + failMessage: `This palette is not colorblind friendly for ${type} color blindness ${blindnessLabels[type]}. The following pairs are undifferentiable: ({{blame}})`, + id: `colorblind-friendly-${type}-built-in`, + blameMode: "pair" as const, +})); +export default lints; diff --git a/src/lib/lints/color-similarity.ts b/src/lib/lints/color-similarity.ts deleted file mode 100644 index 59dbba24..00000000 --- a/src/lib/lints/color-similarity.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ColorLint } from "./ColorLint"; -import type { TaskType } from "./ColorLint"; - -export default class ColorSimilarity extends ColorLint { - name = "Colors are differentiable in order"; - taskTypes = ["sequential", "diverging", "categorical"] as TaskType[]; - hasParam = true; - defaultParam: number = 10; - group: string = "usability"; - description: string = `Opt for colors that are perceptually distinguishable in a logical sequence when designing visual elements like charts or graphs. This ensures that viewers can easily recognize the order or progression of data points. For categorical this means that when only a small number of colors are used, they should be as different as possible. For sequential and diverging, this means that the colors should be as different as possible in order.`; - paramOptions: { type: "number"; min: number; max: number; step: number } = { - type: "number", - min: 10, - max: 100, - step: 1, - }; - _runCheck() { - const { colors } = this.palette; - const des = []; - for (let i = 0; i < colors.length - 1; i++) { - des.push(colors[i].deltaE(colors[i + 1])); - } - const failingIndexes = - des.reduce((acc, x, idx) => { - if (x < this.config.val!) { - return [...acc, idx]; - } - return acc; - }, [] as number[]) || []; - const passCheck = failingIndexes?.length === 0; - return { passCheck, data: failingIndexes }; - } - buildMessage(): string { - const { colors } = this.palette; - const pairs = this.checkData - .map((x) => { - const a = colors[x].toHex(); - const b = colors[x + 1].toHex(); - return `(${a} ${b})`; - }) - .join(", "); - return `Some colors are too similar based on dE scores: ${pairs}. Try reordering them or making them more distinguishable.`; - } -} diff --git a/src/lib/lints/contrast.ts b/src/lib/lints/contrast.ts deleted file mode 100644 index 79d77014..00000000 --- a/src/lib/lints/contrast.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Color } from "../Color"; -import { ColorLint } from "./ColorLint"; -import type { TaskType } from "./ColorLint"; - -type Algorithm = - | "APCA" - | "WCAG21" - | "Michelson" - | "Weber" - | "Lstar" - | "DeltaPhi"; - -export default class BackgroundContrast extends ColorLint { - name = "Background Contrast"; - taskTypes = ["sequential", "diverging", "categorical"] as TaskType[]; - hasParam = true; - defaultParam = "APCA" as Algorithm; - paramOptions: { type: "enum"; options: string[] } = { - type: "enum", - options: ["APCA", "WCAG21", "Michelson", "Weber", "Lstar", "DeltaPhi"], - }; - level: "error" | "warning" = "error"; - group: string = "accessibility"; - description: string = `All colors in a palette should have a sufficient contrast ratio with the background color. This is because if they are not, then they will not be differentiable from each other in some contexts.`; - - _runCheck() { - const { background } = this.palette; - const bg = background.toColorIO(); - const failingColors = this.palette.colors.filter((x) => { - const contrast = Math.abs(x.toColorIO().contrast(bg, this.config.val!)); - return contrast < 4.5; - }); - return { - passCheck: failingColors.length === 0, - data: failingColors, - }; - } - buildMessage(): string { - const colors = this.checkData?.map((x) => x.toHex()); - return `These colors (${colors}) do not have a sufficient contrast ratio with the background and may be hard to discriminate in some contexts. Contrast is calculated using the ${this.config.val} algorithm.`; - } -} diff --git a/src/lib/lints/diverging-order.ts b/src/lib/lints/diverging-order.ts index 899a1d07..c0d23650 100644 --- a/src/lib/lints/diverging-order.ts +++ b/src/lib/lints/diverging-order.ts @@ -1,6 +1,8 @@ -import { ColorLint } from "./ColorLint"; -import type { TaskType } from "./ColorLint"; +import { ColorLint } from "../ColorLint"; +import type { TaskType } from "../ColorLint"; import { Color } from "../Color"; +import type { Palette } from "../../stores/color-store"; +import type { LintFixer } from "../linter-tools/lint-fixer"; const meanPoint2d = (points: Color[]) => { const labPoints = points.map((x) => x.toColorIO().to("lab").coords); @@ -42,46 +44,47 @@ export default class SequentialOrder extends ColorLint { buildMessage(): string { return `This palette should have a middle color that is the lightest or darkest color, from which the other colors grow darker or lighter respectively.`; } - hasHeuristicFix = true; - async suggestFix() { - // figure out if its centered on a light color or a dark color? - // a dumb hueristic is just look at what the center color is in lab space, and see if its darker or lighter than most colors + subscribedFix: string = "fixDivergingOrder"; +} + +export const fixDivergingOrder: LintFixer = async (palette) => { + // figure out if its centered on a light color or a dark color? + // a dumb hueristic is just look at what the center color is in lab space, and see if its darker or lighter than most colors - let colors = [...this.palette.colors]; - // const medianPoint = findMinDistPoint(colors, meanPoint2d(colors)); - // console.log(medianPoint.toHex()); - // let darkerThanMedian = colors.filter( - // (x) => x.luminance() < medianPoint.luminance() - // ).length; + let colors = [...palette.colors]; + // const medianPoint = findMinDistPoint(colors, meanPoint2d(colors)); + // console.log(medianPoint.toHex()); + // let darkerThanMedian = colors.filter( + // (x) => x.luminance() < medianPoint.luminance() + // ).length; - const sortByLum = (a: Color, b: Color) => { - const aL = a.luminance(); - const bL = b.luminance(); - if (aL === bL) return 0; - return aL > bL ? 1 : -1; - }; - // if (darkerThanMedian < colors.length / 2) { - // console.log("reversing"); - // colors = colors.reverse(); - // } + const sortByLum = (a: Color, b: Color) => { + const aL = a.luminance(); + const bL = b.luminance(); + if (aL === bL) return 0; + return aL > bL ? 1 : -1; + }; + // if (darkerThanMedian < colors.length / 2) { + // console.log("reversing"); + // colors = colors.reverse(); + // } - // const lightPoint = colors.at(-1)!; - const leftColors = [colors.at(-1)!]; - const rightColors = [colors.at(-2)!]; - for (let i = 0; i < colors.length - 2; i++) { - const color = colors[i]; - const leftColor = leftColors.at(-1)!; - const rightColor = rightColors.at(-1)!; - if (color.deltaE(leftColor) < color.deltaE(rightColor)) { - leftColors.push(color); - } else { - rightColors.push(color); - } + // const lightPoint = colors.at(-1)!; + const leftColors = [colors.at(-1)!]; + const rightColors = [colors.at(-2)!]; + for (let i = 0; i < colors.length - 2; i++) { + const color = colors[i]; + const leftColor = leftColors.at(-1)!; + const rightColor = rightColors.at(-1)!; + if (color.deltaE(leftColor) < color.deltaE(rightColor)) { + leftColors.push(color); + } else { + rightColors.push(color); } - colors = [ - ...leftColors.sort(sortByLum), - ...rightColors.sort(sortByLum).reverse(), - ]; - return [{ ...this.palette, colors }]; } -} + colors = [ + ...leftColors.sort(sortByLum), + ...rightColors.sort(sortByLum).reverse(), + ]; + return [{ ...palette, colors }]; +}; diff --git a/src/lib/lints/even-distribution.ts b/src/lib/lints/even-distribution.ts index 8bafe21b..e1c60f02 100644 --- a/src/lib/lints/even-distribution.ts +++ b/src/lib/lints/even-distribution.ts @@ -1,5 +1,5 @@ -import { ColorLint } from "./ColorLint"; -import type { TaskType } from "./ColorLint"; +import { ColorLint } from "../ColorLint"; +import type { TaskType } from "../ColorLint"; const sum = (arr: number[]) => arr.reduce((acc, val) => acc + val, 0); diff --git a/src/lib/lints/fair.ts b/src/lib/lints/fair.ts index 8e2e5508..cbdc65ad 100644 --- a/src/lib/lints/fair.ts +++ b/src/lib/lints/fair.ts @@ -1,90 +1,52 @@ -import { Color } from "../Color"; -import { ColorLint } from "./ColorLint"; -import type { TaskType } from "./ColorLint"; -import { extent } from "../utils"; +import { JSONToPrettyString } from "../utils"; +import type { CustomLint } from "../CustomLint"; // magic numbers supplied by the paper const cRangeUnfair = 80; const lRangeUnfair = 50; -const identifyUnfair = ( - colors: Color[], - threshold: number, - channel: number -) => { - const channelRange = extent(colors.map((x) => x.toChannels()[channel])); - const outOfBoundColors = colors.filter( - (x) => x.toChannels()[channel] > channelRange[0] + threshold - ); - return outOfBoundColors; +const lRangePredicate = { + "<": { + left: { extent: { sort: "colors", varb: "x", func: { "lch.l": "x" } } }, + right: lRangeUnfair, + }, }; - -const fairMessage = (outOfBandL: Color[], outOfBandC: Color[]) => { - const lPass = outOfBandL.length === 0; - const cPass = outOfBandC.length === 0; - if (lPass && cPass) { - return ""; - } - const baseMessage = `This palette is unfair (meaning that some values may unduely stand out).`; - const lMsg = lPass - ? "" - : `The following colors have a luminance value that is out of range: ${outOfBandL - .map((x) => x.toHex()) - .join(", ")}.`; - const cMsg = cPass - ? "" - : `The following colors have a chroma value that is out of range: ${outOfBandC - .map((x) => x.toHex()) - .join(", ")}.`; - return `${baseMessage} ${lMsg} ${cMsg}`; +const cRangePredicate = { + "<": { + left: { extent: { sort: "colors", varb: "x", func: { "lch.c": "x" } } }, + right: cRangeUnfair, + }, }; - -class FairBase extends ColorLint { - name = "Fair"; - group = "design"; - // hasParam = true; - level: "error" | "warning" = "warning"; - // defaultParam = 1; - // paramOptions: { type: "number"; min: number; max: number; step: 1 } = { - // type: "number", - // min: 2, - // max: 20, - // step: 1, - // }; - - description: string = `Do the colors stand out equally? A color palette is described as fair if both chroma and luminance ranges are below a certain threshold and unfair if one of them is above a certain threshold. For sequential and diverging palettes, only the chroma range is considered.`; - buildMessage(): string { - const { outOfBandL, outOfBandC } = this.checkData; - return fairMessage(outOfBandL, outOfBandC); - } -} - -class FairSequential extends FairBase { - taskTypes = ["sequential", "diverging"] as TaskType[]; - - _runCheck() { - const { colors } = this.palette; - const outOfBandL = identifyUnfair(colors, lRangeUnfair, 0); - return { - passCheck: outOfBandL.length === 0, - data: { outOfBandL, outOfBandC: [] }, - }; - } -} - -class FairNominal extends FairBase { - taskTypes = ["categorical"] as TaskType[]; - - _runCheck() { - const { colors } = this.palette; - const outOfBandL = identifyUnfair(colors, lRangeUnfair, 0); - const outOfBandC = identifyUnfair(colors, cRangeUnfair, 1); - return { - passCheck: !outOfBandL.length && !outOfBandC.length, - data: { outOfBandL, outOfBandC }, - }; - } -} - -const Fair = [FairSequential, FairNominal]; -export default Fair; +const failMsgBase = `This palette is unfair (meaning that some values may unduely stand out). Note that this check is naturally at odds with color blind friendly palettes.`; +// json program version +const FairNominal: CustomLint = { + name: "Fair", + program: JSONToPrettyString({ + // @ts-ignore + $schema: `${location.href}lint-schema.json`, + and: [lRangePredicate, cRangePredicate], + }), + taskTypes: ["categorical"] as const, + level: "warning", + group: "design", + description: + "Do the colors stand out equally? A color palette is described as fair if both chroma and luminance ranges are below a certain threshold and unfair if one of them is above a certain threshold.", + failMessage: `${failMsgBase} Maximum chroma range: ${cRangeUnfair}, maximum luminance range: ${lRangeUnfair}.`, + id: "fair-nominal-built-in", + blameMode: "single", + subscribedFix: "fixMaxColors", +}; +const FairSequential: CustomLint = { + ...FairNominal, + taskTypes: ["sequential", "diverging"] as const, + program: JSONToPrettyString({ + // @ts-ignore + $schema: `${location.href}lint-schema.json`, + and: [lRangePredicate], + }), + failMessage: `${failMsgBase} Maximum chroma range: ${lRangeUnfair}.`, + id: "fair-sequential-built-in", + description: + "Do the colors stand out equally? A color palette is described as fair if the luminance ranges are below a certain threshold and unfair if one of them is above a certain threshold. ", +}; +export default [FairNominal, FairSequential]; diff --git a/src/lib/lints/in-gamut.ts b/src/lib/lints/in-gamut.ts new file mode 100644 index 00000000..eb09a523 --- /dev/null +++ b/src/lib/lints/in-gamut.ts @@ -0,0 +1,34 @@ +import { JSONToPrettyString } from "../utils"; +import type { CustomLint } from "../CustomLint"; +import type { LintFixer } from "../linter-tools/lint-fixer"; +import { clipToGamut } from "../utils"; + +const lint: CustomLint = { + name: "In Gamut", + program: JSONToPrettyString({ + // @ts-ignore + $schema: `${location.href}lint-schema.json`, + all: { + in: "colors", + varb: "a", + predicate: { "==": { left: { inGamut: "a" }, right: true } }, + }, + }), + taskTypes: ["sequential", "diverging", "categorical"] as const, + level: "warning", + group: "design", + description: + "Checks if the colors are in the sRGB gamut. This is important to ensure that the colors are visible and can be displayed on most devices.", + failMessage: `The color is not in the sRGB gamut. Please choose a color that is in the sRGB gamut.`, + id: "gamut-check-built-in", + blameMode: "single", + subscribedFix: "fixGamut", +}; +export default lint; + +export const fixGamut: LintFixer = async (palette) => { + const colors = palette.colors.map((color) => + color.fromChannels(clipToGamut(color)) + ); + return [{ ...palette, colors }]; +}; diff --git a/src/lib/lints/max-colors.ts b/src/lib/lints/max-colors.ts new file mode 100644 index 00000000..efc7a664 --- /dev/null +++ b/src/lib/lints/max-colors.ts @@ -0,0 +1,27 @@ +import { JSONToPrettyString } from "../utils"; +import type { CustomLint } from "../CustomLint"; +import type { LintFixer } from "../linter-tools/lint-fixer"; + +const lint: CustomLint = { + name: "Max Colors", + program: JSONToPrettyString({ + // @ts-ignore + $schema: `${location.href}lint-schema.json`, + "<": { left: { count: "colors" }, right: 10 }, + }), + taskTypes: ["sequential", "diverging", "categorical"] as const, + level: "warning", + group: "design", + description: + "Palettes should have a maximum number of colors. Higher numbers of colors can make it hard to identify specific values.", + failMessage: `This palette has too many colors and may be hard to discriminate in some contexts. Maximum: 10.`, + id: "too-many-colors-built-in", + blameMode: "single", + subscribedFix: "fixMaxColors", +}; +export default lint; + +export const fixMaxColors: LintFixer = async (palette) => { + const colors = palette.colors; + return [{ ...palette, colors: colors.slice(0, 10) }]; +}; diff --git a/src/lib/lints/mutually-distinct.ts b/src/lib/lints/mutually-distinct.ts new file mode 100644 index 00000000..ae10455c --- /dev/null +++ b/src/lib/lints/mutually-distinct.ts @@ -0,0 +1,29 @@ +import { JSONToPrettyString } from "../utils"; +import type { CustomLint } from "../CustomLint"; +const lint: CustomLint = { + program: JSONToPrettyString({ + // @ts-ignore + $schema: `${location.href}lint-schema.json`, + all: { + in: "colors", + varbs: ["a", "b"], + where: { "!=": { left: "index(a)", right: "index(b)" } }, + predicate: { + ">": { + left: { dist: { left: "a", right: "b" }, space: "lab" }, + right: 15, + }, + }, + }, + }), + name: "Mutually Distinct", + taskTypes: ["categorical"] as const, + group: "usability", + level: "error", + description: `All colors in a palette should be different from each other. This is because if they are not, then they will not be differentiable from each other in some contexts.`, + failMessage: `Some colors in this palette ({{blame}}) are not differentiable from each other.`, + id: "mutually-distinct-built-in", + blameMode: "pair" as const, +}; + +export default lint; diff --git a/src/lib/lints/name-discrim.ts b/src/lib/lints/name-discrim.ts index 200a8d41..ea7fd4bd 100644 --- a/src/lib/lints/name-discrim.ts +++ b/src/lib/lints/name-discrim.ts @@ -1,7 +1,10 @@ +import { JSONToPrettyString } from "../utils"; +import type { CustomLint } from "../CustomLint"; import namer from "color-namer"; -import { ColorLint } from "./ColorLint"; -import type { TaskType } from "./ColorLint"; import { Color } from "../Color"; +import type { Palette } from "../../stores/color-store"; +import { titleCase } from "../utils"; +import type { LintFixer } from "../linter-tools/lint-fixer"; function findSmallest(arr: A[], accessor: (x: A) => number): A { let smallest = arr[0]; @@ -11,43 +14,11 @@ function findSmallest(arr: A[], accessor: (x: A) => number): A { return smallest; } -function titleCase(str: string) { - return str - .split(" ") - .filter((x) => x.length > 0) - .map((x) => x[0].toUpperCase() + x.slice(1)) - .join(" "); -} - // Simpler version of the color name stuff export function colorNameSimple(colors: Color[]) { return colors.map((x) => ({ word: getName(x), hex: x.toHex() })); } -function simpleDiscrim(colors: Color[]) { - const names = colorNameSimple(colors); - const counts = names.reduce((acc, x) => { - if (!acc[x.word]) { - acc[x.word] = []; - } - acc[x.word].push(x); - return acc; - }, {} as Record); - - const remaining = Object.entries(counts) - .filter((x) => x[1].length > 1) - .map(([x, y]) => [x, y.map((el) => el.hex).join(", ")]); - // .map((x) => x[0]); - if (remaining.length === 0) { - return false; - } - - const countsStr = remaining - .map(([word, vals]) => `${word} (${vals})`) - .join(", "); - return `Color Name discriminability check failed. The following color names are repeated: ${countsStr}`; -} - const nameCache = new Map(); export const getName = (color: Color) => { const hex = color.toHex().toUpperCase(); @@ -73,7 +44,33 @@ function suggestFixForColorsWithCommonNames(colors: Color[]): Color[] { }); } -function buildFix(colors: Color[]): Color[] { +const lint: CustomLint = { + program: JSONToPrettyString({ + // @ts-ignore + $schema: `${location.href}lint-schema.json`, + all: { + in: "colors", + varbs: ["a", "b"], + where: { "!=": { left: "index(a)", right: "index(b)" } }, + predicate: { "!=": { left: { name: "a" }, right: { name: "b" } } }, + }, + }), + name: "Color Name Discriminability", + taskTypes: ["sequential"] as const, + level: "error", + group: "usability", + description: `Being able to identify colors by name is important for usability and for memorability.`, + failMessage: `The following pairs of colors have the same name: {{blame}}`, + id: "color-name-discriminability-built-in", + blameMode: "pair" as const, + subscribedFix: "fixColorNameDiscriminability", +}; +export default lint; + +export const fixColorNameDiscriminability: LintFixer = async ( + palette: Palette +) => { + const colors = palette.colors; const colorNamesByIndex = colors.reduce((acc, color, index) => { const name = getName(color); acc[name] = (acc[name] || []).concat(index); @@ -89,28 +86,5 @@ function buildFix(colors: Color[]): Color[] { newColors[i] = updatedColors[j]; }); }); - return newColors; -} - -export default class ColorNameDiscriminability extends ColorLint< - string, - false -> { - name = "Color Name Discriminability"; - taskTypes = ["sequential", "diverging", "categorical"] as TaskType[]; - group = "usability"; - description: string = - "Being able to identify colors by name is important for usability and for memorability. "; - _runCheck() { - const { colors } = this.palette; - const passCheck = simpleDiscrim(colors); - return { passCheck: !passCheck, data: passCheck || "" }; - } - buildMessage(): string { - return this.checkData; - } - hasHeuristicFix = true; - async suggestFix() { - return [{ ...this.palette, colors: buildFix(this.palette.colors) }]; - } -} + return [{ ...palette, colors: newColors }]; +}; diff --git a/src/lib/lints/sequential-order.ts b/src/lib/lints/sequential-order.ts index d72a5d93..a7fedb3c 100644 --- a/src/lib/lints/sequential-order.ts +++ b/src/lib/lints/sequential-order.ts @@ -1,35 +1,46 @@ -import { ColorLint } from "./ColorLint"; -import type { TaskType } from "./ColorLint"; +import { JSONToPrettyString } from "../utils"; +import type { CustomLint } from "../CustomLint"; import { Color } from "../Color"; +import type { Palette } from "../../stores/color-store"; +import type { LintFixer } from "../linter-tools/lint-fixer"; + +const lint: CustomLint = { + name: "Sequential Pal Order", + program: JSONToPrettyString({ + // @ts-ignore + $schema: `${location.href}lint-schema.json`, + or: [ + { + "==": { + left: { sort: "colors", varb: "x", func: { "lch.l": "x" } }, + right: { map: "colors", varb: "x", func: { "lch.l": "x" } }, + }, + }, + { + "==": { + left: { sort: "colors", varb: "x", func: { "lch.l": "x" } }, + right: { + reverse: { map: "colors", varb: "x", func: { "lch.l": "x" } }, + }, + }, + }, + ], + }), + taskTypes: ["sequential", "diverging", "categorical"] as const, + level: "error", + group: "usability", + description: + "Sequential palettes should be ordered by lightness. This is a defining property of a sequential palette and ensures that values are understood as having an increase (or decreasing) value.", + failMessage: `This pal should be ordered by lightness if being used as a sequential palette. {{blame}} may be to blame.`, + id: "sequential-order-built-in", + blameMode: "single", + subscribedFix: "fixSequentialOrder", +}; +export default lint; const getLightness = (color: Color) => color.toColorIO().to("lab").coords[0]; -export default class SequentialOrder extends ColorLint { - name = "Sequential Palettes should be ordered by lightness"; - taskTypes = ["sequential"] as TaskType[]; - group = "usability"; - description = `Sequential palettes should be ordered by lightness. This is a defining property of a sequential palette and ensures that values are understood as having an increase (or decreasing) value.`; - _runCheck() { - const { colors } = this.palette; - if (colors.length < 2) { - return { passCheck: true, data: false }; - } - let direction = getLightness(colors[0]) < getLightness(colors[1]) ? 1 : -1; - for (let i = 1; i < colors.length; i++) { - const check = - direction * (getLightness(colors[i - 1]) - getLightness(colors[i])); - if (check > 0) { - return { passCheck: false, data: false }; - } - } - return { passCheck: true, data: false }; - } - buildMessage(): string { - return `This pal should be ordered by lightness if being used as a sequential palette`; - } - hasHeuristicFix = true; - async suggestFix() { - const colors = [...this.palette.colors]; - colors.sort((a, b) => getLightness(a) - getLightness(b)); - return [{ ...this.palette, colors }]; - } -} +export const fixSequentialOrder: LintFixer = async (palette: Palette) => { + const colors = [...palette.colors]; + colors.sort((a, b) => getLightness(a) - getLightness(b)); + return [{ ...palette, colors }]; +}; diff --git a/src/lib/lints/size-discrim.ts b/src/lib/lints/size-discrim.ts index 51cffc7e..4d9479b4 100644 --- a/src/lib/lints/size-discrim.ts +++ b/src/lib/lints/size-discrim.ts @@ -1,7 +1,5 @@ -import { Color } from "../Color"; -import { ColorLint } from "./ColorLint"; -import type { TaskType } from "./ColorLint"; - +import { JSONToPrettyString } from "../utils"; +import type { CustomLint } from "../CustomLint"; // based on // https://github.com/connorgr/d3-jnd/blob/master/src/jnd.js @@ -31,77 +29,58 @@ function jndLabInterval(p: pType, s: sType) { return nd(pVal, sVal); } -export function noticeablyDifferent( - c1: Color, - c2: Color, - s: sType = 0.1, - p: pType = 0.5 -) { - var jnd = jndLabInterval(p, s); - const [l1, a1, b1] = Color.toColorSpace(c1, "lab").toChannels(); - const [l2, a2, b2] = Color.toColorSpace(c2, "lab").toChannels(); - - return ( - Math.abs(l1 - l2) >= jnd.l || - Math.abs(a1 - a2) >= jnd.a || - Math.abs(b1 - b2) >= jnd.b - ); -} - -export function checkJNDs( - colors: Color[] -): [keyof typeof sMap, Color, Color, number, number][] { - const invalid = [] as any[]; - for (let i = 0; i < colors.length; i++) { - for (let j = i + 1; j < colors.length; j++) { - Object.keys(sMap).forEach((s) => { - if ( - !noticeablyDifferent( - colors[i], - colors[j], - s as keyof typeof sMap, - "default" - ) - ) { - invalid.push([s, colors[i], colors[j], i, j]); - } - }); - } - } - return invalid; -} - -function uniqueJNDColors(key: string, jnds: ReturnType) { - const uniqueColors = new Set(); - jnds - .filter((x) => x[0] === key) - .forEach(([_key, A, B]) => { - uniqueColors.add(A.toHex()); - uniqueColors.add(B.toHex()); - }); - return [...uniqueColors].join(", "); -} - -const Discrims = ["Thin", "Medium", "Wide"].map((key) => { - return class SizeDiscrim extends ColorLint< - ReturnType, - false - > { - name = `${key} Discriminability`; - taskTypes = ["sequential", "diverging", "categorical"] as TaskType[]; - group = "usability"; - description: string = `Pairs of colors in a palette should be differentiable from each other in ${key} lines. `; - _runCheck() { - const jnds = checkJNDs(this.palette.colors); - const passCheck = jnds.filter((x) => x[0] === key).length === 0; - return { passCheck, data: jnds }; - } - buildMessage() { - const jnds = this.checkData; - const invalid = uniqueJNDColors(key, jnds); - return `This palette has some colors (${invalid}) that are close to each other in perceptual space and will not be resolvable for ${key} areas.`; - } +const lints: CustomLint[] = ["Thin", "Medium", "Wide"].map((key) => { + const p = "default"; + const s = key as keyof typeof sMap; + const jnd = jndLabInterval(p, s); + return { + name: `Works for ${key} marks`, + program: JSONToPrettyString({ + // @ts-ignore + $schema: `${location.href}lint-schema.json`, + all: { + in: "colors", + varbs: ["x", "y"], + where: { "!=": { left: "index(x)", right: "index(y)" } }, + predicate: { + // this being or is real important + or: [ + { + ">": { + left: { + absDiff: { left: { "lab.l": "x" }, right: { "lab.l": "y" } }, + }, + right: jnd.l, + }, + }, + { + ">": { + left: { + absDiff: { left: { "lab.a": "x" }, right: { "lab.a": "y" } }, + }, + right: jnd.a, + }, + }, + { + ">": { + left: { + absDiff: { left: { "lab.b": "x" }, right: { "lab.b": "y" } }, + }, + right: jnd.b, + }, + }, + ], + }, + }, + }), + taskTypes: ["sequential", "diverging", "categorical"] as const, + level: "warning", + group: "usability", + description: `Pairs of colors in a palette should be differentiable from each other in ${key} lines. `, + failMessage: `This palette has some colors ({{blame}}) that are close to each other in perceptual space and will not be resolvable for ${key} areas.`, + id: `${key}-discrim-built-in`, + blameMode: "pair", }; }); -export default Discrims; +export default lints; diff --git a/src/lib/lints/ugly-colors.ts b/src/lib/lints/ugly-colors.ts new file mode 100644 index 00000000..6e715ae4 --- /dev/null +++ b/src/lib/lints/ugly-colors.ts @@ -0,0 +1,35 @@ +import { JSONToPrettyString } from "../utils"; +import type { CustomLint } from "../CustomLint"; + +const lint: CustomLint = { + name: "Palette does not have ugly colors", + program: JSONToPrettyString({ + // @ts-ignore + $schema: `${location.href}lint-schema.json`, + all: { + in: "colors", + varb: "a", + predicate: { + all: { + in: ["#56FF00", "#0010FF", "#6A7E25", "#FF00EF", "#806E28"], + varb: "b", + predicate: { + ">": { + left: { deltaE: { left: "a", right: "b" }, algorithm: "2000" }, + + right: 10, + }, + }, + }, + }, + }, + }), + taskTypes: ["categorical"], + description: `Colors that are close to what are known as ugly colors are sometimes advised against. See https://www.colourlovers.com/palette/1416250/The_Ugliest_Colors for more details.`, + failMessage: `This palette has some colors (specifically {{blame}}) that are close to what are known as ugly colors`, + level: "warning", + group: "design", + id: "ugly-colors-built-in", + blameMode: "single", +}; +export default lint; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 0095d04c..24404ddc 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,6 @@ import { Color, colorPickerConfig } from "./Color"; import type { PalType, Palette } from "../stores/color-store"; +import type { LintProgram } from "./lint-language/lint-type"; import { Formatter, FracturedJsonOptions, EolStyle } from "fracturedjsonjs"; const options = new FracturedJsonOptions(); @@ -13,6 +14,9 @@ formatter.Options = options; export function JSONStringify(obj: string) { return formatter.Reformat(obj); } +export function JSONToPrettyString(program: LintProgram) { + return JSONStringify(JSON.stringify(program)); +} export const pick = (arr: any[]) => arr[Math.floor(Math.random() * arr.length)]; @@ -551,3 +555,9 @@ export const webColors = [ "DARKSLATEGRAY", "BLACK", ].map((x) => `"${x}"`.toLowerCase()); + +export const titleCase = (str: string) => + str + .split(" ") + .map((x) => x[0].toUpperCase() + x.slice(1)) + .join(" "); diff --git a/src/linting/Eval.svelte b/src/linting/Eval.svelte index 6f9e3f1b..7d531eb0 100644 --- a/src/linting/Eval.svelte +++ b/src/linting/Eval.svelte @@ -2,9 +2,9 @@ import colorStore from "../stores/color-store"; import configStore from "../stores/config-store"; import lintStore from "../stores/lint-store"; - import { ColorLint } from "../lib/lints/ColorLint"; + import { ColorLint } from "../lib/ColorLint"; - import { runLintChecks } from "../lib/linter"; + import { lint } from "../lib/api-calls"; import { buttonStyle } from "../lib/styles"; import LintDisplay from "./LintDisplay.svelte"; import EvalColorColumn from "./EvalColorColumn.svelte"; @@ -12,17 +12,17 @@ import Nav from "../components/Nav.svelte"; import NewLintSuggestion from "./NewLintSuggestion.svelte"; import { debounce } from "vega"; + import { titleCase } from "../lib/utils"; $: currentPal = $colorStore.palettes[$colorStore.currentPal]; - $: palType = currentPal.type; $: evalConfig = currentPal.evalConfig; - $: customLints = $lintStore.lints; - // $: checks = runLintChecks(currentPal, palType, customLints, evalConfig); $: checks = [] as ColorLint[]; $: selectedLint = $lintStore.focusedLint; $: updateSearchDebounced = debounce(100, (pal: any) => { if (!selectedLint) { - checks = runLintChecks(pal, palType, customLints, evalConfig); + lint(pal).then((res) => { + checks = res; + }); } }); $: updateSearchDebounced(currentPal); @@ -38,12 +38,6 @@ {} as Record[]> ); - const titleCase = (str: string) => - str - .split(" ") - .map((x) => x[0].toUpperCase() + x.slice(1)) - .join(" "); - function setGroupTo(checks: ColorLint[], ignore: boolean) { const newEvalConfig = { ...evalConfig }; checks.forEach((check) => { diff --git a/src/linting/EvalResponse.svelte b/src/linting/EvalResponse.svelte index 0439c817..86b64a54 100644 --- a/src/linting/EvalResponse.svelte +++ b/src/linting/EvalResponse.svelte @@ -3,14 +3,19 @@ import colorStore from "../stores/color-store"; import focusStore from "../stores/focus-store"; import configStore from "../stores/config-store"; - import { ColorLint } from "../lib/lints/ColorLint"; + import type { LintResult } from "../lib/ColorLint"; import type { Palette } from "../stores/color-store"; import PalDiff from "../components/PalDiff.svelte"; + import { + suggestLintAIFix, + suggestLintFix, + } from "../lib/linter-tools/lint-fixer"; import { buttonStyle } from "../lib/styles"; let requestState: "idle" | "loading" | "loaded" | "failed" = "idle"; - export let check: ColorLint; + export let check: LintResult; + $: palette = $colorStore.palettes[$colorStore.currentPal]; $: engine = $configStore.engine; $: suggestions = [] as Palette[]; @@ -19,12 +24,12 @@ let hasRetried = false; const getFix = () => { if (useAi) { - return check.suggestAIFix(engine).then((x) => { + return suggestLintAIFix(palette, check, engine).then((x) => { suggestions = x; requestState = "loaded"; }); } else { - return check.suggestFix(engine).then((x) => { + return suggestLintFix(palette, check, engine).then((x) => { suggestions = x; requestState = "loaded"; }); @@ -43,21 +48,21 @@ } $: currentPal = $colorStore.palettes[$colorStore.currentPal]; $: evalConfig = currentPal.evalConfig; - function updateEvalConfig( - checkName: string, - value: any, - formatter: "number" | "string" - ) { - let val = value; - if (val.target.value) { - val = - formatter === "number" ? Number(val.target.value) : val.target.value; - } - colorStore.setCurrentPalEvalConfig({ - ...evalConfig, - [checkName]: { ...evalConfig[checkName], val }, - }); - } + // function updateEvalConfig( + // checkName: string, + // value: any, + // formatter: "number" | "string" + // ) { + // let val = value; + // if (val.target.value) { + // val = + // formatter === "number" ? Number(val.target.value) : val.target.value; + // } + // colorStore.setCurrentPalEvalConfig({ + // ...evalConfig, + // [checkName]: { ...evalConfig[checkName], val }, + // }); + // } const options = [ "deuteranopia", @@ -83,7 +88,7 @@ - {#if check.hasHeuristicFix} + {#if check.subscribedFix !== "none"} @@ -100,31 +105,6 @@ > Ignore for this palette - {#if check.paramOptions.type !== "none"} -
-
Adjust check parameter
- {#if check.paramOptions.type === "number"} - updateEvalConfig(check.name, e, "number")} - /> - {/if} - {#if check.paramOptions.type === "enum"} - - {/if} -
- {/if} {#if requestState === "loading"}
Loading...
diff --git a/src/linting/ExplanationViewer.svelte b/src/linting/ExplanationViewer.svelte index 06dfff56..4085d7ad 100644 --- a/src/linting/ExplanationViewer.svelte +++ b/src/linting/ExplanationViewer.svelte @@ -1,5 +1,5 @@