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.
- package/README.md +198 -6
- package/out/cli/index.js +415 -37
- package/out/shared/aiTaskExport.js +161 -0
- package/out/shared/caseStudyExport.js +115 -0
- package/out/shared/flashcardExport.js +161 -0
- package/out/shared/generate.js +490 -11
- package/out/shared/h5pExporter.js +286 -0
- package/out/shared/imageClient.js +227 -0
- package/out/shared/inkParser.js +187 -0
- package/out/shared/planExport.js +110 -0
- package/out/shared/project.js +12 -0
- package/out/shared/prompts.js +433 -1
- package/out/shared/quizExport.js +182 -0
- package/out/shared/storyExport.js +53 -4
- package/out/shared/summaryExport.js +144 -0
- package/out/shared/version.generated.js +7 -0
- package/package.json +7 -4
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* AI-collaboration task export formats, shared by the renderer and the CLI.
|
|
4
|
+
*
|
|
5
|
+
* Standalone (no runtime imports from sibling shared modules) — only the
|
|
6
|
+
* AiTaskResult *type* is imported, which is erased at compile time, so it
|
|
7
|
+
* resolves 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
|
+
/**
|
|
13
|
+
* A printable task sheet: for each task, the scenario, student brief,
|
|
14
|
+
* deliverable, load-bearing specifics (with why a generic answer misses them),
|
|
15
|
+
* the engagement-anchored rubric, and why a delegating learner loses.
|
|
16
|
+
*/
|
|
17
|
+
function toPlainText(result, title) {
|
|
18
|
+
const heading = result.title || title || 'AI-Collaboration Tasks';
|
|
19
|
+
const lines = [heading, ''];
|
|
20
|
+
result.tasks.forEach((t, i) => {
|
|
21
|
+
lines.push(`Task ${i + 1}`);
|
|
22
|
+
lines.push(`Scenario: ${t.scenario}`);
|
|
23
|
+
lines.push(`Brief: ${t.brief}`);
|
|
24
|
+
lines.push(`Deliverable: ${t.deliverable}`);
|
|
25
|
+
lines.push('Load-bearing specifics:');
|
|
26
|
+
t.loadBearingSpecifics.forEach((s) => {
|
|
27
|
+
lines.push(` - ${s.detail} — ${s.whyGenericAnswersMissIt}`);
|
|
28
|
+
});
|
|
29
|
+
lines.push('Rubric (engagement-anchored):');
|
|
30
|
+
t.rubric.forEach((r) => {
|
|
31
|
+
lines.push(` - ${r.criterion}: ${r.description}`);
|
|
32
|
+
});
|
|
33
|
+
lines.push(`Why it works: ${t.whyItWorks}`);
|
|
34
|
+
lines.push('');
|
|
35
|
+
});
|
|
36
|
+
return lines.join('\n') + '\n';
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A self-contained, offline HTML task sheet. All text is rendered via
|
|
40
|
+
* textContent (no innerHTML); the embedded JSON is guarded against breaking out
|
|
41
|
+
* of its <script> by escaping '<' to '\u003c'.
|
|
42
|
+
*/
|
|
43
|
+
function toStandaloneHTML(result, title) {
|
|
44
|
+
const pageTitle = result.title || title;
|
|
45
|
+
const dataJson = JSON.stringify(result).replace(/</g, '\\u003c');
|
|
46
|
+
const safeTitle = String(pageTitle)
|
|
47
|
+
.replace(/&/g, '&')
|
|
48
|
+
.replace(/</g, '<')
|
|
49
|
+
.replace(/>/g, '>')
|
|
50
|
+
.replace(/"/g, '"')
|
|
51
|
+
.replace(/'/g, ''');
|
|
52
|
+
return `<!DOCTYPE html>
|
|
53
|
+
<html lang="en">
|
|
54
|
+
<head>
|
|
55
|
+
<meta charset="UTF-8">
|
|
56
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
57
|
+
<title>${safeTitle} — AI-Collaboration Tasks</title>
|
|
58
|
+
<style>
|
|
59
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
60
|
+
body { font-family: Georgia, 'Times New Roman', serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; display: flex; justify-content: center; padding: 40px 20px; line-height: 1.7; }
|
|
61
|
+
#sheet { max-width: 760px; width: 100%; }
|
|
62
|
+
h1 { font-size: 26px; color: #a78bfa; margin-bottom: 8px; }
|
|
63
|
+
.lede { font-size: 14px; color: #888; margin-bottom: 28px; }
|
|
64
|
+
.task { background: #252547; border: 1px solid #3a3a5c; border-radius: 14px; padding: 24px 26px; margin-bottom: 22px; }
|
|
65
|
+
.task-no { font-size: 13px; letter-spacing: 0.06em; text-transform: uppercase; color: #a78bfa; font-weight: 700; margin-bottom: 6px; }
|
|
66
|
+
h2 { font-size: 16px; color: #a78bfa; margin: 18px 0 6px; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
67
|
+
p { font-size: 16px; color: #cfcfe6; }
|
|
68
|
+
.brief { font-size: 17px; background: rgba(167,139,250,0.08); border-left: 3px solid #a78bfa; padding: 12px 14px; border-radius: 0 6px 6px 0; }
|
|
69
|
+
ul { list-style: none; }
|
|
70
|
+
ul li { padding: 6px 0 6px 18px; position: relative; font-size: 15px; color: #cfcfe6; }
|
|
71
|
+
ul li::before { content: '•'; position: absolute; left: 0; color: #a78bfa; }
|
|
72
|
+
.why { font-size: 14px; color: #6b8; font-style: italic; }
|
|
73
|
+
.specifics li .why, .rubric li .why { display: block; color: #9aa; font-size: 14px; }
|
|
74
|
+
.works { font-size: 15px; color: #c4b5fd; font-style: italic; margin-top: 6px; }
|
|
75
|
+
.meta { text-align: center; color: #555; font-size: 12px; margin-top: 32px; }
|
|
76
|
+
</style>
|
|
77
|
+
</head>
|
|
78
|
+
<body>
|
|
79
|
+
<div id="sheet">
|
|
80
|
+
<h1 id="title"></h1>
|
|
81
|
+
<p class="lede">The learner works iteratively with a chatbot; interrogating it — not pasting its first answer — is what produces a good result.</p>
|
|
82
|
+
<div id="tasks"></div>
|
|
83
|
+
</div>
|
|
84
|
+
<script>
|
|
85
|
+
(function () {
|
|
86
|
+
var r = ${dataJson};
|
|
87
|
+
document.getElementById('title').textContent = r.title || 'AI-Collaboration Tasks';
|
|
88
|
+
var host = document.getElementById('tasks');
|
|
89
|
+
(r.tasks || []).forEach(function (t, i) {
|
|
90
|
+
var card = document.createElement('div');
|
|
91
|
+
card.className = 'task';
|
|
92
|
+
|
|
93
|
+
var no = document.createElement('div');
|
|
94
|
+
no.className = 'task-no';
|
|
95
|
+
no.textContent = 'Task ' + (i + 1);
|
|
96
|
+
card.appendChild(no);
|
|
97
|
+
|
|
98
|
+
var h = function (label) { var e = document.createElement('h2'); e.textContent = label; return e; };
|
|
99
|
+
var para = function (txt, cls) { var e = document.createElement('p'); e.textContent = txt; if (cls) e.className = cls; return e; };
|
|
100
|
+
|
|
101
|
+
card.appendChild(h('Scenario'));
|
|
102
|
+
card.appendChild(para(t.scenario));
|
|
103
|
+
card.appendChild(h('Your brief'));
|
|
104
|
+
card.appendChild(para(t.brief, 'brief'));
|
|
105
|
+
card.appendChild(h('Deliverable'));
|
|
106
|
+
card.appendChild(para(t.deliverable));
|
|
107
|
+
|
|
108
|
+
if (t.loadBearingSpecifics && t.loadBearingSpecifics.length) {
|
|
109
|
+
card.appendChild(h('Load-bearing specifics'));
|
|
110
|
+
var ul = document.createElement('ul');
|
|
111
|
+
ul.className = 'specifics';
|
|
112
|
+
t.loadBearingSpecifics.forEach(function (s) {
|
|
113
|
+
var li = document.createElement('li');
|
|
114
|
+
var main = document.createElement('span');
|
|
115
|
+
main.textContent = s.detail;
|
|
116
|
+
var why = document.createElement('span');
|
|
117
|
+
why.className = 'why';
|
|
118
|
+
why.textContent = 'Generic answers miss it: ' + s.whyGenericAnswersMissIt;
|
|
119
|
+
li.appendChild(main);
|
|
120
|
+
li.appendChild(why);
|
|
121
|
+
ul.appendChild(li);
|
|
122
|
+
});
|
|
123
|
+
card.appendChild(ul);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (t.rubric && t.rubric.length) {
|
|
127
|
+
card.appendChild(h('Rubric (engagement-anchored)'));
|
|
128
|
+
var rl = document.createElement('ul');
|
|
129
|
+
rl.className = 'rubric';
|
|
130
|
+
t.rubric.forEach(function (rb) {
|
|
131
|
+
var li = document.createElement('li');
|
|
132
|
+
var main = document.createElement('span');
|
|
133
|
+
main.textContent = rb.criterion;
|
|
134
|
+
var why = document.createElement('span');
|
|
135
|
+
why.className = 'why';
|
|
136
|
+
why.textContent = rb.description;
|
|
137
|
+
li.appendChild(main);
|
|
138
|
+
li.appendChild(why);
|
|
139
|
+
rl.appendChild(li);
|
|
140
|
+
});
|
|
141
|
+
card.appendChild(rl);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
card.appendChild(h('Why it works'));
|
|
145
|
+
var w = document.createElement('p');
|
|
146
|
+
w.className = 'works';
|
|
147
|
+
w.textContent = t.whyItWorks;
|
|
148
|
+
card.appendChild(w);
|
|
149
|
+
|
|
150
|
+
host.appendChild(card);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
var meta = document.createElement('div');
|
|
154
|
+
meta.className = 'meta';
|
|
155
|
+
meta.textContent = 'Created with Playable Lessons';
|
|
156
|
+
document.getElementById('sheet').appendChild(meta);
|
|
157
|
+
})();
|
|
158
|
+
</script>
|
|
159
|
+
</body>
|
|
160
|
+
</html>`;
|
|
161
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Case-study export formats, shared by the renderer and the CLI.
|
|
4
|
+
*
|
|
5
|
+
* Standalone (no runtime imports from sibling shared modules) — only the
|
|
6
|
+
* CaseStudyResult *type* is imported, which is erased at compile time, so it
|
|
7
|
+
* resolves 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
|
+
/** A printable case study: narrative (if present) then the structured fields. */
|
|
13
|
+
function toPlainText(result, title) {
|
|
14
|
+
const cs = result.caseStudy;
|
|
15
|
+
const heading = result.title || title || 'Case Study';
|
|
16
|
+
const lines = [heading, ''];
|
|
17
|
+
if (cs.narrative) {
|
|
18
|
+
lines.push(cs.narrative, '');
|
|
19
|
+
}
|
|
20
|
+
if (cs.protagonist)
|
|
21
|
+
lines.push(`Protagonist: ${cs.protagonist}`);
|
|
22
|
+
if (cs.situation)
|
|
23
|
+
lines.push(`Situation: ${cs.situation}`);
|
|
24
|
+
if (cs.keyFacts.length) {
|
|
25
|
+
lines.push('Key facts:');
|
|
26
|
+
cs.keyFacts.forEach((f) => lines.push(` - ${f}`));
|
|
27
|
+
}
|
|
28
|
+
if (cs.conflict)
|
|
29
|
+
lines.push(`Conflict: ${cs.conflict}`);
|
|
30
|
+
if (cs.decisionPoints.length) {
|
|
31
|
+
lines.push('Decision points:');
|
|
32
|
+
cs.decisionPoints.forEach((d) => lines.push(` - ${d}`));
|
|
33
|
+
}
|
|
34
|
+
if (cs.discussionQuestions.length) {
|
|
35
|
+
lines.push('Discussion questions:');
|
|
36
|
+
cs.discussionQuestions.forEach((q) => lines.push(` - ${q}`));
|
|
37
|
+
}
|
|
38
|
+
return lines.join('\n') + '\n';
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A self-contained, offline HTML case study. Renders the narrative (if present)
|
|
42
|
+
* as prose, then structured sections. Text via textContent (no innerHTML); the
|
|
43
|
+
* embedded JSON is guarded against breaking out of its <script> by escaping
|
|
44
|
+
* '<' to '\u003c'.
|
|
45
|
+
*/
|
|
46
|
+
function toStandaloneHTML(result, title) {
|
|
47
|
+
const pageTitle = result.title || title;
|
|
48
|
+
const dataJson = JSON.stringify(result.caseStudy).replace(/</g, '\\u003c');
|
|
49
|
+
const safeTitle = String(pageTitle)
|
|
50
|
+
.replace(/&/g, '&')
|
|
51
|
+
.replace(/</g, '<')
|
|
52
|
+
.replace(/>/g, '>')
|
|
53
|
+
.replace(/"/g, '"')
|
|
54
|
+
.replace(/'/g, ''');
|
|
55
|
+
return `<!DOCTYPE html>
|
|
56
|
+
<html lang="en">
|
|
57
|
+
<head>
|
|
58
|
+
<meta charset="UTF-8">
|
|
59
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
60
|
+
<title>${safeTitle} — Case Study</title>
|
|
61
|
+
<style>
|
|
62
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
63
|
+
body { font-family: Georgia, 'Times New Roman', serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; display: flex; justify-content: center; padding: 40px 20px; line-height: 1.7; }
|
|
64
|
+
#sheet { max-width: 740px; width: 100%; }
|
|
65
|
+
h1 { font-size: 28px; color: #a78bfa; margin-bottom: 18px; }
|
|
66
|
+
.narrative { font-size: 17px; color: #cfcfe6; margin-bottom: 24px; white-space: pre-wrap; }
|
|
67
|
+
h2 { font-size: 15px; color: #a78bfa; margin: 22px 0 8px; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
68
|
+
.field { font-size: 16px; color: #cfcfe6; margin-bottom: 10px; }
|
|
69
|
+
ul { list-style: none; }
|
|
70
|
+
ul li { padding: 5px 0 5px 18px; position: relative; font-size: 16px; color: #cfcfe6; }
|
|
71
|
+
ul li::before { content: '•'; position: absolute; left: 0; color: #a78bfa; }
|
|
72
|
+
.meta { text-align: center; color: #555; font-size: 12px; margin-top: 36px; }
|
|
73
|
+
</style>
|
|
74
|
+
</head>
|
|
75
|
+
<body>
|
|
76
|
+
<div id="sheet">
|
|
77
|
+
<h1 id="title"></h1>
|
|
78
|
+
<div id="body"></div>
|
|
79
|
+
</div>
|
|
80
|
+
<script>
|
|
81
|
+
(function () {
|
|
82
|
+
var cs = ${dataJson};
|
|
83
|
+
document.getElementById('title').textContent = cs.title || 'Case Study';
|
|
84
|
+
var body = document.getElementById('body');
|
|
85
|
+
|
|
86
|
+
function para(txt, cls) { var e = document.createElement('p'); e.textContent = txt; if (cls) e.className = cls; return e; }
|
|
87
|
+
function heading(label) { var e = document.createElement('h2'); e.textContent = label; return e; }
|
|
88
|
+
function list(items) {
|
|
89
|
+
var ul = document.createElement('ul');
|
|
90
|
+
items.forEach(function (it) { var li = document.createElement('li'); li.textContent = it; ul.appendChild(li); });
|
|
91
|
+
return ul;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (cs.narrative) {
|
|
95
|
+
var n = document.createElement('div');
|
|
96
|
+
n.className = 'narrative';
|
|
97
|
+
n.textContent = cs.narrative;
|
|
98
|
+
body.appendChild(n);
|
|
99
|
+
}
|
|
100
|
+
if (cs.protagonist) { body.appendChild(heading('Protagonist')); body.appendChild(para(cs.protagonist, 'field')); }
|
|
101
|
+
if (cs.situation) { body.appendChild(heading('Situation')); body.appendChild(para(cs.situation, 'field')); }
|
|
102
|
+
if (cs.keyFacts && cs.keyFacts.length) { body.appendChild(heading('Key facts')); body.appendChild(list(cs.keyFacts)); }
|
|
103
|
+
if (cs.conflict) { body.appendChild(heading('The conflict')); body.appendChild(para(cs.conflict, 'field')); }
|
|
104
|
+
if (cs.decisionPoints && cs.decisionPoints.length) { body.appendChild(heading('Decision points')); body.appendChild(list(cs.decisionPoints)); }
|
|
105
|
+
if (cs.discussionQuestions && cs.discussionQuestions.length) { body.appendChild(heading('Discussion questions')); body.appendChild(list(cs.discussionQuestions)); }
|
|
106
|
+
|
|
107
|
+
var meta = document.createElement('div');
|
|
108
|
+
meta.className = 'meta';
|
|
109
|
+
meta.textContent = 'Created with Playable Lessons';
|
|
110
|
+
document.getElementById('sheet').appendChild(meta);
|
|
111
|
+
})();
|
|
112
|
+
</script>
|
|
113
|
+
</body>
|
|
114
|
+
</html>`;
|
|
115
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Flashcard export formats, shared by the renderer and the CLI.
|
|
4
|
+
*
|
|
5
|
+
* Standalone (no runtime imports from sibling shared modules) — it only needs
|
|
6
|
+
* the FlashcardResult *type*, which is erased at compile time, so it resolves
|
|
7
|
+
* cleanly under both bundler (renderer) and NodeNext (CLI) module resolution.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.toCSV = toCSV;
|
|
11
|
+
exports.toAnkiTSV = toAnkiTSV;
|
|
12
|
+
exports.toStandaloneHTML = toStandaloneHTML;
|
|
13
|
+
/** Quote a CSV field per RFC 4180 when it contains a comma, quote, or newline. */
|
|
14
|
+
function csvField(value) {
|
|
15
|
+
const needsQuote = /[",\n\r]/.test(value);
|
|
16
|
+
const escaped = value.replace(/"/g, '""');
|
|
17
|
+
return needsQuote ? `"${escaped}"` : escaped;
|
|
18
|
+
}
|
|
19
|
+
/** CSV with a header row: front,back,hint,tag (imports into Anki, Quizlet, Excel). */
|
|
20
|
+
function toCSV(result) {
|
|
21
|
+
const header = 'front,back,hint,tag';
|
|
22
|
+
const rows = result.cards.map((c) => [csvField(c.front), csvField(c.back), csvField(c.hint ?? ''), csvField(c.tag ?? '')].join(','));
|
|
23
|
+
return [header, ...rows].join('\n') + '\n';
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Anki-importable TSV: three columns — front <TAB> back <TAB> tag (empty when a
|
|
27
|
+
* card has no tag). Anki renders fields as HTML, so newlines become <br> and
|
|
28
|
+
* tabs (which would split columns) are collapsed to spaces. Hints are omitted
|
|
29
|
+
* here — use CSV or the HTML deck for hints.
|
|
30
|
+
*/
|
|
31
|
+
function toAnkiTSV(result) {
|
|
32
|
+
const clean = (s) => s.replace(/\t/g, ' ').replace(/\r?\n/g, '<br>');
|
|
33
|
+
return (result.cards
|
|
34
|
+
.map((c) => [clean(c.front), clean(c.back), clean(c.tag ?? '')].join('\t'))
|
|
35
|
+
.join('\n') + '\n');
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A self-contained, offline HTML flip-card deck: click/Space to flip, arrow keys
|
|
39
|
+
* to navigate, plus shuffle and restart. Card text is rendered via textContent
|
|
40
|
+
* (no innerHTML), and the embedded card JSON is guarded against breaking out of
|
|
41
|
+
* its <script> by escaping '<' to '\u003c'.
|
|
42
|
+
*/
|
|
43
|
+
function toStandaloneHTML(result, title) {
|
|
44
|
+
const deckTitle = result.deckTitle || title;
|
|
45
|
+
const cardsJson = JSON.stringify(result.cards).replace(/</g, '\\u003c');
|
|
46
|
+
const safeTitle = String(deckTitle)
|
|
47
|
+
.replace(/&/g, '&')
|
|
48
|
+
.replace(/</g, '<')
|
|
49
|
+
.replace(/>/g, '>')
|
|
50
|
+
.replace(/"/g, '"')
|
|
51
|
+
.replace(/'/g, ''');
|
|
52
|
+
return `<!DOCTYPE html>
|
|
53
|
+
<html lang="en">
|
|
54
|
+
<head>
|
|
55
|
+
<meta charset="UTF-8">
|
|
56
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
57
|
+
<title>${safeTitle} \u2014 Flashcards</title>
|
|
58
|
+
<style>
|
|
59
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
60
|
+
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; }
|
|
61
|
+
#deck { max-width: 640px; width: 100%; }
|
|
62
|
+
h1 { font-size: 26px; margin-bottom: 8px; color: #a78bfa; text-align: center; }
|
|
63
|
+
.progress { text-align: center; color: #888; font-size: 14px; margin-bottom: 8px; }
|
|
64
|
+
.progress-bar { height: 4px; background: #252547; border-radius: 2px; margin-bottom: 20px; overflow: hidden; }
|
|
65
|
+
.progress-fill { height: 100%; background: #a78bfa; border-radius: 2px; transition: width 0.3s ease; }
|
|
66
|
+
.card { background: #252547; border: 1px solid #3a3a5c; border-radius: 14px; min-height: 220px; padding: 32px 28px; display: flex; flex-direction: column; justify-content: center; cursor: pointer; transition: border-color 0.15s, transform 0.1s; user-select: none; outline: none; }
|
|
67
|
+
.card:hover, .card:focus { border-color: #a78bfa; }
|
|
68
|
+
.card:active { transform: scale(0.99); }
|
|
69
|
+
.face-label { font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: #6b6b8f; margin-bottom: 12px; }
|
|
70
|
+
.face-text { font-size: 20px; line-height: 1.6; }
|
|
71
|
+
.hint { margin-top: 16px; font-size: 15px; color: #a78bfa; font-style: italic; }
|
|
72
|
+
.controls { display: flex; gap: 10px; justify-content: center; margin-top: 24px; flex-wrap: wrap; }
|
|
73
|
+
button { padding: 12px 22px; background: #252547; border: 1px solid #3a3a5c; border-radius: 8px; color: #e0e0e0; cursor: pointer; font-size: 15px; font-family: inherit; transition: all 0.15s; }
|
|
74
|
+
button:hover:not(:disabled) { border-color: #a78bfa; background: rgba(167,139,250,0.1); }
|
|
75
|
+
button:disabled { opacity: 0.4; cursor: default; }
|
|
76
|
+
button.primary { background: #a78bfa; color: #1a1a2e; border-color: #a78bfa; font-weight: 600; }
|
|
77
|
+
button.primary:hover:not(:disabled) { background: #8b6fdf; }
|
|
78
|
+
.keys { text-align: center; color: #555; font-size: 12px; margin-top: 20px; }
|
|
79
|
+
.meta { text-align: center; color: #555; font-size: 12px; margin-top: 32px; }
|
|
80
|
+
</style>
|
|
81
|
+
</head>
|
|
82
|
+
<body>
|
|
83
|
+
<div id="deck">
|
|
84
|
+
<h1>${safeTitle}</h1>
|
|
85
|
+
<div class="progress" id="progress"></div>
|
|
86
|
+
<div class="progress-bar"><div class="progress-fill" id="pfill" style="width:0%"></div></div>
|
|
87
|
+
<div class="card" id="card" tabindex="0" role="button" aria-label="Flashcard \u2014 click or press Space to flip"></div>
|
|
88
|
+
<div class="controls">
|
|
89
|
+
<button id="prev">‹ Prev</button>
|
|
90
|
+
<button id="flip" class="primary">Flip</button>
|
|
91
|
+
<button id="next">Next ›</button>
|
|
92
|
+
<button id="shuffle">Shuffle</button>
|
|
93
|
+
<button id="newdeck" class="primary">New deck</button>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="keys">Space = flip · ← → = navigate</div>
|
|
96
|
+
</div>
|
|
97
|
+
<script>
|
|
98
|
+
(function () {
|
|
99
|
+
var ALL = ${cardsJson};
|
|
100
|
+
var DECK_SIZE = 5;
|
|
101
|
+
var deck = [], idx = 0, flipped = false;
|
|
102
|
+
var cardEl = document.getElementById('card');
|
|
103
|
+
var progressEl = document.getElementById('progress');
|
|
104
|
+
var fillEl = document.getElementById('pfill');
|
|
105
|
+
|
|
106
|
+
function shuffleArr(arr) {
|
|
107
|
+
var a = arr.slice();
|
|
108
|
+
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; }
|
|
109
|
+
return a;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function newDeck() {
|
|
113
|
+
deck = ALL.length <= DECK_SIZE ? shuffleArr(ALL) : shuffleArr(ALL).slice(0, DECK_SIZE);
|
|
114
|
+
idx = 0; flipped = false; render();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function render() {
|
|
118
|
+
var c = deck[idx]; if (!c) return;
|
|
119
|
+
cardEl.innerHTML = '';
|
|
120
|
+
var label = document.createElement('div');
|
|
121
|
+
label.className = 'face-label';
|
|
122
|
+
label.textContent = flipped ? 'Back' : 'Front';
|
|
123
|
+
var text = document.createElement('div');
|
|
124
|
+
text.className = 'face-text';
|
|
125
|
+
text.textContent = flipped ? c.back : c.front;
|
|
126
|
+
cardEl.appendChild(label); cardEl.appendChild(text);
|
|
127
|
+
if (flipped && c.hint) {
|
|
128
|
+
var hint = document.createElement('div'); hint.className = 'hint';
|
|
129
|
+
hint.textContent = 'Hint: ' + c.hint; cardEl.appendChild(hint);
|
|
130
|
+
}
|
|
131
|
+
progressEl.textContent = 'Card ' + (idx + 1) + ' of ' + deck.length;
|
|
132
|
+
fillEl.style.width = ((idx + 1) / deck.length * 100) + '%';
|
|
133
|
+
document.getElementById('prev').disabled = idx === 0;
|
|
134
|
+
document.getElementById('next').disabled = idx === deck.length - 1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function flip() { flipped = !flipped; render(); }
|
|
138
|
+
function go(n) { var j = idx + n; if (j < 0 || j >= deck.length) return; idx = j; flipped = false; render(); }
|
|
139
|
+
function shuffleDeck() { deck = shuffleArr(deck); idx = 0; flipped = false; render(); }
|
|
140
|
+
|
|
141
|
+
cardEl.addEventListener('click', flip);
|
|
142
|
+
document.getElementById('flip').addEventListener('click', flip);
|
|
143
|
+
document.getElementById('prev').addEventListener('click', function () { go(-1); });
|
|
144
|
+
document.getElementById('next').addEventListener('click', function () { go(1); });
|
|
145
|
+
document.getElementById('shuffle').addEventListener('click', shuffleDeck);
|
|
146
|
+
document.getElementById('newdeck').addEventListener('click', newDeck);
|
|
147
|
+
document.addEventListener('keydown', function (e) {
|
|
148
|
+
if (e.key === ' ') { e.preventDefault(); flip(); }
|
|
149
|
+
else if (e.key === 'ArrowLeft') { go(-1); }
|
|
150
|
+
else if (e.key === 'ArrowRight') { go(1); }
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
newDeck();
|
|
154
|
+
var meta = document.createElement('div'); meta.className = 'meta';
|
|
155
|
+
meta.textContent = 'Created with Playable Lessons';
|
|
156
|
+
document.getElementById('deck').appendChild(meta);
|
|
157
|
+
})();
|
|
158
|
+
</script>
|
|
159
|
+
</body>
|
|
160
|
+
</html>`;
|
|
161
|
+
}
|