diff --git a/package-lock.json b/package-lock.json index 18d132f..e1337cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "remark-frontmatter": "^3.0.0", "remark-gfm": "^1.0.0", "remark-parse": "^9.0.0", - "unified": "^9.2.2" + "unified": "^9.2.2", + "use-color": "^2.0.4" }, "devDependencies": { "@react-native/eslint-config": "^0.81.2", @@ -15537,6 +15538,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-color": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/use-color/-/use-color-2.0.4.tgz", + "integrity": "sha512-HL6N3tlZ+n97aSRVoA3b5Gl8NKRoN+UI4JSC3x0b+CNtrwcdqzuMTLoaWH1c5W/AgZyoNnmvIZYKyujnukr4+Q==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index ba4d87a..186c40a 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,8 @@ "remark-frontmatter": "^3.0.0", "remark-gfm": "^1.0.0", "remark-parse": "^9.0.0", - "unified": "^9.2.2" + "unified": "^9.2.2", + "use-color": "^2.0.4" }, "devDependencies": { "@react-native/eslint-config": "^0.81.2", diff --git a/src/renderer/__tests__/theme.test.ts b/src/renderer/__tests__/theme.test.ts new file mode 100644 index 0000000..557dd3e --- /dev/null +++ b/src/renderer/__tests__/theme.test.ts @@ -0,0 +1,191 @@ +import {getNodeColors, getCanvasBackground, getMutedTextColor, resolveScheme} from '../theme'; + +// Regex helpers +const HEX6 = /^#[0-9a-f]{6}$/i; +const HEX8 = /^#[0-9a-f]{8}$/i; +const HEX6_OR_TRANSPARENT = /^(#[0-9a-f]{6}|#[0-9a-f]{8}|transparent)$/i; + +function isValidHex6(s: string) { + return HEX6.test(s); +} +function isValidHex8(s: string) { + return HEX8.test(s); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Output shape + +describe('getNodeColors — output shape', () => { + const schemes = ['dark', 'light'] as const; + const codes = ['0', '1', '2', '3', '4', '5', '6', undefined]; + + for (const scheme of schemes) { + for (const code of codes) { + it(`returns valid hex for code=${code ?? 'undefined'}, scheme=${scheme}`, () => { + const c = getNodeColors(code, scheme); + expect(HEX6_OR_TRANSPARENT.test(c.card)).toBe(true); + expect(HEX6_OR_TRANSPARENT.test(c.border) || HEX8.test(c.border)).toBe(true); + expect(HEX6_OR_TRANSPARENT.test(c.background) || HEX8.test(c.background)).toBe(true); + expect(isValidHex6(c.active)).toBe(true); + expect(isValidHex8(c.activeTransparent)).toBe(true); + expect(isValidHex6(c.text)).toBe(true); + }); + } + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// activeTransparent contract + +describe('activeTransparent', () => { + it('shares the same RGB as active with 00 alpha', () => { + for (const code of ['1', '2', '3', '4', '5', '6']) { + const {active, activeTransparent} = getNodeColors(code, 'dark'); + // toHex8 appends alpha after the 6 RGB digits + expect(activeTransparent.slice(0, 7).toLowerCase()).toBe(active.toLowerCase()); + expect(activeTransparent.slice(-2).toLowerCase()).toBe('00'); + } + }); + + it('is the same in dark and light modes', () => { + for (const code of ['1', '3', '5']) { + const dark = getNodeColors(code, 'dark'); + const light = getNodeColors(code, 'light'); + expect(dark.activeTransparent).toBe(light.activeTransparent); + expect(dark.active).toBe(light.active); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// No-colour defaults (code=undefined and code='0') + +describe('no-colour defaults', () => { + it('returns identical results for undefined and "0"', () => { + for (const scheme of ['dark', 'light'] as const) { + const fromUndef = getNodeColors(undefined, scheme); + const from0 = getNodeColors('0', scheme); + expect(fromUndef).toEqual(from0); + } + }); + + it('uses hardcoded card/border/background in dark mode', () => { + const c = getNodeColors(undefined, 'dark'); + expect(c.card).toBe('#323238'); + expect(c.border).toBe('#505058'); + expect(c.background).toBe('transparent'); + }); + + it('uses hardcoded card/border/background in light mode', () => { + const c = getNodeColors(undefined, 'light'); + expect(c.card).toBe('#FFFFFF'); + expect(c.border).toBe('#C8C8CC'); + expect(c.background).toBe('transparent'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Preset distinctness — each code should produce a unique active colour + +describe('preset distinctness', () => { + it('produces unique active hex for codes 1–6', () => { + const actives = ['1', '2', '3', '4', '5', '6'].map( + code => getNodeColors(code, 'dark').active, + ); + const unique = new Set(actives); + expect(unique.size).toBe(6); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Dark vs light card contrast + +describe('dark vs light card', () => { + it('dark card is darker than light card (lower R+G+B sum)', () => { + for (const code of ['1', '3', '5']) { + const dark = getNodeColors(code, 'dark'); + const light = getNodeColors(code, 'light'); + + const hexToSum = (hex: string) => { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return r + g + b; + }; + expect(hexToSum(dark.card)).toBeLessThan(hexToSum(light.card)); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Hex colour input (user-provided #rrggbb) + +describe('hex colour input', () => { + it('accepts a valid 6-digit hex and returns valid palette', () => { + const c = getNodeColors('#FF5733', 'dark'); + expect(isValidHex6(c.active)).toBe(true); + expect(isValidHex8(c.activeTransparent)).toBe(true); + expect(c.activeTransparent.slice(0, 7).toLowerCase()).toBe(c.active.toLowerCase()); + expect(c.activeTransparent.slice(-2).toLowerCase()).toBe('00'); + }); + + it('accepts a hex and produces chromatic (non-default) card', () => { + const cHex = getNodeColors('#0000FF', 'dark'); + const cNone = getNodeColors(undefined, 'dark'); + expect(cHex.card).not.toBe(cNone.card); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Unknown code fallback + +describe('unknown preset code', () => { + it('uses neutral-gray OKLCH for unknown codes (active colour is a gray hex)', () => { + const unknown = getNodeColors('99', 'dark'); + // Active resolves to the neutral-gray preset → achromatic hex + // (R === G === B in the active colour) + const hex = unknown.active.slice(1); // strip # + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + expect(r).toBe(g); + expect(g).toBe(b); + }); + + it('still produces valid hex palette for unknown codes', () => { + const unknown = getNodeColors('99', 'dark'); + expect(HEX6_OR_TRANSPARENT.test(unknown.card) || HEX8.test(unknown.card)).toBe(true); + expect(isValidHex6(unknown.active)).toBe(true); + expect(isValidHex8(unknown.activeTransparent)).toBe(true); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Utility functions + +describe('getCanvasBackground', () => { + it('returns dark hex in dark mode', () => { + expect(isValidHex6(getCanvasBackground('dark'))).toBe(true); + }); + it('returns light hex in light mode', () => { + expect(isValidHex6(getCanvasBackground('light'))).toBe(true); + }); + it('dark is darker than light', () => { + const dark = parseInt(getCanvasBackground('dark').slice(1), 16); + const light = parseInt(getCanvasBackground('light').slice(1), 16); + expect(dark).toBeLessThan(light); + }); +}); + +describe('getMutedTextColor', () => { + it('returns valid hex for both schemes', () => { + expect(isValidHex6(getMutedTextColor('dark'))).toBe(true); + expect(isValidHex6(getMutedTextColor('light'))).toBe(true); + }); +}); + +describe('resolveScheme', () => { + it('maps light → light', () => expect(resolveScheme('light')).toBe('light')); + it('maps dark → dark', () => expect(resolveScheme('dark')).toBe('dark')); + it('maps null → dark', () => expect(resolveScheme(null)).toBe('dark')); +}); diff --git a/src/renderer/theme.ts b/src/renderer/theme.ts index 919afea..2efc347 100644 --- a/src/renderer/theme.ts +++ b/src/renderer/theme.ts @@ -1,3 +1,9 @@ +// Import from the package main entry (not the `/core` subpath): the main +// entry has `main`/`module` fallback fields, so it resolves under Metro +// regardless of whether package-exports is enabled. The `/core` subpath is +// exports-only — depending on it would force every downstream consumer's +// Metro to enable `unstable_enablePackageExports`. See PR #60. +import {color, Color, type OKLCH} from 'use-color'; import {type ColorSchemeName} from 'react-native'; export type ColorScheme = 'light' | 'dark'; @@ -9,98 +15,97 @@ interface NodeColors { active: string; /** Same hue as `active` at alpha 0. Used as the fade-to stop for * `cc-card-gradient-Ndeg` so the midpoint of the gradient stays inside - * the active colour's family — `[active, 'transparent']` interpolates - * through grey/black in straight RGBA space and reads as muddy on a - * light canvas background. See #163. */ + * the active colour's family — interpolating toward a literal transparent + * black reads as muddy on a light canvas background. See #163. */ activeTransparent: string; text: string; } -// HSL presets for each canvas colour code. -// Values chosen to match the Obsidian/hesprs viewer aesthetic. +// OKLCH presets for JSON Canvas colour codes 0–6. +// +// OKLCH gives perceptually uniform colour: a fixed L keeps perceived +// brightness consistent across hues, and C (chroma) controls vividness +// independently of hue. The old HSL presets all sat at L=55%, which read +// very differently across hues — yellow appeared far brighter than green. +// +// Yellow (code 3) intentionally carries a higher L (0.78 vs 0.62) because +// that is perceptually correct: yellow has inherently higher luminosity than +// red, green or blue at equivalent vividity. Matching L=0.62 would produce +// a muddy brown-gold. +// +// Values: [L (0–1), C (chroma), H° (0–360)] const PRESETS: Record = { - '0': [0, 0, 40], // neutral gray (no colour set) - '1': [2, 78, 55], // red - '2': [29, 90, 55], // orange - '3': [48, 90, 55], // yellow - '4': [142, 60, 45], // green - '5': [204, 80, 55], // cyan/blue - '6': [270, 60, 55], // purple + '0': [0.50, 0.000, 0], // neutral gray (achromatic: H is ignored) + '1': [0.62, 0.190, 25], // red + '2': [0.62, 0.190, 55], // orange + '3': [0.78, 0.155, 98], // yellow + '4': [0.62, 0.175, 145], // green + '5': [0.62, 0.175, 222], // blue + '6': [0.62, 0.175, 302], // purple }; -function hslToRgb(h: number, s: number, l: number): [number, number, number] { - s /= 100; - l /= 100; - const a = s * Math.min(l, 1 - l); - const f = (n: number) => { - const k = (n + h / 30) % 12; - return l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); - }; - return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)]; -} - -function rgbToHex(r: number, g: number, b: number): string { - return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join(''); -} - -function hsl(h: number, s: number, l: number): string { - return rgbToHex(...hslToRgb(h, s, l)); -} - -function hsla(h: number, s: number, l: number, a: number): string { - const [r, g, b] = hslToRgb(h, s, l); - const alpha = Math.round(a * 255).toString(16).padStart(2, '0'); - return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}${alpha}`; -} - -function hexToHsl(hex: string): [number, number, number] { - const r = parseInt(hex.slice(1, 3), 16) / 255; - const g = parseInt(hex.slice(3, 5), 16) / 255; - const b = parseInt(hex.slice(5, 7), 16) / 255; - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - const l = (max + min) / 2; - if (max === min) return [0, 0, Math.round(l * 100)]; - const d = max - min; - const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - let h = 0; - if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; - else if (max === g) h = ((b - r) / d + 2) / 6; - else h = ((r - g) / d + 4) / 6; - return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)]; +/** Build a Color from raw OKLCH coordinates. + * Uses the object overload so TypeScript can check the shape statically, + * with no template-string formatting or AsValidColor brand requirement. */ +function fromOklch(l: number, c: number, h: number): Color { + return color({l, c, h, a: 1} as OKLCH); } -function resolveHsl(color?: string): [number, number, number] { - if (!color) return PRESETS['0']; - if (color.startsWith('#') && color.length >= 7) return hexToHsl(color); - return PRESETS[color] ?? PRESETS['0']; +/** Resolve canvas colour input to OKLCH [L, C, H°]. + * Accepts preset codes ('1'–'6'), undefined, or a hex string (#rrggbb). + * Unknown codes fall back to the neutral gray preset. */ +function resolveOklch(colorCode?: string): [number, number, number] { + if (!colorCode) { + return [...PRESETS['0']] as [number, number, number]; + } + if (colorCode.startsWith('#') && colorCode.length >= 7) { + // Color.from() accepts ColorInputValue which includes plain `string`, + // avoiding the AsValidColor branded-string constraint of color(). + const {l, c, h} = Color.from(colorCode).toOklch(); + // Achromatic colours return h:0 — no NaN guard needed. + return [l, c, h]; + } + const preset = PRESETS[colorCode]; + return preset + ? ([...preset] as [number, number, number]) + : ([...PRESETS['0']] as [number, number, number]); } /** * Derive a full colour palette for a node or edge from a canvas colour value. - * Mirrors the hesprs viewer's HSL processing approach. + * All colour operations happen in OKLCH space — hue, lightness, and alpha + * adjustments are perceptually uniform, and gamut mapping is automatic. */ -export function getNodeColors(color: string | undefined, scheme: ColorScheme): NodeColors { - const [h, s, l] = resolveHsl(color); - const hasColor = color !== undefined && color !== '0'; +export function getNodeColors(colorCode: string | undefined, scheme: ColorScheme): NodeColors { + const [l, c, h] = resolveOklch(colorCode); + const hasColor = colorCode !== undefined && colorCode !== '0'; + + const active = fromOklch(l, c, h); + const activeHex = active.toHex(); + // Same hue at full transparency — fading to `transparent` (#00000000) + // would interpolate through black mid-gradient; same-hue zero-alpha stays + // inside the colour family. See #163. + const activeTransparent = active.alpha(0).toHex8(); if (scheme === 'dark') { return { - card: hasColor ? hsl(h, Math.round(s * 0.35), 24) : '#323238', - border: hasColor ? hsla(h, s, Math.min(l, 65), 0.75) : '#505058', - background: hasColor ? hsla(h, s, l, 0.15) : 'transparent', - active: hsl(h, s, l), - activeTransparent: hsla(h, s, l, 0), + // Desaturated (C×0.35) and very dark — a deep hue-tinted background. + card: hasColor ? fromOklch(0.20, c * 0.35, h).toHex() : '#323238', + border: hasColor ? active.alpha(0.75).toHex8() : '#505058', + background: hasColor ? active.alpha(0.15).toHex8() : 'transparent', + active: activeHex, + activeTransparent, text: '#E5E7EB', }; } return { - card: hasColor ? hsl(h, Math.round(s * 0.4), 90) : '#FFFFFF', - border: hasColor ? hsla(h, s, l, 0.55) : '#C8C8CC', - background: hasColor ? hsla(h, s, l, 0.08) : 'transparent', - active: hsl(h, s, l), - activeTransparent: hsla(h, s, l, 0), + // Desaturated (C×0.40) and very light — a barely-tinted near-white. + card: hasColor ? fromOklch(0.93, c * 0.40, h).toHex() : '#FFFFFF', + border: hasColor ? active.alpha(0.55).toHex8() : '#C8C8CC', + background: hasColor ? active.alpha(0.08).toHex8() : 'transparent', + active: activeHex, + activeTransparent, text: '#1F2937', }; }