dsh-nerd-mode 0.4.6 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +11 -6
  2. package/README.zh.md +11 -6
  3. package/assets/calibration.svg +69 -69
  4. package/assets/nerd.svg +22 -22
  5. package/assets/section.md +15 -15
  6. package/client.js +2170 -1806
  7. package/cordis.patch.yml +24 -24
  8. package/lib/index.js +838 -223
  9. package/lib/study-sessions.js +283 -0
  10. package/package.json +2 -2
  11. package/skills/convert-documents-to-markdown/SKILL.md +26 -26
  12. package/skills/diagram/SKILL.md +43 -19
  13. package/skills/diagram/scripts/check-svg.mjs +184 -0
  14. package/skills/humanizer/.claude-plugin/marketplace.json +18 -18
  15. package/skills/humanizer/.claude-plugin/plugin.json +15 -15
  16. package/skills/humanizer/.cursor-plugin/plugin.json +12 -12
  17. package/skills/humanizer/.github/ISSUE_TEMPLATE/config.yml +8 -8
  18. package/skills/humanizer/.github/ISSUE_TEMPLATE/new-pattern.yml +24 -24
  19. package/skills/humanizer/.github/ISSUE_TEMPLATE/rewrite-problem.yml +44 -44
  20. package/skills/humanizer/.github/workflows/validate.yml +29 -29
  21. package/skills/humanizer/AGENTS.md +54 -54
  22. package/skills/humanizer/CHANGELOG.md +116 -116
  23. package/skills/humanizer/LICENSE +21 -21
  24. package/skills/humanizer/README.md +206 -206
  25. package/skills/humanizer/SKILL.md +397 -397
  26. package/skills/humanizer/agents/openai.yaml +4 -4
  27. package/skills/humanizer/scripts/validate-package.py +146 -146
  28. package/skills/study/SKILL.md +81 -78
  29. package/skills/study-plus/SKILL.md +246 -246
  30. package/skills/study-plus/scripts/book-client.js +284 -284
  31. package/skills/study-plus/scripts/book-data.mjs +289 -289
  32. package/skills/study-plus/scripts/build-book.mjs +155 -155
  33. package/skills/writing-beats/SKILL.md +66 -66
  34. package/skills/writing-beats/agents/openai.yaml +5 -5
@@ -0,0 +1,283 @@
1
+ /**
2
+ * The Study Sessions history index. It reads the session logs the host writes
3
+ * and caches the two facts the panel needs: whether Nerd Mode was ever on, and
4
+ * whether a completed `/study-plus` invocation appears. Kept free of host
5
+ * imports like `lib/fold.js`, so it runs under plain node. See ADR 0021.
6
+ */
7
+
8
+ import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
9
+ import { homedir } from 'node:os'
10
+ import { dirname, join } from 'node:path'
11
+ import { zstdDecompressSync } from 'node:zlib'
12
+
13
+ const COMMAND = 'nerd-mode'
14
+ const STUDY_PLUS = 'study-plus'
15
+ const SESSION_FILE = 'session.v4.jsonl.zstd'
16
+ const DEFAULT_INDEX_FILE = join(homedir(), '.dsh', 'study-sessions-index.json')
17
+ const DEFAULT_SESSIONS_ROOT = join(homedir(), '.dsh', 'sessions')
18
+ const DEFAULT_CHUNK_SIZE = 20
19
+ const MAGIC = [0x28, 0xb5, 0x2f, 0xfd]
20
+ // The token must stand alone: `not/study-plus` and `/study-plussed` are not
21
+ // invocations. This is the same boundary the live command parser uses.
22
+ const DIRECT_COMMAND = /(^|\s)\/study-plus(?=\s|$)/
23
+
24
+ function isRecord(value) {
25
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
26
+ }
27
+
28
+ function messageText(message) {
29
+ const content = message?.content
30
+ if (!Array.isArray(content)) return ''
31
+ return content
32
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
33
+ .map((block) => block.text)
34
+ .join('\n')
35
+ }
36
+
37
+ function skillNameOf(argumentText) {
38
+ if (typeof argumentText !== 'string') return undefined
39
+ try {
40
+ return JSON.parse(argumentText)?.name
41
+ } catch {
42
+ return undefined
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Read the two history facts from one session's decoded events.
48
+ * @param events - decoded session log events, in log order
49
+ * @returns {{ nerd: boolean, studyPlus: boolean }} `nerd` means Nerd Mode was
50
+ * switched on at least once, `studyPlus` means a completed `/study-plus`
51
+ * invocation appears. Malformed events are ignored.
52
+ */
53
+ export function classify(events) {
54
+ const successfulCommands = new Set()
55
+ const wantedCommands = new Set()
56
+ const skillCalls = new Set()
57
+ const completedCalls = new Set()
58
+ let studyPlus = false
59
+
60
+ for (const event of events) {
61
+ const data = event?.data
62
+ if (event?.type === 'command/run') {
63
+ if (data?.name !== COMMAND) continue
64
+ // A bare /nerd-mode means on, matching lib/fold.js.
65
+ const args = typeof data.args === 'string' ? data.args : ''
66
+ if (args.trim().toLowerCase() !== 'off' && typeof data.commandId === 'string') {
67
+ wantedCommands.add(data.commandId)
68
+ }
69
+ } else if (event?.type === 'command/done') {
70
+ if (data?.kind === 'success' && typeof data.commandId === 'string') {
71
+ successfulCommands.add(data.commandId)
72
+ }
73
+ } else if (event?.type === 'user/message') {
74
+ const source = data?.source
75
+ if (source?.kind === 'skill-invocation') {
76
+ if (source.name === STUDY_PLUS) studyPlus = true
77
+ } else if (source?.kind === 'user' && DIRECT_COMMAND.test(messageText(data))) {
78
+ studyPlus = true
79
+ }
80
+ } else if (event?.type === 'tool/call') {
81
+ if (data?.name === 'skill' && typeof data.callId === 'string' && skillNameOf(data.arguments) === STUDY_PLUS) {
82
+ skillCalls.add(data.callId)
83
+ }
84
+ } else if (event?.type === 'tool/result') {
85
+ const message = data?.message
86
+ if (message?.isError !== true && typeof message?.source?.callId === 'string') {
87
+ completedCalls.add(message.source.callId)
88
+ }
89
+ } else if (event?.type === 'tool/ptc-dispatch') {
90
+ // A skill load inside run_code arrives as a dispatch and carries its own outcome.
91
+ if (data?.name === 'skill' && data?.arguments?.name === STUDY_PLUS && data?.isError !== true) {
92
+ studyPlus = true
93
+ }
94
+ }
95
+ }
96
+
97
+ if (!studyPlus) {
98
+ studyPlus = [...skillCalls].some((callId) => completedCalls.has(callId))
99
+ }
100
+ return {
101
+ nerd: [...wantedCommands].some((commandId) => successfulCommands.has(commandId)),
102
+ studyPlus,
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Decode a session log into events.
108
+ *
109
+ * The file is a chain of zstd frames, one per append batch. Whole-file
110
+ * decompression stops at the first frame, which holds only the header, so each
111
+ * frame is decoded on its own.
112
+ *
113
+ * @param file - path to a `session.v4.jsonl.zstd` file
114
+ * @returns the decoded events; malformed frames and lines are dropped
115
+ * @throws when the file itself cannot be read
116
+ */
117
+ export function decodeSessionFile(file) {
118
+ const bytes = readFileSync(file)
119
+ const starts = []
120
+ for (let offset = 0; offset <= bytes.length - MAGIC.length; offset += 1) {
121
+ if (MAGIC.every((byte, index) => bytes[offset + index] === byte)) starts.push(offset)
122
+ }
123
+
124
+ const events = []
125
+ for (let index = 0; index < starts.length; index += 1) {
126
+ let text
127
+ try {
128
+ text = zstdDecompressSync(bytes.subarray(starts[index], starts[index + 1] ?? bytes.length)).toString('utf8')
129
+ } catch {
130
+ // A magic byte sequence can occur inside compressed data, so a failed
131
+ // slice is expected. See docs/agents/dsh-session-api.md.
132
+ continue
133
+ }
134
+ for (const line of text.split('\n')) {
135
+ if (line === '') continue
136
+ try {
137
+ events.push(JSON.parse(line))
138
+ } catch {
139
+ // A crash can leave a partial trailing line. One bad record must not
140
+ // hide the rest of the log.
141
+ }
142
+ }
143
+ }
144
+ return events
145
+ }
146
+
147
+ function readIndex(file) {
148
+ let text
149
+ try {
150
+ text = readFileSync(file, 'utf8')
151
+ } catch (error) {
152
+ if (error?.code === 'ENOENT') return {}
153
+ // A missing index is a first run. Any other failure reaches the panel.
154
+ throw error
155
+ }
156
+ try {
157
+ const index = JSON.parse(text)
158
+ return isRecord(index) ? index : {}
159
+ } catch {
160
+ // A corrupt cache is rebuilt from the logs, not reported as a failure.
161
+ return {}
162
+ }
163
+ }
164
+
165
+ function writeIndex(file, index) {
166
+ mkdirSync(dirname(file), { recursive: true })
167
+ const temp = `${file}.tmp`
168
+ writeFileSync(temp, JSON.stringify(index))
169
+ // Rename keeps the previous index readable if the process dies mid-write.
170
+ renameSync(temp, file)
171
+ }
172
+
173
+ function listSessionFiles(root) {
174
+ let workspaces
175
+ try {
176
+ workspaces = readdirSync(root, { withFileTypes: true })
177
+ } catch (error) {
178
+ if (error?.code === 'ENOENT') return { files: [], complete: true }
179
+ throw error
180
+ }
181
+
182
+ const files = []
183
+ let complete = true
184
+ for (const workspace of workspaces) {
185
+ if (!workspace.isDirectory()) continue
186
+ let sessions
187
+ try {
188
+ sessions = readdirSync(join(root, workspace.name), { withFileTypes: true })
189
+ } catch {
190
+ // One locked workspace must not blank the others, and must not prune them.
191
+ complete = false
192
+ continue
193
+ }
194
+ for (const session of sessions) {
195
+ if (!session.isDirectory()) continue
196
+ const file = join(root, workspace.name, session.name, SESSION_FILE)
197
+ try {
198
+ const stat = statSync(file)
199
+ if (stat.isFile()) {
200
+ files.push({ id: session.name, file, mtimeMs: stat.mtimeMs, size: stat.size })
201
+ }
202
+ } catch {
203
+ // A session folder without a readable log is not a session yet.
204
+ }
205
+ }
206
+ }
207
+ files.sort((left, right) => (left.file < right.file ? -1 : left.file > right.file ? 1 : 0))
208
+ return { files, complete }
209
+ }
210
+
211
+ /**
212
+ * Classify one chunk of the session tree and persist the index.
213
+ *
214
+ * @param options
215
+ * @param options.cursor - index of the first file in the sorted session list to
216
+ * process; negative and non-integer values read as 0
217
+ * @param options.indexFile - index path; defaults to
218
+ * `~/.dsh/study-sessions-index.json`
219
+ * @param options.sessionsRoot - session tree; defaults to `~/.dsh/sessions`
220
+ * @param options.chunkSize - files to process per call; defaults to 20
221
+ * @param options.decode - decoder override for tests; defaults to
222
+ * `decodeSessionFile`
223
+ * @returns {{ phase: 'pending' | 'ready', cursor: number, done: number,
224
+ * total: number, sessions: Record<string, { studyPlus: boolean }> }} `cursor`
225
+ * is the next call's starting index and equals `total` once `phase` is
226
+ * `ready`. `done` counts files visited so far. A key in `sessions` means Nerd
227
+ * Mode was on at least once for that id.
228
+ * @throws when the index or the session tree cannot be read or written
229
+ */
230
+ export function scan({
231
+ cursor = 0,
232
+ indexFile = DEFAULT_INDEX_FILE,
233
+ sessionsRoot = DEFAULT_SESSIONS_ROOT,
234
+ chunkSize = DEFAULT_CHUNK_SIZE,
235
+ decode = decodeSessionFile,
236
+ } = {}) {
237
+ const { files, complete } = listSessionFiles(sessionsRoot)
238
+ const index = readIndex(indexFile)
239
+ if (complete) {
240
+ const live = new Set(files.map(({ id }) => id))
241
+ for (const id of Object.keys(index)) {
242
+ if (!live.has(id)) delete index[id]
243
+ }
244
+ }
245
+
246
+ const start = Number.isInteger(cursor) && cursor > 0 ? cursor : 0
247
+ const size = Number.isInteger(chunkSize) && chunkSize > 0 ? chunkSize : DEFAULT_CHUNK_SIZE
248
+ const end = Math.min(start + size, files.length)
249
+ for (let position = start; position < end; position += 1) {
250
+ const { id, file, mtimeMs, size: bytes } = files[position]
251
+ const previous = index[id]
252
+ if (previous?.mtimeMs === mtimeMs && previous?.size === bytes) continue
253
+
254
+ let flags
255
+ try {
256
+ flags = classify(decode(file))
257
+ } catch {
258
+ // An unreadable session keeps its previous record and retries next scan.
259
+ continue
260
+ }
261
+ index[id] = {
262
+ mtimeMs,
263
+ size: bytes,
264
+ // Compaction can drop the original event. A fact that disappeared from a
265
+ // rewritten log must not resurface as a missing session.
266
+ nerd: flags.nerd || previous?.nerd === true,
267
+ studyPlus: flags.studyPlus,
268
+ }
269
+ }
270
+ writeIndex(indexFile, index)
271
+
272
+ const sessions = {}
273
+ for (const [id, entry] of Object.entries(index)) {
274
+ if (entry?.nerd === true) sessions[id] = { studyPlus: entry.studyPlus === true }
275
+ }
276
+ return {
277
+ phase: end >= files.length ? 'ready' : 'pending',
278
+ cursor: end,
279
+ done: end,
280
+ total: files.length,
281
+ sessions,
282
+ }
283
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-nerd-mode",
3
- "version": "0.4.6",
3
+ "version": "0.5.2",
4
4
  "description": "A study switch and study skills for DeepSeek Harness.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -64,7 +64,7 @@
64
64
  },
65
65
  "devDependencies": {
66
66
  "@deepseek-ai/schemastery": "^3.18.5",
67
- "eslint": "^10.13.0",
67
+ "eslint": "^9.39.5",
68
68
  "globals": "^17.13.0"
69
69
  }
70
70
  }
@@ -1,26 +1,26 @@
1
- ---
2
- name: convert-documents-to-markdown
3
- description: Convert Word (.doc, .docx), PowerPoint (.ppt, .pptx), Excel (.xls, .xlsx), OpenDocument (.odt, .ods, .odp), RTF, EPUB, CSV, and PDF files to GitHub-Flavored Markdown. Use when a task needs the contents of an office document, spreadsheet, presentation, ebook, or PDF you cannot read directly.
4
- license: MIT
5
- metadata:
6
- author: firecrawl
7
- ---
8
-
9
- # Convert documents to Markdown
10
-
11
- Run the anydoc CLI. It needs Node 20+ and no install:
12
-
13
- ```bash
14
- npx -y @firecrawl/anydoc <file> # Markdown to stdout
15
- npx -y @firecrawl/anydoc <file> -o out.md # write to a file
16
- npx -y @firecrawl/anydoc - --format csv < f # read stdin
17
- ```
18
-
19
- Rules:
20
-
21
- 1. Supported inputs: `.doc`, `.docx`, `.docm`, `.odt`, `.rtf`, `.epub`, `.pdf`, `.ppt`, `.pps`, `.pot`, `.pptx`, `.pptm`, `.ppsx`, `.ppsm`, `.odp`, `.xls`, `.xlsx`, `.xlsm`, `.xlsb`, `.ods`, `.csv`.
22
- 2. The format is detected from the file content. Pass `--format <name>` only when detection cannot work: CSV from stdin, or a missing or wrong extension.
23
- 3. Exit codes: 0 success, 1 the document could not be converted, 2 usage error, 3 pages of a PDF need OCR. Failures print one `anydoc: <message>` line to stderr. The CLI never prompts.
24
- 4. For a large document, write to a file with `-o` and read the parts you need instead of streaming everything into context.
25
- 5. Scanned and image-only pages need OCR, which anydoc does not do, so the document exits 3. Rerun with `--ocr hosted` to send it to [Firecrawl Parse](https://firecrawl.dev/parse). No signup needed. Pass `--api-key` or set `FIRECRAWL_API_KEY` for higher limits.
26
- 6. Inside a Node, Python, or Rust codebase, prefer the library over shelling out: `@firecrawl/anydoc` on npm, `firecrawl-anydoc` on PyPI, `anydoc` on crates.io. Each exposes the same `to_markdown` / `toMarkdown` API.
1
+ ---
2
+ name: convert-documents-to-markdown
3
+ description: Convert Word (.doc, .docx), PowerPoint (.ppt, .pptx), Excel (.xls, .xlsx), OpenDocument (.odt, .ods, .odp), RTF, EPUB, CSV, and PDF files to GitHub-Flavored Markdown. Use when a task needs the contents of an office document, spreadsheet, presentation, ebook, or PDF you cannot read directly.
4
+ license: MIT
5
+ metadata:
6
+ author: firecrawl
7
+ ---
8
+
9
+ # Convert documents to Markdown
10
+
11
+ Run the anydoc CLI. It needs Node 20+ and no install:
12
+
13
+ ```bash
14
+ npx -y @firecrawl/anydoc <file> # Markdown to stdout
15
+ npx -y @firecrawl/anydoc <file> -o out.md # write to a file
16
+ npx -y @firecrawl/anydoc - --format csv < f # read stdin
17
+ ```
18
+
19
+ Rules:
20
+
21
+ 1. Supported inputs: `.doc`, `.docx`, `.docm`, `.odt`, `.rtf`, `.epub`, `.pdf`, `.ppt`, `.pps`, `.pot`, `.pptx`, `.pptm`, `.ppsx`, `.ppsm`, `.odp`, `.xls`, `.xlsx`, `.xlsm`, `.xlsb`, `.ods`, `.csv`.
22
+ 2. The format is detected from the file content. Pass `--format <name>` only when detection cannot work: CSV from stdin, or a missing or wrong extension.
23
+ 3. Exit codes: 0 success, 1 the document could not be converted, 2 usage error, 3 pages of a PDF need OCR. Failures print one `anydoc: <message>` line to stderr. The CLI never prompts.
24
+ 4. For a large document, write to a file with `-o` and read the parts you need instead of streaming everything into context.
25
+ 5. Scanned and image-only pages need OCR, which anydoc does not do, so the document exits 3. Rerun with `--ocr hosted` to send it to [Firecrawl Parse](https://firecrawl.dev/parse). No signup needed. Pass `--api-key` or set `FIRECRAWL_API_KEY` for higher limits.
26
+ 6. Inside a Node, Python, or Rust codebase, prefer the library over shelling out: `@firecrawl/anydoc` on npm, `firecrawl-anydoc` on PyPI, `anydoc` on crates.io. Each exposes the same `to_markdown` / `toMarkdown` API.
@@ -9,6 +9,14 @@ A picture earns its place when the reader has to hold two things at once: the pa
9
9
 
10
10
  Do not draw a list. If the content is a sequence of steps with no branching, a numbered list reads faster. One figure carries one idea; split rather than crowd.
11
11
 
12
+ ## Before you draw
13
+
14
+ Name the one relation the figure must show, in one sentence. List the nodes and the arrows between them. If the list passes 12 nodes, split the figure.
15
+
16
+ Pick the shape from the relation, not from the topic. Decide the one accent: which node or arrow is the point. Every accent class you use gets a role you can name; most figures need one, a comparison may need two.
17
+
18
+ When the figure would need a second paragraph to explain, draw two figures.
19
+
12
20
  ## Pick the shape
13
21
 
14
22
  - **Flow**: what happens in order. Three to five boxes left to right, an arrow between each, a dashed return edge for the retry path.
@@ -31,17 +39,24 @@ Write the SVG file first, then talk about it.
31
39
 
32
40
  The Markdown path is resolved against the working directory and served from disk, so it must be a file that exists when the message renders. Write the file before the sentence that shows it. No query string, no fragment, no `data:` URL: the renderer rejects all three and shows the alt text instead. To revise a figure, overwrite the file; if the learner still sees the old picture, write a new name.
33
41
 
34
- ## File contract
42
+ ## The floor
35
43
 
36
- The file must carry:
44
+ Non-negotiable, every figure:
37
45
 
38
- - `xmlns="http://www.w3.org/2000/svg"`, `viewBox`, and `width`/`height` in pixels.
39
- - `role="img"` and an `aria-label` that states what the figure shows.
40
- - One `<style>` block with the palette classes below, so a dark background can override the light default.
41
- - `font-family="Segoe UI, Inter, system-ui, sans-serif"` on every text group.
42
- - Arrowheads as `<marker>` in `<defs>`, referenced with `marker-end`.
46
+ - The canvas is 720 wide. The root carries `xmlns`, `viewBox`, `width` and `height` in pixels, `role="img"`, and a non-empty `aria-label`.
47
+ - One `<style>` block holds the palette classes and the dark-mode override. No other style block.
48
+ - `font-family="Segoe UI, Inter, system-ui, sans-serif"` sits on every text group.
49
+ - Arrowheads are `<marker>` elements in `<defs>`, referenced with `marker-end`.
50
+ - Colors come only from the palette classes. No raw hex or named color outside `<style>`. No gradients, shadows, 3D, icons, emoji, or decorative marks.
51
+ - One accent class, one meaning, for the whole figure. At most four accent classes; most figures need one.
52
+ - Every label fits its box: 13px, at most two lines. At 13px a Latin line runs about 7.2px per character and a CJK line about 13px. A box needs `7.2 × Latin characters + 13 × CJK characters + 36`.
53
+ - Every arrow lands on a border with clearance. No wire crosses a label. No orphan node.
54
+ - No relation the source does not state. No invented number, name, or arrow.
55
+ - No `<script>`, `<foreignObject>`, external `href`, `@import`, animation, or LaTeX delimiter. Formulas are plain Unicode text inside `<text>`.
56
+ - The `aria-label`, the Markdown alt text, and the caption sentence agree.
57
+ - The figure says one thing.
43
58
 
44
- The file must not carry `<script>`, `<foreignObject>`, external `href` or `url(...)` fonts, LaTeX delimiters, or animation. A browser renders it as an isolated image: it inherits no page CSS and no page font, so every color and every glyph has to be stated in the file. Formulas become plain Unicode text inside `<text>`: `θ`, `∇f(x)`, `x²`, `≤`.
59
+ A browser renders the file as an isolated image. It inherits no page CSS and no page font, so every color and every glyph has to be stated in the file.
45
60
 
46
61
  ## Layout budget
47
62
 
@@ -51,7 +66,7 @@ Work in a 720 wide canvas; height is whatever the content needs plus 40.
51
66
  - Title at 15px bold, baseline `y=40`, left margin 28.
52
67
  - Node: 60 tall, `rx="8"`, 42 to 50 of gap between nodes. Three across fit as 180 wide boxes at x=28, 270, 512.
53
68
  - Node label 13px, sub-label 12 to 13px in the muted color, both anchored `middle`.
54
- - Measure before you place: at 13px Segoe UI a line runs about 7.2px per character. A box must be at least `7.2 × characters + 36` wide, and a node holds at most two lines.
69
+ - Measure before you place: at 13px Segoe UI a Latin line runs about 7.2px per character, a CJK line about 13px. A box must be at least `7.2 × Latin characters + 13 × CJK characters + 36` wide, and a node holds at most two lines.
55
70
  - Arrows land on a box edge with a few pixels of clearance, never inside the box.
56
71
  - Keep it under about 12 nodes. Past that the figure stops being readable at chat width.
57
72
 
@@ -67,6 +82,7 @@ Copy these classes verbatim and use them; they carry the dark-mode overrides.
67
82
  .sub { fill: #475569; }
68
83
  .note { fill: #64748b; }
69
84
  .wire { stroke: #94a3b8; }
85
+ .head { fill: #94a3b8; }
70
86
  .b1 { fill: #2563eb; fill-opacity: .08; stroke: #2563eb; }
71
87
  .b2 { fill: #b45309; fill-opacity: .08; stroke: #b45309; }
72
88
  .b3 { fill: #15803d; fill-opacity: .08; stroke: #15803d; }
@@ -87,7 +103,7 @@ Copy these classes verbatim and use them; they carry the dark-mode overrides.
87
103
  </style>
88
104
  ```
89
105
 
90
- Class `b1` to `b4` mark four distinct things. Give one class one meaning for the whole figure, and when the figure has a legend, build it from the same classes. `note` is for edge labels and footnotes; `wire` styles connectors.
106
+ Class `b1` to `b4` mark four distinct things. Give one class one meaning for the whole figure, and when the figure has a legend, build it from the same classes. `note` is for edge labels and footnotes; `wire` styles connectors; `head` fills the arrowhead.
91
107
 
92
108
  ## Starter
93
109
 
@@ -96,7 +112,7 @@ Class `b1` to `b4` mark four distinct things. Give one class one meaning for the
96
112
  <style>…the palette above…</style>
97
113
  <defs>
98
114
  <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
99
- <path class="head" d="M0 0 L10 5 L0 10 z" fill="#94a3b8"/>
115
+ <path class="head" d="M0 0 L10 5 L0 10 z"/>
100
116
  </marker>
101
117
  </defs>
102
118
  <rect class="panel" x="0.75" y="0.75" width="718.5" height="248.5" rx="12" stroke-width="1.5"/>
@@ -109,11 +125,11 @@ Class `b1` to `b4` mark four distinct things. Give one class one meaning for the
109
125
 
110
126
  <rect class="b2" x="270" y="72" width="180" height="60" rx="8" stroke-width="1.5"/>
111
127
  <text class="label" x="360" y="98" text-anchor="middle" font-weight="600">Name the gap</text>
112
- <text class="sub" x="360" y="117" text-anchor="middle">without fixing it yet</text>
128
+ <text class="sub" x="360" y="117" text-anchor="middle">without fixing yet</text>
113
129
 
114
130
  <rect class="b3" x="512" y="72" width="180" height="60" rx="8" stroke-width="1.5"/>
115
131
  <text class="label" x="602" y="98" text-anchor="middle" font-weight="600">Explain again</text>
116
- <text class="sub" x="602" y="117" text-anchor="middle">with the fix in place</text>
132
+ <text class="sub" x="602" y="117" text-anchor="middle">after the fix</text>
117
133
  </g>
118
134
 
119
135
  <g class="wire" stroke-width="1.5" fill="none">
@@ -125,10 +141,18 @@ Class `b1` to `b4` mark four distinct things. Give one class one meaning for the
125
141
  </svg>
126
142
  ```
127
143
 
128
- ## Check before you send
144
+ ## Verify
129
145
 
130
- - Every arrow touches a border at both ends, and no label touches a border.
131
- - The `aria-label`, the Markdown alt text, and the caption sentence agree.
132
- - Colors come only from the palette classes, so the figure survives a theme switch.
133
- - The file path in the image line matches the file you wrote, character for character.
134
- - The figure says one thing. If it needs a second paragraph of explanation, draw two figures.
146
+ Run the checker after writing the file:
147
+
148
+ ```text
149
+ node <skill-dir>/scripts/check-svg.mjs diagrams/<name>.svg
150
+ ```
151
+
152
+ `<skill-dir>` is the folder that holds this SKILL.md.
153
+
154
+ Fix every FAIL and run it once more. A WARN is a question: fix it, or say why it is intentional.
155
+
156
+ The checker measures text, not pixels. When a browser is available, open the SVG and look at it. Check it at 720 wide and at 360 wide: the whole figure scales by half, so a 13px label reads at 6.5px. If that is too small for the material, split the figure or draw a simpler one. Do not shrink the text below 13px at 720.
157
+
158
+ A clean lint is not a good figure. The last check is the reader's: can they name the one thing the figure shows without reading the caption?
@@ -0,0 +1,184 @@
1
+ // Lint a diagram SVG against the floor in SKILL.md. No dependencies.
2
+ // The checker sees text, not pixels: it estimates label width and catches
3
+ // contract breaks. A rendered look still decides the final figure.
4
+
5
+ import { readFileSync } from 'node:fs'
6
+ import { pathToFileURL } from 'node:url'
7
+
8
+ const CJK = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]/
9
+ const PALETTE = ['.panel', '.title', '.label', '.sub', '.note', '.wire', '.head', '.b1', '.b2', '.b3', '.b4']
10
+
11
+ function attr(attrs, name) {
12
+ const match = attrs.match(new RegExp(`\\s${name}="([^"]*)"`))
13
+ return match ? match[1] : null
14
+ }
15
+
16
+ function visibleText(raw) {
17
+ return raw.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
18
+ }
19
+
20
+ function textWidth(text, size = 13) {
21
+ const latin = size * 0.554
22
+ let width = 0
23
+ for (const char of text) width += CJK.test(char) ? size : latin
24
+ return width
25
+ }
26
+
27
+ /**
28
+ * Check one SVG source against the diagram floor.
29
+ *
30
+ * Args:
31
+ * source: the SVG file as text. It may be malformed; the checker reports that
32
+ * as a finding instead of throwing.
33
+ *
34
+ * Returns:
35
+ * { findings: Array<{ level: 'FAIL' | 'WARN', area: string, message: string }> }
36
+ *
37
+ * FAIL means the file breaks the contract. WARN means a style rule needs a
38
+ * human decision. The caller sets the exit code.
39
+ */
40
+ export function checkSvg(source) {
41
+ const findings = []
42
+ const fail = (area, message) => findings.push({ level: 'FAIL', area, message })
43
+ const warn = (area, message) => findings.push({ level: 'WARN', area, message })
44
+
45
+ const svg = source.match(/<svg\b([^>]*)>/)
46
+ if (!svg) {
47
+ fail('root', 'no <svg> element')
48
+ return { findings }
49
+ }
50
+
51
+ const root = svg[1]
52
+ if (!/xmlns="http:\/\/www\.w3\.org\/2000\/svg"/.test(root)) fail('root', 'missing xmlns')
53
+ const viewBox = attr(root, 'viewBox')
54
+ const box = viewBox ? viewBox.trim().split(/[\s,]+/).map(Number) : []
55
+ if (box.length !== 4 || box.some(Number.isNaN)) {
56
+ fail('root', 'viewBox must hold four numbers')
57
+ } else if (box[2] !== 720) {
58
+ warn('root', `canvas is ${box[2]} wide; the budget is 720`)
59
+ }
60
+ const width = attr(root, 'width')
61
+ const height = attr(root, 'height')
62
+ if (!width || !height) {
63
+ fail('root', 'missing width or height')
64
+ } else if (box.length === 4 && (Number(width) !== box[2] || Number(height) !== box[3])) {
65
+ warn('root', 'width and height do not match the viewBox')
66
+ }
67
+ if (attr(root, 'role') !== 'img') fail('root', 'missing role="img"')
68
+ if (!attr(root, 'aria-label')) fail('root', 'missing or empty aria-label')
69
+
70
+ const style = source.match(/<style[^>]*>([\s\S]*?)<\/style>/)?.[1] ?? ''
71
+ if (!style) fail('style', 'missing <style> block')
72
+ const styleCount = (source.match(/<style\b/g) ?? []).length
73
+ if (styleCount > 1) fail('style', `${styleCount} <style> blocks; the floor allows one`)
74
+ const missing = PALETTE.filter((name) => !style.includes(name))
75
+ if (missing.length) fail('style', `missing palette classes: ${missing.join(', ')}`)
76
+ if (!/@media\s*\(\s*prefers-color-scheme:\s*dark\s*\)/.test(style)) {
77
+ fail('style', 'missing dark-mode block')
78
+ }
79
+
80
+ for (const [label, pattern] of [
81
+ ['script', /<script\b/i],
82
+ ['foreignObject', /<foreignObject\b/i],
83
+ ['external href', /\bhref="/],
84
+ ['@import', /@import\b/],
85
+ ['animation', /<animate\b|<set\b/i],
86
+ ]) {
87
+ if (pattern.test(source)) fail('forbidden', `${label} is not allowed`)
88
+ }
89
+ if (/url\((?!\s*#)/.test(source)) fail('forbidden', 'external url(...) is not allowed')
90
+ if (/\\\(|\\\[|\$\$/.test(source)) fail('forbidden', 'LaTeX delimiter; use plain Unicode text')
91
+
92
+ const outside = source.replace(/<style[^>]*>[\s\S]*?<\/style>/g, '')
93
+ for (const match of outside.matchAll(/\b(?:fill|stroke)="([^"]+)"/g)) {
94
+ const value = match[1]
95
+ if (value === 'none' || value === 'currentColor') continue
96
+ if (/^#[0-9a-fA-F]{3,8}$/.test(value)) fail('color', `raw hex outside <style>: ${value}`)
97
+ else fail('color', `paint "${value}" bypasses the palette classes`)
98
+ }
99
+ for (const match of outside.matchAll(/\bstyle="([^"]*)"/g)) {
100
+ if (/#[0-9a-fA-F]{3,8}|(?:fill|stroke|color)\s*:/.test(match[1])) {
101
+ fail('color', 'inline style carries paint; use the palette classes')
102
+ }
103
+ }
104
+
105
+ const elements = []
106
+ for (const match of source.matchAll(/<rect\b([^>]*?)\/?>/g)) {
107
+ elements.push({ type: 'rect', index: match.index, attrs: match[1] })
108
+ }
109
+ for (const match of source.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text>/g)) {
110
+ elements.push({ type: 'text', index: match.index, attrs: match[1], content: visibleText(match[2]) })
111
+ }
112
+ elements.sort((a, b) => a.index - b.index)
113
+
114
+ const rects = elements.filter((element) => element.type === 'rect')
115
+ const nodes = rects.filter((element) => !/\bpanel\b/.test(attr(element.attrs, 'class') ?? ''))
116
+ if (nodes.length > 12) warn('nodes', `${nodes.length} nodes; the budget is 12`)
117
+
118
+ for (const [index, element] of elements.entries()) {
119
+ if (element.type !== 'text' || !element.content) continue
120
+ const cls = attr(element.attrs, 'class') ?? ''
121
+ const size = Number(attr(element.attrs, 'font-size')) || (cls.includes('title') ? 15 : cls.includes('note') ? 12 : 13)
122
+ const needed = textWidth(element.content, size)
123
+ const x = Number(attr(element.attrs, 'x'))
124
+ const y = Number(attr(element.attrs, 'y'))
125
+ let available = 664
126
+ if (attr(element.attrs, 'text-anchor') === 'middle' && Number.isFinite(x) && Number.isFinite(y)) {
127
+ for (let i = index - 1; i >= 0; i -= 1) {
128
+ const rect = elements[i]
129
+ if (rect.type !== 'rect') continue
130
+ const rx = Number(attr(rect.attrs, 'x'))
131
+ const ry = Number(attr(rect.attrs, 'y'))
132
+ const rw = Number(attr(rect.attrs, 'width'))
133
+ const rh = Number(attr(rect.attrs, 'height'))
134
+ if ([rx, ry, rw, rh].every(Number.isFinite) && x >= rx && x <= rx + rw && y >= ry && y <= ry + rh) {
135
+ available = rw - 36
136
+ break
137
+ }
138
+ }
139
+ }
140
+ if (needed > available) {
141
+ fail('text', `"${element.content}" needs ${Math.ceil(needed)}px, box has ${Math.floor(available)}px`)
142
+ }
143
+ }
144
+
145
+ const stack = []
146
+ for (const match of source.matchAll(/<(\/?)(g|text)\b([^>]*)>/g)) {
147
+ const closing = match[1] === '/'
148
+ const tag = match[2]
149
+ const attrs = match[3]
150
+ if (tag === 'g') {
151
+ if (closing) stack.pop()
152
+ else stack.push(attr(attrs, 'font-family') ?? stack.at(-1) ?? null)
153
+ continue
154
+ }
155
+ if (!closing && !attr(attrs, 'font-family') && !stack.at(-1)) {
156
+ warn('font', 'a <text> element has no font-family')
157
+ }
158
+ }
159
+
160
+ const markers = [...source.matchAll(/marker-(?:start|mid|end)="url\(#([^)]+)\)"/g)].map((match) => match[1])
161
+ for (const id of new Set(markers)) {
162
+ if (!new RegExp(`<marker\\b[^>]*\\bid="${id}"`).test(source)) {
163
+ fail('marker', `missing <marker id="${id}">`)
164
+ }
165
+ }
166
+
167
+ return { findings }
168
+ }
169
+
170
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
171
+ const file = process.argv[2]
172
+ if (!file) {
173
+ console.error('usage: node check-svg.mjs <file.svg>')
174
+ process.exit(2)
175
+ }
176
+ const { findings } = checkSvg(readFileSync(file, 'utf8'))
177
+ for (const finding of findings) {
178
+ console.log(`${finding.level} ${finding.area}: ${finding.message}`)
179
+ }
180
+ const fails = findings.filter((finding) => finding.level === 'FAIL').length
181
+ const warns = findings.length - fails
182
+ console.log(findings.length ? `${findings.length} findings (${fails} FAIL, ${warns} WARN)` : 'OK no findings')
183
+ process.exitCode = fails ? 1 : 0
184
+ }