@baravak/risloo-profile-cli 4.46.2 → 4.70.0

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 (90) hide show
  1. package/.agents/skills/create-profile/SKILL.md +14 -0
  2. package/.claude/settings.local.json +7 -0
  3. package/.claude/skills/create-profile/SKILL.md +80 -0
  4. package/AGENTS.md +239 -0
  5. package/CLAUDE.md +5 -0
  6. package/package.json +1 -1
  7. package/src/cli-commands/Executor.js +8 -2
  8. package/src/cli-commands/ExtractExecutor.js +55 -21
  9. package/src/cli-commands/GiftExecutor.js +1 -0
  10. package/src/handlebars/importPartials.js +10 -44
  11. package/src/handlebars/init.js +17 -11
  12. package/src/publish/json/profiles/BSCT93.json +495 -0
  13. package/src/publish/json/profiles/BSSI93.json +392 -0
  14. package/src/publish/json/profiles/FRHPT93.json +2 -2
  15. package/src/publish/json/profiles/ISKS93.json +288 -0
  16. package/src/publish/json/profiles/MCMI9A.json +1540 -0
  17. package/src/publish/json/profiles/NEO93.json +3858 -0
  18. package/src/publish/json/profiles/NEO9A.json +1159 -1
  19. package/src/publish/json/profiles/NEO9Q.json +3856 -0
  20. package/src/publish/json/profiles/NEO9V.json +1157 -0
  21. package/src/publish/json/profiles/RIASEC9A.json +2775 -0
  22. package/src/publish/json/profiles/RSI93.json +359 -0
  23. package/src/samples/AEQ93.js +1 -1
  24. package/src/samples/AMS93.js +1 -1
  25. package/src/samples/AMS9A.js +3 -1
  26. package/src/samples/BAOMEIS93.js +1 -1
  27. package/src/samples/BEQI93.js +1 -1
  28. package/src/samples/BSCT93.js +90 -0
  29. package/src/samples/BSSI93.js +93 -0
  30. package/src/samples/CAATS93.js +1 -1
  31. package/src/samples/CERQ93.js +1 -1
  32. package/src/samples/CRAAS93.js +1 -1
  33. package/src/samples/DSWLS93.js +1 -1
  34. package/src/samples/EMSS93.js +1 -1
  35. package/src/samples/EPQ9A.js +6 -1
  36. package/src/samples/FACES93.js +6 -0
  37. package/src/samples/FRHPT93.js +41 -13
  38. package/src/samples/GMIT93.js +1 -1
  39. package/src/samples/HPL93.js +1 -1
  40. package/src/samples/ISKS93.js +62 -0
  41. package/src/samples/IUS93.js +1 -1
  42. package/src/samples/JCSI93.js +1 -1
  43. package/src/samples/JPFQ93.js +5 -0
  44. package/src/samples/KJGI93.js +1 -1
  45. package/src/samples/LMIQ93.js +1 -1
  46. package/src/samples/MMFAD93.js +1 -1
  47. package/src/samples/MOCI93.js +1 -1
  48. package/src/samples/NEO93.js +277 -0
  49. package/src/samples/NEO9A.js +91 -60
  50. package/src/samples/NEO9Q.js +279 -0
  51. package/src/samples/NEO9V.js +215 -0
  52. package/src/samples/OBQ4493.js +1 -1
  53. package/src/samples/PIES93.js +1 -1
  54. package/src/samples/PIES9A.js +4 -0
  55. package/src/samples/PMCIEF93.js +7 -1
  56. package/src/samples/PSWQ93.js +1 -1
  57. package/src/samples/RIASEC93.js +1 -1
  58. package/src/samples/RIASEC9A.js +256 -0
  59. package/src/samples/RSI93.js +89 -0
  60. package/src/samples/SASQ93.js +1 -1
  61. package/src/samples/SDCAQ93.js +1 -1
  62. package/src/samples/STAIY93.js +1 -1
  63. package/src/samples/WAQ93.js +1 -1
  64. package/src/samples/YBOCS93.js +1 -1
  65. package/src/samples/YPI93.js +5 -1
  66. package/views/profiles/NEO_long_facets.hbs +125 -0
  67. package/views/profiles/NEO_main.hbs +150 -0
  68. package/views/profiles/NEO_short_facets.hbs +82 -0
  69. package/views/profiles/samples/BSCT93.hbs +99 -0
  70. package/views/profiles/samples/BSSI93.hbs +102 -0
  71. package/views/profiles/samples/FRHPT93_1.hbs +570 -87
  72. package/views/profiles/samples/FRHPT93_2.hbs +90 -76
  73. package/views/profiles/samples/FRHPT93_3.hbs +2 -2
  74. package/views/profiles/samples/FRHPT93_4.hbs +2 -2
  75. package/views/profiles/samples/FRHPT93_5.hbs +103 -0
  76. package/views/profiles/samples/FRHPT9A.hbs +7 -7
  77. package/views/profiles/samples/ISKS93.hbs +38 -0
  78. package/views/profiles/samples/NEO93_1.hbs +14 -0
  79. package/views/profiles/samples/NEO93_2.hbs +15 -0
  80. package/views/profiles/samples/NEO93_3.hbs +15 -0
  81. package/views/profiles/samples/NEO9A.hbs +14 -29
  82. package/views/profiles/samples/NEO9Q_1.hbs +13 -0
  83. package/views/profiles/samples/NEO9Q_2.hbs +14 -0
  84. package/views/profiles/samples/NEO9Q_3.hbs +14 -0
  85. package/views/profiles/samples/NEO9V_1.hbs +13 -0
  86. package/views/profiles/samples/NEO9V_2.hbs +3 -0
  87. package/views/profiles/samples/RIASEC9A_1.hbs +38 -0
  88. package/views/profiles/samples/RIASEC9A_2.hbs +67 -0
  89. package/views/profiles/samples/RIASEC9A_3.hbs +93 -0
  90. package/views/profiles/samples/RSI93.hbs +101 -0
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: create-profile
3
+ description: Build a new psychological profile (نیم‌رخ) or a new page/chart of one — or edit an existing profile's chart — from a Figma design into a JS controller (src/samples/<NAME>.js) + Handlebars SVG template (views/profiles/samples/<NAME>*.hbs). Use when the user wants to create/extend/edit a profile, references Figma SVG/HTML exports (x/<NAME>.svg, x/<NAME>.html) for a <NAME>, or asks to wire a `score` dataset into a chart. Read AGENTS.md alongside this skill — it holds the conventions, helper reference, and the Gotchas this skill points to.
4
+ ---
5
+
6
+ # create-profile (pointer)
7
+
8
+ This is a shortcut. The full runbook lives in one place, shared by every agent:
9
+
10
+ **`.claude/skills/create-profile/SKILL.md`**
11
+
12
+ Read that file end to end **now**, before taking any task action, then follow it. Also read `AGENTS.md` at the repo root — the runbook references its _Handlebars Helper Reference_, _Gotchas & Reusable Patterns_, and conventions instead of repeating them.
13
+
14
+ Do not edit this pointer to add content; edit the runbook above.
@@ -0,0 +1,7 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(*)"
5
+ ]
6
+ }
7
+ }
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: create-profile
3
+ description: Build a new psychological profile (نیم‌رخ) or a new page/chart of one — or edit an existing profile's chart — from a Figma design into a JS controller (src/samples/<NAME>.js) + Handlebars SVG template (views/profiles/samples/<NAME>*.hbs). Use when the user wants to create/extend/edit a profile, references Figma SVG/HTML exports (x/<NAME>.svg, x/<NAME>.html) for a <NAME>, or asks to wire a `score` dataset into a chart. Read AGENTS.md alongside this skill — it holds the conventions, helper reference, and the Gotchas this skill points to.
4
+ ---
5
+
6
+ # Create / edit a profile (نیم‌رخ)
7
+
8
+ This project renders psychological-test results into SVG/PNG profiles. A profile = a JS controller (`src/samples/<NAME>.js`) + one or more Handlebars templates (`views/profiles/samples/<NAME>.hbs`, or `<NAME>_1.hbs`, `<NAME>_2.hbs`, … for multi-page). Follow this runbook. **Read `AGENTS.md` first** — this skill references its _Handlebars Helper Reference_, _Gotchas & Reusable Patterns_, and conventions instead of repeating them.
9
+
10
+ ## Core principle — you produce the RAW chart only
11
+
12
+ You draw only the Figma **Chart** layer (the raw chart). The engine injects the header, sidebar and footer via the `{{#> layout}}` wrapper. **Never put header/sidebar/footer in the HBS.** The CLI emits a `raw` variant (chart only) and a full with-sidebar variant. (AGENTS.md › _What the HBS Draws_.)
13
+
14
+ ## 1. Gather inputs — ask the user for these 6 files (as prompts)
15
+
16
+ 1. **Scoring source** — the engine that computes the `score` object. Its **language varies (PHP, Python, or Node.js)** and it usually lives in a sibling repo (e.g. for NEO: `../risloo-docker/services/risloo/app/Score<NAME>.php` + trait). Don't assume the language/path — ask. It tells you how each `score` key is computed and what flags mean.
17
+ 2. **Scoring documentation** — the validity rules, thresholds, and level/norm descriptions (a file or pasted text).
18
+ 3. **Profile SVG** — `x/<NAME>.svg`. The **source of truth**: which elements exist, exact coordinates, and styling (e.g. a line that looks solid in HTML is `stroke-dasharray` dashed here).
19
+ 4. **Profile HTML** — `x/<NAME>.html`. A **helper** only: SVG text is outlined paths, so use the HTML to read the text content and learn **where each label goes (and where it doesn't)**, plus colors/gradients. If the HTML has an element the SVG lacks, it's a disabled Figma layer — drop it (AGENTS.md › Gotchas).
20
+ 5. **Profile JSON** — `src/publish/json/profiles/<NAME>.json` (input dataset; its `score` object drives everything).
21
+ 6. **Reference PNG** — the designer's rendered export of the **Chart** layer (same variant as the CLI `raw` output, *not* the full sidebar page). This is the validation target for step 7: at the end you render your own `raw` PNG and visually match it against this one. Note which demo data it was rendered with (usually the values in the HTML) so the JSON reproduces the same bar positions.
22
+
23
+ Then **ask for the designer's Figma comments** — thresholds, coefficients, the inside/outside text threshold, validity logic. The Figma MCP / SVG / HTML do **not** contain comments, and these specs usually live only there. Don't start coding until you have them.
24
+
25
+ If the SVG and HTML disagree (an element present in one, absent in the other, or a different layout), surface it and ask which is authoritative before building.
26
+
27
+ ## 2. Pick a base profile
28
+
29
+ Find the closest existing profile and copy its idioms rather than writing from scratch:
30
+ - Horizontal bars + validity boxes → `src/samples/FRHPT93.js` + `views/profiles/samples/FRHPT93_2.hbs`.
31
+ - A simple 5-row bar chart → `src/samples/BSCT93.js`.
32
+ - The most recent full example built with this skill → `src/samples/NEO93.js` + `views/profiles/samples/NEO93_1.hbs`.
33
+
34
+ Read its JS + HBS end to end.
35
+
36
+ ## 3. Geometry (from the SVG)
37
+
38
+ - The Figma **Chart** layer = the SVG `viewBox` (e.g. `0 0 800 674`).
39
+ - `dimensions = Chart + 2·padding`; derive `padding` from `(Main − Chart)/2` using the HTML offset (e.g. content at `left:72 top:40` inside a `943×754` frame → `{x:71.5, y:40}`).
40
+ - Draw all content inside `<g transform="translate(padding.x, padding.y)">`.
41
+ - **Transcribe verbatim**, in the SVG's element order, the static parts: gridlines (`line`), tracks/boxes (`rect`), `linearGradient`, `filter`, icon `path`s. Draw order = z-order — keep it (e.g. a shadowed baseline drawn *after* the bars sits in front).
42
+ - **Reconstruct** every text run as a real `<text>` at the SVG's x/y — Figma exports text as outlined `<path>`, never `<text>` (AGENTS.md › Gotchas).
43
+ - Check the **axis**: if gridline positions look "irregular", they may be a zoomed range. Derive the linear map `x = a·pct + b` from two `(label, x)` pairs and draw the bars on that scale (AGENTS.md › Gotchas › Custom/zoomed axis).
44
+ - **Alignment is the whole job.** This is pixel-precise infographic work — measure exact x/y (and text glyph bounds) from the SVG; never estimate. Get every label's horizontal alignment (left/center/right via `text-anchor`, RTL-aware) and vertical alignment (row mid-line, shared baseline) right. If the chart sits in an offset frame, transcribe its `translate` exactly. See AGENTS.md › Gotchas › _Alignment is the whole job_.
45
+
46
+ ## 4. Wire the data
47
+
48
+ - In the controller, define `labels` so each entry's `eng` matches a `score` key; `Object.values(this.labels)` feeds `Dataset._extractData`, producing `dataset.score = [{ label, mark }]`.
49
+ - In `_calcContext()`, read **every** mark with `?? 0` (AGENTS.md › `?? 0` rule) — `_extractData` turns a `0` into `undefined`. Compute per-item geometry (bar width / percentage, text x + anchor) and any validity indicators.
50
+ - Confirm ambiguous encodings (validity flags `1`=valid vs invalid, reversed answer scales, norm-based levels) against the **scoring source** (PHP/Python/Node.js) — never guess.
51
+
52
+ ## 5. Write the controller + template
53
+
54
+ - **Controller**: `labels`; `profileSpec` (`sample`, `profile.dimensions`/`padding`, `labels: Object.values(this.labels)`); `constructor` calling `this._init(...)`; `_calcContext()` returning one context object per page.
55
+ - **Template**: `{{#> layout}}` → `<defs>` (gradients/clips/filters) → `<g transform="translate(padding.x, padding.y)">` → static structure + `{{#each items}}` rows + conditional boxes → `{{/layout}}`.
56
+ - Use the helpers from AGENTS.md › _Helper Reference_ (`bar`, `math`, `boolean`, `ternary`, `object`, `toRad`). Apply the gotcha checklist below.
57
+
58
+ ## 6. Multi-page
59
+
60
+ - Set `static pages = N`; build `<NAME>_1.hbs … <NAME>_N.hbs`. Create blank `{{#> layout}} … {{/layout}}` placeholders for pages not built yet — **a missing template file errors the whole run**.
61
+ - Make `profile.dimensions` / `profile.padding` arrays (indexed `page-1` by `layout.hbs`); give each page a `titleAppend`.
62
+
63
+ ## 7. Verify
64
+
65
+ - Render: `./bin/risloo.js E <NAME> -d ./src/publish/json/profiles/<NAME>.json -a ./temp`
66
+ - Open `temp/<NAME>.raw.png` (chart only) and `temp/<NAME>.png` (with sidebar); eyeball against `x/<NAME>.svg` / `x/<NAME>.html`.
67
+ - Test edge & validity states with **scratch JSONs** that modify the `score` object (flip a `voption_*`/validity flag, push a value to 0% or 100%) and render with `-d <scratch>.json`. Clean up scratch files afterward.
68
+ - **Close with a PNG visual match (the final gate).** Render with the **same data the reference PNG used** (the demo values from the HTML) so bars land in the same place, then **view both** `temp/<NAME>.raw.png` and the reference PNG (Read them) and compare region by region. Your eyes catch structural/color/placement/RTL errors but are **not a pixel ruler** — when your eye flags a spot, pull the exact coordinate from the **SVG** and fix from that number, then re-render. Iterate render → view → fix until the two match. (AGENTS.md › Gotchas › _Close with a PNG visual match_.)
69
+
70
+ ## Gotcha checklist (detail in AGENTS.md › Gotchas)
71
+
72
+ - [ ] RTL `text-anchor`: right-align Persian with `start`; inside-bar `%` `start`, outside `end`.
73
+ - [ ] `?? 0` on every mark read in `_calcContext`.
74
+ - [ ] Text reconstructed as `<text>`, not transcribed glyph paths.
75
+ - [ ] Western digits in templates (the `DanaFaNum` font shapes them to Persian).
76
+ - [ ] Elements emitted in Figma-SVG order (draw order = z-order).
77
+ - [ ] Bars drawn on the same scale as the (possibly zoomed) axis labels.
78
+ - [ ] Validity/alert boxes hidden unless ≥1 indicator; separator dots via `{{#unless last}}`.
79
+ - [ ] No header/sidebar/footer in the HBS — only the Chart layer.
80
+ - [ ] Closed with a PNG visual match against the reference (raw-vs-raw, same data): viewed both images, iterated render → view → fix until aligned.
package/AGENTS.md ADDED
@@ -0,0 +1,239 @@
1
+ # AGENTS.md
2
+
3
+ > Single source of truth for agent instructions in this repo (Codex, Claude Code, …).
4
+ > `CLAUDE.md` is only a pointer to this file — edit **this** file, never the pointer.
5
+
6
+ ## Skills (runbooks)
7
+
8
+ | Skill | Path | Use for |
9
+ |---|---|---|
10
+ | `create-profile` | `.claude/skills/create-profile/SKILL.md` | Create / extend / edit a profile (نیم‌رخ) from a Figma design → JS controller + HBS template. `.agents/skills/create-profile/SKILL.md` is a vendor-neutral pointer to the same file (Codex scans `.agents/skills` from the cwd up to the repo root). |
11
+
12
+ ## Project Overview
13
+
14
+ **@baravak/risloo-profile-cli** is a Node.js CLI tool that converts psychological test result JSON data into SVG and PNG profile/report images. It supports 81+ psychological questionnaires with Persian/Farsi labeling.
15
+
16
+ Published as npm package: `@baravak/risloo-profile-cli` (v4.46.2, MIT)
17
+
18
+ ---
19
+
20
+ ## Directory Structure
21
+
22
+ ```
23
+ risloo-extractor-app/
24
+ ├── bin/
25
+ │ └── risloo.js # CLI entry point
26
+ ├── src/
27
+ │ ├── cli.js # Commander.js command definitions
28
+ │ ├── cli-commands/
29
+ │ │ ├── Executor.js # Base executor class
30
+ │ │ ├── ExtractExecutor.js # Profile/report/sheet extraction logic
31
+ │ │ ├── GiftExecutor.js # Gift card generation
32
+ │ │ └── utilities/ # BaseOps, Benchmarker, Response, Errors, Status codes
33
+ │ ├── Profile.js # Base Profile class (math utilities)
34
+ │ ├── Gift.js # Gift card class
35
+ │ ├── samples/ # JS controller files — one per profile (e.g. BSCT93.js)
36
+ │ ├── handlebars/
37
+ │ │ ├── init.js # Handlebars initialization
38
+ │ │ ├── helpers.js # Entry point for all helpers
39
+ │ │ ├── helpers/ # 45+ geometry helper modules
40
+ │ │ ├── importPartials.js # Partials loader
41
+ │ │ └── polygon.js # Polygon drawing logic
42
+ │ ├── helpers/ # Math helpers (angleABS, polarXY, gauge, polygonXY)
43
+ │ ├── qrcode/ # QR code generation & rendering
44
+ │ └── publish/
45
+ │ ├── json/profiles/ # Template JSON per sample (e.g. BSCT93.json)
46
+ │ ├── json/gift/ # Gift template data
47
+ │ ├── test.js # Auto-test all samples
48
+ │ └── bot.js # Post-publish automation
49
+ ├── views/
50
+ │ ├── profiles/samples/ # Handlebars SVG templates (.hbs)
51
+ │ └── gift.hbs
52
+ └── package.json
53
+ ```
54
+
55
+ ---
56
+
57
+ ## Commands
58
+
59
+ ```bash
60
+ # Test a single profile (generates SVG in ./temp)
61
+ ./bin/risloo.js E <NAME> -d ./src/publish/json/profiles/<NAME>.json -a ./temp
62
+
63
+ # Watch mode
64
+ risloo extract <NAME> profile -i local -d ./src/publish/json/profiles/<NAME>.json -o local -a ./temp -w
65
+
66
+ # Generate gift card
67
+ risloo gift -i raw-json -d '{"code":"..."}' -o local -a ./output
68
+
69
+ # Test all samples
70
+ npm test # → node ./src/publish/test.js
71
+ ```
72
+
73
+ ---
74
+
75
+ ## Profile Development Workflow (Figma → Code)
76
+
77
+ ### Design Source
78
+
79
+ Each profile is designed in Figma. Per task, one or both of the following may be provided:
80
+
81
+ - **Figma design file** — connect via Figma MCP to read layer dimensions and structure. The relevant layer is named **Chart**.
82
+ - **Figma handoff file** — contains additional implementation notes and specs. May state that this profile is similar to an existing one (e.g. "similar to BSCT93 with these changes"). In that case: find the referenced profile's JS + HBS files, use them as the base, and apply only the described differences.
83
+
84
+ > **Important:** The Figma MCP reads text layers only — it does NOT read Figma comments. Designers often leave critical specs (thresholds, coefficients, pixel values) as Figma comments. Always ask the user to share designer comments before finalizing the plan.
85
+
86
+ ### What the HBS Draws
87
+
88
+ The HBS file draws **only the Chart layer** — i.e. the **raw** chart. The engine produces the final page by automatically injecting the header (test name, dates, client info), the sidebar (logo, room info, prerequisites) and the footer/closing info via the `{{#> layout}}` wrapper. Never include any of these in the HBS. (The CLI emits both a `raw` variant — chart only, e.g. `<NAME>.raw.svg/png` — and the full with-sidebar variant `<NAME>.svg/png`.)
89
+
90
+ ### SVG Coordinate System
91
+
92
+ Since this is SVG, coordinate origin matters for correctness across **all** inputs:
93
+
94
+ - **x=0** = left edge, increases rightward
95
+ - **y=0** = top edge, increases downward
96
+ - Horizontal bars fill left → right: `{{bar (BAR_WIDTH * factor.p) height ...}}`
97
+ - Vertical total bar fills bottom → up via the transform trick: `translate(0, barHeight - barHeight * p)`
98
+ - All positions must remain stable for edge-case inputs: 0% score, 100% score, missing data
99
+
100
+ ### Inside/Outside Bar Text
101
+
102
+ When rendering percentage text on a bar, check if the bar is wide enough to contain the text. The threshold is specified by the designer per profile (in Figma comments):
103
+
104
+ ```hbs
105
+ {{#if (boolean factor.percentage '<=' THRESHOLD)}}
106
+ <text x="{{math (math BAR_WIDTH '*' factor.p) '+' 6}}" ...>{{factor.percentage}} ٪</text>
107
+ {{else}}
108
+ <text x="{{math (math BAR_WIDTH '*' factor.p) '-' 4}}" ...>{{factor.percentage}} ٪</text>
109
+ {{/if}}
110
+ ```
111
+
112
+ ### File Structure per Profile
113
+
114
+ Each profile (نیم‌رخ) consists of three files:
115
+
116
+ | File | Path | Role |
117
+ |---|---|---|
118
+ | JSON template | `src/publish/json/profiles/<NAME>.json` | Test data / input template |
119
+ | JS controller | `src/samples/<NAME>.js` | Data processing, geometry config |
120
+ | HBS template | `views/profiles/samples/<NAME>.hbs` | SVG rendering |
121
+
122
+ If a profile has **multiple pages**, the HBS files are named with suffixes:
123
+ - `<NAME>_1.hbs`, `<NAME>_2.hbs`, `<NAME>_3.hbs`, ...
124
+
125
+ Multi-page mechanics:
126
+ - Set `static pages = N` on the controller class. `ExtractExecutor.js` then loads `<NAME>_1.hbs … <NAME>_N.hbs` (a single-page profile uses the un-suffixed `<NAME>.hbs`). **A missing template file errors the whole run** — when a page isn't built yet, create a blank `{{#> layout}} … {{/layout}}` placeholder for it.
127
+ - `profile.dimensions` and `profile.padding` become **arrays**; `layout.hbs` picks index `page - 1` for each page. Each entry returned by `_calcContext()` is one page's context object and may carry a `titleAppend` string that is appended to the header title.
128
+
129
+ ### Dimensions & Padding Convention
130
+
131
+ The chart drawing must fit within the **Main** layer in Figma.
132
+
133
+ - Read the **Main** layer dimensions from Figma
134
+ - `profile.padding` = 20 units per side
135
+ - The `x` and `y` values (drawing area) = Main dimensions minus one padding unit each:
136
+ - e.g. Main = 104×255 → `{ x: 84, y: 235 }`
137
+
138
+ The `dimensions` property in the JS controller adds padding back on both sides of the **Chart** layer dimensions:
139
+
140
+ ```js
141
+ get dimensions() {
142
+ return {
143
+ width: 736 + 2 * this.padding.x, // Chart layer width from Figma
144
+ height: 254 + 2 * this.padding.y, // Chart layer height from Figma
145
+ };
146
+ },
147
+ ```
148
+
149
+ ### Data / Labels Convention
150
+
151
+ All data needed for rendering goes in the `labels` property of the JS controller.
152
+
153
+ The data structure is typically derived from a Python script that outputs a JSON file — flattened with `_` separators — which maps source fields to their `labels` keys.
154
+
155
+ ```js
156
+ get labels() {
157
+ return {
158
+ // flat_key: value
159
+ score_total: ...,
160
+ subscale_anxiety: ...,
161
+ };
162
+ }
163
+ ```
164
+
165
+ **Where the `score` values come from.** The JSON `score` object fed to a profile is produced by a separate **scoring engine** whose language **varies per test — it may be PHP, Python, or Node.js** — and which usually lives in a sibling repo (e.g. for NEO: `../risloo-docker/services/risloo/app/ScoreNEO93.php` plus a shared `ScoreNEO` trait). **Always ask the user for the actual scoring-source file** rather than assuming the language/path. When a score key's meaning is ambiguous — especially validity flags (is `1` valid or invalid?) — read that source to confirm the encoding instead of guessing; the scoring source also defines the level thresholds (norms) and per-item (reverse) scoring. Each `score` key is matched to a label via `label.eng`.
166
+
167
+ **The `?? 0` rule.** `Dataset._extractData` (in `src/Profile.js`) maps `mark: score[label.eng] || …`, so a score of `0` (or any falsy value) falls through to `undefined`. Always read marks with `?? 0` in `_calcContext` to avoid `NaN` widths/positions.
168
+
169
+ ---
170
+
171
+ ## Handlebars Helper Reference
172
+
173
+ Common helpers used in profile templates (defined under `src/handlebars/helpers/`):
174
+
175
+ | Helper | Signature | Notes |
176
+ |---|---|---|
177
+ | `bar` | `bar W H (object tl= bl= tr= br=) (toRad deg) ...attrs` | Rounded-rect `<path>` — the building block for every fill bar. For a horizontal bar use `tl=bl=0` and round `tr/br`. The hash accepts `fill`, `fill-opacity`, `stroke`, `clip-path`, `transform`. |
178
+ | `math` | `math a op b` | Arithmetic `+ - * / %` (op defaults to `+`). Nestable: `(math (math …) '*' …)`. |
179
+ | `boolean` | `boolean a op b` | Comparison/logical `=== !== == != < <= > >= && \|\|`. |
180
+ | `ternary` | `ternary cond a b` | Returns `a` if `cond` is truthy, else `b`. |
181
+ | `object` | `object k=v …` | Builds an inline object literal (used for the `bar` corner radii). |
182
+ | `toRad` | `toRad deg` | Degrees → radians. |
183
+
184
+ ---
185
+
186
+ ## Gotchas & Reusable Patterns
187
+
188
+ Hard-won knowledge that applies to **every** profile:
189
+
190
+ - **Alignment is the whole job — measure, never estimate.** These are pixel-precise **infographics** built from SVG, so every element must line up with the design **both horizontally and vertically**. Pull exact coordinates from the SVG (element `x`/`y`; for text-as-paths, the glyph bounding box / the `M…` start) instead of eyeballing. **Horizontal:** decide per label whether it is left-, center-, or right-aligned and set `text-anchor` to match (RTL flips `start`/`end`) — e.g. the right-most axis label's *right edge* sits on the gridline (right-aligned), not centered over it; domain names are right-aligned to a fixed edge with the dash/letter at fixed x's after them. **Vertical:** center row text on the row mid-line with `y=<center> dy=".3em"`, and keep every column of a row on the same baseline. If the chart lives inside an offset frame (NEO93 page 1 = `translate(32, 128)` inside the 800×674 content), transcribe that `translate` exactly, and remember a `filterUnits="userSpaceOnUse"` region is in that same (post-translate) local space. Re-render and eyeball after **every** change.
191
+ - **RTL is the base — account for it in EVERY alignment.** The root SVG sets `direction="rtl"` (`views/profiles/layout.hbs`), which flips both horizontal text flow **and** the advance direction of rotated labels. This is the single most common alignment mistake — get it wrong and a label slides *under* a bar or lands a whole run-length away. Two cases:
192
+ - **Horizontal `text-anchor`.** Under RTL, `text-anchor="start"` puts the **right** edge at `x` (text grows **left**); `text-anchor="end"` puts the **left** edge at `x` (text grows **right**). So: right-align Persian labels with `start`; a label that must sit to the **right** of an element and grow away from it uses `end`. Inside-bar `%` label → `start`; outside (narrow-bar) label → `end` (mirror `FRHPT93_2.hbs`). BSCT93 vertical total bar: the `%`/ratio labels sit to the **right** of the bar → `end`; the `٪۱۰۰` axis label sits to the **left** → `start` (using `end` there grows it rightward *under* the bar, which is drawn after and paints over it). If a run of Latin/numbers mis-orders, add `direction="ltr"` on that `<text>`.
193
+ - **Rotated (`rotate(-90)`) vertical labels advance DOWN, not up.** Because the base is RTL, a `<text transform="rotate(-90,0,0) translate(tx,ty)">` grows **downward** on screen (its reading run goes top→bottom). So the baseline x = `<group-x> + ty` (glyph bodies extend **left** of it) and the text's **TOP** edge = `<group-y> − tx`, running down for its length. BSCT93 «نمره کل» under group `translate(637,1)` → `ty=22` (baseline x≈659), `tx=-202` (top y≈203, bottom ≈238). Assuming it grows *up* drops it ~a run-length too low.
194
+ - **`?? 0` on every mark.** See _Data / Labels Convention_ — `_extractData` turns a `0` mark into `undefined`; always guard with `?? 0`.
195
+ - **Figma SVG = outlined paths, not text.** A Figma SVG export renders every text run as an outlined `<path>`, never `<text>`. Do **not** transcribe glyph paths. Reconstruct text with real `<text>` elements at the SVG's x/y, and take the actual content/colors from the **HTML** export. Transcribe verbatim only: `rect`, `line`, `linearGradient`, `filter`, and icon `<path>`s.
196
+ - **The SVG is the source of truth; the HTML export is only a helper.** Trust the **SVG** for everything authoritative: which elements exist, exact coordinates, and styling — e.g. a divider that looks like a plain solid line in the HTML is actually `stroke-dasharray` *dashed* in the SVG. Use the **HTML** only because (a) SVG text is outlined paths so you can't read it there — the HTML tells you the text content and **where each label goes (and where it doesn't)**, and (b) it's easier to read colors/gradients/structure. If the HTML shows an element the SVG lacks, it's a *disabled Figma layer* (the HTML export still draws disabled layers, with color) — **drop it**. (NEO93 level chips were exactly this: present in HTML, absent in SVG → removed; the level *text* stays, the chip box doesn't.)
197
+ - **Persian digits come from the font.** Emit Western digits in templates (`{{item.mark}}`, `از 192`, `50 ٪`); the `DanaFaNum` font shapes them to Persian. Never hand-convert.
198
+ - **Draw order = z-order.** Later elements paint on top. Transcribe in the same order as the Figma SVG (e.g. a shadowed baseline drawn **after** the bars sits in front of them).
199
+ - **Custom / zoomed axis.** Gridlines are not always 0–100%. If their positions look "irregular", check for a zoomed range (e.g. NEO93 = 20–80%). Derive the linear map `x = a·pct + b` from two known `(label, x)` pairs and draw the bars on **that** scale, so a bar's end lands exactly on its labeled gridline.
200
+ - **Validity / alert-box pattern.** Lay indicators right-to-left: text at `x = ANCHOR − (start + length/2)`, separator dot at `ANCHOR − (start + length + 26)` guarded by `{{#unless last}}`; hide the whole box unless ≥1 indicator is active. Drive it from JS with per-item `{ start, length, last }` (see `FRHPT93.js` / `NEO93.js`).
201
+ - **Close with a PNG visual match — your eye finds *where*, the SVG gives *what*.** The final gate before delivery: render the `raw` PNG and, by actually **viewing both images** (Read the two PNGs), compare it against the designer's reference PNG. Match variants — the reference must be a **Chart-layer** export (same as the CLI `raw`), never the full sidebar page. Render with the **same data the reference used** (transcribe the demo values from the HTML into the JSON), or bars land at different positions and every difference is a false alarm. Your vision reliably catches **structural** errors — wrong gradient/color, a label flipped to the wrong side (RTL), a bar whose end misses its gridline, reordered rows, a dropped or extra element — but it is **not a pixel ruler**: never trust it for 1–3px nudges. So when your eye flags a spot, go back to the **SVG**, read the exact coordinate, and fix from that number (eye = *where*, SVG = *what*). Iterate render → view → fix until the two PNGs match; that match is the close of the job. (This complements — never replaces — HTML-vs-output text checking, since outlined/rendered text is too noisy for a visual diff to judge.)
202
+
203
+ ---
204
+
205
+ ## Tech Stack
206
+
207
+ | Area | Technology |
208
+ |---|---|
209
+ | Language | JavaScript (Node.js, no build step) |
210
+ | CLI | Commander.js |
211
+ | Templating | Handlebars (SVG generation) |
212
+ | Image output | Sharp (SVG → PNG) |
213
+ | Dates | Moment.js + moment-jalaali (Persian calendar) |
214
+ | QR codes | qrcode |
215
+ | File watching | Chokidar |
216
+ | Package manager | Yarn |
217
+ | Design source | Figma (via MCP) |
218
+
219
+ ---
220
+
221
+ ## Naming Conventions
222
+
223
+ - `items` — array of data elements to draw
224
+ - `raw` — total/aggregate element
225
+ - `ticks` — graduation marks on profiles
226
+ - `s` suffix — denotes arrays (not `Arr`)
227
+ - Sample names: uppercase acronym + year (e.g. `BSCT93`, `BEQI93`, `16PF93`)
228
+ - Dataset score structure: `dataset.score = [{ label: { eng, ... }, mark }]`
229
+
230
+ ---
231
+
232
+ ## Publishing
233
+
234
+ ```bash
235
+ npm version <patch|minor|major>
236
+ npm publish
237
+ # prepublishOnly: npm test
238
+ # postpublish: npm run bot
239
+ ```
package/CLAUDE.md ADDED
@@ -0,0 +1,5 @@
1
+ # CLAUDE.md
2
+
3
+ Pointer only — all project instructions live in [AGENTS.md](AGENTS.md). Edit that file, not this one.
4
+
5
+ @AGENTS.md
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baravak/risloo-profile-cli",
3
- "version": "4.46.2",
3
+ "version": "4.70.0",
4
4
  "description": "**Risloo Profile CLI** is a library for creating profiles, reports and sheets for *psychological* samples.",
5
5
  "main": "bin/risloo.js",
6
6
  "publishConfig": {
@@ -1,5 +1,5 @@
1
1
  const { checkAndLoad, loadStdin, ensureDirExistence, createSVG, createPNG } = require("./utilities/BaseOps");
2
- const Handlebars = require("../handlebars/init");
2
+ const createHandlebars = require("../handlebars/init");
3
3
  const path = require("path");
4
4
  const Benchmarker = require("./utilities/Benchmarker");
5
5
  const Response = require("./utilities/Response");
@@ -74,8 +74,14 @@ class Executor {
74
74
  }
75
75
  }
76
76
 
77
+ _addHandlebarsPromise(partials = {}) {
78
+ this.promises["handlebars"] = Promise.resolve(partials).then(createHandlebars);
79
+ return this.promises["handlebars"];
80
+ }
81
+
77
82
  async _renderTemplate(ctx, templateBuffer) {
78
- const template = (await Handlebars).compile(templateBuffer.toString(), "utf-8");
83
+ const hbs = await this.promises.handlebars;
84
+ const template = hbs.compile(templateBuffer.toString(), "utf-8");
79
85
  return template(ctx);
80
86
  }
81
87
 
@@ -7,7 +7,8 @@ const { FileNotFoundError } = require("./utilities/CustomErrors");
7
7
 
8
8
  class ExtractExecutor extends Executor {
9
9
  samplesJSDir = path.join(__dirname, "..", "samples");
10
- profilesTemplatesDir = path.join(__dirname, "..", "..", "views", "profiles", "samples");
10
+ profilesDir = path.join(__dirname, "..", "..", "views", "profiles");
11
+ profilesTemplatesDir = path.join(this.profilesDir, "samples");
11
12
 
12
13
  constructor(options) {
13
14
  super(options);
@@ -39,6 +40,18 @@ class ExtractExecutor extends Executor {
39
40
  });
40
41
  }
41
42
 
43
+ _addProfileHandlebarsPromise() {
44
+ const { promises, response } = this;
45
+ const partials = promises["js"].then((Profile) => Profile.partials || {});
46
+ const handlebarsPromise = this._addHandlebarsPromise(partials);
47
+
48
+ promises["handlebars"] = handlebarsPromise.catch((err) =>
49
+ response.setStatus(EXTRACT_STATUS["TEMPLATE_NOT_FOUND"], err)
50
+ );
51
+
52
+ return promises["handlebars"];
53
+ }
54
+
42
55
  _addProfilesTemplatePromises() {
43
56
  const { sample, dirs, profilesTemplatesDir, promises, response } = this;
44
57
 
@@ -77,6 +90,7 @@ class ExtractExecutor extends Executor {
77
90
  name: options.name,
78
91
  };
79
92
 
93
+ this._addProfileHandlebarsPromise();
80
94
  this._addProfilesTemplatePromises();
81
95
  }
82
96
 
@@ -124,7 +138,7 @@ class ExtractExecutor extends Executor {
124
138
  }
125
139
 
126
140
  _watch() {
127
- const { command, dirs, input, response, benchmarker, promises } = this;
141
+ const { dirs, input, promises } = this;
128
142
 
129
143
  const opts = {
130
144
  awaitWriteFinish: {
@@ -137,38 +151,58 @@ class ExtractExecutor extends Executor {
137
151
  dirs.templates.map((templateFileDir, index) => {
138
152
  chokidar.watch(templateFileDir, opts).on("change", () => {
139
153
  promises["templates"][index] = checkAndLoad(templateFileDir);
140
- benchmarker?.restart(command);
141
- this._createProfile("with-sidebar", ["svg"]).then(() => {
142
- benchmarker?.end();
143
- response.setTime(benchmarker?.totalTime);
144
- response.showOutput();
145
- });
154
+ this._renderWatchedProfile();
146
155
  });
147
156
  });
148
157
  });
149
158
 
159
+ this._resetPartialWatcher(opts).catch(() => {});
160
+
150
161
  chokidar.watch(dirs.sampleJS, opts).on("change", () => {
151
162
  promises["js"] = checkAndImport(dirs.sampleJS);
152
- benchmarker?.restart(command);
153
- this._createProfile("with-sidebar", ["svg"]).then(() => {
154
- benchmarker?.end();
155
- response.setTime(benchmarker?.totalTime);
156
- response.showOutput();
157
- });
163
+ this._addProfileHandlebarsPromise();
164
+ this._resetPartialWatcher(opts).catch(() => {});
165
+ this._renderWatchedProfile();
158
166
  });
159
167
 
160
168
  if(input.type === 'local') chokidar.watch(input.data, opts).on('change', () => {
161
169
  promises["input"] = checkAndLoad(input.data).then((json) => Promise.resolve(JSON.parse(json)));
162
- benchmarker?.restart(command);
163
- this._createProfile("with-sidebar", ["svg"]).then(() => {
164
- benchmarker?.end();
165
- response.setTime(benchmarker?.totalTime);
166
- response.showOutput();
167
- });
168
-
170
+ this._renderWatchedProfile();
169
171
  })
170
172
  }
171
173
 
174
+ async _resetPartialWatcher(opts) {
175
+ const { dirs, profilesDir, promises } = this;
176
+ const Profile = await promises["js"];
177
+ const partialDirs = Object.values(Profile.partials || {}).map((partialFile) =>
178
+ path.join(profilesDir, partialFile)
179
+ );
180
+
181
+ dirs["partials"] = partialDirs;
182
+
183
+ if (this.partialWatcher) await this.partialWatcher.close();
184
+ if (!partialDirs.length) {
185
+ this.partialWatcher = null;
186
+ return;
187
+ }
188
+
189
+ this.partialWatcher = chokidar.watch(partialDirs, opts).on("change", () => {
190
+ this._addProfileHandlebarsPromise();
191
+ this._renderWatchedProfile();
192
+ });
193
+ }
194
+
195
+ _renderWatchedProfile() {
196
+ const { command, response, benchmarker } = this;
197
+
198
+ benchmarker?.restart(command);
199
+ return this._createProfile("with-sidebar", ["svg"]).then(() => {
200
+ benchmarker?.end();
201
+ response.setTime(benchmarker?.totalTime);
202
+ response.showOutput();
203
+ });
204
+ }
205
+
172
206
  _createProfileOutputName(variant) {
173
207
  const {
174
208
  settings: {
@@ -11,6 +11,7 @@ class GiftExecutor extends Executor {
11
11
  constructor(options) {
12
12
  super(options);
13
13
 
14
+ this._addHandlebarsPromise();
14
15
  this._addTemplatePromise();
15
16
  this._addAvatarPromise();
16
17
 
@@ -1,51 +1,17 @@
1
- const fs = require("fs");
2
- const { readdir } = require("fs/promises");
1
+ const fs = require("fs/promises");
3
2
  const path = require("path");
4
3
 
5
- // These are the profiles that might be used completely in other profiles
6
- const baseProfiles = [
7
- "AMS93",
8
- "MMFAD93",
9
- "PIES93",
10
- "JPFQ93_partial",
11
- "JPFQ93_partial2",
12
- "FACES93_1_partial",
13
- "FACES93_2_1_partial",
14
- "FACES93_2_2_partial",
15
- "EPQ93",
16
- 'YPI93_factors'
17
- ];
4
+ const partialsDir = path.join(__dirname, "..", "..", "views", "profiles");
18
5
 
19
- async function importPartials(hbs) {
20
- const partialsDir = path.join(__dirname, "..", "..", "views", "profiles");
6
+ async function importPartials(hbs, partials) {
7
+ await Promise.all(
8
+ Object.entries(partials).map(async ([name, relativePath]) => {
9
+ const template = await fs.readFile(path.join(partialsDir, relativePath), "utf-8");
10
+ hbs.registerPartial(name, template);
11
+ })
12
+ );
21
13
 
22
- let baseProfilePromises = baseProfiles.map((profileName) => {
23
- new Promise(function (resolve, reject) {
24
- fs.readFile(path.join(partialsDir, "samples", `${profileName}.hbs`), "utf-8", (err, template) => {
25
- if (err) reject(err);
26
- hbs.registerPartial(profileName, template);
27
- resolve(true);
28
- });
29
- });
30
- });
31
-
32
- return readdir(partialsDir).then((partialFiles) => {
33
- let generalPartialsPromises = partialFiles.map(
34
- (partialFile) =>
35
- new Promise(function (resolve, reject) {
36
- const matches = /^([^.]+).(hbs|css|js)$/.exec(partialFile);
37
- if (!matches) return resolve(true);
38
- const name = matches[1];
39
- fs.readFile(path.join(partialsDir, partialFile), "utf-8", (err, template) => {
40
- if (err) reject(err);
41
- hbs.registerPartial(name, template);
42
- resolve(true);
43
- });
44
- })
45
- );
46
-
47
- return Promise.all(generalPartialsPromises, baseProfilePromises);
48
- });
14
+ return hbs;
49
15
  }
50
16
 
51
17
  module.exports = importPartials;
@@ -2,14 +2,20 @@ const Handlebars = require("handlebars");
2
2
  const helpers = require("./helpers");
3
3
  const importPartials = require("./importPartials");
4
4
 
5
- // Add Custom Helpers to Handlebars
6
- for (let helper in helpers) Handlebars.registerHelper(helper, helpers[helper]);
7
-
8
- module.exports = new Promise(function (resolve, reject) {
9
- // Add Partials to Handlebars
10
- importPartials(Handlebars)
11
- .then(() => resolve(Handlebars))
12
- .catch((err) => {
13
- reject(err);
14
- });
15
- });
5
+ const sharedPartials = {
6
+ fonts: "fonts.css",
7
+ layout: "layout.hbs",
8
+ sidebar: "sidebar.hbs",
9
+ };
10
+
11
+ async function createHandlebars(partials = {}) {
12
+ const hbs = Handlebars.create();
13
+
14
+ for (const [name, helper] of Object.entries(helpers)) {
15
+ hbs.registerHelper(name, helper);
16
+ }
17
+
18
+ return importPartials(hbs, { ...partials, ...sharedPartials });
19
+ }
20
+
21
+ module.exports = createHandlebars;