Compare commits

...
4 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
8 changed files with 158 additions and 45 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@sanjibdevnath/mcp-excalidraw-local",
"version": "1.4.0",
"version": "1.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@sanjibdevnath/mcp-excalidraw-local",
"version": "1.4.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.4.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",
+4 -4
View File
@@ -26,7 +26,7 @@ Before creating any elements, load the user's diagram preferences. These control
| 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: 1, roughness: 0, fontSize: 20, strokeWidth: 2) | — |
| 4 (lowest) | Hardcoded | Server defaults (fontFamily: 5, roughness: 0, fontSize: 20, strokeWidth: 2) | — |
### How to Load
@@ -45,11 +45,11 @@ If no preferences file exists at either location, **prompt the user before drawi
Ask these questions (use `AskUserQuestion` tool if available, otherwise ask inline):
1. **Font family** — Which font for all text?
- Excalifont (hand-drawn) = 1
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 = 4
- Comic Shanns = 8
- Nunito = 6
- Lilita One = 7
@@ -1,16 +1,8 @@
{
"_comment": "Excalidraw MCP user preferences. Copy to preferences.json to activate.",
"_fontReference": {
"1": "Excalifont (hand-drawn)",
"2": "Helvetica (sans-serif)",
"3": "Cascadia (monospace)",
"4": "Comic Shanns",
"5": "Liberation Sans",
"6": "Nunito",
"7": "Lilita One"
},
"_fontReference": "See src/font-families.json for canonical font ID → name mapping.",
"defaults": {
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"roughness": 0,
"strokeWidth": 2
+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
}
+6 -4
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';
@@ -75,7 +77,7 @@ interface ExcalidrawPreferences {
}
const HARDCODED_DEFAULTS: ExcalidrawPreferences = {
fontFamily: 1,
fontFamily: DEFAULT_FONT_FAMILY,
fontSize: 20,
roughness: 0,
strokeWidth: 2,
@@ -459,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' },
@@ -690,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' },
+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;