htmlineage 0.1.0-alpha.0 → 0.1.0-beta.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/README.md CHANGED
@@ -1,8 +1,18 @@
1
- # htmlineage
1
+ <picture>
2
+ <source media="(prefers-color-scheme: dark)" srcset=".github/assets/logo-dark.svg">
3
+ <img src=".github/assets/logo-light.svg" alt="htmlineage" width="420">
4
+ </picture>
2
5
 
3
6
  **A provenance layer for AI-generated reports.** Every number, chart, and claim traces back to the query that produced it.
4
7
 
5
- AI writes analytics reports faster than data teams can vet them. htmlineage records where each figure came from — `source → view → figure → insight` — so a reviewer can see the lineage behind any claim before it reaches a CXO deck.
8
+ <picture>
9
+ <source media="(prefers-color-scheme: dark)" srcset=".github/assets/viewer-dark.png">
10
+ <img src=".github/assets/viewer-light.png" alt="The htmlineage viewer: a quarterly business review on the left, and on the right how its &quot;Gap to year-end target&quot; figure was computed">
11
+ </picture>
12
+
13
+ <sub>The viewer on a sample report. Halden is a fictional company and its data is made up.</sub>
14
+
15
+ AI writes analytics reports faster than data teams can vet them. htmlineage records where each figure came from — `source → view → figure → insight` — so a reviewer can see the lineage behind any claim before it reaches a CXO deck. A skill teaches your AI assistant to record that lineage while it builds a report, and a small CLI checks it, composes it into one SQL script, and shows it beside the report.
6
16
 
7
17
  - **Visibility, not verification.** htmlineage shows the lineage; it doesn't assert the numbers are right.
8
18
  - **Zero builder friction.** Reports stay plain `.html`. Lineage rides along in an inert `<script type="application/lineage+json">` block and `data-lineage` attributes — invisible to the reader.
@@ -11,38 +21,70 @@ AI writes analytics reports faster than data teams can vet them. htmlineage reco
11
21
 
12
22
  ## Status
13
23
 
14
- ⚠️ **Pre-release.** This is an early preview, published under the `next` tag. The lineage format may still change before `0.1.0`. The skill that teaches an AI assistant to write lineage as it builds a report is not included yet.
24
+ **Beta.** The lineage format may still change before `0.1.0`.
15
25
 
16
26
  ## Install
17
27
 
18
- Requires Node 18 or later.
28
+ **The skill** follows the open [Agent Skills](https://agentskills.io) standard, so it works with Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot and other tools that read skills. Install it with [skills.sh](https://skills.sh):
19
29
 
20
30
  ```bash
21
- npx htmlineage@next --help
31
+ npx skills add htmlineage/htmlineage -s htmlineage
22
32
  ```
23
33
 
24
- ## Usage
34
+ Or copy [`skills/htmlineage/`](skills/htmlineage/) into your tool's skills folder (for example `.claude/skills/` for Claude Code, `.agents/skills/` for most others).
35
+
36
+ The skill is opt-in: your assistant uses it when you ask for a report that will be reviewed, traced or shared with leadership, and not for quick questions. To use it for every report in a repository, add a line such as "Every report built here uses htmlineage" to the instructions file your tool reads (`AGENTS.md`, `CLAUDE.md` or similar).
37
+
38
+ **The CLI** needs Node 18 or later and nothing else. There is nothing to install: run it with `npx`.
25
39
 
26
40
  ```bash
27
- npx htmlineage@next check report.html # validate the lineage and embed it into the report
28
- npx htmlineage@next sql report.html # compose the whole report into one SQL script
29
- npx htmlineage@next sql report.html --id f3 # the SQL behind one number
30
- npx htmlineage@next view report.html # open the report beside its lineage
41
+ npx htmlineage --help
31
42
  ```
32
43
 
33
- Run `htmlineage <command> --help` for the options of each command.
44
+ ## Build and check a report
45
+
46
+ ### 1. Ask for a report
47
+
48
+ Tell your assistant you want a report that will be reviewed, traced or shared with leadership. The assistant writes the report as plain HTML and, beside it, the lineage graph in `.lineage/<report>.json`.
34
49
 
35
- ### `check`
50
+ ### 2. Check it
36
51
 
37
- Reads the lineage graph from `.lineage/<report>.json` beside the report, checks it against the report's `data-lineage` bindings, embeds it into the HTML, and prints a summary. Exits `0` when the report passes (warnings still pass) and `1` when it does not. Use `--no-embed` to leave the file untouched and `--json` for machine-readable output.
52
+ This validates the graph against the report, embeds it into the HTML, and prints a summary. It exits `0` when the report passes (warnings still pass) and `1` when it does not.
38
53
 
39
- ### `sql`
54
+ ```bash
55
+ npx htmlineage check report.html
56
+ ```
57
+
58
+ It reads the graph from `.lineage/<report>.json` beside the report, or else the block already embedded in the report; `--graph <path>` reads that file instead. Use `--no-embed` to leave the file untouched and `--json` for machine-readable output. The rules and what each message means are in [CHECKS.md](https://github.com/htmlineage/htmlineage/blob/main/CHECKS.md).
40
59
 
41
- Joins every source, view, and figure into a single script a reviewer can paste into the warehouse. `--id <node>` composes only that node and everything it rests on. External and ungrounded nodes are listed as trailing comments. A report that fails `check` is refused.
60
+ ### 3. Open it
42
61
 
43
- ### `view`
62
+ This serves a two-pane page on 127.0.0.1: the report on the left, the lineage of whatever you click on the right.
63
+
64
+ ```bash
65
+ npx htmlineage view report.html
66
+ ```
44
67
 
45
- Serves a two-pane page on `localhost`: the report on the left, the lineage of whatever you click on the right. It reads only the report and its graph — no database, no credentials. The viewer itself makes no requests off your machine.
68
+ ### 4. Compose the SQL
69
+
70
+ This joins every source, view, and figure into a single script a reviewer can paste into the warehouse. A report that fails `check` is refused.
71
+
72
+ ```bash
73
+ npx htmlineage sql report.html > report.sql
74
+ npx htmlineage sql report.html --id f3 > f3.sql
75
+ ```
76
+
77
+ `--id <node>` composes only that node and everything it rests on.
78
+
79
+ Each command has its own options: run `npx htmlineage <command> --help`.
80
+
81
+ ## Review a report
82
+
83
+ To review a report someone else built, ask your assistant to review it. The skill then follows [the review guide](skills/htmlineage/references/review.md): read the check summary, trace the numbers that carry the report, and say where the evidence is weak.
84
+
85
+ To do it yourself, run `check`, click through the report in `view`, and run `sql --id` on the numbers that matter.
86
+
87
+ `view` serves the report, its graph and files beside the report such as images, only on 127.0.0.1. It never serves any other file whose name starts with a dot or that lies outside the report's folder, and it needs no database or credentials. More in [SECURITY.md](SECURITY.md).
46
88
 
47
89
  ## How lineage is recorded
48
90
 
@@ -62,6 +104,8 @@ Elements on the page point at nodes:
62
104
  <div class="kpi-value" data-lineage="f1">46.6%</div>
63
105
  ```
64
106
 
107
+ Every field of every step is in the [field reference](skills/htmlineage/references/format.md), and a complete short report with its graph is in the [worked example](skills/htmlineage/references/example/).
108
+
65
109
  ## License
66
110
 
67
111
  Apache-2.0 © Varun Jain
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlineage",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-beta.0",
4
4
  "description": "A provenance layer for AI-generated reports — every number, chart, and claim traces back to the query that produced it.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -9,7 +9,8 @@
9
9
  },
10
10
  "files": [
11
11
  "index.js",
12
- "src/"
12
+ "src/",
13
+ "skills/"
13
14
  ],
14
15
  "engines": {
15
16
  "node": ">=18"
@@ -0,0 +1,173 @@
1
+ ---
2
+ name: htmlineage
3
+ description: Records where every number in an HTML data report came from, so a reviewer can trace each figure, chart and claim back to the query behind it. Use when the user asks for a report that will be reviewed, traced or shared with leadership, asks for lineage or htmlineage by name, or when project instructions say reports use htmlineage. Also use to review such a report or explain how one of its numbers was computed. Skip it for quick, throwaway analysis unless asked.
4
+ ---
5
+
6
+ # htmlineage
7
+
8
+ While you build a report, you also write down how each thing on the page was
9
+ made: which query, which step, which value. That record is the lineage graph.
10
+ `htmlineage check` validates it and embeds it in the report. A reviewer can
11
+ then open any number and see the SQL behind it.
12
+
13
+ This changes nothing about how you query, chart or write. The report looks the
14
+ same with or without it. You add the graph and you run `check`.
15
+
16
+ ## When to use it
17
+
18
+ Use it when the report will be reviewed before it is shared: the user says so,
19
+ asks for lineage or htmlineage, or the project's instructions say every report
20
+ there uses it. Do not use it for a quick look at some data, or when the user
21
+ says they do not need it. When you are unsure, ask once.
22
+
23
+ ## The shape
24
+
25
+ Every value reaches the page by one path:
26
+
27
+ 1. **Source.** Pull once, wide, per mart: one query per dataset, with the
28
+ reporting period as literal dates.
29
+ 2. **View.** Make a view for everything you show: each chart and each table
30
+ is a SELECT over sources or other views.
31
+ 3. **Figure.** Pick each number from a view: one cell. Or derive it from other
32
+ figures with an expression.
33
+ 4. **Insight.** Write each claim over the figures, views and insights it
34
+ rests on.
35
+
36
+ If a claim rests on nothing you queried, keep it and mark it ungrounded. Never
37
+ leave it out to make the report look cleaner.
38
+
39
+ ## Workflow
40
+
41
+ 1. Query the warehouse the way you normally would. Write each source into the
42
+ graph as you run it.
43
+ 2. Write the graph to `.lineage/<report-stem>.json`, beside the report. For
44
+ `growth-review.html` that is `.lineage/growth-review.json`. Add views,
45
+ figures and insights to it as you build the page, not at the end.
46
+ 3. Put `data-lineage="<id>"` on every element that shows a view, figure or
47
+ insight. A number inside a sentence gets its own span.
48
+ 4. Run `npx htmlineage check <report>.html`.
49
+ 5. Fix every error. Most messages end with what to change. Run `check` again.
50
+ 6. Fix the warnings you can. A warning does not fail the report, but each one
51
+ is something a reviewer will see.
52
+ 7. When you edit the page after this, update the graph to match and run
53
+ `check` again. It is the last thing you do before you hand the report over.
54
+
55
+ `check` exits 0 when the report passes, with or without warnings, and 1 when
56
+ it does not. Exit 2 means the command was wrong (a bad flag, or no report
57
+ named): fix the command, not the graph. Never write the embedded
58
+ `<script>` block yourself; `check` does.
59
+
60
+ ## Pin time
61
+
62
+ Every source names its period with literal dates, both in `period` and in its
63
+ SQL:
64
+
65
+ ```sql
66
+ where order_date between '2026-08-01' and '2026-08-31'
67
+ ```
68
+
69
+ Never use `current_date`, `now()`, `dateadd(…, current_date)` or "last 30
70
+ days". A report must give the same numbers when it is run again next month.
71
+
72
+ ## Patterns
73
+
74
+ - **Ranks.** Add a `rank() over (…)` column to the view already on the page,
75
+ then pick the rank as a figure. Do not make a separate unbound view for it.
76
+ - **Shares.** Pick from the view on the page with a subquery in the figure's
77
+ SQL, for example a channel's count over the total, when no shown view has
78
+ the share as a column.
79
+ - **A change or a ratio.** A figure with `expr` over other figures, such as
80
+ `(f1 - f2) / f2`. It has no `sql`.
81
+ - **A change in a rate.** When a percentage moves, say whether the change is
82
+ in points or relative: "up 4.9 points" or "a relative gain of 11.8%", never
83
+ a bare "up 11.8%".
84
+ - **A name.** A figure can hold text, such as the top channel's name. Its
85
+ `value` and `display` are the name.
86
+ - **A number you were told.** When someone hands you a number or a file (a
87
+ target, a forecast, a CSV), make an external source with `given` saying what
88
+ you were handed and by whom. Put a figure over it, and bind the figure. Never
89
+ bind the source.
90
+ - **Copy `display` exactly.** The span's text is the figure's `display`,
91
+ character for character: `46.6%`, not `46.60%` or `47%`.
92
+ - **Metric names.** Write `30-day retention`, not "retention over 30 days".
93
+ `check` reads a number joined to a time unit by a hyphen as part of a
94
+ definition, not a claim to trace.
95
+ - **Opinion and forecast.** A claim from judgement or outside knowledge is an
96
+ insight with `"ungrounded": true` and `"from": []`. Ungrounded means no data
97
+ backs it. A projection you can compute from figures, such as "at this pace
98
+ we end the year at $1.47M", is an `expr` figure and the claim rests on it.
99
+ - **Every number a claim leans on.** If a claim depends on a quantity, that
100
+ quantity is a figure shown on the page and the claim rests on it. "All of
101
+ the growth came from new customers" needs new MRR on the page, not only the
102
+ totals beside it.
103
+
104
+ ## Describe each step
105
+
106
+ Sql sources, views and figures carry a `desc`: one line, 120 characters at
107
+ most, saying how the step was computed. A reviewer reads it before the SQL.
108
+
109
+ - Use business words. Name the population, the filter and the time window.
110
+ - No result values: the desc says how, not what came out.
111
+ - No SQL words: no `select`, `join`, column names or table names.
112
+
113
+ | Step | Good | Bad |
114
+ | ------------------ | ----------------------------------------------------------------------------- | ------------------------------------------------------------- |
115
+ | sql source | Retention by channel and cohort month, January to August 2026 | select channel, retention_30d from marts.retention_by_channel |
116
+ | view | Average 30-day retention for each channel and month, ranked within each month | Retention by channel, paid social leads at 46.6% |
117
+ | figure with `sql` | Paid social's 30-day retention for the August 2026 cohort | Pick from v1 where channel = paid_social |
118
+ | figure with `expr` | Change in paid social retention from July to August, relative to July | (f1 - f2) / f2 |
119
+
120
+ A figure's `label` is what the number _is_ ("Paid social retention,
121
+ August"). Its `desc` is how it was _computed_.
122
+
123
+ ## SQL style
124
+
125
+ The composed SQL is what a reviewer reads, so write it to be read:
126
+
127
+ - Lowercase keywords, one clause per line, one column per line.
128
+ - Name every computed column: `avg(retention_30d) as retention`.
129
+ - Aliases of three letters or more.
130
+ - A view or figure names its parents by id, as table names: `from q1`,
131
+ `from v2`.
132
+ - List columns. No `select *`.
133
+
134
+ ## Don't
135
+
136
+ - Give a figure its own query against the warehouse. It picks from a view or
137
+ source that is already there.
138
+ - Leave a number in prose outside a `data-lineage` span.
139
+ - Use `current_date` or any relative date, anywhere.
140
+ - Chain views deeply. Each link is one more thing a reviewer has to read;
141
+ two deep is usually enough.
142
+ - Drop a claim you cannot source. Mark it ungrounded instead.
143
+
144
+ ## When asked how a number was computed
145
+
146
+ For a number, chart or claim in a report, your own or one you were handed,
147
+ take the id from the element's `data-lineage` attribute and run:
148
+
149
+ ```
150
+ npx htmlineage sql <report>.html --id <id>
151
+ ```
152
+
153
+ It prints one runnable script: the source, every view and the figure, in
154
+ order. Answer from that script rather than by reading the graph by hand.
155
+ `npx htmlineage view <report>.html` opens the report beside its lineage for a
156
+ person to click through.
157
+
158
+ ## When asked to review a report
159
+
160
+ Follow [references/review.md](references/review.md). In short: read the check
161
+ summary with `--no-embed`, trace the numbers the report rests on with
162
+ `sql --id`, and report what is weak. Do not edit the report.
163
+
164
+ ## References
165
+
166
+ - [references/format.md](references/format.md): every field of every kind of
167
+ node, what may rest on what, ids, and binding. Open it when you write your
168
+ first graph, or when `check` names a field you do not recognise.
169
+ - [references/example/](references/example/): a complete short report with
170
+ its graph, the order it was built in, and the `check` output it gives. Read
171
+ it before your first report; copy its shape.
172
+ - [references/review.md](references/review.md): how to review a report
173
+ someone else built. Open it only when asked to review.
@@ -0,0 +1,167 @@
1
+ {
2
+ "lineage": "1.0",
3
+ "nodes": {
4
+ "q1": {
5
+ "step": "source",
6
+ "kind": "sql",
7
+ "desc": "Monthly 30-day retention inputs for each channel, region and signup cohort, January to August 2026",
8
+ "mart": "marts.retention_by_channel",
9
+ "period": {
10
+ "from": "2026-01-01",
11
+ "to": "2026-08-31"
12
+ },
13
+ "sql": "-- 30-day retention inputs by channel, region and cohort month, Jan to Aug 2026\nselect\n channel,\n region,\n cohort_month,\n cohort_customers,\n retained_30d\nfrom marts.retention_by_channel\nwhere cohort_month between '2026-01-01' and '2026-08-31'",
14
+ "columns": [
15
+ "channel",
16
+ "region",
17
+ "cohort_month",
18
+ "cohort_customers",
19
+ "retained_30d"
20
+ ],
21
+ "rows": null
22
+ },
23
+ "q2": {
24
+ "step": "source",
25
+ "kind": "sql",
26
+ "desc": "New customers for each channel who signed up in August 2026",
27
+ "mart": "marts.acquisition_by_channel",
28
+ "period": {
29
+ "from": "2026-08-01",
30
+ "to": "2026-08-31"
31
+ },
32
+ "sql": "-- New customers by channel, August 2026\nselect\n channel,\n signup_month,\n new_customers\nfrom marts.acquisition_by_channel\nwhere signup_month between '2026-08-01' and '2026-08-31'",
33
+ "columns": ["channel", "signup_month", "new_customers"],
34
+ "rows": {
35
+ "channel": ["paid_social", "paid_search", "organic", "referral"],
36
+ "signup_month": [
37
+ "2026-08-01",
38
+ "2026-08-01",
39
+ "2026-08-01",
40
+ "2026-08-01"
41
+ ],
42
+ "new_customers": [4120, 3310, 3870, 1900]
43
+ }
44
+ },
45
+ "x1": {
46
+ "step": "source",
47
+ "kind": "external",
48
+ "type": "csv",
49
+ "ref": "channel-retention-targets.csv",
50
+ "given": "Retention targets by channel for August 2026, shared as a CSV by the growth lead",
51
+ "columns": ["channel", "target_retention"],
52
+ "rows": {
53
+ "channel": ["paid_social", "paid_search", "organic", "referral"],
54
+ "target_retention": [0.45, 0.4, 0.43, 0.44]
55
+ }
56
+ },
57
+ "v1": {
58
+ "step": "view",
59
+ "desc": "Retention for each channel and month, with each channel ranked against the others in its month",
60
+ "from": ["q1"],
61
+ "sql": "-- Monthly 30-day retention by channel, with each channel's rank in its month\nselect\n channel,\n cohort_month,\n sum(retained_30d) / sum(cohort_customers) as retention,\n rank() over (\n partition by cohort_month\n order by sum(retained_30d) / sum(cohort_customers) desc\n ) as retention_rank\nfrom q1\ngroup by channel, cohort_month",
62
+ "columns": ["channel", "cohort_month", "retention", "retention_rank"],
63
+ "encoding": {
64
+ "x": "cohort_month",
65
+ "y": "retention",
66
+ "series": "channel",
67
+ "visual": "line"
68
+ },
69
+ "rows": null
70
+ },
71
+ "v2": {
72
+ "step": "view",
73
+ "desc": "August new customers and retention for each channel, side by side",
74
+ "from": ["q1", "q2"],
75
+ "sql": "-- August new customers and 30-day retention by channel\nselect\n q2.channel,\n q2.new_customers,\n sum(q1.retained_30d) / sum(q1.cohort_customers) as retention\nfrom q2\njoin q1\n on q1.channel = q2.channel\n and q1.cohort_month = q2.signup_month\ngroup by q2.channel, q2.new_customers\norder by retention desc",
76
+ "columns": ["channel", "new_customers", "retention"],
77
+ "encoding": {
78
+ "visual": "table"
79
+ },
80
+ "rows": null
81
+ },
82
+ "f1": {
83
+ "step": "figure",
84
+ "desc": "Paid social's retention for the August 2026 signup cohort",
85
+ "from": ["v1"],
86
+ "sql": "select retention\nfrom v1\nwhere channel = 'paid_social'\n and cohort_month = '2026-08-01'",
87
+ "value": 0.466,
88
+ "display": "46.6%",
89
+ "label": "Paid social retention, August"
90
+ },
91
+ "f2": {
92
+ "step": "figure",
93
+ "desc": "Paid social's retention for the July 2026 signup cohort",
94
+ "from": ["v1"],
95
+ "sql": "select retention\nfrom v1\nwhere channel = 'paid_social'\n and cohort_month = '2026-07-01'",
96
+ "value": 0.417,
97
+ "display": "41.7%",
98
+ "label": "Paid social retention, July"
99
+ },
100
+ "f3": {
101
+ "step": "figure",
102
+ "desc": "August retention minus July retention, as a share of July retention",
103
+ "from": ["f1", "f2"],
104
+ "expr": "(f1 - f2) / f2",
105
+ "value": 0.1175,
106
+ "display": "11.8%",
107
+ "label": "Change in paid social retention, August vs July"
108
+ },
109
+ "f4": {
110
+ "step": "figure",
111
+ "desc": "Paid social's retention rank among the channels in August 2026",
112
+ "from": ["v1"],
113
+ "sql": "select retention_rank\nfrom v1\nwhere channel = 'paid_social'\n and cohort_month = '2026-08-01'",
114
+ "value": 1,
115
+ "display": "#1",
116
+ "label": "Paid social's retention rank among channels, August"
117
+ },
118
+ "f5": {
119
+ "step": "figure",
120
+ "desc": "Paid social's August new customers as a share of all channels' August new customers",
121
+ "from": ["v2"],
122
+ "sql": "select customer_share\nfrom (\n select\n channel,\n new_customers / sum(new_customers) over () as customer_share\n from v2\n) as shares\nwhere channel = 'paid_social'",
123
+ "value": 0.3121,
124
+ "display": "31.2%",
125
+ "label": "Paid social share of August new customers"
126
+ },
127
+ "f6": {
128
+ "step": "figure",
129
+ "desc": "The channel with the highest retention in August 2026",
130
+ "from": ["v2"],
131
+ "sql": "select channel\nfrom v2\norder by retention desc\nlimit 1",
132
+ "value": "paid_social",
133
+ "display": "Paid social",
134
+ "label": "Channel with the highest August retention"
135
+ },
136
+ "f7": {
137
+ "step": "figure",
138
+ "desc": "Paid social's August 2026 retention target",
139
+ "from": ["x1"],
140
+ "sql": "select target_retention\nfrom x1\nwhere channel = 'paid_social'",
141
+ "value": 0.45,
142
+ "display": "45%",
143
+ "label": "Paid social retention target, August"
144
+ },
145
+ "i1": {
146
+ "step": "insight",
147
+ "from": ["f6"],
148
+ "text": "Paid social led August retention."
149
+ },
150
+ "i2": {
151
+ "step": "insight",
152
+ "from": ["f1", "f2", "f3", "f4"],
153
+ "text": "Paid social 30-day retention rose to 46.6% in August from July's 41.7%, a relative gain of 11.8%, and ranked #1 of the four channels."
154
+ },
155
+ "i3": {
156
+ "step": "insight",
157
+ "from": ["f5", "i2"],
158
+ "text": "Paid social brought in 31.2% of August's new customers, so its gain lifts retention across the whole base, not just one corner of it."
159
+ },
160
+ "i4": {
161
+ "step": "insight",
162
+ "from": [],
163
+ "ungrounded": true,
164
+ "text": "Paid social's gain is likely to hold into September."
165
+ }
166
+ }
167
+ }
@@ -0,0 +1,51 @@
1
+ # Worked example
2
+
3
+ A short report as you should leave it before you run `check`: the graph sits
4
+ in `.lineage/august-retention.json`, the page in `august-retention.html` has
5
+ no embedded block, and the graph header holds only `lineage` and `nodes`.
6
+ Read the JSON beside the page. `check` embeds the graph for you.
7
+
8
+ ## Prompt
9
+
10
+ > Build a one-page August retention report for the growth review: paid social's
11
+ > 30-day retention against July, how it ranks among the channels, and its share
12
+ > of new customers. Compare retention with the targets the growth lead sent.
13
+
14
+ ## Build order
15
+
16
+ The queries and steps in the order they were made. Each id appears once.
17
+
18
+ 1. `q1` source: one wide pinned query on `marts.retention_by_channel`, January to August 2026.
19
+ 2. `q2` source: one wide pinned query on `marts.acquisition_by_channel`, August 2026.
20
+ 3. `x1` external source: the retention targets the growth lead shared as a CSV. You were handed it, so it has `given` and no `sql`.
21
+ 4. `v1` view: retention by channel and month, with a `rank() over` column. It is the line chart.
22
+ 5. `v2` view: August new customers and retention by channel. It is the table.
23
+ 6. `f1` figure: paid social's August retention, picked from `v1`.
24
+ 7. `f2` figure: its July retention, picked from `v1`.
25
+ 8. `f3` figure: the change on July, an `expr` over `f1` and `f2`, with no `sql`.
26
+ 9. `f4` figure: its August rank, picked from `v1`.
27
+ 10. `f5` figure: its share of August new customers, a subquery over `v2`.
28
+ 11. `f6` figure: the top channel's name. A figure can hold text.
29
+ 12. `f7` figure: paid social's August target, picked from `x1`.
30
+ 13. `i1` insight: the headline, resting on `f6`.
31
+ 14. `i2` insight: the retention paragraph, resting on `f1`, `f2`, `f3` and `f4`.
32
+ 15. `i3` insight: the share paragraph, resting on `f5` and the insight `i2`.
33
+ 16. `i4` insight: a forecast no query backs. It has `from: []` and `ungrounded: true`.
34
+
35
+ ## Check output
36
+
37
+ ```text
38
+ august-retention.html
39
+
40
+ sources 3 (1 external)
41
+ views 2 all shown on the page
42
+ figures 7 6 fully traced, 1 partially traced
43
+ insights 4 3 fully traced, 1 ungrounded
44
+
45
+ needs attention
46
+ i4 ungrounded "Paid social's gain is likely to hold int…"
47
+ x1 external channel-retention-targets.csv — a reader will be asked to confirm this with you
48
+ ```
49
+
50
+ `i4` and `x1` are listed on purpose. They show a reader what is not backed by a query.
51
+ Everything else passes with no error and no warning.
@@ -0,0 +1,111 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>August retention by channel</title>
7
+ <style>
8
+ :root { --ink: #1d2128; --muted: #5f6570; --line: #e4e6ea; --accent: #2a6fdb; }
9
+ body { margin: 0; background: #fff; color: var(--ink); font: 16px/1.55 -apple-system, "Segoe UI", Roboto, sans-serif; }
10
+ main { max-width: 760px; margin: 0 auto; padding: 40px 20px 64px; }
11
+ .eyebrow { margin: 0; color: var(--muted); font-size: 13px; letter-spacing: .04em; text-transform: uppercase; }
12
+ h1 { margin: 6px 0 4px; font-size: 30px; line-height: 1.2; }
13
+ .subtitle { margin: 0 0 28px; color: var(--muted); }
14
+ .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 28px; }
15
+ .kpi { border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }
16
+ .kpi-label { color: var(--muted); font-size: 13px; }
17
+ .kpi-value { font-size: 26px; font-weight: 600; }
18
+ .kpi-note { color: var(--muted); font-size: 13px; }
19
+ h2 { margin: 32px 0 8px; font-size: 18px; }
20
+ figure { margin: 0; }
21
+ svg { width: 100%; height: auto; }
22
+ svg .grid { stroke: var(--line); }
23
+ svg .tick { fill: var(--muted); font-size: 11px; }
24
+ svg .series { font-size: 12px; }
25
+ table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
26
+ th, td { padding: 8px 10px; border-bottom: 1px solid var(--line); text-align: right; }
27
+ th:first-child, td:first-child { text-align: left; }
28
+ th { color: var(--muted); font-weight: 500; font-size: 13px; }
29
+ footer { margin-top: 40px; color: var(--muted); font-size: 13px; }
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <main>
34
+ <header>
35
+ <p class="eyebrow">Growth review · August 2026</p>
36
+ <h1 data-lineage="i1"><span data-lineage="f6">Paid social</span> led August retention</h1>
37
+ <p class="subtitle">30-day retention by acquisition channel, cohorts from January to August 2026</p>
38
+ </header>
39
+
40
+ <section class="kpis">
41
+ <div class="kpi">
42
+ <div class="kpi-label">Paid social retention, August</div>
43
+ <div class="kpi-value" data-lineage="f1">46.6%</div>
44
+ <div class="kpi-note">vs <span data-lineage="f2">41.7%</span> in July</div>
45
+ <div class="kpi-note">Target <span data-lineage="f7">45%</span></div>
46
+ </div>
47
+ <div class="kpi">
48
+ <div class="kpi-label">Change on July</div>
49
+ <div class="kpi-value" data-lineage="f3">11.8%</div>
50
+ <div class="kpi-note">relative change</div>
51
+ </div>
52
+ <div class="kpi">
53
+ <div class="kpi-label">Share of August new customers</div>
54
+ <div class="kpi-value" data-lineage="f5">31.2%</div>
55
+ <div class="kpi-note">paid social</div>
56
+ </div>
57
+ </section>
58
+
59
+ <p data-lineage="i2">Paid social 30-day retention rose to <span data-lineage="f1">46.6%</span> in August from July's <span data-lineage="f2">41.7%</span>, a relative gain of <span data-lineage="f3">11.8%</span>, and ranked <span data-lineage="f4">#1</span> of the four channels.</p>
60
+
61
+ <p data-lineage="i3">Paid social brought in <span data-lineage="f5">31.2%</span> of August's new customers, so its gain lifts retention across the whole base, not just one corner of it.</p>
62
+
63
+ <p data-lineage="i4">Paid social's gain is likely to hold into September.</p>
64
+
65
+ <h2>Retention by channel, month by month</h2>
66
+ <figure data-lineage="v1">
67
+ <svg viewBox="0 0 640 260" role="img" aria-label="30-day retention by channel, January to August 2026">
68
+ <line class="grid" x1="44" x2="544" y1="232.0" y2="232.0"/>
69
+ <text class="tick" x="36" y="236.0" text-anchor="end">36%</text>
70
+ <line class="grid" x1="44" x2="544" y1="160.0" y2="160.0"/>
71
+ <text class="tick" x="36" y="164.0" text-anchor="end">40%</text>
72
+ <line class="grid" x1="44" x2="544" y1="88.0" y2="88.0"/>
73
+ <text class="tick" x="36" y="92.0" text-anchor="end">44%</text>
74
+ <line class="grid" x1="44" x2="544" y1="16.0" y2="16.0"/>
75
+ <text class="tick" x="36" y="20.0" text-anchor="end">48%</text>
76
+ <text class="tick" x="44.0" y="252" text-anchor="middle">Jan</text>
77
+ <text class="tick" x="115.4" y="252" text-anchor="middle">Feb</text>
78
+ <text class="tick" x="186.9" y="252" text-anchor="middle">Mar</text>
79
+ <text class="tick" x="258.3" y="252" text-anchor="middle">Apr</text>
80
+ <text class="tick" x="329.7" y="252" text-anchor="middle">May</text>
81
+ <text class="tick" x="401.1" y="252" text-anchor="middle">Jun</text>
82
+ <text class="tick" x="472.6" y="252" text-anchor="middle">Jul</text>
83
+ <text class="tick" x="544.0" y="252" text-anchor="middle">Aug</text>
84
+ <polyline fill="none" stroke="#d8dadf" stroke-width="1.5" points="44.0,196.0 115.4,188.8 186.9,179.8 258.3,185.2 329.7,176.2 401.1,172.6 472.6,174.4 544.0,181.6"/>
85
+ <text class="series" x="552" y="185.6" fill="#9a9ea6">paid search</text>
86
+ <polyline fill="none" stroke="#b5b9c0" stroke-width="1.5" points="44.0,115.0 115.4,109.6 186.9,106.0 258.3,113.2 329.7,100.6 401.1,107.8 472.6,109.6 544.0,104.2"/>
87
+ <text class="series" x="552" y="108.2" fill="#b5b9c0">organic</text>
88
+ <polyline fill="none" stroke="#8a8f98" stroke-width="1.5" points="44.0,73.6 115.4,68.2 186.9,61.0 258.3,71.8 329.7,55.6 401.1,66.4 472.6,61.0 544.0,66.4"/>
89
+ <text class="series" x="552" y="70.4" fill="#8a8f98">referral</text>
90
+ <polyline fill="none" stroke="#2a6fdb" stroke-width="2.5" points="44.0,174.4 115.4,163.6 186.9,151.0 258.3,158.2 329.7,142.0 401.1,143.8 472.6,129.4 544.0,41.2"/>
91
+ <text class="series" x="552" y="45.2" fill="#2a6fdb">paid social</text>
92
+ </svg>
93
+ </figure>
94
+
95
+ <h2>August by channel</h2>
96
+ <table data-lineage="v2">
97
+ <thead>
98
+ <tr><th>Channel</th><th>New customers</th><th>30-day retention</th></tr>
99
+ </thead>
100
+ <tbody>
101
+ <tr><td>Paid social</td><td>4,120</td><td>46.6%</td></tr>
102
+ <tr><td>Referral</td><td>1,900</td><td>45.2%</td></tr>
103
+ <tr><td>Organic</td><td>3,870</td><td>43.1%</td></tr>
104
+ <tr><td>Paid search</td><td>3,310</td><td>38.8%</td></tr>
105
+ </tbody>
106
+ </table>
107
+
108
+ <footer>Source: marts.retention_by_channel and marts.acquisition_by_channel. Retention is the 30-day active rate of each signup cohort: customers still active a month after signup, over customers in the cohort.</footer>
109
+ </main>
110
+ </body>
111
+ </html>