@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 & 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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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` |
|
|
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
|
|
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 #
|
|
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
|
|
309
|
-
|
|
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
|
-
|
|
88
|
-
'
|
|
89
|
-
|
|
90
|
-
~/.config/dcl-artifacts/credentials.json
|
|
91
|
-
run 'login' again to renew, 'logout' to
|
|
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.
|
|
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
|
-
"
|
|
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",
|