@dcl-regenesislabs/artifacts 1.0.0 → 1.1.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.
@@ -0,0 +1,217 @@
1
+ ---
2
+ name: dcl-artifacts
3
+ description: Publish a static artifact (HTML page, demo, report, dashboard, mockup, slide deck, checklist, any folder of static files) to https://artifacts.dclregenesislabs.xyz and get a link back; push again to the same link to add a version. Use whenever someone asks to publish, host, deploy, share, update, or "give me a link to" a generated page or folder, or wants to send a visual result to someone who is not at this terminal. Also covers inspecting, listing and deleting artifacts, checkboxes that remember their state, and explaining how the public link works.
4
+ ---
5
+
6
+ # Publishing artifacts to artifacts.dclregenesislabs.xyz
7
+
8
+ Self-hosted artifact host on Cloudflare Workers + R2. Push a folder, get back a
9
+ link. The link is a UUID that stays the same for life: pushing to it again adds
10
+ a **version**, and readers can step back through versions from the bar at the
11
+ top of the page.
12
+
13
+ | | URL | Who can open it |
14
+ | --- | --- | --- |
15
+ | **team** (always) | `/{id}` | Only **@dclregenesislabs.xyz** and **@decentraland.org** accounts. They type their work email and paste the 6-digit code Cloudflare emails them. |
16
+ | pinned version | `/{id}@2` | Same people; one specific version, frozen. |
17
+ | **public** (optional) | `/p/{alias}` | Anyone holding the link. No login. |
18
+
19
+ The public link is switched on and off **from the bar on the page**, by any
20
+ person who can open it. **You cannot do it, and the CLI cannot do it** — that is
21
+ deliberate. When the user wants a page public, publish it as usual and tell
22
+ them: *open the link, flip "Public link" in the bar at the top, and copy the
23
+ public URL it shows.* Never suggest a workaround.
24
+
25
+ **If the CLI says `not signed in`, stop and tell the user to run
26
+ `node cli/artifacts.mjs login`** — it opens a browser, they sign in with their
27
+ work email, and it stores a token for them. You cannot do it for them, and
28
+ there is no token in this file to fall back on.
29
+
30
+ ## Build the page
31
+
32
+ **Default: the Regenesis data-report style —
33
+ `references/regenesis-artifact-style.md`.** Read it before writing any markup. It is the house
34
+ look for this host: the DCL brand palette with dark mode, the Inter type scale, the logo and
35
+ header, the tab strip, the section-title/key-finding/card rhythm, the KPI-scorecard and
36
+ pivot-table patterns, the hand-written SVG chart conventions (no chart library), and a list of
37
+ the layout bugs that have actually broken these pages.
38
+
39
+ It covers a page with one tab as happily as one with ten — a single-tab page is just the tab strip
40
+ omitted. Start there for anything you would call a report, a scorecard, a dashboard, an analysis or
41
+ a summary, which is nearly everything published here.
42
+
43
+ The reference implementation is the shipped **LMT mobile-growth report**. When the guide is
44
+ ambiguous, open the most recent one and copy the pattern.
45
+
46
+ ### Host rules — these apply to every page, whichever style you use
47
+
48
+ **One file — in the published output.** Inline the CSS, the JS, the logo and small images (as
49
+ data URIs). If you must add files, reference them **relatively** (`./chart.png`, never
50
+ `/chart.png`) — artifacts are served under a path prefix. Entry point must be `index.html`, except
51
+ a single-file push. Never load a stylesheet from another artifact. Never name a top-level file
52
+ starting with `__` (reserved).
53
+
54
+ *Authoring may differ from output.* The legacy template keeps its CSS and JS in
55
+ `templates/artifact/assets/` and `artifacts.mjs new` inlines them at scaffold time by replacing
56
+ the `<link>`/`<script>` markers, so what you publish is still one file. If you edit that template,
57
+ **leave those two marker lines exactly as they are** — the CLI throws if it cannot find them.
58
+
59
+ **The bar is not yours to build.** The host injects a bar at the top of every gated page: name,
60
+ version picker, the public switch, copy link. Do not add a navbar, a version number, a "last
61
+ updated" line or a share button to the page itself — they duplicate the bar and go stale. Do not
62
+ rely on it either: the public link and the file itself have no bar.
63
+
64
+ **Checkboxes that stick.** Any `<input type="checkbox" data-persist="some-key">` remembers its
65
+ state on the host: tick it, reload, open it elsewhere, it stays ticked, and the bar shows who
66
+ ticked it and when. Use it whenever the page has things to complete or confirm. The key is the
67
+ identity — a short, stable slug (`deploy-staging`, `legal-signoff`), unique within the page, and
68
+ **unchanged across versions** or the ticks are lost. Only team members can change them; the public
69
+ link shows them read-only. This is a host feature, not a template feature — it works in any page.
70
+
71
+ Keep running text under ~78 characters, and put wide things (tables, code) in their own scrolling
72
+ container so the body never scrolls sideways.
73
+
74
+ ### Prose template — for documents, not reports
75
+
76
+ ```bash
77
+ node cli/artifacts.mjs new ./path/to/dir --name "Page title"
78
+ ```
79
+
80
+ `templates/artifact/index.html` is **no longer the default**, but it is not off-brand and it is not
81
+ going away: it now carries the **same house palette, Inter type, radius and dark mode** as the
82
+ report style (`assets/dcl.css` was repainted to the house tokens). One visual language, two
83
+ layouts — this is the prose layout.
84
+
85
+ Reach for it when a page is genuinely a **document** whose value is in the writing — a spec, a
86
+ runbook, a long memo — and the report style's tabs and cards would fight the content. Its blocks:
87
+
88
+ | Need | Use |
89
+ | --- | --- |
90
+ | The one thing the reader must leave with | `.callout` (filled); `.callout.ok / .warn / .bad / .info` |
91
+ | A caveat, a dependency, context | `.note` (rail); same colour modifiers |
92
+ | Tabular data | `.tblwrap > table`; cells `.n` name, `.t` short, `.src` file refs, `.r` numbers; `.yes / .no / .chg` |
93
+ | A few facts about one thing | `dl.kv` |
94
+ | Named things with attributes | `.grid2 / .grid3 > .card.ok / .warn / .bad / .info / .plain` with `.card-h > .name + .badge + .lang` |
95
+ | Figures that are the point | `.grid3 > .stat > .v + .l` |
96
+ | Inline state | `.pill.ok / .warn / .bad / .accent / .info`, or outlined `.tag` |
97
+ | A real sequence (rollout, procedure) | `ol.phases` — never for an unordered list |
98
+ | Things to tick off | `ul.checklist > li > label > input[type=checkbox][data-persist="key"]` |
99
+ | Commands, output | `pre > code` with spans `.c` comment, `.k` keyword, `.o` output — a Copy button is added by itself |
100
+ | A code change | `.path` + `.split > .col > .colhead.before/.after + .code > .row.del/.add/.ctx/.pad` |
101
+
102
+ Write inside `.wrap`; leave the big `<style>` and `<script>` alone. The one per-page knob is
103
+ `--accent` / `--accent-soft` in the *small* `<style>` block after them.
104
+
105
+ Skip both when the page is an app or demo whose own design is the point.
106
+
107
+ ## Publish
108
+
109
+ With the page in a directory (`index.html` is the entry point):
110
+
111
+ ```bash
112
+ node cli/artifacts.mjs push ./path/to/dir --name "short label"
113
+ ```
114
+
115
+ The URL is printed on stdout — progress goes to stderr, so
116
+ `URL=$(... push ./dir)` captures just the link. **Hand it over exactly as
117
+ printed** — `https://artifacts.dclregenesislabs.xyz/{id}`, no trailing slash,
118
+ nothing appended — and mention it needs a work-email login the first time they
119
+ open it. (A multi-file folder redirects itself to `/{id}/` when opened; that's
120
+ fine, don't pre-empt it.) `--name` is what the bar shows; make it a real title.
121
+
122
+ ## Update — push a new version
123
+
124
+ Same link, new content. Pass the artifact's URL (or bare id) as `--id`:
125
+
126
+ ```bash
127
+ node cli/artifacts.mjs push ./path/to/dir --id https://artifacts.dclregenesislabs.xyz/3f2b9c1e-4d5a-4b6c-8e7f-9a0b1c2d3e4f
128
+ ```
129
+
130
+ - The link does not change. Readers get the new version; the bar lets them
131
+ open older ones, and `/{id}@1` pins the first.
132
+ - Identical content records nothing (the CLI says so). Only changed files are
133
+ uploaded.
134
+ - Checkbox ticks survive a new version as long as the `data-persist` keys do.
135
+ - When you have iterated on a page in this session, **always** push with
136
+ `--id` — a fresh push makes a second, unrelated link and splits the history.
137
+ If the user gives you an artifact link and asks for changes, that link is
138
+ the `--id`.
139
+ - `--name` on an update renames it; omit it to keep the name.
140
+
141
+ Variations:
142
+
143
+ ```bash
144
+ # a single file becomes a one-file artifact served at the bare URL
145
+ ... push ./report.html --name "weekly report"
146
+
147
+ # several new artifacts in one go (omit --name and --id; each folder gets its own link)
148
+ ... push ./demos/*/
149
+
150
+ # machine-readable result: {id, url, version, unchanged, name, files, bytes, hasIndex}
151
+ ... push ./dir --json
152
+ ```
153
+
154
+ ## Inspect, list, delete
155
+
156
+ ```bash
157
+ node cli/artifacts.mjs info https://artifacts.dclregenesislabs.xyz/3f2b9c1e-4d5a-4b6c-8e7f-9a0b1c2d3e4f
158
+ node cli/artifacts.mjs ls
159
+ node cli/artifacts.mjs rm https://artifacts.dclregenesislabs.xyz/3f2b9c1e-4d5a-4b6c-8e7f-9a0b1c2d3e4f
160
+ ```
161
+
162
+ `info` shows the name, every version (when, by whom) and whether the public
163
+ link is on. `ls` is the only listing that exists — the web has none. `rm`
164
+ deletes every version, the checkbox state and the public link; confirm with
165
+ the user first, it cannot be undone.
166
+
167
+ ## What to build
168
+
169
+ - Start from `new` unless there is a reason not to (see "Build the page").
170
+ - Entry point **must** be `index.html` (a single-file push is the exception).
171
+ - Reference assets **relatively** — `./assets/app.js`, never `/assets/app.js`.
172
+ Artifacts are served under a path prefix, so absolute paths break.
173
+ - Everything must be static. No server, no build step at request time — bundle or
174
+ inline what you need, or load libraries from a CDN.
175
+ - Per-file limit 25 MB; up to 2000 files per artifact.
176
+ - `.git`, `node_modules`, `.DS_Store`, `.env` and `.dev.vars` are skipped
177
+ automatically (pass `--all` to include them).
178
+
179
+ ## When to use this
180
+
181
+ Use it when the result is meant to be **looked at, shared, or worked through** —
182
+ a demo, a report, a chart, a mockup, a checklist the team will tick off,
183
+ anything the user wants to open on their phone or send to a teammate. For code
184
+ the user will edit, just write files locally instead.
185
+
186
+ ## Things worth knowing
187
+
188
+ - A `/{id}` URL is safe to paste in team channels; it still needs a work email.
189
+ - A `/p/{alias}` URL is not. It is unauthenticated and stays live until someone
190
+ switches it off in the bar. **Never publish credentials, private keys, or
191
+ customer data** on a page that might be made public.
192
+ - The bar records who switched the public link on, and who ticked each box.
193
+ - All artifacts share one origin, so a published page can read another artifact's
194
+ `localStorage` and call its in-page API. Do not publish pages that run
195
+ untrusted third-party script.
196
+ - Publishing is effectively permanent until someone runs `rm`. Confirm with the
197
+ user before publishing anything that was not obviously meant to be shared.
198
+
199
+ ## Raw HTTP (only if the CLI is unavailable)
200
+
201
+ All calls take `Authorization: Bearer <token>`, using the token from
202
+ `~/.config/dcl-artifacts/credentials.json` (`hosts` -> the host -> `token`).
203
+ `/_api/*` is exempt from the Access gate and guarded by the bearer alone. `id` is a UUID v4 you mint;
204
+ `base` is the version number `status` reported (0 for a new artifact):
205
+
206
+ ```
207
+ GET /_api/artifacts -> {artifacts:[...]}
208
+ GET /_api/artifacts/{id} -> name, versions, visibility, public link
209
+ POST /_api/artifacts/{id}/status body: {files:[{path,size,sha256}]} -> {exists, current, missing:[sha256]}
210
+ PUT /_api/artifacts/{id}/blobs/{sha256} body: raw bytes (must hash to the path)
211
+ POST /_api/artifacts/{id}/commit body: {files, base, name?, author?} -> {id, url, version}
212
+ DELETE /_api/artifacts/{id}
213
+ ```
214
+
215
+ There is no endpoint that makes an artifact public. `shared/artifact-id.js` in
216
+ this repo is the reference for ids, the version content hash, and manifest
217
+ validation.
@@ -0,0 +1,498 @@
1
+ # Regenesis data-report style
2
+
3
+ **The default look for pages on this host.** Read this before writing any markup.
4
+
5
+ It covers a page with one tab as happily as one with ten, so it is the starting point for anything
6
+ you would call a report, a scorecard, a dashboard, an analysis or a summary — nearly everything
7
+ published here. The older IBM Plex prose template (`templates/artifact/index.html`) is stale and
8
+ kept only for existing pages and for genuine long-form documents; see `SKILL.md`.
9
+
10
+ Everything below was extracted from the shipped **LMT2 Mobile Growth** report, which is the
11
+ reference implementation, and is reproduced here so the guide is self-contained.
12
+
13
+ ## Relationship to `assets/dcl.css`
14
+
15
+ **This guide is authoritative.** `templates/artifact/assets/dcl.css` — the prose template's
16
+ stylesheet — has been repainted to match it, so the two are now **one visual language in two
17
+ layouts**: this guide is the *report* layout (tabs, KPI scorecards, charts), `dcl.css` is the
18
+ *prose* layout (callouts, notes, phases, checklists, code diffs).
19
+
20
+ Aligned, and to be kept aligned:
21
+
22
+ | Token in `dcl.css` | Value | This guide |
23
+ |---|---|---|
24
+ | `--ground` | `#f9f9f7` | `--page` |
25
+ | `--surface` | `#fcfcfb` | `--surface` |
26
+ | `--ink` | `#0b0b0b` | `--ink` |
27
+ | `--muted` | `#52514e` | `--ink-2` |
28
+ | `--hairline` | `#e1e0d9` | `--grid` |
29
+ | `--accent` / `--bad` | `#FF2D55` | `--dcl-red` / `--critical` |
30
+ | `--ok` | `#0ca30c` | `--good` |
31
+ | `--warn` | `#fab219` | `--warning` |
32
+ | `--info` | `#2a78d6` | `--desktop` |
33
+ | `--font-sans` / `--font-mono` | Inter | Inter |
34
+ | `--radius` | `14px` | card radius |
35
+ | `--page-width` | `920px` | `.wrap` max-width |
36
+
37
+ **If you change a colour here, change it in `dcl.css` too.** They drifting apart is the failure
38
+ mode this alignment exists to prevent.
39
+
40
+ ### The two name collisions are resolved — don't reintroduce them
41
+
42
+ `dcl.css` previously used `--page` for a **length** and `--line` for a **hairline colour**, while
43
+ this guide uses `--page` for a **colour** and `--line` for the **red trend-line**. Loading both
44
+ broke layout (`max-width: var(--page)` resolved to a colour) and turned every border brand red.
45
+
46
+ `dcl.css` now uses **`--page-width`** and **`--hairline`**, so the names no longer clash. Do not
47
+ rename them back, and do not introduce a `--page` or `--line` into `dcl.css`.
48
+
49
+ Sharing a palette still does **not** mean sharing a page: a page is a report *or* a document. Do
50
+ not load both stylesheets or mix their class vocabularies.
51
+
52
+ ### Existing published pages are unaffected
53
+
54
+ `artifacts.mjs new` inlines `dcl.css` into the scaffolded `index.html`, and publishing uploads that
55
+ file's bytes. **Every already-published page carries its own frozen copy of the CSS**, so editing
56
+ the template repaints *future* scaffolds only. Nothing that is already live changes appearance, and
57
+ no `data-persist` keys are at risk.
58
+
59
+ ## Host integration
60
+
61
+ Three things come from the host, not from this stylesheet, and apply to every page:
62
+
63
+ - **The bar** — the host injects name, version picker, public switch and copy link at the top of
64
+ a gated page. Never build your own navbar, version line or share button. Never depend on it
65
+ either: the public link and the downloaded file have no bar.
66
+ - **Persisted checkboxes** — `<input type="checkbox" data-persist="stable-slug">` keeps its state
67
+ on the host across reloads, viewers and versions, and the bar records who ticked it. This works
68
+ in **any** page, including one built with this guide. Keep the key stable across versions or the
69
+ ticks are lost.
70
+ - **Relative asset paths only** (`./chart.png`). Artifacts are served under a path prefix.
71
+ ---
72
+
73
+ ## 1. The one-file rule
74
+
75
+ One `.html` file. Inline the CSS, the JS, the logo and any images (as data URIs). No build step,
76
+ no external stylesheet, no framework. Two consequences worth internalising:
77
+
78
+ - **Charts are hand-written SVG.** No charting library. There is an `el()` helper (below) and
79
+ every chart is ~60 lines of explicit geometry. This is deliberate: it survives being pasted
80
+ anywhere and never breaks on a CDN change.
81
+ - **A published page is frozen.** Numbers are literals in the JS, not queries. Anything that will
82
+ be re-run must be re-queried and re-pasted, so keep the data arrays together at the top of each
83
+ chart block with a comment saying when they were pulled and from what.
84
+
85
+ ## 2. Type
86
+
87
+ ```css
88
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
89
+ font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
90
+ ```
91
+
92
+ Inter throughout. Numerals are always `font-variant-numeric: tabular-nums` in tables and chart
93
+ labels so columns line up. Monospace is *Inter* too (`"Inter", monospace`) — there is no separate
94
+ mono face; the label just gets tabular figures.
95
+
96
+ | Role | Size | Weight |
97
+ |---|---|---|
98
+ | `h1` page title | 28px | 800 |
99
+ | `.kpi-section-title` | 20px | 700 |
100
+ | `.kpi-keyfinding` (the sentence under a title) | 17.5px | 400 |
101
+ | `.chart-title` | 14.5px | 700 |
102
+ | `.card-title` | 16px | 700 |
103
+ | body / `.kf-list li` | 14px | 400 |
104
+ | table cells | 12.5-13px | 400 |
105
+ | `.kpi-metric-name` | 13.5px | 700 |
106
+ | `.kpi-metric-sub`, footnotes | 11-12px | 400 |
107
+ | `.page-eyebrow`, `.page-byline` | 10.5px | 700, `letter-spacing: .09em` |
108
+
109
+ **Precision**: one decimal on every metric and percentage ("4.2 min", "10.4%"). Currency keeps
110
+ two ("$0.60"). Round at display time only.
111
+
112
+ ## 3. Palette
113
+
114
+ Copy this block verbatim. It is theme-aware: light on bare `:root`, dark under both
115
+ `prefers-color-scheme` and an explicit `[data-theme="dark"]`.
116
+
117
+ ```css
118
+ :root {
119
+ color-scheme: light;
120
+ --page: #f9f9f7;
121
+ --surface: #fcfcfb;
122
+ --ink: #0b0b0b;
123
+ --ink-2: #52514e;
124
+ --ink-muted: #898781;
125
+ --grid: #e1e0d9;
126
+ --axis: #c3c2b7;
127
+ --border: rgba(11,11,11,0.10);
128
+ --up: #006300;
129
+
130
+ --dcl-red: #FF2D55;
131
+ --dcl-purple: #39055C;
132
+ --dcl-purple-2:#A524B3;
133
+
134
+ --desktop: #2a78d6;
135
+ --mobile: #008300;
136
+ --line: #FF2D55;
137
+
138
+ --good: #0ca30c;
139
+ --warning: #fab219;
140
+ --serious: #ec835a;
141
+ --critical: #FF2D55;
142
+
143
+ --tier-high: #2a78d6;
144
+ --tier-mid: #1baf7a;
145
+ --tier-low: #eda100;
146
+ --tier-unscored: #ababa3;
147
+ --tier-no-telemetry: var(--critical);
148
+
149
+ --pt1: #d7ecd2;
150
+ --pt2: #a9d69f;
151
+ --pt3: #79bd6a;
152
+ --pt4: #429b30;
153
+ --pt5: #196b0d;
154
+ --scpt1: #FF2D55;
155
+ --scpt2: #f3722c;
156
+ --scpt3: #f9c74f;
157
+ --scpt4: #90be6d;
158
+ --scpt5: #39ff14;
159
+
160
+ --card-shadow: 0 1px 2px rgba(11,11,11,0.04), 0 8px 24px rgba(11,11,11,0.05);
161
+ --head-label-ink: color-mix(in srgb, #ffffff 68.5%, var(--ink-muted));
162
+ --sources-ink: var(--ink-muted);
163
+ --sources-ink-strong: var(--ink-2);
164
+ }
165
+ ```
166
+
167
+ Dark mode redefines the same tokens (note the **purple gradient page background** — dark mode is
168
+ not just an inverted light mode):
169
+
170
+ ```css
171
+ :root[data-theme="dark"] {
172
+ color-scheme: dark;
173
+ --page: #0d0d0d;
174
+ --page-gradient: linear-gradient(to right, #C640CD 0%, #691FA9 100%);
175
+ --surface: #1a1a19;
176
+ --ink: #ffffff;
177
+ --ink-2: #c3c2b7;
178
+ --ink-muted: #898781;
179
+ --grid: #2c2c2a;
180
+ --axis: #383835;
181
+ --border: rgba(255,255,255,0.10);
182
+ --up: #0ca30c;
183
+
184
+ --dcl-red: #FF2D55;
185
+ --dcl-purple: #C77DFF;
186
+ --dcl-purple-2:#D68BFF;
187
+
188
+ --desktop: #3987e5;
189
+ --mobile: #008300;
190
+ --line: #FF2D55;
191
+
192
+ --good: #0ca30c;
193
+ --warning: #fab219;
194
+ --serious: #ec835a;
195
+ --critical: #FF2D55;
196
+
197
+ --tier-high: #3987e5;
198
+ --tier-mid: #199e70;
199
+ --tier-low: #c98500;
200
+ --tier-unscored: #6b6b64;
201
+ --tier-no-telemetry: var(--critical);
202
+
203
+ --pt1: #1c3517;
204
+ --pt2: #2c5a22;
205
+ --pt3: #3e8830;
206
+ --pt4: #55b840;
207
+ --pt5: #74e858;
208
+ --scpt1: #FF2D55;
209
+ --scpt2: #f3722c;
210
+ --scpt3: #f9c74f;
211
+ --scpt4: #90be6d;
212
+ --scpt5: #39ff14;
213
+
214
+ --card-shadow: 0 1px 2px rgba(0,0,0,0.3), 0 8px 24px rgba(0,0,0,0.35);
215
+ --head-label-ink: color-mix(in srgb, #ffffff 68.5%, var(--ink-muted));
216
+ --sources-ink: #ffffff;
217
+ --sources-ink-strong: #ffffff;
218
+ }
219
+ ```
220
+
221
+ Under `@media (prefers-color-scheme: dark)` guard the same overrides with
222
+ `:root:where(:not([data-theme="light"]))`.
223
+
224
+ **Token meanings that matter**
225
+
226
+ - `--dcl-red #FF2D55` — the brand accent. Selected tab underline, chart trend lines, the
227
+ "critical" status. Use sparingly; it is loud.
228
+ - `--mobile #008300` / `--desktop #2a78d6` — the platform pair. **Never swap them**; every chart
229
+ in the family uses green = mobile, blue = desktop.
230
+ - `--good / --warning / --serious / --critical` — status scale for KPI chips and heat scales.
231
+ `--critical` is the same red as the brand accent by design.
232
+ - `--pt1..--pt5` — a 5-step green ramp for playtime buckets (light → dark = short → long).
233
+ - `--tier-*` — device tiers. `--tier-no-telemetry` is deliberately `--critical`: missing telemetry
234
+ is a problem, not a neutral category.
235
+
236
+ **Dark-mode text override.** The purple gradient background needs sub-headers, tab labels and the
237
+ footer forced to `--ink` (white), or they inherit muted grey and vanish:
238
+
239
+ ```css
240
+ :root[data-theme="dark"] .sub, :root[data-theme="dark"] .tab-btn,
241
+ :root[data-theme="dark"] footer, :root[data-theme="dark"] footer a { color: var(--ink); }
242
+ ```
243
+
244
+ ## 4. Page shell
245
+
246
+ ```html
247
+ <div class="wrap"> <!-- max-width 920px; padding 40px 24px 64px; flex column, gap 28px -->
248
+ <header class="page-head"> <!-- eyebrow + h1 + sub | logo + byline -->
249
+ <div class="tabs" role="tablist">
250
+ <div class="tab-panel" role="tabpanel" id="tab-panel-N" hidden>
251
+ ```
252
+
253
+ **Header**: left column is `.page-eyebrow` (uppercase project tag) → `h1#page-title` →
254
+ `p.sub#page-sub`; right column is the 40x40 logo above `.page-byline` ("by @Name"). The h1 and sub
255
+ are **driven by the tab machinery**, not static — see §5.
256
+
257
+ **Logo** (paste as-is into `.page-head-logo`):
258
+
259
+ ```html
260
+ <svg viewBox="0 0 90 90" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M45 89.9999C69.8528 89.9999 89.9999 69.8528 89.9999 45C89.9999 20.1472 69.8528 0 45 0C20.1472 0 0 20.1472 0 45C0 69.8528 20.1472 89.9999 45 89.9999Z" fill="url(#dcl0)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18 80.9996C25.515 86.6471 34.875 89.9996 45 89.9996C55.125 89.9996 64.485 86.6471 72 80.9996H18Z" fill="#FF2D55"/><path fill-rule="evenodd" clip-rule="evenodd" d="M9 71.9996C11.565 75.397 14.603 78.435 18 80.9996H72C75.398 78.435 78.435 75.397 81 71.9996H9Z" fill="#FFA25A"/><path fill-rule="evenodd" clip-rule="evenodd" d="M60.368 62.9997H3.758C5.153 66.2172 6.93 69.2322 9 71.9997H60.39V62.9997H60.368Z" fill="#FFC95B"/><path fill-rule="evenodd" clip-rule="evenodd" d="M31.883 29.25V63H60.008L31.883 29.25Z" fill="url(#dcl1)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M3.758 63H31.883V29.25L3.758 63Z" fill="white"/><path fill-rule="evenodd" clip-rule="evenodd" d="M60.368 47.25V72H81L60.368 47.25Z" fill="url(#dcl2)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M39.757 72H60.367V47.25L39.757 72Z" fill="white"/><path fill-rule="evenodd" clip-rule="evenodd" d="M60.368 40.5C66.58 40.5 71.618 35.463 71.618 29.25C71.618 23.037 66.58 18 60.368 18C54.154 18 49.118 23.037 49.118 29.25C49.118 35.463 54.154 40.5 60.368 40.5Z" fill="#FFC95B"/><path fill-rule="evenodd" clip-rule="evenodd" d="M31.882 22.5C34.989 22.5 37.507 19.981 37.507 16.875C37.507 13.768 34.989 11.25 31.882 11.25C28.776 11.25 26.257 13.768 26.257 16.875C26.257 19.981 28.776 22.5 31.882 22.5Z" fill="#FFC95B"/><defs><linearGradient id="dcl0" x1="45" y1="-18.64" x2="-18.64" y2="45" gradientUnits="userSpaceOnUse"><stop stop-color="#FF2D55"/><stop offset="1" stop-color="#FFBC5B"/></linearGradient><linearGradient id="dcl1" x1="31.873" y1="29.25" x2="31.873" y2="63" gradientUnits="userSpaceOnUse"><stop stop-color="#A524B3"/><stop offset="1" stop-color="#FF2D55"/></linearGradient><linearGradient id="dcl2" x1="60.36" y1="47.25" x2="60.36" y2="72" gradientUnits="userSpaceOnUse"><stop stop-color="#A524B3"/><stop offset="1" stop-color="#FF2D55"/></linearGradient></defs></svg>
261
+ ```
262
+
263
+ **Cards**: `section.card` — `--surface` background, 1px `--border`, `border-radius: 14px`,
264
+ `box-shadow: var(--card-shadow)`, padding `22px 22px 18px`. Every chart and table lives in one.
265
+
266
+ ## 5. Navigation
267
+
268
+ A single non-wrapping tab strip that scrolls sideways rather than reflowing to two rows.
269
+
270
+ ```html
271
+ <button class="tab-btn" role="tab" id="tab-btn-3" aria-selected="false"
272
+ aria-controls="tab-panel-3" data-idx="3"><span class="tab-icon">⏳</span>Playtime</button>
273
+ ```
274
+
275
+ ```js
276
+ const btns = Array.from(document.querySelectorAll(".tab-btn"));
277
+ const panels = [0,1,2].map(i => document.getElementById("tab-panel-" + i));
278
+ const meta = [{ title: "...", sub: "" }]; // one per panel index
279
+ function select(idx) {
280
+ btns.forEach(b => b.setAttribute("aria-selected", String(Number(b.dataset.idx) === idx)));
281
+ panels.forEach((p,i) => { p.hidden = i !== idx; });
282
+ titleEl.textContent = meta[idx].title; // the h1 follows the tab
283
+ subEl.innerHTML = meta[idx].sub; subEl.hidden = !meta[idx].sub;
284
+ }
285
+ ```
286
+
287
+ - `data-idx` is the panel index and **need not match strip order** — the strip is ordered for the
288
+ reader, the indices for the code.
289
+ - Selected tab: `--ink` text + 2px `--dcl-red` bottom border. Hover: 7% ink tint + a 45%-alpha red
290
+ underline previewing the selection (colour alone is not enough, because dark mode forces all tab
291
+ labels to the same ink).
292
+ - **Emoji tab icons**, one per tab, in a `.tab-icon` span (`margin-right: 6px`, scales 1.14x on
293
+ hover). Reuse the same emoji anywhere else that metric appears — section headings
294
+ (`.sec-icon`) and KPI table rows (`.kpi-metric-icon`) — so the reader recognises it across tabs.
295
+ - Cross-tab links use `data-jump="N"` + a `window.__selectTab(n)` that also scrolls to top.
296
+
297
+ ## 6. Content blocks
298
+
299
+ **Section title → key finding → card.** This trio is the spine of every tab:
300
+
301
+ ```html
302
+ <div class="kpi-section-title"><span class="sec-icon">🤝</span>Social Behavior</div>
303
+ <div class="kpi-keyfinding">The one sentence the reader must leave with, <b>numbers bolded</b>.</div>
304
+ <section class="card"> ... </section>
305
+ ```
306
+
307
+ The key finding is a **claim, not a caption** — it states what the chart proves, in prose, with
308
+ the load-bearing figures in `<b>`. Never write "the chart below shows…".
309
+
310
+ **Collapsible section** — same trio, wrapped, for secondary material. Ships **closed** (no `open`
311
+ attribute) so a tab opens condensed:
312
+
313
+ ```html
314
+ <details class="kpi-collapse">
315
+ <summary class="kpi-section-title">Secondary cut</summary>
316
+ <div class="kpi-keyfinding">…</div>
317
+ <section class="card">…</section>
318
+ </details>
319
+ ```
320
+
321
+ The caret is a CSS `::before` `\25B8` at `font-size: 1.44em; line-height: .7` that rotates 90° when
322
+ open. Inside a `<details>` the children are ordinary blocks, so the flex gap and the key finding's
323
+ negative margins do not apply — spacing is set explicitly by
324
+ `details.kpi-collapse > .kpi-keyfinding { margin: 14px 2px 16px; }`.
325
+
326
+ **Stat tiles** for a few headline figures: `.stat-row > .stat-tile > .stat-value + .stat-label`.
327
+ Variants `.stat-row-tight` and `.stat-row-sm` for denser rows.
328
+
329
+ **Findings / Recommendations** side by side: `section.card.kf-two-col` with two `.kf-block`s, each
330
+ led by `.kf-block-title.kf-find` (blue dot) or `.kf-rec` (green dot). Collapses to stacked under
331
+ 720px, and the vertical divider becomes a horizontal rule.
332
+
333
+ **Sources**: every tab ends with `<hr class="sources-divider">` and a
334
+ `<details class="sources-dropdown"><summary>See Sources</summary>` holding a two-column table
335
+ (Chart / Analysis | Source & Query). Rendered by a `renderSources(tableId, rows)` that
336
+ early-returns if the table is absent, so a tab with no queries can simply omit it.
337
+
338
+ ## 7. Tables
339
+
340
+ `table.kpi-table` (fixed layout, `border-separate`, `border-spacing: 0`).
341
+
342
+ - Header: centred, 14px/700, `--ink`, 1px `--border` bottom.
343
+ - Cells: right-aligned by default; the metric cell and status cell are left-aligned.
344
+ - Metric cell holds `.kpi-metric-name` (+ optional `.kpi-metric-icon`) and `.kpi-metric-sub`.
345
+ - `.pivot` variant for segment comparisons: `table-layout: auto`, centred cells, a
346
+ **`.pivot-overall` reference column styled distinctly and excluded from best/worst ranking**.
347
+ - `td.pivot-best` green tint / `td.pivot-worst` red tint for the winner and loser in each row.
348
+ - Per-column **heat scale** (`heatStyle(col, value)`) tints the highest value green and the lowest
349
+ red *by magnitude only* — say so in a footnote, because a low value is not always "bad".
350
+ - Wide tables live in `.pivot-wrap` / `.table-scroll` (`overflow-x: auto`). **The page body must
351
+ never scroll sideways.**
352
+ - Footnotes go in `.kpi-footnote-group > .kpi-footnote` under the table, italic, 12px.
353
+
354
+ **Status chips** (`.kpi-status.status-good|review|shortfall`): a coloured dot + text on a 14%-alpha
355
+ background of the same colour. A second `.kpi-growth-chip` sits under the first for a
356
+ "vs previous test" delta. **Always render the plain-language read** ("20% below target"), never a
357
+ bare signed number — the sign's meaning is metric-dependent (lower is better for cost and crash
358
+ rate, worse for engagement).
359
+
360
+ **Dedup rule.** A "platform share" style row is deduplicated (an entity present on both sides
361
+ counts once in the total), so the two shares legitimately sum to slightly over 100%; a stacked
362
+ share **chart** divides by the sum and is exactly 100%. When both appear together the table needs
363
+ a footnote saying so. Never reconcile them by changing a denominator.
364
+
365
+ ## 8. Charts
366
+
367
+ Hand-written SVG. The helper — **note `createElementNS`**:
368
+
369
+ ```js
370
+ const NS = "http://www.w3.org/2000/svg";
371
+ function el(tag, attrs, parent) {
372
+ const e = document.createElementNS(NS, tag); // document.createElement() silently fails
373
+ for (const k in attrs) e.setAttribute(k, attrs[k]);
374
+ if (parent) parent.appendChild(e);
375
+ return e;
376
+ }
377
+ ```
378
+
379
+ **Structure**
380
+
381
+ ```html
382
+ <section class="card">
383
+ <div class="chart-title">Monthly players &amp; mobile share</div>
384
+ <div class="legend"><span class="legend-item"><span class="swatch" style="background:var(--mobile)"></span>Mobile</span></div>
385
+ <div class="chart-wrap">
386
+ <div class="chart-scroll"><svg id="chartN" viewBox="0 0 860 340" width="860" height="340"></svg></div>
387
+ <div class="tooltip" id="tooltipN"></div>
388
+ </div>
389
+ <details class="table-toggle"><summary>View data table</summary>…</details>
390
+ </section>
391
+ ```
392
+
393
+ - `viewBox` + `width`/`height` **and** any `H`/`W` constant in the JS must be changed together, or
394
+ the drawing scales wrong.
395
+ - `.chart-scroll svg { width: 100%; height: auto; }` — charts are authored at a fixed logical size
396
+ and scale down.
397
+ - Every chart offers a `.table-toggle` "View data table" with the underlying numbers.
398
+
399
+ **Chart type conventions**
400
+
401
+ | Class | Use |
402
+ |---|---|
403
+ | `.tick-label` | axis values, 11px, `--ink-2` |
404
+ | `.month-label` | x-axis categories, same treatment |
405
+ | `.axis-title` | 10.5px uppercase, `.03em`, `--ink-muted` |
406
+ | `.value-label` | data labels, 10px, tabular |
407
+ | `.value-label.on-line` | label on a trend line, coloured `--line` |
408
+ | `.gridline` | **`display: none`** — gridlines are off by default |
409
+ | `.baseline` | the single axis rule, `--axis`, 1px |
410
+ | `.bar.mobile` / `.bar.desktop` | the platform pair |
411
+ | `.share-line` + `.share-dot` | the red overlay trend line and its markers |
412
+
413
+ - **Both** `viewBox` units and CSS px appear; class-driven label sizes need a scoped override
414
+ (`#chartN .tick-label { font-size: 14.3px }`) while inline sizes are attributes on the element.
415
+ - Tooltips: absolutely positioned `.tooltip` inside `.chart-wrap`, `.show` toggles opacity,
416
+ positioned from `getBoundingClientRect()` scaled by `cx / W * bounds.width`.
417
+ - Two-line category labels are `<tspan x=cx dy=0>` + `<tspan x=cx dy=12>`; **raise `dy` whenever
418
+ you raise the font size** or the lines collide.
419
+ - **Copy-paste hazard**: charts are often cloned. Before find-and-replacing any geometry or font
420
+ size, check whether the same string exists in another chart's block and anchor the replacement
421
+ on that chart's `svg` variable.
422
+
423
+ **Milestone highlight**: to call out one column, draw a rounded `rect` behind it first
424
+ (`color-mix(in srgb, var(--ink) 6%, transparent)` fill, `--border` stroke) so the bars sit on top.
425
+ Any label lifted out of the box must be re-checked against the top margin after a height change.
426
+
427
+ **Funnel bar width**: `Math.min(cap, bandW * ratio)`. The **cap usually binds** — raising only the
428
+ ratio does nothing. Change both.
429
+
430
+ **Broken axis**: when a few outliers stretch a scale, give the dense range ~75-85% of the width
431
+ and compress the tail into the rest, with a twin-slash break marker on the axis and an explicit
432
+ tick list (an every-N loop will crowd the compressed side).
433
+
434
+ ## 9. Diagrams
435
+
436
+ For a non-charted diagram (a phased timeline, a gated pipeline), use CSS grid + absolutely
437
+ positioned markers rather than SVG — text wraps natively and it stays responsive:
438
+
439
+ - N equal columns, an absolutely positioned rail at the dot centres
440
+ (`top = padding-top + dot-radius`), markers placed by percentage (`left: 25%` for the boundary
441
+ between 4 columns).
442
+ - Verdict colour on the marker: `--good` where a gate is cleared, `--critical` where it is not.
443
+ - **Everything is coupled to the rail.** If you change the top padding you must move the rail, the
444
+ marker bars and any band together.
445
+ - Under the breakpoint, hide the rail and markers and reflow the items to a 2-column grid — the
446
+ percentage positioning is meaningless once columns stack.
447
+
448
+ ## 10. Responsive + theme rules
449
+
450
+ - Relative units, flex/grid, `max-width: 100%` on images.
451
+ - Wide content scrolls inside its own container; the body never scrolls horizontally.
452
+ - Define the **complete** light palette on bare `:root`; only redefine tokens in the dark blocks.
453
+ Never give a colour its only definition inside a media query.
454
+ - Give `body` an explicit token background.
455
+ - Breakpoints in use: **720px** (two-col → stacked), **780px** (diagram rails off), **860px**
456
+ (split cards stack).
457
+
458
+ ## 11. Embeds
459
+
460
+ **A third-party `<iframe>` is blocked in Claude Artifacts.** The viewer's CSP admits only the
461
+ artifact's own files, Google Fonts and a few script CDNs; Drive, YouTube and everything else
462
+ render blank and the publish step warns "loads N embedded frames from other websites". The same
463
+ page **works on `artifacts.dclregenesislabs.xyz`**. So: link out for Claude Artifacts, embed only
464
+ when the DCL host is the target — and say which you are building for.
465
+
466
+ **Google Drive video embed** (`/preview`, not `/view`): the player has a minimum layout width, so
467
+ in a narrow slot it renders only its left letterbox bar with the video cut off. Fix by
468
+ over-sizing and centring the iframe:
469
+
470
+ ```css
471
+ .video-frame { position: relative; overflow: hidden; } /* the visible box */
472
+ .video-frame iframe {
473
+ position: absolute; top: 50%; left: 50%;
474
+ width: 400%; height: 100%; /* 4x the box, centred */
475
+ transform: translate(-50%, -50%); border: 0;
476
+ }
477
+ ```
478
+
479
+ The video is sized by the player's height, so in a 9/16 box it comes out exactly box-width and the
480
+ black bars crop away symmetrically.
481
+
482
+ ## 12. Gotchas that cost real time
483
+
484
+ 1. **`document.createElement` for SVG** produces an element that appends without error and never
485
+ renders. Always `createElementNS`.
486
+ 2. **CSS specificity**: an element carrying two classes needs both in the selector.
487
+ `.kf-conclusion { margin-top: 18px }` loses to a later `.kf-recap { margin: 0 }`; it must be
488
+ `.kf-recap.kf-conclusion`.
489
+ 3. **Margin shorthand resets**: a later `margin:` shorthand silently wipes an earlier
490
+ `margin-top`. Keep a block's margin in one declaration.
491
+ 4. **`aspect-ratio` vs flex**: a flex child that must stretch needs `min-height: 0` (or
492
+ `min-width: 0` in a row) or it refuses to shrink and forces the container.
493
+ 5. **`align-items: flex-start`** on a flex row means a column only grows to its own content — a
494
+ child can never reach the container's height. Use `stretch`.
495
+ 6. **Emoji in JS** are written as `\u` escapes throughout, for consistency and to avoid encoding
496
+ surprises.
497
+ 7. **A `<details>` child is not a flex item** of the tab panel, so the panel's `gap` and any
498
+ negative margins tuned against it stop applying inside.
package/README.md CHANGED
@@ -127,39 +127,29 @@ Claude picks all of this up through the project skill in
127
127
 
128
128
  ## Install the skill (one line)
129
129
 
130
- macOS / Linux:
131
-
132
130
  ```bash
133
- curl -fsSL https://artifacts.dclregenesislabs.xyz/install.sh | bash
134
- ```
135
-
136
- Windows (PowerShell):
137
-
138
- ```powershell
139
- irm https://artifacts.dclregenesislabs.xyz/install.ps1 | iex
131
+ npx @dcl-regenesislabs/artifacts setup
140
132
  ```
141
133
 
142
- No secret in either line. `scripts/install.sh` / `scripts/install.ps1` are
143
- served by the Worker at a public path together with `dist/dcl-artifacts.tgz` —
144
- the CLI, the shared id module, the house template and the skill with its style
145
- reference, built by `scripts/build-dist.mjs` before every `npm run dev` /
146
- `npm run deploy` and checked to contain nothing token-shaped. The installer
147
- unpacks the tarball into `~/.dcl-artifacts`, writes the skill to
148
- `~/.claude/skills/dcl-artifacts/` with that path filled in, and then runs
149
- `dcl-artifacts login`, which opens a browser for the work-email one-time PIN.
150
- Needs Node 20+, nothing else — no git, no repo access, no `npm install`.
151
- Re-run the same line to update.
134
+ Same line on macOS, Linux and Windows. It writes the skill — `SKILL.md` and its
135
+ style reference, both shipped inside the npm package to
136
+ `~/.claude/skills/dcl-artifacts/`, with every command rewritten to
137
+ `npx -y @dcl-regenesislabs/artifacts …`, and then runs `login` unless you are
138
+ already signed in. Run it again to update; it only rewrites what changed.
152
139
 
153
- By hand, with access to this repo (no `npm install` needed; run
154
- `node cli/artifacts.mjs login` afterwards):
140
+ `login` on its own offers to install the skill afterwards when run in a
141
+ terminal, and stays quiet otherwise. `--skill-dir <dir>` puts the skill
142
+ somewhere else — any agent that reads the `SKILL.md` format, e.g.
143
+ `~/.codex/skills/dcl-artifacts`, or `.claude/skills/dcl-artifacts` inside a
144
+ project to share it with a team.
155
145
 
156
- ```bash
157
- git clone git@github.com:dcl-regenesislabs/ia-generated-artifacts.git ~/.dcl-artifacts
158
- ~/.dcl-artifacts/scripts/install-skill.sh # or: npm run skill:install, inside the checkout
159
- ```
146
+ Needs Node 20+, nothing else — no git, no repo access, no `npm install`. The
147
+ old `curl … | bash` and `irm … | iex` lines still work: they are stubs that
148
+ run the line above.
160
149
 
161
- The one-line installers leave an existing checkout at `~/.dcl-artifacts` alone,
162
- so the two ways don't fight.
150
+ Inside this repository, the project-level skill at `.claude/skills/dcl-artifacts/`
151
+ is the source of truth and runs `node cli/artifacts.mjs` directly — `npx` cannot
152
+ resolve a package from inside its own checkout.
163
153
 
164
154
  ## Signing in
165
155
 
@@ -198,8 +188,7 @@ ever pasted anywhere, and the token is never printed.
198
188
  | `scripts/smoke.sh` | end-to-end check against a local or deployed host |
199
189
  | `scripts/setup-access.mjs` | reconciles the Cloudflare Access apps and policies |
200
190
  | `scripts/build-guide.mjs` | regenerates `docs/guide/index.html` from the template assets |
201
- | `scripts/install.sh`, `scripts/install.ps1` | the one-line installers, served at `/install.sh` and `/install.ps1` |
202
- | `scripts/build-dist.mjs` | builds `dist/dcl-artifacts.tgz`, served at `/install/dcl-artifacts.tgz` |
191
+ | `scripts/install.sh`, `scripts/install.ps1` | legacy installer stubs served at `/install.sh` and `/install.ps1`; they run `npx … setup` |
203
192
 
204
193
  R2 layout, per artifact:
205
194
 
@@ -248,7 +237,7 @@ path first, which gives these rules:
248
237
  | `/_api/*` | bypass | already guarded by the bearer token |
249
238
  | `/_health` | bypass | exposes nothing, keeps uptime monitors working |
250
239
  | `/p/*` | bypass | opt-in public aliases carry their own secret |
251
- | `/install.sh`, `/install.ps1`, `/install/*` | bypass | the installers and their tarball; no secrets in any of them |
240
+ | `/install.sh`, `/install.ps1` | bypass | legacy installer stubs; no secrets in them |
252
241
  | everything else | allow `@dclregenesislabs.xyz`, `@decentraland.org` | the gate — pages, `/__ui/bar.js`, the in-page API |
253
242
 
254
243
  Login is Cloudflare's built-in **one-time PIN**: the visitor types their email,
@@ -290,7 +279,7 @@ npx wrangler r2 bucket create dcl-artifacts-preview # used by `wrangler dev
290
279
  npx wrangler kv namespace create TOKENS # put the id in wrangler.jsonc
291
280
  npx wrangler kv namespace create TOKENS --preview # and the preview_id
292
281
  npx wrangler secret put UPLOAD_TOKEN # the shared token, until it is retired
293
- npm run deploy # builds dist/, provisions the custom domain
282
+ npm run deploy # provisions the custom domain
294
283
 
295
284
  # Cloudflare Access — needs an API token with Access: Apps and Policies = Edit
296
285
  CLOUDFLARE_API_TOKEN=… node scripts/setup-access.mjs --dry-run
@@ -305,8 +294,9 @@ policies to match the table above, so re-run it after any policy change.
305
294
 
306
295
  ### Releasing the CLI to npm
307
296
 
308
- The package is `cli/`, `shared/` and `templates/` the `files` list in
309
- `package.json`; nothing of the Worker ships. Releases go through
297
+ The package is `cli/`, `shared/`, `templates/` and the skill under
298
+ `.claude/skills/dcl-artifacts/` the `files` list in `package.json`; nothing
299
+ of the Worker ships. Releases go through
310
300
  `.github/workflows/publish.yml` using npm **trusted publishing**: npm trusts
311
301
  that workflow's GitHub OIDC identity, so there is no npm token in the repo, in
312
302
  Actions secrets, or anywhere else, and every release carries provenance.
package/cli/artifacts.mjs CHANGED
@@ -15,6 +15,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
15
15
  import { realpathSync } from "node:fs";
16
16
  import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
17
17
  import { createServer } from "node:http";
18
+ import { createInterface } from "node:readline/promises";
18
19
  import { homedir, hostname, userInfo } from "node:os";
19
20
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
20
21
  import { fileURLToPath } from "node:url";
@@ -31,6 +32,11 @@ import {
31
32
 
32
33
  const DEFAULT_BASE = "https://artifacts.dclregenesislabs.xyz";
33
34
  const TEMPLATE_DIR = fileURLToPath(new URL("../templates/artifact/", import.meta.url));
35
+ /** The skill as authored. Shipped in the npm package at the same relative path as in the repo. */
36
+ const SKILL_SRC = fileURLToPath(new URL("../.claude/skills/dcl-artifacts/", import.meta.url));
37
+ const PACKAGE = "@dcl-regenesislabs/artifacts";
38
+ /** What the installed skill tells the agent to run. The authored file says `node cli/artifacts.mjs`. */
39
+ const SKILL_COMMAND = `npx -y ${PACKAGE}`;
34
40
  const TEMPLATE_TITLE = "Artifact template";
35
41
  const MAX_FILE_BYTES = 25 * 1024 * 1024;
36
42
  const UPLOAD_CONCURRENCY = 6;
@@ -53,6 +59,7 @@ const MIME = {
53
59
  const USAGE = `dcl-artifacts — publish static artifacts to ${DEFAULT_BASE}
54
60
 
55
61
  Usage
62
+ dcl-artifacts setup [--skill-dir <dir>] Install the Claude Code skill and sign in. Run it again to update.
56
63
  dcl-artifacts login Sign in with your work email, in a browser
57
64
  dcl-artifacts logout [--all] Revoke this machine's token (--all: every machine)
58
65
  dcl-artifacts whoami Who the current token belongs to
@@ -74,6 +81,9 @@ Options
74
81
  saved credentials.
75
82
  --all push: include normally-skipped entries (.git, node_modules, ...)
76
83
  logout: revoke every token issued to your email, not just this one
84
+ --skill-dir <dir> Where setup/login put the skill. Default ~/.claude/skills/dcl-artifacts.
85
+ Any tool that reads the SKILL.md format works, e.g.
86
+ ~/.codex/skills/dcl-artifacts, or .claude/skills/dcl-artifacts in a project.
77
87
  --json Emit machine-readable JSON on stdout
78
88
  -h, --help Show this help
79
89
 
@@ -84,19 +94,19 @@ URLs
84
94
  the bar at the top of the page, by anyone
85
95
  who can open it. Not from here.
86
96
 
87
- Signing in
88
- 'login' opens a browser, you sign in with a @dclregenesislabs.xyz or
89
- @decentraland.org email, and the token it gives back is written to
90
- ~/.config/dcl-artifacts/credentials.json (mode 600). Tokens last 90 days;
91
- run 'login' again to renew, 'logout' to revoke. Nothing needs to be pasted
92
- anywhere, and no token is ever printed.
97
+ Getting started
98
+ 'setup' installs the skill Claude Code uses to publish, then opens a browser
99
+ where you sign in with a @dclregenesislabs.xyz or @decentraland.org email.
100
+ The token it gives back is written to ~/.config/dcl-artifacts/credentials.json
101
+ (mode 600). Tokens last 90 days; run 'login' again to renew, 'logout' to
102
+ revoke. Nothing needs to be pasted anywhere, and no token is ever printed.
93
103
 
94
104
  An artifact keeps its id for life; each push with --id adds a version. Pushing
95
105
  bytes identical to the latest version records nothing. Blobs are stored by
96
106
  digest, so a new version uploads only what changed.`;
97
107
 
98
108
  function parseArgs(argv) {
99
- const opts = { positional: [], name: null, id: null, base: null, token: null, all: false, json: false };
109
+ const opts = { positional: [], name: null, id: null, base: null, token: null, all: false, json: false, skillDir: null };
100
110
  for (let i = 0; i < argv.length; i++) {
101
111
  const arg = argv[i];
102
112
  switch (arg) {
@@ -105,6 +115,7 @@ function parseArgs(argv) {
105
115
  case "--base": opts.base = argv[++i]; break;
106
116
  case "--token": opts.token = argv[++i]; break;
107
117
  case "--all": opts.all = true; break;
118
+ case "--skill-dir": opts.skillDir = argv[++i]; break;
108
119
  case "--json": opts.json = true; break;
109
120
  case "-h": case "--help": opts.help = true; break;
110
121
  default:
@@ -405,6 +416,69 @@ async function forgetCredentials(base, log) {
405
416
  await writeSecret(path, `${JSON.stringify(file, null, 2)}\n`);
406
417
  }
407
418
 
419
+ // ---------------------------------------------------------------- skill
420
+
421
+ /**
422
+ * Where the Claude Code skill goes by default. Any tool that reads the
423
+ * SKILL.md format can be pointed at instead with --skill-dir.
424
+ */
425
+ const defaultSkillDir = () => join(homedir(), ".claude", "skills", "dcl-artifacts");
426
+
427
+ /** The skill files as they should be on disk, with the command rewritten for the package. */
428
+ async function renderSkill() {
429
+ const skill = (await readFile(join(SKILL_SRC, "SKILL.md"), "utf8")).replaceAll("node cli/artifacts.mjs", SKILL_COMMAND);
430
+ const style = await readFile(join(SKILL_SRC, "references", "regenesis-artifact-style.md"), "utf8");
431
+ return { "SKILL.md": skill, "references/regenesis-artifact-style.md": style };
432
+ }
433
+
434
+ /** True when every file is already there with the same content. */
435
+ async function skillInstalled(dir) {
436
+ const want = await renderSkill();
437
+ for (const [rel, body] of Object.entries(want)) {
438
+ const have = await readFile(join(dir, rel), "utf8").catch(() => null);
439
+ if (have !== body) return false;
440
+ }
441
+ return true;
442
+ }
443
+
444
+ async function installSkill(dir) {
445
+ const files = await renderSkill();
446
+ for (const [rel, body] of Object.entries(files)) {
447
+ await mkdir(dirname(join(dir, rel)), { recursive: true });
448
+ await writeFile(join(dir, rel), body);
449
+ }
450
+ return Object.keys(files).length;
451
+ }
452
+
453
+ /**
454
+ * Yes/no on a real terminal. Anywhere else — CI, a pipe, `curl | bash` — the
455
+ * answer is no: nothing gets installed that was not explicitly asked for, and
456
+ * `setup` exists for asking explicitly.
457
+ */
458
+ async function confirm(question, preferYes) {
459
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
460
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
461
+ try {
462
+ const answer = (await rl.question(`${question} ${preferYes ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
463
+ if (!answer) return preferYes;
464
+ return answer === "y" || answer === "yes";
465
+ } finally {
466
+ rl.close();
467
+ }
468
+ }
469
+
470
+ /** Is the saved credential, if any, still accepted? */
471
+ async function signedIn(base, log) {
472
+ const entry = await readCredentials(base, log);
473
+ if (!entry) return null;
474
+ try {
475
+ const me = await new Client(base, entry.token).call("GET", "/_api/whoami");
476
+ return me?.email ?? null;
477
+ } catch {
478
+ return null;
479
+ }
480
+ }
481
+
408
482
  // ---------------------------------------------------------------- login
409
483
 
410
484
  const LOGIN_TIMEOUT_MS = 120_000;
@@ -603,6 +677,39 @@ async function main() {
603
677
  ? JSON.stringify({ email: res.email, tokenId: res.tokenId, expiresAt: res.expiresAt }, null, 2)
604
678
  : `signed in as ${res.email} — expires ${res.expiresAt.slice(0, 10)}`,
605
679
  );
680
+ // The natural next step, offered rather than assumed: `setup` is the
681
+ // command that does both without asking.
682
+ const skillDir = opts.skillDir ? resolve(opts.skillDir) : defaultSkillDir();
683
+ if (!opts.json && !(await skillInstalled(skillDir))) {
684
+ if (await confirm(`install the Claude Code skill to ${skillDir}?`, true)) {
685
+ await installSkill(skillDir);
686
+ log(`skill installed -> ${skillDir}`);
687
+ log(`in Claude Code, from any project, try: publish this page as an artifact`);
688
+ } else {
689
+ log(`later: npx -y ${PACKAGE} setup`);
690
+ }
691
+ }
692
+ return;
693
+ }
694
+
695
+ if (command === "setup") {
696
+ const skillDir = opts.skillDir ? resolve(opts.skillDir) : defaultSkillDir();
697
+ const fresh = await skillInstalled(skillDir);
698
+ if (!fresh) await installSkill(skillDir);
699
+ log(`${fresh ? "skill up to date" : "skill installed"} -> ${skillDir}`);
700
+
701
+ let email = await signedIn(base, log);
702
+ if (email) {
703
+ log(`already signed in as ${email}`);
704
+ } else {
705
+ log("signing you in — a browser window should open");
706
+ email = (await login(base, log)).email;
707
+ }
708
+ console.log(
709
+ opts.json
710
+ ? JSON.stringify({ skillDir, email }, null, 2)
711
+ : `ready — ${email}\nin Claude Code, from any project, try: publish this page as an artifact`,
712
+ );
606
713
  return;
607
714
  }
608
715
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcl-regenesislabs/artifacts",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Publish static artifacts to artifacts.dclregenesislabs.xyz — sign in with a work email, push a folder, get a link back",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,8 @@
9
9
  "files": [
10
10
  "cli/",
11
11
  "shared/",
12
- "templates/"
12
+ "templates/",
13
+ ".claude/skills/dcl-artifacts/"
13
14
  ],
14
15
  "engines": {
15
16
  "node": ">=20"
@@ -30,11 +31,7 @@
30
31
  "typecheck": "tsc --noEmit",
31
32
  "push": "node ./cli/artifacts.mjs push",
32
33
  "smoke": "./scripts/smoke.sh",
33
- "skill:install": "./scripts/install-skill.sh",
34
- "guide": "node ./scripts/build-guide.mjs",
35
- "dist": "node ./scripts/build-dist.mjs",
36
- "predev": "node ./scripts/build-dist.mjs",
37
- "predeploy": "node ./scripts/build-dist.mjs"
34
+ "guide": "node ./scripts/build-guide.mjs"
38
35
  },
39
36
  "devDependencies": {
40
37
  "@cloudflare/workers-types": "^5.20260831.1",