@osuki-dev/muqun-theme 1.0.1

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 ADDED
@@ -0,0 +1,717 @@
1
+ # @osuki-dev/muqun-theme
2
+
3
+ Build and check [Muqun](https://github.com/osuki-dev/muqun-app) themes from a terminal.
4
+
5
+ A Muqun theme is a `.muqun-theme` file: a ZIP holding a `theme.json` manifest and
6
+ an `assets/` folder of artwork. The app validates one when you import it, and a
7
+ rejected package is a red message on a phone — which is the worst possible place
8
+ to discover that a drawing was one byte over the ceiling, that an asset the
9
+ manifest declares was never in the archive, or that your palette leaves the
10
+ translucency slider with nowhere to go.
11
+
12
+ This package moves all of those checks to where the theme is being written.
13
+
14
+ **Who it is for:** anyone authoring a Muqun theme by hand, generating one from a
15
+ script or an agent, or checking one in CI before shipping it.
16
+
17
+ The checks are not a reimplementation. `schema.ts`, `package.ts`,
18
+ `opacity-policy.ts` and `image-inspection.ts` are the app's own modules, lifted
19
+ out unchanged apart from their imports. A package this tool accepts is a package
20
+ the app accepts by construction rather than by agreement.
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ bun install -g @osuki-dev/muqun-theme
26
+ muqun-theme validate my-theme.muqun-theme
27
+ ```
28
+
29
+ Or without installing anything:
30
+
31
+ ```sh
32
+ bunx @osuki-dev/muqun-theme validate my-theme.muqun-theme
33
+ ```
34
+
35
+ **This is a Bun tool.** It requires **Bun 1.4 or newer** and will not run under
36
+ Node: `pack` converts artwork to WebP through `Bun.Image`, which has no Node
37
+ equivalent short of a native dependency. No React Native and no native modules.
38
+
39
+ The install is a single file. `bunx` downloads one package and runs it; there is
40
+ no dependency tree behind it.
41
+
42
+ ## Commands
43
+
44
+ ```
45
+ muqun-theme init [slug] [--dir .] scaffold a complete theme, with placeholder art
46
+ [--colors-only] ...or just a palette, with no assets
47
+ muqun-theme validate <target> schema, references, images, digests, limits
48
+ muqun-theme contrast <target> opacity floors and the colours that set them
49
+ muqun-theme pack <dir> [--out file] build a .muqun-theme, optimising artwork to WebP
50
+ [--no-optimize] ...keeping artwork exactly as authored
51
+ muqun-theme unpack <file> [--out dir] extract a package for editing
52
+ ```
53
+
54
+ The author's loop is **init → edit → check → pack**:
55
+
56
+ ```sh
57
+ muqun-theme init grand-voyage --dir ./grand-voyage # a complete, installable theme
58
+ # replace the placeholder art in ./grand-voyage/assets, edit the colours
59
+ muqun-theme contrast ./grand-voyage # what the palette costs in translucency
60
+ muqun-theme validate ./grand-voyage # everything the app will check on import
61
+ muqun-theme pack ./grand-voyage --out grand-voyage.muqun-theme
62
+ ```
63
+
64
+ `<target>` accepts any of the three shapes a theme has on disk: a packed
65
+ `.muqun-theme`, a bare `.muqun-theme.json` manifest, or a directory holding
66
+ `theme.json` beside `assets/`. You never have to pack something just to check it.
67
+
68
+ ### Exit codes
69
+
70
+ | Code | Meaning |
71
+ | ---- | ------- |
72
+ | `0` | The target is usable. Warnings may have been printed. |
73
+ | `1` | The target is broken, or the command was used wrongly. |
74
+
75
+ **Warnings never fail a command.** Only errors do. That distinction is what makes
76
+ `muqun-theme validate` safe to put in a CI job: a theme that draws a picture
77
+ bigger than it needs to is still a theme, and the tool says so without failing
78
+ your build.
79
+
80
+ Colour is disabled automatically when output is not a terminal, and `NO_COLOR=1`
81
+ disables it explicitly.
82
+
83
+ ### `init`
84
+
85
+ Scaffolds a theme that is **complete and installable the moment it is created**.
86
+ Not a skeleton with holes: every colour, every decoration slot, both icons, the
87
+ Home identity and the material settings are filled in and wired to a file that
88
+ exists. `pack` works on it before you have changed anything.
89
+
90
+ ```
91
+ $ muqun-theme init grand-voyage --dir ./grand-voyage
92
+ created grand-voyage/theme.json (10 decoration slots, 14 placeholder images)
93
+ Replace the flat tints in assets/ with real artwork, or delete slots you do not want.
94
+ Next: muqun-theme contrast ./grand-voyage then: muqun-theme pack ./grand-voyage
95
+ ```
96
+
97
+ That writes `theme.json` plus fourteen PNGs in `assets/` — about 47 KB in total:
98
+
99
+ ```
100
+ assets/shell-light.png assets/shell-dark.png assets/home-background.png
101
+ assets/home-banner.png assets/navigation.png assets/composer.png
102
+ assets/actions.png assets/tabs.png assets/cards.png
103
+ assets/buttons.png assets/empty-state.png assets/icon-back.png
104
+ assets/icon-send.png assets/logo.png
105
+ ```
106
+
107
+ The point is that the format is **discoverable by deletion**. Every section the
108
+ schema supports is already there, so you find out that per-mode wallpaper, a
109
+ custom Home name or a replaceable back arrow exist by seeing them in your own
110
+ `theme.json` and removing what you do not want — rather than by reading the app's
111
+ source to learn they were available.
112
+
113
+ Each placeholder is sized for the slot it fills, so a fresh scaffold produces no
114
+ warnings about artwork larger than it needs.
115
+
116
+ #### The placeholders are meant to be replaced
117
+
118
+ They are flat tints pulled from the palette, deliberately not art. Each one also
119
+ carries a marker in a PNG `tEXt` chunk, so the tools can tell you when one is
120
+ still in place:
121
+
122
+ ```
123
+ $ muqun-theme validate ./grand-voyage
124
+ valid grand-voyage (grand-voyage 1.0.0)
125
+ 14/32 asset(s), 46.6 KiB of artwork
126
+ warning 14x still the placeholder written by `muqun-theme init`
127
+ shell-light, shell-dark, home-background, home-banner, navigation, composer, actions, tabs, +6 more
128
+ ```
129
+
130
+ Replace one and the count goes down. It is a warning rather than an error,
131
+ because packing a work-in-progress is a perfectly reasonable thing to do — the
132
+ point is that nobody ships a scaffold without having been told.
133
+
134
+ #### `--colors-only`
135
+
136
+ If you only want a palette, say so and no artwork is written:
137
+
138
+ ```
139
+ $ muqun-theme init --colors-only --dir ./palette
140
+ created palette/theme.json (colours only, and already passes the contrast gate)
141
+ ```
142
+
143
+ This is the app's own starter, unchanged — the same manifest the authoring skill
144
+ shows an agent. With no slug the theme is called `my-theme`.
145
+
146
+ ### `validate`
147
+
148
+ Parses the manifest, resolves every asset reference, and then checks the things
149
+ the schema cannot express.
150
+
151
+ ```
152
+ $ muqun-theme validate one-piece-grand-voyage-webp.muqun-theme
153
+ valid One Piece — Grand Voyage (one-piece-grand-voyage 1.0.0)
154
+ 10/32 asset(s), 3.81 MiB of artwork
155
+ warning 6x 1254x1254 is larger than 1024px on its longest edge (6.3 MB decoded)
156
+ panel-light, panel-dark, action-light, action-dark, empty-light, empty-dark
157
+ ```
158
+
159
+ Exit code `0` — those are warnings, and that pack installs. Issues that share a
160
+ message are collapsed into a count, so one repeated problem cannot bury the rest
161
+ of the report.
162
+
163
+ A schema failure prints the path of the offending value and exits `1`. Here
164
+ `colors.text` has been set to a CSS colour name, which the format does not accept:
165
+
166
+ ```
167
+ $ muqun-theme validate ./my-theme
168
+ variants.light.colors.text: Expected an opaque #RRGGBB color
169
+ ```
170
+
171
+ #### What `validate` checks, and what each failure means
172
+
173
+ **Errors** — the app will refuse the theme:
174
+
175
+ | Failure | What it means |
176
+ | ------- | ------------- |
177
+ | `<path>: Expected an opaque #RRGGBB color` | A UI or terminal colour is missing, malformed, or carries alpha where alpha is not allowed. Only `primarySubtle`, `dangerSubtle` and `terminal.selection` may be 8-digit. |
178
+ | `<path>: Unrecognized key` | An unknown field. The manifest is strict everywhere except `icons`, so a typo is an error rather than a silently ignored setting. |
179
+ | `Unknown asset: <id>` | A `decoration`, `icons` or `homeIdentity` entry names an asset that `assets` does not declare. |
180
+ | `Theme manifest exceeds 256 KiB` | The manifest is over the limit, measured as UTF-8 bytes rather than characters. |
181
+ | `At most 32 assets are allowed` | Too many declared assets. |
182
+ | `Unsupported or malformed static theme image` | A packaged file is not a valid PNG, JPEG or WebP, is animated (APNG or animated WebP), or is over 16 megapixels. |
183
+ | `assets.<id>.sha256: declared … but the bytes are …` | The manifest declares a checksum that does not describe the file it names. Either the art changed and the manifest did not, or the file is not the one you think it is. |
184
+ | `Invalid theme package: unsupported or unsafe path` | A ZIP entry is not `theme.json` or `assets/<name>.<ext>`. Path traversal, absolute paths, nested directories and symlinks all land here. |
185
+ | `Invalid theme package: undeclared files` | The archive contains a file the manifest never declares. |
186
+ | `Invalid theme package: declared image is missing` | The manifest declares a packaged asset the archive does not contain. |
187
+ | `Invalid theme package: package exceeds 25 MiB` | The compressed archive is over the limit. |
188
+ | `Invalid theme package: expanded size limit` | An entry, or the archive as a whole, expands past its ceiling. |
189
+ | `Invalid theme package: size or checksum mismatch` | The archive is corrupt. |
190
+
191
+ **Warnings** — the theme still works:
192
+
193
+ | Warning | What it means |
194
+ | ------- | ------------- |
195
+ | `larger than <n>px on its longest edge` | The drawing costs more memory than its slot needs. See below. |
196
+ | `declared but never drawn` | An asset is in the package and in `assets`, but nothing references it. Dead weight in the download. |
197
+ | `not a glyph this build draws` | An `icons` key that is not a known glyph name. Deliberately tolerated — see the icons note in the format section. |
198
+
199
+ The size warning is the one worth explaining. The hard limit is 16 megapixels,
200
+ and that is a guard against a decode bomb rather than advice. The number you can
201
+ act on is different: an image is decoded to `width × height × 4` bytes and held
202
+ for as long as its slot is on screen, so a 1254×1254 drawing behind a 44pt
203
+ navigation bar costs 6.3 MB to show a strip it could have filled at a twentieth
204
+ of that. Full-screen artwork (`shell.background`) gets a phone-at-3× budget of
205
+ 3000px; every other slot is chrome, and 1024px on the longest edge is already
206
+ generous.
207
+
208
+ ### `contrast`
209
+
210
+ This is the command that explains a number you will otherwise meet as a mystery.
211
+
212
+ Muqun lets a reader make surfaces and the terminal translucent so artwork shows
213
+ through. Translucency costs contrast: text over a 60%-opaque surface is partly
214
+ text over whatever is behind it. So the app computes the lowest opacity at which
215
+ your palette still meets [WCAG](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum)
216
+ ratios — 4.5:1 for text and 3:1 for large or non-text marks — and clamps the
217
+ slider there. That is the **opacity floor**.
218
+
219
+ `contrast` prints the floor for both modes, and the colour pairs that set it.
220
+
221
+ ```
222
+ $ muqun-theme contrast one-piece-grand-voyage-webp.muqun-theme
223
+ One Piece — Grand Voyage one-piece-grand-voyage
224
+ light interface 80% terminal 80%
225
+ interface floor is set by:
226
+ 80% colors.textSubtle/surfaceRaised (needs 4.5:1)
227
+ 79% colors.primary/primarySubtle/surfaceRaised (needs 4.5:1)
228
+ 79% colors.danger/dangerSubtle/surfaceRaised (needs 4.5:1)
229
+ 78% colors.textSubtle/background (needs 4.5:1)
230
+ 78% colors.primary/primarySubtle/background (needs 4.5:1)
231
+ terminal floor is set by:
232
+ 80% terminal.link/background (needs 4.5:1)
233
+ 80% terminal.ansi.1/background (needs 4.5:1)
234
+ 80% terminal.ansi.2/background (needs 4.5:1)
235
+ 80% terminal.ansi.3/background (needs 4.5:1)
236
+ 80% terminal.ansi.5/background (needs 4.5:1)
237
+ dark interface 80% terminal 80%
238
+ interface floor is set by:
239
+ 80% colors.textSubtle/surfaceRaised (needs 4.5:1)
240
+ 80% colors.primary/primarySubtle/surfaceRaised (needs 4.5:1)
241
+ 80% colors.danger/dangerSubtle/surfaceRaised (needs 4.5:1)
242
+ 76% colors.textSubtle/surface (needs 4.5:1)
243
+ 76% colors.primary/primarySubtle/surface (needs 4.5:1)
244
+ terminal floor is set by:
245
+ 80% terminal.ansi.0/background (needs 4.5:1)
246
+ 80% terminal.ansi.8/background (needs 4.5:1)
247
+ 75% terminal.ansi.5/background (needs 4.5:1)
248
+ 74% terminal.ansi.1/background (needs 4.5:1)
249
+ 73% terminal.ansi.4/background (needs 4.5:1)
250
+ surface slider 80%-100% 20 points of travel
251
+ terminal slider 80%-100% 20 points of travel
252
+ ```
253
+
254
+ Each listed pair is the floor **that pair alone** would impose. The group's floor
255
+ is the highest of them, so the list is the answer the single number cannot give:
256
+ raise `colors.textSubtle` against `surfaceRaised` and the 80% moves. Chase the
257
+ 79% entries and nothing happens, because the 80% is still there.
258
+
259
+ A few things the output is telling you:
260
+
261
+ - **`slider 80%-100%`** is what the reader actually gets. `20 points of travel`
262
+ is a comfortable range; under 10 points is flagged, and a floor of 100% means
263
+ translucency is impossible for that palette.
264
+ - **`Both modes share the stricter floor`** appears when light and dark disagree.
265
+ One slider controls both, so the tighter palette decides for the other.
266
+ - **`4 ANSI below 4.5:1`** means some terminal ANSI colours fail against the
267
+ terminal background even at full opacity. They are excluded from the floor
268
+ calculation rather than pinning it at 100% — arbitrary ANSI combinations are
269
+ not a contrast claim the theme can make — but they are still hard to read.
270
+ - **`fails at full opacity`** is the serious one. A declared pairing misses its
271
+ ratio even with no translucency at all, which no slider position can fix. This
272
+ exits `1`.
273
+
274
+ The starter is deliberately a tight example:
275
+
276
+ ```
277
+ $ muqun-theme contrast ./grand-voyage
278
+ surface slider 99%-100% only 1 point of travel
279
+ terminal slider 97%-100% only 3 points of travel
280
+ Both modes share the stricter floor, so the tighter palette decides the slider.
281
+ ```
282
+
283
+ It is valid and installable, but its muted greys leave almost no room. Strengthen
284
+ the foreground colours and the travel opens up.
285
+
286
+ ### `pack` and `unpack`
287
+
288
+ `pack` collects exactly the files the manifest declares — anything else in the
289
+ directory is listed and left out — verifies them, **converts artwork to WebP**,
290
+ writes the archive, and then unpacks its own output to confirm the result round
291
+ trips. "It packed" and "it installs" are the same claim rather than two hopeful
292
+ ones.
293
+
294
+ #### Artwork is optimised automatically
295
+
296
+ A theme is downloaded to a phone, so image size is not a detail. `pack` converts
297
+ every packaged PNG and JPEG to WebP at quality 94, and reports what it did to
298
+ each one:
299
+
300
+ ```
301
+ $ muqun-theme pack ./one-piece --out one-piece.muqun-theme
302
+ webp scene-light png 2.08 MiB -> 286.0 KiB (13%, q94) sha256 rewritten
303
+ webp scene-dark png 2.07 MiB -> 256.8 KiB (12%, q94) sha256 rewritten
304
+ webp scene-wide-light png 2.45 MiB -> 492.6 KiB (20%, q94) sha256 rewritten
305
+ webp scene-wide-dark png 2.22 MiB -> 366.9 KiB (16%, q94) sha256 rewritten
306
+ webp panel-light png 2.33 MiB -> 373.5 KiB (16%, q94) sha256 rewritten
307
+ webp panel-dark png 1.90 MiB -> 238.5 KiB (12%, q94) sha256 rewritten
308
+ webp action-light png 2.80 MiB -> 700.4 KiB (24%, q94) sha256 rewritten
309
+ webp action-dark png 3.27 MiB -> 899.3 KiB (27%, q94) sha256 rewritten
310
+ webp empty-light png 1.50 MiB -> 201.7 KiB (13%, q94) sha256 rewritten
311
+ webp empty-dark png 1.58 MiB -> 205.4 KiB (13%, q94) sha256 rewritten
312
+ 10 image(s) optimised: 22.20 MiB -> 3.93 MiB (18%)
313
+ packed one-piece.muqun-theme 3.93 MiB 10 asset(s) round trip ok
314
+ ```
315
+
316
+ That is a real pack: **22.2 MB of PNG became 3.9 MB of WebP**, visually
317
+ indistinguishable, and the app's own `inspectThemeImage` is run over every
318
+ converted image before it is accepted. Quality 94 was chosen by measuring, not by
319
+ taste.
320
+
321
+ The conversion is conservative in four ways, each of them reported rather than
322
+ silent. Artwork already in WebP is left alone. A conversion that comes out
323
+ *larger* than the original is discarded. An image Bun cannot convert, or whose
324
+ output fails inspection, keeps its original bytes. And the packaged path moves
325
+ from `.png` to `.webp` only when the converted image is actually adopted.
326
+
327
+ Alpha survives: a template icon's transparency is carried through as a WebP
328
+ `ALPH` chunk, so glyphs keep their shape.
329
+
330
+ <sub>Bun's WebP codec is bundled rather than borrowed from the OS, and CI checks
331
+ that on every run rather than trusting the documentation — `Bun.Image.backend`
332
+ reports `bun` on Linux and `system` on macOS, and both produce WebP the app
333
+ accepts, with alpha intact.</sub>
334
+
335
+ #### What happens to a `sha256` you declared
336
+
337
+ The manifest's `sha256` is a claim *you* made about *your* bytes — this tool does
338
+ not compute digests for you — so conversion handles it in a fixed order:
339
+
340
+ 1. **Before anything is converted**, every declared digest is checked against the
341
+ file on disk. If one does not match, `pack` refuses and tells you which:
342
+
343
+ ```
344
+ refusing to pack:
345
+ error assets.scene-light.sha256 declared aaaaaaaaaaaa… but the bytes are 1c1604efda53…
346
+ ```
347
+
348
+ A claim that was already false is never papered over by conversion, and
349
+ nothing is written.
350
+
351
+ 2. **Only then** is the image converted, and the digest recomputed over the new
352
+ bytes and rewritten — reported per asset as `sha256 rewritten`.
353
+
354
+ An asset with no declared digest never gains one. Adding a claim you did not make
355
+ would be as wrong as silently rewriting one you did.
356
+
357
+ #### `--no-optimize`
358
+
359
+ If you want your exact bytes preserved:
360
+
361
+ ```sh
362
+ muqun-theme pack ./one-piece --out one-piece.muqun-theme --no-optimize
363
+ ```
364
+
365
+ The escape hatch is a flag rather than a per-asset field in `theme.json`, and
366
+ that is forced rather than chosen: the manifest's asset entries are validated by
367
+ a strict schema that rejects any key it does not define, so an opt-out living in
368
+ the manifest would mean changing the app's format — which is the app's to change,
369
+ not this tool's.
370
+
371
+ ```
372
+ $ muqun-theme pack ./grand-voyage --out ./grand-voyage.muqun-theme
373
+ packed grand-voyage.muqun-theme 2.5 KiB 0 asset(s) round trip ok
374
+ package limit 25.00 MiB
375
+ ```
376
+
377
+ `pack` refuses to build a package that `validate` would reject, so a checksum
378
+ mismatch or an animated WebP stops here rather than shipping.
379
+
380
+ `unpack` is the reverse, and writes a formatted `theme.json` you can edit:
381
+
382
+ ```
383
+ $ muqun-theme unpack ./grand-voyage.muqun-theme --out ./editable
384
+ unpacked grand-voyage into ./editable (theme.json + 0 asset(s))
385
+ ```
386
+
387
+ The cycle is lossless. Unpacking a package and repacking it reproduces the same
388
+ manifest and the same asset bytes.
389
+
390
+ ## The `.muqun-theme` format
391
+
392
+ Enough detail to author one without reading the app's source. The app remains the
393
+ source of truth — see the note at the end.
394
+
395
+ ### Package layout
396
+
397
+ A `.muqun-theme` is a ZIP with a flat, closed structure:
398
+
399
+ ```
400
+ theme.json required, exactly this name, at the root
401
+ assets/ optional directory
402
+ assets/<name>.png artwork, one level deep only
403
+ ```
404
+
405
+ Nothing else is permitted. Asset filenames match `[a-zA-Z0-9_-]+` with a `.png`,
406
+ `.jpg`, `.jpeg` or `.webp` extension. There are no nested directories, no
407
+ symlinks, no other file types, and every file in the archive must be declared in
408
+ the manifest. `.muqun-theme.json` — the manifest on its own, with no artwork — is
409
+ also a valid thing to hand the app.
410
+
411
+ ### Manifest shape
412
+
413
+ ```jsonc
414
+ {
415
+ "format": "muqun-theme", // required, exactly this
416
+ "schemaVersion": 1, // required, exactly 1
417
+ "id": "grand-voyage", // required, ^[a-z][a-z0-9-]*$, 1-64 chars
418
+ "name": "Grand Voyage", // required, 1-64 printable chars
419
+ "version": "1.0.0", // required, exactly three numeric parts
420
+ "author": "…", // optional, up to 100 chars
421
+ "license": "…", // optional, up to 100 chars
422
+ "source": "https://…", // optional, HTTPS only
423
+
424
+ "variants": { // required, both modes, no inheritance
425
+ "light": { "colors": { … }, "terminal": { … }, "surfaces": { … } },
426
+ "dark": { "colors": { … }, "terminal": { … }, "surfaces": { … } }
427
+ },
428
+
429
+ "assets": { … }, // optional
430
+ "decoration": { … }, // optional
431
+ "variantDecorations": { … }, // optional
432
+ "icons": { … }, // optional
433
+ "materials": { … }, // optional
434
+ "homeIdentity": { … } // optional
435
+ }
436
+ ```
437
+
438
+ Unknown keys are an error everywhere except inside `icons`.
439
+
440
+ ### Colours
441
+
442
+ Every variant declares all **17** UI tokens. There are no defaults and no
443
+ inheritance between modes: a half-filled variant is a rejected theme, so there is
444
+ never a half-themed screen.
445
+
446
+ ```
447
+ background surface surfaceRaised the three surfaces, in depth order
448
+ border borderStrong separators
449
+ text textMuted textSubtle textDisabled type, in descending emphasis
450
+ primary onPrimary primarySubtle the accent, its label, its tint
451
+ danger dangerSubtle success warning info semantic colours
452
+ ```
453
+
454
+ All are opaque `#RRGGBB` except `primarySubtle` and `dangerSubtle`, which may be
455
+ `#RRGGBBAA` — they are tints painted *over* a surface rather than replacing it.
456
+
457
+ `terminal` carries its own surface plus the ANSI 16:
458
+
459
+ ```jsonc
460
+ "terminal": {
461
+ "background": "#050B12",
462
+ "foreground": "#E6EAF2",
463
+ "cursor": "#FF5A4A",
464
+ "link": "#6E8BFF",
465
+ "selection": "#FF5A4A24", // may carry alpha
466
+ "ansi": [ /* exactly 16 opaque colours: 0-7 normal, 8-15 bright */ ],
467
+ "backgroundOpacity": 0.9 // optional, 0..1
468
+ }
469
+ ```
470
+
471
+ `variants.<mode>.surfaces.backgroundOpacity` does the same for the interface.
472
+ Both default to `1`, are independent, and are clamped to the floor `contrast`
473
+ reports. Text, icons, explicit ANSI backgrounds and safety scrims never fade.
474
+
475
+ ### Assets and artwork
476
+
477
+ ```jsonc
478
+ "assets": {
479
+ "paper": { "path": "assets/paper.webp", "sha256": "…" }, // packaged
480
+ "crest": { "url": "https://example.com/crest.png" } // downloaded
481
+ }
482
+ ```
483
+
484
+ Asset ids match `^[a-z][a-z0-9-]*$`. `sha256` is optional; when present it must be
485
+ the lowercase hex SHA-256 of the actual file bytes, and `validate` checks it. An
486
+ offline `.muqun-theme` must use `path` — a `url` asset cannot be packaged, and a
487
+ package that declares one is rejected.
488
+
489
+ Artwork is placed by referencing an asset from a **decoration slot**:
490
+
491
+ ```jsonc
492
+ "decoration": {
493
+ "shell.background": { "asset": "paper", "fit": "cover", "opacity": 0.6 }
494
+ }
495
+ ```
496
+
497
+ The ten slots:
498
+
499
+ | Slot | What it decorates |
500
+ | ---- | ----------------- |
501
+ | `shell.background` | Shared wallpaper behind everything. The only full-screen slot. |
502
+ | `home.background` | Overrides the wallpaper on Home. |
503
+ | `home.decoration` | A contained 2:1 banner on Home, max width 560. Not wallpaper. |
504
+ | `navigation.background` | The navigation bar. |
505
+ | `composer.background` | The input composer. |
506
+ | `actions.background` | The actions bar. |
507
+ | `tabs.background` | The tab strip. |
508
+ | `cards.decoration` | Cards. |
509
+ | `buttons.primary.background` | Primary buttons. |
510
+ | `emptyState.illustration` | Empty states. Use a square, `contain`-fit image. |
511
+
512
+ Each slot takes `asset`, plus optional `fit` (`cover`, `contain`, `tile`),
513
+ `opacity` (0..1), and `focalPoint` (`{x, y}`, each 0..1). It may also carry
514
+ `compact` and `regular` sub-entries to serve phone and tablet different artwork.
515
+
516
+ `variantDecorations.light` and `variantDecorations.dark` override `decoration`
517
+ per mode. The resolution rule is worth memorising: **omitting a slot inherits,
518
+ and `null` explicitly disables it.** Missing artwork reserves no space, so every
519
+ slot is safely optional.
520
+
521
+ ### Icons, materials, home identity
522
+
523
+ ```jsonc
524
+ "icons": { "chrome.back": { "asset": "arrow", "render": "template" } }
525
+ ```
526
+
527
+ Known glyph names are `chrome.back` and `chrome.send`. `icons` is the one open
528
+ part of the schema: an unknown name is ignored rather than failing the theme, so
529
+ an older app keeps its own glyph instead of refusing a newer pack outright. The
530
+ cost is that a typo is silent at runtime, which is why `validate` reports unknown
531
+ names as a warning.
532
+
533
+ `render` defaults to `template`: the drawing supplies the shape through its alpha
534
+ and the theme supplies the colour, so one image is correct in both modes. Use
535
+ `original` only for a mark whose colours are fixed — a plain arrow in fixed black
536
+ disappears in dark mode.
537
+
538
+ ```jsonc
539
+ "materials": { "navigation": "glass" } // auto | solid | glass
540
+ ```
541
+
542
+ Applies to `default`, `navigation`, `composer` and `actions`. `glass` falls back
543
+ to `solid` where unsupported.
544
+
545
+ ```jsonc
546
+ "homeIdentity": {
547
+ "name": { "mode": "custom", "text": "Grand Voyage" }, // or default | hidden
548
+ "logo": { "mode": "custom", "asset": "crest" } // or default | hidden
549
+ }
550
+ ```
551
+
552
+ A pack that says nothing here gets nothing: once a theme is applied, Home is the
553
+ theme's, and the app does not print its own name over your illustration. Ask for
554
+ it back with `"mode": "default"`. This never renames the launcher icon.
555
+
556
+ ### Limits
557
+
558
+ Every one of these is enforced by `validate` and by the app:
559
+
560
+ | Limit | Value |
561
+ | ----- | ----- |
562
+ | Manifest size | 256 KiB (UTF-8 bytes) |
563
+ | Assets per theme | 32 |
564
+ | Single asset | 8 MiB |
565
+ | Image dimensions | 16 megapixels |
566
+ | Package, compressed | 25 MiB |
567
+ | Package, expanded | 50 MiB |
568
+
569
+ ### What a theme may not contain
570
+
571
+ No scripts, HTML, CSS, SVG, fonts, or animation of any kind. No base64-embedded
572
+ images, no local filesystem paths, no credentials. Artwork is static PNG, JPEG or
573
+ WebP, and it is inspected as bytes rather than trusted by extension.
574
+
575
+ ## Architecture
576
+
577
+ The package is a domain and a command, and the split between them is enforced
578
+ rather than intended.
579
+
580
+ ```
581
+ src/
582
+ schema package image-inspection opacity-policy the domain: pure, synchronous,
583
+ contrast clone format-json digest verify zod and fflate only
584
+ scaffold placeholder-png starter
585
+ index.ts the domain, listed in one place
586
+
587
+ cli/
588
+ output.ts the one port this package declares: where words go
589
+ theme-source.ts the whole boundary to a disk, over Effect's FileSystem
590
+ format.ts every printed line, as pure functions
591
+ commands.ts the five commands, as Effects
592
+ main.ts argument parsing, help, exit codes
593
+ cli.ts the executable
594
+ ```
595
+
596
+ **The domain does not know Effect exists.** Everything reachable from `index.ts`
597
+ is plain TypeScript over `zod` and `fflate`, which is what lets those modules
598
+ stay byte-identical to the app's own. That is checked, not promised:
599
+ `src/__tests__/purity.test.ts` walks the real import graph from `index.ts` —
600
+ static imports, re-exports, `import()` and `require()` alike — and fails if
601
+ anything in it reaches for `effect` or steps into `cli/`.
602
+
603
+ **Effect lives in the command layer**, where the work is genuinely effectful:
604
+ reading a theme off a disk, writing a pack, printing, and choosing an exit code.
605
+ It is Effect 4.0, currently a release candidate pinned to an exact version; its
606
+ CLI module is published under `effect/unstable/cli` and may change before 4.0
607
+ final. None of that reaches an install, because of the next point.
608
+
609
+ **It ships as one file.** `bun build` bundles `cli.ts` and everything it imports
610
+ — Effect included — into `lib/cli.js`, and that file is the whole of what npm
611
+ installs. There are no runtime dependencies and no `src/` in the tarball.
612
+
613
+ **I/O goes through ports.** The filesystem and path services are Effect's own
614
+ (`FileSystem.FileSystem`, `Path.Path`), and the single port this package
615
+ declares is `Output` — stdout and stderr behind an interface. Together they are
616
+ why `src/__tests__/commands.test.ts` can run a command and read what it said,
617
+ instead of spawning a process and scraping a pipe. The subprocess tests still
618
+ exist alongside them, because exit codes are the contract a CI job depends on and
619
+ those deserve to be tested the way they are used.
620
+
621
+ **One schema, not two.** The manifest is validated by `zod`, and only by `zod`.
622
+ Effect ships its own schema library; using it here would mean two definitions of
623
+ the `.muqun-theme` format, one of which would eventually disagree with the app.
624
+ `skill.test.ts` pins `themeJsonSchema()` output byte-for-byte against the
625
+ authoring skill, so that drift would fail a test.
626
+
627
+ ## Agent skill
628
+
629
+ `skills/muqun-theme/SKILL.md` is the authoring contract handed to an AI agent
630
+ asked to make a Muqun theme. It carries the workflow, the resource and surface
631
+ rules, the boundaries, the full JSON Schema and a complete starter manifest — so
632
+ an agent can produce an installable pack without reading the app's source. It
633
+ ships in the npm tarball, so it is on disk after an install:
634
+
635
+ ```
636
+ node_modules/@osuki-dev/muqun-theme/skills/muqun-theme/SKILL.md
637
+ ```
638
+
639
+ **It is generated upstream.** The file is produced in the Muqun app repository
640
+ from `src/theme/authoring.ts` and `src/theme/schema.ts`, and vendored here
641
+ verbatim. Edits belong upstream — an edit to the copy is lost the next time the
642
+ app regenerates it, and would put the copy at odds with the contract the app
643
+ enforces. The file says so at the top.
644
+
645
+ The copy is pinned rather than trusted: `src/__tests__/skill.test.ts` asserts
646
+ that the skill's JSON Schema and starter manifest are byte-for-byte what
647
+ `themeJsonSchema()` and `createThemeStarter()` produce, and that the limits its
648
+ prose quotes match `THEME_LIMITS`. A copy that falls out of step with the code
649
+ fails a test instead of quietly misinforming an agent.
650
+
651
+ One section is not generated: **Checking your work**, appended at the end, which
652
+ points an agent at `muqun-theme validate` and `muqun-theme contrast`. That
653
+ tooling does not exist upstream, so it has nowhere else to be documented.
654
+
655
+ ## Not built yet
656
+
657
+ Two things the authoring loop will eventually want. Neither exists, and neither
658
+ is stubbed — this section is here so the next person is not guessing at intent.
659
+
660
+ ### `publish` — submitting to the themes gallery
661
+
662
+ A themes gallery is planned for the Muqun website, where people browse, install
663
+ and submit themes. `muqun-theme publish` will mean **submit this pack to that
664
+ gallery**, completing the loop as `init → edit → check → pack → publish`.
665
+
666
+ It is not implemented because there is no gallery and no API to write against,
667
+ and guessing at an endpoint's shape now would mean rewriting it later.
668
+
669
+ Nothing in the current design blocks it. `pack` already produces the exact bytes
670
+ a submission would upload, `validate` already answers the question a gallery
671
+ would have to ask before accepting one, and `verifyAssets` already returns
672
+ structured issues rather than printed text. When the API exists, `publish` is
673
+ roughly: pack, refuse on any error, refuse on a package whose artwork is still
674
+ the `init` placeholder, then upload.
675
+
676
+ Three things would be worth settling before writing it, because they are cheaper
677
+ to decide than to change once themes are live:
678
+
679
+ - **Identity.** `id` is unique within one manifest but nothing makes it unique
680
+ across a gallery. Whether two authors may both publish `ocean` — and if not,
681
+ who owns the name — is a gallery decision that reaches back into the format.
682
+ - **Versioning.** `version` exists and is validated, but nothing currently
683
+ rejects re-publishing the same version with different bytes. A gallery almost
684
+ certainly wants that to be an error.
685
+ - **Authorship.** `author` is free text. A gallery that shows who made a theme
686
+ will want it tied to an account rather than to a string the pack chose.
687
+
688
+ None of those needs code today. They need an answer before the first upload.
689
+
690
+ ### Previewing without installing the app
691
+
692
+ This is a real gap. `contrast` tells you what a palette costs in translucency and
693
+ which pairs are responsible, and `validate` tells you the pack is well-formed —
694
+ but neither shows you what the theme *looks like*. Today the only way to see a
695
+ theme is to install it on a device.
696
+
697
+ A `muqun-theme preview <target> --out preview.html` writing a static page — both
698
+ modes side by side, the three surfaces with real text on them, the ANSI 16, and
699
+ each decoration slot with its artwork in place — would close it, and needs
700
+ nothing this package does not already have.
701
+
702
+ It is deliberately not built on spec, for the same reason `publish` is not: a
703
+ preview that diverges from what the app actually renders is worse than no
704
+ preview, and getting that right means checking it against the app rather than
705
+ against the schema.
706
+
707
+ ## Source of truth
708
+
709
+ The `.muqun-theme` format is defined by the Muqun app, not by this package. The
710
+ modules here are the app's own, extracted so they can run outside it, and this
711
+ README documents the format as of the extraction. Where the two ever disagree,
712
+ the app is correct and this package has drifted — please
713
+ [open an issue](https://github.com/osuki-dev/muqun-theme-cli/issues).
714
+
715
+ ## License
716
+
717
+ Apache-2.0