mdeck 2.3.0 → 3.0.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.
Files changed (108) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/README.md +9 -9
  3. package/assets/base.css +4 -1
  4. package/assets/packs/academic/pack.toml +8 -0
  5. package/assets/packs/aurora/pack.toml +8 -0
  6. package/assets/packs/lagoon/pack.toml +8 -0
  7. package/assets/packs/minimal/minimal/extension.toml +45 -0
  8. package/assets/packs/minimal/minimal/styles.css +342 -0
  9. package/assets/packs/minimal/pack.toml +8 -0
  10. package/assets/packs/minimal/paper/extension.toml +27 -0
  11. package/assets/packs/neue/pack.toml +8 -0
  12. package/docs/reference/authoring.md +13 -18
  13. package/docs/reference/claude-skill.md +1 -1
  14. package/docs/reference/extensions.md +29 -20
  15. package/docs/reference/palettes.md +8 -12
  16. package/docs/reference/themes.md +8 -7
  17. package/docs/site/content/appearance.md +33 -49
  18. package/docs/site/content/commands.md +2 -2
  19. package/docs/site/content/theme-authoring.md +1 -1
  20. package/docs/site/content/theme-repository.md +19 -15
  21. package/docs/site/examples/first-talk.md +1 -1
  22. package/docs/site/images/themes/academic-dark.webp +0 -0
  23. package/docs/site/images/themes/academic-light.webp +0 -0
  24. package/docs/site/images/themes/academic.webp +0 -0
  25. package/docs/site/images/themes/aurora-dark.webp +0 -0
  26. package/docs/site/images/themes/aurora-light.webp +0 -0
  27. package/docs/site/images/themes/aurora.webp +0 -0
  28. package/docs/site/images/themes/minimal-dark.webp +0 -0
  29. package/docs/site/images/themes/minimal-light.webp +0 -0
  30. package/docs/site/images/themes/minimal.webp +0 -0
  31. package/docs/site/images/themes/neue-dark.webp +0 -0
  32. package/docs/site/images/themes/neue-light.webp +0 -0
  33. package/docs/site/images/themes/neue.webp +0 -0
  34. package/docs/site/pages.js +1 -1
  35. package/examples/custom-layouts/slides.md +1 -1
  36. package/examples/poll/slides.md +1 -1
  37. package/examples/python/slides.md +2 -2
  38. package/examples/showcase/slides.md +3 -3
  39. package/package.json +1 -1
  40. package/skills/write-slides/SKILL.md +2 -2
  41. package/src/build/check.js +0 -7
  42. package/src/build/editorPlugin.js +19 -11
  43. package/src/build/themeCheck.js +15 -5
  44. package/src/cli/args.js +1 -1
  45. package/src/cli/index.js +3 -3
  46. package/src/cli/themes.js +93 -64
  47. package/src/core/validateDeck.js +4 -2
  48. package/src/editor/api.js +9 -1
  49. package/src/editor/components/App.jsx +4 -2
  50. package/src/editor/components/DesignApp.jsx +12 -3
  51. package/src/editor/components/ExtensionEditor.jsx +15 -4
  52. package/src/editor/components/ExtensionsPanel.jsx +19 -21
  53. package/src/editor/designLink.js +18 -1
  54. package/src/editor/sampleDeck.js +3 -1
  55. package/src/extensions/appearance.js +2 -1
  56. package/src/extensions/discover.js +56 -16
  57. package/src/extensions/installed.js +63 -0
  58. package/src/extensions/moved.js +13 -0
  59. package/src/extensions/packs.js +135 -46
  60. package/src/home/Home.jsx +12 -6
  61. package/src/layouts/layoutApi.jsx +10 -2
  62. package/src/paths.js +2 -0
  63. package/src/runtime/themeLoader.js +5 -2
  64. package/assets/extensions/palettes/brand/extension.toml +0 -29
  65. package/assets/extensions/palettes/cobalt/extension.toml +0 -27
  66. package/assets/extensions/palettes/ember/extension.toml +0 -27
  67. package/assets/extensions/palettes/forest/extension.toml +0 -27
  68. package/assets/extensions/palettes/graphite/extension.toml +0 -27
  69. package/assets/extensions/palettes/pastel/extension.toml +0 -30
  70. package/assets/extensions/palettes/phosphor/extension.toml +0 -27
  71. package/assets/extensions/palettes/terra/extension.toml +0 -27
  72. package/assets/extensions/themes/duet/extension.toml +0 -46
  73. package/assets/extensions/themes/duet/styles.css +0 -573
  74. package/assets/extensions/themes/editorial/extension.toml +0 -46
  75. package/assets/extensions/themes/editorial/styles.css +0 -574
  76. package/assets/extensions/themes/fhnw/extension.toml +0 -61
  77. package/assets/extensions/themes/fhnw/styles.css +0 -604
  78. package/assets/extensions/themes/sketch/extension.toml +0 -48
  79. package/assets/extensions/themes/sketch/styles.css +0 -633
  80. package/assets/extensions/themes/terminal/extension.toml +0 -38
  81. package/assets/extensions/themes/terminal/styles.css +0 -550
  82. package/docs/site/images/themes/duet-dark.webp +0 -0
  83. package/docs/site/images/themes/duet-light.webp +0 -0
  84. package/docs/site/images/themes/duet.webp +0 -0
  85. package/docs/site/images/themes/editorial-dark.webp +0 -0
  86. package/docs/site/images/themes/editorial-light.webp +0 -0
  87. package/docs/site/images/themes/editorial.webp +0 -0
  88. package/docs/site/images/themes/fhnw.webp +0 -0
  89. package/docs/site/images/themes/sketch-dark.webp +0 -0
  90. package/docs/site/images/themes/sketch-light.webp +0 -0
  91. package/docs/site/images/themes/sketch.webp +0 -0
  92. package/docs/site/images/themes/terminal-dark.webp +0 -0
  93. package/docs/site/images/themes/terminal-light.webp +0 -0
  94. package/docs/site/images/themes/terminal.webp +0 -0
  95. package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T08-36-47Z.json +0 -19
  96. package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T11-27-30Z.json +0 -6
  97. package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T08-08-30Z.json +0 -10
  98. package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T11-41-35Z.json +0 -13
  99. /package/assets/{extensions/themes → packs/academic}/academic/extension.toml +0 -0
  100. /package/assets/{extensions/themes → packs/academic}/academic/styles.css +0 -0
  101. /package/assets/{extensions/palettes → packs/academic}/nordic/extension.toml +0 -0
  102. /package/assets/{extensions/themes → packs/aurora}/aurora/extension.toml +0 -0
  103. /package/assets/{extensions/themes → packs/aurora}/aurora/styles.css +0 -0
  104. /package/assets/{extensions/palettes → packs/aurora}/neon/extension.toml +0 -0
  105. /package/assets/{extensions/palettes → packs/lagoon}/lagoon/extension.toml +0 -0
  106. /package/assets/{extensions/themes → packs/neue}/neue/extension.toml +0 -0
  107. /package/assets/{extensions/themes → packs/neue}/neue/styles.css +0 -0
  108. /package/assets/{extensions/palettes → packs/neue}/swiss/extension.toml +0 -0
@@ -11,6 +11,7 @@ import { resolve } from 'node:path'
11
11
  import { launchChrome } from './chrome.js'
12
12
  import { frameworkRoot } from '../paths.js'
13
13
  import { sampleDeck, SAMPLE_PICTURE } from '../editor/sampleDeck.js'
14
+ import { loadRegistry } from '../extensions/discover.js'
14
15
 
15
16
  // Runs in the page before the deck: keeps what the page reports.
16
17
  const RECORDER = `window.__report = [];
@@ -80,18 +81,27 @@ const MEASURE = `(() => {
80
81
  return problems
81
82
  })()`
82
83
 
83
- // Builds the sample deck with `extensions` ({ id: folder }) beside it and
84
- // opens it in Chrome for `use({ open, ids })`. Installed user extensions are
85
- // left out, so the result depends only on what is given.
86
- export async function withSampleDeck({ extensions = {} } = {}, use) {
84
+ // Builds the sample deck with `extensions` ({ id: folder }) beside it into
85
+ // `outDir`, as a folder to host. Installed user extensions are left out, so
86
+ // the result depends only on what is given. Returns the registry it used.
87
+ export function buildSampleDeck({ extensions = {}, outDir }) {
87
88
  const temp = mkdtempSync(resolve(tmpdir(), 'mdeck-sample-'))
88
89
  try {
89
90
  writeFileSync(resolve(temp, 'slides.md'), sampleDeck())
90
91
  writeFileSync(resolve(temp, 'picture.svg'), SAMPLE_PICTURE)
91
92
  for (const [id, dir] of Object.entries(extensions)) cpSync(dir, resolve(temp, 'extensions', id), { recursive: true })
92
93
  mkdirSync(resolve(temp, 'home'))
93
- execFileSync(process.execPath, [resolve(frameworkRoot, 'bin/mdeck.js'), 'build', resolve(temp, 'slides.md'), '-o', resolve(temp, 'out/index.html')],
94
+ execFileSync(process.execPath, [resolve(frameworkRoot, 'bin/mdeck.js'), 'build', resolve(temp, 'slides.md'), '-o', resolve(outDir, 'index.html')],
94
95
  { cwd: temp, stdio: 'pipe', env: { ...process.env, MDECK_HOME: resolve(temp, 'home') } })
96
+ return loadRegistry(resolve(temp, 'slides.md'), { userRoot: null })
97
+ } finally { rmSync(temp, { recursive: true, force: true }) }
98
+ }
99
+
100
+ // The sample deck built with `extensions`, opened in Chrome for `use({ open })`.
101
+ export async function withSampleDeck({ extensions = {} } = {}, use) {
102
+ const temp = mkdtempSync(resolve(tmpdir(), 'mdeck-sample-'))
103
+ try {
104
+ buildSampleDeck({ extensions, outDir: resolve(temp, 'out') })
95
105
  const browser = await launchChrome({ dir: resolve(temp, 'out') })
96
106
  try {
97
107
  // A page with the deck in a look, recording what it reports.
package/src/cli/args.js CHANGED
@@ -7,7 +7,7 @@ const commands = {
7
7
  run: { ...common, '--network': flag, '--server': optional, max: 1 },
8
8
  edit: { ...common, max: 1 },
9
9
  design: { ...common, max: 1 },
10
- themes: { '--global': flag, '--force': flag, '--check': flag, '--output': value, '-o': '--output', max: 4 },
10
+ themes: { '--local': flag, '--global': flag, '--force': flag, '--check': flag, '--output': value, '-o': '--output', max: 4 },
11
11
  build: { ...output, '--single-file': flag, '--launchers': flag, '--reader': flag, '--notes': flag, max: 1 },
12
12
  send: { ...output, '--notes': flag, '--no-pdf': flag, max: 1 },
13
13
  pdf: { ...output, max: 1 },
package/src/cli/index.js CHANGED
@@ -313,9 +313,9 @@ const HELP = `
313
313
  ${c.green}mdeck edit${c.reset} [slides.md] Edit slides in the browser; saves to the file
314
314
  ${c.green}mdeck design${c.reset} [folder | slides.md] Look at and fine-tune themes and palettes on a sample deck;
315
315
  saves to the folder's extensions/ (default: this folder)
316
- ${c.green}mdeck themes${c.reset} search [words] Themes and palettes from the online repository
317
- install <pack> [slides.md | folder] into the deck's extensions/ (--global: for every deck)
318
- remove <pack>, update [pack], list --force replaces files changed by hand
316
+ ${c.green}mdeck themes${c.reset} search [words] Themes and palettes: those that come with mdeck and the online repository
317
+ install <pack>, remove <pack> for every deck; add slides.md or --local for one deck only
318
+ update [pack], list --force replaces files changed by hand
319
319
 
320
320
  ${c.dim}Make something to hand out${c.reset}
321
321
  ${c.green}mdeck build${c.reset} [slides.md] [-o dir/index.html] A folder to host
package/src/cli/themes.js CHANGED
@@ -1,103 +1,129 @@
1
- // `mdeck themes`: themes and palettes from the theme repository. Installs go
2
- // into a deck's extensions/ folder, or with --global into the user's
3
- // (~/.mdeck/extensions), where every deck finds them. `build` is for the
4
- // repository itself: it checks the packs and writes what is served.
1
+ // `mdeck themes`: every theme and palette comes in a pack. Some packs come
2
+ // with mdeck (the starter set), the others from the theme repository.
3
+ // Packs are installed for every deck (~/.mdeck/extensions) unless a deck or
4
+ // folder is named, or --local is given: then into the extensions folder
5
+ // beside it, so the slide folder carries them. A pack that comes with mdeck
6
+ // is removed by hiding it, and installed again from mdeck's own copy, so
7
+ // that works offline. `build` is for the repository itself.
5
8
  import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
6
9
  import { createRequire } from 'node:module'
7
10
  import { dirname, resolve } from 'node:path'
8
- import { builtInRegistry, loadRegistry, userExtensionsDir } from '../extensions/discover.js'
11
+ import { bundledPacks, loadRegistry, manifestsOf, removedPacks, userExtensionsDir } from '../extensions/discover.js'
12
+ import { target, packsAt, installHere, removeHere } from '../extensions/installed.js'
13
+ import { palettesFor } from '../extensions/tokens.js'
9
14
  import { ManifestError } from '../extensions/manifest.js'
10
- import { buildRepository, compareVersions, fetchIndex, findPack, installedPacks, installFromIndex, PackError, removePack } from '../extensions/packs.js'
15
+ import { buildRepository, compareVersions, findPack, installedPacks, installWithRequirements, loadCatalogue, PackError } from '../extensions/packs.js'
11
16
 
12
17
  const VERSION = createRequire(import.meta.url)('../../package.json').version
13
18
 
14
- const USAGE = 'search [words] | install <pack> | remove <pack> | update [pack] | list, each with [slides.md or folder] and --global; build <packs> -o <out> [--check]'
19
+ const USAGE = 'search [words] | install <pack> | remove <pack> | update [pack] | list — for every deck, or beside a deck with [slides.md or folder] or --local; build <packs> -o <out> [--check]'
15
20
 
16
- // The built-in themes and palettes a pack may name and must not reuse the ids of.
17
- const builtIns = () => {
18
- const registry = builtInRegistry()
19
- return { themes: registry.themes, palettes: registry.palettes }
20
- }
21
-
22
- // The extensions folder a command works on, and a slides path to load the
23
- // registry with: the user's with --global, else beside the deck or in the folder.
24
- function target(where, global) {
25
- if (global) return { root: userExtensionsDir(), deck: resolve('slides.md'), label: 'for every deck' }
26
- const given = resolve(where ?? '.')
27
- const folder = existsSync(given) && statSync(given).isDirectory() ? given : dirname(given)
28
- return { root: resolve(folder, 'extensions'), deck: existsSync(given) && !statSync(given).isDirectory() ? given : resolve(folder, 'slides.md'), label: `in ${resolve(folder, 'extensions')}` }
21
+ // Every theme with the palettes it offers, and every palette's colours, each
22
+ // marked with its pack and whether it comes with mdeck.
23
+ function looksOf(registry, index) {
24
+ const packOf = Object.fromEntries(index.packs.flatMap(p => [...p.themes, ...p.palettes].map(id => [id, p])))
25
+ const palettes = manifestsOf(registry, 'palette')
26
+ const colours = tokens => Object.fromEntries(['--bg', '--surface', '--ink', '--accent', '--accent-2'].map(key => [key, tokens?.[key] ?? null]))
27
+ const origin = id => ({ pack: packOf[id]?.id ?? null, bundled: Boolean(packOf[id]?.bundled) })
28
+ return {
29
+ mdeck: VERSION,
30
+ themes: Object.values(manifestsOf(registry, 'theme')).map(theme => ({ id: theme.id, title: theme.title, description: theme.description ?? '', ...origin(theme.id),
31
+ palette: theme.palette, appearance: theme.appearance ?? 'light', palettes: palettesFor(theme, palettes).map(p => p.id) })),
32
+ palettes: Object.values(palettes).map(palette => ({ id: palette.id, title: palette.title, description: palette.description ?? '', ...origin(palette.id),
33
+ light: colours(palette.light), dark: colours(palette.dark) })),
34
+ }
29
35
  }
30
36
 
31
- const install = (index, entry, { root, force }) => installFromIndex(index, entry, { root, known: builtIns(), mdeckVersion: VERSION, force })
32
-
33
37
  export async function runThemes({ positionals, flag, output, ui: { ok, err, tip, c } }) {
34
38
  const [sub = 'search', ...rest] = positionals
35
- const global = flag('--global'), force = flag('--force')
39
+ const local = flag('--local'), force = flag('--force')
40
+ const warnOffline = catalogue => { if (catalogue.offline) tip(`The theme repository could not be reached (${catalogue.offline}); showing the packs that come with mdeck.`) }
41
+ const what = step => [step.themes?.length ? `themes ${step.themes.join(', ')}` : '', step.palettes?.length ? `palettes ${step.palettes.join(', ')}` : ''].filter(Boolean).join('; ')
36
42
  try {
37
43
  if (sub === 'search') {
38
- const index = await fetchIndex()
44
+ const catalogue = await loadCatalogue({ bundled: bundledPacks({ all: true }) })
45
+ warnOffline(catalogue)
39
46
  const words = rest.map(word => word.toLowerCase())
40
- const local = installedPacks(target(null, false).root), mine = installedPacks(userExtensionsDir())
41
- const found = index.packs.filter(pack => words.every(word => [pack.id, pack.title, pack.description, ...pack.themes, ...pack.palettes].join(' ').toLowerCase().includes(word)))
42
- if (!found.length) return tip(`No pack matches${words.length ? ` "${words.join(' ')}"` : ''} in ${index.url}`)
47
+ const mine = packsAt(target(null, false)), here = installedPacks(target('.', true).root), hidden = removedPacks()
48
+ const found = catalogue.packs.filter(pack => words.every(word => [pack.id, pack.title, pack.description, ...pack.themes, ...pack.palettes].join(' ').toLowerCase().includes(word)))
49
+ if (!found.length) return tip(`No pack matches${words.length ? ` "${words.join(' ')}"` : ''}`)
43
50
  for (const pack of found) {
44
- const have = local[pack.id] ?? mine[pack.id]
45
- const state = !have ? '' : compareVersions(pack.version, have.version) > 0 ? ` ${c.yellow}(${have.version} installed, update available)${c.reset}` : ` ${c.green}(installed)${c.reset}`
51
+ const have = here[pack.id] ?? mine[pack.id]
52
+ const state = hidden.includes(pack.id) && !have ? ` ${c.dim}(comes with mdeck, removed)${c.reset}`
53
+ : !have ? (pack.bundled || pack.bundledVersion ? ` ${c.dim}(comes with mdeck)${c.reset}` : '')
54
+ : have.bundled ? (pack.bundled ? ` ${c.green}(comes with mdeck)${c.reset}` : ` ${c.yellow}(comes with mdeck as ${pack.bundledVersion}, update available)${c.reset}`)
55
+ : compareVersions(pack.version, have.version) > 0 ? ` ${c.yellow}(${have.version} installed, update available)${c.reset}` : ` ${c.green}(installed)${c.reset}`
46
56
  console.log(`\n ${c.cyan}${pack.id}${c.reset} ${pack.version} — ${pack.title}${state}`)
47
57
  if (pack.description) console.log(` ${pack.description}`)
48
- console.log(` ${c.dim}${[pack.themes.length ? `themes: ${pack.themes.join(', ')}` : '', pack.palettes.length ? `palettes: ${pack.palettes.join(', ')}` : ''].filter(Boolean).join(' · ')} · by ${pack.author}, ${pack.license}${c.reset}`)
58
+ console.log(` ${c.dim}${[pack.themes.length ? `themes: ${pack.themes.join(', ')}` : '', pack.palettes.length ? `palettes: ${pack.palettes.join(', ')}` : '', pack.requires?.length ? `requires: ${pack.requires.join(', ')}` : ''].filter(Boolean).join(' · ')} · by ${pack.author}, ${pack.license}${c.reset}`)
49
59
  }
50
60
  console.log()
51
- return tip('Install one with: mdeck themes install <pack> [slides.md] (--global for every deck)')
61
+ return tip('Install one for every deck with: mdeck themes install <pack> (beside a deck: add slides.md or --local)')
52
62
  }
53
63
 
54
64
  if (sub === 'install') {
55
65
  const [id, where] = rest
56
- if (!id) { err('Name a pack.'); return tip('Usage: mdeck themes install <pack> [slides.md or folder] [--global]; mdeck themes search lists them.') }
57
- const { root, deck, label } = target(where, global)
58
- const index = await fetchIndex()
59
- const result = await install(index, findPack(index, id), { root, force })
60
- loadRegistry(deck)
61
- ok(`${result.replaced ? `Updated ${id} from ${result.replaced}` : `Installed ${id}`} ${label}`)
62
- if (result.themes.length) tip(`Themes: ${result.themes.join(', ')}. Use one with "theme: ${result.themes[0]}" in the deck settings.`)
63
- if (result.palettes.length) tip(`Palettes: ${result.palettes.join(', ')}. Use one with "palette: ${result.palettes[0]}".`)
66
+ if (!id) { err('Name a pack.'); return tip('Usage: mdeck themes install <pack> [slides.md or folder] [--local]; mdeck themes search lists them.') }
67
+ const at = target(where, local)
68
+ const catalogue = await loadCatalogue({ bundled: bundledPacks({ all: true }) })
69
+ warnOffline(catalogue)
70
+ findPack(catalogue, id)
71
+ for (const step of await installHere(catalogue, id, at, { mdeckVersion: VERSION, force })) {
72
+ if (step.kept) tip(`${step.id} ${step.version} is there already`)
73
+ else if (step.bundled) (step.wasRemoved ? ok(`${step.id} comes with mdeck and is offered again ${at.label}`) : tip(`${step.id} comes with mdeck and is installed ${at.label} already`))
74
+ else ok(`${step.replaced ? `Updated ${step.id} from ${step.replaced} to ${step.version}` : `Installed ${step.id} ${step.version}`} ${at.label}${what(step) ? `: ${what(step)}` : ''}`)
75
+ }
76
+ loadRegistry(at.deck)
77
+ const pack = findPack(catalogue, id)
78
+ if (pack.themes.length) tip(`Use it with "theme: ${pack.themes[0]}" at the top of a slide file.`)
79
+ else if (pack.palettes.length) tip(`Use it with "palette: ${pack.palettes[0]}" at the top of a slide file.`)
64
80
  return tip('Look at it on a sample deck: mdeck design')
65
81
  }
66
82
 
67
83
  if (sub === 'remove') {
68
84
  const [id, where] = rest
69
- if (!id) { err('Name a pack.'); return tip('Usage: mdeck themes remove <pack> [slides.md or folder] [--global]') }
70
- const { root, label } = target(where, global)
71
- const pack = removePack(id, { root, force })
72
- return ok(`Removed ${id} ${label} (${pack.folders.join(', ')})`)
85
+ if (!id) { err('Name a pack.'); return tip('Usage: mdeck themes remove <pack> [slides.md or folder] [--local]') }
86
+ const at = target(where, local)
87
+ const catalogue = await loadCatalogue({ bundled: bundledPacks({ all: true }), online: false })
88
+ const removed = removeHere(catalogue, id, at, { force })
89
+ if (removed.folders.length) ok(`Removed ${id} ${at.label} (${removed.folders.join(', ')})`)
90
+ if (removed.hidden) ok(`${id} comes with mdeck and is no longer offered; mdeck themes install ${id} brings it back`)
91
+ return
73
92
  }
74
93
 
75
94
  if (sub === 'update') {
76
95
  const [first, second] = rest
77
96
  const named = first && !/\.md$/i.test(first) && !(existsSync(first) && statSync(first).isDirectory()) ? first : null
78
- const { root, deck, label } = target(named ? second : first, global)
79
- const installed = installedPacks(root)
97
+ const at = target(named ? second : first, local)
98
+ const catalogue = await loadCatalogue({ bundled: bundledPacks({ all: true }) })
99
+ warnOffline(catalogue)
100
+ const installed = packsAt(at)
80
101
  const ids = named ? [named] : Object.keys(installed)
81
- if (!ids.length) return tip(`No packs installed ${label}`)
82
- const index = await fetchIndex()
102
+ if (!ids.length) return tip(`No packs installed ${at.label}`)
83
103
  for (const id of ids) {
84
- if (!installed[id]) throw new PackError(`The pack ${id} is not installed ${label}`)
85
- const entry = findPack(index, id)
86
- if (compareVersions(entry.version, installed[id].version) <= 0) { tip(`${id} ${installed[id].version} is up to date`); continue }
87
- await install(index, entry, { root, force })
88
- ok(`Updated ${id} ${installed[id].version} → ${entry.version}`)
104
+ const have = installed[id]
105
+ if (!have) throw new PackError(`The pack ${id} is not installed ${at.label}`)
106
+ const entry = findPack(catalogue, id)
107
+ // A bundled pack is up to date until the repository has a newer one.
108
+ if (have.bundled ? entry.bundled : compareVersions(entry.version, have.version) <= 0) { tip(`${id} ${have.version ?? entry.version} is up to date`); continue }
109
+ const steps = await installWithRequirements(catalogue, id, { root: at.root, mdeckVersion: VERSION, force, has: pack => pack !== id && Object.hasOwn(packsAt(at), pack) })
110
+ ok(`Updated ${id} ${have.version ?? entry.bundledVersion} → ${steps.at(-1).version}`)
89
111
  }
90
- loadRegistry(deck)
112
+ loadRegistry(at.deck)
91
113
  return
92
114
  }
93
115
 
94
116
  if (sub === 'list') {
95
117
  const [where] = rest
96
- for (const [label, root] of [['This folder', target(where, false).root], ['Every deck (--global)', userExtensionsDir()]]) {
118
+ const hidden = removedPacks()
119
+ console.log(`\n ${c.bold}Come with mdeck${c.reset}`)
120
+ for (const id of Object.keys(bundledPacks({ all: true }))) console.log(` ${c.cyan}${id}${c.reset}${hidden.includes(id) ? ` ${c.dim}(removed; mdeck themes install ${id} brings it back)${c.reset}` : ''}`)
121
+ const sections = [['For every deck', userExtensionsDir()], ...(where || local ? [['Beside the deck', target(where, true).root]] : [])]
122
+ for (const [label, root] of sections) {
97
123
  const packs = installedPacks(root)
98
124
  console.log(`\n ${c.bold}${label}${c.reset} ${c.dim}${root}${c.reset}`)
99
125
  if (!Object.keys(packs).length) console.log(` ${c.dim}none${c.reset}`)
100
- for (const [id, pack] of Object.entries(packs)) console.log(` ${c.cyan}${id}${c.reset} ${pack.version} — ${pack.folders.join(', ')}`)
126
+ for (const [id, pack] of Object.entries(packs)) console.log(` ${c.cyan}${id}${c.reset} ${pack.version} — ${pack.folders.join(', ')}${pack.requires.length ? ` ${c.dim}(requires ${pack.requires.join(', ')})${c.reset}` : ''}`)
101
127
  }
102
128
  console.log()
103
129
  return
@@ -107,14 +133,14 @@ export async function runThemes({ positionals, flag, output, ui: { ok, err, tip,
107
133
  const [packsDir] = rest
108
134
  const out = output()
109
135
  if (!packsDir || !out) { err('Name the packs folder and the output folder.'); return tip('Usage: mdeck themes build <packs folder> -o <output folder> [--check]') }
110
- const index = buildRepository(resolve(packsDir), resolve(out), { known: builtIns() })
111
- // Every theme and palette of every pack, from the pack folders.
112
- const extensions = Object.fromEntries(index.packs.flatMap(pack => [...pack.themes, ...pack.palettes].map(id => [id, resolve(packsDir, pack.id, id)])))
113
- const paletteTheme = id => {
114
- const text = readFileSync(resolve(extensions[id], 'extension.toml'), 'utf8')
115
- return /^theme\s*=\s*"([^"]+)"/m.exec(text)?.[1] ?? 'neue'
116
- }
117
- const { checkLooks, renderPreviews } = await import('../build/themeCheck.js')
136
+ const bundled = bundledPacks({ all: true })
137
+ const index = buildRepository(resolve(packsDir), resolve(out), { bundled })
138
+ // The repository's own themes and palettes go beside the sample deck;
139
+ // the bundled ones are in it already.
140
+ const extensions = Object.fromEntries(index.packs.filter(pack => !pack.bundled).flatMap(pack => [...pack.themes, ...pack.palettes].map(id => [id, resolve(packsDir, pack.id, id)])))
141
+ const dirOf = id => extensions[id] ?? resolve(bundled[index.packs.find(pack => [...pack.themes, ...pack.palettes].includes(id)).id], id)
142
+ const paletteTheme = id => /^theme\s*=\s*"([^"]+)"/m.exec(readFileSync(resolve(dirOf(id), 'extension.toml'), 'utf8'))?.[1] ?? 'neue'
143
+ const { checkLooks, renderPreviews, buildSampleDeck } = await import('../build/themeCheck.js')
118
144
  const looks = index.packs.flatMap(pack => [...pack.themes.map(theme => ({ id: theme, theme })), ...pack.palettes.map(palette => ({ id: palette, theme: paletteTheme(palette), palette }))])
119
145
  if (flag('--check')) {
120
146
  const failures = await checkLooks(looks, { extensions })
@@ -125,6 +151,10 @@ export async function runThemes({ positionals, flag, output, ui: { ok, err, tip,
125
151
  await renderPreviews(looks.map(look => ({ file: resolve(out, 'previews', `${look.id}.webp`), theme: look.theme, palette: look.palette ?? '', slide: look.palette ? 3 : 1 })), { extensions })
126
152
  for (const pack of index.packs) pack.previews = Object.fromEntries([...pack.themes, ...pack.palettes].map(id => [id, `previews/${id}.webp`]))
127
153
  writeFileSync(resolve(out, 'index.json'), JSON.stringify(index, null, 2) + '\n')
154
+ // For the repository's theme browser: the sample deck with every theme
155
+ // and palette, and what each theme offers.
156
+ const registry = buildSampleDeck({ extensions, outDir: resolve(out, 'preview') })
157
+ writeFileSync(resolve(out, 'looks.json'), JSON.stringify(looksOf(registry, index), null, 2) + '\n')
128
158
  return ok(`Built ${index.packs.length} pack(s) into ${resolve(out)}`)
129
159
  }
130
160
 
@@ -137,4 +167,3 @@ export async function runThemes({ positionals, flag, output, ui: { ok, err, tip,
137
167
  process.exitCode = 1
138
168
  }
139
169
  }
140
-
@@ -3,6 +3,7 @@ import { isPlainObject, RENAMED_DECK_KEYS } from './parseSlides.js'
3
3
  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
+ import { movedHint } from '../extensions/moved.js'
6
7
 
7
8
  // `layouts`, `themes` and `palettes` are manifest maps from the extension
8
9
  // registry (see src/extensions/discover.js). Checks for a kind are skipped when
@@ -49,11 +50,12 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
49
50
  if (config.show[key] != null && !values.includes(config.show[key])) add('invalid-config', `show.${key} must be one of: ${values.join(', ')}`, deck.configSource?.start)
50
51
  }
51
52
  }
52
- if (themes && config.theme != null && !Object.hasOwn(themes, config.theme)) add('unknown-theme', `Unknown theme "${config.theme}"; available: ${Object.keys(themes).join(', ')}`, deck.configSource?.start)
53
+ if (themes && config.theme != null && !Object.hasOwn(themes, config.theme)) add('unknown-theme', movedHint('theme', config.theme) ?? `Unknown theme "${config.theme}"; available: ${Object.keys(themes).join(', ') || 'none'}. mdeck themes search lists more to install`, deck.configSource?.start)
54
+ if (themes && config.theme == null && !Object.hasOwn(themes, 'neue')) add('unknown-theme', `This deck sets no theme, so it uses neue, which is not installed: run mdeck themes install neue, or set a theme${Object.keys(themes).length ? ` (${Object.keys(themes).join(', ')})` : ''}`, deck.configSource?.start ?? 0)
53
55
  if (palettes && themes && config.palette != null) {
54
56
  const theme = themes[config.theme ?? 'neue']
55
57
  const offered = theme ? palettesFor(theme, palettes).map(palette => palette.id) : Object.keys(palettes)
56
- if (!Object.hasOwn(palettes, config.palette)) add('unknown-palette', `Unknown palette "${config.palette}"; available: ${offered.join(', ')}`, deck.configSource?.start)
58
+ if (!Object.hasOwn(palettes, config.palette)) add('unknown-palette', movedHint('palette', config.palette) ?? `Unknown palette "${config.palette}"; available: ${offered.join(', ')}`, deck.configSource?.start)
57
59
  else if (theme && !offered.includes(config.palette)) add('unknown-palette', `The theme "${theme.id}" does not offer the palette "${config.palette}"; available: ${offered.join(', ')}`, deck.configSource?.start)
58
60
  }
59
61
  if (themes && config.params != null && isPlainObject(config.params) && Object.hasOwn(themes, config.theme ?? 'neue')) {
package/src/editor/api.js CHANGED
@@ -59,9 +59,17 @@ export async function loadThemeIndex() {
59
59
  return response.json()
60
60
  }
61
61
 
62
- // Installs a pack into the deck's extensions folder. Returns the new registry.
62
+ // Installs a pack for every deck, with the packs it requires. Returns the
63
+ // new registry.
63
64
  export async function installThemePack(id) {
64
65
  const response = await fetch('/__mdeck/themes/install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) })
65
66
  if (!response.ok) throw await failure(response)
66
67
  return response.json()
67
68
  }
69
+
70
+ // Removes a pack for every deck (one that comes with mdeck is hidden).
71
+ export async function removeThemePack(id) {
72
+ const response = await fetch('/__mdeck/themes/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) })
73
+ if (!response.ok) throw await failure(response)
74
+ return response.json()
75
+ }
@@ -10,7 +10,7 @@ import { DeckSettings } from './DeckSettings.jsx'
10
10
  import { LayoutPicker } from './LayoutPicker.jsx'
11
11
  import { ConflictBanner } from './ConflictBanner.jsx'
12
12
  import { Resizer, readInspectorWidth, storeInspectorWidth } from './Resizer.jsx'
13
- import { DESIGN_URL, DESIGN_TAB } from '../designLink.js'
13
+ import { designUrl, DESIGN_TAB, launchPageUrl } from '../designLink.js'
14
14
  import markUrl from '../../../assets/logo/mark.svg'
15
15
 
16
16
  const STATUS = { saved: 'Saved', unsaved: 'Unsaved changes', saving: 'Saving…', conflict: 'Conflict', error: 'Could not save' }
@@ -20,6 +20,7 @@ export function App() {
20
20
  const [picker, setPicker] = useState(false)
21
21
  const [inspectorWidth, setInspectorWidth] = useState(readInspectorWidth)
22
22
  const resize = width => { setInspectorWidth(width); storeInspectorWidth(width) }
23
+ const [home] = useState(launchPageUrl)
23
24
 
24
25
  const select = index => dispatch({ type: 'select', index })
25
26
  const actions = {
@@ -50,7 +51,8 @@ export function App() {
50
51
  <span class="spacer" />
51
52
  <button class="btn is-small" onClick={actions.undo} disabled={!state.history.past.length} title="Undo (⌘Z)">Undo</button>
52
53
  <button class="btn is-small" onClick={actions.redo} disabled={!state.history.future.length} title="Redo (⇧⌘Z)">Redo</button>
53
- <a class="btn is-small" href={DESIGN_URL} target={DESIGN_TAB} title="Make and change themes, palettes and layouts, on a sample deck">Design themes</a>
54
+ {home && <a class="btn is-small" href={home}>Launch page</a>}
55
+ <a class="btn is-small" href={designUrl()} target={DESIGN_TAB} title="Make and change themes, palettes and layouts, on a sample deck">Design themes</a>
54
56
  <a class="btn is-small" href="/?view=presenter" target="_blank" rel="noopener">Present</a>
55
57
  <a class="btn is-small" href="/" target="_blank" rel="noopener">Open deck</a>
56
58
  </header>
@@ -1,10 +1,10 @@
1
1
  import { h } from 'preact'
2
2
  import { useEffect, useRef, useState } from 'preact/hooks'
3
3
  import { useDeckDocument } from '../useDeckDocument.js'
4
- import { loadExtension, saveExtension, deleteExtension, installThemePack } from '../api.js'
4
+ import { loadExtension, saveExtension, deleteExtension, installThemePack, removeThemePack } from '../api.js'
5
5
  import { starterFiles, parseManifest, toModel, toRuntimeManifest, MANIFEST } from '../extensions.js'
6
6
  import { sampleDeck, sampleDataUrl } from '../sampleDeck.js'
7
- import { parseTarget } from '../designLink.js'
7
+ import { parseTarget, launchPageUrl } from '../designLink.js'
8
8
  import { palettesFor } from '../../extensions/tokens.js'
9
9
  import { parseSlides } from '../../core/parseSlides.js'
10
10
  import { setDeckConfig } from '../../core/editDeck.js'
@@ -36,6 +36,7 @@ export function DesignApp() {
36
36
  const [slide, setSlide] = useState(0)
37
37
  const [inspectorWidth, setInspectorWidth] = useState(readInspectorWidth)
38
38
  const resize = width => { setInspectorWidth(width); storeInspectorWidth(width) }
39
+ const [home] = useState(launchPageUrl)
39
40
  const timer = useRef(null)
40
41
  const currentRef = useRef(current)
41
42
  currentRef.current = current
@@ -126,6 +127,13 @@ export function DesignApp() {
126
127
  if (id) await select(kind, id)
127
128
  }
128
129
 
130
+ // Removes a pack for every deck; the deck's look is not changed.
131
+ const removeOnline = async pack => {
132
+ const result = await removeThemePack(pack)
133
+ dispatch({ type: 'setRegistry', registry: result.registry })
134
+ setCurrent(null)
135
+ }
136
+
129
137
  const remove = async () => {
130
138
  const target = current
131
139
  try {
@@ -232,6 +240,7 @@ export function DesignApp() {
232
240
  {state.designOnly
233
241
  ? <span class="muted" title={`${state.path}/extensions`}>Saved in {state.name}/extensions</span>
234
242
  : <a class="btn is-small" href="/editor.html">Back to the slides</a>}
243
+ {home && <a class="btn is-small" href={home}>Launch page</a>}
235
244
  </header>
236
245
  {state.status === 'conflict' && <ConflictBanner onReload={() => resolve('reload')} onOverwrite={() => resolve('overwrite')} />}
237
246
  <div class="editor-main" style={{ gridTemplateColumns: `290px minmax(0, 1fr) ${inspectorWidth}px` }}>
@@ -244,7 +253,7 @@ export function DesignApp() {
244
253
  ? <div class="form"><p class="empty">Choose one of {state.designOnly ? "this folder's" : "this deck's"} themes and palettes on the left to change it, or start from a built-in one. What you change is saved as you type, in the <code>extensions</code> folder {state.designOnly ? 'of this folder, where decks in it find them' : "beside the deck. The deck's look is chosen in the editor's Deck settings"}.{error ? ` ${error}` : ''}</p></div>
245
254
  : current.source === 'local'
246
255
  ? <>{usage}<ExtensionEditor extension={current} palettes={manifests.palettes} status={status} error={error} note={note} onFiles={onFiles} onDelete={remove} onCopy={() => copyDialog(current.kind, current.id)} /></>
247
- : <BuiltInCard extension={current} manifest={manifests[`${current.kind}s`][current.id]} onCopy={() => copyDialog(current.kind, current.id)} />}
256
+ : <BuiltInCard extension={current} manifest={manifests[`${current.kind}s`][current.id]} onCopy={() => copyDialog(current.kind, current.id)} onRemovePack={removeOnline} />}
248
257
  </aside>
249
258
  </div>
250
259
  {dialog && <NewExtensionDialog kind={dialog.kind} initial={dialog.initial ?? null} registry={state.registry} existing={existing} onCreate={create} onClose={() => setDialog(null)} />}
@@ -47,17 +47,28 @@ export function ExtensionEditor({ extension, palettes = {}, status, error, note,
47
47
 
48
48
  // A built-in or installed extension: what it is, and the one way to change
49
49
  // it, a copy in the deck's extensions folder.
50
- export function BuiltInCard({ extension, manifest, onCopy }) {
50
+ export function BuiltInCard({ extension, manifest, onCopy, onRemovePack }) {
51
51
  const { kind, id, files } = extension
52
52
  const user = extension.source === 'user'
53
+ const pack = manifest?.pack ?? null
54
+ const [removing, setRemoving] = useState(null)
53
55
  return <div class="form">
54
- <p class="section-title">{user ? `Installed ${kind}, for every deck` : `Built-in ${kind}`}</p>
56
+ <p class="section-title">{user ? `Installed for every deck` : `Comes with mdeck`}{pack ? ` · pack ${pack}` : ''}</p>
55
57
  <h2 class="builtin-title">{manifest?.title ?? id} <span class="muted">{id}</span></h2>
56
58
  {manifest?.description && <p class="builtin-description">{manifest.description}</p>}
57
59
  {kind === 'palette' && manifest && ['light', 'dark'].map(mode => <Field key={mode} label={mode === 'light' ? 'Light' : 'Dark'}><Swatches tokens={manifest[mode] ?? {}} /></Field>)}
58
60
  {kind === 'theme' && manifest?.palette && <p class="builtin-description">Its default palette is <code>{manifest.palette}</code>{manifest.palettes?.length ? `; it offers only ${manifest.palettes.join(', ')}` : ''}.</p>}
59
- <p class="note">{user ? `Installed ${kind}s are changed only by updating them (mdeck themes update --global).` : `Built-in ${kind}s cannot be changed.`} Copy this one into the deck's <code>extensions</code> folder and change the copy. The preview shows every change as you make it.</p>
60
- <div class="row" style={{ display: 'flex', gap: '8px' }}><button class="btn is-primary" onClick={onCopy}>Copy to customise</button></div>
61
+ <p class="note">{user ? `Installed ${kind}s change only when they are updated (mdeck themes update).` : `This ${kind} cannot be changed where it is.`} Copy this one into the deck's <code>extensions</code> folder and change the copy. The preview shows every change as you make it.</p>
62
+ <div class="row" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
63
+ <button class="btn is-primary" onClick={onCopy}>Copy to customise</button>
64
+ {pack && onRemovePack && (removing === 'confirm'
65
+ ? <><span class="muted" style={{ alignSelf: 'center' }}>Remove the pack {pack} for every deck?</span>
66
+ <button class="btn" onClick={async () => { setRemoving('busy'); try { await onRemovePack(pack); setRemoving(null) } catch (error) { setRemoving({ error: error.message }) } }}>Remove</button>
67
+ <button class="btn" onClick={() => setRemoving(null)}>Keep</button></>
68
+ : <button class="btn" disabled={removing === 'busy'} onClick={() => setRemoving('confirm')}>Remove pack</button>)}
69
+ </div>
70
+ {removing?.error && <p class="online-error">{removing.error}</p>}
71
+ {pack && !user && <p class="hint-text">Removing a pack that comes with mdeck hides it; install it again from “More to install”.</p>}
61
72
  <details class="builtin-source">
62
73
  <summary>{MANIFEST}</summary>
63
74
  <pre>{files?.[MANIFEST] ?? ''}</pre>
@@ -37,54 +37,52 @@ export function ExtensionsPanel({ registry, view = {}, owner = "This deck's", se
37
37
  </div>
38
38
  {own.length
39
39
  ? <ol class="outline-list">{own.map(row)}</ol>
40
- : <p class="panel-hint">None yet. Start from a built-in one below{kind === 'palette' ? ', or with + from plain colours' : ''}.</p>}
40
+ : <p class="panel-hint">None yet. Copy one below to start{kind === 'palette' ? ', or with + from plain colours' : ''}.</p>}
41
41
  {installed.length > 0 && <>
42
42
  <div class="panel-section-head"><p class="section-title">Installed for every deck</p></div>
43
43
  <ol class="outline-list">{installed.map(row)}</ol>
44
44
  </>}
45
- <OnlinePacks key="online" kind={kind} owner={owner} onInstall={onInstall} />
46
- <div class="panel-section-head"><p class="section-title">Built in, to start from</p></div>
45
+ <div class="panel-section-head"><p class="section-title">Come with mdeck</p></div>
47
46
  <ol class="outline-list">{builtIn.map(row)}</ol>
47
+ <OnlinePacks key="online" kind={kind} onInstall={onInstall} />
48
48
  </aside>
49
49
  }
50
50
 
51
- // Packs from the theme repository with this kind of extension, loaded on
52
- // request since it needs the network. Installing puts a pack beside the deck.
53
- function OnlinePacks({ kind, owner, onInstall }) {
51
+ // Packs not installed yet that have this kind of extension: from the theme
52
+ // repository, and those that come with mdeck but were removed. Loaded on
53
+ // request, since it needs the network. Installing makes a pack available to
54
+ // every deck.
55
+ function OnlinePacks({ kind, onInstall }) {
54
56
  const [state, setState] = useState({ status: 'idle' })
55
57
  const [busy, setBusy] = useState(null)
56
58
  const load = () => {
57
- setState({ status: 'loading' })
59
+ setState(prev => ({ ...prev, status: prev.index ? 'ready' : 'loading' }))
58
60
  loadThemeIndex().then(index => setState({ status: 'ready', index }), error => setState({ status: 'error', error: error.message }))
59
61
  }
60
62
  const install = async pack => {
61
63
  setBusy(pack.id)
62
- try {
63
- await onInstall(pack, kind)
64
- setState(prev => ({ ...prev, index: { ...prev.index, installed: { ...prev.index.installed, [pack.id]: { version: pack.version } } } }))
65
- } catch (error) { setState(prev => ({ ...prev, error: error.message })) }
64
+ try { await onInstall(pack, kind); load() } catch (error) { setState(prev => ({ ...prev, error: error.message })) }
66
65
  setBusy(null)
67
66
  }
68
- const head = <div class="panel-section-head"><p class="section-title">From the theme repository</p></div>
67
+ const head = <div class="panel-section-head"><p class="section-title">More to install</p></div>
69
68
  if (state.status !== 'ready') return <>{head}<div class="panel-hint">
70
- {state.status === 'loading' ? 'Loading…' : <button class="btn is-small" onClick={load}>Show what others made</button>}
69
+ {state.status === 'loading' ? 'Loading…' : <button class="btn is-small" onClick={load}>Show what you can install</button>}
71
70
  {state.error && <p class="online-error">{state.error}</p>}
72
71
  </div></>
73
72
  const { index } = state
74
- const packs = index.packs.filter(pack => pack[`${kind}s`].length)
73
+ const packs = index.packs.filter(pack => pack[`${kind}s`].length && !index.installed?.[pack.id])
75
74
  return <>{head}
76
75
  {state.error && <p class="panel-hint online-error">{state.error}</p>}
77
- {!packs.length && <p class="panel-hint">No {kind}s there yet.</p>}
76
+ {index.offline && <p class="panel-hint">The theme repository could not be reached; showing what comes with mdeck.</p>}
77
+ {!packs.length && <p class="panel-hint">Every {kind} there is, is installed.</p>}
78
78
  <ol class="outline-list">{packs.map(pack => {
79
79
  const id = pack[`${kind}s`][0]
80
- const have = index.installed?.[pack.id]
80
+ const previews = pack.previews ?? pack.repository?.previews
81
81
  return <li key={pack.id} class="outline-item has-thumb online-pack">
82
- {pack.previews?.[id] && <div class="thumb"><img src={new URL(pack.previews[id], index.url).href} alt="" loading="lazy" /></div>}
82
+ {previews?.[id] && <div class="thumb"><img src={new URL(previews[id], index.url).href} alt="" loading="lazy" /></div>}
83
83
  <span><span class="title" style={{ display: 'block' }}>{pack.title} <span class="muted">{pack.version}</span></span>
84
- <span class="online-meta">{[...pack.themes, ...pack.palettes].join(', ')} · {pack.author}</span></span>
85
- {have
86
- ? <span class="muted">{`Installed${have.version !== pack.version ? ` (${have.version})` : ''} in ${owner === "This folder's" ? 'this folder' : 'this deck'}`}</span>
87
- : <button class="btn is-small" disabled={busy === pack.id} onClick={() => install(pack)}>{busy === pack.id ? 'Installing…' : 'Install'}</button>}
84
+ <span class="online-meta">{[...pack.themes, ...pack.palettes].join(', ')}{pack.removed ? ' · comes with mdeck, removed' : ` · ${pack.author}`}{pack.requires?.length ? ` · needs ${pack.requires.join(', ')}` : ''}</span></span>
85
+ <button class="btn is-small" disabled={busy === pack.id} onClick={() => install(pack)}>{busy === pack.id ? 'Installing…' : 'Install'}</button>
88
86
  </li>
89
87
  })}</ol>
90
88
  </>
@@ -4,11 +4,15 @@ export const DESIGN_URL = '/design.html'
4
4
  export const DESIGN_TAB = 'mdeck-design'
5
5
 
6
6
  // `copy` starts a copy of a built-in extension to change; `open` opens one of
7
- // the deck's own. Both are `kind:id`.
7
+ // the deck's own. Both are `kind:id`. The launch page's address, if this
8
+ // editor came from it, is passed on.
8
9
  export function designUrl({ copy = null, open = null } = {}) {
9
10
  const query = new URLSearchParams()
10
11
  if (copy) query.set('copy', copy)
11
12
  if (open) query.set('open', open)
13
+ // A new tab does not share this one's memory: the way back goes along.
14
+ const home = typeof location === 'undefined' ? null : launchPageUrl()
15
+ if (home) query.set('home', home)
12
16
  return query.size ? `${DESIGN_URL}?${query}` : DESIGN_URL
13
17
  }
14
18
 
@@ -17,3 +21,16 @@ export function parseTarget(value) {
17
21
  const match = /^(theme|palette|layout):([a-z][a-z0-9-]*)$/.exec(value ?? '')
18
22
  return match ? { kind: match[1], id: match[2] } : null
19
23
  }
24
+
25
+ // The launch page of `mdeck run` that opened this editor, if one did: it
26
+ // passes its address as ?home=…, kept for this tab so that switching
27
+ // between the editor and the design page keeps the way back.
28
+ const HOME_KEY = 'mdeck-launch-page'
29
+ export function launchPageUrl() {
30
+ let home = new URLSearchParams(location.search).get('home')
31
+ try {
32
+ if (home) sessionStorage.setItem(HOME_KEY, home)
33
+ else home = sessionStorage.getItem(HOME_KEY)
34
+ } catch {}
35
+ try { return home && /^https?:$/.test(new URL(home).protocol) ? home : null } catch { return null }
36
+ }
@@ -2,7 +2,9 @@
2
2
  // ordinary amounts. The design page previews themes and palettes on it, and
3
3
  // `npm run test:themes` checks every theme against it.
4
4
 
5
- export const SAMPLE_PICTURE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"><rect width="400" height="300" fill="#6b7f99"/><circle cx="140" cy="150" r="80" fill="#e0c068"/><rect x="220" y="80" width="120" height="140" fill="#2d3e50"/></svg>'
5
+ // A drawing in the palette's colours (with plain colours elsewhere): a chart
6
+ // over hills under a sun, for every layout that shows a picture.
7
+ export const SAMPLE_PICTURE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 500"><rect width="800" height="500" fill="var(--surface, #eef1f4)"/><g stroke="var(--rule, #d5dbe1)" stroke-width="2"><path d="M0 100H800M0 200H800M0 300H800M0 400H800M100 0V500M200 0V500M300 0V500M400 0V500M500 0V500M600 0V500M700 0V500"/></g><circle cx="580" cy="165" r="128" fill="none" stroke="var(--accent, #3d5a73)" stroke-width="4" opacity="0.35"/><circle cx="580" cy="165" r="88" fill="var(--accent, #3d5a73)"/><path d="M0 360C140 292 262 300 384 338S628 424 800 330V500H0Z" fill="var(--accent-2, #c2410c)"/><path d="M0 418C168 366 330 392 470 422S690 474 800 434V500H0Z" fill="var(--accent, #3d5a73)" opacity="0.6"/><polyline points="80,262 170,214 260,236 350,170 440,196" fill="none" stroke="var(--ink, #1b2733)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/><g fill="var(--bg, #ffffff)" stroke="var(--ink, #1b2733)" stroke-width="5"><circle cx="80" cy="262" r="10"/><circle cx="170" cy="214" r="10"/><circle cx="260" cy="236" r="10"/><circle cx="350" cy="170" r="10"/><circle cx="440" cy="196" r="10"/></g></svg>'
6
8
 
7
9
  // `picture` is where the slides find the picture: a file beside the deck, or
8
10
  // a data URL where there is no such file.