✨ 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:
co-authored by
Claude Opus 4.6
parent
fac1c7a267
commit
9114e81f02
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user