From 82d99434c615aab935c75e3db6652dfd566fb28c Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sun, 23 Aug 2026 22:02:48 -0500 Subject: [PATCH] feat: tweak defaults to match canvas --- TODO.md | 22 +++--- .../e2e/visual/editor-defaults.visual.spec.ts | 25 +++++++ packages/core/src/cards.ts | 17 ++--- packages/core/src/export.ts | 2 +- packages/core/src/index.ts | 1 + packages/core/src/style-policy.ts | 71 +++++++++++++++++++ packages/core/src/tools/markdown.ts | 7 +- packages/core/src/tools/pen.ts | 21 +++--- packages/core/src/tools/shape.ts | 21 +++--- packages/core/src/tools/text.ts | 7 +- packages/core/tests/style-policy.test.ts | 19 +++++ packages/core/tests/tools.test.ts | 2 +- packages/editor/src/renderer.ts | 2 +- .../lib/editor/canvas/canvas-store.svelte.ts | 19 ++--- 14 files changed, 176 insertions(+), 60 deletions(-) create mode 100644 apps/web/e2e/visual/editor-defaults.visual.spec.ts create mode 100644 packages/core/src/style-policy.ts create mode 100644 packages/core/tests/style-policy.test.ts diff --git a/TODO.md b/TODO.md index 7b8b44d..8e75672 100644 --- a/TODO.md +++ b/TODO.md @@ -4,23 +4,23 @@ Make newly created content look deliberate before the user changes any styling. -- [ ] Move creation-time visual defaults into a shared style policy instead of +- [x] Move creation-time visual defaults into a shared style policy instead of hard-coding colors independently in editor tools and UI controls -- [ ] Define defaults for rectangles, ellipses, frames, lines, arrows, pen +- [x] Define defaults for rectangles, ellipses, frames, lines, arrows, pen strokes, text, Markdown, and cards -- [ ] Use neutral shape styling by default and reserve the Inkfinite accent for +- [x] Use neutral shape styling by default and reserve the Inkfinite accent for selection, arrows, emphasis, and interactive state -- [ ] Increase the default rectangle corner radius and define distinct frame +- [x] Increase the default rectangle corner radius and define distinct frame styling so frames do not look like ordinary shapes -- [ ] Remove light-canvas assumptions from text and Markdown defaults -- [] Resolve automatic creation colors against the document/page canvas at creation time - and persist the resulting explicit colors; keep application theme colors out of - canonical document rendering. -- [ ] Ensure creation through the editor, CLI, MCP, stencils, and starter +- [x] Remove light-canvas assumptions from text and Markdown defaults +- [x] Resolve automatic creation colors against the document/page canvas at creation time + and persist the resulting explicit colors; keep application theme colors out of + canonical document rendering +- [x] Ensure creation through the editor, CLI, MCP, stencils, and starter documents uses the same default style policy where appropriate -- [ ] Add light- and dark-canvas visual fixtures covering every built-in +- [x] Add light- and dark-canvas visual fixtures covering every built-in primitive -- [ ] Add screenshot regression coverage for a representative mixed document +- [x] Add screenshot regression coverage for a representative mixed document ## SVG round-trip diff --git a/apps/web/e2e/visual/editor-defaults.visual.spec.ts b/apps/web/e2e/visual/editor-defaults.visual.spec.ts new file mode 100644 index 0000000..61b23e9 --- /dev/null +++ b/apps/web/e2e/visual/editor-defaults.visual.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from '../fixtures/editor'; + +for (const theme of ['light', 'dark'] as const) { + test(`mixed editor defaults on ${theme} canvas`, { tag: '@visual' }, async ({ editor, page }) => { + await editor.open(theme); + await editor.drawRectangle({ x: 270, y: 400 }, { x: 430, y: 500 }); + await editor.chooseTool('Ellipse'); + await editor.drag({ x: 480, y: 400 }, { x: 600, y: 500 }); + await editor.chooseTool('Frame'); + await editor.drag({ x: 650, y: 380 }, { x: 850, y: 520 }); + await editor.chooseTool('Line'); + await editor.drag({ x: 290, y: 570 }, { x: 430, y: 650 }); + await editor.chooseTool('Arrow'); + await editor.drag({ x: 480, y: 610 }, { x: 610, y: 610 }); + await page.getByRole('button', { name: 'Text', exact: true }).click(); + await page.mouse.click(680, 590); + await page.getByRole('button', { name: 'Markdown', exact: true }).click(); + await page.mouse.click(790, 570); + await page.getByRole('button', { name: 'Pen', exact: true }).click(); + await editor.drag({ x: 300, y: 720 }, { x: 460, y: 750 }); + await editor.selectAt({ x: 620, y: 800 }); + await editor.waitForRendering(); + await expect(page).toHaveScreenshot(`editor-defaults-${theme}.png`, { fullPage: true }); + }); +} diff --git a/packages/core/src/cards.ts b/packages/core/src/cards.ts index ec47916..5aef4f8 100644 --- a/packages/core/src/cards.ts +++ b/packages/core/src/cards.ts @@ -6,6 +6,7 @@ import { type ShapeMetadata, type ShapeRecord as Shape } from './model'; +import { creationStylePolicy, type CanvasAppearance } from './style-policy'; /** User-editable fields carried by a card container. */ export type CardFields = { @@ -54,31 +55,27 @@ export function createCardShapes( x: number, y: number, fields: CardFields, - id = createId('shape') + id = createId('shape'), + appearance: CanvasAppearance = 'light' ): Shape[] { + const styles = creationStylePolicy(appearance); const container = ShapeRecord.createContainer( pageId, x, y, - { w: 320, h: 220, fill: '#ffffff', stroke: '#cbd5e1', radius: 10 }, + { w: 320, h: 220, ...styles.card.container }, id ); const title = ShapeRecord.createText(pageId, x + 16, y + 16, { text: fields.title, - fontSize: 18, - fontFamily: 'Instrument Sans Variable', - color: '#0f172a', + ...styles.card.title, w: 288 }); const body = ShapeRecord.createMarkdown(pageId, x + 16, y + 58, { md: fields.body, w: 288, h: 140, - fontSize: 14, - fontFamily: 'Instrument Sans Variable', - color: '#334155', - bg: 'transparent', - border: 'transparent' + ...styles.card.body }); const children = [ { ...title, groupId: container.id }, diff --git a/packages/core/src/export.ts b/packages/core/src/export.ts index 892a9a4..b11a3c3 100644 --- a/packages/core/src/export.ts +++ b/packages/core/src/export.ts @@ -313,7 +313,7 @@ function wrapSemanticMetadata(shape: ShapeRecord, content: string): string { function containerToSVG(shape: ContainerShape, transform: string): string { const { w = 0, h = 0, title, fill, stroke, radius = 0 } = shape.props; const elements = [``]; - if (title) elements.push(`${escapeXML(title)}`); + if (title) elements.push(`${escapeXML(title)}`); return elements.join(""); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 375396c..8b214f6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -19,6 +19,7 @@ export * from './persistence/stats'; export * from './reactivity'; export * from './selection'; export * from './snapping'; +export * from './style-policy'; export * as stencils from './stencils'; export * from './tools'; export * from './ui/filebrowser'; diff --git a/packages/core/src/style-policy.ts b/packages/core/src/style-policy.ts new file mode 100644 index 0000000..f7f00ae --- /dev/null +++ b/packages/core/src/style-policy.ts @@ -0,0 +1,71 @@ +import type { + ArrowStyle, + BrushConfig, + ContainerProps, + EllipseProps, + LineProps, + MarkdownProps, + RectProps, + StrokeStyle, + TextProps +} from './model'; + +/** Canvas appearance used to resolve explicit creation-time document colors. */ +export type CanvasAppearance = 'light' | 'dark'; + +/** Visual defaults persisted on newly created native content. */ +export type CreationStylePolicy = { + rect: Pick; + ellipse: Pick; + frame: Pick; + line: Pick; + arrow: ArrowStyle; + pen: { brush: BrushConfig; style: StrokeStyle }; + text: Pick; + markdown: Pick; + card: { + container: Pick; + title: Pick; + body: Pick; + }; +}; + +const FONT_FAMILY = 'Instrument Sans Variable'; +const BRUSH = { size: 6, thinning: 0.35, smoothing: 0.55, streamline: 0.55, simulatePressure: true }; + +/** Resolves theme-independent, explicit document colors against the current canvas. */ +export function creationStylePolicy(appearance: CanvasAppearance = 'light'): CreationStylePolicy { + const dark = appearance === 'dark'; + const fill = dark ? '#252738' : '#ffffff'; + const stroke = dark ? '#8f93a8' : '#69717d'; + const text = dark ? '#d8e6e6' : '#1e2029'; + const mutedText = dark ? '#b8c5c8' : '#495063'; + const accent = dark ? '#a78bfa' : '#7655df'; + return { + rect: { fill, stroke, radius: 12 }, + ellipse: { fill, stroke }, + frame: { fill: dark ? '#1d1f30' : '#e5e9eb', stroke, radius: 6 }, + line: { stroke, width: 2 }, + arrow: { stroke: accent, width: 2, headEnd: true }, + pen: { brush: { ...BRUSH }, style: { color: text, opacity: 1 } }, + text: { fontSize: 16, fontFamily: FONT_FAMILY, color: text }, + markdown: { + fontSize: 16, + fontFamily: FONT_FAMILY, + color: text, + bg: 'transparent', + border: 'transparent' + }, + card: { + container: { fill, stroke, radius: 14 }, + title: { fontSize: 18, fontFamily: FONT_FAMILY, color: text }, + body: { + fontSize: 14, + fontFamily: FONT_FAMILY, + color: mutedText, + bg: 'transparent', + border: 'transparent' + } + } + }; +} diff --git a/packages/core/src/tools/markdown.ts b/packages/core/src/tools/markdown.ts index 8e88f1b..0347f74 100644 --- a/packages/core/src/tools/markdown.ts +++ b/packages/core/src/tools/markdown.ts @@ -3,10 +3,13 @@ import { createId, ShapeRecord } from "../model"; import type { EditorState, ToolId } from "../reactivity"; import { canCreateShapeOnActiveLayer, getCurrentPage } from "../reactivity"; import type { Tool } from "./base"; +import { creationStylePolicy, type CanvasAppearance } from "../style-policy"; export class MarkdownTool implements Tool { readonly id: ToolId = "markdown"; + constructor(private readonly getAppearance: () => CanvasAppearance = () => "light") {} + onEnter(state: EditorState): EditorState { return state; } @@ -39,9 +42,7 @@ export class MarkdownTool implements Tool { md: "# Markdown\n\nEdit me...", w: 300, h: 200, - fontSize: 16, - fontFamily: "Instrument Sans Variable", - color: "#1f2933", + ...creationStylePolicy(this.getAppearance()).markdown, }, shapeId); const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; diff --git a/packages/core/src/tools/pen.ts b/packages/core/src/tools/pen.ts index eafc435..b2c0723 100644 --- a/packages/core/src/tools/pen.ts +++ b/packages/core/src/tools/pen.ts @@ -4,6 +4,7 @@ import { createId, ShapeRecord } from "../model"; import type { EditorState, ToolId } from "../reactivity"; import { canCreateShapeOnActiveLayer, getCurrentPage } from "../reactivity"; import type { Tool } from "../tools/base"; +import { creationStylePolicy, type CanvasAppearance } from "../style-policy"; /** * Internal state for pen tool @@ -36,16 +37,6 @@ const MIN_POINT_DISTANCE = 1; */ const FRAME_DURATION_MS = 1000 / 60; -/** - * Default brush configuration - */ -const DEFAULT_BRUSH = { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }; - -/** - * Default stroke style - */ -const DEFAULT_STYLE: StrokeStyle = { color: "#000000", opacity: 1.0 }; - /** * Pen tool - creates freehand stroke shapes using perfect-freehand * @@ -61,7 +52,11 @@ export class PenTool implements Tool { private getBrush: () => BrushConfig; private getStrokeStyle: () => StrokeStyle; - constructor(getBrush?: () => BrushConfig, getStrokeStyle?: () => StrokeStyle) { + constructor( + getBrush?: () => BrushConfig, + getStrokeStyle?: () => StrokeStyle, + getAppearance: () => CanvasAppearance = () => "light", + ) { this.toolState = { isDrawing: false, draftPoints: [], @@ -69,8 +64,8 @@ export class PenTool implements Tool { draftNeedsSync: false, lastUpdateFrame: null, }; - this.getBrush = getBrush ?? (() => DEFAULT_BRUSH); - this.getStrokeStyle = getStrokeStyle ?? (() => DEFAULT_STYLE); + this.getBrush = getBrush ?? (() => creationStylePolicy(getAppearance()).pen.brush); + this.getStrokeStyle = getStrokeStyle ?? (() => creationStylePolicy(getAppearance()).pen.style); } onEnter(state: EditorState): EditorState { diff --git a/packages/core/src/tools/shape.ts b/packages/core/src/tools/shape.ts index 9c39292..b5a86a2 100644 --- a/packages/core/src/tools/shape.ts +++ b/packages/core/src/tools/shape.ts @@ -6,6 +6,7 @@ import { BindingRecord, createId, ShapeRecord } from '../model'; import type { EditorState, ToolId } from '../reactivity'; import { canCreateShapeOnActiveLayer, getCurrentPage } from '../reactivity'; import type { Tool } from '../tools/base'; +import { creationStylePolicy, type CanvasAppearance } from '../style-policy'; function adoptFrameContents(state: EditorState, frameId: string): EditorState { const frame = state.doc.shapes[frameId]; @@ -100,7 +101,7 @@ export class RectTool implements Tool { readonly id: ToolId = 'rect'; private toolState: ShapeCreationToolState; - constructor() { + constructor(private readonly getAppearance: () => CanvasAppearance = () => 'light') { this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; } @@ -151,7 +152,7 @@ export class RectTool implements Tool { currentPage.id, action.world.x, action.world.y, - { w: 0, h: 0, fill: '#4a90e2', stroke: '#2e5c8a', radius: 4 }, + { w: 0, h: 0, ...creationStylePolicy(this.getAppearance()).rect }, shapeId ); @@ -258,6 +259,8 @@ export class FrameTool implements Tool { readonly id: ToolId = 'frame'; private toolState: ShapeCreationToolState = { isCreating: false, startWorld: null, creatingShapeId: null }; + constructor(private readonly getAppearance: () => CanvasAppearance = () => 'light') {} + onEnter(state: EditorState): EditorState { this.resetToolState(); return state; @@ -293,7 +296,7 @@ export class FrameTool implements Tool { page.id, action.world.x, action.world.y, - { w: 0, h: 0, title: 'Frame', fill: 'rgba(37, 99, 235, 0.05)', stroke: '#2563eb', radius: 8 }, + { w: 0, h: 0, title: 'Frame', ...creationStylePolicy(this.getAppearance()).frame }, id ); shape.layerId = state.ui.activeLayerId ?? page.layerIds?.[0]; @@ -385,7 +388,7 @@ export class EllipseTool implements Tool { readonly id: ToolId = 'ellipse'; private toolState: ShapeCreationToolState; - constructor() { + constructor(private readonly getAppearance: () => CanvasAppearance = () => 'light') { this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; } @@ -436,7 +439,7 @@ export class EllipseTool implements Tool { currentPage.id, action.world.x, action.world.y, - { w: 0, h: 0, fill: '#51cf66', stroke: '#2f9e44' }, + { w: 0, h: 0, ...creationStylePolicy(this.getAppearance()).ellipse }, shapeId ); @@ -547,7 +550,7 @@ export class LineTool implements Tool { readonly id: ToolId = 'line'; private toolState: ShapeCreationToolState; - constructor() { + constructor(private readonly getAppearance: () => CanvasAppearance = () => 'light') { this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; } @@ -598,7 +601,7 @@ export class LineTool implements Tool { currentPage.id, action.world.x, action.world.y, - { a: { x: 0, y: 0 }, b: { x: 0, y: 0 }, stroke: '#495057', width: 2 }, + { a: { x: 0, y: 0 }, b: { x: 0, y: 0 }, ...creationStylePolicy(this.getAppearance()).line }, shapeId ); @@ -706,7 +709,7 @@ export class ArrowTool implements Tool { readonly id: ToolId = 'arrow'; private toolState: ShapeCreationToolState; - constructor() { + constructor(private readonly getAppearance: () => CanvasAppearance = () => 'light') { this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; } @@ -764,7 +767,7 @@ export class ArrowTool implements Tool { ], start: { kind: 'free' }, end: { kind: 'free' }, - style: { stroke: '#2563eb', width: 2, headEnd: true }, + style: creationStylePolicy(this.getAppearance()).arrow, routing: { kind: 'straight' } }, shapeId diff --git a/packages/core/src/tools/text.ts b/packages/core/src/tools/text.ts index 82f698f..223592d 100644 --- a/packages/core/src/tools/text.ts +++ b/packages/core/src/tools/text.ts @@ -3,6 +3,7 @@ import { createId, ShapeRecord } from "../model"; import type { EditorState, ToolId } from "../reactivity"; import { canCreateShapeOnActiveLayer, getCurrentPage } from "../reactivity"; import type { Tool } from "./base"; +import { creationStylePolicy, type CanvasAppearance } from "../style-policy"; /** * Text tool - creates text shapes on click @@ -15,6 +16,8 @@ import type { Tool } from "./base"; export class TextTool implements Tool { readonly id: ToolId = "text"; + constructor(private readonly getAppearance: () => CanvasAppearance = () => "light") {} + onEnter(state: EditorState): EditorState { return state; } @@ -45,9 +48,7 @@ export class TextTool implements Tool { const shape = ShapeRecord.createText(currentPage.id, action.world.x, action.world.y, { text: "Text", - fontSize: 16, - fontFamily: "Instrument Sans Variable", - color: "#1f2933", + ...creationStylePolicy(this.getAppearance()).text, }, shapeId); const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; diff --git a/packages/core/tests/style-policy.test.ts b/packages/core/tests/style-policy.test.ts new file mode 100644 index 0000000..808e25d --- /dev/null +++ b/packages/core/tests/style-policy.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { creationStylePolicy } from '../src/style-policy'; + +describe('creation style policy', () => { + it('uses neutral geometry and reserves the accent for arrows', () => { + const policy = creationStylePolicy('light'); + expect(policy.rect.fill).toBe('#ffffff'); + expect(policy.rect.stroke).not.toBe(policy.arrow.stroke); + expect(policy.rect.radius).toBeGreaterThan(policy.frame.radius ?? 0); + }); + + it('resolves explicit readable colors for a dark canvas', () => { + const policy = creationStylePolicy('dark'); + expect(policy.rect).toEqual({ fill: '#252738', stroke: '#8f93a8', radius: 12 }); + expect(policy.text.color).toBe('#d8e6e6'); + expect(policy.markdown.color).toBe(policy.text.color); + expect(Object.values(policy).some((value) => JSON.stringify(value).includes('var('))).toBe(false); + }); +}); diff --git a/packages/core/tests/tools.test.ts b/packages/core/tests/tools.test.ts index 916bf69..ca1cd7a 100644 --- a/packages/core/tests/tools.test.ts +++ b/packages/core/tests/tools.test.ts @@ -471,7 +471,7 @@ describe("Tools", () => { const createdShape = shapeId ? nextState.doc.shapes[shapeId] : null; expect(createdShape?.type).toBe("text"); - expect((createdShape?.props as TextProps).color).toBe("#1f2933"); + expect((createdShape?.props as TextProps).color).toBe("#1e2029"); }); }); }); diff --git a/packages/editor/src/renderer.ts b/packages/editor/src/renderer.ts index 1ddf6fb..f9c8048 100644 --- a/packages/editor/src/renderer.ts +++ b/packages/editor/src/renderer.ts @@ -780,7 +780,7 @@ function drawContainer(context: CanvasRenderingContext2D, shape: Extract(null); @@ -405,13 +407,14 @@ export function createCanvasController( (point, state, excludedIds) => snapPoint(state, point, excludedIds, snapStore.get()) ); const directSelectTool = new DirectSelectTool(); - const rectTool = new RectTool(); - const ellipseTool = new EllipseTool(); - const frameTool = new FrameTool(); - const lineTool = new LineTool(); - const arrowTool = new ArrowTool(); - const textTool = new TextTool(); - const markdownTool = new MarkdownTool(); + const getCanvasAppearance = () => themeStore.current; + const rectTool = new RectTool(getCanvasAppearance); + const ellipseTool = new EllipseTool(getCanvasAppearance); + const frameTool = new FrameTool(getCanvasAppearance); + const lineTool = new LineTool(getCanvasAppearance); + const arrowTool = new ArrowTool(getCanvasAppearance); + const textTool = new TextTool(getCanvasAppearance); + const markdownTool = new MarkdownTool(getCanvasAppearance); const getPenBrushConfig = () => { const { color: _color, ...config } = brushStore.get(); return config; -- 2.51.2