playable-lessons 0.2.2 → 0.6.2

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,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PROMPTS = void 0;
4
+ exports.storyPrompts = storyPrompts;
4
5
  exports.PROMPTS = {
5
6
  system: `You are the Playable Lessons assistant, an AI that transforms educational source material into playable interactive fiction using the Ink scripting language.
6
7
 
@@ -20,6 +21,7 @@ When generating Ink, follow these rules:
20
21
  - Use * for once-only choices, + for sticky choices
21
22
  - Use -> knot_name for diverts
22
23
  - Use { variable > X: text } for conditional content
24
+ - Compound conditions use the words and / or / not — Ink has NO && or || operators
23
25
  - Use ~ variable = value for variable assignments
24
26
  - Always include a -> END or -> DONE to terminate paths
25
27
  - Use # IMAGE: and # TIMER: tags for Playable Lessons custom features`,
@@ -169,6 +171,14 @@ VAR has_key = false
169
171
  You need more experience.
170
172
  }
171
173
 
174
+ // Compound conditions use WORDS, not symbols (Ink has no && or ||)
175
+ { score > 5 and has_key:
176
+ You stride forward, prepared.
177
+ }
178
+ { tier == "premium" or score > 10:
179
+ The exclusive option.
180
+ }
181
+
172
182
  // Inline conditional
173
183
  You feel {has_key: confident|nervous}.
174
184
 
@@ -183,6 +193,7 @@ You feel {has_key: confident|nervous}.
183
193
 
184
194
  // Tags (for Playable Lessons)
185
195
  # IMAGE: assets/images/scene.jpg
196
+ # IMAGE_PROMPT: a short visual description of the scene, used to generate an illustration
186
197
  # TIMER: 15
187
198
  # ENDING: good
188
199
 
@@ -190,5 +201,426 @@ You feel {has_key: confident|nervous}.
190
201
  Text<>
191
202
  <>more text on same line
192
203
 
193
- === END ===`
204
+ === END ===`,
205
+ /**
206
+ * Appended to the Ink-generation prompt when scene images are enabled
207
+ * (GenerateParams.sceneImages). Style-agnostic — works for both stateful and
208
+ * branching stories. The tags are resolved to real images by
209
+ * generateSceneImages (generate.ts) after the story compiles.
210
+ */
211
+ sceneImageInstruction: `
212
+ - Give EVERY knot a scene illustration: on its own line immediately after the
213
+ knot's === header === line, write exactly one tag:
214
+ # IMAGE_PROMPT: <one sentence describing the scene visually — setting, who is
215
+ present, their action, the mood; concrete and specific to this knot's prose>
216
+ - Describe only what is visible; never mention text, captions, or the story itself
217
+ - Keep each prompt self-contained (an image model sees only the prompt, not the story)`,
218
+ // ─── Pure-branching story variants (no variables — H5P/LMS-convertible) ───
219
+ systemBranching: `You are the Playable Lessons assistant, an AI that transforms educational source material into playable interactive fiction using the Ink scripting language.
220
+
221
+ Your goals:
222
+ - Create engaging, educational interactive stories from the provided source material
223
+ - Ensure all generated Ink code is syntactically valid and compiles without errors
224
+ - Preserve factual accuracy from the source material
225
+ - Never invent facts about named real people or institutions
226
+ - Create meaningful branching choices that explore different perspectives and outcomes
227
+ - Express every consequence through the branch structure itself — the passage a choice leads to shows its outcome
228
+
229
+ THIS STORY MUST BE PURE BRANCHING — NO STATE:
230
+ - Do NOT declare or use variables (no VAR lines, no ~ assignments)
231
+ - Do NOT use conditional text or logic (no { ... } blocks of any kind)
232
+ - A choice's consequence is carried by the knot it diverts to, not by tracked state
233
+ - This keeps the story convertible to tree-based formats (H5P Branching Scenario, LMS embeds)
234
+
235
+ When generating Ink, follow these rules:
236
+ - Always start the file with a -> start divert (no VAR declarations before it)
237
+ - The first knot MUST be named === start ===
238
+ - Use === knot_name === for passages (knots)
239
+ - Use = stitch_name for sub-sections within knots
240
+ - Use * for once-only choices, + for sticky choices
241
+ - Use -> knot_name for diverts
242
+ - Always include a -> END or -> DONE to terminate paths
243
+ - Use # IMAGE: and # TIMER: tags for Playable Lessons custom features`,
244
+ outlineBranching: `Create a detailed story outline for an interactive fiction based on this:
245
+
246
+ Input mode: {{inputMode}}
247
+ Source material: """{{inputText}}"""
248
+ Story length: {{storyLength}} (short=~5 nodes, medium=~15, long=~30+)
249
+ Protagonist type: {{protagonistType}}
250
+ Tone: {{tone}}
251
+
252
+ User's answers to clarification questions:
253
+ {{answers}}
254
+
255
+ Generate a JSON outline with this structure:
256
+ \`\`\`json
257
+ {
258
+ "nodes": [
259
+ { "id": "start", "title": "Opening Scene", "summary": "Brief description" }
260
+ ],
261
+ "edges": [
262
+ { "from": "start", "to": "next_node", "choiceText": "What the player clicks" }
263
+ ],
264
+ "canonPath": ["start", "node2", "node3", "best_ending"]
265
+ }
266
+ \`\`\`
267
+
268
+ Ensure:
269
+ - Every node is reachable from "start"
270
+ - Every path reaches an ending node
271
+ - Do NOT include variables — this story is a pure branching tree, and every
272
+ consequence must be expressed by which node a choice leads to
273
+ - Choices lead to genuinely different passages, not cosmetic variations
274
+ - The story has at least 2 distinct endings
275
+ - The canon path represents the "ideal" learning path`,
276
+ inkGenerationBranching: `Convert this story outline into valid Ink source code.
277
+
278
+ Outline:
279
+ {{outline}}
280
+
281
+ Original source material (for factual accuracy):
282
+ Input mode: {{inputMode}}
283
+ """{{inputText}}"""
284
+
285
+ Target length: {{storyLength}}
286
+
287
+ Requirements:
288
+ - Begin the file with a -> start divert (do NOT declare any VAR)
289
+ - Each node from the outline becomes an === knot ===
290
+ - Each edge becomes a choice (* [choice text])
291
+ - Do NOT use variables, ~ assignments, or conditional text { ... } anywhere —
292
+ this story must remain a pure branching tree
293
+ - Let each passage's prose reflect the choice that led to it, so consequences
294
+ live in the branches rather than in tracked state
295
+ - Every path must reach -> END
296
+ - Write engaging, educational prose — not just placeholder text
297
+ - Write ALL prose yourself. Do NOT copy any example or placeholder text from the
298
+ syntax reference verbatim (e.g. "[Write the opening scene here]"); those
299
+ bracketed placeholders show structure only — replace them with real content
300
+ - Mark endings with a # ENDING: tag followed by a label (good/neutral/bad)
301
+
302
+ Return ONLY the complete Ink source inside a \`\`\`ink code block.`,
303
+ inkSyntaxRefBranching: `=== INK SYNTAX REFERENCE (PURE BRANCHING — no variables, no conditionals) ===
304
+
305
+ // Start the file with a divert to the first knot (no VAR declarations)
306
+ -> start
307
+
308
+ // First knot MUST be named "start"
309
+ // (bracketed text below = placeholders — replace with your own prose, do not copy)
310
+ === start ===
311
+ [Write the opening scene here]
312
+ * [First choice] -> next_scene
313
+
314
+ // Other knots
315
+ === knot_name ===
316
+ [Write the passage prose here]
317
+ * [Choice text visible to player]
318
+ [Result prose shown after choosing]
319
+ -> next_knot
320
+ * [Another choice]
321
+ -> another_knot
322
+
323
+ // Stitches (sub-sections within a knot)
324
+ === knot ===
325
+ = stitch_one
326
+ [Stitch prose here]
327
+ -> stitch_two
328
+ = stitch_two
329
+ [More stitch prose here]
330
+
331
+ // Sticky choices (can be chosen repeatedly)
332
+ + [Repeatable choice]
333
+
334
+ // Diverts
335
+ -> knot_name
336
+ -> END
337
+ -> DONE
338
+
339
+ // Tags (for Playable Lessons)
340
+ # IMAGE: assets/images/scene.jpg
341
+ # IMAGE_PROMPT: a short visual description of the scene, used to generate an illustration
342
+ # TIMER: 15
343
+ # ENDING: good
344
+
345
+ // Glue (prevents line break)
346
+ Text<>
347
+ <>more text on same line
348
+
349
+ // FORBIDDEN in this style — do not emit any of these:
350
+ // VAR score = 0
351
+ // ~ score = score + 1
352
+ // { score > 5: ... }
353
+ // You feel {has_key: confident|nervous}.
354
+
355
+ === END ===`,
356
+ flashcardsSystem: `You are the Playable Lessons assistant, an AI that transforms educational source material into concise, effective study flashcards.
357
+
358
+ Your goals:
359
+ - Surface the most important, learnable facts and concepts in the source material
360
+ - Phrase each front as a clear question, term, or prompt; keep it short and unambiguous
361
+ - Make each back a precise, self-contained answer that is understandable without seeing the source
362
+ - Preserve factual accuracy — never invent facts not supported by the source
363
+ - Avoid trivial cards (common-word definitions) unless they are genuinely the point
364
+ - Use a short tag to group related cards (e.g. "definitions", "process", "people")
365
+
366
+ Respond with ONLY the JSON object requested — no preamble, no commentary.`,
367
+ flashcards: `Create a set of study flashcards from the following source material.
368
+
369
+ Input mode: {{inputMode}}
370
+ Source material:
371
+ """
372
+ {{inputText}}
373
+ """
374
+
375
+ Target number of cards: {{cardCount}}
376
+ Tone: {{tone}}
377
+
378
+ Return ONLY this JSON shape (wrapped in a \`\`\`json block is fine):
379
+ {
380
+ "deckTitle": "a short title for the deck",
381
+ "cards": [
382
+ {
383
+ "front": "a question, term, or prompt",
384
+ "back": "the precise answer or explanation",
385
+ "hint": "optional nudge; omit the key entirely if not useful",
386
+ "tag": "optional short topic tag"
387
+ }
388
+ ]
389
+ }
390
+
391
+ Rules:
392
+ - Produce close to {{cardCount}} cards; every card needs a non-empty front and back
393
+ - "hint" and "tag" are optional — omit the key rather than sending null or an empty string
394
+ - Do not output any text outside the JSON object`,
395
+ quizSystem: `You are the Playable Lessons assistant, an AI that transforms educational source material into fair, well-formed multiple-choice quiz questions.
396
+
397
+ Your goals:
398
+ - Test genuine understanding, not trivia or word-matching
399
+ - Write a clear, specific stem (the question) that is unambiguous
400
+ - Provide plausible distractors that are clearly wrong but not absurd
401
+ - Make exactly one option unambiguously correct
402
+ - Vary which position (A/B/C/D) holds the correct answer across questions
403
+ - Preserve factual accuracy — never invent facts not supported by the source
404
+ - Add a short explanation for each answer justifying why the correct option is right
405
+
406
+ Respond with ONLY the JSON object requested — no preamble, no commentary.`,
407
+ quiz: `Create a set of multiple-choice quiz questions from the following source material.
408
+
409
+ Input mode: {{inputMode}}
410
+ Source material:
411
+ """
412
+ {{inputText}}
413
+ """
414
+
415
+ Target number of questions: {{questionCount}}
416
+ Tone: {{tone}}
417
+
418
+ Return ONLY this JSON shape (wrapped in a \`\`\`json block is fine):
419
+ {
420
+ "quizTitle": "a short title for the quiz",
421
+ "questions": [
422
+ {
423
+ "stem": "the question text",
424
+ "options": ["choice A", "choice B", "choice C", "choice D"],
425
+ "correctIndex": 0,
426
+ "explanation": "why the correct option is right (optional but encouraged)"
427
+ }
428
+ ]
429
+ }
430
+
431
+ Rules:
432
+ - Produce close to {{questionCount}} questions
433
+ - Each question needs 2-6 options; exactly one must be correct
434
+ - "correctIndex" is the 0-based index of the correct option within "options"
435
+ - Vary the correct option's position across questions
436
+ - "explanation" is optional — omit the key rather than sending null or an empty string
437
+ - Do not output any text outside the JSON object`,
438
+ summarySystem: `You are the Playable Lessons assistant, an AI that distills educational source material into a clear, accurate study summary.
439
+
440
+ Your goals:
441
+ - Capture the essence: the main subject and why it matters
442
+ - Pull out the most important, learnable key points (not trivia)
443
+ - Define the essential terms a learner must know
444
+ - Preserve factual accuracy — never invent facts not supported by the source
445
+ - Be concise and concrete; write prose a learner can actually use
446
+
447
+ Respond with ONLY the JSON object requested — no preamble, no commentary.`,
448
+ summary: `Create a study summary from the following source material.
449
+
450
+ Input mode: {{inputMode}}
451
+ Source material:
452
+ """
453
+ {{inputText}}
454
+ """
455
+
456
+ Target number of key points: {{keyPointCount}}
457
+ Tone: {{tone}}
458
+
459
+ Return ONLY this JSON shape (wrapped in a \`\`\`json block is fine):
460
+ {
461
+ "title": "a short title for the summary",
462
+ "overview": "1-3 sentences capturing the essence of the material",
463
+ "keyPoints": ["a concise, self-contained point", "..."],
464
+ "glossary": [{ "term": "a key term", "definition": "a short definition" }]
465
+ }
466
+
467
+ Rules:
468
+ - Produce close to {{keyPointCount}} key points; each must be non-empty and self-contained
469
+ - Include only terms central to understanding in "glossary" (5-12 entries is typical)
470
+ - Omit a key entirely rather than sending null or an empty string
471
+ - Do not output any text outside the JSON object`,
472
+ aiTaskSystem: `You are the Playable Lessons assistant. You re-engineer educational source material (a worksheet, lesson, or rough idea) into AI-collaboration tasks — tasks a learner completes by working iteratively with an LLM chatbot.
473
+
474
+ THE DEFINING REQUIREMENT: every task must be one where critically engaging the chatbot — probing, challenging, revising its answers — is what produces a good result. A task where the AI's first answer is already complete, or where a learner could do well by pasting that answer, is a FAILURE no matter how polished it looks.
475
+
476
+ A task qualifies ONLY if it is:
477
+ - Tool-mediated: the learner uses the chatbot as a working tool; the task is not about AI itself.
478
+ - Iterative: one prompt and one answer cannot do it well.
479
+ - First-answer-defeatable: the brief contains specifics the AI cannot see that make a generic answer wrong or incomplete.
480
+ - Artifact-producing: it ends in a deliverable judged on the learner's reasoning.
481
+ - Engagement-visible: a reader can tell an interrogating learner from a delegating one.
482
+
483
+ NEVER propose: quizzes, flashcards, fact-lookup, "use AI to learn/summarise topic X", anything solvable in one prompt, or anything about AI as a subject. (Those other outputs exist as separate targets in this tool; THIS output is exclusively AI-collaboration tasks.)
484
+
485
+ THE MECHANISM THAT MAKES INTERROGATION PAY OFF: load-bearing specifics. Build 2-4 concrete details into each scenario — a constraint, a conflicting stakeholder, an awkward number, a clause — that a generic answer gets wrong or ignores. The learner sees the scenario; the chatbot does not, until the learner surfaces those specifics through interrogation.
486
+
487
+ Respond with ONLY the JSON object requested — no preamble, no commentary.`,
488
+ aiTask: `Re-engineer the following source material into {{taskCount}} AI-collaboration tasks.
489
+
490
+ Input mode: {{inputMode}}
491
+ Source material:
492
+ """
493
+ {{inputText}}
494
+ """
495
+ Tone: {{tone}}
496
+
497
+ Return ONLY this JSON shape (wrapped in a \`\`\`json block is fine):
498
+ {
499
+ "title": "a short title for this set of tasks",
500
+ "tasks": [
501
+ {
502
+ "scenario": "a concrete situation that embeds 2-4 load-bearing specifics",
503
+ "brief": "the student-facing instruction, readable with zero chatbot experience",
504
+ "deliverable": "the human-authored output the learner must produce",
505
+ "loadBearingSpecifics": [
506
+ { "detail": "a concrete specific built into the scenario", "whyGenericAnswersMissIt": "why a generic AI answer gets this wrong or ignores it" }
507
+ ],
508
+ "rubric": [
509
+ { "criterion": "a criterion anchored on engagement with the specifics", "description": "what strong engagement looks like" }
510
+ ],
511
+ "whyItWorks": "why a delegating learner loses and an interrogating one wins"
512
+ }
513
+ ]
514
+ }
515
+
516
+ Rules:
517
+ - Produce close to {{taskCount}} tasks; every task must satisfy ALL five qualifying criteria
518
+ - Each task needs 2-4 loadBearingSpecifics and 2-4 rubric criteria
519
+ - Rubric criteria must be anchored on engagement with the specifics, NOT on the correctness of the final answer
520
+ - The scenario carries the load-bearing specifics (learner-visible); a chatbot that has not been told them must get the task wrong
521
+ - Omit an array element's key rather than sending null or an empty string
522
+ - Do not output any text outside the JSON object`,
523
+ caseStudySystem: `You are the Playable Lessons assistant. You turn educational source material (a topic, lesson, methodology, or rough idea) into a teaching case study — a realistic situation a learner analyzes to surface and apply the key concepts.
524
+
525
+ A good teaching case study:
526
+ - Is a concrete, specific situation (real or plausible), not a summary of theory
527
+ - Has a protagonist facing a real decision or dilemma
528
+ - Embeds the key concepts as details the learner must notice and reason about
529
+ - Carries just enough data / numbers / context to force analysis, not recitation
530
+ - Ends open-ended (a decision to weigh), not with "the answer"
531
+ - Stays factually grounded — never invent specifics presented as real events, and never fabricate named real people or institutions
532
+
533
+ Respond with ONLY the JSON object requested — no preamble, no commentary.`,
534
+ caseStudy: `Create a teaching case study from the following source material.
535
+
536
+ Input mode: {{inputMode}}
537
+ Source material:
538
+ """
539
+ {{inputText}}
540
+ """
541
+ Depth: {{depth}}
542
+ Tone: {{tone}}
543
+
544
+ Return ONLY this JSON shape (wrapped in a \`\`\`json block is fine):
545
+ {
546
+ "title": "a short, specific title for the case",
547
+ "protagonist": "who/what the case centers on (a role, team, or organization — fictional unless the source names one)",
548
+ "situation": "the concrete setup and context",
549
+ "keyFacts": ["a specific data point, number, constraint, or exhibit the learner must weigh", "..."],
550
+ "conflict": "the central tension or dilemma",
551
+ "decisionPoints": ["a juncture or choice the protagonist faces", "..."],
552
+ "discussionQuestions": ["an open question that forces analysis (not recall)", "..."],
553
+ "narrative": "the full case written as connected prose — OMIT this key entirely unless depth is 'complete'"
554
+ }
555
+
556
+ Depth rules:
557
+ - idea: keep situation, conflict, and decisionPoints brief (a premise); 1-2 keyFacts; omit narrative
558
+ - outline: fill every structural field concretely, but omit narrative (this is a skeleton)
559
+ - complete: fill every structural field AND write a full "narrative" (several paragraphs) weaving them together
560
+ - discussionQuestions: 2-4, always open-ended
561
+ - Omit the "narrative" key entirely unless depth is "complete"; never send null or empty strings
562
+ - Do not output any text outside the JSON object`,
563
+ planSystem: `You are the Playable Lessons planning assistant. You analyze educational source material and recommend a complementary SET of outputs an educator could generate from it.
564
+
565
+ Available output targets and what each is good for:
566
+ - story: a branching, playable interactive-fiction narrative (immersive practice; best for decision-making, ethics, scenarios)
567
+ - flashcards: spaced-recall study cards (best for terminology, definitions, recall)
568
+ - quiz: self-marking multiple-choice questions (best for checking comprehension)
569
+ - summary: a concise study summary with key points + glossary (best for overview / revision)
570
+ - ai-task: AI-collaboration tasks where the learner interrogates a chatbot (best for critical thinking, reasoning)
571
+ - case-study: a teaching case study with a dilemma + discussion questions (best for applied analysis)
572
+
573
+ Recommend 2-4 complementary targets that together serve the material — not all six, and not redundant ones. Pick the combination that fits the content and a likely learning goal.
574
+
575
+ Respond with ONLY the JSON object requested — no preamble, no commentary.`,
576
+ plan: `Analyze the following source material and recommend a set of outputs to generate from it.
577
+
578
+ Input mode: {{inputMode}}
579
+ Source material:
580
+ """
581
+ {{inputText}}
582
+ """
583
+ Tone: {{tone}}
584
+
585
+ Return ONLY this JSON shape (wrapped in a \`\`\`json block is fine):
586
+ {
587
+ "title": "a short title for the material",
588
+ "summary": "1-2 sentences on what the material is and the likely learning goal",
589
+ "recommendations": [
590
+ {
591
+ "target": "one of: story | flashcards | quiz | summary | ai-task | case-study",
592
+ "rationale": "why this target suits this material",
593
+ "depth": "for case-study only: idea | outline | complete",
594
+ "count": "optional number of items (cards / questions / key points / tasks)"
595
+ }
596
+ ]
597
+ }
598
+
599
+ Rules:
600
+ - Recommend 2-4 targets; each target must be one of the six listed
601
+ - Order them by usefulness for this material
602
+ - Include "depth" only when target is "case-study"; include "count" only when relevant — omit them otherwise
603
+ - Do not output any text outside the JSON object`
194
604
  };
605
+ /**
606
+ * The style-dependent story prompts. Both the interactive GUI flow and the
607
+ * headless pipeline (CLI, web server) must select prompts through this so a
608
+ * given style generates identically everywhere. Analysis/clarification/review
609
+ * prompts are shared across styles.
610
+ */
611
+ function storyPrompts(style = 'stateful') {
612
+ if (style === 'branching') {
613
+ return {
614
+ system: exports.PROMPTS.systemBranching,
615
+ outline: exports.PROMPTS.outlineBranching,
616
+ inkGeneration: exports.PROMPTS.inkGenerationBranching,
617
+ inkSyntaxRef: exports.PROMPTS.inkSyntaxRefBranching
618
+ };
619
+ }
620
+ return {
621
+ system: exports.PROMPTS.system,
622
+ outline: exports.PROMPTS.outline,
623
+ inkGeneration: exports.PROMPTS.inkGeneration,
624
+ inkSyntaxRef: exports.PROMPTS.inkSyntaxRef
625
+ };
626
+ }
@@ -0,0 +1,182 @@
1
+ "use strict";
2
+ /**
3
+ * Quiz export formats, shared by the renderer and the CLI.
4
+ *
5
+ * Standalone (no runtime imports from sibling shared modules) — only the
6
+ * QuizResult *type* is imported, which is erased at compile time, so it resolves
7
+ * under both bundler (renderer) and NodeNext (CLI) module resolution.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.toPlainText = toPlainText;
11
+ exports.toStandaloneHTML = toStandaloneHTML;
12
+ const generate_js_1 = require("./generate.js");
13
+ /**
14
+ * A printable quiz with a separate answer key — for handouts or offline review.
15
+ * Questions are numbered; options are lettered A, B, C, …
16
+ */
17
+ function toPlainText(result, title) {
18
+ const heading = result.quizTitle || title || 'Quiz';
19
+ const lines = [heading, ''];
20
+ result.questions.forEach((q, i) => {
21
+ lines.push(`${i + 1}. ${q.stem}`);
22
+ q.options.forEach((opt, j) => {
23
+ lines.push(` ${(0, generate_js_1.quizLetter)(j)}) ${opt}`);
24
+ });
25
+ lines.push('');
26
+ });
27
+ lines.push('--- Answer Key ---');
28
+ result.questions.forEach((q, i) => {
29
+ const entry = `${i + 1}. ${(0, generate_js_1.quizLetter)(q.correctIndex)}${q.explanation ? ` — ${q.explanation}` : ''}`;
30
+ lines.push(entry);
31
+ });
32
+ return lines.join('\n') + '\n';
33
+ }
34
+ /**
35
+ * A self-contained, offline HTML quiz. Each question is multiple-choice; on
36
+ * submit it grades the answers, shows a score, marks each option correct/wrong,
37
+ * and reveals explanations. Option/question text is rendered via textContent
38
+ * (no innerHTML), and the embedded question JSON is guarded against breaking out
39
+ * of its <script> by escaping '<' to '\u003c'.
40
+ */
41
+ function toStandaloneHTML(result, title) {
42
+ const quizTitle = result.quizTitle || title;
43
+ const dataJson = JSON.stringify(result.questions).replace(/</g, '\\u003c');
44
+ const safeTitle = String(quizTitle)
45
+ .replace(/&/g, '&amp;')
46
+ .replace(/</g, '&lt;')
47
+ .replace(/>/g, '&gt;')
48
+ .replace(/"/g, '&quot;')
49
+ .replace(/'/g, '&#39;');
50
+ return `<!DOCTYPE html>
51
+ <html lang="en">
52
+ <head>
53
+ <meta charset="UTF-8">
54
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
55
+ <title>${safeTitle} — Quiz</title>
56
+ <style>
57
+ * { margin: 0; padding: 0; box-sizing: border-box; }
58
+ body { font-family: Georgia, 'Times New Roman', serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 40px 20px; }
59
+ #wrap { max-width: 640px; width: 100%; }
60
+ h1 { font-size: 26px; margin-bottom: 4px; color: #a78bfa; text-align: center; }
61
+ .progress-bar { height: 4px; background: #252547; border-radius: 2px; margin: 16px 0 24px; overflow: hidden; }
62
+ .progress-fill { height: 100%; background: #a78bfa; border-radius: 2px; transition: width 0.3s ease; }
63
+ .progress-text { text-align: center; font-size: 14px; color: #888; margin-bottom: 20px; }
64
+ .question-card { background: #252547; border: 1px solid #3a3a5c; border-radius: 14px; padding: 28px 26px; margin-bottom: 20px; }
65
+ .question-card .stem { font-size: 19px; line-height: 1.5; margin-bottom: 18px; }
66
+ .option { display: flex; gap: 12px; align-items: flex-start; padding: 12px 14px; margin-top: 8px; border: 1px solid transparent; border-radius: 8px; cursor: pointer; transition: all 0.2s; }
67
+ .option:hover { border-color: #3a3a5c; background: rgba(167,139,250,0.05); }
68
+ .option input { margin-top: 3px; accent-color: #a78bfa; cursor: pointer; }
69
+ .option span { line-height: 1.5; font-size: 16px; }
70
+ .option.correct { border-color: #4ade80; background: rgba(74,222,128,0.12); }
71
+ .option.wrong { border-color: #f87171; background: rgba(248,113,113,0.12); }
72
+ .option.selected { border-color: #a78bfa; background: rgba(167,139,250,0.1); }
73
+ .explanation { display: none; margin-top: 12px; padding: 12px 14px; background: rgba(167,139,250,0.1); border-left: 3px solid #a78bfa; color: #c4b5fd; font-size: 15px; border-radius: 0 6px 6px 0; font-style: italic; }
74
+ .score-display { text-align: center; padding: 20px; }
75
+ .score-display .score-num { font-size: 36px; font-weight: 700; color: #a78bfa; }
76
+ .score-display .score-label { font-size: 16px; color: #888; margin-bottom: 20px; }
77
+ .controls { display: flex; gap: 10px; justify-content: center; margin-top: 20px; }
78
+ button { padding: 14px 32px; background: #a78bfa; color: #1a1a2e; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; font-family: inherit; transition: background 0.15s; }
79
+ button:hover:not(:disabled) { background: #8b6fdf; }
80
+ button:disabled { opacity: 0.4; cursor: default; }
81
+ button.secondary { background: #252547; color: #e0e0e0; border: 1px solid #3a3a5c; font-weight: 400; }
82
+ button.secondary:hover:not(:disabled) { border-color: #a78bfa; }
83
+ .meta { text-align: center; color: #555; font-size: 12px; margin-top: 32px; }
84
+ </style>
85
+ </head>
86
+ <body>
87
+ <div id="wrap">
88
+ <h1>${safeTitle}</h1>
89
+ <div id="content"></div>
90
+ </div>
91
+ <script>
92
+ (function () {
93
+ var ALL = ${dataJson};
94
+ var QUIZ_SIZE = 5;
95
+ var deck = [], pos = 0, answers = {}, submitted = false;
96
+ var content = document.getElementById('content');
97
+
98
+ function shuffle(arr) {
99
+ var a = arr.slice();
100
+ for (var i = a.length - 1; i > 0; i--) { var j = Math.floor(Math.random()*(i+1)); var t = a[i]; a[i]=a[j]; a[j]=t; }
101
+ return a;
102
+ }
103
+
104
+ function newDeck() {
105
+ deck = ALL.length <= QUIZ_SIZE ? shuffle(ALL) : shuffle(ALL).slice(0, QUIZ_SIZE);
106
+ pos = 0; answers = {}; submitted = false;
107
+ renderQuestion();
108
+ }
109
+
110
+ function renderQuestion() {
111
+ var q = deck[pos];
112
+ var html = '<div class="progress-bar"><div class="progress-fill" style="width:' + (pos / deck.length * 100) + '%"></div></div>';
113
+ html += '<div class="progress-text">Question ' + (pos + 1) + ' of ' + deck.length + '</div>';
114
+ html += '<div class="question-card"><div class="stem">' + esc(q.stem) + '</div>';
115
+ html += '<div id="options">';
116
+ q.options.forEach(function (opt, oi) {
117
+ var sel = answers[pos] === oi ? ' selected' : '';
118
+ html += '<div class="option' + sel + '" data-oi="' + oi + '"><input type="radio" name="q" id="o' + oi + '"' + (answers[pos] === oi ? ' checked' : '') + '><span>' + letters(oi) + ') ' + esc(opt) + '</span></div>';
119
+ });
120
+ html += '</div>';
121
+ if (q.explanation) html += '<div class="explanation" id="exp">' + esc(q.explanation) + '</div>';
122
+ html += '</div>';
123
+ html += '<div class="controls">';
124
+ if (pos > 0) html += '<button class="secondary" id="prev">&lsaquo; Prev</button>';
125
+ if (pos < deck.length - 1) html += '<button id="next">Next &rsaquo;</button>';
126
+ else html += '<button id="submit">Submit</button>';
127
+ html += '</div>';
128
+ content.innerHTML = html;
129
+
130
+ content.querySelectorAll('.option').forEach(function (el) {
131
+ el.addEventListener('click', function () {
132
+ if (submitted) return;
133
+ answers[pos] = parseInt(el.dataset.oi);
134
+ content.querySelectorAll('.option').forEach(function (e) { e.classList.remove('selected'); });
135
+ el.classList.add('selected');
136
+ el.querySelector('input').checked = true;
137
+ });
138
+ });
139
+ var prev = document.getElementById('prev'); if (prev) prev.addEventListener('click', function () { pos--; renderQuestion(); });
140
+ var next = document.getElementById('next'); if (next) next.addEventListener('click', function () { pos++; renderQuestion(); });
141
+ var sub = document.getElementById('submit'); if (sub) sub.addEventListener('click', grade);
142
+ window.scrollTo({ top: 0, behavior: 'smooth' });
143
+ }
144
+
145
+ function grade() {
146
+ submitted = true;
147
+ var score = 0;
148
+ deck.forEach(function (q, qi) { if (answers[qi] === q.correctIndex) score++; });
149
+
150
+ var html = '<div class="score-display"><div class="score-num">' + score + ' / ' + deck.length + '</div><div class="score-label">Score</div></div>';
151
+ deck.forEach(function (q, qi) {
152
+ var chosen = answers[qi];
153
+ var correct = chosen === q.correctIndex;
154
+ html += '<div class="question-card"><div class="stem">' + (qi+1) + '. ' + esc(q.stem) + '</div>';
155
+ q.options.forEach(function (opt, oi) {
156
+ var cls = 'option';
157
+ if (oi === q.correctIndex) cls += ' correct';
158
+ else if (oi === chosen) cls += ' wrong';
159
+ html += '<div class="' + cls + '"><span>' + letters(oi) + ') ' + esc(opt);
160
+ if (oi === q.correctIndex) html += ' \\u2705';
161
+ html += '</span></div>';
162
+ });
163
+ if (q.explanation) html += '<div class="explanation" style="display:block">' + esc(q.explanation) + '</div>';
164
+ html += '</div>';
165
+ });
166
+ html += '<div class="controls"><button id="again">Try again (new questions)</button></div>';
167
+ content.innerHTML = html;
168
+ document.getElementById('again').addEventListener('click', newDeck);
169
+ window.scrollTo({ top: 0, behavior: 'smooth' });
170
+ }
171
+
172
+ function letters(i) { return 'ABCDEFGHIJ'[i] || (i+1); }
173
+ function esc(t) { var d = document.createElement('div'); d.textContent = String(t||''); return d.innerHTML; }
174
+
175
+ newDeck();
176
+ var meta = document.createElement('div'); meta.className = 'meta'; meta.textContent = 'Created with Playable Lessons';
177
+ document.getElementById('wrap').appendChild(meta);
178
+ })();
179
+ </script>
180
+ </body>
181
+ </html>`;
182
+ }