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;