mdeck 3.8.0 → 3.9.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/CHANGELOG.md +4 -0
- package/assets/extensions/themes/academic/extension.toml +4 -1
- package/assets/extensions/themes/glass/extension.toml +7 -1
- package/assets/extensions/themes/minimal/extension.toml +3 -1
- package/assets/extensions/themes/neue/extension.toml +3 -1
- package/assets/extensions/themes/plain/extension.toml +7 -1
- package/assets/extensions/themes/work/extension.toml +8 -2
- package/docs/reference/authoring.md +9 -4
- package/docs/reference/extensions.md +5 -1
- package/docs/reference/themes.md +6 -2
- package/docs/site/content/theme-authoring.md +28 -0
- package/package.json +1 -1
- package/src/cli/index.js +4 -0
- package/src/cli/themes.js +2 -0
- package/src/core/validateDeck.js +8 -1
- package/src/editor/components/DeckSettings.jsx +33 -3
- package/src/editor/components/ExtensionForms.jsx +12 -0
- package/src/editor/extensions.js +6 -3
- package/src/extensions/appearance.js +6 -2
- package/src/extensions/manifest.js +3 -3
- package/src/extensions/params.js +113 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.9.0 — 2026-10-11
|
|
4
|
+
|
|
5
|
+
- Theme parameters say what kind of value they take: `type = "size"` (pixels, with `min`, `max` and `step`), `"number"`, `"switch"` (1 or 0), `"choice"` (one of `choices`) or `"font"`. The deck settings show a slider, a box to tick or a list instead of a text box; a deck value that does not fit is left out, so the theme's own value stays, and `mdeck check` says why; a value outside the range is used, with a warning. `mdeck list themes` shows each theme's settings and what they take, and the design page lets a theme's author set the kind. `mdeck themes build` writes each theme's settings into the gallery's `looks.json`. A parameter without a type takes any text, as before. The built-in themes give every parameter a type and a description with its default and range. A theme that uses `type` needs this version of mdeck.
|
|
6
|
+
|
|
3
7
|
## 3.8.0 — 2026-10-11
|
|
4
8
|
|
|
5
9
|
- Hotspots: with `hotspots: auto` in the deck's settings, the presenter view puts a numbered key on each picture, code block, table, formula, video, callout, column and poll of the slide, and **1**–**9** zoom smoothly until that part fills the screen, keeping the slide around a part of another shape in view. The audience window, a paired iPad and phones zoom along; only the presenter view shows the keys, which can also be tapped. The same number, **0**, **Escape**, a tap on the slide or the next point or slide show the whole slide again. `hotspots: marked` keys only parts marked `data-hotspot`; a slide can set its own (`hotspots: false`), and **H** or the presenter view's new button turns hotspots on or off during the talk. Without hotspots, the default, **1**–**9** still go to slides 1–9. The editor has a **Hotspots** choice in the deck's settings and in each slide's details, and the tour deck a slide to try it on.
|
|
@@ -3,7 +3,7 @@ kind = "theme"
|
|
|
3
3
|
id = "academic"
|
|
4
4
|
title = "Academic"
|
|
5
5
|
description = "A lecture: Source Sans and Source Serif, compact slides with the heading in a fixed place, theorem and definition blocks, booktabs tables and a footnote rule."
|
|
6
|
-
version = "1.
|
|
6
|
+
version = "1.1.0"
|
|
7
7
|
author = "Tilman Schieber"
|
|
8
8
|
license = "MIT"
|
|
9
9
|
# The palette this theme uses unless the deck sets one; colours come only from palettes.
|
|
@@ -46,14 +46,17 @@ styles = "styles.css"
|
|
|
46
46
|
[params.fontDisplay]
|
|
47
47
|
token = "--font-display"
|
|
48
48
|
title = "Display font"
|
|
49
|
+
type = "font"
|
|
49
50
|
|
|
50
51
|
[params.fontBody]
|
|
51
52
|
token = "--font-body"
|
|
52
53
|
title = "Body font"
|
|
54
|
+
type = "font"
|
|
53
55
|
|
|
54
56
|
[params.fontSerif]
|
|
55
57
|
token = "--font-serif"
|
|
56
58
|
title = "Serif for theorems and quotes"
|
|
59
|
+
type = "font"
|
|
57
60
|
|
|
58
61
|
# What this theme is for and how to write for it: shown in listings, and read
|
|
59
62
|
# by assistants that write slides as the theme author's advice.
|
|
@@ -3,7 +3,7 @@ kind = "theme"
|
|
|
3
3
|
id = "glass"
|
|
4
4
|
title = "Glass"
|
|
5
5
|
description = "Liquid glass: soft pools of light behind every slide, content without frames, titles on a frosted pane with a bright rim; capsule labels, glass beads for list markers and a glass lens for chapter numbers; Geist throughout, dark by default."
|
|
6
|
-
version = "0.
|
|
6
|
+
version = "0.2.0"
|
|
7
7
|
author = "Tilman Schieber"
|
|
8
8
|
license = "MIT"
|
|
9
9
|
# The palette this theme uses unless the deck sets one; colours come only from palettes.
|
|
@@ -49,14 +49,20 @@ styles = "styles.css"
|
|
|
49
49
|
[params.fontDisplay]
|
|
50
50
|
token = "--font-display"
|
|
51
51
|
title = "Display font"
|
|
52
|
+
type = "font"
|
|
52
53
|
|
|
53
54
|
[params.fontBody]
|
|
54
55
|
token = "--font-body"
|
|
55
56
|
title = "Body font"
|
|
57
|
+
type = "font"
|
|
56
58
|
|
|
57
59
|
[params.glassBlur]
|
|
58
60
|
token = "--glass-blur"
|
|
59
61
|
title = "How much the glass blurs"
|
|
62
|
+
type = "size"
|
|
63
|
+
min = 0
|
|
64
|
+
max = 48
|
|
65
|
+
description = "28px by default, from 0px (clear glass) to about 48px."
|
|
60
66
|
|
|
61
67
|
# What this theme is for and how to write for it: shown in listings, and read
|
|
62
68
|
# by assistants that write slides as the theme author's advice.
|
|
@@ -3,7 +3,7 @@ kind = "theme"
|
|
|
3
3
|
id = "minimal"
|
|
4
4
|
title = "Minimal"
|
|
5
5
|
description = "Nothing but the content: system fonts, calm type, plenty of space, no decoration. Works offline."
|
|
6
|
-
version = "1.
|
|
6
|
+
version = "1.1.0"
|
|
7
7
|
author = "Tilman Schieber"
|
|
8
8
|
license = "MIT"
|
|
9
9
|
# The palette this theme uses unless the deck sets one; colours come only from palettes.
|
|
@@ -42,10 +42,12 @@ styles = "styles.css"
|
|
|
42
42
|
[params.fontDisplay]
|
|
43
43
|
token = "--font-display"
|
|
44
44
|
title = "Display font"
|
|
45
|
+
type = "font"
|
|
45
46
|
|
|
46
47
|
[params.fontBody]
|
|
47
48
|
token = "--font-body"
|
|
48
49
|
title = "Body font"
|
|
50
|
+
type = "font"
|
|
49
51
|
|
|
50
52
|
# What this theme is for and how to write for it: shown in listings, and read
|
|
51
53
|
# by assistants that write slides as the theme author's advice.
|
|
@@ -3,7 +3,7 @@ kind = "theme"
|
|
|
3
3
|
id = "neue"
|
|
4
4
|
title = "Neue"
|
|
5
5
|
description = "Swiss typography: large flush-left type, rules instead of boxes, signal red on white."
|
|
6
|
-
version = "1.
|
|
6
|
+
version = "1.1.0"
|
|
7
7
|
author = "Tilman Schieber"
|
|
8
8
|
license = "MIT"
|
|
9
9
|
# The palette this theme uses unless the deck sets one; colours come only from palettes.
|
|
@@ -43,10 +43,12 @@ styles = "styles.css"
|
|
|
43
43
|
[params.fontDisplay]
|
|
44
44
|
token = "--font-display"
|
|
45
45
|
title = "Display font"
|
|
46
|
+
type = "font"
|
|
46
47
|
|
|
47
48
|
[params.fontBody]
|
|
48
49
|
token = "--font-body"
|
|
49
50
|
title = "Body font"
|
|
51
|
+
type = "font"
|
|
50
52
|
|
|
51
53
|
# What this theme is for and how to write for it: shown in listings, and read
|
|
52
54
|
# by assistants that write slides as the theme author's advice.
|
|
@@ -3,7 +3,7 @@ kind = "theme"
|
|
|
3
3
|
id = "plain"
|
|
4
4
|
title = "Plain"
|
|
5
5
|
description = "Black on white and lots of room: Inter, the heading in a column of its own beside the content, lists of short titles with a line under each, the accent only in small places; in greyscale, dark is the same, inverted."
|
|
6
|
-
version = "0.
|
|
6
|
+
version = "0.2.0"
|
|
7
7
|
author = "Tilman Schieber"
|
|
8
8
|
license = "MIT"
|
|
9
9
|
# The palette this theme uses unless the deck sets one; colours come only from palettes.
|
|
@@ -48,14 +48,20 @@ styles = "styles.css"
|
|
|
48
48
|
[params.fontDisplay]
|
|
49
49
|
token = "--font-display"
|
|
50
50
|
title = "Display font"
|
|
51
|
+
type = "font"
|
|
51
52
|
|
|
52
53
|
[params.fontBody]
|
|
53
54
|
token = "--font-body"
|
|
54
55
|
title = "Body font"
|
|
56
|
+
type = "font"
|
|
55
57
|
|
|
56
58
|
[params.side]
|
|
57
59
|
token = "--side"
|
|
58
60
|
title = "Width of the heading column"
|
|
61
|
+
type = "size"
|
|
62
|
+
min = 400
|
|
63
|
+
max = 640
|
|
64
|
+
description = "560px of the 1920px slide by default, from about 400px to 640px; the content takes the rest."
|
|
59
65
|
|
|
60
66
|
# What this theme is for and how to write for it: shown in listings, and read
|
|
61
67
|
# by assistants that write slides as the theme author's advice.
|
|
@@ -3,7 +3,7 @@ kind = "theme"
|
|
|
3
3
|
id = "work"
|
|
4
4
|
title = "Work"
|
|
5
5
|
description = "An everyday company template: IBM Plex Sans, an accent band along the top, the logo top left, headings in a fixed place, section dividers with a large number in the accent, a quiet footer with the page number."
|
|
6
|
-
version = "0.
|
|
6
|
+
version = "0.2.0"
|
|
7
7
|
author = "Tilman Schieber"
|
|
8
8
|
license = "MIT"
|
|
9
9
|
# The palette this theme uses unless the deck sets one; colours come only from palettes.
|
|
@@ -47,14 +47,20 @@ styles = "styles.css"
|
|
|
47
47
|
[params.fontDisplay]
|
|
48
48
|
token = "--font-display"
|
|
49
49
|
title = "Display font"
|
|
50
|
+
type = "font"
|
|
50
51
|
|
|
51
52
|
[params.fontBody]
|
|
52
53
|
token = "--font-body"
|
|
53
54
|
title = "Body font"
|
|
55
|
+
type = "font"
|
|
54
56
|
|
|
55
57
|
[params.band]
|
|
56
58
|
token = "--band"
|
|
57
|
-
title = "Height of the band along the top
|
|
59
|
+
title = "Height of the band along the top"
|
|
60
|
+
type = "size"
|
|
61
|
+
min = 0
|
|
62
|
+
max = 32
|
|
63
|
+
description = "14px by default, up to about 32px; 0px leaves the top without a band."
|
|
58
64
|
|
|
59
65
|
# What this theme is for and how to write for it: shown in listings, and read
|
|
60
66
|
# by assistants that write slides as the theme author's advice.
|
|
@@ -60,7 +60,7 @@ Each `---` line starts a new slide. Frontmatter is only needed when you want a s
|
|
|
60
60
|
| `session.id` | from the title | Name the session code is derived from |
|
|
61
61
|
| `session.code` | from `session.id` | The session code itself, 4 to 8 digits; phones join at `<server>/<code>` |
|
|
62
62
|
| `components` | — | Extra folders of Preact components shared between decks, relative to the deck or starting with `~/` |
|
|
63
|
-
| `params` | — |
|
|
63
|
+
| `params` | — | The theme's own settings, such as its fonts (see below) |
|
|
64
64
|
| `reader.themes` | `true` | Whether the reader view offers a switch between light and dark (see below) |
|
|
65
65
|
| `reader.notes` | `false` | Whether the reader view shows speaker notes under each slide in Read mode |
|
|
66
66
|
| `bibliography` | — | Reference file, or a list of them, relative to the deck: `.bib`, `.json` (CSL-JSON) or `.yml` (Hayagriva) (see [Citations](#citations-and-references)) |
|
|
@@ -71,15 +71,20 @@ Each `---` line starts a new slide. Frontmatter is only needed when you want a s
|
|
|
71
71
|
|
|
72
72
|
### Theme params
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
Themes offer settings of their own: their fonts, and often the size or
|
|
75
|
+
strength of what makes them look the way they do. Set them under `params:`:
|
|
75
76
|
|
|
76
77
|
```yaml
|
|
77
78
|
params:
|
|
78
79
|
fontDisplay: "Georgia, serif"
|
|
80
|
+
glassBlur: 12px
|
|
79
81
|
```
|
|
80
82
|
|
|
81
|
-
Available params depend on the theme; `mdeck list themes` shows them
|
|
82
|
-
|
|
83
|
+
Available params depend on the theme; `mdeck list themes` shows them with the
|
|
84
|
+
kind of value each takes: a size in pixels, a number, a switch (`1` or `0`), a
|
|
85
|
+
choice or a font. `0` or `0px` turns a feature off. A value that does not fit
|
|
86
|
+
is left out, and `mdeck check` says why. Colours are not params: they come from
|
|
87
|
+
the palette.
|
|
83
88
|
|
|
84
89
|
---
|
|
85
90
|
|
|
@@ -201,7 +201,11 @@ Keep headings to a few words; three or four points make a good slide.
|
|
|
201
201
|
`:root` block from them, so the stylesheet only contains rules.
|
|
202
202
|
- `params` name the settings authors may change under `params:` in the deck.
|
|
203
203
|
Each points at a token; its default is the token's value, so nothing is
|
|
204
|
-
written twice.
|
|
204
|
+
written twice. `type` (`size`, `number`, `switch`, `choice` or `font`) says
|
|
205
|
+
what kind of value it takes; `min`, `max` and `step` go with a size (in
|
|
206
|
+
pixels) or a number, `choices` with a choice. The token's value must be of
|
|
207
|
+
that type. The editor shows a slider, a box to tick or a list for it, and
|
|
208
|
+
`mdeck check` reports a deck value that does not fit.
|
|
205
209
|
- `fonts` are stylesheet URLs. Normal builds load them; single files (`mdeck send`,
|
|
206
210
|
`--single-file`) and PDFs embed their Latin character sets, so they look the
|
|
207
211
|
same offline. The fallbacks named in the token values apply when a font
|
package/docs/reference/themes.md
CHANGED
|
@@ -77,8 +77,12 @@ params:
|
|
|
77
77
|
fontDisplay: "Georgia, serif"
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
-
A parameter's default is its token's value.
|
|
81
|
-
|
|
80
|
+
A parameter's default is its token's value. `type` says what kind of value it
|
|
81
|
+
takes: `size` (pixels, with `min`, `max` and `step`), `number` (with the same
|
|
82
|
+
three), `switch` (1 or 0), `choice` (one of `choices`) or `font`. A deck value
|
|
83
|
+
that does not fit is left out, and `mdeck check` reports it, like unknown
|
|
84
|
+
parameter names; one outside `min` and `max` is used, with a warning. Without
|
|
85
|
+
a `type`, a parameter takes any text.
|
|
82
86
|
|
|
83
87
|
Optional settings: `description`, `appearance = "dark"` for a theme that starts
|
|
84
88
|
dark, and `palettes = [...]` to offer only those palettes (a corporate design
|
|
@@ -140,6 +140,34 @@ params:
|
|
|
140
140
|
|
|
141
141
|
Its default is simply the token's value. Colours are not parameters: they come from the palette.
|
|
142
142
|
|
|
143
|
+
Say what kind of value a parameter takes with `type`, so mdeck can check what authors write and the editor can offer the right control:
|
|
144
|
+
|
|
145
|
+
```toml
|
|
146
|
+
[params.bar]
|
|
147
|
+
token = "--bar"
|
|
148
|
+
title = "Thickness of the bar under headings"
|
|
149
|
+
type = "size"
|
|
150
|
+
min = 0
|
|
151
|
+
max = 20
|
|
152
|
+
description = "12px by default, from about 6px to 20px; 0px leaves headings without bars."
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
| `type` | Takes | The editor shows | Settings |
|
|
156
|
+
|---|---|---|---|
|
|
157
|
+
| `size` | pixels: `12px`, or just `12` | a slider | `min`, `max`, `step` in pixels |
|
|
158
|
+
| `number` | a number; a strength from 0 to 1 | a slider | `min`, `max`, `step` |
|
|
159
|
+
| `switch` | `1` or `0` (also `true`/`false`, `on`/`off`) | a box to tick | |
|
|
160
|
+
| `choice` | one of its `choices` | a list | `choices = ["left", "right"]` |
|
|
161
|
+
| `font` | a CSS font list | a text box | |
|
|
162
|
+
|
|
163
|
+
The token's value must be a value of that type: a `size` is written `"12px"`, a `switch` `"1"`. A value in the deck that does not fit is left out, and the theme's own value stays; `mdeck check` says why. A value outside `min` and `max` is used, with a warning: the range is what the theme was made for. A parameter without a `type` takes any text, as before.
|
|
164
|
+
|
|
165
|
+
A few habits make parameters easy to use:
|
|
166
|
+
|
|
167
|
+
- Turn a feature off with its parameter at zero (`0px`, `0`) rather than a second parameter.
|
|
168
|
+
- Use a switch or a strength by multiplying with it, as in `calc(330px * var(--shapes))`.
|
|
169
|
+
- Let the `description` give the default and a sensible range: the editor shows it under the control, and assistants read it.
|
|
170
|
+
|
|
143
171
|
## Say what it is for
|
|
144
172
|
|
|
145
173
|
A `[guide]` tells people, and assistants that write slides, what your theme is good for and how slides for it are written:
|
package/package.json
CHANGED
package/src/cli/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import { renderPdf, attachPdf, findChrome } from '../build/pdf.js'
|
|
|
22
22
|
import { installSkill, readSkill, TARGETS } from './skill.js'
|
|
23
23
|
import { runPackages } from './themes.js'
|
|
24
24
|
import { ManifestError, KINDS } from '../extensions/manifest.js'
|
|
25
|
+
import { paramKind } from '../extensions/params.js'
|
|
25
26
|
|
|
26
27
|
import { frameworkRoot } from '../paths.js'
|
|
27
28
|
import { parseSlides } from '../core/parseSlides.js'
|
|
@@ -523,6 +524,9 @@ if (command === 'new') {
|
|
|
523
524
|
console.log(` ${c.cyan}${record.id}${c.reset} — ${record.title} ${c.dim}(${record.source})${record.description ? ' ' + record.description : ''}${c.reset}`)
|
|
524
525
|
// What a theme suits, from its guide; --json has the whole guide.
|
|
525
526
|
if (record.manifest?.guide?.suits) console.log(` ${c.dim}Suits: ${record.manifest.guide.suits.join(' · ')}${c.reset}`)
|
|
527
|
+
// The settings a deck may change under params:, with the kind of value each takes.
|
|
528
|
+
const params = Object.entries(record.manifest?.params ?? {})
|
|
529
|
+
if (params.length) console.log(` ${c.dim}Settings: ${params.map(([name, param]) => name + paramKind(param)).join(' · ')}${c.reset}`)
|
|
526
530
|
}
|
|
527
531
|
}
|
|
528
532
|
|
package/src/cli/themes.js
CHANGED
|
@@ -34,6 +34,8 @@ function looksOf(registry, catalogue) {
|
|
|
34
34
|
// The oldest mdeck it works with, for the gallery's install note.
|
|
35
35
|
...(registry.themes[theme.id]?.package?.mdeck ? { mdeck: registry.themes[theme.id].package.mdeck } : {}),
|
|
36
36
|
palette: theme.palette, appearance: theme.appearance ?? 'light', palettes: palettesFor(theme, palettes).map(p => p.id), ...(theme.guide ? { guide: theme.guide } : {}),
|
|
37
|
+
// Its settings, for the gallery's controls: token, default, kind and range.
|
|
38
|
+
...(Object.keys(theme.params ?? {}).length ? { params: theme.params } : {}),
|
|
37
39
|
...(icons.get(theme.id) ? { icon: icons.get(theme.id) } : {}) })),
|
|
38
40
|
palettes: Object.values(palettes).map(palette => ({ id: palette.id, title: palette.title, description: palette.description ?? '', builtIn: builtIn.has(palette.id + ':p'),
|
|
39
41
|
...(palette.theme ? { theme: palette.theme } : {}), light: colours(palette.light), dark: colours(palette.dark) })),
|
package/src/core/validateDeck.js
CHANGED
|
@@ -4,6 +4,7 @@ import { palettesFor } from '../extensions/tokens.js'
|
|
|
4
4
|
import { LABEL_KEYS, CALLOUT_TYPES } from './labels.js'
|
|
5
5
|
import { resolveLayoutProps, propertyErrors } from '../layouts/layoutProps.js'
|
|
6
6
|
import { movedHint } from '../extensions/moved.js'
|
|
7
|
+
import { paramValue } from '../extensions/params.js'
|
|
7
8
|
|
|
8
9
|
// Hotspots (runtime/hotspots.js): the deck's setting and a slide's own.
|
|
9
10
|
const HOTSPOT_SETTINGS = ['auto', 'marked', 'off', true, false]
|
|
@@ -70,7 +71,13 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
|
|
|
70
71
|
}
|
|
71
72
|
if (themes && config.params != null && isPlainObject(config.params) && Object.hasOwn(themes, config.theme ?? 'neue')) {
|
|
72
73
|
const theme = themes[config.theme ?? 'neue']
|
|
73
|
-
for (const name of Object.
|
|
74
|
+
for (const [name, value] of Object.entries(config.params)) {
|
|
75
|
+
const param = theme.params?.[name]
|
|
76
|
+
if (!param) { add('unknown-param', `Theme "${theme.id}" has no parameter "${name}"; available: ${Object.keys(theme.params ?? {}).join(', ') || 'none'}`, deck.configSource?.start, 'warning'); continue }
|
|
77
|
+
const result = paramValue(param, value)
|
|
78
|
+
if (result.problem) add('invalid-param', `params.${name} ${result.problem}; the theme's ${param.default} is used instead`, deck.configSource?.start, 'warning')
|
|
79
|
+
else if (result.note) add('invalid-param', `params.${name} ${result.note}; ${result.value} may not look right`, deck.configSource?.start, 'warning')
|
|
80
|
+
}
|
|
74
81
|
}
|
|
75
82
|
// Callout types the deck names under `callouts:` are its own, on purpose.
|
|
76
83
|
const calloutTypes = [...CALLOUT_TYPES, ...(isPlainObject(config.callouts) ? Object.keys(config.callouts).map(key => key.toLowerCase()) : [])]
|
|
@@ -10,6 +10,7 @@ import { PalettePicker } from './PalettePicker.jsx'
|
|
|
10
10
|
import { LookThumbnail } from './LookThumbnail.jsx'
|
|
11
11
|
import { designUrl, DESIGN_TAB } from '../designLink.js'
|
|
12
12
|
import { normalMode } from '../../runtime/hotspots.js'
|
|
13
|
+
import { paramValue } from '../../extensions/params.js'
|
|
13
14
|
|
|
14
15
|
// What the `show` setting controls: the label for each choice and its values.
|
|
15
16
|
const SHOW = { organization: ['Organization', ['title', 'all', 'none']], author: ['Author and date', ['title', 'all', 'none']], numbers: ['Slide numbers', ['slides', 'all', 'none']], sections: ['Section labels', ['all', 'none']] }
|
|
@@ -75,11 +76,9 @@ export function DeckSettings({ state, edit }) {
|
|
|
75
76
|
<p class="section-title">Theme settings</p>
|
|
76
77
|
{Object.entries(theme.params).map(([key, def]) => {
|
|
77
78
|
const value = config.params?.[key]
|
|
78
|
-
const color = isColor(def.default)
|
|
79
79
|
return <Field key={key} label={def.title ?? key} hint={def.description}>
|
|
80
80
|
<div class="row">
|
|
81
|
-
|
|
82
|
-
<input type="text" value={value ?? ''} placeholder={def.default} onInput={e => param(key, e.currentTarget.value)} />
|
|
81
|
+
<ParamInput def={def} value={value} onChange={next => param(key, next)} />
|
|
83
82
|
{value != null && <button class="btn is-small is-icon" title="Back to the default" aria-label="Back to the default" onClick={() => param(key, '')}><Icon name="close" size={14} /></button>}
|
|
84
83
|
</div>
|
|
85
84
|
</Field>
|
|
@@ -110,3 +109,34 @@ function DesignLink({ kind, item }) {
|
|
|
110
109
|
{own ? `Edit ${kind}` : `Customise ${kind}`} <span class="muted">{item.title}</span>
|
|
111
110
|
</a>
|
|
112
111
|
}
|
|
112
|
+
|
|
113
|
+
// The control for one theme parameter, by its type: a slider for a size or a
|
|
114
|
+
// number with a range, a box to tick for a switch, a list for a choice, else
|
|
115
|
+
// text (with a colour picker when the default is a colour). An empty value
|
|
116
|
+
// means the theme's own.
|
|
117
|
+
function ParamInput({ def, value, onChange }) {
|
|
118
|
+
const current = value != null && !paramValue(def, value).problem ? paramValue(def, value).value : def.default
|
|
119
|
+
if (def.type === 'switch') return <>
|
|
120
|
+
<input type="checkbox" checked={current === '1'} aria-label={def.title} onChange={e => onChange(e.currentTarget.checked ? 1 : 0)} style={{ flex: '0 0 auto', width: 'auto' }} />
|
|
121
|
+
<span class="muted" style={{ flex: '1 1 auto' }}>{current === '1' ? 'On' : 'Off'}</span>
|
|
122
|
+
</>
|
|
123
|
+
if (def.type === 'choice') return <select value={value ?? ''} onChange={e => onChange(e.currentTarget.value)}>
|
|
124
|
+
<option value="">{def.default} (the theme's)</option>
|
|
125
|
+
{def.choices.filter(choice => choice !== def.default).map(choice => <option key={choice} value={choice}>{choice}</option>)}
|
|
126
|
+
</select>
|
|
127
|
+
if ((def.type === 'size' || def.type === 'number') && def.min != null && def.max != null) {
|
|
128
|
+
const unit = def.type === 'size' ? 'px' : ''
|
|
129
|
+
const number = parseFloat(current)
|
|
130
|
+
const step = def.step ?? (def.type === 'size' ? 1 : (def.max - def.min) / 100)
|
|
131
|
+
return <>
|
|
132
|
+
<input type="range" min={def.min} max={def.max} step={step} value={number} aria-label={def.title}
|
|
133
|
+
onInput={e => onChange(unit ? `${e.currentTarget.value}${unit}` : Number(e.currentTarget.value))} />
|
|
134
|
+
<span class="muted" style={{ flex: '0 0 auto', minWidth: '4em', textAlign: 'end' }}>{number}{unit}</span>
|
|
135
|
+
</>
|
|
136
|
+
}
|
|
137
|
+
const color = isColor(def.default)
|
|
138
|
+
return <>
|
|
139
|
+
{color ? <input type="color" value={isColor(value) ? value : def.default} onInput={e => onChange(e.currentTarget.value)} style={{ flex: '0 0 auto' }} /> : null}
|
|
140
|
+
<input type="text" value={value ?? ''} placeholder={def.default} onInput={e => onChange(e.currentTarget.value)} />
|
|
141
|
+
</>
|
|
142
|
+
}
|
|
@@ -7,6 +7,8 @@ import { PalettePicker, Swatches } from './PalettePicker.jsx'
|
|
|
7
7
|
const isColor = value => /^#[0-9a-f]{3,8}$/i.test(String(value ?? '').trim())
|
|
8
8
|
const update = (list, index, patch) => list.map((item, i) => i === index ? { ...item, ...patch } : item)
|
|
9
9
|
const without = (list, index) => list.filter((_, i) => i !== index)
|
|
10
|
+
// The kinds of value a theme parameter takes (see src/extensions/params.js).
|
|
11
|
+
const PARAM_KINDS = [['', 'text'], ['size', 'size'], ['number', 'number'], ['switch', 'switch'], ['choice', 'choice'], ['font', 'font']]
|
|
10
12
|
|
|
11
13
|
function Identity({ model, onChange }) {
|
|
12
14
|
return <>
|
|
@@ -122,8 +124,18 @@ export function ThemeForm({ model, files, palettes = {}, onChange, onFile }) {
|
|
|
122
124
|
<input type="text" value={param.name} placeholder="fontBody" onInput={e => onChange({ params: update(model.params, index, { name: e.currentTarget.value }) })} />
|
|
123
125
|
<select value={param.token ?? ''} onChange={e => onChange({ params: update(model.params, index, { token: e.currentTarget.value }) })}><option value="">token…</option>{tokenNames.map(name => <option key={name} value={name}>{name}</option>)}</select>
|
|
124
126
|
<input type="text" value={param.title ?? ''} placeholder="Title" onInput={e => onChange({ params: update(model.params, index, { title: e.currentTarget.value }) })} />
|
|
127
|
+
<select value={param.type ?? ''} aria-label="Kind of value" title="Kind of value" onChange={e => onChange({ params: update(model.params, index, { type: e.currentTarget.value || undefined }) })}>
|
|
128
|
+
{PARAM_KINDS.map(([type, label]) => <option key={type} value={type}>{label}</option>)}
|
|
129
|
+
</select>
|
|
125
130
|
<button class="btn is-small is-icon" title="Remove setting" aria-label="Remove setting" onClick={() => onChange({ params: without(model.params, index) })}><Icon name="close" size={14} /></button>
|
|
126
131
|
</div>
|
|
132
|
+
{(param.type === 'size' || param.type === 'number') && <div class="row">
|
|
133
|
+
{['min', 'max', 'step'].map(key => <input key={key} type="number" value={param[key] ?? ''} placeholder={key} aria-label={key}
|
|
134
|
+
onInput={e => onChange({ params: update(model.params, index, { [key]: e.currentTarget.value === '' ? undefined : Number(e.currentTarget.value) }) })} />)}
|
|
135
|
+
</div>}
|
|
136
|
+
{param.type === 'choice' && <input type="text" value={(param.choices ?? []).join(', ')} placeholder="choices, separated by commas"
|
|
137
|
+
onInput={e => onChange({ params: update(model.params, index, { choices: e.currentTarget.value.split(',').map(choice => choice.trim()).filter(Boolean) }) })} />}
|
|
138
|
+
<input type="text" value={param.description ?? ''} placeholder="Description: the default and a sensible range" onInput={e => onChange({ params: update(model.params, index, { description: e.currentTarget.value }) })} />
|
|
127
139
|
</div>)}
|
|
128
140
|
<div><button class="btn is-small" onClick={() => onChange({ params: [...model.params, { name: '', token: tokenNames[0] ?? '', title: '' }] })}>+ setting</button></div>
|
|
129
141
|
<details class="advanced">
|
package/src/editor/extensions.js
CHANGED
|
@@ -13,6 +13,9 @@ const STARTER_COLORS = {
|
|
|
13
13
|
|
|
14
14
|
const entries = table => Object.entries(table ?? {}).map(([name, value]) => ({ name, ...(value && typeof value === 'object' && !Array.isArray(value) ? value : { value }) }))
|
|
15
15
|
const table = (list, pick) => Object.fromEntries(list.filter(item => item.name).map(item => [item.name, pick(item)]))
|
|
16
|
+
// A parameter's settings as the manifest writes them; the typed ones only with a type.
|
|
17
|
+
const paramSettings = param => clean({ token: param.token, title: param.title, description: param.description,
|
|
18
|
+
...(param.type ? { type: param.type, min: param.min, max: param.max, step: param.step, choices: param.choices } : {}) })
|
|
16
19
|
const clean = object => Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined && value !== '' && value !== null && !(Array.isArray(value) && !value.length)))
|
|
17
20
|
|
|
18
21
|
export function parseManifest(text) {
|
|
@@ -39,7 +42,7 @@ export function toToml(model) {
|
|
|
39
42
|
else if (model.kind === 'theme') raw = {
|
|
40
43
|
...base, palette: model.palette, ...clean({ palettes: model.palettes }), ...(model.appearance === 'dark' ? { appearance: 'dark' } : {}), fonts: model.fonts ?? [], ...model.extra,
|
|
41
44
|
files: { styles: 'styles.css', ...(model.files ?? {}) }, tokens,
|
|
42
|
-
params: table(model.params ?? [],
|
|
45
|
+
params: table(model.params ?? [], paramSettings),
|
|
43
46
|
}
|
|
44
47
|
else if (model.kind === 'layout') raw = {
|
|
45
48
|
...base, ...(model.frame && model.frame !== 'standard' ? { frame: model.frame } : {}), ...model.extra, ...(Object.keys(model.files ?? {}).length ? { files: model.files } : {}),
|
|
@@ -67,7 +70,7 @@ export function toRuntimeManifest(model) {
|
|
|
67
70
|
if (model.kind === 'palette') return { ...shared, ...(model.theme ? { theme: model.theme } : {}), light: table(model.light ?? [], token => token.value ?? ''), dark: table(model.dark ?? [], token => token.value ?? '') }
|
|
68
71
|
if (model.kind === 'theme') return {
|
|
69
72
|
...shared, palette: model.palette, ...(model.palettes?.length ? { palettes: model.palettes } : {}), appearance: model.appearance ?? 'light', fonts: model.fonts ?? [], tokens,
|
|
70
|
-
params: table(model.params ?? [], param => ({
|
|
73
|
+
params: table(model.params ?? [], param => ({ ...paramSettings(param), default: tokens[param.token] ?? '' })),
|
|
71
74
|
...(model.extra?.guide ? { guide: runtimeGuide(model.extra.guide) } : {}),
|
|
72
75
|
}
|
|
73
76
|
return shared
|
|
@@ -89,6 +92,6 @@ export function starterFiles(kind, id, title, from = null, fromFiles = {}) {
|
|
|
89
92
|
return { [MANIFEST]: toToml({ kind, id, title, description: '', theme: '', light, dark, extra: {} }) }
|
|
90
93
|
}
|
|
91
94
|
if (kind !== 'theme' || !from) throw new Error('A new theme starts as a copy of another one')
|
|
92
|
-
const params = from.params && Object.fromEntries(Object.entries(from.params).map(([name, param]) => [name,
|
|
95
|
+
const params = from.params && Object.fromEntries(Object.entries(from.params).map(([name, param]) => [name, paramSettings(param)]))
|
|
93
96
|
return { [MANIFEST]: toToml({ ...toModel({ ...from, kind, params }), id, title, files: {} }), 'styles.css': fromFiles['styles.css'] ?? '' }
|
|
94
97
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// Theme tokens (type, spacing, fonts) and theme parameters come on top.
|
|
8
8
|
import { tokensToCss, COLOR_ROLES, APPEARANCES, palettesFor } from './tokens.js'
|
|
9
9
|
import { movedHint } from './moved.js'
|
|
10
|
+
import { paramValue } from './params.js'
|
|
10
11
|
|
|
11
12
|
// Code highlighting and logos on dark slides, unless the palette says otherwise.
|
|
12
13
|
export const DARK_TOKENS = {
|
|
@@ -62,8 +63,11 @@ export function buildAppearance({ theme, palettes = {}, palette, appearance, par
|
|
|
62
63
|
const overrides = {}
|
|
63
64
|
for (const [name, value] of Object.entries(params ?? {})) {
|
|
64
65
|
const param = theme.params?.[name]
|
|
65
|
-
if (param)
|
|
66
|
-
|
|
66
|
+
if (!param) { warnings.push(`Theme "${theme.id}" has no parameter "${name}"`); continue }
|
|
67
|
+
// A value that does not fit the parameter's type is left out: the theme's own value stays.
|
|
68
|
+
const result = paramValue(param, value)
|
|
69
|
+
if (result.problem) warnings.push(`params.${name} ${result.problem}; the theme's ${param.default} is used`)
|
|
70
|
+
else overrides[param.token] = result.value
|
|
67
71
|
}
|
|
68
72
|
for (const [key, value] of Object.entries(meta ?? {})) overrides[`--meta-${key}`] = JSON.stringify(String(value))
|
|
69
73
|
const overridesCss = tokensToCss(overrides)
|
|
@@ -6,6 +6,7 @@ import { resolve, relative, isAbsolute, sep } from 'node:path'
|
|
|
6
6
|
import { parse as parseToml, TomlError } from 'smol-toml'
|
|
7
7
|
import { TOKEN_NAME_RE, COLOR_ROLES, COLOR_FALLBACKS, APPEARANCES } from './tokens.js'
|
|
8
8
|
import { propertyErrors } from '../layouts/layoutProps.js'
|
|
9
|
+
import { paramSettingProblems, paramSettings } from './params.js'
|
|
9
10
|
|
|
10
11
|
export const SCHEMA_VERSION = 1
|
|
11
12
|
export const KINDS = ['layout', 'theme', 'palette']
|
|
@@ -198,10 +199,9 @@ function validateTheme(raw, shared, fail) {
|
|
|
198
199
|
const path = `params.${name}`
|
|
199
200
|
if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(name) || RESERVED_NAMES.includes(name)) fail('parameter names use letters and digits and start with a letter', path)
|
|
200
201
|
if (!isPlainObject(param)) fail('must be a table with a token', path)
|
|
201
|
-
for (const key of Object.keys(param)) if (!['token', 'title', 'description'].includes(key)) fail('Unknown parameter setting. Allowed: token, title, description', `${path}.${key}`)
|
|
202
202
|
if (typeof param.token !== 'string' || !Object.hasOwn(tokens, param.token)) fail(`token must name an entry of [tokens] (got ${JSON.stringify(param.token)})`, `${path}.token`)
|
|
203
|
-
for (const key
|
|
204
|
-
params[name] = { token: param.token, default: tokens[param.token], ...(param.title ? { title: param.title } : {}), ...(param.description ? { description: param.description } : {}) }
|
|
203
|
+
for (const [key, message] of paramSettingProblems(param, tokens[param.token])) fail(message, `${path}.${key}`)
|
|
204
|
+
params[name] = { token: param.token, default: tokens[param.token], ...(param.title ? { title: param.title } : {}), ...(param.description ? { description: param.description } : {}), ...paramSettings(param) }
|
|
205
205
|
}
|
|
206
206
|
const guide = validateGuide(raw.guide, fail)
|
|
207
207
|
return {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// What a theme parameter may be set to. A parameter without a type takes any
|
|
2
|
+
// text, as before; a typed one says what kind of value it takes, and mdeck
|
|
3
|
+
// turns what the author wrote into CSS or says why it cannot:
|
|
4
|
+
//
|
|
5
|
+
// size pixels ("12px", or a number: 12), with min and max in pixels
|
|
6
|
+
// number a number, with min and max (a strength is one from 0 to 1)
|
|
7
|
+
// switch 1 or 0 (also true and false, on and off, yes and no)
|
|
8
|
+
// choice one of `choices`
|
|
9
|
+
// font a CSS font list
|
|
10
|
+
//
|
|
11
|
+
// The manifest check, `mdeck check`, the slides and the editor all use these
|
|
12
|
+
// functions, so they agree on what a value means.
|
|
13
|
+
|
|
14
|
+
export const PARAM_TYPES = ['size', 'number', 'switch', 'choice', 'font']
|
|
15
|
+
export const PARAM_KEYS = ['token', 'title', 'description', 'type', 'min', 'max', 'step', 'choices']
|
|
16
|
+
const RANGED = ['size', 'number']
|
|
17
|
+
const NUMBER_RE = /^-?(\d+\.?\d*|\.\d+)$/
|
|
18
|
+
const SWITCH = new Map([['1', '1'], ['0', '0'], ['true', '1'], ['false', '0'], ['on', '1'], ['off', '0'], ['yes', '1'], ['no', '0']])
|
|
19
|
+
|
|
20
|
+
// "12px", 12 or "12" as a number of pixels; null when it is not a size.
|
|
21
|
+
function pixels(value) {
|
|
22
|
+
const text = String(value).trim().toLowerCase()
|
|
23
|
+
const number = text.endsWith('px') ? text.slice(0, -2).trim() : text
|
|
24
|
+
return NUMBER_RE.test(number) ? Number(number) : null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const unit = param => param.type === 'size' ? 'px' : ''
|
|
28
|
+
|
|
29
|
+
/** The range a parameter was made for, as text ("6px to 20px", "at least 0"), or ''. */
|
|
30
|
+
export function paramRange(param) {
|
|
31
|
+
const { min, max } = param
|
|
32
|
+
const u = unit(param)
|
|
33
|
+
if (min != null && max != null) return `${min}${u} to ${max}${u}`
|
|
34
|
+
if (min != null) return `at least ${min}${u}`
|
|
35
|
+
if (max != null) return `at most ${max}${u}`
|
|
36
|
+
return ''
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The kind of value a parameter takes, for listings: " (size, 0px to 48px)", " (1 or 0)", or '' without a type. */
|
|
40
|
+
export function paramKind(param) {
|
|
41
|
+
if (!param.type) return ''
|
|
42
|
+
if (param.type === 'switch') return ' (1 or 0)'
|
|
43
|
+
if (param.type === 'choice') return ` (${param.choices.join(', ')})`
|
|
44
|
+
const range = paramRange(param)
|
|
45
|
+
return ` (${param.type}${range ? ', ' + range : ''})`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The CSS for a value an author set, or why it cannot be used.
|
|
50
|
+
* Returns { value } with a note when it lies outside the range (it is still
|
|
51
|
+
* used: the range is what the theme was made for, not a limit), or { problem }.
|
|
52
|
+
*/
|
|
53
|
+
export function paramValue(param, raw) {
|
|
54
|
+
const type = param.type
|
|
55
|
+
if (raw == null || (typeof raw === 'object')) return { problem: 'needs a single value' }
|
|
56
|
+
if (!type) return { value: String(raw) }
|
|
57
|
+
const text = String(raw).trim()
|
|
58
|
+
if (!text) return { problem: 'is empty' }
|
|
59
|
+
if (type === 'font') return { value: text }
|
|
60
|
+
if (type === 'switch') {
|
|
61
|
+
const value = SWITCH.get(text.toLowerCase())
|
|
62
|
+
return value ? { value } : { problem: 'is a switch: 1 or 0' }
|
|
63
|
+
}
|
|
64
|
+
if (type === 'choice') {
|
|
65
|
+
return param.choices.includes(text) ? { value: text } : { problem: `must be one of: ${param.choices.join(', ')}` }
|
|
66
|
+
}
|
|
67
|
+
const number = type === 'size' ? pixels(text) : (NUMBER_RE.test(text) ? Number(text) : null)
|
|
68
|
+
if (number == null) return { problem: type === 'size' ? 'is a size in pixels, such as 12px' : 'is a number, such as 0.5' }
|
|
69
|
+
const value = type === 'size' ? `${number}px` : String(number)
|
|
70
|
+
const outside = (param.min != null && number < param.min) || (param.max != null && number > param.max)
|
|
71
|
+
return outside ? { value, note: `is made for ${paramRange(param)}` } : { value }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What is wrong with a parameter's settings in a manifest, as [key, message]
|
|
76
|
+
* pairs; `fallback` is its default (the token's value), which must fit it too.
|
|
77
|
+
*/
|
|
78
|
+
export function paramSettingProblems(param, fallback) {
|
|
79
|
+
const problems = []
|
|
80
|
+
for (const key of Object.keys(param)) if (!PARAM_KEYS.includes(key)) problems.push([key, `Unknown parameter setting. Allowed: ${PARAM_KEYS.join(', ')}`])
|
|
81
|
+
for (const key of ['title', 'description']) if (param[key] != null && typeof param[key] !== 'string') problems.push([key, 'must be text'])
|
|
82
|
+
const type = param.type
|
|
83
|
+
if (type == null) {
|
|
84
|
+
for (const key of ['min', 'max', 'step', 'choices']) if (param[key] != null) problems.push([key, 'needs a type (size or number for min, max and step; choice for choices)'])
|
|
85
|
+
return problems
|
|
86
|
+
}
|
|
87
|
+
if (!PARAM_TYPES.includes(type)) return [...problems, ['type', `must be one of: ${PARAM_TYPES.join(', ')}`]]
|
|
88
|
+
for (const key of ['min', 'max', 'step']) {
|
|
89
|
+
if (param[key] == null) continue
|
|
90
|
+
if (!RANGED.includes(type)) problems.push([key, `is for a size or a number, not a ${type}`])
|
|
91
|
+
else if (typeof param[key] !== 'number' || !Number.isFinite(param[key])) problems.push([key, `must be a number${type === 'size' ? ' of pixels, such as 12' : ''}`])
|
|
92
|
+
}
|
|
93
|
+
if (typeof param.step === 'number' && param.step <= 0) problems.push(['step', 'must be more than 0'])
|
|
94
|
+
if (typeof param.min === 'number' && typeof param.max === 'number' && param.min > param.max) problems.push(['max', 'must not be less than min'])
|
|
95
|
+
if (type === 'choice') {
|
|
96
|
+
const choices = param.choices
|
|
97
|
+
if (!Array.isArray(choices) || !choices.length || choices.some(choice => typeof choice !== 'string' || !choice.trim())) problems.push(['choices', 'a choice needs a list of its values, such as choices = ["left", "right"]'])
|
|
98
|
+
else if (new Set(choices).size !== choices.length) problems.push(['choices', 'lists a value twice'])
|
|
99
|
+
} else if (param.choices != null) problems.push(['choices', `is for a choice, not a ${type}`])
|
|
100
|
+
if (problems.length || fallback == null) return problems
|
|
101
|
+
// The default is the token's value: it has to be a value of this type too.
|
|
102
|
+
const result = paramValue(param, fallback)
|
|
103
|
+
if (result.problem) problems.push(['type', `the token's value ${JSON.stringify(fallback)} ${result.problem}`])
|
|
104
|
+
else if (result.note) problems.push(['type', `the token's value ${JSON.stringify(fallback)} lies outside ${paramRange(param)}`])
|
|
105
|
+
else if (type !== 'font' && result.value !== fallback) problems.push(['type', `write the token's value as ${JSON.stringify(result.value)}`])
|
|
106
|
+
return problems
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A parameter's typed settings, as a normalized manifest keeps them. */
|
|
110
|
+
export function paramSettings(param) {
|
|
111
|
+
if (!param.type) return {}
|
|
112
|
+
return Object.fromEntries(['type', 'min', 'max', 'step', 'choices'].filter(key => param[key] != null).map(key => [key, key === 'choices' ? [...param[key]] : param[key]]))
|
|
113
|
+
}
|