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.
- package/README.md +11 -6
- package/README.zh.md +11 -6
- package/assets/calibration.svg +69 -69
- package/assets/nerd.svg +22 -22
- package/assets/section.md +15 -15
- package/client.js +2170 -1806
- package/cordis.patch.yml +24 -24
- package/lib/index.js +838 -223
- package/lib/study-sessions.js +283 -0
- package/package.json +2 -2
- package/skills/convert-documents-to-markdown/SKILL.md +26 -26
- package/skills/diagram/SKILL.md +43 -19
- package/skills/diagram/scripts/check-svg.mjs +184 -0
- package/skills/humanizer/.claude-plugin/marketplace.json +18 -18
- package/skills/humanizer/.claude-plugin/plugin.json +15 -15
- package/skills/humanizer/.cursor-plugin/plugin.json +12 -12
- package/skills/humanizer/.github/ISSUE_TEMPLATE/config.yml +8 -8
- package/skills/humanizer/.github/ISSUE_TEMPLATE/new-pattern.yml +24 -24
- package/skills/humanizer/.github/ISSUE_TEMPLATE/rewrite-problem.yml +44 -44
- package/skills/humanizer/.github/workflows/validate.yml +29 -29
- package/skills/humanizer/AGENTS.md +54 -54
- package/skills/humanizer/CHANGELOG.md +116 -116
- package/skills/humanizer/LICENSE +21 -21
- package/skills/humanizer/README.md +206 -206
- package/skills/humanizer/SKILL.md +397 -397
- package/skills/humanizer/agents/openai.yaml +4 -4
- package/skills/humanizer/scripts/validate-package.py +146 -146
- package/skills/study/SKILL.md +81 -78
- package/skills/study-plus/SKILL.md +246 -246
- package/skills/study-plus/scripts/book-client.js +284 -284
- package/skills/study-plus/scripts/book-data.mjs +289 -289
- package/skills/study-plus/scripts/build-book.mjs +155 -155
- package/skills/writing-beats/SKILL.md +66 -66
- 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.
|
|
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": "^
|
|
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.
|
package/skills/diagram/SKILL.md
CHANGED
|
@@ -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
|
-
##
|
|
42
|
+
## The floor
|
|
35
43
|
|
|
36
|
-
|
|
44
|
+
Non-negotiable, every figure:
|
|
37
45
|
|
|
38
|
-
- `xmlns
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
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
|
-
|
|
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"
|
|
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
|
|
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">
|
|
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
|
-
##
|
|
144
|
+
## Verify
|
|
129
145
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
-
|
|
134
|
-
|
|
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
|
+
}
|