mdeck 2.3.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -1
- package/README.md +9 -9
- package/assets/base.css +4 -1
- package/assets/extensions/palettes/lagoon/extension.toml +3 -0
- package/assets/extensions/palettes/neon/extension.toml +3 -0
- package/assets/extensions/palettes/nordic/extension.toml +3 -0
- package/assets/extensions/palettes/paper/extension.toml +30 -0
- package/assets/extensions/palettes/swiss/extension.toml +3 -0
- package/assets/extensions/themes/academic/extension.toml +3 -0
- package/assets/extensions/themes/aurora/extension.toml +3 -0
- package/assets/extensions/themes/minimal/extension.toml +48 -0
- package/assets/extensions/themes/minimal/styles.css +342 -0
- package/assets/extensions/themes/neue/extension.toml +3 -0
- package/docs/reference/authoring.md +13 -18
- package/docs/reference/claude-skill.md +1 -1
- package/docs/reference/extensions.md +40 -26
- package/docs/reference/palettes.md +8 -12
- package/docs/reference/themes.md +8 -7
- package/docs/site/content/appearance.md +35 -49
- package/docs/site/content/commands.md +3 -2
- package/docs/site/content/theme-authoring.md +1 -1
- package/docs/site/content/theme-repository.md +44 -32
- package/docs/site/examples/first-talk.md +1 -1
- package/docs/site/images/themes/academic-dark.webp +0 -0
- package/docs/site/images/themes/academic-light.webp +0 -0
- package/docs/site/images/themes/academic.webp +0 -0
- package/docs/site/images/themes/aurora-dark.webp +0 -0
- package/docs/site/images/themes/aurora-light.webp +0 -0
- package/docs/site/images/themes/aurora.webp +0 -0
- package/docs/site/images/themes/minimal-dark.webp +0 -0
- package/docs/site/images/themes/minimal-light.webp +0 -0
- package/docs/site/images/themes/minimal.webp +0 -0
- package/docs/site/images/themes/neue-dark.webp +0 -0
- package/docs/site/images/themes/neue-light.webp +0 -0
- package/docs/site/images/themes/neue.webp +0 -0
- package/docs/site/pages.js +1 -1
- package/examples/custom-layouts/slides.md +1 -1
- package/examples/poll/slides.md +1 -1
- package/examples/python/slides.md +2 -2
- package/examples/showcase/slides.md +3 -3
- package/package.json +2 -2
- package/skills/write-slides/SKILL.md +2 -2
- package/src/build/check.js +0 -7
- package/src/build/editorPlugin.js +26 -13
- package/src/build/themeCheck.js +17 -6
- package/src/cli/args.js +2 -1
- package/src/cli/index.js +7 -6
- package/src/cli/themes.js +117 -87
- package/src/core/validateDeck.js +4 -2
- package/src/editor/api.js +15 -6
- package/src/editor/components/App.jsx +4 -2
- package/src/editor/components/DesignApp.jsx +16 -9
- package/src/editor/components/ExtensionEditor.jsx +16 -6
- package/src/editor/components/ExtensionsPanel.jsx +28 -34
- package/src/editor/designLink.js +18 -1
- package/src/editor/sampleDeck.js +3 -1
- package/src/extensions/appearance.js +2 -1
- package/src/extensions/discover.js +44 -11
- package/src/extensions/installed.js +88 -0
- package/src/extensions/manifest.js +20 -3
- package/src/extensions/moved.js +20 -0
- package/src/extensions/packages.js +318 -0
- package/src/home/Home.jsx +12 -6
- package/src/layouts/layoutApi.jsx +10 -2
- package/src/runtime/themeLoader.js +5 -2
- package/assets/extensions/palettes/brand/extension.toml +0 -29
- package/assets/extensions/palettes/cobalt/extension.toml +0 -27
- package/assets/extensions/palettes/ember/extension.toml +0 -27
- package/assets/extensions/palettes/forest/extension.toml +0 -27
- package/assets/extensions/palettes/graphite/extension.toml +0 -27
- package/assets/extensions/palettes/pastel/extension.toml +0 -30
- package/assets/extensions/palettes/phosphor/extension.toml +0 -27
- package/assets/extensions/palettes/terra/extension.toml +0 -27
- package/assets/extensions/themes/duet/extension.toml +0 -46
- package/assets/extensions/themes/duet/styles.css +0 -573
- package/assets/extensions/themes/editorial/extension.toml +0 -46
- package/assets/extensions/themes/editorial/styles.css +0 -574
- package/assets/extensions/themes/fhnw/extension.toml +0 -61
- package/assets/extensions/themes/fhnw/styles.css +0 -604
- package/assets/extensions/themes/sketch/extension.toml +0 -48
- package/assets/extensions/themes/sketch/styles.css +0 -633
- package/assets/extensions/themes/terminal/extension.toml +0 -38
- package/assets/extensions/themes/terminal/styles.css +0 -550
- package/docs/site/images/themes/duet-dark.webp +0 -0
- package/docs/site/images/themes/duet-light.webp +0 -0
- package/docs/site/images/themes/duet.webp +0 -0
- package/docs/site/images/themes/editorial-dark.webp +0 -0
- package/docs/site/images/themes/editorial-light.webp +0 -0
- package/docs/site/images/themes/editorial.webp +0 -0
- package/docs/site/images/themes/fhnw.webp +0 -0
- package/docs/site/images/themes/sketch-dark.webp +0 -0
- package/docs/site/images/themes/sketch-light.webp +0 -0
- package/docs/site/images/themes/sketch.webp +0 -0
- package/docs/site/images/themes/terminal-dark.webp +0 -0
- package/docs/site/images/themes/terminal-light.webp +0 -0
- package/docs/site/images/themes/terminal.webp +0 -0
- package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T08-36-47Z.json +0 -19
- package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T11-27-30Z.json +0 -6
- package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T08-08-30Z.json +0 -10
- package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T11-41-35Z.json +0 -13
- package/src/extensions/packs.js +0 -268
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// Packages: every theme and every palette can be installed on its own, from
|
|
2
|
+
// mdeck itself (the built-in ones) or from the theme repository
|
|
3
|
+
// (https://gh.tschieber.de/mdeck-themes/). A package is one extension folder;
|
|
4
|
+
// it travels as one JSON file of its text files, so installing it is checking
|
|
5
|
+
// it and writing those files. Only extension.toml and, for a theme,
|
|
6
|
+
// styles.css are allowed: a package never carries code.
|
|
7
|
+
// What a package needs follows from its manifest: a theme needs its default
|
|
8
|
+
// palette, and a palette that belongs to one theme needs that theme. Those
|
|
9
|
+
// are installed along with it.
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
11
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { resolve, relative, basename } from 'node:path'
|
|
13
|
+
import { ID_RE, ManifestError, RANGE_RE, parseManifestText, validateManifest } from './manifest.js'
|
|
14
|
+
import { builtinExtensionsRoot } from '../paths.js'
|
|
15
|
+
|
|
16
|
+
export const CATALOGUE_URL = 'https://gh.tschieber.de/mdeck-themes/catalogue.json'
|
|
17
|
+
export const MARKER = '.mdeck-package.json'
|
|
18
|
+
const PACKAGE_KINDS = ['theme', 'palette']
|
|
19
|
+
const FILES = { theme: ['extension.toml', 'styles.css'], palette: ['extension.toml'] }
|
|
20
|
+
// Where theme fonts may come from: stylesheet services that only serve fonts.
|
|
21
|
+
export const FONT_HOSTS = ['https://fonts.googleapis.com/', 'https://fonts.bunny.net/']
|
|
22
|
+
|
|
23
|
+
export class PackageError extends Error {}
|
|
24
|
+
const fail = message => { throw new PackageError(message) }
|
|
25
|
+
|
|
26
|
+
export const sha256 = text => createHash('sha256').update(text, 'utf8').digest('hex')
|
|
27
|
+
export const keyOf = (kind, id) => `${kind}:${id}`
|
|
28
|
+
|
|
29
|
+
export function catalogueUrl() {
|
|
30
|
+
return process.env.MDECK_THEMES_URL || CATALOGUE_URL
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const parts = version => version.split('.').map(Number)
|
|
34
|
+
export function compareVersions(a, b) {
|
|
35
|
+
const [x, y] = [parts(a), parts(b)]
|
|
36
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i]
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Ranges are only of the form ">=1.2.3"; no range means any version.
|
|
41
|
+
export function satisfies(range, version) {
|
|
42
|
+
if (!range) return true
|
|
43
|
+
const match = RANGE_RE.exec(range)
|
|
44
|
+
return Boolean(match) && compareVersions(version, match[1]) >= 0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function checkCss(text, where) {
|
|
48
|
+
if (/@import/i.test(text)) fail(`${where}: @import is not allowed; list font stylesheets under fonts in extension.toml`)
|
|
49
|
+
for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi)) {
|
|
50
|
+
if (!match[2].trim().startsWith('data:')) fail(`${where}: url(${match[2]}) is not allowed; only data: URLs`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Checks one package's files: what it may hold, its manifest, its fonts and
|
|
55
|
+
// stylesheet. `published` also requires version, author and license, as the
|
|
56
|
+
// theme repository does. Returns the validated record.
|
|
57
|
+
export function checkPackage({ kind, id, files }, { published = false } = {}) {
|
|
58
|
+
if (!PACKAGE_KINDS.includes(kind)) fail(`${kind} is not a kind of package; only themes and palettes are`)
|
|
59
|
+
if (typeof id !== 'string' || !ID_RE.test(id)) fail(`"${id}" is not an id: lowercase letters, digits and hyphens, starting with a letter`)
|
|
60
|
+
if (!files || typeof files !== 'object') fail(`The ${kind} ${id} has no files`)
|
|
61
|
+
for (const [name, text] of Object.entries(files)) {
|
|
62
|
+
if (!FILES[kind].includes(name)) fail(`The ${kind} ${id}: "${name}" is not allowed; a ${kind} holds only ${FILES[kind].join(' and ')}`)
|
|
63
|
+
if (typeof text !== 'string') fail(`The ${kind} ${id}: ${name} is not text`)
|
|
64
|
+
}
|
|
65
|
+
if (!files['extension.toml']) fail(`The ${kind} ${id}: extension.toml is missing`)
|
|
66
|
+
const dir = `/package/${id}`
|
|
67
|
+
const file = `${dir}/extension.toml`
|
|
68
|
+
let record
|
|
69
|
+
try {
|
|
70
|
+
record = validateManifest(parseManifestText(files['extension.toml'], file), { file, dir, folderName: id, fileExists: path => Object.hasOwn(files, relative(dir, path)) })
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error instanceof ManifestError) fail(`The ${kind} ${id}: ${error.message.replace(`${file}: `, '')}`)
|
|
73
|
+
throw error
|
|
74
|
+
}
|
|
75
|
+
if (record.kind !== kind) fail(`${id}/extension.toml says kind = "${record.kind}", but it is in the ${kind}s`)
|
|
76
|
+
if (published) for (const key of ['version', 'author', 'license']) if (!record.package?.[key]) fail(`The ${kind} ${id}: ${key} is required in the theme repository`)
|
|
77
|
+
if (kind === 'theme') {
|
|
78
|
+
for (const url of record.manifest.fonts) if (!FONT_HOSTS.some(host => url.startsWith(host))) fail(`The theme ${id} loads fonts from ${url}; allowed are ${FONT_HOSTS.join(', ')}`)
|
|
79
|
+
checkCss(files['styles.css'] ?? '', `The theme ${id}: styles.css`)
|
|
80
|
+
}
|
|
81
|
+
return record
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// What a package needs installed with it: a theme its default palette, a
|
|
85
|
+
// palette of one theme that theme.
|
|
86
|
+
export function dependencies(kind, manifest) {
|
|
87
|
+
if (kind === 'theme') return [{ kind: 'palette', id: manifest.palette }]
|
|
88
|
+
return manifest.theme ? [{ kind: 'theme', id: manifest.theme }] : []
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A package folder's files and record; `kind` comes from the manifest.
|
|
92
|
+
export function readPackageDir(dir, { published = false } = {}) {
|
|
93
|
+
const id = basename(dir)
|
|
94
|
+
const files = {}
|
|
95
|
+
for (const name of readdirSync(dir).sort()) {
|
|
96
|
+
if (name.startsWith('.') || name === 'README.md') continue
|
|
97
|
+
if (!statSync(resolve(dir, name)).isFile()) fail(`${resolve(dir, name)}: a package holds only files`)
|
|
98
|
+
files[name] = readFileSync(resolve(dir, name), 'utf8')
|
|
99
|
+
}
|
|
100
|
+
const kind = /^kind\s*=\s*"([a-z]+)"/m.exec(files['extension.toml'] ?? '')?.[1]
|
|
101
|
+
const record = checkPackage({ kind, id, files }, { published })
|
|
102
|
+
return { kind, id, files, record }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The published form of a package folder: one JSON text and its checksum.
|
|
106
|
+
export function bundlePackage(dir, options) {
|
|
107
|
+
const { kind, id, files, record } = readPackageDir(dir, options)
|
|
108
|
+
const bundle = { schema: 2, kind, id, version: record.package?.version ?? '0.0.0', ...(record.package?.mdeck ? { mdeck: record.package.mdeck } : {}), files }
|
|
109
|
+
const text = JSON.stringify(bundle, null, 1) + '\n'
|
|
110
|
+
return { bundle, text, sha256: sha256(text), record }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ─── Installed packages ───────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
const readJson = file => { try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null } }
|
|
116
|
+
const packageDir = (root, kind, id) => resolve(root, `${kind}s`, id)
|
|
117
|
+
|
|
118
|
+
// Files changed since the package was installed, by name.
|
|
119
|
+
function changedFiles(dir, marker) {
|
|
120
|
+
return Object.entries(marker.files ?? {}).filter(([name, hash]) => {
|
|
121
|
+
const file = resolve(dir, name)
|
|
122
|
+
return !existsSync(file) || sha256(readFileSync(file, 'utf8')) !== hash
|
|
123
|
+
}).map(([name]) => name)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const refuseChanged = (dir, marker) => {
|
|
127
|
+
const changed = changedFiles(dir, marker)
|
|
128
|
+
if (changed.length) fail(`${dir} was changed since it was installed (${changed.join(', ')}); add --force to replace or remove it anyway`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The packages installed in an extensions folder (in its themes/ and
|
|
132
|
+
// palettes/), by "kind:id".
|
|
133
|
+
export function installedPackages(root) {
|
|
134
|
+
const found = {}
|
|
135
|
+
for (const kind of PACKAGE_KINDS) {
|
|
136
|
+
const dir = resolve(root, `${kind}s`)
|
|
137
|
+
if (!existsSync(dir)) continue
|
|
138
|
+
for (const id of readdirSync(dir).sort()) {
|
|
139
|
+
const marker = readJson(resolve(dir, id, MARKER))
|
|
140
|
+
if (marker?.kind === kind) found[keyOf(kind, id)] = { kind, id, version: marker.version ?? null, url: marker.url ?? null, dir: resolve(dir, id), marker }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return found
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Writes a checked package into an extensions folder (a deck's, or the
|
|
147
|
+
// user's), as <kind>s/<id>. A folder of the deck's own is never touched; one
|
|
148
|
+
// installed before is replaced unless it was changed by hand.
|
|
149
|
+
export function installPackage(bundle, { root, url = null, digest = null, force = false } = {}) {
|
|
150
|
+
const { kind, id } = bundle
|
|
151
|
+
const record = checkPackage(bundle)
|
|
152
|
+
const dir = packageDir(root, kind, id)
|
|
153
|
+
const before = installedPackages(root)[keyOf(kind, id)]
|
|
154
|
+
if (existsSync(dir) && !readJson(resolve(dir, MARKER))) fail(`${dir} already exists and was not installed by mdeck; rename or remove it first`)
|
|
155
|
+
if (before && !force) refuseChanged(before.dir, before.marker)
|
|
156
|
+
// Files are overwritten in place, not deleted and written again, so a
|
|
157
|
+
// running editor sees a change instead of a file that disappears.
|
|
158
|
+
mkdirSync(dir, { recursive: true })
|
|
159
|
+
const hashes = {}
|
|
160
|
+
for (const [name, content] of Object.entries(bundle.files)) {
|
|
161
|
+
writeFileSync(resolve(dir, name), content, 'utf8')
|
|
162
|
+
hashes[name] = sha256(content)
|
|
163
|
+
}
|
|
164
|
+
for (const name of readdirSync(dir)) if (name !== MARKER && !Object.hasOwn(hashes, name)) rmSync(resolve(dir, name), { recursive: true, force: true })
|
|
165
|
+
writeFileSync(resolve(dir, MARKER), JSON.stringify({ kind, id, version: bundle.version, url, sha256: digest, files: hashes }, null, 2) + '\n')
|
|
166
|
+
return { record, dir, replaced: before?.version ?? null }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function removePackage(kind, id, { root, force = false } = {}) {
|
|
170
|
+
const installed = installedPackages(root)[keyOf(kind, id)]
|
|
171
|
+
if (!installed) fail(`The ${kind} ${id} is not installed in ${root}`)
|
|
172
|
+
if (!force) refuseChanged(installed.dir, installed.marker)
|
|
173
|
+
rmSync(installed.dir, { recursive: true, force: true })
|
|
174
|
+
return installed
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── The catalogue: built-in packages and the repository ──────────────────
|
|
178
|
+
|
|
179
|
+
async function get(url, as) {
|
|
180
|
+
let response
|
|
181
|
+
try { response = await fetch(url) } catch (error) { fail(`Could not reach ${url}: ${error.cause?.message ?? error.message}`) }
|
|
182
|
+
if (!response.ok) fail(`${url} answered ${response.status}`)
|
|
183
|
+
return as === 'json' ? response.json() : response.text()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function fetchCatalogue(url = catalogueUrl()) {
|
|
187
|
+
const data = await get(url, 'json')
|
|
188
|
+
if (data?.schema !== 2 || !Array.isArray(data.themes) || !Array.isArray(data.palettes)) fail(`${url} is not a theme repository catalogue`)
|
|
189
|
+
return data
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// One entry of the catalogue, from a package's record.
|
|
193
|
+
function entryOf(kind, record) {
|
|
194
|
+
return {
|
|
195
|
+
kind, id: record.id, title: record.title, description: record.description ?? '',
|
|
196
|
+
version: record.package?.version ?? '0.0.0', ...(record.package?.author ? { author: record.package.author } : {}), ...(record.package?.license ? { license: record.package.license } : {}),
|
|
197
|
+
...(record.package?.homepage ? { homepage: record.package.homepage } : {}), ...(record.package?.mdeck ? { mdeck: record.package.mdeck } : {}),
|
|
198
|
+
...(kind === 'theme' ? { palette: record.manifest.palette, ...(record.manifest.palettes?.length ? { palettes: record.manifest.palettes } : {}), appearance: record.manifest.appearance ?? 'light' } : {}),
|
|
199
|
+
...(kind === 'palette' && record.manifest.theme ? { theme: record.manifest.theme } : {}),
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The themes and palettes that come with mdeck, as catalogue entries.
|
|
204
|
+
export function builtInEntries(root = builtinExtensionsRoot) {
|
|
205
|
+
const entries = []
|
|
206
|
+
for (const kind of PACKAGE_KINDS) {
|
|
207
|
+
const dir = resolve(root, `${kind}s`)
|
|
208
|
+
if (!existsSync(dir)) continue
|
|
209
|
+
for (const id of readdirSync(dir).sort()) {
|
|
210
|
+
if (id.startsWith('.')) continue
|
|
211
|
+
const { record } = readPackageDir(resolve(dir, id))
|
|
212
|
+
entries.push({ ...entryOf(kind, record), builtIn: true, dir: resolve(dir, id) })
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return entries
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Every theme and palette mdeck can install: the built-in ones, and the
|
|
219
|
+
// repository's when it answers. Of two versions the newer is offered; the
|
|
220
|
+
// built-in one on a tie, since it needs no download. `offline` says why the
|
|
221
|
+
// repository is missing.
|
|
222
|
+
export async function loadCatalogue({ url = catalogueUrl(), online = true } = {}) {
|
|
223
|
+
const entries = new Map(builtInEntries().map(entry => [keyOf(entry.kind, entry.id), entry]))
|
|
224
|
+
let offline = null
|
|
225
|
+
let remote = { themes: [], palettes: [] }
|
|
226
|
+
if (online) { try { remote = await fetchCatalogue(url) } catch (error) { offline = error.message } }
|
|
227
|
+
for (const entry of [...remote.themes.map(e => ({ ...e, kind: 'theme' })), ...remote.palettes.map(e => ({ ...e, kind: 'palette' }))]) {
|
|
228
|
+
const key = keyOf(entry.kind, entry.id)
|
|
229
|
+
const have = entries.get(key)
|
|
230
|
+
if (entry.builtIn) { if (have) have.preview = entry.preview; continue }
|
|
231
|
+
if (!have || compareVersions(entry.version, have.version) > 0) entries.set(key, { ...entry, builtIn: false, builtInVersion: have?.version })
|
|
232
|
+
}
|
|
233
|
+
const sorted = [...entries.values()].sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id, 'en'))
|
|
234
|
+
return { url, offline, entries: sorted, get: (kind, id) => entries.get(keyOf(kind, id)) }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function findEntry(catalogue, kind, id) {
|
|
238
|
+
return catalogue.get(kind, id) ?? fail(`There is no ${kind} "${id}"${catalogue.offline ? ` among those that come with mdeck, and the theme repository could not be reached (${catalogue.offline})` : `; mdeck ${kind}s search lists them`}`)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// A package's files: read from mdeck's folder, or downloaded and checked
|
|
242
|
+
// against the repository's checksum.
|
|
243
|
+
export async function obtainPackage(catalogue, entry) {
|
|
244
|
+
if (entry.builtIn) { const { bundle, sha256: digest } = bundlePackage(entry.dir); return { bundle, url: null, digest } }
|
|
245
|
+
const url = new URL(entry.url, catalogue.url).href
|
|
246
|
+
const text = await get(url, 'text')
|
|
247
|
+
if (sha256(text) !== entry.sha256) fail(`${url} does not match its checksum in the catalogue; not installed`)
|
|
248
|
+
let bundle
|
|
249
|
+
try { bundle = JSON.parse(text) } catch { fail(`${url} is not a package`) }
|
|
250
|
+
if (bundle.kind !== entry.kind || bundle.id !== entry.id || bundle.version !== entry.version) fail(`${url} holds the ${bundle.kind} ${bundle.id} ${bundle.version}, not the ${entry.kind} ${entry.id} ${entry.version}`)
|
|
251
|
+
return { bundle, url, digest: entry.sha256 }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// A package and what it needs, in the order to install them: what is needed
|
|
255
|
+
// first, the package itself last. A theme and its own palette need each
|
|
256
|
+
// other; each is installed once.
|
|
257
|
+
export function withDependencies(catalogue, kind, id, seen = new Set()) {
|
|
258
|
+
const key = keyOf(kind, id)
|
|
259
|
+
if (seen.has(key)) return []
|
|
260
|
+
seen.add(key)
|
|
261
|
+
const entry = findEntry(catalogue, kind, id)
|
|
262
|
+
const needs = kind === 'theme' ? [{ kind: 'palette', id: entry.palette }] : entry.theme ? [{ kind: 'theme', id: entry.theme }] : []
|
|
263
|
+
const order = []
|
|
264
|
+
for (const need of needs) for (const item of withDependencies(catalogue, need.kind, need.id, seen)) if (!order.includes(item)) order.push(item)
|
|
265
|
+
return [...order, entry]
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ─── The repository itself ────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
// The theme repository as served: catalogue.json, and <kind>s/<id>-<version>.json
|
|
271
|
+
// for each package, from a folder with themes/ and palettes/. mdeck's
|
|
272
|
+
// built-in packages are listed too (marked builtIn), so the repository's
|
|
273
|
+
// themes may use built-in palettes. Every package is checked, no id appears
|
|
274
|
+
// twice in a kind, and what each needs is in the catalogue. Previews are added
|
|
275
|
+
// by the caller.
|
|
276
|
+
export function buildRepository(repoDir, outDir) {
|
|
277
|
+
const builtIn = builtInEntries()
|
|
278
|
+
const records = {}
|
|
279
|
+
const add = (kind, id, value) => {
|
|
280
|
+
if (records[keyOf(kind, id)]) fail(`There are two ${kind}s "${id}"${value.builtIn || records[keyOf(kind, id)].builtIn ? '; one comes with mdeck, change it there' : ''}`)
|
|
281
|
+
records[keyOf(kind, id)] = value
|
|
282
|
+
}
|
|
283
|
+
for (const entry of builtIn) add(entry.kind, entry.id, { entry, builtIn: true, ...bundlePackage(entry.dir) })
|
|
284
|
+
for (const kind of PACKAGE_KINDS) {
|
|
285
|
+
const dir = resolve(repoDir, `${kind}s`)
|
|
286
|
+
if (!existsSync(dir)) continue
|
|
287
|
+
for (const id of readdirSync(dir).sort()) {
|
|
288
|
+
if (id.startsWith('.') || !statSync(resolve(dir, id)).isDirectory()) continue
|
|
289
|
+
const read = bundlePackage(resolve(dir, id), { published: true })
|
|
290
|
+
if (read.bundle.kind !== kind) fail(`${kind}s/${id} holds a ${read.bundle.kind}`)
|
|
291
|
+
add(kind, id, { entry: entryOf(kind, read.record), builtIn: false, dir: resolve(dir, id), ...read })
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// What each needs must be there, and a theme may offer only palettes that
|
|
295
|
+
// are open to it.
|
|
296
|
+
for (const { record, bundle } of Object.values(records)) {
|
|
297
|
+
for (const need of dependencies(bundle.kind, record.manifest)) {
|
|
298
|
+
if (!records[keyOf(need.kind, need.id)]) fail(`The ${bundle.kind} ${bundle.id} needs the ${need.kind} "${need.id}", which is neither in the repository nor built into mdeck`)
|
|
299
|
+
}
|
|
300
|
+
if (bundle.kind === 'theme') for (const id of [record.manifest.palette, ...(record.manifest.palettes ?? [])]) {
|
|
301
|
+
const palette = records[keyOf('palette', id)]
|
|
302
|
+
if (!palette) fail(`The theme ${bundle.id} offers the palette "${id}", which is neither in the repository nor built into mdeck`)
|
|
303
|
+
const owner = palette.record.manifest.theme
|
|
304
|
+
if (owner && owner !== bundle.id) fail(`The theme ${bundle.id} offers the palette ${id}, which belongs to the theme ${owner}`)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const catalogue = { schema: 2, themes: [], palettes: [] }
|
|
308
|
+
for (const key of Object.keys(records).sort()) {
|
|
309
|
+
const { entry, text, sha256: digest, bundle, builtIn: isBuiltIn } = records[key]
|
|
310
|
+
const file = `${bundle.kind}s/${bundle.id}-${bundle.version}.json`
|
|
311
|
+
mkdirSync(resolve(outDir, `${bundle.kind}s`), { recursive: true })
|
|
312
|
+
writeFileSync(resolve(outDir, file), text)
|
|
313
|
+
const { dir, ...listed } = entry
|
|
314
|
+
catalogue[`${bundle.kind}s`].push({ ...listed, ...(isBuiltIn ? { builtIn: true } : {}), url: file, sha256: digest })
|
|
315
|
+
}
|
|
316
|
+
writeFileSync(resolve(outDir, 'catalogue.json'), JSON.stringify(catalogue, null, 2) + '\n')
|
|
317
|
+
return { catalogue, dirs: Object.fromEntries(Object.entries(records).map(([key, value]) => [key, value.dir ?? value.entry.dir])) }
|
|
318
|
+
}
|
package/src/home/Home.jsx
CHANGED
|
@@ -66,24 +66,29 @@ async function openService(action, page = '') {
|
|
|
66
66
|
const tab = window.open('about:blank', '_blank')
|
|
67
67
|
try {
|
|
68
68
|
const { url } = await api('/action', { action })
|
|
69
|
-
const target = new URL(page, url)
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
const target = new URL(page, url)
|
|
70
|
+
// The editors link back here.
|
|
71
|
+
if (action === 'editor') target.searchParams.set('home', location.href.split('#')[0])
|
|
72
|
+
if (tab) tab.location = target.href
|
|
73
|
+
else window.open(target.href, '_blank')
|
|
72
74
|
} catch (error) {
|
|
73
75
|
tab?.close()
|
|
74
76
|
throw error
|
|
75
77
|
}
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
|
|
80
|
+
// `page` opens another page of the same service, such as the editor's
|
|
81
|
+
// design page.
|
|
82
|
+
function ServiceTile({ icon, title, text, action, page = '', url, onError, onStarted }) {
|
|
79
83
|
const [starting, setStarting] = useState(false)
|
|
80
84
|
const launch = async () => {
|
|
81
85
|
setStarting(true)
|
|
82
|
-
try { await openService(action); onStarted() } catch (error) { onError(error.message) } finally { setStarting(false) }
|
|
86
|
+
try { await openService(action, page); onStarted() } catch (error) { onError(error.message) } finally { setStarting(false) }
|
|
83
87
|
}
|
|
88
|
+
const shown = url && new URL(page, url).href
|
|
84
89
|
return <button class="home-tile" onClick={launch} disabled={starting}>
|
|
85
90
|
<span class="home-tile-icon"><HomeIcon name={icon} size={22} /></span>
|
|
86
|
-
<span class="home-tile-body"><strong>{title}</strong><span>{starting ? 'Starting…' :
|
|
91
|
+
<span class="home-tile-body"><strong>{title}</strong><span>{starting ? 'Starting…' : shown ? shown.replace(/^https?:\/\//, '') : text}</span></span>
|
|
87
92
|
<HomeIcon name="external" size={16} />
|
|
88
93
|
</button>
|
|
89
94
|
}
|
|
@@ -335,6 +340,7 @@ export function Home() {
|
|
|
335
340
|
<h2>Write and learn</h2>
|
|
336
341
|
<div class="home-tiles">
|
|
337
342
|
<ServiceTile icon="editor" title="Visual editor" text="Forms and a live preview; saves into the file and keeps a backup." action="editor" url={info.services.editor} onError={setError} onStarted={load} />
|
|
343
|
+
<ServiceTile icon="look" title="Theme editor" text="Try and fine-tune themes and palettes on a sample deck; install more." action="editor" page="design.html" url={info.services.editor} onError={setError} onStarted={load} />
|
|
338
344
|
<ServiceTile icon="guides" title="Guides" text="How to write slides, layouts, presenting, sharing and more." action="docs" url={info.services.docs} onError={setError} onStarted={load} />
|
|
339
345
|
</div>
|
|
340
346
|
</section>
|
|
@@ -62,8 +62,16 @@ export function extractContent(markdown, { headingLevels = [], paragraph = false
|
|
|
62
62
|
|
|
63
63
|
// An SVG picture that uses the theme's colour variables is drawn inline, so it
|
|
64
64
|
// follows the theme, palette and light or dark. The build lists them by the
|
|
65
|
-
// path the deck uses.
|
|
66
|
-
export const themedSvg = src => themedSvgs[src] ?? themedSvgs[String(src ?? '').split(/[?#]/)[0]]
|
|
65
|
+
// path the deck uses; an SVG data URL carries its drawing itself.
|
|
66
|
+
export const themedSvg = src => themedSvgs[src] ?? themedSvgs[String(src ?? '').split(/[?#]/)[0]] ?? themedDataSvg(src)
|
|
67
|
+
|
|
68
|
+
function themedDataSvg(src) {
|
|
69
|
+
const match = /^data:image\/svg\+xml(;base64)?,(.*)$/s.exec(String(src ?? ''))
|
|
70
|
+
if (!match) return null
|
|
71
|
+
let markup
|
|
72
|
+
try { markup = match[1] ? atob(match[2]) : decodeURIComponent(match[2]) } catch { return null }
|
|
73
|
+
return /var\(--/.test(markup) && /^\s*<svg\b/.test(markup) ? markup : null
|
|
74
|
+
}
|
|
67
75
|
|
|
68
76
|
const attribute = (tag, name) => tag.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i'))?.slice(1).find(v => v != null)
|
|
69
77
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import baseCSS from '../../assets/base.css?inline'
|
|
2
2
|
import { themes, palettes } from 'virtual:mdeck-extensions'
|
|
3
3
|
import { buildAppearance } from '../extensions/appearance.js'
|
|
4
|
+
import { movedHint } from '../extensions/moved.js'
|
|
4
5
|
|
|
5
6
|
const SELF_CONTAINED = typeof __MDECK_SELF_CONTAINED__ !== 'undefined'
|
|
6
7
|
&& __MDECK_SELF_CONTAINED__
|
|
@@ -48,8 +49,10 @@ function syncThemeFonts(urls) {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
export async function loadTheme({ theme = 'neue', palette, appearance, params = {}, meta = {} } = {}) {
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
// A deck without a theme gets neue, or the first installed theme when neue
|
|
53
|
+
// was removed.
|
|
54
|
+
const entry = themeEntry(theme) ?? (theme === 'neue' && THEME_NAMES.length ? themeEntry(THEME_NAMES[0]) : null)
|
|
55
|
+
if (!entry) throw new Error(movedHint('theme', theme) ?? `Unknown theme: "${theme}". Available: ${THEME_NAMES.join(', ') || 'none; install one with mdeck themes install'}`)
|
|
53
56
|
|
|
54
57
|
const styles = await entry.load()
|
|
55
58
|
const look = buildAppearance({
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
schema = 1
|
|
2
|
-
kind = "palette"
|
|
3
|
-
id = "brand"
|
|
4
|
-
title = "FHNW"
|
|
5
|
-
description = "The FHNW corporate colours: black, white and FHNW yellow."
|
|
6
|
-
# Only for the fhnw theme: its corporate colours.
|
|
7
|
-
theme = "fhnw"
|
|
8
|
-
|
|
9
|
-
[light]
|
|
10
|
-
"--bg" = "#ffffff"
|
|
11
|
-
"--surface" = "#f1f1ee"
|
|
12
|
-
"--ink" = "#000000"
|
|
13
|
-
"--ink-soft" = "#262626"
|
|
14
|
-
"--muted" = "#6e6e6e"
|
|
15
|
-
"--rule" = "#deded9"
|
|
16
|
-
"--accent" = "#fde70e"
|
|
17
|
-
"--accent-2" = "#000000"
|
|
18
|
-
"--on-accent" = "#000000"
|
|
19
|
-
|
|
20
|
-
[dark]
|
|
21
|
-
"--bg" = "#000000"
|
|
22
|
-
"--surface" = "#1c1c1c"
|
|
23
|
-
"--ink" = "#ffffff"
|
|
24
|
-
"--ink-soft" = "#dcdcdc"
|
|
25
|
-
"--muted" = "#9a9a9a"
|
|
26
|
-
"--rule" = "#333333"
|
|
27
|
-
"--accent" = "#fde70e"
|
|
28
|
-
"--accent-2" = "#ffffff"
|
|
29
|
-
"--on-accent" = "#000000"
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
schema = 1
|
|
2
|
-
kind = "palette"
|
|
3
|
-
id = "cobalt"
|
|
4
|
-
title = "Cobalt"
|
|
5
|
-
description = "Strong ultramarine on white, with vivid orange; bold and clear."
|
|
6
|
-
|
|
7
|
-
[light]
|
|
8
|
-
"--bg" = "#ffffff"
|
|
9
|
-
"--surface" = "#eef1fb"
|
|
10
|
-
"--ink" = "#0c1033"
|
|
11
|
-
"--ink-soft" = "#2a2f55"
|
|
12
|
-
"--muted" = "#5b6080"
|
|
13
|
-
"--rule" = "#d5dbf0"
|
|
14
|
-
"--accent" = "#1f3fd1"
|
|
15
|
-
"--accent-2" = "#e8590c"
|
|
16
|
-
"--on-accent" = "#ffffff"
|
|
17
|
-
|
|
18
|
-
[dark]
|
|
19
|
-
"--bg" = "#0a1030"
|
|
20
|
-
"--surface" = "#121a44"
|
|
21
|
-
"--ink" = "#eef1ff"
|
|
22
|
-
"--ink-soft" = "#c9cff0"
|
|
23
|
-
"--muted" = "#8e97c4"
|
|
24
|
-
"--rule" = "#212b5e"
|
|
25
|
-
"--accent" = "#6f8cff"
|
|
26
|
-
"--accent-2" = "#ff922b"
|
|
27
|
-
"--on-accent" = "#0a1030"
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
schema = 1
|
|
2
|
-
kind = "palette"
|
|
3
|
-
id = "ember"
|
|
4
|
-
title = "Ember"
|
|
5
|
-
description = "Sand and espresso with a glowing orange."
|
|
6
|
-
|
|
7
|
-
[light]
|
|
8
|
-
"--bg" = "#fbf6ef"
|
|
9
|
-
"--surface" = "#f1e7da"
|
|
10
|
-
"--ink" = "#2a1a10"
|
|
11
|
-
"--ink-soft" = "#4a3324"
|
|
12
|
-
"--muted" = "#7a6656"
|
|
13
|
-
"--rule" = "#e6d6c3"
|
|
14
|
-
"--accent" = "#c2410c"
|
|
15
|
-
"--accent-2" = "#8f5a3c"
|
|
16
|
-
"--on-accent" = "#ffffff"
|
|
17
|
-
|
|
18
|
-
[dark]
|
|
19
|
-
"--bg" = "#121005"
|
|
20
|
-
"--surface" = "#1f1e19"
|
|
21
|
-
"--ink" = "#eae0d5"
|
|
22
|
-
"--ink-soft" = "#c6ac8f"
|
|
23
|
-
"--muted" = "#9a8a77"
|
|
24
|
-
"--rule" = "#2b2a29"
|
|
25
|
-
"--accent" = "#eb5e28"
|
|
26
|
-
"--accent-2" = "#c98b6b"
|
|
27
|
-
"--on-accent" = "#0d0a05"
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
schema = 1
|
|
2
|
-
kind = "palette"
|
|
3
|
-
id = "forest"
|
|
4
|
-
title = "Forest"
|
|
5
|
-
description = "Deep green with moss and ochre; calm, with contrast."
|
|
6
|
-
|
|
7
|
-
[light]
|
|
8
|
-
"--bg" = "#f6f5ef"
|
|
9
|
-
"--surface" = "#e9eadf"
|
|
10
|
-
"--ink" = "#12261c"
|
|
11
|
-
"--ink-soft" = "#2c4436"
|
|
12
|
-
"--muted" = "#5a6c60"
|
|
13
|
-
"--rule" = "#d3d8c8"
|
|
14
|
-
"--accent" = "#2e7d32"
|
|
15
|
-
"--accent-2" = "#9a6b00"
|
|
16
|
-
"--on-accent" = "#ffffff"
|
|
17
|
-
|
|
18
|
-
[dark]
|
|
19
|
-
"--bg" = "#0e1a14"
|
|
20
|
-
"--surface" = "#16261d"
|
|
21
|
-
"--ink" = "#e9f1e6"
|
|
22
|
-
"--ink-soft" = "#c4d3c0"
|
|
23
|
-
"--muted" = "#8ea493"
|
|
24
|
-
"--rule" = "#24382c"
|
|
25
|
-
"--accent" = "#7bd389"
|
|
26
|
-
"--accent-2" = "#e6c35c"
|
|
27
|
-
"--on-accent" = "#0e1a14"
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
schema = 1
|
|
2
|
-
kind = "palette"
|
|
3
|
-
id = "graphite"
|
|
4
|
-
title = "Graphite"
|
|
5
|
-
description = "Black and white with a steel-blue accent."
|
|
6
|
-
|
|
7
|
-
[light]
|
|
8
|
-
"--bg" = "#fbfaf8"
|
|
9
|
-
"--surface" = "#efeeeb"
|
|
10
|
-
"--ink" = "#141414"
|
|
11
|
-
"--ink-soft" = "#3a3a3a"
|
|
12
|
-
"--muted" = "#6a6a6a"
|
|
13
|
-
"--rule" = "#d9d8d4"
|
|
14
|
-
"--accent" = "#3d5a73"
|
|
15
|
-
"--accent-2" = "#8a6d3b"
|
|
16
|
-
"--on-accent" = "#ffffff"
|
|
17
|
-
|
|
18
|
-
[dark]
|
|
19
|
-
"--bg" = "#141414"
|
|
20
|
-
"--surface" = "#1f1f1f"
|
|
21
|
-
"--ink" = "#f2f2f0"
|
|
22
|
-
"--ink-soft" = "#cfcfcc"
|
|
23
|
-
"--muted" = "#8e8e8b"
|
|
24
|
-
"--rule" = "#2f2f2f"
|
|
25
|
-
"--accent" = "#9fc2e0"
|
|
26
|
-
"--accent-2" = "#d9b878"
|
|
27
|
-
"--on-accent" = "#141414"
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
schema = 1
|
|
2
|
-
kind = "palette"
|
|
3
|
-
id = "pastel"
|
|
4
|
-
title = "Pastel"
|
|
5
|
-
description = "Soft pastels in the Catppuccin style: Latte by day, Mocha by night, mauve and peach."
|
|
6
|
-
|
|
7
|
-
# Catppuccin Latte; the peach and the muted grey are a shade darker so they
|
|
8
|
-
# can be read on the base.
|
|
9
|
-
[light]
|
|
10
|
-
"--bg" = "#eff1f5"
|
|
11
|
-
"--surface" = "#e6e9ef"
|
|
12
|
-
"--ink" = "#4c4f69"
|
|
13
|
-
"--ink-soft" = "#5c5f77"
|
|
14
|
-
"--muted" = "#676a80"
|
|
15
|
-
"--rule" = "#ccd0da"
|
|
16
|
-
"--accent" = "#8839ef"
|
|
17
|
-
"--accent-2" = "#dc5c0c"
|
|
18
|
-
"--on-accent" = "#eff1f5"
|
|
19
|
-
|
|
20
|
-
# Catppuccin Mocha.
|
|
21
|
-
[dark]
|
|
22
|
-
"--bg" = "#1e1e2e"
|
|
23
|
-
"--surface" = "#313244"
|
|
24
|
-
"--ink" = "#cdd6f4"
|
|
25
|
-
"--ink-soft" = "#bac2de"
|
|
26
|
-
"--muted" = "#a6adc8"
|
|
27
|
-
"--rule" = "#45475a"
|
|
28
|
-
"--accent" = "#cba6f7"
|
|
29
|
-
"--accent-2" = "#fab387"
|
|
30
|
-
"--on-accent" = "#1e1e2e"
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
schema = 1
|
|
2
|
-
kind = "palette"
|
|
3
|
-
id = "phosphor"
|
|
4
|
-
title = "Phosphor"
|
|
5
|
-
description = "A terminal screen: near-black with phosphor green and amber; in light, a printout on paper."
|
|
6
|
-
|
|
7
|
-
[light]
|
|
8
|
-
"--bg" = "#fbfbf7"
|
|
9
|
-
"--surface" = "#eeefe8"
|
|
10
|
-
"--ink" = "#141714"
|
|
11
|
-
"--ink-soft" = "#2f362f"
|
|
12
|
-
"--muted" = "#5c665c"
|
|
13
|
-
"--rule" = "#d6dad0"
|
|
14
|
-
"--accent" = "#16803a"
|
|
15
|
-
"--accent-2" = "#a05a00"
|
|
16
|
-
"--on-accent" = "#ffffff"
|
|
17
|
-
|
|
18
|
-
[dark]
|
|
19
|
-
"--bg" = "#050805"
|
|
20
|
-
"--surface" = "#0e160f"
|
|
21
|
-
"--ink" = "#b6ffc4"
|
|
22
|
-
"--ink-soft" = "#86e29a"
|
|
23
|
-
"--muted" = "#5c9e6a"
|
|
24
|
-
"--rule" = "#1a2e1d"
|
|
25
|
-
"--accent" = "#39ff6e"
|
|
26
|
-
"--accent-2" = "#ffb000"
|
|
27
|
-
"--on-accent" = "#050805"
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
schema = 1
|
|
2
|
-
kind = "palette"
|
|
3
|
-
id = "terra"
|
|
4
|
-
title = "Terra"
|
|
5
|
-
description = "Cream, deep navy and brick red; warm and grounded."
|
|
6
|
-
|
|
7
|
-
[light]
|
|
8
|
-
"--bg" = "#fdf0d5"
|
|
9
|
-
"--surface" = "#f1e3c3"
|
|
10
|
-
"--ink" = "#002b42"
|
|
11
|
-
"--ink-soft" = "#12425c"
|
|
12
|
-
"--muted" = "#5a6870"
|
|
13
|
-
"--rule" = "#e3cfa3"
|
|
14
|
-
"--accent" = "#b3121d"
|
|
15
|
-
"--accent-2" = "#c25e00"
|
|
16
|
-
"--on-accent" = "#fff7e6"
|
|
17
|
-
|
|
18
|
-
[dark]
|
|
19
|
-
"--bg" = "#001f30"
|
|
20
|
-
"--surface" = "#062c42"
|
|
21
|
-
"--ink" = "#fdf0d5"
|
|
22
|
-
"--ink-soft" = "#e8d9b8"
|
|
23
|
-
"--muted" = "#9fb0b8"
|
|
24
|
-
"--rule" = "#10405a"
|
|
25
|
-
"--accent" = "#ff6b5b"
|
|
26
|
-
"--accent-2" = "#ffb347"
|
|
27
|
-
"--on-accent" = "#001f30"
|