@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/LICENSE +202 -0
- package/README.md +717 -0
- package/lib/cli.js +23804 -0
- package/package.json +63 -0
- package/skills/muqun-theme/SKILL.md +478 -0
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
|