@gentbajko/slopify 2.2.0 → 2.3.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.
@@ -1,5 +1,5 @@
1
1
  background.jpg
2
- The parchment page texture of the DiceMaster document theme, from the lore2script2 PDF
2
+ The parchment page texture any document theme can use as its background, from the lore2script2 PDF
3
3
  generator (same owner).
4
4
  SHA-256: 18f9701b476f299e942cbb99c05ba3267d8341df2b6136e3aebbafad83b03b31
5
5
 
@@ -1,39 +1,86 @@
1
1
  import { Hono } from "hono";
2
- import { exportPortable, importPortable, portableMaxArchiveBytes, storageUsage, } from "../../slices/storage/portable.js";
2
+ import { BackupBusyError, planBackup, streamBackup, } from "../../slices/storage/backup-export.js";
3
+ import { BackupImportRefused, importBackup } from "../../slices/storage/backup-import.js";
4
+ import { importPortable, portableMaxArchiveBytes, storageUsage, } from "../../slices/storage/portable.js";
3
5
  import { reconcileStorage } from "../../slices/storage/reconcile.js";
4
6
  import { problem, titleOf } from "./problem.js";
7
+ const exportFailed = "Slopify could not create the backup. Check there is free disk space and try again; if it keeps failing, use Download diagnostics in Settings and report it.";
5
8
  export function storageRoutes(deps) {
6
- return new Hono()
9
+ const backup = () => ({
10
+ db: deps.db,
11
+ paths: deps.paths,
12
+ clock: deps.clock,
13
+ ids: deps.ids,
14
+ appVersion: deps.version,
15
+ hasInflight: deps.runner.hasInflight,
16
+ });
17
+ return (new Hono()
7
18
  .get("/", (c) => c.json(storageUsage({ db: deps.db, paths: deps.paths })))
8
- .get("/export", (c) => {
19
+ // What Export everything would write, asked before the download starts: a download
20
+ // link cannot show a refusal, the browser would save it as the file.
21
+ .get("/export/summary", (c) => {
9
22
  try {
10
- const bytes = exportPortable({
11
- db: deps.db,
12
- paths: deps.paths,
13
- ids: deps.ids,
14
- now: () => deps.clock.now().toISOString(),
15
- });
16
- return c.body(bytes, 200, {
17
- "content-type": "application/zip",
18
- "content-length": String(bytes.byteLength),
19
- "content-disposition": 'attachment; filename="slopify-backup.zip"',
23
+ const plan = planBackup(backup());
24
+ return c.json({
25
+ ready: true,
26
+ projects: plan.manifest.projects.length,
27
+ files: plan.manifest.files,
28
+ bytes: plan.archiveBytes,
20
29
  });
21
30
  }
22
- catch {
23
- return problem(c, {
24
- status: 500,
25
- title: titleOf(500),
26
- detail: "Slopify could not create the backup. Check there is free disk space and try again; if it keeps failing, use Download diagnostics in Settings and report it.",
27
- });
31
+ catch (error) {
32
+ if (error instanceof BackupBusyError)
33
+ return c.json({ ready: false, detail: error.message, busy: error.projects });
34
+ deps.log.write("error", "storage.export", { detail: String(error) });
35
+ return problem(c, { status: 500, title: titleOf(500), detail: exportFailed });
28
36
  }
37
+ })
38
+ .get("/export", (c) => {
39
+ let plan;
40
+ try {
41
+ plan = planBackup(backup());
42
+ }
43
+ catch (error) {
44
+ if (error instanceof BackupBusyError)
45
+ return problem(c, { status: 409, title: titleOf(409), detail: error.message });
46
+ deps.log.write("error", "storage.export", { detail: String(error) });
47
+ return problem(c, { status: 500, title: titleOf(500), detail: exportFailed });
48
+ }
49
+ // Pulled one chunk at a time, so a slow download reads files no faster than it sends.
50
+ const chunks = streamBackup(plan);
51
+ const body = new ReadableStream({
52
+ async pull(controller) {
53
+ try {
54
+ const next = await chunks.next();
55
+ if (next.done === true)
56
+ controller.close();
57
+ else
58
+ controller.enqueue(next.value);
59
+ }
60
+ catch (error) {
61
+ deps.log.write("error", "storage.export", { detail: String(error) });
62
+ controller.error(error);
63
+ }
64
+ },
65
+ async cancel() {
66
+ await chunks.return(undefined);
67
+ },
68
+ });
69
+ return c.body(body, 200, {
70
+ "content-type": "application/x-tar",
71
+ "content-length": String(plan.archiveBytes),
72
+ "content-disposition": `attachment; filename="${plan.fileName}"`,
73
+ });
29
74
  })
30
75
  .put("/import", async (c) => {
31
76
  const contentType = c.req.header("content-type") ?? "";
77
+ if (contentType.includes("application/x-tar"))
78
+ return importFull(c.req.raw);
32
79
  if (!contentType.includes("application/zip"))
33
80
  return problem(c, {
34
81
  status: 415,
35
82
  title: titleOf(415),
36
- detail: "Choose a Slopify backup file (.zip) made with Backup & storage in Settings.",
83
+ detail: "Choose a Slopify backup file: a .tar made with Export everything, or an older .zip made with Export backup, both in Settings → Backup & storage.",
37
84
  });
38
85
  const upload = await readPortableUpload(c.req.raw);
39
86
  if (!upload.ok)
@@ -58,8 +105,46 @@ export function storageRoutes(deps) {
58
105
  detail: "This file is not a complete Slopify backup; it may be damaged or a different kind of ZIP. Choose a backup made with Backup & storage in Settings.",
59
106
  });
60
107
  }
108
+ async function importFull(request) {
109
+ if (request.body === null)
110
+ return problem(c, {
111
+ status: 400,
112
+ title: titleOf(400),
113
+ detail: "The backup file arrived empty. Choose the .tar made with Export everything and import it again.",
114
+ });
115
+ const reader = request.body.getReader();
116
+ try {
117
+ return c.json(await importBackup(backup(), chunksOf(reader)));
118
+ }
119
+ catch (error) {
120
+ if (error instanceof BackupImportRefused)
121
+ return problem(c, {
122
+ status: error.status,
123
+ title: titleOf(error.status),
124
+ detail: error.message,
125
+ });
126
+ deps.log.write("error", "storage.import", { detail: String(error) });
127
+ return problem(c, {
128
+ status: 500,
129
+ title: titleOf(500),
130
+ detail: "The backup wasn't imported and nothing in this install changed. Try again; if it keeps failing, use Download diagnostics in Settings and report it.",
131
+ });
132
+ }
133
+ finally {
134
+ // A refusal mid-upload stops reading the rest of a file that may be gigabytes.
135
+ await reader.cancel().catch(() => undefined);
136
+ }
137
+ }
61
138
  })
62
- .post("/cleanup", (c) => c.json(reconcileStorage(deps.db, deps.paths)));
139
+ .post("/cleanup", (c) => c.json(reconcileStorage(deps.db, deps.paths))));
140
+ }
141
+ async function* chunksOf(reader) {
142
+ while (true) {
143
+ const next = await reader.read();
144
+ if (next.done)
145
+ return;
146
+ yield next.value;
147
+ }
63
148
  }
64
149
  async function readPortableUpload(request) {
65
150
  const declared = request.headers.get("content-length");
@@ -3,7 +3,7 @@ import { readdirSync, readFileSync } from "node:fs";
3
3
  // under Vitest (src/kernel/db/migrations) and after the build (dist/kernel/db/migrations,
4
4
  // filled by packages/app/scripts/copy-migrations.mjs).
5
5
  const migrationsDir = new URL("./migrations/", import.meta.url);
6
- export function migrate(db, clock) {
6
+ export function migrate(db, clock, options = {}) {
7
7
  const files = readdirSync(migrationsDir)
8
8
  .filter((file) => file.endsWith(".sql"))
9
9
  .sort();
@@ -20,6 +20,9 @@ export function migrate(db, clock) {
20
20
  if (version === undefined || applied.has(version)) {
21
21
  continue;
22
22
  }
23
+ if (options.through !== undefined && version > options.through) {
24
+ break;
25
+ }
23
26
  const sql = readFileSync(new URL(file, migrationsDir), "utf8");
24
27
  // A migration that rebuilds a table other tables reference must run with enforcement
25
28
  // off: dropping the old table would otherwise cascade into every row that points at it.
@@ -51,6 +54,12 @@ export function migrate(db, clock) {
51
54
  }
52
55
  }
53
56
  }
57
+ // The newest schema this build knows, which a backup records as the schema its rows fit.
58
+ export function newestMigration() {
59
+ return Math.max(0, ...readdirSync(migrationsDir)
60
+ .filter((file) => file.endsWith(".sql"))
61
+ .map(versionOf));
62
+ }
54
63
  // The first line of such a migration, compared without its line ending.
55
64
  const foreignKeysOff = "-- foreign-keys: off";
56
65
  function foreignKeysOn(db) {
@@ -0,0 +1,12 @@
1
+ -- DiceMaster stopped being a built-in document theme: it is one brand's look (DiceMaster.io),
2
+ -- not Slopify's. An install that has already used the Document stage gets it back as a saved
3
+ -- Library → Documents theme with exactly the values it had, so it can still be picked for new
4
+ -- projects. Projects saved with it keep drawing with it either way (legacy-dicemaster.ts).
5
+ -- A fresh install gets nothing, and an existing theme of that name is left alone.
6
+ -- The JSON below is legacyDiceMasterTheme; legacy-dicemaster.test.ts holds the two equal.
7
+ INSERT INTO document_themes (id, name, values_json, created_at, updated_at)
8
+ SELECT 'legacy-dicemaster', 'DiceMaster',
9
+ '{"page":{"format":"a4","margin":22,"contentTop":32},"background":{"image":"parchment","color":"#f5ebd7"},"colors":{"heading":"#8c1e14","text":"#000000","muted":"#50463c","faint":"#786e5f"},"fonts":{"body":{"family":"Cinzel","style":"normal","letterSpacing":0.01},"strong":{"family":"Cinzel","style":"bold","letterSpacing":0.02},"emphasis":{"family":"times","style":"italic","letterSpacing":0},"heading":{"family":"Cinzel","style":"bold","letterSpacing":0.02},"dramatic":{"family":"Cinzel","style":"black","letterSpacing":0.03},"decorative":{"family":"Cinzel","style":"medium","letterSpacing":0.015},"dropCap":{"family":"Cinzel","style":"black","letterSpacing":0},"footer":{"family":"times","style":"normal","letterSpacing":0}},"sizes":{"brand":31,"title":22,"section":19,"heading":17,"subheading":13,"body":10.5,"footer":9,"meta":10},"spacing":{"bodyLine":8,"headingLine":11,"subheadingLine":9,"paragraphGap":0.5,"headingGap":1,"itemGap":0.2,"listIndent":6,"quoteIndent":8,"ruleWidth":30},"dropCap":{"enabled":true,"lines":3,"scale":3,"gap":4,"minLength":50,"minRoom":0.25},"titlePage":{"brandY":60,"taglineOffset":10,"titleY":80,"titleLine":10,"metaOffset":20,"metaLine":10,"showDate":true,"showWordCount":true,"cover":{"enabled":true,"gap":6,"maxHeight":110}},"contents":{"enabled":true,"title":"Table of Contents","depth":2,"titleOffset":20,"firstEntryOffset":40,"line":8,"indent":5},"header":{"enabled":true,"top":15,"maxTitleCharacters":35},"footer":{"enabled":true,"bottom":10,"reserve":15,"text":"Page {page}"},"brand":{"name":"DiceMaster.io","url":"https://dicemaster.io/","tagline":"Let us Handle the Crunch, You Focus on the Story","linkLabel":"Visit DiceMaster.io"},"metadata":{"author":"Created with DiceMaster.io","subject":"{title} - Powered by DiceMaster.io","keywords":"D&D, DnD, Dungeons and Dragons, lore, script, tabletop, RPG, TTRPG, DiceMaster.io","creator":"DiceMaster.io - The AI-Powered Virtual Tabletop"},"sources":{"enabled":true,"title":"Sources Consulted","titleOffset":20,"bodyOffset":40,"line":8,"gap":2},"endPage":{"enabled":true,"title":"About DiceMaster.io","titleOffset":20,"bodyOffset":40,"lines":["About DiceMaster.io:","• Character Engine - Form-driven creation with automatic leveling","• Automated Rules Engine - D&D mechanics and spells run automatically","• AI-Generated NPCs - Instant personalities with full stat blocks","• Granular Inventory Tracking - Live gear & condition management","• Real-Time Rule Guidance - Context-aware clarifications","• Dynamic Storytelling AI - On-the-fly narration & dialogue","• True-Physics Dice - RNG-driven rolls with animated suspense","• Cinematic Combat Module - Positional tracking & narrative summaries","• Voice Commands - Seamless STT/TTS multimodal interaction","• AI Dungeon Master Layer - Rules adjudication & encounter balancing"],"showDocumentDetails":true,"link":{"text":"Visit DiceMaster.io","url":"https://dicemaster.io/"},"closing":"Experience the future of tabletop RPGs!"}}',
10
+ strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
11
+ WHERE EXISTS (SELECT 1 FROM stages WHERE kind = 'document' AND source <> 'off')
12
+ AND NOT EXISTS (SELECT 1 FROM document_themes WHERE lower(name) = 'dicemaster');
@@ -0,0 +1,7 @@
1
+ -- One row per backup archive imported (Settings → Backup & storage). A backup carries its own
2
+ -- id, so importing the same file twice adds its usage history once.
3
+ CREATE TABLE backup_imports (
4
+ backup_id TEXT PRIMARY KEY,
5
+ imported_at TEXT NOT NULL,
6
+ summary_json TEXT NOT NULL CHECK (json_valid(summary_json))
7
+ );
@@ -1,6 +1,7 @@
1
1
  import { transact } from "../../kernel/db/tx.js";
2
2
  import { stageKinds } from "../../kernel/pipeline.js";
3
3
  import { storeArticleText } from "../article/store.js";
4
+ import { defaultDocumentTheme } from "../document/model.js";
4
5
  import { collectSharedGlossary } from "../narration/shared-glossary.js";
5
6
  import { admitInitialRevision } from "../rebuild/runtime-admission.js";
6
7
  import { adoptBaseline } from "../revisions/adopt.js";
@@ -27,6 +28,11 @@ export function startRun(deps, draft, rendered, retainStaged = false, templates)
27
28
  : [];
28
29
  const config = {
29
30
  ...draft,
31
+ // A new project always names its document theme: a config without one reads as the
32
+ // DiceMaster of older projects (see documentThemeOf), which no new project should get.
33
+ ...(sourceOf(draft.sources, "document") === "generate" && draft.document === undefined
34
+ ? { document: { theme: defaultDocumentTheme } }
35
+ : {}),
30
36
  rendered,
31
37
  ...(shared.length === 0 ? {} : { sharedGlossary: shared }),
32
38
  };
@@ -0,0 +1,118 @@
1
+ // The DiceMaster look, the built-in default before 2.3.0. It is one brand's look (DiceMaster.io),
2
+ // not one Slopify offers, so no picker and no built-in list shows it any more. It stays in the
3
+ // code for two reasons only: a project saved with `document: { theme: "dicemaster" }`, or
4
+ // with the Document stage on and no theme at all, must keep drawing (and fingerprinting) exactly
5
+ // as it did, or its PDF would read as outdated; and migration 0017 copies these values into
6
+ // Library → Documents as a saved "DiceMaster" theme, which a test holds to this literal.
7
+ //
8
+ // Ported from the lore2script2 PDF generator's stock DiceMaster.io look, value for value. Do not
9
+ // edit it: any change reaches the documents of every project that still uses it.
10
+ const cinzel = (style, letterSpacing) => ({
11
+ family: "Cinzel",
12
+ style,
13
+ letterSpacing,
14
+ });
15
+ export const legacyDiceMasterTheme = {
16
+ page: { format: "a4", margin: 22, contentTop: 32 },
17
+ background: { image: "parchment", color: "#f5ebd7" },
18
+ colors: { heading: "#8c1e14", text: "#000000", muted: "#50463c", faint: "#786e5f" },
19
+ fonts: {
20
+ body: cinzel("normal", 0.01),
21
+ strong: cinzel("bold", 0.02),
22
+ // Cinzel has no italic, so emphasis borrows the standard serif's.
23
+ emphasis: { family: "times", style: "italic", letterSpacing: 0 },
24
+ heading: cinzel("bold", 0.02),
25
+ dramatic: cinzel("black", 0.03),
26
+ decorative: cinzel("medium", 0.015),
27
+ dropCap: cinzel("black", 0),
28
+ footer: { family: "times", style: "normal", letterSpacing: 0 },
29
+ },
30
+ // lore2script2 scaled its Cinzel sizes and line heights (by 1.1, 1.05 and 1.2) and
31
+ // rounded; these are the results.
32
+ sizes: {
33
+ brand: 31,
34
+ title: 22,
35
+ section: 19,
36
+ heading: 17,
37
+ subheading: 13,
38
+ body: 10.5,
39
+ footer: 9,
40
+ meta: 10,
41
+ },
42
+ spacing: {
43
+ bodyLine: 8,
44
+ headingLine: 11,
45
+ subheadingLine: 9,
46
+ paragraphGap: 0.5,
47
+ headingGap: 1,
48
+ itemGap: 0.2,
49
+ listIndent: 6,
50
+ quoteIndent: 8,
51
+ ruleWidth: 30,
52
+ },
53
+ dropCap: { enabled: true, lines: 3, scale: 3, gap: 4, minLength: 50, minRoom: 0.25 },
54
+ titlePage: {
55
+ brandY: 60,
56
+ taglineOffset: 10,
57
+ titleY: 80,
58
+ titleLine: 10,
59
+ metaOffset: 20,
60
+ metaLine: 10,
61
+ showDate: true,
62
+ showWordCount: true,
63
+ cover: { enabled: true, gap: 6, maxHeight: 110 },
64
+ },
65
+ contents: {
66
+ enabled: true,
67
+ title: "Table of Contents",
68
+ depth: 2,
69
+ titleOffset: 20,
70
+ firstEntryOffset: 40,
71
+ line: 8,
72
+ indent: 5,
73
+ },
74
+ header: { enabled: true, top: 15, maxTitleCharacters: 35 },
75
+ footer: { enabled: true, bottom: 10, reserve: 15, text: "Page {page}" },
76
+ brand: {
77
+ name: "DiceMaster.io",
78
+ url: "https://dicemaster.io/",
79
+ tagline: "Let us Handle the Crunch, You Focus on the Story",
80
+ linkLabel: "Visit DiceMaster.io",
81
+ },
82
+ metadata: {
83
+ author: "Created with DiceMaster.io",
84
+ subject: "{title} - Powered by DiceMaster.io",
85
+ keywords: "D&D, DnD, Dungeons and Dragons, lore, script, tabletop, RPG, TTRPG, DiceMaster.io",
86
+ creator: "DiceMaster.io - The AI-Powered Virtual Tabletop",
87
+ },
88
+ sources: {
89
+ enabled: true,
90
+ title: "Sources Consulted",
91
+ titleOffset: 20,
92
+ bodyOffset: 40,
93
+ line: 8,
94
+ gap: 2,
95
+ },
96
+ endPage: {
97
+ enabled: true,
98
+ title: "About DiceMaster.io",
99
+ titleOffset: 20,
100
+ bodyOffset: 40,
101
+ lines: [
102
+ "About DiceMaster.io:",
103
+ "• Character Engine - Form-driven creation with automatic leveling",
104
+ "• Automated Rules Engine - D&D mechanics and spells run automatically",
105
+ "• AI-Generated NPCs - Instant personalities with full stat blocks",
106
+ "• Granular Inventory Tracking - Live gear & condition management",
107
+ "• Real-Time Rule Guidance - Context-aware clarifications",
108
+ "• Dynamic Storytelling AI - On-the-fly narration & dialogue",
109
+ "• True-Physics Dice - RNG-driven rolls with animated suspense",
110
+ "• Cinematic Combat Module - Positional tracking & narrative summaries",
111
+ "• Voice Commands - Seamless STT/TTS multimodal interaction",
112
+ "• AI Dungeon Master Layer - Rules adjudication & encounter balancing",
113
+ ],
114
+ showDocumentDetails: true,
115
+ link: { text: "Visit DiceMaster.io", url: "https://dicemaster.io/" },
116
+ closing: "Experience the future of tabletop RPGs!",
117
+ },
118
+ };
@@ -1,12 +1,28 @@
1
- // The built-in looks a project's PDF can take. Browser-safe: Play, Edit project and the
2
- // server all offer and accept the same names from here.
3
- export const documentThemes = ["dicemaster", "plain"];
4
- export const defaultDocumentTheme = "dicemaster";
1
+ // The built-in looks a project's PDF can be given. Browser-safe: Play, Edit project and the
2
+ // server all offer the same names from here.
3
+ export const documentThemes = ["plain"];
4
+ // DiceMaster was built in before 2.3.0 and is one brand's look, not Slopify's. A project
5
+ // saved with it keeps it (see legacy-dicemaster.ts), so the name is still read everywhere a
6
+ // config is parsed, but it is never offered.
7
+ export const legacyDocumentTheme = "dicemaster";
8
+ // Every name a saved config may carry.
9
+ export const documentThemeNames = [...documentThemes, legacyDocumentTheme];
10
+ // What a new project, a fresh Play draft and a draft saved before the Document stage get.
11
+ export const defaultDocumentTheme = "plain";
5
12
  export const documentThemeLabels = {
6
- dicemaster: "DiceMaster",
7
13
  plain: "Plain",
14
+ dicemaster: "DiceMaster",
8
15
  };
16
+ // The built-in a saved project config is drawn with. A project saved before the Document stage
17
+ // had a theme setting has none, and was drawn with DiceMaster, the default of the day; it still
18
+ // is, so the PDFs it already made stay current. New projects always save their theme, so this
19
+ // fallback never reaches one.
9
20
  export function documentThemeOf(settings) {
21
+ return settings?.theme ?? legacyDocumentTheme;
22
+ }
23
+ // The built-in a Play draft or template form stands for. A draft is not a project yet, so an
24
+ // absent setting is the current default rather than the old one.
25
+ export function draftDocumentThemeOf(settings) {
10
26
  return settings?.theme ?? defaultDocumentTheme;
11
27
  }
12
28
  // The name a project's theme is shown by: the Library theme's own, or the built-in's label.
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { documentThemeNameMax, documentThemes } from "./model.js";
2
+ import { documentThemeNameMax, documentThemeNames } from "./model.js";
3
3
  import { fontFamilies, fontStyles } from "./theme.js";
4
4
  // Every value of a document theme with the range the renderer draws sensibly within, so a
5
5
  // saved theme, a project config and the editor's preview all refuse the same values.
@@ -166,7 +166,8 @@ export const customDocumentThemeSchema = z
166
166
  values: documentThemeSchema,
167
167
  })
168
168
  .strict();
169
- // A project's, a draft's and a template's document settings.
169
+ // A project's, a draft's and a template's document settings. The retired "dicemaster" still
170
+ // parses, so a project or draft saved with it keeps opening.
170
171
  export const documentSettingsSchema = z
171
- .object({ theme: z.enum(documentThemes), custom: customDocumentThemeSchema.optional() })
172
+ .object({ theme: z.enum(documentThemeNames), custom: customDocumentThemeSchema.optional() })
172
173
  .strict();
@@ -1,4 +1,5 @@
1
- import { defaultDocumentTheme, documentThemeOf, } from "./model.js";
1
+ import { legacyDiceMasterTheme } from "./legacy-dicemaster.js";
2
+ import { defaultDocumentTheme, documentThemeOf, legacyDocumentTheme, } from "./model.js";
2
3
  // "Cinzel" is bundled (assets/document/fonts) in the styles below; the other three are the
3
4
  // PDF standard fonts every reader has.
4
5
  export const fontFamilies = ["Cinzel", "times", "helvetica", "courier"];
@@ -8,10 +9,12 @@ const cinzel = (style, letterSpacing) => ({
8
9
  style,
9
10
  letterSpacing,
10
11
  });
11
- const dicemaster = {
12
+ // The one built-in: an unbranded flat page. Its layout is the one Slopify has always drawn,
13
+ // written out in full rather than as changes to another theme.
14
+ const plain = {
12
15
  page: { format: "a4", margin: 22, contentTop: 32 },
13
- background: { image: "parchment", color: "#f5ebd7" },
14
- colors: { heading: "#8c1e14", text: "#000000", muted: "#50463c", faint: "#786e5f" },
16
+ background: { image: null, color: "#fdfaf3" },
17
+ colors: { heading: "#1f3a5f", text: "#1a1a1a", muted: "#555555", faint: "#888888" },
15
18
  fonts: {
16
19
  body: cinzel("normal", 0.01),
17
20
  strong: cinzel("bold", 0.02),
@@ -23,8 +26,6 @@ const dicemaster = {
23
26
  dropCap: cinzel("black", 0),
24
27
  footer: { family: "times", style: "normal", letterSpacing: 0 },
25
28
  },
26
- // lore2script2 scaled its Cinzel sizes and line heights (by 1.1, 1.05 and 1.2) and
27
- // rounded; these are the results.
28
29
  sizes: {
29
30
  brand: 31,
30
31
  title: 22,
@@ -69,18 +70,8 @@ const dicemaster = {
69
70
  },
70
71
  header: { enabled: true, top: 15, maxTitleCharacters: 35 },
71
72
  footer: { enabled: true, bottom: 10, reserve: 15, text: "Page {page}" },
72
- brand: {
73
- name: "DiceMaster.io",
74
- url: "https://dicemaster.io/",
75
- tagline: "Let us Handle the Crunch, You Focus on the Story",
76
- linkLabel: "Visit DiceMaster.io",
77
- },
78
- metadata: {
79
- author: "Created with DiceMaster.io",
80
- subject: "{title} - Powered by DiceMaster.io",
81
- keywords: "D&D, DnD, Dungeons and Dragons, lore, script, tabletop, RPG, TTRPG, DiceMaster.io",
82
- creator: "DiceMaster.io - The AI-Powered Virtual Tabletop",
83
- },
73
+ brand: { name: null, url: null, tagline: null, linkLabel: null },
74
+ metadata: { author: "", subject: "{title}", keywords: "", creator: "" },
84
75
  sources: {
85
76
  enabled: true,
86
77
  title: "Sources Consulted",
@@ -90,37 +81,20 @@ const dicemaster = {
90
81
  gap: 2,
91
82
  },
92
83
  endPage: {
93
- enabled: true,
94
- title: "About DiceMaster.io",
84
+ enabled: false,
85
+ title: "About",
95
86
  titleOffset: 20,
96
87
  bodyOffset: 40,
97
- lines: [
98
- "About DiceMaster.io:",
99
- "• Character Engine - Form-driven creation with automatic leveling",
100
- "• Automated Rules Engine - D&D mechanics and spells run automatically",
101
- "• AI-Generated NPCs - Instant personalities with full stat blocks",
102
- "• Granular Inventory Tracking - Live gear & condition management",
103
- "• Real-Time Rule Guidance - Context-aware clarifications",
104
- "• Dynamic Storytelling AI - On-the-fly narration & dialogue",
105
- "• True-Physics Dice - RNG-driven rolls with animated suspense",
106
- "• Cinematic Combat Module - Positional tracking & narrative summaries",
107
- "• Voice Commands - Seamless STT/TTS multimodal interaction",
108
- "• AI Dungeon Master Layer - Rules adjudication & encounter balancing",
109
- ],
88
+ lines: [],
110
89
  showDocumentDetails: true,
111
- link: { text: "Visit DiceMaster.io", url: "https://dicemaster.io/" },
112
- closing: "Experience the future of tabletop RPGs!",
90
+ link: null,
91
+ closing: null,
113
92
  },
114
93
  };
115
- const plain = {
116
- background: { image: null, color: "#fdfaf3" },
117
- colors: { heading: "#1f3a5f", text: "#1a1a1a", muted: "#555555", faint: "#888888" },
118
- brand: { name: null, url: null, tagline: null, linkLabel: null },
119
- metadata: { author: "", subject: "{title}", keywords: "D&D, lore", creator: "" },
120
- endPage: { enabled: false },
121
- };
94
+ // A built-in by name. "dicemaster" is still read, from projects saved while it was built in,
95
+ // and gives exactly the values it gave then.
122
96
  export function builtInTheme(name) {
123
- return name === "plain" ? resolveTheme(plain) : resolveTheme();
97
+ return resolveTheme({}, name === legacyDocumentTheme ? legacyDiceMasterTheme : plain);
124
98
  }
125
99
  // The theme a project's document is drawn with: its copy of a Library theme, or a built-in.
126
100
  export function resolvedDocumentTheme(settings) {
@@ -133,7 +107,7 @@ export function defaultTheme() {
133
107
  return builtInTheme(defaultDocumentTheme);
134
108
  }
135
109
  // Unknown sections and keys throw, so a typo in a saved theme can't pass silently.
136
- export function resolveTheme(overrides = {}, base = dicemaster) {
110
+ export function resolveTheme(overrides = {}, base = plain) {
137
111
  const theme = structuredClone(base);
138
112
  for (const [section, values] of Object.entries(overrides)) {
139
113
  const target = theme[section];
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultZoomPercent, edgeSilenceSecondsProblem, imageSecondsProblem, numberPerPromptMax, zoomPercentProblem, } from "../admission/rules.js";
3
3
  import { runDraftSchema } from "../admission/schema.js";
4
- import { documentThemeOf } from "../document/model.js";
4
+ import { draftDocumentThemeOf } from "../document/model.js";
5
5
  import { defaultSubtitles } from "../subtitles/model.js";
6
6
  export function toAdmissionDraft(input) {
7
7
  const { form } = input.document;
@@ -74,7 +74,7 @@ export function toAdmissionDraft(input) {
74
74
  ...(sources.document === "generate"
75
75
  ? {
76
76
  document: {
77
- theme: documentThemeOf(form.document),
77
+ theme: draftDocumentThemeOf(form.document),
78
78
  ...(form.document?.custom === undefined ? {} : { custom: form.document.custom }),
79
79
  },
80
80
  }
@@ -1,8 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { transact } from "../../kernel/db/tx.js";
3
+ import { sourceOf } from "../admission/model.js";
3
4
  import { projectById } from "../admission/repo.js";
4
5
  import { detectSlots } from "../admission/substitute.js";
5
6
  import { listCheckpoints } from "../checkpoints/repo.js";
7
+ import { documentThemeOf } from "../document/model.js";
6
8
  import { requestHash } from "../play-drafts/repo.js";
7
9
  import { currentRevisionId, revisionById } from "../revisions/repo.js";
8
10
  import { createTemplate, readTemplate } from "./service.js";
@@ -128,7 +130,13 @@ function documentFromProject(deps, revision) {
128
130
  title: config.title,
129
131
  format: config.format,
130
132
  sources: config.sources,
131
- ...(config.document === undefined ? {} : { document: config.document }),
133
+ // A project from before theme settings was drawn with DiceMaster; the template says so
134
+ // outright, as a draft without a theme would now mean Plain.
135
+ ...(config.document === undefined
136
+ ? sourceOf(config.sources, "document") === "generate"
137
+ ? { document: { theme: documentThemeOf(undefined) } }
138
+ : {}
139
+ : { document: config.document }),
132
140
  checkpoints: listCheckpoints(deps.db, revision.projectId, revision.id).map((gate) => gate.stage),
133
141
  llm: config.llm ?? { provider: "", model: "" },
134
142
  audio: config.audio ?? { provider: "", model: "", voice: "" },