Compare commits

..
6 Commits
Author SHA1 Message Date
sanjibdevnathlabs-release-bot[bot] 4e410f1205 chore(release): v1.5.1 2026-03-17 13:27:27 +00:00
sanjibdevnathlabsandClaude Opus 4.6 71b2a55231 ♻️ refactor(fonts): extract font families to shared JSON single source of truth
Font family IDs were duplicated across 5 files (types.ts, setup.ts,
index.ts, SKILL.md, preferences.example.json) with inconsistent
mappings — Comic Shanns was 4 in some places but actually 8 in
Excalidraw source. This caused wrong fonts to render on canvas.

Fix: create src/font-families.json as the canonical font data, import
it in types.ts, and derive all other references from it. Static docs
now point to the JSON file instead of duplicating the mapping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 18:55:11 +05:30
sanjibdevnathlabs-release-bot[bot] 25767838fa chore(release): v1.5.0 2026-03-17 12:12:15 +00:00
sanjibdevnathlabsandClaude Opus 4.6 493a20054b feat(setup): add interactive diagram preferences to setup and update flows
Users are now prompted to choose their preferred font family and roughness
style during both `setup` and `update`. Preferences are saved to
~/.claude/skills/excalidraw-skill/preferences.json, which the MCP server
already reads at startup via loadPreferences(). This ensures third-party
users who install via npx get preferences configured before first use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:39:58 +05:30
sanjibdevnathlabs-release-bot[bot] aa29ddbf13 chore(release): v1.4.0 2026-03-17 09:02:29 +00:00
sanjibdevnathlabsandClaude Opus 4.6 9114e81f02 feat(skill): add user-configurable diagram preferences system
Add a preference system that lets users configure default font, roughness,
fontSize, and strokeWidth — with three scopes (session/folder/global).

Skill layer (Step 1 in SKILL.md):
- Reads .claude/excalidraw-preferences.json (folder) then
  ~/.claude/skills/excalidraw-skill/preferences.json (global)
- If neither exists, prompts user interactively on first use
- Session-only scope keeps preferences in-memory without saving

Server layer (index.ts):
- loadPreferences() reads the same files at startup
- Replaces hardcoded fontFamily ?? 1 with USER_PREFS.fontFamily
- Folder-level preferences override global; user values override both

Also ships preferences.example.json as a template (preferences.json
is gitignored).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 14:27:34 +05:30
10 changed files with 285 additions and 38 deletions
+3
View File
@@ -15,6 +15,9 @@ public/dist/
.cursor/
.claude/
# User preferences (only the example ships)
skills/excalidraw-skill/preferences.json
# Development artifacts
*.excalidraw
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@sanjibdevnath/mcp-excalidraw-local",
"version": "1.3.0",
"version": "1.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@sanjibdevnath/mcp-excalidraw-local",
"version": "1.3.0",
"version": "1.5.1",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@sanjibdevnath/mcp-excalidraw-local",
"version": "1.3.0",
"version": "1.5.1",
"description": "Fully local MCP server for Excalidraw with SQLite persistence, multi-tenancy, auto-sync, real-time canvas, and 32 tools",
"main": "dist/index.js",
"type": "module",
+73
View File
@@ -15,6 +15,79 @@ Run these checks **in order**:
See `references/cheatsheet.md` for the full MCP-vs-REST mapping and REST API gotchas.
## Step 1: Load User Preferences
Before creating any elements, load the user's diagram preferences. These control default font, roughness, stroke width, etc.
### Preference Resolution Order (most specific wins)
| Priority | Scope | Location | Persists |
|----------|-------|----------|----------|
| 1 (highest) | Session | In-memory (set via prompt during this conversation) | No — current session only |
| 2 | Folder | `.claude/excalidraw-preferences.json` in the current project root | Yes — per-project |
| 3 | Global | `~/.claude/skills/excalidraw-skill/preferences.json` | Yes — all projects |
| 4 (lowest) | Hardcoded | Server defaults (fontFamily: 5, roughness: 0, fontSize: 20, strokeWidth: 2) | — |
### How to Load
1. **Check folder-level first**: Read `.claude/excalidraw-preferences.json` from the current working directory (or project root). If it exists and has `defaults`, use those values.
2. **Fall back to global**: Read `~/.claude/skills/excalidraw-skill/preferences.json`. If it exists and has `defaults`, use those values.
3. **If neither exists** → run the **First-Time Setup** prompt below.
4. **Merge**: Folder preferences override global; global overrides hardcoded. Only override fields that are explicitly set.
### First-Time Setup (Interactive)
If no preferences file exists at either location, **prompt the user before drawing anything**:
> **Excalidraw Preferences Setup**
>
> I don't have any saved diagram preferences yet. Let me set up your defaults so every diagram looks the way you want.
Ask these questions (use `AskUserQuestion` tool if available, otherwise ask inline):
1. **Font family** — Which font for all text? _(IDs from `src/font-families.json`)_
- Excalifont (hand-drawn) = 5
- Helvetica (sans-serif) = 2
- Cascadia (monospace) = 3
- Comic Shanns = 8
- Nunito = 6
- Lilita One = 7
2. **Roughness** — Diagram style?
- Clean/professional (roughness: 0) — recommended
- Hand-drawn sketch (roughness: 1)
- Very rough (roughness: 2)
3. **Scope** — Where to save?
- **This session only** — don't save to disk, just use for this conversation
- **This project** — save to `.claude/excalidraw-preferences.json` in project root
- **Global (all projects)** — save to `~/.claude/skills/excalidraw-skill/preferences.json`
Then save the preferences JSON to the chosen location:
```json
{
"defaults": {
"fontFamily": <user_choice>,
"fontSize": 20,
"roughness": <user_choice>,
"strokeWidth": 2
}
}
```
For session-only scope, just hold the values in memory and apply them to every element in this conversation.
### Applying Preferences
Once loaded, apply `defaults` to **every element** that supports the property:
- `fontFamily` → all text-containing elements (text, rectangles with labels, diamonds, ellipses, arrows with labels)
- `fontSize` → text elements and labels (unless the element explicitly overrides it)
- `roughness` → all elements
- `strokeWidth` → arrows and lines
User-specified values in individual element calls always override preferences.
## Core Principles (Read Before Any Diagram)
These principles were learned through extensive iterative use. Violating them produces bad diagrams.
@@ -0,0 +1,10 @@
{
"_comment": "Excalidraw MCP user preferences. Copy to preferences.json to activate.",
"_fontReference": "See src/font-families.json for canonical font ID → name mapping.",
"defaults": {
"fontFamily": 5,
"fontSize": 20,
"roughness": 0,
"strokeWidth": 2
}
}
@@ -94,16 +94,15 @@
|---------|----------------|-----------------|
| Shape labels | `"text": "My Label"` (auto-converts) | `"label": {"text": "My Label"}` |
| Arrow binding | `"startElementId": "id"` / `"endElementId": "id"` | `"start": {"id": "id"}` / `"end": {"id": "id"}` |
| `fontFamily` | String `"1"` or omit | String `"1"` or omit (never a number) |
| `fontFamily` | Number or string — use value from user preferences (see Step 1 in SKILL.md) | String — use value from user preferences |
| Tenant scoping | Auto (uses active tenant) | Include `X-Tenant-Id` header on every request |
### Element Creation Best Practices
- **Always set `roughness: 0`** for clean, professional diagrams (default is hand-drawn).
- **Always set `strokeWidth: 2`** on arrows for visibility.
- **Always apply user preferences** — load from Step 1 in SKILL.md and apply `fontFamily`, `roughness`, `fontSize`, `strokeWidth` to every element.
- **Create shapes first, arrows second** (two separate `batch_create_elements` calls).
- **Assign custom `id`** to every shape so arrows can reference it.
- **Size shapes for their text** — Virgil font is ~30% wider than standard. Use sizing formulas from SKILL.md.
- **Size shapes for their text** — use sizing formulas from SKILL.md.
- `points` accepts both `[[x,y]]` tuples and `[{x,y}]` objects — normalized automatically.
- **Curved arrows**: Use `"roundness": {"type": 2}` with 3+ points. **Elbowed arrows**: Use `"elbowed": true`.
+13
View File
@@ -0,0 +1,13 @@
{
"fonts": [
{ "id": 5, "name": "Excalifont", "label": "Excalifont (hand-drawn)", "aliases": ["excalifont", "hand-drawn"] },
{ "id": 2, "name": "Helvetica", "label": "Helvetica (sans-serif)", "aliases": ["helvetica", "arial", "sans-serif"] },
{ "id": 3, "name": "Cascadia", "label": "Cascadia (monospace)", "aliases": ["cascadia", "monospace", "courier"] },
{ "id": 8, "name": "Comic Shanns", "label": "Comic Shanns", "aliases": ["comic shanns", "comic sans"] },
{ "id": 6, "name": "Nunito", "label": "Nunito", "aliases": ["nunito"] },
{ "id": 7, "name": "Lilita One", "label": "Lilita One", "aliases": ["lilita one"] },
{ "id": 9, "name": "Liberation Sans", "label": "Liberation Sans", "aliases": ["liberation sans"], "legacy": true },
{ "id": 1, "name": "Virgil", "label": "Virgil (legacy)", "aliases": ["virgil"], "legacy": true }
],
"defaultFontFamily": 5
}
+50 -7
View File
@@ -27,7 +27,9 @@ import {
ExcalidrawElementType,
validateElement,
normalizeFontFamily,
files as globalFiles
files as globalFiles,
DEFAULT_FONT_FAMILY,
FONT_FAMILY_DESCRIPTION,
} from './types.js';
import fetch from 'node-fetch';
import { startCanvasServer, stopCanvasServer } from './server.js';
@@ -65,6 +67,47 @@ const CANVAS_PORT = process.env.CANVAS_PORT || process.env.PORT || '3000';
const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || `http://localhost:${CANVAS_PORT}`;
const ENABLE_CANVAS_SYNC = true;
// User preferences for element defaults (font, roughness, etc.)
// Resolution: folder-level .claude/excalidraw-preferences.json > global ~/.claude/skills/excalidraw-skill/preferences.json > hardcoded
interface ExcalidrawPreferences {
fontFamily: number;
fontSize: number;
roughness: number;
strokeWidth: number;
}
const HARDCODED_DEFAULTS: ExcalidrawPreferences = {
fontFamily: DEFAULT_FONT_FAMILY,
fontSize: 20,
roughness: 0,
strokeWidth: 2,
};
function loadPreferences(): ExcalidrawPreferences {
const locations = [
path.join(process.cwd(), '.claude', 'excalidraw-preferences.json'),
path.join(process.env.HOME || '~', '.claude', 'skills', 'excalidraw-skill', 'preferences.json'),
];
for (const loc of locations) {
try {
if (fs.existsSync(loc)) {
const raw = JSON.parse(fs.readFileSync(loc, 'utf-8'));
if (raw?.defaults) {
logger.info(`Loaded user preferences from ${loc}`);
return { ...HARDCODED_DEFAULTS, ...raw.defaults };
}
}
} catch (e) {
logger.warn(`Failed to read preferences from ${loc}: ${e}`);
}
}
return HARDCODED_DEFAULTS;
}
const USER_PREFS = loadPreferences();
// One-time tokens for clear_canvas confirmation (token → expiry timestamp)
const pendingClearTokens = new Map<string, { expiresAt: number; elementCount: number }>();
const CLEAR_TOKEN_TTL_MS = 120_000; // 2 minutes
@@ -418,7 +461,7 @@ const tools: Tool[] = [
opacity: { type: 'number' },
text: { type: 'string' },
fontSize: { type: 'number' },
fontFamily: { type: ['string', 'number'], description: 'Font family: 1=Excalifont (hand-drawn), 2=Helvetica (sans-serif), 3=Cascadia (monospace), 4=Comic Shanns, 5=Liberation Sans, 6=Nunito, 7=Lilita One. Accepts name strings too.' },
fontFamily: { type: ['string', 'number'], description: FONT_FAMILY_DESCRIPTION },
startElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow start to. Arrow auto-routes to element edge.' },
endElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow end to. Arrow auto-routes to element edge.' },
endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' },
@@ -649,7 +692,7 @@ const tools: Tool[] = [
opacity: { type: 'number' },
text: { type: 'string' },
fontSize: { type: 'number' },
fontFamily: { type: ['string', 'number'], description: 'Font family: 1=Excalifont, 2=Helvetica, 3=Cascadia, 4=Comic Shanns, 5=Liberation Sans, 6=Nunito, 7=Lilita One. Accepts name strings too.' },
fontFamily: { type: ['string', 'number'], description: FONT_FAMILY_DESCRIPTION },
startElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow start to' },
endElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow end to' },
endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' },
@@ -2199,8 +2242,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
if (el.type === 'text') {
base.text = text ?? '';
base.originalText = text ?? '';
base.fontSize = rest.fontSize ?? 20;
base.fontFamily = rest.fontFamily ?? 1;
base.fontSize = rest.fontSize ?? USER_PREFS.fontSize;
base.fontFamily = rest.fontFamily ?? USER_PREFS.fontFamily;
base.textAlign = rest.textAlign ?? 'center';
base.verticalAlign = rest.verticalAlign ?? 'middle';
base.autoResize = rest.autoResize ?? true;
@@ -2294,8 +2337,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
locked: false,
text: labelText,
originalText: labelText,
fontSize: isArrow ? 14 : (rest.fontSize ?? 16),
fontFamily: rest.fontFamily ?? 1,
fontSize: isArrow ? 14 : (rest.fontSize ?? USER_PREFS.fontSize),
fontFamily: rest.fontFamily ?? USER_PREFS.fontFamily,
textAlign: 'center',
verticalAlign: 'middle',
autoResize: true,
+100 -6
View File
@@ -13,6 +13,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { execSync } from 'child_process';
import { FONT_FAMILIES, DEFAULT_FONT_FAMILY } from './types.js';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
@@ -118,7 +119,7 @@ function detectInstalledAgents(): AgentDef[] {
// ── Phase 1: Environment Check ──────────────────────────────
async function phaseEnvironment(rl: readline.Interface): Promise<boolean> {
heading('1/3', 'Environment');
heading('1/4', 'Environment');
let allOk = true;
// Node.js version
@@ -182,10 +183,99 @@ async function phaseEnvironment(rl: readline.Interface): Promise<boolean> {
return allOk;
}
// ── Preference Setup ─────────────────────────────────────────
// Derived from FONT_FAMILIES in types.ts — single source of truth
const FONT_OPTIONS = FONT_FAMILIES
.filter(f => !f.legacy)
.map(f => ({ value: f.id, label: f.label }));
const ROUGHNESS_OPTIONS: { value: number; label: string }[] = [
{ value: 0, label: 'Clean / professional' },
{ value: 1, label: 'Hand-drawn sketch' },
{ value: 2, label: 'Very rough' },
];
function getGlobalPreferencesPath(): string {
return path.join(os.homedir(), '.claude', 'skills', 'excalidraw-skill', 'preferences.json');
}
function globalPreferencesExist(): boolean {
return fs.existsSync(getGlobalPreferencesPath());
}
function writePreferencesFile(filePath: string, prefs: { fontFamily: number; fontSize: number; roughness: number; strokeWidth: number }): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const content = {
defaults: prefs,
};
fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n', 'utf-8');
}
async function phasePreferences(rl: readline.Interface, phaseLabel: string): Promise<void> {
heading(phaseLabel, 'Diagram Preferences');
const prefsPath = getGlobalPreferencesPath();
if (globalPreferencesExist()) {
try {
const raw = JSON.parse(fs.readFileSync(prefsPath, 'utf-8'));
const d = raw?.defaults;
if (d) {
const fontLabel = FONT_OPTIONS.find(f => f.value === d.fontFamily)?.label ?? `font ${d.fontFamily}`;
const roughLabel = ROUGHNESS_OPTIONS.find(r => r.value === d.roughness)?.label ?? `roughness ${d.roughness}`;
ok(`Current: ${fontLabel}, ${roughLabel}, fontSize ${d.fontSize}, strokeWidth ${d.strokeWidth}`);
const change = await confirm(rl, 'Change preferences?', false);
if (!change) return;
}
} catch {
warn(`Could not read ${prefsPath}, will reconfigure.`);
}
}
info('These defaults apply to every diagram (font, style, etc.).');
info('');
// Font
process.stdout.write('\n Font family:\n');
FONT_OPTIONS.forEach((f, i) => {
const marker = f.value === DEFAULT_FONT_FAMILY ? ' (default)' : '';
process.stdout.write(` ${CYAN}[${i + 1}]${RESET} ${f.label}${marker}\n`);
});
const fontAnswer = (await ask(rl, 'Choose [1]: ')).trim();
const fontIdx = fontAnswer === '' ? 0 : parseInt(fontAnswer, 10) - 1;
const fontFamily = (fontIdx >= 0 && fontIdx < FONT_OPTIONS.length) ? FONT_OPTIONS[fontIdx]!.value : DEFAULT_FONT_FAMILY;
// Roughness
process.stdout.write('\n Diagram style:\n');
ROUGHNESS_OPTIONS.forEach((r, i) => {
const marker = r.value === 0 ? ' (default)' : '';
process.stdout.write(` ${CYAN}[${i + 1}]${RESET} ${r.label}${marker}\n`);
});
const roughAnswer = (await ask(rl, 'Choose [1]: ')).trim();
const roughIdx = roughAnswer === '' ? 0 : parseInt(roughAnswer, 10) - 1;
const roughness = (roughIdx >= 0 && roughIdx < ROUGHNESS_OPTIONS.length) ? ROUGHNESS_OPTIONS[roughIdx]!.value : 0;
const prefs = { fontFamily, fontSize: 20, roughness, strokeWidth: 2 };
try {
writePreferencesFile(prefsPath, prefs);
const fontLabel = FONT_OPTIONS.find(f => f.value === fontFamily)?.label ?? `${fontFamily}`;
const roughLabel = ROUGHNESS_OPTIONS.find(r => r.value === roughness)?.label ?? `${roughness}`;
ok(`Saved: ${fontLabel}, ${roughLabel}`);
ok(`File: ${prefsPath}`);
} catch (err) {
fail(`Failed to save preferences: ${(err as Error).message}`);
}
}
// ── Phase 2: Skill Installation ─────────────────────────────
async function phaseSkillInstall(rl: readline.Interface): Promise<void> {
heading('2/3', 'Agent Skill');
heading('2/4', 'Agent Skill');
const wantSkill = await confirm(rl, 'Install the Excalidraw agent skill?');
if (!wantSkill) {
@@ -347,7 +437,7 @@ function writeInstructionDirective(filePath: string, format: 'claude-md' | 'curs
// ── Phase 3: MCP Configuration ──────────────────────────────
async function phaseMcpConfig(rl: readline.Interface): Promise<void> {
heading('3/3', 'MCP Configuration');
heading('4/4', 'MCP Configuration');
const wantConfig = await confirm(rl, 'Add MCP server to agent configs automatically?');
if (!wantConfig) {
@@ -519,7 +609,7 @@ export async function runUpdate(): Promise<void> {
try {
// ── Phase 1: Detect existing skill installations ──────────
heading('1/2', 'Skill Update');
heading('1/3', 'Skill Update');
const allInstalls = findExistingSkillInstalls();
const existing = allInstalls.filter(i => i.exists);
@@ -612,8 +702,11 @@ export async function runUpdate(): Promise<void> {
}
}
// ── Phase 2: MCP config check ────────────────────────────
heading('2/2', 'MCP Configuration');
// ── Phase 2: Preferences ─────────────────────────────────
await phasePreferences(rl, '2/3');
// ── Phase 3: MCP config check ────────────────────────────
heading('3/3', 'MCP Configuration');
for (const agent of detectedAgents) {
if (agent.mcpConfigType === 'json-file' && agent.mcpConfigPath) {
@@ -685,6 +778,7 @@ export async function runSetup(): Promise<void> {
try {
await phaseEnvironment(rl);
await phaseSkillInstall(rl);
await phasePreferences(rl, '3/4');
await phaseMcpConfig(rl);
process.stdout.write(`\n ${GREEN}${BOLD}Done!${RESET} Open ${CYAN}http://localhost:3000${RESET} to verify the canvas.\n\n`);
+30 -18
View File
@@ -311,24 +311,36 @@ export interface ExcalidrawFile {
// In-memory file storage (image files are too large for SQLite row storage)
export const files = new Map<string, ExcalidrawFile>();
// Font family normalization: Excalidraw expects numeric IDs, but agents
// often send string names. Map common names to their numeric equivalents.
const FONT_FAMILY_MAP: Record<string, number> = {
'virgil': 1,
'hand-drawn': 1,
'excalifont': 1,
'helvetica': 2,
'arial': 2,
'sans-serif': 2,
'cascadia': 3,
'monospace': 3,
'courier': 3,
'comic shanns': 4,
'comic sans': 4,
'liberation sans': 5,
'nunito': 6,
'lilita one': 7,
};
// ── Font families — single source of truth ──────────────────────────────
// IDs match the @excalidraw/excalidraw FONT_FAMILY constant.
// The canonical data lives in font-families.json; every other file derives from it.
import fontData from './font-families.json' with { type: 'json' };
export interface FontFamilyDef {
id: number;
name: string;
label: string;
aliases: string[];
legacy?: boolean; // hidden from setup menus / tool docs
}
export const FONT_FAMILIES: FontFamilyDef[] = fontData.fonts as FontFamilyDef[];
export const DEFAULT_FONT_FAMILY: number = fontData.defaultFontFamily;
// Derived: description string for MCP tool schemas
export const FONT_FAMILY_DESCRIPTION =
'Font family: ' +
FONT_FAMILIES.filter(f => !f.legacy).map(f => `${f.id}=${f.name}`).join(', ') +
'. Accepts name strings too.';
// Derived: string → number mapping for normalization
const FONT_FAMILY_MAP: Record<string, number> = {};
for (const font of FONT_FAMILIES) {
for (const alias of font.aliases) {
FONT_FAMILY_MAP[alias] = font.id;
}
}
export function normalizeFontFamily(value: string | number | undefined): number | undefined {
if (value === undefined || value === null) return undefined;