@hegemonart/get-design-done 1.37.1 → 1.38.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +45 -0
- package/README.md +8 -0
- package/SKILL.md +1 -0
- package/agents/design-verifier.md +1 -1
- package/agents/ds-generator.md +74 -0
- package/agents/experiment-result-ingester.md +61 -0
- package/agents/user-research-synthesizer.md +65 -0
- package/connections/connections.md +7 -1
- package/connections/growthbook.md +110 -0
- package/connections/hotjar.md +110 -0
- package/connections/launchdarkly.md +83 -0
- package/connections/maze.md +130 -0
- package/connections/statsig.md +83 -0
- package/connections/usertesting.md +99 -0
- package/package.json +1 -1
- package/reference/design-variants.md +56 -0
- package/reference/ds-bootstrap-rubric.md +51 -0
- package/reference/registry.json +14 -0
- package/scripts/lib/ds/token-scale.cjs +88 -0
- package/scripts/lib/ds-arms/design-arms-store.cjs +119 -0
- package/skills/bootstrap-ds/SKILL.md +43 -0
- package/skills/brief/SKILL.md +8 -0
- package/skills/connections/SKILL.md +4 -4
- package/skills/connections/connections-onboarding.md +58 -4
- package/skills/design/SKILL.md +2 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# LaunchDarkly — Connection Specification
|
|
2
|
+
|
|
3
|
+
This file is the connection specification for LaunchDarkly within the get-design-done pipeline. It lives in `connections/` alongside other connection specs (see [`connections/slack.md`](slack.md) for the structural sibling — an API/env-based connection with a three-value probe and degrade-to-noop).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
LaunchDarkly is an **experiment-source** for the outcome-learning layer (Phase 38). GDD **reads** A/B experiment results from LaunchDarkly and feeds each variant→outcome into the `design_arms` posterior, so shipped design decisions get reinforced or discounted by what actually performed in production. GDD never runs, creates, edits, or stops experiments — it is strictly **read-only** (D-04). Reads degrade to a noop when unconfigured or disabled; outcome learning simply pauses and the pipeline never blocks.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
**Prerequisites:** read-only access to a LaunchDarkly project's experiment results — either a LaunchDarkly **API key** (a reader/viewer-scoped token, not a writer token) **or** an SDK key, **or** the LaunchDarkly MCP if it is installed in your runtime.
|
|
14
|
+
|
|
15
|
+
**Token (env, never committed):**
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
export LAUNCHDARKLY_API_KEY="<reader-scoped-api-or-sdk-key>"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Use the narrowest scope LaunchDarkly offers (reader/viewer). The key is a credential — never commit it (not in source, not in `.env`, not in config), never log it, rotate if exposed. GDD reads it from env only and never requests a write scope.
|
|
22
|
+
|
|
23
|
+
**Verification:**
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
test -n "${LAUNCHDARKLY_API_KEY}" && echo "launchdarkly key present" || echo "launchdarkly key absent"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Availability Probe
|
|
32
|
+
|
|
33
|
+
Probe is **MCP-first**, env-fallback, kill-switch-aware:
|
|
34
|
+
|
|
35
|
+
1. If `GDD_DISABLE_LAUNCHDARKLY=1` → short-circuit to `not_configured` (treated as disabled; never probe further).
|
|
36
|
+
2. Run `ToolSearch({ query: "launchdarkly" })`. If a LaunchDarkly MCP tool resolves → `launchdarkly: available`.
|
|
37
|
+
3. Else check the env key: `test -n "${LAUNCHDARKLY_API_KEY}"`.
|
|
38
|
+
- Non-empty → `launchdarkly: available`
|
|
39
|
+
- Empty → `launchdarkly: not_configured`
|
|
40
|
+
4. Source present (MCP or key) but a read errored at fetch time → `launchdarkly: unavailable`.
|
|
41
|
+
|
|
42
|
+
Write the `launchdarkly` status to `.design/STATE.md` `<connections>` after probing:
|
|
43
|
+
|
|
44
|
+
```xml
|
|
45
|
+
<connections>
|
|
46
|
+
launchdarkly: not_configured
|
|
47
|
+
</connections>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
| Value | Meaning |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `available` | LaunchDarkly MCP resolves OR `LAUNCHDARKLY_API_KEY` set, AND not disabled |
|
|
53
|
+
| `unavailable` | source present but a result read errored |
|
|
54
|
+
| `not_configured` | no MCP and no `LAUNCHDARKLY_API_KEY`, or `GDD_DISABLE_LAUNCHDARKLY=1` |
|
|
55
|
+
|
|
56
|
+
The kill-switch `GDD_DISABLE_LAUNCHDARKLY=1` forces `not_configured` regardless of MCP/key presence (mirrors the Phase 30 / 35.1 disable convention). `gsd-health` surfaces the state.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Pipeline Integration
|
|
61
|
+
|
|
62
|
+
LaunchDarkly contributes the **experiment-source** capability. The flow is read-only and one-directional (results in, never experiments out):
|
|
63
|
+
|
|
64
|
+
1. The probe marks `launchdarkly: available` in `.design/STATE.md`.
|
|
65
|
+
2. The experiment-result ingester (`agents/experiment-result-ingester.md`) reads completed A/B results from LaunchDarkly — variant identifiers plus their measured metric outcomes.
|
|
66
|
+
3. It maps each variant to the matching `design_arms` arm and records the outcome (win / loss / lift) against that arm's posterior, so the next design decision is informed by production evidence.
|
|
67
|
+
4. For each mapped result it emits an `experiment_result` event into the pipeline's event stream for downstream learning and audit.
|
|
68
|
+
|
|
69
|
+
The ingester reads results only; it issues no experiment-creation, assignment, or mutation calls against LaunchDarkly (D-04).
|
|
70
|
+
|
|
71
|
+
**Injectable fetch (hermetic tests):** the ingester takes an injectable `fetchImpl` (defaulting to the resolved MCP tool or global `fetch`). Tests pass a stub `fetchImpl` so `npm test` exercises the variant→outcome mapping with no real egress — no live LaunchDarkly call in CI. There is **no bundled LaunchDarkly SDK and no new dependency**; reads go through the MCP tool or the injectable `fetchImpl`.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Fallback Behavior
|
|
76
|
+
|
|
77
|
+
`not_configured` (no MCP, no key) or disabled (`GDD_DISABLE_LAUNCHDARKLY=1`) → the experiment-source **degrades to a noop**: the ingester is skipped, no `experiment_result` events are emitted, and the `design_arms` posterior simply does not get the outcome update this cycle. Design decisions still ship — they just rely on prior evidence instead of fresh experiment results.
|
|
78
|
+
|
|
79
|
+
A read failure when a source *is* present → `launchdarkly: unavailable`; that cycle's ingestion is skipped (no error surfaced to the pipeline) and retried on the next probe. The ingester returns a skipped/empty result and never throws, so outcome learning is best-effort and **never blocks the pipeline** (mirrors the notify degrade-to-noop in [`connections/slack.md`](slack.md)).
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
Do NOT edit the connection index here — the 38 wiring plan adds the Active-Connections row + the experiment-source matrix column.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Maze — Connection Specification
|
|
2
|
+
|
|
3
|
+
This file is the connection specification for Maze within the get-design-done pipeline. It lives in `connections/` alongside other connection specs (the structural template is [`connections/slack.md`](slack.md)). See `connections/connections.md` for the full connection index and capability matrix (the maze row is added at the Phase 38 wiring closeout).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Maze is a **user-research source** for the Outcome-Driven Adaptation layer (Phase 38). It is the usability-testing / prototype-testing counterpart to a notification surface: GDD does not *push* to Maze — it **reads** completed test reports and their aggregate metrics (read-only), then feeds the findings into the brief as prior research. The signal answers "how did real users actually do on this flow?" — misclick rate, time-on-task, completion rate — so the design stage starts from evidence instead of assumption.
|
|
8
|
+
|
|
9
|
+
**CRITICAL — PII guard (D-05):** every payload read from Maze MUST pass through `scripts/lib/pseudonymize.cjs` **before** it reaches any agent context. Test reports can carry participant-identifying fields (tester names, emails, free-text answers, session URLs). Pseudonymization is the single mandatory gate between Maze and the LLM; there is no path that skips it. This is read-only *plus* identity-scrubbed — the two properties together are the contract.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
**Prerequisites:** a Maze account with at least one completed test (a usability or prototype test that has collected responses), and a Maze **API token** with read access.
|
|
16
|
+
|
|
17
|
+
**Token (env, never committed):**
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
export MAZE_API_KEY="<your-maze-api-token>"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`MAZE_API_KEY` is a **read-only** credential — GDD uses it solely to GET indexed insights and aggregate metrics (report summaries, per-task rates). GDD never writes to Maze, never creates or modifies tests, and never pulls **raw session recordings** (video/click-stream) — only the indexed, already-aggregated insights and metrics. Treat the token like a password: never commit it (not in source, not in `.env`, not in config), never log it, rotate it if exposed. GDD reads it from env only.
|
|
24
|
+
|
|
25
|
+
**Verification:**
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
test -n "${MAZE_API_KEY}" && echo "maze token present" || echo "maze token absent"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Availability Probe
|
|
34
|
+
|
|
35
|
+
Maze may be reachable either via an MCP (if one is registered in the host) or via its read-only HTTP API keyed by `MAZE_API_KEY`. Probe **MCP-first**, then fall back to the env check.
|
|
36
|
+
|
|
37
|
+
**Step M1 — MCP presence (ToolSearch-only, no tool call, no API cost):**
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
ToolSearch({ query: "maze", max_results: 5 })
|
|
41
|
+
→ Non-empty result → maze: available
|
|
42
|
+
→ Empty result → proceed to Step M2
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Step M2 — API token check:**
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
test -n "${MAZE_API_KEY}"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- Non-empty AND not disabled → `maze: available`
|
|
52
|
+
- Empty → `maze: not_configured`
|
|
53
|
+
- Present but a read errored at fetch time → `maze: unavailable`
|
|
54
|
+
|
|
55
|
+
**Kill-switch:** Maze reads are a noop when `GDD_DISABLE_MAZE=1` (env), regardless of token/MCP presence — the probe resolves to `not_configured`. `gsd-health` surfaces the state (mirrors the Phase 30 / 35.1 health-mirror pattern).
|
|
56
|
+
|
|
57
|
+
**Write `maze` status to `.design/STATE.md` `<connections>` after probing:**
|
|
58
|
+
|
|
59
|
+
```xml
|
|
60
|
+
<connections>
|
|
61
|
+
maze: not_configured
|
|
62
|
+
</connections>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
| Value | Meaning |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `available` | `maze` MCP registered, OR `MAZE_API_KEY` set — AND not disabled |
|
|
68
|
+
| `unavailable` | reachable but a read errored at fetch time |
|
|
69
|
+
| `not_configured` | no `maze` MCP and no `MAZE_API_KEY`, or `GDD_DISABLE_MAZE=1` |
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## What GDD reads
|
|
74
|
+
|
|
75
|
+
Read-only, indexed surface only — the parity inverse of the slack.md "What GDD sends" table:
|
|
76
|
+
|
|
77
|
+
| Surface | Read? | Pipeline use |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| Report summary (per-test insights) | yes | synthesized into `<prior-research>` |
|
|
80
|
+
| Aggregate metrics (misclick rate, time-on-task, completion) | yes | the headline outcome signal for the brief |
|
|
81
|
+
| Tester names / emails / free-text answers | only via pseudonymize | identity fields scrubbed to placeholders before any agent sees them |
|
|
82
|
+
| Raw session recordings (video / click-stream) | **never** | out of scope — highest-PII surface, not fetched |
|
|
83
|
+
|
|
84
|
+
## Pipeline Integration
|
|
85
|
+
|
|
86
|
+
Maze is a **user-research** input to the discover/plan boundary, consumed only when `maze: available`. The flow is strictly one-directional and identity-scrubbed:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
Maze read (read-only)
|
|
90
|
+
reports + metrics:
|
|
91
|
+
- misclick rate (wrong first/early taps per task)
|
|
92
|
+
- time-on-task (median seconds to complete)
|
|
93
|
+
- completion rate (% of testers who reached the goal)
|
|
94
|
+
→ scripts/lib/pseudonymize.cjs ← MANDATORY, runs FIRST, before any agent sees the payload
|
|
95
|
+
→ agents/user-research-synthesizer.md
|
|
96
|
+
→ brief-grade insights (ranked, de-duplicated, cited)
|
|
97
|
+
→ the brief's <prior-research> block
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
1. **Read** — fetch indexed report summaries + aggregate metrics for the relevant test(s) via the MCP tool (verify the name via ToolSearch) or the read-only API. Never fetch raw recordings.
|
|
101
|
+
2. **Pseudonymize FIRST** — pass the *entire* fetched payload through `scripts/lib/pseudonymize.cjs` (the Phase 30 R1..R7 rule set: git-identity, paths, hostname, repo-origin, env-values, emails, IPs). Names, emails, and identity-correlatable strings in tester free-text become placeholders. **No payload reaches the synthesizer un-pseudonymized — this is the single egress chokepoint, the same discipline `connections/slack.md` applies via `redact.cjs`.**
|
|
102
|
+
3. **Synthesize** — [`agents/user-research-synthesizer.md`](../agents/user-research-synthesizer.md) turns the scrubbed metrics + report text into brief-grade insights: which tasks failed, where misclicks cluster, which steps are slow, framed against the method guidance in `reference/user-research.md` (so a low-n test is reported as directional, not as a statistically reliable rate).
|
|
103
|
+
4. **Inject** — the synthesized insights land in the brief's `<prior-research>` block, so the design stage opens with observed evidence ("testers misclicked the secondary CTA 41% of the time; completion held at 78%") rather than assumption.
|
|
104
|
+
|
|
105
|
+
Honest-framing note: pseudonymization reduces identity correlation, it does not eliminate it (writing style and free-text content can still re-identify). Findings are presented as **directional research signal**, never as anonymized fact, and sample size is always carried through from `reference/user-research.md`'s sample-size heuristics.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Fallback Behavior
|
|
110
|
+
|
|
111
|
+
Maze is an **enhancement, never a gate** (degrade-to-noop, D-03). When `maze: not_configured`, `maze: unavailable`, or `GDD_DISABLE_MAZE=1`:
|
|
112
|
+
|
|
113
|
+
- The discover/plan boundary proceeds with **no** `<prior-research>` Maze block — the brief simply omits the prior-research signal (or carries other research sources if present).
|
|
114
|
+
- The synthesizer is **not** invoked for Maze; nothing is fetched, nothing is pseudonymized, nothing is logged.
|
|
115
|
+
- The pipeline **never blocks** on Maze availability — a missing user-research signal is a quality reduction, not an error. The design stage falls back to the assumption-driven path it would have used before any Maze data existed.
|
|
116
|
+
|
|
117
|
+
A read that errors mid-fetch downgrades to `unavailable` and is treated exactly like `not_configured`: skip, note the absence, continue.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## PII + Privacy
|
|
122
|
+
|
|
123
|
+
- **Pseudonymize before context — non-negotiable.** Every byte read from Maze passes through `scripts/lib/pseudonymize.cjs` before it is placed in any prompt, brief, or agent input. There is no bypass path; the synthesizer only ever receives scrubbed text. This mirrors the data-minimization + pseudonymization ethics in `reference/user-research.md` (names replaced with participant IDs, identifying details removed from quotes before sharing).
|
|
124
|
+
- **Read-only — no write-back.** GDD never sends anything to Maze. The token is GET-only; no test, response, or annotation is ever created or modified.
|
|
125
|
+
- **No raw recordings.** Only indexed insights and aggregate metrics are read. Session-recording video and raw click-streams are never fetched — they are the highest-risk PII surface and are out of scope by design.
|
|
126
|
+
- **No PII in logs or events.** The `maze` connection emits no participant identifiers into logs, the event stream, or `.design/STATE.md`. STATE.md carries only the three-value status token (`available` / `unavailable` / `not_configured`) — never report contents. The pseudonymization replacements log is itself length-truncated so a stray un-scrubbed value cannot leak at full length.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
Do NOT edit the connection index here — the 38 wiring plan adds the Active-Connections row + the experiment-source matrix column.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Statsig — Connection Specification
|
|
2
|
+
|
|
3
|
+
This file is the connection specification for Statsig within the get-design-done pipeline. It lives in `connections/` alongside other connection specs (see [`connections/slack.md`](slack.md) for the structural sibling — an API/env-based connection with a three-value probe and degrade-to-noop, and [`connections/launchdarkly.md`](launchdarkly.md) for the experiment-source sibling this file mirrors).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Statsig is an **experiment-source** for the outcome-learning layer (Phase 38). GDD **reads** A/B experiment results and feature-gate / Pulse metric outcomes from Statsig and feeds each variant→outcome into the `design_arms` posterior, so shipped design decisions get reinforced or discounted by what actually performed in production. GDD never runs, creates, edits, starts, or stops experiments or gates — it is strictly **read-only** (D-04). Reads degrade to a noop when unconfigured or disabled; outcome learning simply pauses and the pipeline never blocks.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
**Prerequisites:** read-only access to a Statsig project's experiment / Pulse results — either a Statsig **console API key** (a read-scoped key, not a server-write key) exported as `STATSIG_API_KEY`, **or** the Statsig MCP if it is installed in your runtime.
|
|
14
|
+
|
|
15
|
+
**Token (env, never committed):**
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
export STATSIG_API_KEY="<read-scoped-console-api-key>"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Use the narrowest scope Statsig offers (a read-only console key). The key is a credential — never commit it (not in source, not in `.env`, not in config), never log it, rotate if exposed. GDD reads it from env only and never requests a write scope.
|
|
22
|
+
|
|
23
|
+
**Verification:**
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
test -n "${STATSIG_API_KEY}" && echo "statsig key present" || echo "statsig key absent"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Availability Probe
|
|
32
|
+
|
|
33
|
+
Probe is **MCP-first**, env-fallback, kill-switch-aware:
|
|
34
|
+
|
|
35
|
+
1. If `GDD_DISABLE_STATSIG=1` → short-circuit to `not_configured` (treated as disabled; never probe further).
|
|
36
|
+
2. Run `ToolSearch({ query: "statsig" })`. If a Statsig MCP tool resolves → `statsig: available`.
|
|
37
|
+
3. Else check the env key: `test -n "${STATSIG_API_KEY}"`.
|
|
38
|
+
- Non-empty → `statsig: available`
|
|
39
|
+
- Empty → `statsig: not_configured`
|
|
40
|
+
4. Source present (MCP or key) but a read errored at fetch time → `statsig: unavailable`.
|
|
41
|
+
|
|
42
|
+
Write the `statsig` status to `.design/STATE.md` `<connections>` after probing:
|
|
43
|
+
|
|
44
|
+
```xml
|
|
45
|
+
<connections>
|
|
46
|
+
statsig: not_configured
|
|
47
|
+
</connections>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
| Value | Meaning |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `available` | Statsig MCP resolves OR `STATSIG_API_KEY` set, AND not disabled |
|
|
53
|
+
| `unavailable` | source present but a result read errored |
|
|
54
|
+
| `not_configured` | no MCP and no `STATSIG_API_KEY`, or `GDD_DISABLE_STATSIG=1` |
|
|
55
|
+
|
|
56
|
+
The kill-switch `GDD_DISABLE_STATSIG=1` forces `not_configured` regardless of MCP/key presence (mirrors the Phase 30 / 35.1 disable convention). `gsd-health` surfaces the state.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Pipeline Integration
|
|
61
|
+
|
|
62
|
+
Statsig contributes the **experiment-source** capability. The flow is read-only and one-directional (results in, never experiments out):
|
|
63
|
+
|
|
64
|
+
1. The probe marks `statsig: available` in `.design/STATE.md`.
|
|
65
|
+
2. The experiment-result ingester (`agents/experiment-result-ingester.md`) reads completed experiment results and feature-gate / Pulse metric outcomes from Statsig — variant (group) identifiers plus their measured metric lift.
|
|
66
|
+
3. It maps each variant to the matching `design_arms` arm and records the outcome (win / loss / lift) against that arm's posterior, so the next design decision is informed by production evidence.
|
|
67
|
+
4. For each mapped result it emits an `experiment_result` event into the pipeline's event stream for downstream learning and audit.
|
|
68
|
+
|
|
69
|
+
The ingester reads results only; it issues no experiment-creation, gate-toggle, assignment, or mutation calls against Statsig (D-04).
|
|
70
|
+
|
|
71
|
+
**Injectable fetch (hermetic tests):** the ingester takes an injectable `fetchImpl` (defaulting to the resolved MCP tool or global `fetch`). Tests pass a stub `fetchImpl` so `npm test` exercises the variant→outcome mapping with no real egress — no live Statsig call in CI. There is **no bundled Statsig SDK and no new dependency**; reads go through the MCP tool or the injectable `fetchImpl`.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Fallback Behavior
|
|
76
|
+
|
|
77
|
+
`not_configured` (no MCP, no key) or disabled (`GDD_DISABLE_STATSIG=1`) → the experiment-source **degrades to a noop**: the ingester is skipped, no `experiment_result` events are emitted, and the `design_arms` posterior simply does not get the outcome update this cycle. Design decisions still ship — they just rely on prior evidence instead of fresh experiment results.
|
|
78
|
+
|
|
79
|
+
A read failure when a source *is* present → `statsig: unavailable`; that cycle's ingestion is skipped (no error surfaced to the pipeline) and retried on the next probe. The ingester returns a skipped/empty result and never throws, so outcome learning is best-effort and **never blocks the pipeline** (mirrors the notify degrade-to-noop in [`connections/slack.md`](slack.md)). Statsig and [`connections/launchdarkly.md`](launchdarkly.md) are parity experiment-sources — the read-only contract is identical; only the metric-payload shape the ingester maps differs.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
Do NOT edit the connection index here — the 38 wiring plan adds the Active-Connections row + the experiment-source matrix column.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# UserTesting — Connection Specification
|
|
2
|
+
|
|
3
|
+
This file is the connection specification for UserTesting within the get-design-done pipeline. It lives in `connections/` alongside other connection specs (see [`connections/slack.md`](slack.md) for the structural sibling — an API/env-based connection with a three-value probe and degrade-to-noop, and [`connections/launchdarkly.md`](launchdarkly.md) for the Phase-38 read-only source pattern).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
UserTesting is a **user-research source** for the outcome-learning layer (Phase 38). GDD **reads** completed test reports and study insights from UserTesting and feeds brief-grade findings into the design brief, so design decisions are grounded in what real participants did and said. GDD never schedules, launches, edits, or stops studies — it is strictly **read-only**. Reads degrade to a noop when unconfigured or disabled; the brief simply ships without a prior-research block and the pipeline never blocks.
|
|
8
|
+
|
|
9
|
+
**CRITICAL (PII guard, D-05): every user-research payload MUST pass through [`scripts/lib/pseudonymize.cjs`](../scripts/lib/pseudonymize.cjs) BEFORE it reaches any agent context.** Participant identities, emails, and faces/voices captured in transcripts are PII. No raw report, transcript, or recording text enters an agent prompt, an event, or a log until it has been pseudonymized. This is mandatory and non-negotiable — see the dedicated PII + Privacy section below.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
**Prerequisites:** read-only access to a UserTesting workspace's completed reports and study insights — either a UserTesting **API key** (a reader/viewer-scoped token, not a writer/admin token) or read-only OAuth, **or** the UserTesting MCP if it is installed in your runtime.
|
|
16
|
+
|
|
17
|
+
**Token (env, never committed):**
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
export USERTESTING_API_KEY="<reader-scoped-api-or-oauth-token>"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Use the narrowest scope UserTesting offers (reader/viewer). The key is a credential — never commit it (not in source, not in `.env`, not in config), never log it, rotate if exposed. GDD reads it from env only and never requests a write scope.
|
|
24
|
+
|
|
25
|
+
**No raw session-replay video storage.** GDD reads **indexed insights** — text findings, tagged highlights, severity/frequency annotations — never the underlying session-replay video. Recordings are never downloaded, never cached, and never stored locally. Only the derived, pseudonymized text crosses into the pipeline.
|
|
26
|
+
|
|
27
|
+
**Verification:**
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
test -n "${USERTESTING_API_KEY}" && echo "usertesting key present" || echo "usertesting key absent"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Availability Probe
|
|
36
|
+
|
|
37
|
+
Probe is **MCP-first**, env-fallback, kill-switch-aware:
|
|
38
|
+
|
|
39
|
+
1. If `GDD_DISABLE_USERTESTING=1` → short-circuit to `not_configured` (treated as disabled; never probe further).
|
|
40
|
+
2. Run `ToolSearch({ query: "usertesting" })`. If a UserTesting MCP tool resolves → `usertesting: available`.
|
|
41
|
+
3. Else check the env key: `test -n "${USERTESTING_API_KEY}"`.
|
|
42
|
+
- Non-empty → `usertesting: available`
|
|
43
|
+
- Empty → `usertesting: not_configured`
|
|
44
|
+
4. Source present (MCP or key) but a read errored at fetch time → `usertesting: unavailable`.
|
|
45
|
+
|
|
46
|
+
Write the `usertesting` status to `.design/STATE.md` `<connections>` after probing:
|
|
47
|
+
|
|
48
|
+
```xml
|
|
49
|
+
<connections>
|
|
50
|
+
usertesting: not_configured
|
|
51
|
+
</connections>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
| Value | Meaning |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `available` | UserTesting MCP resolves OR `USERTESTING_API_KEY` set, AND not disabled |
|
|
57
|
+
| `unavailable` | source present but a result read errored |
|
|
58
|
+
| `not_configured` | no MCP and no `USERTESTING_API_KEY`, or `GDD_DISABLE_USERTESTING=1` |
|
|
59
|
+
|
|
60
|
+
The kill-switch `GDD_DISABLE_USERTESTING=1` forces `not_configured` regardless of MCP/key presence (mirrors the Phase 30 / 35.1 disable convention). `gsd-health` surfaces the state.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Pipeline Integration
|
|
65
|
+
|
|
66
|
+
UserTesting contributes the **user-research source** capability. The flow is read-only and one-directional (insights in, never studies out):
|
|
67
|
+
|
|
68
|
+
1. The probe marks `usertesting: available` in `.design/STATE.md`.
|
|
69
|
+
2. The reader pulls completed test reports / study insights from UserTesting (indexed text only — never video).
|
|
70
|
+
3. **PII pseudonymization runs FIRST.** Every report payload is passed through [`scripts/lib/pseudonymize.cjs`](../scripts/lib/pseudonymize.cjs) before anything else touches it — participant names, emails, and identity-correlatable fields are scrubbed to placeholders. The raw payload is never forwarded.
|
|
71
|
+
4. The pseudonymized payload is handed to [`agents/user-research-synthesizer.md`](../agents/user-research-synthesizer.md), which distills it into **brief-grade insights** — each shaped as `{ finding, frequency, severity }` (the finding text, how many participants hit it, and how impactful it was).
|
|
72
|
+
5. Those insights are written into the design brief's `<prior-research>` block, so the brief carries real-participant evidence alongside design direction.
|
|
73
|
+
|
|
74
|
+
The reader and synthesizer read insights only; they issue no study-creation, scheduling, or mutation calls against UserTesting.
|
|
75
|
+
|
|
76
|
+
**Injectable fetch (hermetic tests):** the reader takes an injectable `fetchImpl` (defaulting to the resolved MCP tool or global `fetch`). Tests pass a stub `fetchImpl` so `npm test` exercises the report → pseudonymize → synthesize path with no real egress — no live UserTesting call in CI. There is **no bundled UserTesting SDK and no new dependency**; reads go through the MCP tool or the injectable `fetchImpl`.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Fallback Behavior
|
|
81
|
+
|
|
82
|
+
`not_configured` (no MCP, no key) or disabled (`GDD_DISABLE_USERTESTING=1`) → the user-research source **degrades to a noop**: the reader is skipped, no payload is fetched or pseudonymized, the synthesizer is not invoked, and the brief's `<prior-research>` block is simply omitted. Design decisions still ship — they just proceed without fresh participant evidence this cycle.
|
|
83
|
+
|
|
84
|
+
A read failure when a source *is* present → `usertesting: unavailable`; that cycle's read is skipped (no error surfaced to the pipeline) and retried on the next probe. The reader returns a skipped/empty result and never throws, so user-research enrichment is best-effort and **never blocks the pipeline** (mirrors the notify degrade-to-noop in [`connections/slack.md`](slack.md)).
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## PII + Privacy
|
|
89
|
+
|
|
90
|
+
User-research data is the highest-sensitivity input GDD ingests. Treat every payload as PII until proven otherwise.
|
|
91
|
+
|
|
92
|
+
- **Pseudonymize before context (mandatory).** No raw UserTesting payload — report body, transcript text, highlight note — enters an agent prompt, the synthesizer, the event stream, or any STATE/brief artifact until it has passed through [`scripts/lib/pseudonymize.cjs`](../scripts/lib/pseudonymize.cjs). Pseudonymization is the first step after the read, before any other handling. There is no bypass path.
|
|
93
|
+
- **No PII in logs or events.** Participant identities, emails, and identity-correlatable fields never appear in logs, the `experiment_result`/research event stream, or error output. Only pseudonymized, brief-grade text is ever emitted downstream.
|
|
94
|
+
- **Indexed insights, not raw recordings.** GDD reads derived text insights only — never session-replay video, audio, or screen captures of participants' faces/voices. Recordings are never downloaded, cached, or stored; the source of truth stays in UserTesting.
|
|
95
|
+
- **Pseudonymization is not anonymization.** Identity correlation is reduced, not eliminated — side-channel signals may still re-identify. The synthesizer keeps only the `{ finding, frequency, severity }` shape it needs and discards the rest, minimizing what is retained.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
Do NOT edit the connection index here — the 38 wiring plan adds the Active-Connections row + the experiment-source matrix column.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hegemonart/get-design-done",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.38.0",
|
|
4
4
|
"description": "A design-quality pipeline for AI coding agents: brief, plan, implement, and verify UI work against your design system.",
|
|
5
5
|
"author": "Hegemon",
|
|
6
6
|
"homepage": "https://github.com/hegemonart/get-design-done",
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Design Variants + the `design_arms` Outcome Loop
|
|
2
|
+
|
|
3
|
+
How `/gdd:design --variants N` generates competing, hypothesis-tagged design variants, and how external outcomes (A/B experiments + user research) feed the `design_arms` posterior so the design stage learns **which patterns win with users** — not just which pass lint/test. The posterior math lives in `scripts/lib/ds-arms/design-arms-store.cjs`; the ingest agents are `agents/experiment-result-ingester.md` (A/B) + `agents/user-research-synthesizer.md` (research).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## The variant tag
|
|
8
|
+
|
|
9
|
+
When `--variants N` is set (default **N = 2**, the A/B baseline), the design stage emits N competing variants, each carrying an **explicit, testable hypothesis**:
|
|
10
|
+
|
|
11
|
+
```html
|
|
12
|
+
<variant id="A" component="primary-cta" pattern="cta-bold-filled"
|
|
13
|
+
hypothesis="A bolder, filled primary CTA raises checkout conversion" />
|
|
14
|
+
<variant id="B" component="primary-cta" pattern="cta-outline-secondary"
|
|
15
|
+
hypothesis="A lower-pressure outline CTA reduces accidental taps" />
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- **`id`** — A, B, C… (stable within a cycle).
|
|
19
|
+
- **`component`** — the `component_type` the arm is keyed on (e.g. `primary-cta`, `pricing-card`, `signup-form`).
|
|
20
|
+
- **`pattern`** — a short pattern slug → hashed to `variant_pattern_hash` via `variantKey(component, pattern)`.
|
|
21
|
+
- **`hypothesis`** — a falsifiable prediction in user-outcome terms (conversion, completion, error rate). This is the contract an A/B test or research finding later resolves.
|
|
22
|
+
|
|
23
|
+
A variant without a hypothesis is not a variant — it's an opinion. The stage refuses to tag one without it.
|
|
24
|
+
|
|
25
|
+
## The `design_arms` posterior (advisory)
|
|
26
|
+
|
|
27
|
+
Each `(component_type, variant_pattern_hash)` is an **arm** with a Beta posterior, conservative **Beta(2, 8)** prior (posterior mean 0.2 — a pattern must EARN trust from real outcomes; the Phase 29 fairness-gate pattern). Distinct from the routing bandit's `routing_arms` (`scripts/lib/bandit-router.cjs`) — design_arms learn from **users**, not from internal pass/fail.
|
|
28
|
+
|
|
29
|
+
**Before generation**, the design stage may consult the posterior:
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
const { variantKey, pull } = require('scripts/lib/ds-arms/design-arms-store.cjs');
|
|
33
|
+
const arm = pull('primary-cta', variantKey('primary-cta', 'cta-bold-filled'));
|
|
34
|
+
// arm.mean = 0.70 → "cta-bold-filled has a 70% win rate — bias toward it"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**D-03 — advisory, never directive.** The posterior *biases* which patterns the stage proposes (and how it orders variants), but the **user always wins**: if the posterior favors A and the user asks for B, generate B. Surface the posterior as a note ("heads-up: pattern A has won 7/10 prior experiments"), never as a veto.
|
|
38
|
+
|
|
39
|
+
## Closing the loop (outcome ingest)
|
|
40
|
+
|
|
41
|
+
1. **A/B** — `experiment-result-ingester` reads a LaunchDarkly / Statsig / GrowthBook result, maps each variant to a win/lose, and calls `observe(component, hash, { won, source: 'ab' })`. Emits an `experiment_result` event (Phase 22 chain).
|
|
42
|
+
2. **Research** — `user-research-synthesizer` reads UserTesting / Maze / Hotjar reports (**pseudonymized first** — D-05), extracts findings, and folds qualitative signal as `observe(..., { source: 'research', weight })`.
|
|
43
|
+
3. **Dev-time** (Phase 47, later) — live-accepted variants observe with `source: 'dev_time'` under a conservative discount.
|
|
44
|
+
|
|
45
|
+
`observe(won: true)` increments `alpha`; `won: false` increments `beta`. Over many experiments the posterior mean converges on the pattern's true win rate, and the design stage's bias tracks reality.
|
|
46
|
+
|
|
47
|
+
## Store API (summary)
|
|
48
|
+
|
|
49
|
+
| Function | Purpose |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `variantKey(componentType, pattern)` | stable arm key (inline FNV-1a; dependency-free) |
|
|
52
|
+
| `pull(componentType, hash)` | the arm's `{ alpha, beta, mean, count, seen }` (Beta(2,8) prior if unseen) |
|
|
53
|
+
| `observe(componentType, hash, { won, weight?, source? })` | fold one outcome; persists atomically |
|
|
54
|
+
| `all()` | every arm + posterior mean (for ranking) |
|
|
55
|
+
|
|
56
|
+
Persists to `.design/telemetry/design-arms.json` (atomic write); never touches `posterior.json` (the routing bandit).
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Greenfield Design-System Bootstrap Rubric
|
|
2
|
+
|
|
3
|
+
Emission rules for `/gdd:bootstrap-ds` + `agents/ds-generator.md` — how to turn a brand input (a primary color + optional secondary + tone tags + target framework) into a coherent token system, **without inventing a brand**. The deterministic math lives in `scripts/lib/ds/token-scale.cjs`; the color-theory grounding lives in `reference/color-theory.md`. This file is the rulebook the generator obeys.
|
|
4
|
+
|
|
5
|
+
GDD does not author a brand identity (logomarks, voice) — it emits a **starter token system + a few proof components** a greenfield project can build on.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Color
|
|
10
|
+
|
|
11
|
+
- **Primary → 9 tints/shades.** Convert the brand primary to OKLCH `{l, c, h}`, then call `oklchScale(primary)` → 9 stops (`100`…`900`) emitted as native CSS `oklch()`. Stop `500` is the primary exactly; lighter stops interpolate L toward white with damped chroma; darker stops toward black. Native `oklch()` — **no hex conversion, no color library**.
|
|
12
|
+
- **Never auto-generate more than 2 brand colors.** Primary is always emitted. A **secondary** scale is emitted **only if the user supplies one**. Never invent a third brand hue.
|
|
13
|
+
- **Neutrals** — a low-chroma gray ramp (chroma ≤ 0.02) sharing the primary's hue for a subtle tint, or pure neutral (`c = 0`). 9 stops via `oklchScale({ l, c: 0.012, h: primary.h })`.
|
|
14
|
+
- **Semantic colors** — success / warning / danger / info derived at fixed hues (≈145 / 85 / 25 / 255) at the brand's mid-lightness + chroma, so they sit in the same family. These are functional, not "brand" colors (they do not count against the ≤2 rule).
|
|
15
|
+
- **Contrast** — every text-on-surface pairing must clear WCAG AA (see `reference/color-theory.md`); the generator checks the chosen `500`/`700` against white/`50`.
|
|
16
|
+
|
|
17
|
+
## Typography
|
|
18
|
+
|
|
19
|
+
- **Modular scale** via `typeScale(baseRem, ratio, steps)`. Base `1rem` (16px). **Ratio ∈ {1.2 (minor third), 1.25 (major third), 1.333 (perfect fourth)}** — pick by tone (calm → 1.2, default → 1.25, editorial/bold → 1.333).
|
|
20
|
+
- Emit named steps (`xs`…`5xl`) from the scale; line-height pairs (tight 1.2 for headings, 1.5 for body).
|
|
21
|
+
- Font family is a **slot**, not a choice — emit a system-font stack placeholder + a `--font-sans` / `--font-mono` variable the user swaps. GDD does not pick a typeface (brand territory).
|
|
22
|
+
|
|
23
|
+
## Spacing
|
|
24
|
+
|
|
25
|
+
- **4pt or 8pt baseline** via `spacingScale(basePx, count)` (8pt is the default; 4pt for dense/data UIs). Emit the standard `[1,2,3,4,6,8,12,16]` multiples as `--space-*`.
|
|
26
|
+
|
|
27
|
+
## Radius + Motion
|
|
28
|
+
|
|
29
|
+
- **Radius** via `radiusScale(basePx)` → `sm/md/lg/xl/full` (`full = 9999` pill).
|
|
30
|
+
- **Motion defaults** — durations `fast 150ms / base 250ms / slow 400ms`; easing `ease-out` for enters, a standard curve for moves; **always** respect `prefers-reduced-motion` (emit the media-query guard).
|
|
31
|
+
|
|
32
|
+
## The 3 variants (D-02 — user picks one)
|
|
33
|
+
|
|
34
|
+
The generator emits three coherent variants; the user picks ONE before first-component scaffolding:
|
|
35
|
+
|
|
36
|
+
| Variant | Chroma | Type ratio | Spacing | Radius | Feel |
|
|
37
|
+
|---|---|---|---|---|---|
|
|
38
|
+
| **conservative** | damped (×0.8) | 1.2 | 8pt | sm (4) | calm, corporate, dense-friendly |
|
|
39
|
+
| **balanced** (default) | as given | 1.25 | 8pt | md (8) | versatile default |
|
|
40
|
+
| **bold** | boosted (×1.15, clamped) | 1.333 | 8pt | lg (12) | expressive, marketing-forward |
|
|
41
|
+
|
|
42
|
+
## Emission format
|
|
43
|
+
|
|
44
|
+
- Emit **CSS custom properties** (`:root { --color-primary-500: oklch(...); --space-4: 16px; ... }`) as the canonical artifact, plus a target-framework mapping:
|
|
45
|
+
- **web (default)** — a Tailwind `theme.extend` block (or shadcn CSS variables) + the `:root` tokens.
|
|
46
|
+
- **native** — route the token set through `reference/native-platforms.md` (Phase 34) for SwiftUI / Compose / Flutter theme objects.
|
|
47
|
+
- Tokens are **named by role, not value** (`--color-primary-500`, not `--blue-500`) so a rebrand is a one-line hue change.
|
|
48
|
+
|
|
49
|
+
## First-component scaffolding (proof artifact)
|
|
50
|
+
|
|
51
|
+
After the user picks a variant, emit **button + input + card** in the detected target framework, consuming only the emitted tokens — a proof the system is coherent, not a full component library (that is out of scope).
|
package/reference/registry.json
CHANGED
|
@@ -972,6 +972,20 @@
|
|
|
972
972
|
"type": "heuristic",
|
|
973
973
|
"phase": 36.3,
|
|
974
974
|
"description": "Phase 36.3 Tier-3 conversational-UI patterns (voice + chatbot): voice-flow no-input/no-match reprompts + confirmation + human handoff, multi-turn dialogue (context carryover, slot-filling, repair), prompt-as-UX (the assistant persona/tone/boundaries as a versioned design artifact), chatbot empty-states + suggested replies, voice-first onboarding, error recovery + accessibility (transcripts/captions). Carries Detection signals + an Audit checklist; loaded by design-context-builder for the conversational project type. CLI/REPL UX out of scope."
|
|
975
|
+
},
|
|
976
|
+
{
|
|
977
|
+
"name": "ds-bootstrap-rubric",
|
|
978
|
+
"path": "reference/ds-bootstrap-rubric.md",
|
|
979
|
+
"type": "heuristic",
|
|
980
|
+
"phase": 37.2,
|
|
981
|
+
"description": "Phase 37.2 greenfield DS emission rules for /gdd:bootstrap-ds + agents/ds-generator.md: primary→9 OKLCH tints (native oklch(), no color library), never >2 brand colors, neutrals + semantic colors, modular type scale (ratio 1.2/1.25/1.333), 4pt/8pt spacing, radius + motion defaults, the 3 variants (conservative/balanced/bold), role-named CSS-custom-property emission + framework mapping, and button/input/card proof scaffolding. Deterministic math in scripts/lib/ds/token-scale.cjs."
|
|
982
|
+
},
|
|
983
|
+
{
|
|
984
|
+
"name": "design-variants",
|
|
985
|
+
"path": "reference/design-variants.md",
|
|
986
|
+
"type": "heuristic",
|
|
987
|
+
"phase": 38,
|
|
988
|
+
"description": "Phase 38 design-variants schema + the design_arms outcome loop: /gdd:design --variants N emits N hypothesis-tagged competing variants (<variant id component pattern hypothesis>); each (component_type, variant_pattern_hash) is a Beta(2,8) arm in scripts/lib/ds-arms/design-arms-store.cjs that learns which patterns win with USERS from A/B (experiment-result-ingester) + user-research (user-research-synthesizer) outcomes. Advisory not directive (the user always wins, D-03). Distinct from the routing bandit."
|
|
975
989
|
}
|
|
976
990
|
]
|
|
977
991
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* scripts/lib/ds/token-scale.cjs — Phase 37.2 greenfield token-scale generator.
|
|
4
|
+
*
|
|
5
|
+
* Pure + dep-free (D-01): zero `require`, no color-conversion library. Emits native CSS
|
|
6
|
+
* `oklch(L C H)` strings — modern browsers render OKLCH directly, so no OKLab→sRGB→hex
|
|
7
|
+
* conversion is needed. Deterministic: same input → byte-identical output (hermetic tests).
|
|
8
|
+
*
|
|
9
|
+
* - oklchScale(primary, opts?) → 9 tint/shade stops {stop, oklch}, anchored at the primary,
|
|
10
|
+
* interpolating lightness toward white/black and damping chroma at the extremes.
|
|
11
|
+
* - typeScale(baseRem, ratio, steps?) → a modular type scale {step, rem}.
|
|
12
|
+
* - spacingScale(basePx, count?) → a 4pt/8pt geometric spacing scale (px).
|
|
13
|
+
* - radiusScale(basePx?) → sm/md/lg/xl/full radii (px / 9999 for full).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const round = (n, d) => {
|
|
17
|
+
const f = Math.pow(10, d);
|
|
18
|
+
return Math.round(n * f) / f;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const DEFAULT_STOPS = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* oklchScale({ l, c, h }, opts) — l ∈ 0..1 (lightness), c ∈ 0..~0.4 (chroma), h ∈ 0..360 (hue).
|
|
25
|
+
* Anchors the `anchorStop` (default 500) at the primary, then interpolates L toward `lLight`
|
|
26
|
+
* for lighter stops and `lDark` for darker stops, damping chroma toward the extremes.
|
|
27
|
+
*/
|
|
28
|
+
function oklchScale(primary, opts = {}) {
|
|
29
|
+
const { l, c, h } = primary || {};
|
|
30
|
+
if (typeof l !== 'number' || typeof c !== 'number' || typeof h !== 'number') {
|
|
31
|
+
throw new TypeError('oklchScale: primary must be { l:number, c:number, h:number }');
|
|
32
|
+
}
|
|
33
|
+
const stops = opts.stops || DEFAULT_STOPS;
|
|
34
|
+
const anchor = opts.anchorStop || 500;
|
|
35
|
+
const lLight = opts.lLight != null ? opts.lLight : 0.97;
|
|
36
|
+
const lDark = opts.lDark != null ? opts.lDark : 0.22;
|
|
37
|
+
const i500 = stops.indexOf(anchor) === -1 ? Math.floor(stops.length / 2) : stops.indexOf(anchor);
|
|
38
|
+
|
|
39
|
+
return stops.map((stop, i) => {
|
|
40
|
+
let L;
|
|
41
|
+
let C;
|
|
42
|
+
if (i === i500) {
|
|
43
|
+
L = l; C = c;
|
|
44
|
+
} else if (i < i500) {
|
|
45
|
+
const t = (i500 - i) / i500; // 0..1 toward the lightest stop
|
|
46
|
+
L = l + (lLight - l) * t;
|
|
47
|
+
C = c * (1 - 0.75 * t); // damp chroma toward white
|
|
48
|
+
} else {
|
|
49
|
+
const t = (i - i500) / (stops.length - 1 - i500); // 0..1 toward the darkest stop
|
|
50
|
+
L = l + (lDark - l) * t;
|
|
51
|
+
C = c * (1 - 0.45 * t); // damp chroma toward black (less than toward white)
|
|
52
|
+
}
|
|
53
|
+
return { stop, oklch: `oklch(${round(L, 3)} ${round(Math.max(C, 0), 4)} ${round(h, 2)})` };
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* typeScale(baseRem, ratio, steps) — a modular scale. Returns `steps.length` entries,
|
|
59
|
+
* each `{ step, rem }` with rem = baseRem * ratio^step (step 0 = base). Default steps -1..5.
|
|
60
|
+
*/
|
|
61
|
+
function typeScale(baseRem = 1, ratio = 1.25, steps = [-1, 0, 1, 2, 3, 4, 5]) {
|
|
62
|
+
if (!(baseRem > 0) || !(ratio > 1)) throw new TypeError('typeScale: baseRem > 0 and ratio > 1 required');
|
|
63
|
+
return steps.map((step) => ({ step, rem: round(baseRem * Math.pow(ratio, step), 3) }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* spacingScale(basePx, count) — a geometric spacing scale off a 4pt/8pt baseline.
|
|
68
|
+
* Returns `count` entries `{ step, px }` following the standard [1,2,3,4,6,8,12,16,...] multiples.
|
|
69
|
+
*/
|
|
70
|
+
function spacingScale(basePx = 4, count = 8) {
|
|
71
|
+
if (![4, 8].includes(basePx)) throw new RangeError('spacingScale: basePx must be 4 or 8 (a 4pt/8pt baseline)');
|
|
72
|
+
const mult = [1, 2, 3, 4, 6, 8, 12, 16, 24, 32];
|
|
73
|
+
return mult.slice(0, count).map((m, i) => ({ step: i + 1, px: basePx * m }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** radiusScale(basePx) — sm/md/lg/xl/full. full = 9999 (pill). */
|
|
77
|
+
function radiusScale(basePx = 8) {
|
|
78
|
+
if (!(basePx > 0)) throw new TypeError('radiusScale: basePx > 0 required');
|
|
79
|
+
return {
|
|
80
|
+
sm: round(basePx / 2, 2),
|
|
81
|
+
md: basePx,
|
|
82
|
+
lg: basePx * 2,
|
|
83
|
+
xl: basePx * 3,
|
|
84
|
+
full: 9999,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { oklchScale, typeScale, spacingScale, radiusScale, DEFAULT_STOPS };
|