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>
This commit is contained in:
sanjibdevnathlabs
2026-03-17 14:27:34 +05:30
co-authored by Claude Opus 4.6
parent fac1c7a267
commit 9114e81f02
5 changed files with 142 additions and 8 deletions
+3
View File
@@ -15,6 +15,9 @@ public/dist/
.cursor/ .cursor/
.claude/ .claude/
# User preferences (only the example ships)
skills/excalidraw-skill/preferences.json
# Development artifacts # Development artifacts
*.excalidraw *.excalidraw
+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. 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: 1, 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?
- Excalifont (hand-drawn) = 1
- Helvetica (sans-serif) = 2
- Cascadia (monospace) = 3
- Comic Shanns = 4
- 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) ## Core Principles (Read Before Any Diagram)
These principles were learned through extensive iterative use. Violating them produces bad diagrams. These principles were learned through extensive iterative use. Violating them produces bad diagrams.
@@ -0,0 +1,18 @@
{
"_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"
},
"defaults": {
"fontFamily": 1,
"fontSize": 20,
"roughness": 0,
"strokeWidth": 2
}
}
@@ -94,16 +94,15 @@
|---------|----------------|-----------------| |---------|----------------|-----------------|
| Shape labels | `"text": "My Label"` (auto-converts) | `"label": {"text": "My Label"}` | | Shape labels | `"text": "My Label"` (auto-converts) | `"label": {"text": "My Label"}` |
| Arrow binding | `"startElementId": "id"` / `"endElementId": "id"` | `"start": {"id": "id"}` / `"end": {"id": "id"}` | | 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 | | Tenant scoping | Auto (uses active tenant) | Include `X-Tenant-Id` header on every request |
### Element Creation Best Practices ### Element Creation Best Practices
- **Always set `roughness: 0`** for clean, professional diagrams (default is hand-drawn). - **Always apply user preferences** — load from Step 1 in SKILL.md and apply `fontFamily`, `roughness`, `fontSize`, `strokeWidth` to every element.
- **Always set `strokeWidth: 2`** on arrows for visibility.
- **Create shapes first, arrows second** (two separate `batch_create_elements` calls). - **Create shapes first, arrows second** (two separate `batch_create_elements` calls).
- **Assign custom `id`** to every shape so arrows can reference it. - **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. - `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`. - **Curved arrows**: Use `"roundness": {"type": 2}` with 3+ points. **Elbowed arrows**: Use `"elbowed": true`.
+45 -4
View File
@@ -65,6 +65,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 EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || `http://localhost:${CANVAS_PORT}`;
const ENABLE_CANVAS_SYNC = true; 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: 1,
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) // One-time tokens for clear_canvas confirmation (token → expiry timestamp)
const pendingClearTokens = new Map<string, { expiresAt: number; elementCount: number }>(); const pendingClearTokens = new Map<string, { expiresAt: number; elementCount: number }>();
const CLEAR_TOKEN_TTL_MS = 120_000; // 2 minutes const CLEAR_TOKEN_TTL_MS = 120_000; // 2 minutes
@@ -2199,8 +2240,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
if (el.type === 'text') { if (el.type === 'text') {
base.text = text ?? ''; base.text = text ?? '';
base.originalText = text ?? ''; base.originalText = text ?? '';
base.fontSize = rest.fontSize ?? 20; base.fontSize = rest.fontSize ?? USER_PREFS.fontSize;
base.fontFamily = rest.fontFamily ?? 1; base.fontFamily = rest.fontFamily ?? USER_PREFS.fontFamily;
base.textAlign = rest.textAlign ?? 'center'; base.textAlign = rest.textAlign ?? 'center';
base.verticalAlign = rest.verticalAlign ?? 'middle'; base.verticalAlign = rest.verticalAlign ?? 'middle';
base.autoResize = rest.autoResize ?? true; base.autoResize = rest.autoResize ?? true;
@@ -2294,8 +2335,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
locked: false, locked: false,
text: labelText, text: labelText,
originalText: labelText, originalText: labelText,
fontSize: isArrow ? 14 : (rest.fontSize ?? 16), fontSize: isArrow ? 14 : (rest.fontSize ?? USER_PREFS.fontSize),
fontFamily: rest.fontFamily ?? 1, fontFamily: rest.fontFamily ?? USER_PREFS.fontFamily,
textAlign: 'center', textAlign: 'center',
verticalAlign: 'middle', verticalAlign: 'middle',
autoResize: true, autoResize: true,