diff --git a/AGENTS.md b/AGENTS.md index 52b8c2d..5f952a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,10 @@ - Write TSDoc/rustdoc comments for exported/public and contextually important symbols for maintainers - Helpers need more than 1 call-site to justify abstraction +- Prefer the smallest end-to-end path through the shared document model before widening a + feature's surface. Verify save/reopen, undo/redo, merge, inspection, and export where they apply. +- Add shared fixtures and regression coverage with feature work. Optimize from measured bottlenecks + rather than speculative architecture. - Run the full test suite through the root `pnpm test` command, or run a package's Vitest command from that package's directory. - Do not invoke a package-local vitest from the repository root Vitest will miss the diff --git a/ROADMAP.md b/ROADMAP.md index 4b1efa8..659eb5d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,18 +3,14 @@ Inkfinite is a local-first infinite canvas for humans and agents. The desktop app, CLI, and programmatic interfaces share one native document model and transaction engine. -Active implementation work is in [TODO.md](TODO.md). Completed work is summarized in -[CHANGELOG.md](CHANGELOG.md), with architecture and behavior documented on the -[documentation site](apps/web/src/content/docs/). - ## Current direction ### SVG round-trip -Inkfinite has one validated Rust SVG pipeline across desktop, web, and CLI. Native paths, -transforms, compound fills, imported hierarchy, static fallbacks, and deterministic rendering are -implemented. The remaining work proves complete document workflows: save and reopen, edit and -export, undo and redo, CRDT merge, CLI access, and visual stability of fallback content. +Inkfinite has one validated Rust SVG pipeline across desktop, web, and CLI. It maps supported +geometry into native shapes, preserves hierarchy, transforms, and compound fills, retains source +assets, and reports unsupported content. The remaining work proves complete document workflows: +save and reopen, edit and export, undo and redo, CRDT merge, and CLI access. See [SVG import](apps/web/src/content/docs/internals/svg-import.md) and [native path geometry](apps/web/src/content/docs/internals/native-path-geometry.md). @@ -36,26 +32,6 @@ IPC, and MCP workloads on recorded reference hardware. Set regression budgets fr Add indexes, caches, incremental materialization, or alternate rendering only when a representative benchmark identifies the cost they address. -## Completed foundation - -Inkfinite now has an Automerge-backed Rust document engine, generated TypeScript contracts, -deterministic SVG rendering, native desktop files, browser WASM sessions, shared editor/runtime -packages, unrestricted CLI file and live workflows, SVG import/export, and native hierarchical path -editing. -The changelog gives the release-level summary; the docs site owns implementation and usage detail. - -## Engineering principles - -- Rust owns the document model, validation, transaction engine, native persistence, and headless - rendering. -- TypeScript owns low-latency interaction, previews, browser input, and Canvas rendering. -- All committed mutations use the validated transaction path. -- Keep document correctness separate from caller authorization. -- Complete save/reopen, undo/redo, merge, inspection, and export workflows before widening a - feature's surface. -- Prefer shared fixtures and measured optimization over duplicated assertions or speculative - architecture. - ## Later Potential later work includes advanced vector operations, additional interchange formats, PWA and diff --git a/TODO.md b/TODO.md index 764225f..5d67d95 100644 --- a/TODO.md +++ b/TODO.md @@ -16,45 +16,11 @@ normalized structure where representation matters and rendered output where visu - [ ] Verify opaque fallback content remains visually stable - [ ] Add deterministic round-trip fixtures for these workflows -## Direct CLI control - -### Remove agent-specific restrictions - -- [x] Audit all `Origin::Agent` behavior - - CLI and IPC creation paths label transactions as agent-originated. - The core engine uses that value to enforce `agent_editable` and hidden-layer mutation rules. - Live proposal and apply entry points require it. - General queries hide invisible-layer shapes without consulting origin. - The [origin and authorization guide](apps/web/src/content/docs/internals/origin-and-authorization.md) - records the findings, rationale, and migration boundary. -- [x] Decide whether `Origin` remains provenance-only - - Decision: keep `Origin` as provenance-only metadata for history and attribution. - It must not grant or deny access. Agent authorization moves to MCP. -- [x] Separate document invariants and ordinary locks from authorization -- [x] Remove `agent_editable` restrictions from direct CLI operations -- [x] Remove hidden-from-agent restrictions from direct CLI operations - -### Preserve document correctness - -- [x] Keep causal-head and record-version checks -- [x] Keep transaction validation and atomic mutation -- [x] Keep ordinary shape and layer locks - -### Simplify CLI and live control - -- [x] Remove Review/Direct authorization concepts from general CLI behavior -- [x] Simplify live apply semantics -- [x] Remove proposal behavior from the general CLI; reserve review for permissioned MCP -- [x] Update capabilities, generated protocols, and schemas -- [x] Update CLI help, documentation, and bundled skill guidance -- [x] Replace permission-oriented CLI tests with direct-control tests -- [x] Add regression coverage for unrestricted scripted mutation - ## Permissioned MCP ### Server and discovery -- [ ] Choose a Rust MCP implementation and add an `inkfinite-mcp` crate or binary +- [ ] Add `inkfinite-mcp` crate with `rmcp` & its macros - [ ] Start with stdio transport and expose Inkfinite capability metadata - [ ] Reuse core query and transaction APIs rather than shelling out to the CLI - [ ] Discover open sessions and accessible files @@ -117,3 +83,13 @@ normalized structure where representation matters and rendered output where visu - [ ] Add a web manifest, service worker, PWA installation, and stronger offline behavior - [ ] Decide crates.io and desktop release packaging; automate release artifacts - [ ] Revisit skill organization after SVG and MCP workflows stabilize + +### Polish + +- [ ] Export SVG as copyable code +- [ ] Export PNG to clipboard +- [ ] Move handle on the layer pane + +--- + +- [ ] Direct Select could be clearer diff --git a/apps/web/src/content/docs/concepts/transactions-and-sync.md b/apps/web/src/content/docs/concepts/transactions-and-sync.md index 1636c86..a275fad 100644 --- a/apps/web/src/content/docs/concepts/transactions-and-sync.md +++ b/apps/web/src/content/docs/concepts/transactions-and-sync.md @@ -17,8 +17,9 @@ validation, history, and review tied to the user's request. Preconditions can require specific document heads or record versions. Inkfinite rejects the whole transaction when those expectations are stale, when a selector is ambiguous, or when an operation -would cross a lock or agent-editability boundary. A rejected transaction does not partially modify -the canonical file. +would cross a shape or layer lock. A rejected transaction does not partially modify the canonical +file. Permissioned integrations can apply caller policy before submitting a valid transaction. +`agent_editable` is not a document-engine lock. ## Undo and redo diff --git a/apps/web/src/content/docs/internals.md b/apps/web/src/content/docs/internals.md index 83544f8..9cbfc3e 100644 --- a/apps/web/src/content/docs/internals.md +++ b/apps/web/src/content/docs/internals.md @@ -14,6 +14,26 @@ The boundary between them is intentional: the editor can keep pointer movement a local, while completed desktop edits still commit through the same Rust transaction engine used by the CLI. +## Implemented foundation + +The current system includes: + +- an Automerge-backed Rust document engine with validated atomic transactions, history, undo and + redo, causal heads, sync, deterministic repair, and native file recovery +- generated TypeScript contracts, semantic metadata, ordered layers, bindings, built-in shapes, and + stencils shared across interfaces +- deterministic SVG and PNG rendering, static SVG import and export, and native path geometry with + hierarchy editing +- native desktop files, browser WASM sessions, and shared editor, runtime, input, and renderer + packages +- CLI workflows for file and live-session inspection, queries, validation, structured mutations, + dry runs, rendering, schemas, and machine-readable output + +The linked pages in this section describe these components in detail. Start with [Documents](/docs/concepts/documents/) +for the record model, [Transactions and sync](/docs/concepts/transactions-and-sync/) for commits and +merges, [SVG import](/docs/internals/svg-import/) for interchange, and [Native path geometry](/docs/internals/native-path-geometry/) +for vector editing. + ## Architecture ```text diff --git a/apps/web/src/content/docs/introduction.md b/apps/web/src/content/docs/introduction.md index 3b4d1f3..f0a0a00 100644 --- a/apps/web/src/content/docs/introduction.md +++ b/apps/web/src/content/docs/introduction.md @@ -31,7 +31,7 @@ command-line tools use file locks, atomic replacement, and recovery data to prot - Use the [desktop editor](/docs/applications/desktop/) for native files and desktop menus. - Use the [command-line interface](/docs/reference/cli/) to inspect, edit, validate, or render a document from a script. -- Use [agent workflows](/docs/reference/agents/) to propose document changes for review. +- Use [agent workflows](/docs/reference/agents/) to inspect, validate, and apply scripted document changes. ## Where to go next diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index bdb8fc9..7b0539b 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -1,7 +1,7 @@ + + + +
+ + + {#if isOpen} + + {/if} +
+ + diff --git a/packages/ui/src/lib/components/ColorPicker.svelte.test.ts b/packages/ui/src/lib/components/ColorPicker.svelte.test.ts new file mode 100644 index 0000000..faa37e4 --- /dev/null +++ b/packages/ui/src/lib/components/ColorPicker.svelte.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-svelte'; + +import ColorPicker from './ColorPicker.svelte'; + +describe('ColorPicker', () => { + it('opens an accessible Reasonable Colors palette', async () => { + const screen = render(ColorPicker, { + label: 'Fill color', + onchange: vi.fn(), + value: '#0089fc' + }); + + const trigger = screen.getByRole('button', { name: 'Fill color' }); + await trigger.click(); + + await expect + .element(screen.getByRole('dialog', { name: 'Fill color' })) + .toBeInTheDocument(); + await expect + .element(screen.getByRole('group', { name: 'Quick colors' })) + .toBeInTheDocument(); + await expect.element(screen.getByRole('button', { name: 'blue 1' })).toBeInTheDocument(); + await expect + .element(screen.getByRole('textbox', { name: 'Hex color' })) + .toHaveValue('#0089fc'); + await expect.element(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + + it('reports quick, shade, and recent color selections', async () => { + const onchange = vi.fn(); + const screen = render(ColorPicker, { + label: 'Stroke color', + onchange, + recentColors: ['#ABC'], + value: '#0089fc' + }); + await screen.getByRole('button', { name: 'Stroke color' }).click(); + + const violetQuickColor = screen + .getByRole('group', { name: 'Quick colors' }) + .getByRole('button', { name: 'violet 3' }); + violetQuickColor.element().focus(); + await expect + .element(screen.getByRole('group', { name: 'violet shades' })) + .toBeInTheDocument(); + + await violetQuickColor.click(); + expect(onchange).toHaveBeenLastCalledWith('#9b70ff'); + + await screen + .getByRole('group', { name: 'violet shades' }) + .getByRole('button', { name: 'violet 6' }) + .click(); + expect(onchange).toHaveBeenLastCalledWith('#0b0074'); + + await screen + .getByRole('group', { name: 'Recent colors' }) + .getByRole('button', { name: 'Recent #aabbcc' }) + .click(); + expect(onchange).toHaveBeenLastCalledWith('#aabbcc'); + }); + + it('accepts valid custom hex values and explains invalid ones', async () => { + const onchange = vi.fn(); + const screen = render(ColorPicker, { label: 'Fill color', onchange, value: '#0089fc' }); + await screen.getByRole('button', { name: 'Fill color' }).click(); + + const hex = screen.getByRole('textbox', { name: 'Hex color' }); + await hex.fill('#abc'); + await screen.getByRole('button', { name: 'Apply' }).click(); + expect(onchange).toHaveBeenLastCalledWith('#aabbcc'); + + await hex.fill('not-a-color'); + await screen.getByRole('button', { name: 'Apply' }).click(); + await expect.element(hex).toHaveAttribute('aria-invalid', 'true'); + await expect + .element(screen.getByText('Enter a 3- or 6-digit hex color.')) + .toBeInTheDocument(); + }); + + it('closes on Escape, restores focus, and respects disabled state', async () => { + const screen = render(ColorPicker, { + disabled: true, + label: 'Disabled color', + onchange: vi.fn(), + value: '#0089fc' + }); + const disabledTrigger = screen.getByRole('button', { name: 'Disabled color' }); + await expect.element(disabledTrigger).toBeDisabled(); + await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument(); + + const enabledScreen = render(ColorPicker, { + label: 'Enabled color', + onchange: vi.fn(), + value: '#0089fc' + }); + const trigger = enabledScreen.getByRole('button', { name: 'Enabled color' }); + await trigger.click(); + await userEvent.keyboard('{Escape}'); + + await expect.element(enabledScreen.getByRole('dialog')).not.toBeInTheDocument(); + await expect.element(trigger).toHaveFocus(); + }); +}); diff --git a/packages/ui/src/lib/components/color-math.test.ts b/packages/ui/src/lib/components/color-math.test.ts new file mode 100644 index 0000000..8e91ad1 --- /dev/null +++ b/packages/ui/src/lib/components/color-math.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { + clamp, + hexToHsv, + hexToRgb, + hsvToHex, + hsvToRgb, + normalizeHex, + rgbToHex +} from './color-math'; + +describe('color math', () => { + it('normalizes only supported hex input through Culori', () => { + expect(normalizeHex('#ABC')).toBe('#aabbcc'); + expect(normalizeHex(' #A1B2C3 ')).toBe('#a1b2c3'); + expect(normalizeHex('rgb(10 20 30)')).toBeUndefined(); + expect(normalizeHex('blue')).toBeUndefined(); + }); + + it('converts hex and RGB values', () => { + expect(hexToRgb('#336699')).toEqual({ r: 51, g: 102, b: 153 }); + expect(rgbToHex({ r: 51, g: 102, b: 153 })).toBe('#336699'); + expect(rgbToHex({ r: -10, g: 300, b: 127.5 })).toBe('#00ff80'); + }); + + it('converts HSV values without hand-written color formulas', () => { + expect(hsvToRgb({ h: 0, s: 1, v: 1 })).toEqual({ r: 255, g: 0, b: 0 }); + expect(hsvToHex({ h: 210, s: 2 / 3, v: 0.6 })).toBe('#336699'); + expect(hexToHsv('#336699')).toEqual({ h: 210, s: 2 / 3, v: 0.6 }); + }); + + it('clamps unit values and wraps hue values', () => { + expect(clamp(-1)).toBe(0); + expect(clamp(2)).toBe(1); + expect(hsvToHex({ h: -150, s: 1, v: 1 })).toBe('#0080ff'); + }); +}); diff --git a/packages/ui/src/lib/components/color-math.ts b/packages/ui/src/lib/components/color-math.ts new file mode 100644 index 0000000..d566744 --- /dev/null +++ b/packages/ui/src/lib/components/color-math.ts @@ -0,0 +1,117 @@ +/// + +import { converter, formatHex, parse } from 'culori'; + +/** HSV values use degrees for hue and unit intervals for saturation and value. */ +export interface HSV { + h: number; + s: number; + v: number; +} + +/** RGB values use the familiar 0–255 channel range. */ +export interface RGB { + r: number; + g: number; + b: number; +} + +type CuloriColor = { + mode?: string; + h?: number; + s?: number; + v?: number; + r?: number; + g?: number; + b?: number; +}; + +type CuloriConverter = (color: CuloriColor | undefined) => CuloriColor | undefined; + +const toHsv = converter('hsv') as CuloriConverter; +const toRgb = converter('rgb') as CuloriConverter; +const hexPattern = /^#[\da-f]{3}(?:[\da-f]{3})?$/i; + +/** Constrains a number to an inclusive interval. */ +export function clamp(value: number, min = 0, max = 1): number { + return Math.min(max, Math.max(min, value)); +} + +function normalizeHue(value: number): number { + if (!Number.isFinite(value)) return 0; + return ((value % 360) + 360) % 360; +} + +/** Normalizes a three- or six-digit hex value through Culori. */ +export function normalizeHex(value: string): string | undefined { + const trimmed = value.trim(); + if (!hexPattern.test(trimmed)) return undefined; + + const parsed = parse(trimmed) as CuloriColor | undefined; + const formatted = parsed ? formatHex(parsed) : undefined; + return typeof formatted === 'string' ? formatted.toLowerCase() : undefined; +} + +/** Converts a hex value to byte-based RGB channels. */ +export function hexToRgb(value: string): RGB | undefined { + const normalized = normalizeHex(value); + const parsed = normalized ? (parse(normalized) as CuloriColor) : undefined; + const rgb = parsed ? toRgb(parsed) : undefined; + if (!rgb || !isRgb(rgb)) return undefined; + + return { + r: Math.round(clamp(rgb.r) * 255), + g: Math.round(clamp(rgb.g) * 255), + b: Math.round(clamp(rgb.b) * 255) + }; +} + +/** Converts a hex value to HSV values for the custom picker controls. */ +export function hexToHsv(value: string): HSV | undefined { + const normalized = normalizeHex(value); + const parsed = normalized ? (parse(normalized) as CuloriColor) : undefined; + const hsv = parsed ? toHsv(parsed) : undefined; + if (!hsv || !isHsv(hsv)) return undefined; + + return { h: normalizeHue(hsv.h ?? 0), s: clamp(hsv.s ?? 0), v: clamp(hsv.v ?? 0) }; +} + +/** Converts HSV values to byte-based RGB channels. */ +export function hsvToRgb({ h, s, v }: HSV): RGB { + const rgb = toRgb({ mode: 'hsv', h: normalizeHue(h), s: clamp(s), v: clamp(v) }); + if (!rgb || !isRgb(rgb)) return { r: 0, g: 0, b: 0 }; + + return { + r: Math.round(clamp(rgb.r) * 255), + g: Math.round(clamp(rgb.g) * 255), + b: Math.round(clamp(rgb.b) * 255) + }; +} + +/** Converts byte-based RGB channels to a lowercase six-digit hex value. */ +export function rgbToHex({ r, g, b }: RGB): string { + const formatted = formatHex({ + mode: 'rgb', + r: clamp(r / 255), + g: clamp(g / 255), + b: clamp(b / 255) + } as CuloriColor); + return typeof formatted === 'string' ? formatted.toLowerCase() : '#000000'; +} + +/** Converts HSV values directly to a lowercase six-digit hex value. */ +export function hsvToHex(value: HSV): string { + return rgbToHex(hsvToRgb(value)); +} + +function isRgb(color: CuloriColor): color is CuloriColor & { r: number; g: number; b: number } { + return [color.r, color.g, color.b].every( + (channel) => channel !== undefined && Number.isFinite(channel) + ); +} + +function isHsv(color: CuloriColor): color is CuloriColor & { s: number; v: number } { + return [color.s, color.v].every( + (channel) => channel !== undefined && Number.isFinite(channel) + ); +} diff --git a/packages/ui/src/lib/components/colors.test.ts b/packages/ui/src/lib/components/colors.test.ts new file mode 100644 index 0000000..f3a876b --- /dev/null +++ b/packages/ui/src/lib/components/colors.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { findPaletteColor, getPaletteColor, quickColors, REASONABLE_COLORS } from './colors'; + +describe('reasonable colors', () => { + it('contains six shades for every Reasonable Colors family', () => { + expect(Object.keys(REASONABLE_COLORS)).toHaveLength(25); + for (const shades of Object.values(REASONABLE_COLORS)) { + expect(shades).toHaveLength(6); + for (const color of shades) { + expect(color).toMatch(/^#[\da-f]{6}$/); + } + } + }); + + it('keeps the quick palette compact and resolves its values', () => { + expect(quickColors).toHaveLength(16); + for (const { family, shade } of quickColors) { + expect(getPaletteColor(family, shade)).toMatch(/^#[\da-f]{6}$/); + } + }); + + it('finds a palette family and one-based shade', () => { + expect(findPaletteColor('#0089FC')).toEqual({ family: 'blue', shade: 3 }); + expect(findPaletteColor('#not-a-color')).toBeUndefined(); + }); +}); diff --git a/packages/ui/src/lib/components/colors.ts b/packages/ui/src/lib/components/colors.ts new file mode 100644 index 0000000..169b626 --- /dev/null +++ b/packages/ui/src/lib/components/colors.ts @@ -0,0 +1,84 @@ +import { normalizeHex } from './color-math'; + +/** + * Reasonable Colors hue family with six perceptual shades. + * + * @see https://github.com/matthewhowell/reasonable-colors + * + * @see https://www.reasonable.work/artifacts/ra005-reasonable-colors/ + * */ +export const REASONABLE_COLORS = { + gray: ['#f6f6f6', '#e2e2e2', '#8b8b8b', '#6f6f6f', '#3e3e3e', '#222222'], + rose: ['#fff7f9', '#ffdce5', '#ff3b8d', '#db0072', '#800040', '#4c0023'], + raspberry: ['#fff8f8', '#ffdddf', '#ff426c', '#de0051', '#82002c', '#510018'], + red: ['#fff8f6', '#ffddd8', '#ff4647', '#e0002b', '#830014', '#530003'], + orange: ['#fff8f5', '#ffded1', '#fd4d00', '#cd3c00', '#752100', '#401600'], + cinnamon: ['#fff8f3', '#ffdfc6', '#d57300', '#ac5c00', '#633300', '#371d00'], + amber: ['#fff8ef', '#ffe0b2', '#b98300', '#926700', '#523800', '#302100'], + yellow: ['#fff9e5', '#ffe53e', '#9c8b00', '#7d6f00', '#463d00', '#292300'], + lime: ['#f7ffac', '#d5f200', '#819300', '#677600', '#394100', '#222600'], + chartreuse: ['#e5ffc3', '#98fb00', '#5c9b00', '#497c00', '#264500', '#182600'], + green: ['#e0ffd9', '#72ff6c', '#00a21f', '#008217', '#004908', '#062800'], + emerald: ['#dcffe6', '#5dffa2', '#00a05a', '#008147', '#004825', '#002812'], + aquamarine: ['#daffef', '#42ffc6', '#009f78', '#007f5f', '#004734', '#00281b'], + teal: ['#d7fff7', '#00ffe4', '#009e8c', '#007c6e', '#00443c', '#002722'], + cyan: ['#c4fffe', '#00fafb', '#00999a', '#007a7b', '#004344', '#002525'], + powder: ['#dafaff', '#8df0ff', '#0098a9', '#007987', '#004048', '#002227'], + sky: ['#e3f7ff', '#aee9ff', '#0094b4', '#007590', '#00404f', '#001f28'], + cerulean: ['#e8f6ff', '#b9e3ff', '#0092c5', '#00749d', '#003c54', '#001d2a'], + azure: ['#e8f2ff', '#c6e0ff', '#008fdb', '#0071af', '#003b5e', '#001c30'], + blue: ['#f0f4ff', '#d4e0ff', '#0089fc', '#006dca', '#00386d', '#001a39'], + indigo: ['#f3f3ff', '#deddff', '#657eff', '#0061fc', '#00328a', '#001649'], + violet: ['#f7f1ff', '#e8daff', '#9b70ff', '#794aff', '#2d0fbf', '#0b0074'], + purple: ['#fdf4ff', '#f7d9ff', '#d150ff', '#b01fe3', '#660087', '#3a004f'], + magenta: ['#fff3fc', '#ffd7f6', '#f911e0', '#ca00b6', '#740068', '#44003c'], + pink: ['#fff7fb', '#ffdcec', '#ff2fb2', '#d2008f', '#790051', '#4b0030'] +} as const; + +export type ColorFamily = keyof typeof REASONABLE_COLORS; + +export type ColorShade = 1 | 2 | 3 | 4 | 5 | 6; + +type CompactColor = { readonly family: ColorFamily; readonly shade: ColorShade }; + +/** The compact set of families shown before a user explores a shade row. */ +export const quickColors: CompactColor[] = [ + { family: 'gray', shade: 6 }, + { family: 'gray', shade: 3 }, + { family: 'violet', shade: 3 }, + { family: 'purple', shade: 3 }, + { family: 'indigo', shade: 3 }, + { family: 'blue', shade: 3 }, + { family: 'sky', shade: 3 }, + { family: 'cyan', shade: 3 }, + { family: 'teal', shade: 3 }, + { family: 'emerald', shade: 3 }, + { family: 'green', shade: 3 }, + { family: 'yellow', shade: 2 }, + { family: 'amber', shade: 3 }, + { family: 'orange', shade: 3 }, + { family: 'red', shade: 3 }, + { family: 'rose', shade: 3 } +] as const; + +/** Finds the Reasonable Colors family and shade for a palette value. */ +export function findPaletteColor(value: string): CompactColor | undefined { + const normalized = normalizeHex(value); + if (!normalized) return undefined; + + for (const [family, shades] of Object.entries(REASONABLE_COLORS) as [ + ColorFamily, + readonly string[] + ][]) { + const index = shades.indexOf(normalized); + if (index !== -1) { + return { family, shade: (index + 1) as ColorShade }; + } + } + return undefined; +} + +/** Returns the palette value for a family and one-based shade number. */ +export function getPaletteColor(family: ColorFamily, shade: ColorShade): string { + return REASONABLE_COLORS[family][shade - 1]; +} diff --git a/packages/ui/src/lib/components/culori.d.ts b/packages/ui/src/lib/components/culori.d.ts new file mode 100644 index 0000000..53c5148 --- /dev/null +++ b/packages/ui/src/lib/components/culori.d.ts @@ -0,0 +1,17 @@ +declare module 'culori' { + interface CuloriColor { + mode?: string; + h?: number; + s?: number; + v?: number; + r?: number; + g?: number; + b?: number; + } + + export function converter( + mode: string + ): (color: CuloriColor | undefined) => CuloriColor | undefined; + export function formatHex(color: CuloriColor | undefined): string | undefined; + export function parse(color: string): CuloriColor | undefined; +} diff --git a/packages/ui/src/lib/editor/components/Toolbar.svelte b/packages/ui/src/lib/editor/components/Toolbar.svelte index d717da0..8d1a248 100644 --- a/packages/ui/src/lib/editor/components/Toolbar.svelte +++ b/packages/ui/src/lib/editor/components/Toolbar.svelte @@ -22,7 +22,13 @@ SnapshotCommand } from '@inkfinite/core'; import { fade } from 'svelte/transition'; - import { BrushPopover, ContextMenu, Icon, type ContextMenuEntry } from '../../index'; + import { + BrushPopover, + ColorPicker, + ContextMenu, + Icon, + type ContextMenuEntry + } from '../../index'; import { DEFAULT_FILL_COLOR, DEFAULT_STROKE_COLOR, TOOLS } from '../constants'; import type { BrushSettings, BrushStore } from '../status'; import ArrowPopover from './ArrowPopover.svelte'; @@ -494,16 +500,14 @@ store.executeCommand(command); } - function handleFillChange(event: Event) { - const input = event.currentTarget as HTMLInputElement; - fillColorValue = input.value; - applyFillColor(input.value); + function handleFillChange(color: string) { + fillColorValue = color; + applyFillColor(color); } - function handleStrokeChange(event: Event) { - const input = event.currentTarget as HTMLInputElement; - strokeColorValue = input.value; - applyStrokeColor(input.value); + function handleStrokeChange(color: string) { + strokeColorValue = color; + applyStrokeColor(color); } function applyOpacity(field: 'fillOpacity' | 'strokeOpacity', value: number) { @@ -646,26 +650,26 @@ {#if showColorControls}
{#if getSelectedShapes(editorState).some(shapeSupportsFill)} -