@docyrus/animaguru 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/dist/animaguru.js +101 -10
- package/package.json +1 -1
- package/skills/animaguru/SKILL.md +8 -0
- package/skills/animaguru/references/sharing.md +3 -0
- package/skills/animaguru-branching-story/SKILL.md +72 -0
- package/skills/animaguru-general-video/SKILL.md +1 -0
- package/skills/animaguru-general-video/references/branching-story.md +13 -0
- package/skills/animaguru-quiz/SKILL.md +80 -0
- package/skills/animaguru-video/SKILL.md +9 -1
- package/templates/video/AGENTS.md +2 -0
- package/templates/video/README.md +8 -0
- package/templates/video/WORKSPACE-AGENTS.md +3 -1
- package/templates/video/scripts/build.mjs +4 -1
- package/templates/video/scripts/export-mp4.mjs +2 -0
- package/templates/video/scripts/html-export.mjs +12 -4
- package/templates/video/scripts/project-context.mjs +20 -3
- package/templates/video/scripts/quiz.d.mts +31 -0
- package/templates/video/scripts/quiz.mjs +126 -0
- package/templates/video/scripts/review.mjs +9 -1
- package/templates/video/scripts/story.d.mts +36 -0
- package/templates/video/scripts/story.mjs +139 -0
- package/templates/video/scripts/workspace-plugin.mjs +9 -4
- package/templates/video/shared/locales/en/player.json +29 -1
- package/templates/video/src/kit/index.ts +1 -0
- package/templates/video/src/kit/story.ts +19 -0
- package/templates/video/src/main.ts +6 -1
- package/templates/video/src/player-types.ts +6 -0
- package/templates/video/src/player.ts +193 -17
- package/templates/video/src/quiz.ts +315 -0
- package/templates/video/src/story.ts +112 -0
- package/templates/video/src/style.css +87 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { checkpointSegment, QUIZ_UI_KEYS, validateQuiz, type Quiz, type QuizCheckpoint, type QuizQuestion, type QuizUiKey } from "../scripts/quiz.mjs";
|
|
2
|
+
import type { StorySegment } from "./story";
|
|
3
|
+
import type { Branding } from "./player-types";
|
|
4
|
+
|
|
5
|
+
export type { Quiz, QuizCheckpoint, QuizQuestion };
|
|
6
|
+
export type QuizCopy = Record<QuizUiKey, string>;
|
|
7
|
+
type Answer = { chosen: number[]; correct: boolean };
|
|
8
|
+
/** Where the quiz is: a checkpoint's question, the final quiz's question, or the score. */
|
|
9
|
+
export type QuizPanel = { kind: "checkpoint"; checkpoint: QuizCheckpoint; index: number } | { kind: "final"; index: number } | { kind: "results" };
|
|
10
|
+
export type QuizMark = "right" | "wrong" | "skipped";
|
|
11
|
+
export type QuizScore = { correct: number; total: number; skipped: number; marks: QuizMark[]; pass: number | null; passed: boolean | null };
|
|
12
|
+
/** What the card over the picture shows. */
|
|
13
|
+
export type QuizView =
|
|
14
|
+
| { kind: "none" }
|
|
15
|
+
| { kind: "question"; key: string; title: string | undefined; final: boolean; number: number; count: number; question: QuizQuestion; selection: number[]; answer: Answer | null; watchAgain: boolean }
|
|
16
|
+
| { kind: "results"; score: QuizScore; retake: boolean; story: boolean };
|
|
17
|
+
|
|
18
|
+
const EPSILON = 1e-6;
|
|
19
|
+
const fill = (template: string, values: Record<string, string | number>) => template.replace(/\{(\w+)\}/g, (match, name: string) => name in values ? String(values[name]) : match);
|
|
20
|
+
|
|
21
|
+
/** The cards' words: quiz.json's ui where it sets them, the player's (quizCheck, quizFinal…) otherwise. */
|
|
22
|
+
export function quizCopy(defaults: Record<string, string>, ui: Quiz["ui"] = {}): QuizCopy {
|
|
23
|
+
return Object.fromEntries(QUIZ_UI_KEYS.map((key) => [key, ui[key] ?? defaults[`quiz${key[0].toUpperCase()}${key.slice(1)}`]])) as QuizCopy;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A viewer's way through the quiz: which checkpoint is due as the film plays, the question showing, the answers and
|
|
28
|
+
* the score. A checkpoint asks once; Watch again asks it again, and its new answers replace the old. The score covers
|
|
29
|
+
* the questions on the viewer's path: every checkpoint of a linear film, those of the story segments visited, then the
|
|
30
|
+
* final quiz once it is done.
|
|
31
|
+
*/
|
|
32
|
+
export class QuizRun {
|
|
33
|
+
panel: QuizPanel | null = null;
|
|
34
|
+
private readonly selection = new Set<number>();
|
|
35
|
+
private readonly answers = new Map<string, Answer>();
|
|
36
|
+
private readonly armed = new Set<string>();
|
|
37
|
+
private finalDone = false;
|
|
38
|
+
readonly checkpoints: QuizCheckpoint[];
|
|
39
|
+
private readonly segmentOf = new Map<string, StorySegment>();
|
|
40
|
+
|
|
41
|
+
constructor(readonly quiz: Quiz, duration: number, private readonly story?: { segments: StorySegment[]; visited: Set<string> }) {
|
|
42
|
+
validateQuiz(quiz, { duration }, story ? { segments: story.segments } : null);
|
|
43
|
+
this.checkpoints = quiz.checkpoints ?? [];
|
|
44
|
+
if (story) for (const checkpoint of this.checkpoints) this.segmentOf.set(checkpoint.id, checkpointSegment(checkpoint, story.segments)!);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Each question's id: its own, or its place ("orbit/1", "final/2"; ids cannot contain a slash). */
|
|
48
|
+
private keys(where: QuizCheckpoint | "final") {
|
|
49
|
+
const [name, questions] = where === "final" ? ["final", this.quiz.final?.questions ?? []] as const : [where.id, where.questions] as const;
|
|
50
|
+
return questions.map((question, index) => question.id ?? `${name}/${index + 1}`);
|
|
51
|
+
}
|
|
52
|
+
private get current() {
|
|
53
|
+
const panel = this.panel;
|
|
54
|
+
if (!panel || panel.kind === "results") return null;
|
|
55
|
+
const [questions, keys] = panel.kind === "final" ? [this.quiz.final!.questions, this.keys("final")] : [panel.checkpoint.questions, this.keys(panel.checkpoint)];
|
|
56
|
+
return { question: questions[panel.index], key: keys[panel.index], count: questions.length };
|
|
57
|
+
}
|
|
58
|
+
/** A question is showing: the film waits for its answer. */
|
|
59
|
+
get asking() { return this.current !== null; }
|
|
60
|
+
/** The question showing has its answer. */
|
|
61
|
+
get answered() { const current = this.current; return Boolean(current && this.answers.has(current.key)); }
|
|
62
|
+
/** The answer to the question showing, as the player's quiz events report it. */
|
|
63
|
+
get lastAnswer() {
|
|
64
|
+
const current = this.current; const answer = current && this.answers.get(current.key);
|
|
65
|
+
return current && answer ? { id: current.key, prompt: current.question.prompt, chosen: answer.chosen, correct: answer.correct } : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The checkpoint the film reaches playing from `from` to `to`, while it still has to ask; within a story segment's span. */
|
|
69
|
+
due(from: number, to: number, span?: { start: number; end: number }) {
|
|
70
|
+
return this.checkpoints.find((checkpoint) => checkpoint.at > from + EPSILON && checkpoint.at <= to + EPSILON
|
|
71
|
+
&& (!span || (checkpoint.at > span.start + EPSILON && checkpoint.at <= span.end + EPSILON))
|
|
72
|
+
&& (this.armed.has(checkpoint.id) || !this.keys(checkpoint).every((key) => this.answers.has(key)))) ?? null;
|
|
73
|
+
}
|
|
74
|
+
open(checkpoint: QuizCheckpoint) {
|
|
75
|
+
this.armed.delete(checkpoint.id);
|
|
76
|
+
for (const key of this.keys(checkpoint)) this.answers.delete(key);
|
|
77
|
+
this.show({ kind: "checkpoint", checkpoint, index: 0 });
|
|
78
|
+
}
|
|
79
|
+
/** At the film's end: the final quiz until it is done, then the score; false when no question was on the viewer's path. */
|
|
80
|
+
openEnd() {
|
|
81
|
+
if (this.quiz.final && !this.finalDone) {
|
|
82
|
+
for (const key of this.keys("final")) this.answers.delete(key);
|
|
83
|
+
this.show({ kind: "final", index: 0 });
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
if (!this.score().total) return false;
|
|
87
|
+
this.show({ kind: "results" });
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
/** Picks an option: answers a one-answer question at once, ticks or unticks one of a "select all that apply". */
|
|
91
|
+
pick(index: number) {
|
|
92
|
+
const current = this.current;
|
|
93
|
+
if (!current || this.answers.has(current.key) || !Number.isInteger(index) || index < 0 || index >= current.question.options.length) return false;
|
|
94
|
+
if (Array.isArray(current.question.answer)) { if (!this.selection.delete(index)) this.selection.add(index); }
|
|
95
|
+
else this.record([index]);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
/** Answers a "select all that apply" with the options ticked. */
|
|
99
|
+
check() {
|
|
100
|
+
const current = this.current;
|
|
101
|
+
if (!current || !Array.isArray(current.question.answer) || this.answers.has(current.key) || !this.selection.size) return false;
|
|
102
|
+
this.record([...this.selection].sort((a, b) => a - b));
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
private record(chosen: number[]) {
|
|
106
|
+
const { question, key } = this.current!;
|
|
107
|
+
const right = Array.isArray(question.answer) ? question.answer : [question.answer];
|
|
108
|
+
this.answers.set(key, { chosen, correct: chosen.length === right.length && chosen.every((value, index) => value === right[index]) });
|
|
109
|
+
}
|
|
110
|
+
/** After an answer: the next question, the score after the final quiz's last, or closed after a checkpoint's last. */
|
|
111
|
+
advance(): "question" | "results" | "closed" | null {
|
|
112
|
+
const panel = this.panel; const current = this.current;
|
|
113
|
+
if (!panel || panel.kind === "results" || !current || !this.answers.has(current.key)) return null;
|
|
114
|
+
if (panel.index + 1 < current.count) { this.show({ ...panel, index: panel.index + 1 }); return "question"; }
|
|
115
|
+
if (panel.kind === "final") { this.finalDone = true; this.show({ kind: "results" }); return "results"; }
|
|
116
|
+
this.show(null);
|
|
117
|
+
return "closed";
|
|
118
|
+
}
|
|
119
|
+
/** Asks a checkpoint again the next time the film reaches it. */
|
|
120
|
+
arm(checkpoint: QuizCheckpoint) { this.armed.add(checkpoint.id); }
|
|
121
|
+
close() { this.show(null); }
|
|
122
|
+
retake() { if (!this.quiz.final) return; this.finalDone = false; this.openEnd(); }
|
|
123
|
+
/** Forgets every answer, for a fresh viewing. */
|
|
124
|
+
reset() { this.answers.clear(); this.armed.clear(); this.finalDone = false; this.show(null); }
|
|
125
|
+
private show(panel: QuizPanel | null) { this.panel = panel; this.selection.clear(); }
|
|
126
|
+
|
|
127
|
+
/** Where Watch again goes back to: the checkpoint's review, else the previous checkpoint (in its story segment) or the start. */
|
|
128
|
+
reviewTime(checkpoint: QuizCheckpoint) {
|
|
129
|
+
if (checkpoint.review !== undefined) return checkpoint.review;
|
|
130
|
+
const segment = this.segmentOf.get(checkpoint.id);
|
|
131
|
+
const earlier = this.checkpoints.filter((other) => other.at < checkpoint.at - EPSILON && (!segment || this.segmentOf.get(other.id) === segment));
|
|
132
|
+
return earlier.at(-1)?.at ?? segment?.start ?? 0;
|
|
133
|
+
}
|
|
134
|
+
score(): QuizScore {
|
|
135
|
+
const marks: QuizMark[] = [];
|
|
136
|
+
const mark = (key: string) => { const answer = this.answers.get(key); marks.push(!answer ? "skipped" : answer.correct ? "right" : "wrong"); };
|
|
137
|
+
for (const checkpoint of this.checkpoints) if (!this.story || this.story.visited.has(this.segmentOf.get(checkpoint.id)!.id)) this.keys(checkpoint).forEach(mark);
|
|
138
|
+
if (this.finalDone) this.keys("final").forEach(mark);
|
|
139
|
+
const correct = marks.filter((value) => value === "right").length;
|
|
140
|
+
const pass = this.quiz.final?.pass ?? null;
|
|
141
|
+
return { correct, total: marks.length, skipped: marks.filter((value) => value === "skipped").length, marks, pass, passed: pass === null || !marks.length ? null : correct * 100 >= pass * marks.length };
|
|
142
|
+
}
|
|
143
|
+
view(): QuizView {
|
|
144
|
+
const panel = this.panel; const current = this.current;
|
|
145
|
+
if (!panel) return { kind: "none" };
|
|
146
|
+
if (panel.kind === "results" || !current) return { kind: "results", score: this.score(), retake: Boolean(this.quiz.final), story: Boolean(this.story) };
|
|
147
|
+
return {
|
|
148
|
+
kind: "question", key: current.key, final: panel.kind === "final", title: panel.kind === "final" ? this.quiz.final!.title : panel.checkpoint.title,
|
|
149
|
+
number: panel.index + 1, count: current.count, question: current.question, selection: [...this.selection].sort((a, b) => a - b),
|
|
150
|
+
answer: this.answers.get(current.key) ?? null, watchAgain: panel.kind === "checkpoint",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const ICONS = { right: '<path d="M5 12.5l4.5 4.5L19 7.5"/>', wrong: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>', skipped: '<path d="M6 12h12"/>' };
|
|
156
|
+
function icon(kind: keyof typeof ICONS) {
|
|
157
|
+
const span = document.createElement("span");
|
|
158
|
+
span.className = "quiz-icon"; span.setAttribute("aria-hidden", "true");
|
|
159
|
+
span.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">${ICONS[kind]}</svg>`;
|
|
160
|
+
return span;
|
|
161
|
+
}
|
|
162
|
+
/** Black or white, whichever reads better on a color. */
|
|
163
|
+
function readableOn(hex: string) {
|
|
164
|
+
const channel = (offset: number) => { const value = parseInt(hex.slice(offset, offset + 2), 16) / 255; return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; };
|
|
165
|
+
const luminance = 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
|
|
166
|
+
return luminance > 0.179 ? "#111418" : "#ffffff";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The question and score cards, centered over the dimmed picture. They are HTML buttons, so a viewer can tab to them,
|
|
171
|
+
* a screen reader reads them and a touch picks them; 1–6 on the keyboard pick too. A card is rebuilt only when its
|
|
172
|
+
* question or answer changes, so focus and hover survive every frame's update; ticks on a "select all" update in place.
|
|
173
|
+
*/
|
|
174
|
+
export class QuizOverlay {
|
|
175
|
+
readonly element = document.createElement("div");
|
|
176
|
+
private readonly live = document.createElement("p");
|
|
177
|
+
private shown = "";
|
|
178
|
+
private ticks = "";
|
|
179
|
+
private returnFocus: Element | null = null;
|
|
180
|
+
|
|
181
|
+
constructor(host: HTMLElement, quiz: Quiz, private readonly copy: QuizCopy, branding: Branding | undefined,
|
|
182
|
+
private readonly actions: { pick(index: number): void; check(): void; next(): void; watchAgain(): void; retake(): void; finish(): void }) {
|
|
183
|
+
this.element.className = "quiz-layer"; this.element.hidden = true;
|
|
184
|
+
const theme = quiz.theme ?? {};
|
|
185
|
+
const accent = theme.accent ?? branding?.colors.primary ?? "#2c6bd0";
|
|
186
|
+
const surface = theme.surface ?? "#fffdfa";
|
|
187
|
+
// The brand's text color suits the brand's background, not necessarily this card: the card picks its own.
|
|
188
|
+
this.element.style.setProperty("--quiz-accent", accent);
|
|
189
|
+
this.element.style.setProperty("--quiz-on-accent", readableOn(accent));
|
|
190
|
+
this.element.style.setProperty("--quiz-surface", surface);
|
|
191
|
+
this.element.style.setProperty("--quiz-text", theme.text ?? (readableOn(surface) === "#ffffff" ? "#f7f5ef" : "#16232a"));
|
|
192
|
+
if (branding) this.element.style.setProperty("--quiz-font", branding.fonts.heading.family);
|
|
193
|
+
this.live.className = "story-live"; this.live.setAttribute("role", "status");
|
|
194
|
+
host.append(this.element, this.live);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
render(view: QuizView) {
|
|
198
|
+
const key = view.kind === "none" ? "none" : view.kind === "results" ? `results:${view.score.marks.join()}` : `question:${view.key}:${view.answer ? "answered" : ""}`;
|
|
199
|
+
if (key !== this.shown) {
|
|
200
|
+
const opening = this.shown === "" || this.shown === "none";
|
|
201
|
+
if (opening && view.kind !== "none") this.returnFocus = document.activeElement;
|
|
202
|
+
const hadFocus = this.element.contains(document.activeElement);
|
|
203
|
+
this.shown = key; this.build(view, opening);
|
|
204
|
+
if (view.kind === "none") {
|
|
205
|
+
// Focus that was on the card goes back where it was before the card opened.
|
|
206
|
+
if (hadFocus && this.returnFocus instanceof HTMLElement && this.returnFocus.isConnected) this.returnFocus.focus({ preventScroll: true });
|
|
207
|
+
} else {
|
|
208
|
+
const target = this.element.querySelector<HTMLElement>(view.kind === "question" && !view.answer ? ".quiz-option" : ".quiz-actions .quiz-primary");
|
|
209
|
+
target?.focus({ preventScroll: true });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (view.kind === "question" && !view.answer && view.selection.join() !== this.ticks) this.tick(view.selection);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Shows the ticked options of a "select all" and enables Check answer once one is ticked. */
|
|
216
|
+
private tick(selection: number[]) {
|
|
217
|
+
this.ticks = selection.join();
|
|
218
|
+
for (const option of this.element.querySelectorAll<HTMLButtonElement>(".quiz-multi .quiz-option")) {
|
|
219
|
+
const on = selection.includes(Number(option.dataset.index));
|
|
220
|
+
option.classList.toggle("selected", on); option.setAttribute("aria-pressed", String(on));
|
|
221
|
+
}
|
|
222
|
+
const check = this.element.querySelector<HTMLButtonElement>(".quiz-check");
|
|
223
|
+
if (check) check.disabled = selection.length === 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** opening: the card appears over the film, rather than changing to the next question or the score. */
|
|
227
|
+
private build(view: QuizView, opening: boolean) {
|
|
228
|
+
this.element.replaceChildren(); this.ticks = "";
|
|
229
|
+
this.element.dataset.view = view.kind; this.element.hidden = view.kind === "none";
|
|
230
|
+
if (view.kind === "none") return;
|
|
231
|
+
const { copy } = this;
|
|
232
|
+
const node = <K extends keyof HTMLElementTagNameMap>(tag: K, className: string, text?: string) => {
|
|
233
|
+
const element = document.createElement(tag); element.className = className;
|
|
234
|
+
if (text !== undefined) element.textContent = text;
|
|
235
|
+
return element;
|
|
236
|
+
};
|
|
237
|
+
const button = (label: string, action: () => void, className: string) => {
|
|
238
|
+
const element = node("button", className, label); element.type = "button";
|
|
239
|
+
// A click on the card is not a click on the picture, which would pause the film.
|
|
240
|
+
element.addEventListener("click", (event) => { event.stopPropagation(); action(); });
|
|
241
|
+
return element;
|
|
242
|
+
};
|
|
243
|
+
const card = node("div", `quiz-card${view.kind === "results" ? " quiz-results" : ""}${view.kind === "question" && view.answer ? " answered" : ""}${opening ? " quiz-card-in" : ""}`);
|
|
244
|
+
card.setAttribute("role", "dialog");
|
|
245
|
+
const actions = node("div", "quiz-actions");
|
|
246
|
+
if (view.kind === "question") {
|
|
247
|
+
const { question, answer } = view;
|
|
248
|
+
const multiple = Array.isArray(question.answer);
|
|
249
|
+
const right = Array.isArray(question.answer) ? question.answer : [question.answer];
|
|
250
|
+
const title = view.title ?? (view.final ? copy.final : copy.check);
|
|
251
|
+
const eyebrow = view.count > 1 ? `${title} · ${fill(copy.progress, { n: view.number, total: view.count })}` : title;
|
|
252
|
+
const prompt = node("h2", "quiz-prompt", question.prompt);
|
|
253
|
+
card.setAttribute("aria-label", `${eyebrow}: ${question.prompt}`);
|
|
254
|
+
card.append(node("p", "quiz-eyebrow", eyebrow), prompt);
|
|
255
|
+
if (multiple && !answer) card.append(node("p", "quiz-hint", copy.selectAll));
|
|
256
|
+
// Long options, and an odd number of them on a wide card, read best as a list; others sit in two columns.
|
|
257
|
+
const long = question.options.some((option) => option.length > 32);
|
|
258
|
+
const options = node("div", `quiz-options${multiple ? " quiz-multi" : ""}${long ? " quiz-long" : question.options.length % 2 ? " quiz-odd" : ""}`);
|
|
259
|
+
options.setAttribute("role", "group"); options.setAttribute("aria-label", question.prompt);
|
|
260
|
+
question.options.forEach((label, index) => {
|
|
261
|
+
const option = button("", () => this.actions.pick(index), "quiz-option");
|
|
262
|
+
option.dataset.index = String(index);
|
|
263
|
+
const isRight = right.includes(index), chosen = Boolean(answer?.chosen.includes(index));
|
|
264
|
+
const state = !answer ? null : isRight ? "right" : chosen ? "wrong" : null;
|
|
265
|
+
const key = node("span", "quiz-key");
|
|
266
|
+
if (state) key.append(icon(state)); else key.textContent = String(index + 1);
|
|
267
|
+
option.append(key, node("span", "quiz-option-text", label));
|
|
268
|
+
if (answer) {
|
|
269
|
+
option.disabled = true;
|
|
270
|
+
option.classList.add(state === "right" ? "correct" : state === "wrong" ? "wrong" : "dim");
|
|
271
|
+
if (isRight && !chosen) option.classList.add("missed");
|
|
272
|
+
if (multiple || chosen) option.setAttribute("aria-pressed", String(chosen));
|
|
273
|
+
} else if (multiple) option.setAttribute("aria-pressed", "false");
|
|
274
|
+
options.append(option);
|
|
275
|
+
});
|
|
276
|
+
card.append(options);
|
|
277
|
+
if (!answer) {
|
|
278
|
+
if (multiple) { const check = button(copy.checkAnswer, () => this.actions.check(), "quiz-primary quiz-check"); check.disabled = true; actions.append(check); }
|
|
279
|
+
this.live.textContent = `${eyebrow}. ${question.prompt} ${multiple ? `${copy.selectAll} ` : ""}${question.options.map((option, index) => `${index + 1}: ${option}.`).join(" ")}`;
|
|
280
|
+
} else {
|
|
281
|
+
const feedback = node("p", `quiz-feedback ${answer.correct ? "correct" : "wrong"}`);
|
|
282
|
+
feedback.append(node("strong", "", answer.correct ? copy.correct : copy.incorrect));
|
|
283
|
+
if (!answer.correct) feedback.append(" ", node("span", "quiz-answer", fill(copy.answer, { answer: right.map((index) => question.options[index]).join(", ") })));
|
|
284
|
+
if (question.explanation) feedback.append(" ", node("span", "", question.explanation));
|
|
285
|
+
card.append(feedback);
|
|
286
|
+
const last = view.number === view.count;
|
|
287
|
+
actions.append(button(last ? (view.final ? copy.seeResults : copy.continue) : copy.next, () => this.actions.next(), "quiz-primary"));
|
|
288
|
+
if (view.watchAgain && !answer.correct) actions.append(button(copy.watchAgain, () => this.actions.watchAgain(), "quiz-secondary"));
|
|
289
|
+
this.live.textContent = feedback.textContent ?? "";
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
const { score } = view;
|
|
293
|
+
const perfect = score.total > 0 && score.correct === score.total;
|
|
294
|
+
const heading = fill(copy.score, { correct: score.correct, total: score.total });
|
|
295
|
+
card.setAttribute("aria-label", `${copy.complete}: ${heading}`);
|
|
296
|
+
const total = node("p", "quiz-score");
|
|
297
|
+
total.append(node("strong", "", String(score.correct)), node("span", "", `/${score.total}`));
|
|
298
|
+
card.append(node("p", "quiz-eyebrow", copy.complete), total, node("h2", "quiz-prompt", heading));
|
|
299
|
+
const verdict = perfect ? copy.perfect : score.passed === null ? null : score.passed ? copy.passed : fill(copy.notPassed, { pass: score.pass ?? 0 });
|
|
300
|
+
if (verdict) card.append(node("p", `quiz-verdict${perfect || score.passed ? " passed" : ""}`, verdict));
|
|
301
|
+
const marks = node("ol", "quiz-marks"); marks.setAttribute("aria-hidden", "true");
|
|
302
|
+
for (const mark of score.marks) { const item = node("li", mark); item.append(icon(mark)); marks.append(item); }
|
|
303
|
+
card.append(marks);
|
|
304
|
+
if (score.skipped) card.append(node("p", "quiz-hint", fill(copy.skipped, { count: score.skipped })));
|
|
305
|
+
// A viewer who has not passed is offered the retake first; a story's score gives way to its ending.
|
|
306
|
+
const retakeFirst = view.retake && !perfect && score.passed !== true;
|
|
307
|
+
const finish = button(view.story ? copy.continue : copy.restart, () => this.actions.finish(), retakeFirst ? "quiz-secondary" : "quiz-primary");
|
|
308
|
+
const retake = view.retake ? button(copy.retake, () => this.actions.retake(), retakeFirst ? "quiz-primary" : "quiz-secondary") : null;
|
|
309
|
+
actions.append(...(retakeFirst && retake ? [retake, finish] : retake ? [finish, retake] : [finish]));
|
|
310
|
+
this.live.textContent = `${heading}${verdict ? ` ${verdict}` : ""}`;
|
|
311
|
+
}
|
|
312
|
+
if (actions.childElementCount) card.append(actions);
|
|
313
|
+
this.element.append(card);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { validateStory, type StoryGraph, type StorySegment } from "../scripts/story.mjs";
|
|
2
|
+
import type { Branding } from "./player-types";
|
|
3
|
+
|
|
4
|
+
export type { StoryGraph, StorySegment };
|
|
5
|
+
export type StoryCopy = { theEnd: string; playAgain: string; back: string; choose: string };
|
|
6
|
+
/** What the panel over the picture shows: nothing, the segment's choices, or its ending. */
|
|
7
|
+
export type StoryView =
|
|
8
|
+
| { kind: "none" }
|
|
9
|
+
| { kind: "choices"; segment: StorySegment; picked: number | null; remaining: number | null }
|
|
10
|
+
| { kind: "ending"; segment: StorySegment; canGoBack: boolean };
|
|
11
|
+
|
|
12
|
+
/** The story's segments by id and by time; the graph is checked once, against the film's duration. */
|
|
13
|
+
export class StoryMap {
|
|
14
|
+
private readonly byId: Map<string, StorySegment>;
|
|
15
|
+
private readonly ordered: StorySegment[];
|
|
16
|
+
constructor(readonly graph: StoryGraph, duration: number) {
|
|
17
|
+
validateStory(graph, { duration });
|
|
18
|
+
this.byId = new Map(graph.segments.map((segment) => [segment.id, segment]));
|
|
19
|
+
this.ordered = [...graph.segments].sort((a, b) => a.start - b.start);
|
|
20
|
+
}
|
|
21
|
+
get first() { return this.get(this.graph.start); }
|
|
22
|
+
get(id: string) {
|
|
23
|
+
const segment = this.byId.get(id);
|
|
24
|
+
if (!segment) throw new Error(`The story has no segment ${id}.`);
|
|
25
|
+
return segment;
|
|
26
|
+
}
|
|
27
|
+
/** The segment playing at a time of the film's timeline; a boundary belongs to the segment that starts there. */
|
|
28
|
+
at(time: number) {
|
|
29
|
+
let found = this.ordered[0];
|
|
30
|
+
for (const segment of this.ordered) if (segment.start <= time + 1e-6) found = segment;
|
|
31
|
+
return found;
|
|
32
|
+
}
|
|
33
|
+
/** When a segment's choices appear. */
|
|
34
|
+
choiceAt(segment: StorySegment) { return segment.choiceAt ?? segment.end; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The choice and ending panels over the picture. They are HTML buttons, so a viewer can tab to them, a screen reader
|
|
39
|
+
* reads them and a touch picks them; 1–4 on the keyboard choose too. The panels are rebuilt only when what they show
|
|
40
|
+
* changes, so focus and hover survive every frame's update.
|
|
41
|
+
*/
|
|
42
|
+
export class StoryOverlay {
|
|
43
|
+
readonly element = document.createElement("div");
|
|
44
|
+
private readonly live = document.createElement("p");
|
|
45
|
+
private shown = "";
|
|
46
|
+
private timer: HTMLElement | null = null;
|
|
47
|
+
|
|
48
|
+
constructor(host: HTMLElement, graph: StoryGraph, private readonly copy: StoryCopy, branding: Branding | undefined,
|
|
49
|
+
private readonly actions: { choose(index: number): void; restart(): void; back(): void }) {
|
|
50
|
+
this.element.className = "story-layer";
|
|
51
|
+
const theme = graph.theme ?? {};
|
|
52
|
+
this.element.style.setProperty("--story-accent", theme.accent ?? branding?.colors.primary ?? "#2c6bd0");
|
|
53
|
+
this.element.style.setProperty("--story-surface", theme.surface ?? "#fffdfa");
|
|
54
|
+
this.element.style.setProperty("--story-text", theme.text ?? branding?.colors.text ?? "#16232a");
|
|
55
|
+
if (branding) this.element.style.setProperty("--story-font", branding.fonts.heading.family);
|
|
56
|
+
this.live.className = "story-live"; this.live.setAttribute("role", "status");
|
|
57
|
+
host.append(this.element, this.live);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
render(view: StoryView) {
|
|
61
|
+
const key = view.kind === "none" ? "none" : `${view.kind}:${view.segment.id}:${view.kind === "choices" ? view.picked : view.canGoBack}`;
|
|
62
|
+
if (key !== this.shown) { this.shown = key; this.build(view); }
|
|
63
|
+
if (view.kind === "choices" && this.timer) this.timer.style.transform = `scaleX(${Math.max(0, view.remaining ?? 0)})`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private build(view: StoryView) {
|
|
67
|
+
this.element.replaceChildren(); this.timer = null;
|
|
68
|
+
this.element.dataset.view = view.kind;
|
|
69
|
+
if (view.kind === "none") return;
|
|
70
|
+
const panel = document.createElement("div");
|
|
71
|
+
panel.className = `story-panel story-panel-${view.kind}`;
|
|
72
|
+
const button = (label: string, action: () => void, className = "") => {
|
|
73
|
+
const element = document.createElement("button");
|
|
74
|
+
element.type = "button"; element.className = className; element.textContent = label;
|
|
75
|
+
// A click on a choice is not a click on the picture, which would pause the film.
|
|
76
|
+
element.addEventListener("click", (event) => { event.stopPropagation(); action(); });
|
|
77
|
+
return element;
|
|
78
|
+
};
|
|
79
|
+
if (view.kind === "choices") {
|
|
80
|
+
const { segment, picked } = view;
|
|
81
|
+
panel.setAttribute("role", "group");
|
|
82
|
+
const prompt = segment.prompt ?? this.copy.choose;
|
|
83
|
+
panel.setAttribute("aria-label", prompt);
|
|
84
|
+
if (segment.prompt) {
|
|
85
|
+
const heading = document.createElement("p"); heading.className = "story-prompt"; heading.textContent = segment.prompt;
|
|
86
|
+
panel.append(heading);
|
|
87
|
+
}
|
|
88
|
+
if (segment.timeout !== undefined && picked === null) {
|
|
89
|
+
const track = document.createElement("div"); track.className = "story-timer"; track.setAttribute("aria-hidden", "true");
|
|
90
|
+
this.timer = document.createElement("span"); track.append(this.timer); panel.append(track);
|
|
91
|
+
}
|
|
92
|
+
const choices = document.createElement("div"); choices.className = "story-choices";
|
|
93
|
+
segment.choices!.forEach((choice, index) => {
|
|
94
|
+
const element = button(choice.label, () => this.actions.choose(index), picked === index ? "picked" : "");
|
|
95
|
+
element.dataset.key = String(index + 1);
|
|
96
|
+
if (picked !== null) { element.disabled = picked !== index; element.setAttribute("aria-pressed", String(picked === index)); }
|
|
97
|
+
choices.append(element);
|
|
98
|
+
});
|
|
99
|
+
panel.append(choices);
|
|
100
|
+
this.live.textContent = picked === null ? `${prompt} ${segment.choices!.map((choice, index) => `${index + 1}: ${choice.label}.`).join(" ")}` : segment.choices![picked].label;
|
|
101
|
+
} else {
|
|
102
|
+
const eyebrow = document.createElement("p"); eyebrow.className = "story-eyebrow"; eyebrow.textContent = this.copy.theEnd;
|
|
103
|
+
const title = document.createElement("h2"); title.className = "story-title"; title.textContent = view.segment.ending!;
|
|
104
|
+
const actions = document.createElement("div"); actions.className = "story-choices";
|
|
105
|
+
actions.append(button(this.copy.playAgain, this.actions.restart, "picked"));
|
|
106
|
+
if (view.canGoBack) actions.append(button(this.copy.back, this.actions.back));
|
|
107
|
+
panel.append(eyebrow, title, actions);
|
|
108
|
+
this.live.textContent = `${this.copy.theEnd}: ${view.segment.ending}`;
|
|
109
|
+
}
|
|
110
|
+
this.element.append(panel);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -117,3 +117,90 @@ body:not(.capture) #canvas-host canvas { cursor: pointer; }
|
|
|
117
117
|
|
|
118
118
|
#audio-status { margin: 0; padding: 9px 16px; font-size: 12px; color: #8b2f25; background: #fff1eb; }
|
|
119
119
|
.capture #audio-status { display: none; }
|
|
120
|
+
|
|
121
|
+
/* A branching story's choice and ending panels, over the picture. Sizes follow the player's width (cqi), so the panels
|
|
122
|
+
keep their proportions from a phone to full screen; the minimums keep text readable and buttons easy to tap. */
|
|
123
|
+
#canvas-host { container-type: inline-size; }
|
|
124
|
+
.story-layer { position: absolute; inset: 0; z-index: 2; display: flex; align-items: flex-end; justify-content: center; pointer-events: none; font-family: var(--story-font, inherit); }
|
|
125
|
+
.story-layer[data-view="choices"] { background: linear-gradient(to top, rgb(8 12 11 / .66), rgb(8 12 11 / 0) 52%); }
|
|
126
|
+
.story-layer[data-view="ending"] { align-items: center; background: rgb(8 12 11 / .62); pointer-events: auto; }
|
|
127
|
+
.story-panel { pointer-events: auto; width: min(94%, 70cqi); padding: 0 0 4.5cqi; display: grid; justify-items: center; gap: 1.5cqi; text-align: center; animation: story-in .35s ease-out both; }
|
|
128
|
+
.story-panel-ending { padding: 0; }
|
|
129
|
+
.story-prompt { margin: 0; color: #fff; font-size: clamp(15px, 2.6cqi, 42px); font-weight: 700; line-height: 1.15; text-shadow: 0 2px 14px rgb(0 0 0 / .5); }
|
|
130
|
+
.story-timer { width: min(100%, 34cqi); height: max(3px, .35cqi); border-radius: 9px; background: rgb(255 255 255 / .3); overflow: hidden; }
|
|
131
|
+
.story-timer span { display: block; height: 100%; background: var(--story-accent); transform-origin: left; }
|
|
132
|
+
.story-choices { display: flex; flex-wrap: wrap; justify-content: center; gap: max(8px, 1.2cqi); width: 100%; }
|
|
133
|
+
.story-choices button { display: flex; align-items: center; justify-content: center; gap: .7em; flex: 1 1 0; min-width: min(100%, 18cqi); max-width: 33cqi; min-height: 44px; padding: 1.1cqi 2.2cqi; border: 2px solid transparent; border-radius: 999px; background: var(--story-surface); color: var(--story-text); font: inherit; font-size: clamp(13px, 1.8cqi, 30px); font-weight: 700; line-height: 1.2; box-shadow: 0 10px 30px -12px rgb(0 0 0 / .65); transition: transform .15s, border-color .15s; }
|
|
134
|
+
.story-choices button::before { content: attr(data-key); display: grid; place-items: center; flex: none; width: 1.6em; height: 1.6em; border-radius: 50%; background: color-mix(in srgb, var(--story-accent) 16%, transparent); color: var(--story-accent); font-size: .72em; }
|
|
135
|
+
.story-panel-ending .story-choices button::before { content: none; }
|
|
136
|
+
.story-choices button:hover:enabled, .story-choices button:focus-visible { transform: translateY(-2px); border-color: var(--story-accent); }
|
|
137
|
+
/* A picked choice keeps the story's text color on a tint of the accent, readable whatever the accent is. */
|
|
138
|
+
.story-choices button.picked { border-color: var(--story-accent); background: color-mix(in srgb, var(--story-accent) 24%, var(--story-surface)); }
|
|
139
|
+
.story-choices button.picked::before { background: var(--story-accent); color: var(--story-surface); }
|
|
140
|
+
.story-eyebrow { margin: 0; color: rgb(255 255 255 / .78); font-size: clamp(11px, 1.3cqi, 20px); font-weight: 700; letter-spacing: .22em; text-transform: uppercase; }
|
|
141
|
+
.story-title { margin: 0 0 1cqi; color: #fff; font-size: clamp(22px, 4.8cqi, 78px); line-height: 1.05; text-wrap: balance; }
|
|
142
|
+
.story-live { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip-path: inset(50%); }
|
|
143
|
+
@keyframes story-in { from { opacity: 0; transform: translateY(12px); } }
|
|
144
|
+
@container (max-width: 560px) { .story-panel { width: 96%; padding-bottom: 10px; gap: 8px; } .story-choices button { max-width: none; } }
|
|
145
|
+
@media (prefers-reduced-motion: reduce) { .story-panel { animation: none; } .story-choices button { transition: none; } }
|
|
146
|
+
.capture .story-layer { display: none; }
|
|
147
|
+
/* A quiz's question and score cards, centered over the dimmed picture. Sizes follow the player's width (cqi) like the
|
|
148
|
+
story panels; a card taller than a small player scrolls inside it. Right and wrong carry an icon as well as a color. */
|
|
149
|
+
.quiz-layer { --quiz-right: #1d8a4e; --quiz-wrong: #c8412f; position: absolute; inset: 0; z-index: 3; display: grid; overflow-y: auto; overscroll-behavior: contain; padding: clamp(8px, 2.4cqi, 40px); box-sizing: border-box; background: rgb(8 12 11 / .6); font-family: var(--quiz-font, inherit); }
|
|
150
|
+
.quiz-layer[hidden] { display: none; }
|
|
151
|
+
.quiz-card { margin: auto; width: min(100%, 64cqi); box-sizing: border-box; display: grid; gap: clamp(8px, 1.25cqi, 22px); padding: clamp(14px, 2.7cqi, 48px); border-radius: clamp(14px, 1.7cqi, 30px); background: var(--quiz-surface); color: var(--quiz-text); box-shadow: 0 30px 80px -30px rgb(0 0 0 / .75); }
|
|
152
|
+
.quiz-card-in { animation: quiz-in .3s ease-out both; }
|
|
153
|
+
.quiz-eyebrow { margin: 0; color: color-mix(in srgb, var(--quiz-accent) 72%, var(--quiz-text)); font-size: clamp(11px, 1.15cqi, 19px); font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
|
|
154
|
+
.quiz-prompt { margin: 0; font-size: clamp(16px, 2.45cqi, 42px); font-weight: 800; line-height: 1.18; text-wrap: balance; }
|
|
155
|
+
.quiz-hint { margin: -.2em 0 0; font-size: clamp(12px, 1.3cqi, 21px); opacity: .72; }
|
|
156
|
+
.quiz-options { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(6px, .95cqi, 16px); }
|
|
157
|
+
.quiz-options.quiz-long, .quiz-options.quiz-odd { grid-template-columns: 1fr; }
|
|
158
|
+
.quiz-option { display: flex; align-items: center; gap: .7em; min-height: 44px; padding: clamp(8px, 1cqi, 18px) clamp(10px, 1.35cqi, 24px); border: 2px solid color-mix(in srgb, var(--quiz-text) 14%, transparent); border-radius: clamp(10px, 1.1cqi, 18px); background: color-mix(in srgb, var(--quiz-text) 3%, var(--quiz-surface)); color: inherit; font: inherit; font-size: clamp(13px, 1.6cqi, 28px); font-weight: 650; line-height: 1.25; text-align: left; cursor: pointer; transition: border-color .15s, background-color .15s, transform .15s; }
|
|
159
|
+
.quiz-option:hover:enabled, .quiz-option:focus-visible { border-color: var(--quiz-accent); transform: translateY(-1px); }
|
|
160
|
+
.quiz-option:focus-visible, .quiz-actions button:focus-visible { outline: 3px solid color-mix(in srgb, var(--quiz-accent) 50%, transparent); outline-offset: 2px; }
|
|
161
|
+
.quiz-option:disabled { cursor: default; opacity: 1; }
|
|
162
|
+
.quiz-key { flex: none; display: grid; place-items: center; width: 1.75em; height: 1.75em; border-radius: 50%; background: color-mix(in srgb, var(--quiz-accent) 18%, transparent); color: color-mix(in srgb, var(--quiz-accent) 62%, var(--quiz-text)); font-size: .78em; font-weight: 800; }
|
|
163
|
+
.quiz-multi .quiz-key { border-radius: 28%; }
|
|
164
|
+
.quiz-option-text { flex: 1; min-width: 0; }
|
|
165
|
+
.quiz-option.selected { border-color: var(--quiz-accent); background: color-mix(in srgb, var(--quiz-accent) 15%, var(--quiz-surface)); }
|
|
166
|
+
.quiz-option.selected .quiz-key { background: var(--quiz-accent); color: var(--quiz-on-accent); }
|
|
167
|
+
.quiz-option.correct { border-color: var(--quiz-right); background: color-mix(in srgb, var(--quiz-right) 13%, var(--quiz-surface)); }
|
|
168
|
+
.quiz-option.wrong { border-color: var(--quiz-wrong); background: color-mix(in srgb, var(--quiz-wrong) 11%, var(--quiz-surface)); }
|
|
169
|
+
.quiz-option.missed { border-style: dashed; }
|
|
170
|
+
.quiz-option.correct .quiz-key { background: var(--quiz-right); color: #fff; }
|
|
171
|
+
.quiz-option.wrong .quiz-key { background: var(--quiz-wrong); color: #fff; }
|
|
172
|
+
.quiz-option.dim { opacity: .5; }
|
|
173
|
+
.quiz-icon { display: grid; place-items: center; width: 100%; height: 100%; }
|
|
174
|
+
.quiz-icon svg { width: 62%; height: 62%; }
|
|
175
|
+
.quiz-feedback { margin: 0; padding: clamp(8px, 1.05cqi, 18px) clamp(10px, 1.35cqi, 22px); border-left: 4px solid var(--quiz-right); border-radius: clamp(8px, .9cqi, 14px); background: color-mix(in srgb, var(--quiz-right) 11%, var(--quiz-surface)); font-size: clamp(13px, 1.45cqi, 25px); line-height: 1.42; }
|
|
176
|
+
.quiz-feedback.wrong { border-left-color: var(--quiz-wrong); background: color-mix(in srgb, var(--quiz-wrong) 9%, var(--quiz-surface)); }
|
|
177
|
+
.quiz-answer { font-weight: 700; }
|
|
178
|
+
.quiz-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: clamp(6px, 1cqi, 14px); }
|
|
179
|
+
.quiz-actions button { min-height: 44px; padding: 0 clamp(14px, 2cqi, 34px); border-radius: 999px; font: inherit; font-size: clamp(13px, 1.45cqi, 25px); font-weight: 750; cursor: pointer; transition: transform .15s, filter .15s; }
|
|
180
|
+
.quiz-actions button:hover:enabled { transform: translateY(-1px); filter: brightness(1.06); }
|
|
181
|
+
.quiz-actions button:disabled { opacity: .45; cursor: default; }
|
|
182
|
+
.quiz-primary { border: 2px solid var(--quiz-accent); background: var(--quiz-accent); color: var(--quiz-on-accent); }
|
|
183
|
+
.quiz-secondary { border: 2px solid color-mix(in srgb, var(--quiz-text) 24%, transparent); background: transparent; color: inherit; }
|
|
184
|
+
.quiz-results { justify-items: center; text-align: center; }
|
|
185
|
+
.quiz-results .quiz-actions { justify-content: center; }
|
|
186
|
+
.quiz-score { margin: 0; color: color-mix(in srgb, var(--quiz-accent) 75%, var(--quiz-text)); font-size: clamp(38px, 7cqi, 124px); font-weight: 900; line-height: 1; }
|
|
187
|
+
.quiz-score span { font-size: .42em; opacity: .62; }
|
|
188
|
+
.quiz-verdict { margin: 0; font-size: clamp(13px, 1.6cqi, 27px); font-weight: 750; }
|
|
189
|
+
.quiz-verdict.passed { color: color-mix(in srgb, var(--quiz-right) 80%, var(--quiz-text)); }
|
|
190
|
+
.quiz-marks { display: flex; flex-wrap: wrap; justify-content: center; gap: clamp(4px, .6cqi, 10px); margin: 0; padding: 0; list-style: none; }
|
|
191
|
+
.quiz-marks li { width: clamp(22px, 2.5cqi, 42px); height: clamp(22px, 2.5cqi, 42px); border-radius: 50%; background: var(--quiz-right); color: #fff; }
|
|
192
|
+
.quiz-marks li.wrong { background: var(--quiz-wrong); }
|
|
193
|
+
.quiz-marks li.skipped { background: color-mix(in srgb, var(--quiz-text) 22%, transparent); color: var(--quiz-text); }
|
|
194
|
+
@keyframes quiz-in { from { opacity: 0; transform: translateY(10px) scale(.985); } }
|
|
195
|
+
@container (max-width: 560px) {
|
|
196
|
+
.quiz-layer { padding: 6px; }
|
|
197
|
+
.quiz-card { width: 100%; padding: 12px; gap: 8px; border-radius: 12px; }
|
|
198
|
+
.quiz-options, .quiz-options.quiz-odd { grid-template-columns: 1fr 1fr; gap: 6px; }
|
|
199
|
+
/* A small player keeps the answer in the feedback's words and the next step in reach. */
|
|
200
|
+
.quiz-card.answered .quiz-options { display: none; }
|
|
201
|
+
.quiz-actions { position: sticky; bottom: -12px; margin: 0 -12px -12px; padding: 8px 12px 12px; background: var(--quiz-surface); border-radius: 0 0 12px 12px; }
|
|
202
|
+
.quiz-option { min-height: 40px; padding: 6px 8px; font-size: 12.5px; }
|
|
203
|
+
.quiz-feedback { font-size: 12.5px; padding: 7px 9px; }
|
|
204
|
+
.quiz-actions button { min-height: 40px; padding: 0 14px; font-size: 13px; }
|
|
205
|
+
}
|
|
206
|
+
@media (prefers-reduced-motion: reduce) { .quiz-card-in { animation: none; } .quiz-option, .quiz-actions button { transition: none; } }
|