no-fi-skill 0.2.1 → 0.3.1

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 CHANGED
@@ -39,7 +39,11 @@ The outer frame is double-lined, every box inside it is single-lined, and the pr
39
39
 
40
40
  ## Install
41
41
 
42
- Pick one. Each puts the `no-fi/` folder where your agent loads skills from.
42
+ Pick one. Each puts the `no-fi` skill folder where your agent loads skills from.
43
+
44
+ **With a prompt**
45
+
46
+ Copy the install prompt from [no-fi.dev](https://no-fi.dev/#get-started) and paste it into Claude Code or any agent. It runs the npx command below, or downloads the files one by one if it can't run npx.
43
47
 
44
48
  **With npx**
45
49
 
@@ -59,18 +63,18 @@ npx skills add p-a-u-l-i-e/no-fi --skill no-fi
59
63
 
60
64
  ```sh
61
65
  git clone https://github.com/p-a-u-l-i-e/no-fi.git
62
- cp -R no-fi/no-fi ~/.claude/skills/
66
+ cp -R no-fi/plugin/skills/no-fi ~/.claude/skills/
63
67
  ```
64
68
 
65
69
  **On claude.ai**
66
70
 
67
- Download the repository as a ZIP and upload it under Settings, Capabilities, Skills.
71
+ Download [no-fi.zip](https://no-fi.dev/no-fi.zip) and upload it under Settings, Capabilities, Skills. It holds only the skill folder. GitHub's Download ZIP holds the whole repository, which claude.ai won't take as a skill.
68
72
 
69
- The skill is plain text and a few small scripts, so you can read every file before you install it.
73
+ The skill is plain text plus one small checker script, in Node and Python, so you can read every file before you install it.
70
74
 
71
75
  ## Use it
72
76
 
73
- Ask for a screen the way you would ask a designer for a sketch:
77
+ Ask for a screen the way you would ask a designer for a sketch. Saying wireframe or sketch is enough to load the skill, or type `/no-fi` in Claude Code.
74
78
 
75
79
  > Wireframe a send-money form with three steps.
76
80
  >
@@ -82,7 +86,7 @@ Claude replies with a fenced code block, runs the checker on it, and adds a note
82
86
 
83
87
  ## The rules
84
88
 
85
- Seven rules keep every wireframe readable. The full text is in [`no-fi/SKILL.md`](no-fi/SKILL.md).
89
+ Seven rules keep every wireframe readable. The full text is in [`SKILL.md`](plugin/skills/no-fi/SKILL.md).
86
90
 
87
91
  1. **Plain characters, plus box-drawing.** Printable ASCII, box-drawing borders, and block characters for bars and charts. No emoji, no tabs.
88
92
  2. **Double outside, single inside.** The outermost container uses double lines. Every box inside it uses single lines.
@@ -102,56 +106,65 @@ Seven rules keep every wireframe readable. The full text is in [`no-fi/SKILL.md`
102
106
  - Open states are drawn open: a **select** with its menu below, an **accordion** with its content under it.
103
107
  - **Modals**, **toasts**, and **tooltips** are cards drawn inside the screen they sit on.
104
108
 
105
- The complete table, with a drawing and a note for each element, is in [`no-fi/SKILL.md`](no-fi/SKILL.md). The same data is in [`no-fi/vocabulary.json`](no-fi/vocabulary.json).
109
+ The complete table, with a drawing and a note for each element, is in [`SKILL.md`](plugin/skills/no-fi/SKILL.md). The same data is in [`vocabulary.json`](plugin/skills/no-fi/vocabulary.json).
106
110
 
107
111
  ## Examples
108
112
 
109
- Seven finished screens in [`no-fi/examples/`](no-fi/examples), built to use the whole vocabulary:
113
+ Seven finished screens in [`examples/`](plugin/skills/no-fi/examples), built to use the whole vocabulary:
110
114
 
111
115
  | Screen | File |
112
116
  | --- | --- |
113
- | Fintech dashboard | [`fintech-dashboard.txt`](no-fi/examples/fintech-dashboard.txt) |
114
- | Send money form | [`send-money-form.txt`](no-fi/examples/send-money-form.txt) |
115
- | Transactions table | [`transactions-table.txt`](no-fi/examples/transactions-table.txt) |
116
- | Spending report | [`spending-report.txt`](no-fi/examples/spending-report.txt) |
117
- | Card details | [`card-details.txt`](no-fi/examples/card-details.txt) |
118
- | Notification settings | [`settings-notifications.txt`](no-fi/examples/settings-notifications.txt) |
119
- | Activity patterns | [`activity-patterns.txt`](no-fi/examples/activity-patterns.txt) |
117
+ | Fintech dashboard | [`fintech-dashboard.txt`](plugin/skills/no-fi/examples/fintech-dashboard.txt) |
118
+ | Send money form | [`send-money-form.txt`](plugin/skills/no-fi/examples/send-money-form.txt) |
119
+ | Transactions table | [`transactions-table.txt`](plugin/skills/no-fi/examples/transactions-table.txt) |
120
+ | Spending report | [`spending-report.txt`](plugin/skills/no-fi/examples/spending-report.txt) |
121
+ | Card details | [`card-details.txt`](plugin/skills/no-fi/examples/card-details.txt) |
122
+ | Notification settings | [`settings-notifications.txt`](plugin/skills/no-fi/examples/settings-notifications.txt) |
123
+ | Activity patterns | [`activity-patterns.txt`](plugin/skills/no-fi/examples/activity-patterns.txt) |
120
124
 
121
125
  ## Check your work
122
126
 
123
- Two scripts ship with the skill. Each comes in Node and Python, with the same output.
124
-
125
- **Check a wireframe**
127
+ Your agent checks every wireframe before it shows you one. To check one yourself, from anywhere:
126
128
 
127
129
  ```sh
128
- node no-fi/scripts/check_wireframe.mjs wireframe.txt
129
- python3 no-fi/scripts/check_wireframe.py wireframe.txt
130
130
  npx no-fi-skill check wireframe.txt
131
131
  ```
132
132
 
133
- It fails on unsupported characters, tabs, ASCII box borders, boxes that don't close or line up, mixed double and single lines in one box, a double-line box nested inside another box, table dividers that don't land in the same column, progress bars whose fill disagrees with their percentage, bar chart rows whose length disagrees with their value, heatmaps without a legend, and column charts that don't say what a row is worth.
133
+ The checker also ships with the skill, in Node and Python with the same output. From an install, or from this repository:
134
+
135
+ ```sh
136
+ node ~/.claude/skills/no-fi/scripts/check_wireframe.mjs wireframe.txt
137
+ python3 plugin/skills/no-fi/scripts/check_wireframe.py wireframe.txt
138
+ ```
139
+
140
+ It fails on unsupported characters, tabs, ASCII box borders, boxes that don't close or line up, mixed double and single lines in one box, a double-line box nested inside another box, table dividers that don't land in the same column, progress bars whose fill disagrees with their percentage, bar chart rows whose length disagrees with their value, heatmaps and stacked columns without a legend, and column charts that don't say what a row is worth.
141
+
142
+ ## Maintaining the vocabulary
134
143
 
135
- **Check vocabulary coverage**
144
+ `plugin/skills/no-fi/vocabulary.json` is the source of truth. These scripts live in `scripts/`, outside the skill, so they don't ship to users. After editing the vocabulary, run:
136
145
 
137
146
  ```sh
138
- node no-fi/scripts/check_coverage.mjs
139
- python3 no-fi/scripts/check_coverage.py
147
+ python3 scripts/sync_vocabulary.py # rewrite the tables in SKILL.md
148
+ node scripts/check_coverage.mjs # every element appears in an example
149
+ node plugin/skills/no-fi/scripts/check_wireframe.mjs plugin/skills/no-fi/examples/*.txt
140
150
  ```
141
151
 
142
- It confirms every vocabulary element appears in at least one example screen.
152
+ `python3 scripts/check_coverage.py` gives the same coverage output without Node.
143
153
 
144
154
  ## Repository layout
145
155
 
146
156
  ```
147
- no-fi/ the skill
148
- SKILL.md rules and vocabulary tables
149
- vocabulary.json the element vocabulary as data
150
- examples/ finished example screens
151
- scripts/ wireframe and coverage checks
152
- bin/no-fi-skill.mjs the npx no-fi-skill command
153
- assets/ README images
154
- site/ the no-fi.dev site
157
+ plugin/ the Claude plugin
158
+ .claude-plugin/ plugin.json
159
+ skills/no-fi/ the skill
160
+ SKILL.md rules and vocabulary tables
161
+ vocabulary.json the element vocabulary as data
162
+ examples/ finished example screens
163
+ scripts/ the wireframe checker
164
+ scripts/ maintainer scripts: vocabulary sync and coverage
165
+ bin/no-fi-skill.mjs the npx no-fi-skill command
166
+ assets/ README images
167
+ site/ the no-fi.dev site
155
168
  ```
156
169
 
157
170
  ## License
@@ -9,7 +9,7 @@ import path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
 
11
11
  const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
12
- const skill = path.join(root, "no-fi");
12
+ const skill = path.join(root, "plugin", "skills", "no-fi");
13
13
  const { version } = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
14
14
 
15
15
  const usage = `no-fi ${version}: wireframes without the distraction.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "no-fi-skill",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "An agent skill that draws UI wireframes and charts as plain text, so teams discuss layout and flow instead of color and type.",
5
5
  "keywords": [
6
6
  "wireframe",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "files": [
27
27
  "bin",
28
- "no-fi",
28
+ "plugin/skills/no-fi",
29
29
  "!**/__pycache__",
30
30
  "!**/.DS_Store"
31
31
  ],
@@ -9,7 +9,7 @@ Draw screens as monospaced text. The goal is to make the conversation about stru
9
9
 
10
10
  ## Rules
11
11
 
12
- 1. **Plain characters, plus box-drawing.** Printable ASCII, the box-drawing characters used for borders (`╔ ═ ╗ ║ ╚ ╝` and `┌ ─ ┐ │ └ ┘ ├ ┤ ┬ ┴ ┼`), and block characters for progress bars and charts (`▮ ▯ █ ▒ ░ ▓`, the horizontal eighths `▏▎▍▌▋▊▉`, and the vertical steps `▁▂▃▄▅▆▇`). No emoji, no tabs.
12
+ 1. **Plain characters, plus box-drawing.** Printable ASCII, the box-drawing characters used for borders (`╔ ═ ╗ ║ ╚ ╝` and `┌ ─ ┐ │ └ ┘ ├ ┤ ┬ ┴ ┼`), and block characters for progress bars and charts (`▮ ▯ █ ▒ ░ ▓`, the horizontal eighths `▏▎▍▌▋▊▉`, and the vertical steps `▁▂▃▄▅▆▇`), and the rounded corners `╭ ╮ ╰ ╯` where a line chart turns. No emoji, no tabs.
13
13
  2. **Double outside, single inside.** The outermost container is drawn with double lines `╔═╗`. Every box inside it uses single lines `┌─┐`.
14
14
  3. **Fixed width.** Keep every line at 80 characters or fewer. Every box is exactly as wide on every row.
15
15
  4. **Code block.** Always wrap the output in a fenced block with no language tag, so monospacing and alignment hold.
@@ -18,12 +18,12 @@ Draw screens as monospaced text. The goal is to make the conversation about stru
18
18
  7. **Validate.** Run the checker before showing the result:
19
19
 
20
20
  ```sh
21
- node no-fi/scripts/check_wireframe.mjs path/to/wireframe.txt
21
+ node ${CLAUDE_SKILL_DIR}/scripts/check_wireframe.mjs path/to/wireframe.txt
22
22
  ```
23
23
 
24
- Without Node, `python3 no-fi/scripts/check_wireframe.py` gives the same output.
24
+ `${CLAUDE_SKILL_DIR}` is the folder this file is in. Without Node, `python3 ${CLAUDE_SKILL_DIR}/scripts/check_wireframe.py` gives the same output.
25
25
 
26
- It fails on unsupported characters, tabs, ASCII box borders, boxes that don't close or line up, mixed double and single lines in one box, a double-line box nested inside another box, table dividers that don't land in the same column top and bottom, progress bars whose fill disagrees with their percentage, bar chart rows whose length disagrees with their value and stated unit, heatmaps without a legend, and column charts that don't say what a row is worth. Fix every failure before presenting the wireframe.
26
+ It fails on unsupported characters, tabs, ASCII box borders, boxes that don't close or line up, mixed double and single lines in one box, a double-line box nested inside another box, table dividers that don't land in the same column top and bottom, progress bars whose fill disagrees with their percentage, bar chart rows whose length disagrees with their value and stated unit, heatmaps and stacked columns without a legend, and column charts that don't say what a row is worth. Fix every failure before presenting the wireframe.
27
27
 
28
28
  ## Draw it like the UI it stands for
29
29
 
@@ -113,22 +113,23 @@ Each element should look like the component it represents, not a character that
113
113
  | --- | --- | --- |
114
114
  | Bar chart | `Rent Co ████████████ $1,200`: one `█` per fixed unit, and `▏▎▍▌▋▊▉` add eighths. ASCII fallback: `#` | State the unit of one █. The partial block is the remainder. |
115
115
  | Column chart | `███` columns, one row per fixed unit, a `▁▂▃▄▅▆▇` top for the remainder, `───` baseline, labels below. ASCII fallback: `###` | State what one row is worth. The partial top is the remainder. |
116
- | Line chart | `*` points against a `$400 │` axis and a `└────` baseline | Label the top, middle, and bottom of the axis. |
116
+ | Line chart | a `─` line that turns with `╭ ╮ ╰ ╯` and climbs with `│`, against a `$400 ┤` axis and a `└────` baseline. ASCII fallback: `o` points | Label the top, middle, and bottom of the axis. Each x label sits under the start of its step. |
117
117
  | Sparkline | `▁▂▃▄▅▆▇█` from low to high. ASCII fallback: `~` then `_ . - = + * #` | A trend with no axis. |
118
- | Stacked bar | `[FFFHHHHTO]` with a legend mapping each letter | One letter per share. Legend below. |
118
+ | Stacked column | `███` columns of `█ ▓ ▒ ░` segments, with a `█ Food ▓ Home` legend. ASCII fallback: `# = : .` | Stack in legend order, darkest at the bottom. |
119
119
  | Heatmap | blank, `░ ▒ ▓ █` from light to dark, two characters per cell, with a `Low ░ ▒ ▓ █ High` legend. ASCII fallback: `. : - = + * # % @` | Always draw the legend. |
120
120
 
121
121
  <!-- vocabulary:end -->
122
122
 
123
123
  ## Charts
124
124
 
125
- - Draw charts with block characters. Each one has an ASCII fallback for places that can't show blocks: `#` for bars, `###` for columns, `~_.-=+*#` for sparklines, and `. : - = + * # % @` for heatmaps. Use one style within a chart.
125
+ - Draw charts with block characters. Each one has an ASCII fallback for places that can't show blocks: `o` points for line charts, `#` for bars, `###` for columns, `~_.-=+*#` for sparklines, `# = : .` for stacked columns, and `. : - = + * # % @` for heatmaps. Use one style within a chart.
126
126
  - State what one unit is: `(one █ is $100)`, `(one row is $25)`. The checker compares each bar's length to its value.
127
127
  - Use the partial block for the remainder, so 4.1 units is `████▏`, not `████`. A value under one unit is just the partial block.
128
128
  - Label the top, middle, and bottom of an axis, and the first and last point on the x axis.
129
129
  - Keep a chart under about eight rows or columns. Summarise the rest in words.
130
130
  - A bar's fill must agree with its label. The checker enforces this for progress bars and bar charts.
131
- - Always draw a legend for stacked bars and heatmaps. The checker requires `Low ░ ▒ ▓ █ High` for a shaded heatmap.
131
+ - Always draw a legend for stacked columns and heatmaps. The checker requires `Low ░ ▒ ▓ █ High` for a shaded heatmap, and a legend naming every shade, like `█ Food ▓ Home`, for stacked columns.
132
+ - Stack segments in legend order, darkest at the bottom, the same order in every column. Use at most four, and group the rest as Other. Round each segment to whole rows and put exact figures in the legend.
132
133
 
133
134
  ## Layout
134
135
 
@@ -165,14 +166,4 @@ Each element should look like the component it represents, not a character that
165
166
  ╚═════════════════════════════════════╝
166
167
  ```
167
168
 
168
- Seven full screens that use every element are in `examples/`. `scripts/check_coverage.mjs` confirms that none of the vocabulary goes unused.
169
-
170
- ## Maintaining the vocabulary
171
-
172
- `vocabulary.json` is the source of truth. After editing it, run:
173
-
174
- ```sh
175
- python3 no-fi/scripts/sync_vocabulary.py # rewrite the tables above
176
- node no-fi/scripts/check_coverage.mjs # every element appears in an example
177
- node no-fi/scripts/check_wireframe.mjs no-fi/examples/*.txt
178
- ```
169
+ Seven full screens that use every element are in `examples/`.
@@ -6,13 +6,13 @@
6
6
  ║ $2,315.40 v -3.4% vs April ║
7
7
  ║ ║
8
8
  ║ Weekly spend ║
9
- ║ $450 │ ║
10
- ║ │ * * ║
11
- ║ │ * ║
12
- ║ $225 │ * * * ║
13
- ║ │ * * * ║
14
- ║ │ * ║
15
- ║ $0 │ ║
9
+ ║ $450 ┤ ║
10
+ ║ │ ╭───╮ ╭───╮ ║
11
+ ║ │ │ │ ╭───╮ │ ╰─── ║
12
+ ║ $225 ┤ ╭───╮ │ ╰───────╯ ╰───╯ ║
13
+ ║ │────╯ ╰───╯ ║
14
+ ║ │ ║
15
+ ║ $0 ┤ ║
16
16
  ║ └──────────────────────────────────────── ║
17
17
  ║ Mar 1 Mar 10 Mar 19 Mar 28 ║
18
18
  ║ ║
@@ -22,9 +22,17 @@
22
22
  ║ Cafe Nero █▊ $180 ║
23
23
  ║ Electric Co ▊ $74 ║
24
24
  ║ ║
25
- ║ By category ║
26
- ║ [FFFFFFHHHHHHHHTTOOOO] ║
27
- ║ F Food 30% H Housing 40% T Travel 10% O Other 20% ║
25
+ ║ By category (one row is $100) ║
26
+ ║ ░░░ ║
27
+ ║ ░░░ ░░░ ║
28
+ ║ ▓▓▓ ▒▒▒ ░░░ ░░░ ║
29
+ ║ ▓▓▓ ▓▓▓ ▒▒▒ ▓▓▓ ║
30
+ ║ ▓▓▓ ▓▓▓ ▓▓▓ ▓▓▓ ║
31
+ ║ ███ ███ ▓▓▓ ███ ║
32
+ ║ ███ ███ ███ ███ ║
33
+ ║ ─── ─── ─── ─── ║
34
+ ║ Wk1 Wk2 Wk3 Wk4 ║
35
+ ║ █ Food 30% ▓ Housing 40% ▒ Travel 10% ░ Other 20% ║
28
36
  ║ ║
29
37
  ║ Budget used ║
30
38
  ║ [ ▮▮▮▮▮▮▮▮▮▮▮▮▯▯▯▯▯▯▯▯ ] 62% ║
@@ -2,7 +2,8 @@
2
2
  // Validate a no-fi wireframe.
3
3
  //
4
4
  // Characters: printable ASCII, box-drawing characters for borders, and block
5
- // characters for progress bars and charts (see BLOCKS below). Nothing else.
5
+ // characters for progress bars and charts (see BLOCKS below), and rounded corners
6
+ // where a line chart turns. Nothing else.
6
7
  //
7
8
  // Boxes: the outermost container uses double lines (╔═╗ ║ ╚═╝). Every box nested
8
9
  // inside it uses single lines (┌─┐ │ └─┘). Each box must close, its sides must line
@@ -12,8 +13,10 @@
12
13
  // - A progress bar's fill must match its percentage.
13
14
  // - A bar chart row's length must match its value, using the stated unit
14
15
  // ("one █ is $100"). Without a stated unit the length is not checked.
15
- // - A heatmap drawn with shade blocks needs a "Low ... High" legend.
16
- // - A column chart needs a stated row value ("one row is $25").
16
+ // - A chart drawn with shade blocks needs a legend: "Low ... High" for a
17
+ // heatmap, or one naming every shade ("█ Food ▓ Home") for stacked columns.
18
+ // - A column chart needs a stated row value ("one row is $25"), unless its
19
+ // legend names its shades (stacked columns).
17
20
  //
18
21
  // This is a port of check_wireframe.py. Keep the two in step.
19
22
  //
@@ -33,9 +36,12 @@ const BOX_CHARS = new Set(Object.values(STYLES).flatMap((style) => Object.values
33
36
  // Block characters, allowed in progress bars and charts.
34
37
  const EIGHTHS = "▏▎▍▌▋▊▉"; // horizontal partials, 1/8 to 7/8 of a cell, left to right
35
38
  const VERTICAL = "▁▂▃▄▅▆▇"; // vertical partials, 1/8 to 7/8 of a cell, bottom to top
36
- const SHADES = "░▒▓"; // heatmap intensity, with █ as the darkest
39
+ const SHADES = "░▒▓"; // heatmap intensity and stacked segments, with █ as the darkest
37
40
  const BLOCKS = new Set([...(EIGHTHS + VERTICAL + SHADES + "█▮▯")]);
38
41
 
42
+ // Rounded corners, allowed where a line chart turns.
43
+ const CURVES = new Set([..."╭╮╰╯"]);
44
+
39
45
  // Progress bars: (filled, empty) pairs. A bar uses one pair, filled first.
40
46
  const UNICODE_BARS = [["▮", "▯"], ["█", "▒"]];
41
47
  const BAR_CHARS = UNICODE_BARS.flat().join("");
@@ -50,6 +56,7 @@ const BLOCK_ROW = new RegExp(`(?<=\\s)(█*[${EIGHTHS}]?)\\s+${VALUE}(?=\\s|[│
50
56
  const HASH_ROW = new RegExp(`(?<=\\s)(#+)\\s+${VALUE}(?=\\s|[│║]|$)`, "g");
51
57
  const BLOCK_UNIT = /one █ is \$?([\d,]+(?:\.\d+)?)/;
52
58
  const HASH_UNIT = /one # is \$?([\d,]+(?:\.\d+)?)/;
59
+ const SHADE_KEY = /([█░▒▓]) [A-Z]/g;
53
60
 
54
61
  const number = (text) => Number(text.replace(/,/g, ""));
55
62
 
@@ -80,7 +87,7 @@ function checkCharacters(lines) {
80
87
  [...line].forEach((ch, index) => {
81
88
  const col = index + 1;
82
89
  if (ch === "\t") errors.push(`line ${lineNo} col ${col}: tab character, use spaces`);
83
- else if (BOX_CHARS.has(ch) || BLOCKS.has(ch)) return;
90
+ else if (BOX_CHARS.has(ch) || BLOCKS.has(ch) || CURVES.has(ch)) return;
84
91
  else if (!(ch >= " " && ch <= "~")) {
85
92
  errors.push(`line ${lineNo} col ${col}: unsupported character '${ch}'`);
86
93
  }
@@ -135,7 +142,7 @@ function checkBars(lines) {
135
142
  return errors;
136
143
  }
137
144
 
138
- // Bar charts, heatmaps, and column charts.
145
+ // Bar charts, heatmaps, column charts, and stacked columns.
139
146
  function checkCharts(lines) {
140
147
  const errors = [];
141
148
  const text = lines.join("\n");
@@ -180,13 +187,25 @@ function checkCharts(lines) {
180
187
  }
181
188
  });
182
189
 
183
- // A heatmap drawn with shades needs its legend.
184
- if ((text.includes("░") || text.includes("▓")) && !/Low [░▒▓█ ]+ High/.test(text)) {
185
- errors.push("heatmap: draw a legend like 'Low ░ ▒ ▓ █ High'");
190
+ // A chart drawn with shades needs its legend. Progress bars use ▒ too, so
191
+ // leave them out.
192
+ const charted = text.replace(UNICODE_BAR, "");
193
+ if ((charted.includes("░") || charted.includes("▓")) && !/Low [░▒▓█ ]+ High/.test(text)) {
194
+ const named = new Set([...text.matchAll(SHADE_KEY)].map((match) => match[1]));
195
+ const missing = [...SHADES].filter((c) => charted.includes(c) && !named.has(c));
196
+ if (named.size === 0) {
197
+ errors.push(
198
+ "shaded chart: draw a legend, 'Low ░ ▒ ▓ █ High' for a heatmap " +
199
+ "or '█ Food ▓ Home' for stacked columns",
200
+ );
201
+ } else if (missing.length) {
202
+ errors.push(`stacked columns: the legend is missing ${missing.join(" ")}`);
203
+ }
186
204
  }
187
205
 
188
206
  // A column chart needs to say what a row is worth.
189
- if (/─{3} ─{3}/.test(text) && !text.includes("one row is")) {
207
+ // Stacked columns name their shades instead.
208
+ if (/─{3} ─{3}/.test(text) && !text.includes("one row is") && !text.match(SHADE_KEY)) {
190
209
  errors.push("column chart: state what one row is worth, e.g. '(one row is $25)'");
191
210
  }
192
211
  return errors;
@@ -2,7 +2,8 @@
2
2
  """Validate a no-fi wireframe.
3
3
 
4
4
  Characters: printable ASCII, box-drawing characters for borders, and block
5
- characters for progress bars and charts (see BLOCKS below). Nothing else.
5
+ characters for progress bars and charts (see BLOCKS below), and rounded corners
6
+ where a line chart turns. Nothing else.
6
7
 
7
8
  Boxes: the outermost container uses double lines (╔═╗ ║ ╚═╝). Every box nested
8
9
  inside it uses single lines (┌─┐ │ └─┘). Each box must close, its sides must line
@@ -12,8 +13,10 @@ Bars and charts:
12
13
  - A progress bar's fill must match its percentage.
13
14
  - A bar chart row's length must match its value, using the stated unit
14
15
  ("one █ is $100"). Without a stated unit the length is not checked.
15
- - A heatmap drawn with shade blocks needs a "Low ... High" legend.
16
- - A column chart needs a stated row value ("one row is $25").
16
+ - A chart drawn with shade blocks needs a legend: "Low ... High" for a
17
+ heatmap, or one naming every shade ("█ Food ▓ Home") for stacked columns.
18
+ - A column chart needs a stated row value ("one row is $25"), unless its
19
+ legend names its shades (stacked columns).
17
20
 
18
21
  Usage: check_wireframe.py FILE [FILE ...]
19
22
  Exit status is 0 when every file passes, 1 otherwise.
@@ -38,9 +41,12 @@ BOX_CHARS = {c for style in STYLES.values() for c in style.values()}
38
41
  # Block characters, allowed in progress bars and charts.
39
42
  EIGHTHS = "▏▎▍▌▋▊▉" # horizontal partials, 1/8 to 7/8 of a cell, left to right
40
43
  VERTICAL = "▁▂▃▄▅▆▇" # vertical partials, 1/8 to 7/8 of a cell, bottom to top
41
- SHADES = "░▒▓" # heatmap intensity, with █ as the darkest
44
+ SHADES = "░▒▓" # heatmap intensity and stacked segments, with █ as the darkest
42
45
  BLOCKS = set(EIGHTHS + VERTICAL + SHADES + "█▮▯")
43
46
 
47
+ # Rounded corners, allowed where a line chart turns.
48
+ CURVES = set("╭╮╰╯")
49
+
44
50
  # Progress bars: (filled, empty) pairs. A bar uses one pair, filled first.
45
51
  UNICODE_BARS = (("▮", "▯"), ("█", "▒"))
46
52
  BAR_CHARS = "".join(filled + empty for filled, empty in UNICODE_BARS)
@@ -55,6 +61,7 @@ BLOCK_ROW = re.compile(r"(?<=\s)(█*[" + EIGHTHS + r"]?)\s+" + VALUE + r"(?=\s|
55
61
  HASH_ROW = re.compile(r"(?<=\s)(#+)\s+" + VALUE + r"(?=\s|[│║]|$)")
56
62
  BLOCK_UNIT = re.compile(r"one █ is \$?([\d,]+(?:\.\d+)?)")
57
63
  HASH_UNIT = re.compile(r"one # is \$?([\d,]+(?:\.\d+)?)")
64
+ SHADE_KEY = re.compile(r"([█░▒▓]) [A-Z]")
58
65
 
59
66
 
60
67
  def number(text):
@@ -74,7 +81,7 @@ def check_characters(lines):
74
81
  col = index + 1
75
82
  if ch == "\t":
76
83
  errors.append(f"line {number_} col {col}: tab character, use spaces")
77
- elif ch in BOX_CHARS or ch in BLOCKS:
84
+ elif ch in BOX_CHARS or ch in BLOCKS or ch in CURVES:
78
85
  continue
79
86
  elif not (" " <= ch <= "~"):
80
87
  errors.append(f"line {number_} col {col}: unsupported character {ch!r}")
@@ -123,7 +130,7 @@ def check_bars(lines):
123
130
 
124
131
 
125
132
  def check_charts(lines):
126
- """Bar charts, heatmaps, and column charts."""
133
+ """Bar charts, heatmaps, column charts, and stacked columns."""
127
134
  errors = []
128
135
  text = "\n".join(lines)
129
136
 
@@ -158,12 +165,23 @@ def check_charts(lines):
158
165
  f"(one # is {hash_unit.group(1)})"
159
166
  )
160
167
 
161
- # A heatmap drawn with shades needs its legend.
162
- if ("░" in text or "▓" in text) and not re.search(r"Low [░▒▓█ ]+ High", text):
163
- errors.append("heatmap: draw a legend like 'Low ░ ▒ ▓ █ High'")
168
+ # A chart drawn with shades needs its legend. Progress bars use ▒ too, so
169
+ # leave them out.
170
+ charted = UNICODE_BAR.sub("", text)
171
+ if ("░" in charted or "▓" in charted) and not re.search(r"Low [░▒▓█ ]+ High", text):
172
+ named = set(SHADE_KEY.findall(text))
173
+ missing = [c for c in SHADES if c in charted and c not in named]
174
+ if not named:
175
+ errors.append(
176
+ "shaded chart: draw a legend, 'Low ░ ▒ ▓ █ High' for a heatmap "
177
+ "or '█ Food ▓ Home' for stacked columns"
178
+ )
179
+ elif missing:
180
+ errors.append(f"stacked columns: the legend is missing {' '.join(missing)}")
164
181
 
165
182
  # A column chart needs to say what a row is worth.
166
- if re.search(r"─{3} ─{3}", text) and "one row is" not in text:
183
+ # Stacked columns name their shades instead.
184
+ if re.search(r"─{3} ─{3}", text) and "one row is" not in text and not SHADE_KEY.search(text):
167
185
  errors.append("column chart: state what one row is worth, e.g. '(one row is $25)'")
168
186
  return errors
169
187
 
@@ -324,10 +324,10 @@
324
324
  {
325
325
  "name": "Line chart",
326
326
  "group": "Charts",
327
- "draw": "`*` points against a `$400 │` axis and a `└────` baseline",
328
- "glyph": "$40 │ *\n$20 │ * * *\n $0 │ *\n └──────────\n 1 2 3 4",
329
- "note": "Label the top, middle, and bottom of the axis.",
330
- "patterns": ["\\$\\d+k? +│.*\\*"]
327
+ "draw": "a `─` line that turns with `╭ ╮ ╰ ╯` and climbs with `│`, against a `$400 ┤` axis and a `└────` baseline. ASCII fallback: `o` points",
328
+ "glyph": "$40 ┤ ╭──╮\n │ │ │\n$20 ┤ ╭─────╯ ╰──\n │ │\n $0 ┤───╯\n └───────────────\n 1 2 3 4 5",
329
+ "note": "Label the top, middle, and bottom of the axis. Each x label sits under the start of its step.",
330
+ "patterns": ["\\$\\d+k? +┤", "[╭╰]─+[╮╯]"]
331
331
  },
332
332
  {
333
333
  "name": "Sparkline",
@@ -338,12 +338,12 @@
338
338
  "patterns": ["(▁▂|▂▃|▃▄|▄▅|▅▆|▆▇|▇█|█▇|▇▆|▆▅|▅▄|▄▃|▃▂|▂▁)"]
339
339
  },
340
340
  {
341
- "name": "Stacked bar",
341
+ "name": "Stacked column",
342
342
  "group": "Charts",
343
- "draw": "`[FFFHHHHTO]` with a legend mapping each letter",
344
- "glyph": "[FFFFFFHHHHHHHHTTOOOO]\nF Food H Home T Trip",
345
- "note": "One letter per share. Legend below.",
346
- "patterns": ["\\[([A-Z])\\1+([A-Z])\\2+"]
343
+ "draw": "`███` columns of `█ ▓ ▒ ░` segments, with a `█ Food ▓ Home` legend. ASCII fallback: `# = : .`",
344
+ "glyph": " ░░░ ░░░\n▒▒▒ ░░░ ▒▒▒ ▓▓▓\n▓▓▓ ▓▓▓ ▓▓▓ ▓▓▓\n███ ▓▓▓ ███ ▓▓▓ ███\n███ ███ ███ ███ ███\n─── ─── ─── ─── ───\nMon Tue Wed Thu Fri\n█ Food ▓ Home ▒ Trip ░ Other",
345
+ "note": "Stack in legend order, darkest at the bottom.",
346
+ "patterns": ["([▓▒░])\\1\\1 ([▓▒░█])\\2\\2", "█ [A-Z][a-z]+[^▓\\n]*▓ [A-Z]"]
347
347
  },
348
348
  {
349
349
  "name": "Heatmap",
@@ -1,73 +0,0 @@
1
- #!/usr/bin/env node
2
- // Check that every vocabulary element appears in at least one example.
3
- //
4
- // Each element in vocabulary.json lists one or more regex patterns. An element
5
- // is covered when every one of its patterns matches somewhere in the examples.
6
- //
7
- // This is a port of check_coverage.py. Keep the two in step.
8
- //
9
- // Usage: node check_coverage.mjs [VOCABULARY_JSON EXAMPLES_DIR]
10
- // Exit status is 0 when everything is covered, 1 otherwise.
11
-
12
- import fs from "node:fs";
13
- import path from "node:path";
14
- import { fileURLToPath } from "node:url";
15
- import { checkText } from "./check_wireframe.mjs";
16
-
17
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
18
-
19
- function loadExamples(directory) {
20
- return Object.fromEntries(
21
- fs
22
- .readdirSync(directory)
23
- .filter((name) => name.endsWith(".txt"))
24
- .sort()
25
- .map((name) => [name, fs.readFileSync(path.join(directory, name), "utf8")]),
26
- );
27
- }
28
-
29
- // Examples that match every pattern, or an empty list if any pattern is unmatched.
30
- function usedIn(item, examples) {
31
- const found = new Set();
32
- for (const pattern of item.patterns) {
33
- const regex = new RegExp(pattern, "m");
34
- const hits = Object.keys(examples).filter((name) => regex.test(examples[name]));
35
- if (hits.length === 0) return [];
36
- hits.forEach((name) => found.add(name));
37
- }
38
- return [...found].sort();
39
- }
40
-
41
- function main(argv) {
42
- const vocabulary = argv[0] ?? path.join(ROOT, "vocabulary.json");
43
- const examplesDir = argv[1] ?? path.join(ROOT, "examples");
44
- const data = JSON.parse(fs.readFileSync(vocabulary, "utf8"));
45
- const examples = loadExamples(examplesDir);
46
-
47
- const missing = [];
48
- for (const item of data.items) {
49
- const hits = usedIn(item, examples);
50
- const status = hits.length ? "ok " : "MISS";
51
- console.log(`${status} ${item.group.padEnd(10)} ${item.name.padEnd(13)} ${hits.join(", ")}`);
52
- if (hits.length === 0) missing.push(item.name);
53
- }
54
-
55
- const total = data.items.length;
56
- console.log(`\n${total - missing.length} of ${total} elements covered by ${Object.keys(examples).length} examples`);
57
-
58
- // The drawings in the vocabulary must pass the same checks as the screens.
59
- const badGlyphs = data.items.flatMap((item) =>
60
- checkText(item.glyph).map((error) => `${item.name}: ${error}`),
61
- );
62
- if (badGlyphs.length) {
63
- console.log("\nInvalid glyphs in vocabulary.json:");
64
- for (const line of badGlyphs) console.log(` ${line}`);
65
- } else {
66
- console.log(`All ${total} vocabulary drawings pass the wireframe checks`);
67
- }
68
-
69
- if (missing.length) console.log("Not covered: " + missing.join(", "));
70
- return missing.length || badGlyphs.length ? 1 : 0;
71
- }
72
-
73
- if (process.argv[1] === fileURLToPath(import.meta.url)) process.exit(main(process.argv.slice(2)));
@@ -1,75 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Check that every vocabulary element appears in at least one example.
3
-
4
- Each element in vocabulary.json lists one or more regex patterns. An element
5
- is covered when every one of its patterns matches somewhere in the examples.
6
-
7
- Usage: check_coverage.py [VOCABULARY_JSON EXAMPLES_DIR]
8
- Exit status is 0 when everything is covered, 1 otherwise.
9
- """
10
-
11
- import json
12
- import re
13
- import sys
14
- from pathlib import Path
15
-
16
- from check_wireframe import check_text
17
-
18
- ROOT = Path(__file__).resolve().parent.parent
19
-
20
-
21
- def load_examples(directory):
22
- return {
23
- path.name: path.read_text(encoding="utf-8")
24
- for path in sorted(Path(directory).glob("*.txt"))
25
- }
26
-
27
-
28
- def used_in(item, examples):
29
- """Examples that match every pattern, or an empty list if any pattern is unmatched."""
30
- found = set()
31
- for pattern in item["patterns"]:
32
- regex = re.compile(pattern, re.MULTILINE)
33
- hits = {name for name, text in examples.items() if regex.search(text)}
34
- if not hits:
35
- return []
36
- found |= hits
37
- return sorted(found)
38
-
39
-
40
- def main(argv):
41
- vocabulary = Path(argv[0]) if argv else ROOT / "vocabulary.json"
42
- examples_dir = Path(argv[1]) if len(argv) > 1 else ROOT / "examples"
43
- data = json.loads(vocabulary.read_text(encoding="utf-8"))
44
- examples = load_examples(examples_dir)
45
-
46
- missing = []
47
- for item in data["items"]:
48
- hits = used_in(item, examples)
49
- status = "ok " if hits else "MISS"
50
- print(f"{status} {item['group']:<10} {item['name']:<13} {', '.join(hits)}")
51
- if not hits:
52
- missing.append(item["name"])
53
-
54
- total = len(data["items"])
55
- print(f"\n{total - len(missing)} of {total} elements covered by {len(examples)} examples")
56
-
57
- # The drawings in the vocabulary must pass the same checks as the screens.
58
- bad_glyphs = []
59
- for item in data["items"]:
60
- for error in check_text(item["glyph"]):
61
- bad_glyphs.append(f"{item['name']}: {error}")
62
- if bad_glyphs:
63
- print("\nInvalid glyphs in vocabulary.json:")
64
- for line in bad_glyphs:
65
- print(f" {line}")
66
- else:
67
- print(f"All {total} vocabulary drawings pass the wireframe checks")
68
-
69
- if missing:
70
- print("Not covered: " + ", ".join(missing))
71
- return 1 if missing or bad_glyphs else 0
72
-
73
-
74
- if __name__ == "__main__":
75
- sys.exit(main(sys.argv[1:]))
@@ -1,57 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Regenerate the vocabulary tables in SKILL.md from vocabulary.json.
3
-
4
- Replaces everything between the vocabulary:start and vocabulary:end markers.
5
-
6
- Usage: sync_vocabulary.py [--check]
7
- With --check, writes nothing and exits 1 if SKILL.md is out of date.
8
- """
9
-
10
- import json
11
- import sys
12
- from pathlib import Path
13
-
14
- ROOT = Path(__file__).resolve().parent.parent
15
- START = "<!-- vocabulary:start -->"
16
- END = "<!-- vocabulary:end -->"
17
-
18
-
19
- def render(data):
20
- blocks = []
21
- for group in data["groups"]:
22
- items = [i for i in data["items"] if i["group"] == group]
23
- if not items:
24
- continue
25
- rows = [f"### {group}", "", "| Element | Draw as | Notes |", "| --- | --- | --- |"]
26
- for item in items:
27
- rows.append(f"| {item['name']} | {item['draw']} | {item['note']} |")
28
- blocks.append("\n".join(rows))
29
- return "\n\n".join(blocks)
30
-
31
-
32
- def main(argv):
33
- data = json.loads((ROOT / "vocabulary.json").read_text(encoding="utf-8"))
34
- skill_path = ROOT / "SKILL.md"
35
- skill = skill_path.read_text(encoding="utf-8")
36
- if START not in skill or END not in skill:
37
- print(f"SKILL.md is missing the {START} / {END} markers")
38
- return 1
39
-
40
- head, rest = skill.split(START, 1)
41
- _, tail = rest.split(END, 1)
42
- updated = f"{head}{START}\n\n{render(data)}\n\n{END}{tail}"
43
-
44
- if "--check" in argv:
45
- if updated != skill:
46
- print("SKILL.md is out of date. Run scripts/sync_vocabulary.py")
47
- return 1
48
- print("SKILL.md is up to date")
49
- return 0
50
-
51
- skill_path.write_text(updated, encoding="utf-8")
52
- print(f"Wrote {len(data['items'])} elements to SKILL.md")
53
- return 0
54
-
55
-
56
- if __name__ == "__main__":
57
- sys.exit(main(sys.argv[1:]))