@jokerized/decksmith 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +183 -36
- package/dist/cli.js +5080 -4955
- package/dist/deck-runtime.js +2 -2
- package/dist/index.js +5953 -5840
- package/dist/mcp.js +3916 -3804
- package/dist/types/build/files.d.ts +2 -0
- package/dist/types/deck/runtime.d.ts +40 -0
- package/dist/types/emit/camera.d.ts +8 -0
- package/dist/types/emit/composition.d.ts +12 -3
- package/dist/types/emit/theme.d.ts +17 -13
- package/dist/types/narrate/narrate.d.ts +73 -3
- package/dist/types/render/timing.d.ts +5 -3
- package/dist/types/server/auth.d.ts +160 -0
- package/dist/types/server/http.d.ts +28 -1
- package/dist/types/server/ui.d.ts +7 -1
- package/dist/types/source/fonts.d.ts +1 -1
- package/dist/types/types.d.ts +29 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -249,7 +249,13 @@ artifact. Three are worth reading as patterns rather than bugs:
|
|
|
249
249
|
sampled frames, because `marginV` was measured to clear *player chrome* and nothing ever
|
|
250
250
|
guaranteed it cleared the *composition*. Both in
|
|
251
251
|
[`.planning/EXPERIMENT-010-reconcile.md`](.planning/EXPERIMENT-010-reconcile.md). The
|
|
252
|
-
|
|
252
|
+
seam one is **closed** in the render: each scene's clip outlasts its slide and dissolves
|
|
253
|
+
over the next scene's empty opening. It stayed open in `deck.html` until 2026-09-18,
|
|
254
|
+
because the presented deck's own visibility pass read the slide instead of the clip,
|
|
255
|
+
and gliding across a seam still showed eleven 60Hz ticks of background. Every gate was
|
|
256
|
+
green, and it was found by stepping that glide one tick at a time —
|
|
257
|
+
[`.planning/2026-09-18-scene-boundary-blink.md`](.planning/2026-09-18-scene-boundary-blink.md).
|
|
258
|
+
The caption one is **closed**: `build --reserve-captions` gives the band its own strip and
|
|
253
259
|
`fidelity` fails a deck that draws into it. It is worth reading for how it closed —
|
|
254
260
|
the plan written to fix it asserted that every archetype lays out into `contentH`, ten
|
|
255
261
|
of them do not, and the first fix therefore changed the worst stop by nothing at all.
|
|
@@ -349,7 +355,7 @@ Startup says what is missing before anyone waits on it:
|
|
|
349
355
|
```
|
|
350
356
|
decksmith: http://127.0.0.1:8475
|
|
351
357
|
decksmith: work /tmp/decksmith-server, one job at a time, 8 may wait
|
|
352
|
-
decksmith: no auth
|
|
358
|
+
decksmith: no auth. Bound to 127.0.0.1, so anyone with an account on this machine can spend your Codex quota — set DECKSMITH_TOKEN_FILE to require a token.
|
|
353
359
|
decksmith: codex, edge-tts and ffmpeg all found
|
|
354
360
|
decksmith: images via codex, then svg
|
|
355
361
|
```
|
|
@@ -360,15 +366,24 @@ for pictures still finishes, on the tool's own SVG if it has to.
|
|
|
360
366
|
|
|
361
367
|
### The HTTP surface
|
|
362
368
|
|
|
363
|
-
| Route | What it does |
|
|
364
|
-
|
|
365
|
-
| `POST /api/jobs` | multipart: `file` (.md/.markdown/.txt/.zip) plus option fields → `202 {id}` |
|
|
366
|
-
| `
|
|
367
|
-
| `GET /api/jobs/:id
|
|
368
|
-
| `GET /api/
|
|
369
|
-
| `GET /
|
|
370
|
-
| `GET /
|
|
371
|
-
| `GET /
|
|
369
|
+
| Route | What it does | With a token file |
|
|
370
|
+
|---|---|---|
|
|
371
|
+
| `POST /api/jobs` | multipart: `file` (.md/.markdown/.txt/.zip) plus option fields → `202 {id}` | token |
|
|
372
|
+
| `POST /api/jobs/:id/retry` | runs a job the server kept on disk again, under a new id | token |
|
|
373
|
+
| `GET /api/jobs/:id` | `{state, stage, steps[], log[], error, result, queuePosition}` | token |
|
|
374
|
+
| `GET /api/jobs/:id/events` | the same payload as SSE on every change | token |
|
|
375
|
+
| `GET /api/formats` | the presets, themes, tones, densities and canvas bounds the picker draws from | token |
|
|
376
|
+
| `GET /d/:id/...` | the built deck, served statically; `/d/:id/deck.html` is the player | public |
|
|
377
|
+
| `GET /player.js` | the `<decksmith-player>` element, as an ES module — see below | public |
|
|
378
|
+
| `GET /examples/embed.html` | a page that embeds two decks with it, and the file you copy | token |
|
|
379
|
+
| `GET /` | the uploader; with a token file and no session, the login page | public |
|
|
380
|
+
| `POST /login`, `POST /logout` | set or clear the session cookie, then 303 to `/` | public, only with a token file |
|
|
381
|
+
|
|
382
|
+
"Token" means `Authorization: Bearer <token>` or the session cookie the login page sets.
|
|
383
|
+
A route that is not in this table answers 401 when a token file is configured, so a route
|
|
384
|
+
added later starts out private. A deck id is 128 random bits and is meant to be shared,
|
|
385
|
+
which is why `/d/` stays public; the job routes are keyed by the same id, which is why
|
|
386
|
+
they do not.
|
|
372
387
|
|
|
373
388
|
Options on `POST`, all optional, all defaulted server-side: `format`, `width`+`height`,
|
|
374
389
|
`theme`, `slides`, `lang`, `tone`, `density`, `speed`, `narrate`, `voice`, `images`,
|
|
@@ -392,46 +407,150 @@ generous one.
|
|
|
392
407
|
| Variable | Default | Why |
|
|
393
408
|
|---|---|---|
|
|
394
409
|
| `PORT` | `8475` | |
|
|
395
|
-
| `DECKSMITH_HOST` | `127.0.0.1` |
|
|
410
|
+
| `DECKSMITH_HOST` | `127.0.0.1` | `127.0.0.1`, `::1` and `localhost` are loopback. **Anything else refuses to start** without `DECKSMITH_TOKEN_FILE`, `DECKSMITH_TLS_CERT` and `DECKSMITH_TLS_KEY` — `127.0.0.2` and `::ffff:127.0.0.1` included. |
|
|
411
|
+
| `DECKSMITH_TOKEN_FILE` | unset | A file holding the token (`openssl rand -base64 32 > f; chmod 600 f`). Set, every route but the public ones in the table above needs it, on any bind. Refused unless it is a regular file, not readable by group or others, 32–1024 characters, one line. |
|
|
412
|
+
| `DECKSMITH_TLS_CERT` / `DECKSMITH_TLS_KEY` | unset | A PEM chain, leaf first, and its **unencrypted** PEM key (mode 600). Set both or neither. Refused unless the certificate is PEM (not DER), matches the key, is in date, has subjectAltNames, and loads into OpenSSL at TLS 1.2. On an exposed bind those subjectAltNames are the only `Host` names the server answers to, and the startup banner prints one of them. |
|
|
413
|
+
| `DECKSMITH_TOKEN` | — | **Not read, and refused if present**, even empty: ignoring it would leave a server someone believes is protected open, and an environment variable reaches shell history and every process the server starts. |
|
|
396
414
|
| `DECKSMITH_WORK` | `$TMPDIR/decksmith-server` | One directory per job; swept by age. |
|
|
397
415
|
| `DECKSMITH_MAX_UPLOAD` | 25 MB | |
|
|
398
416
|
| `DECKSMITH_MAX_QUEUE` | `8` | Concurrency is 1. Past 8 the answer is "full", not "position 400". |
|
|
399
417
|
| `DECKSMITH_JOB_TTL_MIN` | `120` | Files outlive the in-memory record; orphans are swept on boot. |
|
|
400
418
|
| `DECKSMITH_JOBS_PER_HOUR` | `5` | Per IP. Charged only on an **accepted** job. |
|
|
401
419
|
| `DECKSMITH_REQS_PER_MIN` | `240` | Per IP. |
|
|
402
|
-
| `DECKSMITH_FETCH_FIGURES` |
|
|
403
|
-
| `DECKSMITH_DECK_SANDBOX` | on | Serves decks under `CSP: sandbox`. |
|
|
420
|
+
| `DECKSMITH_FETCH_FIGURES` | on | See below. |
|
|
421
|
+
| `DECKSMITH_DECK_SANDBOX` | on | Serves decks under `CSP: sandbox`. Turning it off is refused with a token file or on an exposed bind. |
|
|
404
422
|
| `DECKSMITH_IMAGES` | unset | `openai` names a separate image backend for `illustrate`. Unset, pictures come from the Codex account, then the tool's own SVG. |
|
|
405
423
|
| `DECKSMITH_IMAGES_API_KEY` | | The backend's key. Environment only — never a preference, a config file, a `.deck`, or an error message. |
|
|
406
424
|
| `DECKSMITH_IMAGES_BASE_URL` | `https://api.openai.com/v1` | Any OpenAI-compatible `images/generations` endpoint. |
|
|
407
425
|
| `DECKSMITH_IMAGES_MODEL` | `gpt-image-2` | The backend's model. The Codex rung draws with the account's own. |
|
|
408
426
|
|
|
409
|
-
**Remote figures are
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
427
|
+
**Remote figures are fetched by default, and what makes that safe is not the flag.** A
|
|
428
|
+
paper's markdown usually links its images rather than attaching them. `guardFigures` in
|
|
429
|
+
`src/server/pipeline.ts` drops any figure whose host resolves to a private, loopback or
|
|
430
|
+
link-local address, and the fetch checks the address again when it connects, because DNS
|
|
431
|
+
can answer differently the second time. `fetchFigures` does `readFile(src)` on anything
|
|
432
|
+
that is not an http URL, so an uploaded document containing
|
|
433
|
+
`` would have this process read it: relative paths
|
|
434
|
+
are resolved inside the upload directory and confined there. `DECKSMITH_FETCH_FIGURES=0`
|
|
435
|
+
drops every http figure with a named warning. (The table above said "off" until
|
|
436
|
+
2026-09-18, while `main.ts` defaulted it on.)
|
|
437
|
+
|
|
438
|
+
### Opening it to a network
|
|
439
|
+
|
|
440
|
+
To use a server on another machine yourself, leave it on loopback and tunnel:
|
|
441
|
+
`ssh -L 8475:127.0.0.1:8475 <host>`, then open `http://127.0.0.1:8475` locally. Nothing
|
|
442
|
+
else is needed. If other people have accounts on either machine, add a token file.
|
|
443
|
+
|
|
444
|
+
To bind anything else, the server needs all three:
|
|
445
|
+
|
|
446
|
+
```sh
|
|
447
|
+
openssl rand -base64 32 > ~/.decksmith-token && chmod 600 ~/.decksmith-token
|
|
448
|
+
DECKSMITH_HOST=0.0.0.0 \
|
|
449
|
+
DECKSMITH_TOKEN_FILE=~/.decksmith-token \
|
|
450
|
+
DECKSMITH_TLS_CERT=/etc/decksmith/fullchain.pem \
|
|
451
|
+
DECKSMITH_TLS_KEY=/etc/decksmith/privkey.pem \
|
|
452
|
+
npm run serve
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Missing any of them, it refuses to start, names every problem at once, and exits 1 before
|
|
456
|
+
it touches the work directory. There is no override. The certificate must list the names
|
|
457
|
+
people will type as subjectAltNames: those names, and nothing else, are what the server
|
|
458
|
+
answers to, so DNS rebinding is refused on this bind as well as on loopback — and the
|
|
459
|
+
startup banner prints a URL built from one of those SANs, not from the bind address,
|
|
460
|
+
because `https://0.0.0.0:8475` is a URL this server answers 403 to. The certificate is
|
|
461
|
+
checked as far as OpenSSL will check it before anything is created on disk: a PEM chain
|
|
462
|
+
(a DER file is refused by name, with the `openssl x509 -inform der` line that converts
|
|
463
|
+
it), matching the key, in date, with SANs — and then loaded into a real
|
|
464
|
+
`tls.createSecureContext`, so a key OpenSSL will not use, such as an RSA key under 2048
|
|
465
|
+
bits, is a refusal here rather than an OpenSSL stack trace out of `listen`. There is no
|
|
466
|
+
plain-http listener or redirect beside it, and no HSTS. Renewing the certificate or
|
|
467
|
+
rotating the token means restarting.
|
|
468
|
+
|
|
469
|
+
A browser gets a login page at `/`. The token goes in a password field, so a password
|
|
470
|
+
manager can keep it. The server sets a cookie named for the port it is bound to —
|
|
471
|
+
`__Host-decksmith-8475` over TLS, `decksmith-8475` on plain loopback — `HttpOnly`,
|
|
472
|
+
`SameSite=Strict`, valid seven days. It is signed with a key derived from the token and
|
|
473
|
+
from `host:port`, so it survives a restart, rotating the token file logs every browser
|
|
474
|
+
out, and a second DeckSmith on the same machine neither overwrites this one's cookie nor
|
|
475
|
+
accepts a session it minted. Scripts send `Authorization: Bearer <token>` instead. A
|
|
476
|
+
write that relies on the cookie must carry `Sec-Fetch-Site` or `Origin`; a current
|
|
477
|
+
browser sends both on a POST. Ten wrong tokens from one address lock that address out for
|
|
478
|
+
fifteen minutes, including a right token sent during that time; the lockout is logged
|
|
479
|
+
once and every attempt refused during it is logged too. The log records refusals and
|
|
480
|
+
never the token, a cookie or an `Authorization` header.
|
|
481
|
+
|
|
482
|
+
Three things that cookie does **not** do, each of which this README claimed until
|
|
483
|
+
2026-09-18:
|
|
484
|
+
|
|
485
|
+
- **Logging out does not revoke the session.** `POST /logout` clears the browser's copy.
|
|
486
|
+
The value is a MAC over an expiry, checked with no server-side state, so a copy taken
|
|
487
|
+
before the logout keeps working until it expires — up to seven days. Rotating the token
|
|
488
|
+
file and restarting is what actually revokes.
|
|
489
|
+
- **`SameSite=Strict` does not stop another site steering a logged-in viewer.** It
|
|
490
|
+
withholds the cookie from a navigation another site starts, and sends it on one this
|
|
491
|
+
origin starts. A deck is public and opens top-level, so a link from anywhere can put a
|
|
492
|
+
hostile deck on screen, and that deck can navigate itself to
|
|
493
|
+
`/examples/embed.html?a=<its own directory>` — a same-site hop, which arrives with the
|
|
494
|
+
session. Measured, and it queued a job. `/examples/embed.html` now carries
|
|
495
|
+
`connect-src 'none'`, which is what refuses that job; the page needs no network of its
|
|
496
|
+
own, and the decks it frames still load.
|
|
497
|
+
- **The cookie is not confined to this port.** Cookies have no port (RFC 6265 §1), so the
|
|
498
|
+
browser sends this one to every listener on the host. See below.
|
|
499
|
+
|
|
500
|
+
Every response that is not a deck file carries `X-Frame-Options: DENY` and
|
|
501
|
+
`Content-Security-Policy: … frame-ancestors 'none'`, so nothing can frame the uploader,
|
|
502
|
+
the API, or an error page.
|
|
503
|
+
|
|
504
|
+
#### What a same-host attacker can still do
|
|
505
|
+
|
|
506
|
+
Anything on this machine that the browser can be made to talk to is handed the session
|
|
507
|
+
cookie, because cookies are scoped by host and never by port. So, with a token file set
|
|
508
|
+
and a browser logged in, another local account can:
|
|
509
|
+
|
|
510
|
+
- **read the session cookie** by getting the browser to make any request to a port it
|
|
511
|
+
listens on — a page the viewer opens, an image, a redirect — and then **replay that
|
|
512
|
+
value from a script**, as the logged-in user, until it expires. Naming the cookie after
|
|
513
|
+
the port does not hide it; nothing this server does can, because the decision is the
|
|
514
|
+
browser's. The fix is not on this host: it is a distinct hostname per service, or decks
|
|
515
|
+
and API on separate origins with a `__Host-` cookie that cannot leave one;
|
|
516
|
+
- **read the token file**, if its mode allows it — which is why a mode other than 600 is a
|
|
517
|
+
refusal at startup;
|
|
518
|
+
- **lock the owner out for fifteen minutes** by sending ten wrong tokens. Every local
|
|
519
|
+
process shares `127.0.0.1` as far as `socket.remoteAddress` is concerned, and that is
|
|
520
|
+
the only key the limiter has. The lockout and each refusal during it are in the log, so
|
|
521
|
+
it is at least visible;
|
|
522
|
+
- **reach a loopback bind at all, when no token file is set.** That is the default.
|
|
523
|
+
|
|
524
|
+
Over the LAN, with an exposed bind, none of the above follows: the cookie is `__Host-`
|
|
525
|
+
and `Secure`, the connection is TLS 1.2 or better with no plain-http listener beside it,
|
|
526
|
+
and a request by any name the certificate does not list is refused before the token is
|
|
527
|
+
even considered. What a LAN attacker gets without the token is the deck files whose
|
|
528
|
+
128-bit id they already know, and nothing else.
|
|
415
529
|
|
|
416
530
|
### What is missing before this is public
|
|
417
531
|
|
|
418
|
-
|
|
419
|
-
not a polish gap:
|
|
420
|
-
|
|
421
|
-
- **
|
|
422
|
-
quota
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
532
|
+
A token and TLS are in: see "Opening it to a network" above. This is what is still
|
|
533
|
+
missing, and some of it is not a polish gap:
|
|
534
|
+
|
|
535
|
+
- **One token, no accounts.** Everyone who holds the token is the same user, with the same
|
|
536
|
+
quota and the same view of every job whose id they know. A single session cannot be
|
|
537
|
+
revoked, and neither can logging out revoke one: rotate the token file and restart. No
|
|
538
|
+
roles, no per-token quota, no OIDC.
|
|
539
|
+
- **The session cookie is shared with every other port on this host**, and a captured one
|
|
540
|
+
replays until it expires. See "What a same-host attacker can still do" above.
|
|
541
|
+
- **No reverse-proxy support.** `X-Forwarded-*` is not read (see the rate-limit item
|
|
542
|
+
below), and on a loopback bind the `Host` check refuses whatever public name a proxy
|
|
543
|
+
forwards.
|
|
544
|
+
- **Without a token file, loopback is open to this machine.** Anyone with an account on it
|
|
545
|
+
can reach `127.0.0.1`. `DECKSMITH_TOKEN_FILE` on loopback turns the token on there too.
|
|
429
546
|
- **Codex spend is unmetered per upload.** The per-IP hourly limit is the only brake, and
|
|
430
547
|
it is per-IP.
|
|
431
548
|
- **Rate limiting is by `socket.remoteAddress` only.** `X-Forwarded-For` is deliberately
|
|
432
549
|
not read, because unproxied it is a header the client writes. Behind a reverse proxy
|
|
433
550
|
every client therefore looks like one address and the limits collapse — teach the proxy
|
|
434
|
-
to rate-limit, or teach this to trust exactly one hop.
|
|
551
|
+
to rate-limit, or teach this to trust exactly one hop. On a loopback bind every local
|
|
552
|
+
process is already that one address, so the failed-token lockout is a denial of service
|
|
553
|
+
any local account can aim at the owner; it is logged, not prevented.
|
|
435
554
|
- **Deck files are readable by anyone holding the 128-bit id.**
|
|
436
555
|
- **The deck sandbox does not isolate the origin, and cannot on one host.** Decks are
|
|
437
556
|
served under `Content-Security-Policy: sandbox allow-scripts allow-same-origin
|
|
@@ -440,6 +559,18 @@ not a polish gap:
|
|
|
440
559
|
drives the composition through `iframe.contentDocument`, so an opaque origin makes
|
|
441
560
|
every slide render blank while the job reports `done`. Real isolation means serving
|
|
442
561
|
`/d/:id` from a **separate origin**, which is a second listener and is not built.
|
|
562
|
+
- **A deck shown inside the uploader can act as whoever is logged in.** The deck is
|
|
563
|
+
same-origin with the page around it, so its script can call `parent.fetch`. That call
|
|
564
|
+
runs under the uploader's policy, not the deck's `connect-src 'none'`, and carries the
|
|
565
|
+
session. Measured: a stub deck doing this queued a job
|
|
566
|
+
(`.planning/2026-09-18-deck-parent-reach.md`). A deck opened on its own cannot: nothing
|
|
567
|
+
it could frame will load, and the one page it can navigate the viewer to,
|
|
568
|
+
`/examples/embed.html`, carries `connect-src 'none'` — both measured, both with their
|
|
569
|
+
control. So what is left needs hostile content converted by the operator, script that
|
|
570
|
+
survives into the deck, and the operator watching that deck **in the uploader**, which
|
|
571
|
+
is the one page that must be able to reach the API.
|
|
572
|
+
`DECKSMITH_JOBS_PER_HOUR` still caps what it can spend. The fix is the separate deck
|
|
573
|
+
origin above.
|
|
443
574
|
- **No persistence.** A restart forgets every job record. The files survive and are swept
|
|
444
575
|
by age on boot.
|
|
445
576
|
- **Disk is bounded only by TTL × queue rate**, and uploaded files are not scanned.
|
|
@@ -719,8 +850,8 @@ Three, each a position rather than a hue.
|
|
|
719
850
|
|
|
720
851
|
`mono`'s four tones are a grey ladder plus one red rather than four hues, because value is
|
|
721
852
|
what survives a bad projector and hue is not; four hues would have collapsed into one
|
|
722
|
-
grey. All three keep the Inter stack —
|
|
723
|
-
|
|
853
|
+
grey. All three keep the Inter stack — `build` ships Inter beside every deck, and a serif
|
|
854
|
+
naming a family the deck does not declare falls back silently.
|
|
724
855
|
|
|
725
856
|
A theme is a name and a palette, and that is the whole extension point: a new one is a
|
|
726
857
|
file in `src/emit/themes/` plus a line in `THEMES`. No archetype learns it exists.
|
|
@@ -745,6 +876,20 @@ that just appeared, and the deck advances on speech rather than on a number some
|
|
|
745
876
|
guessed. Write one sentence per reveal, in reveal order. Fewer sentences than stops leaves
|
|
746
877
|
the later reveals silent; more, and the surplus joins the last one.
|
|
747
878
|
|
|
879
|
+
**A beat's stop count can depend on the canvas.** At 1600×900 the demo's stack beat does
|
|
880
|
+
not fit, so `narrate` puts all four of its sentences on one stop; at 1920×1080 it has four.
|
|
881
|
+
So `narrate` takes the same `--format`, `--width`/`--height` and `--reserve-captions` flags
|
|
882
|
+
as `build`, and `narration.json` records the stop count each narrated beat was split over.
|
|
883
|
+
`build` compares that count with its own staging for every beat it keeps, and refuses a
|
|
884
|
+
beat whose sentences it would split differently. The error names the beats and prints the
|
|
885
|
+
`narrate` flags that fix it. Narration made once at the default canvas still builds the
|
|
886
|
+
short, the `--reserve-captions` deck and an unpacked `.deck` wherever those stage the kept
|
|
887
|
+
beats the same way, which on the demo is every one. Re-narrating in the directory the
|
|
888
|
+
narration was made in only synthesises sentences that now split differently. An unpacked
|
|
889
|
+
`.deck` has no TTS cache, so there it synthesises all of them. A `narration.json` or pack
|
|
890
|
+
written before the counts were recorded still builds, and `build` says it could not check
|
|
891
|
+
it. See `.planning/2026-09-18-narrate-build-canvas.md`.
|
|
892
|
+
|
|
748
893
|
Playback reads the audio element's own clock, never a timer: a timer agrees with the audio
|
|
749
894
|
right up until the first stall, and a stall is exactly when a viewer looks at the subtitle
|
|
750
895
|
to find out what they missed. `m` mutes (captions keep tracking, and a muted element is
|
|
@@ -1059,8 +1204,10 @@ mechanically rather than free-hand.
|
|
|
1059
1204
|
presented, never rendered, and its subtitle loop legitimately reads a clock.
|
|
1060
1205
|
- `deck.html` never contains the string `data-composition-id`. A root-level HTML file that
|
|
1061
1206
|
does trips lint's `multiple_root_compositions`, and the deck stops being navigable.
|
|
1062
|
-
-
|
|
1063
|
-
|
|
1207
|
+
- Every deck declares the face its stack names. A CJK deck ships a subsetted Noto; every
|
|
1208
|
+
other deck ships Inter from `@fontsource-variable/inter`. HyperFrames resolves Inter from
|
|
1209
|
+
its own allowlist, but only inside a render: the gates' page and `deck.html` never run its
|
|
1210
|
+
compiler, and without the deck's own face they drew SF on a Mac and DejaVu on Linux.
|
|
1064
1211
|
- Audience text never goes below 40px at 1920x1080, display equations sit at 60–76px. A
|
|
1065
1212
|
30px equation passes every automated gate and is unreadable from row six.
|
|
1066
1213
|
- **No archetype declares its own content width.** The box comes from
|