@commonpub/layer 0.18.1 → 0.18.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.
@@ -17,14 +17,22 @@ const activeContest = computed(() => {
17
17
  // (they're async on first load). A local ref would reset on remount and
18
18
  // the user would see the banner "come back" after dismissing — which also
19
19
  // fails the navigation.spec.ts e2e test.
20
+ //
21
+ // NOTE: useState returns a Ref. Vue SFC's auto-unwrap-on-template-write
22
+ // is reliable for bindings the compiler recognizes as refs, but for
23
+ // auto-imported Nuxt composables the detection is inconsistent across
24
+ // versions — safer to set `.value` explicitly from a real handler.
20
25
  const heroDismissed = useState('cpub:hero-dismissed', () => false);
26
+ function dismissHero(): void {
27
+ heroDismissed.value = true;
28
+ }
21
29
  </script>
22
30
 
23
31
  <template>
24
32
  <section v-if="!heroDismissed" class="cpub-hero-banner">
25
33
  <div class="cpub-hero-grid-bg" />
26
34
  <div class="cpub-hero-gradient" />
27
- <button class="cpub-hero-dismiss" title="Dismiss" @click="heroDismissed = true">
35
+ <button class="cpub-hero-dismiss" title="Dismiss" @click="dismissHero">
28
36
  <i class="fa-solid fa-xmark"></i>
29
37
  </button>
30
38
  <div class="cpub-hero-inner">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.18.1",
3
+ "version": "0.18.2",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -30,7 +30,7 @@
30
30
  "@aws-sdk/client-s3": "^3.1010.0",
31
31
  "@commonpub/explainer": "^0.7.12",
32
32
  "@commonpub/schema": "^0.14.4",
33
- "@commonpub/server": "^2.47.2",
33
+ "@commonpub/server": "^2.47.3",
34
34
  "@tiptap/core": "^2.11.0",
35
35
  "@tiptap/extension-bold": "^2.11.0",
36
36
  "@tiptap/extension-bullet-list": "^2.11.0",
@@ -53,13 +53,13 @@
53
53
  "vue": "^3.4.0",
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
- "@commonpub/docs": "0.6.2",
57
56
  "@commonpub/auth": "0.5.1",
57
+ "@commonpub/docs": "0.6.2",
58
58
  "@commonpub/config": "0.11.0",
59
+ "@commonpub/learning": "0.5.2",
59
60
  "@commonpub/editor": "0.7.9",
60
- "@commonpub/learning": "0.5.1",
61
- "@commonpub/protocol": "0.9.9",
62
- "@commonpub/ui": "0.8.5"
61
+ "@commonpub/ui": "0.8.5",
62
+ "@commonpub/protocol": "0.9.9"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",
package/pages/index.vue CHANGED
@@ -76,7 +76,12 @@ const { data: contests, pending: contestsPending } = await useFetch<{ items: Con
76
76
 
77
77
  // Shared with HeroSection.vue via the same useState key so the dismiss
78
78
  // persists across the configurable-renderer and legacy code paths.
79
+ // Use an explicit handler — see HeroSection.vue for why template
80
+ // auto-unwrap-on-write isn't reliable for Nuxt composables.
79
81
  const heroDismissed = useState('cpub:hero-dismissed', () => false);
82
+ function dismissHero(): void {
83
+ heroDismissed.value = true;
84
+ }
80
85
  const joinedHubs = ref(new Set<string>());
81
86
 
82
87
  // Active contest for hero banner
@@ -166,7 +171,7 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
166
171
  <section v-if="!heroDismissed" class="cpub-hero-banner">
167
172
  <div class="cpub-hero-grid-bg" />
168
173
  <div class="cpub-hero-gradient" />
169
- <button class="cpub-hero-dismiss" title="Dismiss" @click="heroDismissed = true">
174
+ <button class="cpub-hero-dismiss" title="Dismiss" @click="dismissHero">
170
175
  <i class="fa-solid fa-xmark"></i>
171
176
  </button>
172
177
  <div class="cpub-hero-inner">
@@ -37,14 +37,25 @@ const editMarkdown = ref('');
37
37
  // Video URL
38
38
  const editVideoUrl = ref('');
39
39
 
40
- // Quiz data
40
+ // Quiz data — canonical shape: {id, options:[{id,text}], correctOptionId}.
41
+ // Server grades via `@commonpub/learning/gradeQuiz`; see session 129.
42
+ interface QuizOption {
43
+ id: string;
44
+ text: string;
45
+ }
41
46
  interface QuizQuestion {
47
+ id: string;
42
48
  question: string;
43
- options: string[];
44
- correctIndex: number;
49
+ options: QuizOption[];
50
+ correctOptionId: string;
45
51
  explanation: string;
46
52
  }
47
53
  const editQuiz = ref<QuizQuestion[]>([]);
54
+ const editPassingScore = ref(70);
55
+
56
+ function genId(): string {
57
+ return crypto.randomUUID().replace(/-/g, '').slice(0, 8);
58
+ }
48
59
 
49
60
  const lessonTypes = ['article', 'video', 'quiz', 'project', 'explainer'] as const;
50
61
 
@@ -69,12 +80,46 @@ watch(lesson, (l) => {
69
80
  editVideoUrl.value = (content.videoUrl as string) ?? '';
70
81
  editMarkdown.value = (content.notes as string) ?? '';
71
82
  } else if (l.type === 'quiz') {
72
- editQuiz.value = Array.isArray(content.questions)
73
- ? (content.questions as QuizQuestion[]).map(q => ({ ...q }))
74
- : [];
83
+ editPassingScore.value = typeof content.passingScore === 'number' ? content.passingScore : 70;
84
+ const rawQuestions = Array.isArray(content.questions) ? (content.questions as unknown[]) : [];
85
+ editQuiz.value = rawQuestions.map((raw) => migrateQuestion(raw));
75
86
  }
76
87
  }, { immediate: true });
77
88
 
89
+ // Upgrade legacy `{question, options: string[], correctIndex, explanation}` to the
90
+ // canonical `{id, options:[{id,text}], correctOptionId, explanation}` shape.
91
+ // Content already in canonical shape is preserved. Missing ids are filled in.
92
+ function migrateQuestion(raw: unknown): QuizQuestion {
93
+ const src = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
94
+ const options: QuizOption[] = Array.isArray(src.options)
95
+ ? (src.options as unknown[]).map((o) => {
96
+ if (typeof o === 'string') return { id: genId(), text: o };
97
+ const obj = (o && typeof o === 'object' ? o : {}) as Record<string, unknown>;
98
+ return {
99
+ id: typeof obj.id === 'string' && obj.id ? obj.id : genId(),
100
+ text: typeof obj.text === 'string' ? obj.text : '',
101
+ };
102
+ })
103
+ : [];
104
+
105
+ let correctOptionId =
106
+ typeof src.correctOptionId === 'string' && src.correctOptionId ? src.correctOptionId : '';
107
+ if (!correctOptionId && typeof src.correctIndex === 'number') {
108
+ correctOptionId = options[src.correctIndex]?.id ?? options[0]?.id ?? '';
109
+ }
110
+ if (!correctOptionId) {
111
+ correctOptionId = options[0]?.id ?? '';
112
+ }
113
+
114
+ return {
115
+ id: typeof src.id === 'string' && src.id ? src.id : genId(),
116
+ question: typeof src.question === 'string' ? src.question : '',
117
+ options,
118
+ correctOptionId,
119
+ explanation: typeof src.explanation === 'string' ? src.explanation : '',
120
+ };
121
+ }
122
+
78
123
  // Build content payload based on type
79
124
  function buildContent(): unknown {
80
125
  switch (editType.value) {
@@ -85,7 +130,17 @@ function buildContent(): unknown {
85
130
  case 'video':
86
131
  return { videoUrl: editVideoUrl.value, notes: editMarkdown.value };
87
132
  case 'quiz':
88
- return { questions: editQuiz.value };
133
+ return {
134
+ type: 'quiz',
135
+ passingScore: editPassingScore.value,
136
+ questions: editQuiz.value.map((q) => ({
137
+ id: q.id,
138
+ question: q.question,
139
+ options: q.options.map((o) => ({ id: o.id, text: o.text })),
140
+ correctOptionId: q.correctOptionId,
141
+ ...(q.explanation ? { explanation: q.explanation } : {}),
142
+ })),
143
+ };
89
144
  default:
90
145
  return { markdown: editMarkdown.value };
91
146
  }
@@ -134,10 +189,17 @@ async function unlinkContent(): Promise<void> {
134
189
 
135
190
  // Quiz helpers
136
191
  function addQuestion(): void {
192
+ const options: QuizOption[] = [
193
+ { id: genId(), text: '' },
194
+ { id: genId(), text: '' },
195
+ { id: genId(), text: '' },
196
+ { id: genId(), text: '' },
197
+ ];
137
198
  editQuiz.value.push({
199
+ id: genId(),
138
200
  question: '',
139
- options: ['', '', '', ''],
140
- correctIndex: 0,
201
+ options,
202
+ correctOptionId: options[0]!.id,
141
203
  explanation: '',
142
204
  });
143
205
  }
@@ -147,14 +209,16 @@ function removeQuestion(index: number): void {
147
209
  }
148
210
 
149
211
  function addOption(qIndex: number): void {
150
- editQuiz.value[qIndex]!.options.push('');
212
+ editQuiz.value[qIndex]!.options.push({ id: genId(), text: '' });
151
213
  }
152
214
 
153
215
  function removeOption(qIndex: number, oIndex: number): void {
154
216
  const q = editQuiz.value[qIndex]!;
217
+ const removed = q.options[oIndex];
155
218
  q.options.splice(oIndex, 1);
156
- if (q.correctIndex >= q.options.length) {
157
- q.correctIndex = Math.max(0, q.options.length - 1);
219
+ // If the removed option was the correct answer, reassign to the first remaining.
220
+ if (removed && removed.id === q.correctOptionId) {
221
+ q.correctOptionId = q.options[0]?.id ?? '';
158
222
  }
159
223
  }
160
224
 
@@ -265,7 +329,19 @@ const videoEmbedUrl = computed(() => {
265
329
  <section v-else-if="editType === 'quiz'" class="lesson-section">
266
330
  <h2 class="lesson-section-title">Quiz Questions</h2>
267
331
 
268
- <div v-for="(q, qi) in editQuiz" :key="qi" class="quiz-question-card">
332
+ <div class="form-field" style="margin-bottom: 16px; max-width: 240px;">
333
+ <label for="passing-score" class="form-label">Passing Score (%)</label>
334
+ <input
335
+ id="passing-score"
336
+ v-model.number="editPassingScore"
337
+ type="number"
338
+ min="0"
339
+ max="100"
340
+ class="form-input"
341
+ />
342
+ </div>
343
+
344
+ <div v-for="(q, qi) in editQuiz" :key="q.id" class="quiz-question-card">
269
345
  <div class="quiz-q-header">
270
346
  <span class="quiz-q-number">Q{{ qi + 1 }}</span>
271
347
  <button class="cpub-delete-btn" @click="removeQuestion(qi)" aria-label="Remove question">
@@ -279,11 +355,11 @@ const videoEmbedUrl = computed(() => {
279
355
  </div>
280
356
 
281
357
  <div class="quiz-options">
282
- <div v-for="(opt, oi) in q.options" :key="oi" class="quiz-option-row">
358
+ <div v-for="(opt, oi) in q.options" :key="opt.id" class="quiz-option-row">
283
359
  <label class="quiz-radio-label">
284
- <input type="radio" :name="`q${qi}-correct`" :value="oi" v-model="q.correctIndex" />
360
+ <input type="radio" :name="`q-${q.id}-correct`" :value="opt.id" v-model="q.correctOptionId" />
285
361
  </label>
286
- <input v-model="q.options[oi]" type="text" class="form-input quiz-option-input" :placeholder="`Option ${oi + 1}`" />
362
+ <input v-model="opt.text" type="text" class="form-input quiz-option-input" :placeholder="`Option ${oi + 1}`" />
287
363
  <button v-if="q.options.length > 2" class="cpub-delete-btn cpub-delete-btn-sm" @click="removeOption(qi, oi)" aria-label="Remove option">
288
364
  <i class="fa-solid fa-xmark"></i>
289
365
  </button>
@@ -49,6 +49,27 @@ async function markComplete(): Promise<void> {
49
49
  }
50
50
  }
51
51
 
52
+ // Quiz server-response shape (mirrors @commonpub/learning's QuizGrade).
53
+ interface QuizQuestionResult {
54
+ questionId: string;
55
+ selectedOptionId: string | null;
56
+ correctOptionId: string;
57
+ correct: boolean;
58
+ explanation?: string;
59
+ }
60
+ interface QuizGrade {
61
+ correct: number;
62
+ total: number;
63
+ score: number;
64
+ passed: boolean;
65
+ results: QuizQuestionResult[];
66
+ }
67
+ interface CompleteResponse {
68
+ progress: number;
69
+ certificateIssued: boolean;
70
+ quiz?: QuizGrade;
71
+ }
72
+
52
73
  // Build flat lesson list for prev/next navigation
53
74
  interface FlatLesson {
54
75
  slug: string;
@@ -89,41 +110,78 @@ const videoUrl = computed(() => {
89
110
  return url;
90
111
  });
91
112
 
92
- // Quiz data
113
+ // Quiz data — answers are GRADED SERVER-SIDE. The GET response redacts
114
+ // `correctOptionId` + `explanation` from each question, so the client cannot
115
+ // grade locally; it must POST to /complete and render the server's grade.
116
+ interface QuizOption {
117
+ id: string;
118
+ text: string;
119
+ }
93
120
  interface QuizQuestion {
121
+ id: string;
94
122
  question: string;
95
- options: string[];
96
- correctIndex: number;
97
- explanation: string;
123
+ options: QuizOption[];
124
+ // correctOptionId and explanation are redacted until after submission.
98
125
  }
99
126
 
100
127
  const quizQuestions = computed<QuizQuestion[]>(() => {
101
128
  const content = lesson.value?.content as Record<string, unknown> | null;
102
129
  if (!content || !Array.isArray(content.questions)) return [];
103
- return content.questions as QuizQuestion[];
130
+ return (content.questions as QuizQuestion[]).filter((q) => q && Array.isArray(q.options));
104
131
  });
105
132
 
106
- const quizAnswers = ref<Record<number, number>>({});
107
- const quizSubmitted = ref<Record<number, boolean>>({});
133
+ // Draft answers: questionId → selected optionId. User can change freely
134
+ // until submit.
135
+ const quizAnswers = ref<Record<string, string>>({});
136
+ const quizSubmitting = ref(false);
137
+ const quizGrade = ref<QuizGrade | null>(null);
138
+ // Lookup map from questionId → result for the current submission.
139
+ const quizResultByQuestion = computed<Record<string, QuizQuestionResult>>(() => {
140
+ if (!quizGrade.value) return {};
141
+ const map: Record<string, QuizQuestionResult> = {};
142
+ for (const r of quizGrade.value.results) map[r.questionId] = r;
143
+ return map;
144
+ });
108
145
 
109
- function submitQuizAnswer(qIndex: number, optionIndex: number): void {
110
- if (quizSubmitted.value[qIndex]) return;
111
- quizAnswers.value[qIndex] = optionIndex;
112
- quizSubmitted.value[qIndex] = true;
146
+ function selectAnswer(questionId: string, optionId: string): void {
147
+ if (quizGrade.value) return; // locked after submission until retry
148
+ quizAnswers.value = { ...quizAnswers.value, [questionId]: optionId };
113
149
  }
114
150
 
115
- function isQuizComplete(): boolean {
116
- return quizQuestions.value.length > 0 && quizQuestions.value.every((_, i) => quizSubmitted.value[i]);
117
- }
151
+ const allQuizAnswered = computed(() =>
152
+ quizQuestions.value.length > 0 &&
153
+ quizQuestions.value.every((q) => !!quizAnswers.value[q.id]),
154
+ );
118
155
 
119
- const quizScore = computed(() => {
120
- if (!isQuizComplete()) return null;
121
- let correct = 0;
122
- for (let i = 0; i < quizQuestions.value.length; i++) {
123
- if (quizAnswers.value[i] === quizQuestions.value[i]!.correctIndex) correct++;
156
+ async function submitQuiz(): Promise<void> {
157
+ if (!allQuizAnswered.value || quizSubmitting.value) return;
158
+ quizSubmitting.value = true;
159
+ try {
160
+ const res = await $fetch<CompleteResponse>(
161
+ `/api/learn/${slug.value}/${lessonSlug.value}/complete`,
162
+ { method: 'POST', body: { answers: { ...quizAnswers.value } } },
163
+ );
164
+ if (res.quiz) {
165
+ quizGrade.value = res.quiz;
166
+ if (res.quiz.passed) {
167
+ completed.value = true;
168
+ toast.success(`Passed — ${res.quiz.score}%`);
169
+ } else {
170
+ toast.error(`Scored ${res.quiz.score}% — below passing. Try again.`);
171
+ }
172
+ }
173
+ } catch (err: unknown) {
174
+ const msg = err instanceof Error ? err.message : 'Failed to submit quiz';
175
+ toast.error(msg);
176
+ } finally {
177
+ quizSubmitting.value = false;
124
178
  }
125
- return { correct, total: quizQuestions.value.length };
126
- });
179
+ }
180
+
181
+ function retryQuiz(): void {
182
+ quizAnswers.value = {};
183
+ quizGrade.value = null;
184
+ }
127
185
 
128
186
  // Lesson type icon
129
187
  function getLessonTypeIcon(type: string): string {
@@ -233,7 +291,7 @@ const isOwner = computed(() => user.value?.id === path.value?.author?.id);
233
291
 
234
292
  <!-- QUIZ lesson -->
235
293
  <div v-if="lesson.type === 'quiz' && quizQuestions.length" class="lesson-quiz">
236
- <div v-for="(q, qi) in quizQuestions" :key="qi" class="quiz-card">
294
+ <div v-for="(q, qi) in quizQuestions" :key="q.id" class="quiz-card">
237
295
  <div class="quiz-header">
238
296
  <span class="quiz-badge">QUESTION {{ qi + 1 }}</span>
239
297
  </div>
@@ -241,30 +299,51 @@ const isOwner = computed(() => user.value?.id === path.value?.author?.id);
241
299
  <div class="quiz-options">
242
300
  <button
243
301
  v-for="(opt, oi) in q.options"
244
- :key="oi"
302
+ :key="opt.id"
245
303
  class="quiz-option"
246
304
  :class="{
247
- 'selected-correct': quizSubmitted[qi] && oi === q.correctIndex,
248
- 'selected-wrong': quizSubmitted[qi] && quizAnswers[qi] === oi && oi !== q.correctIndex,
249
- answered: quizSubmitted[qi],
305
+ 'selected-correct': quizResultByQuestion[q.id] && opt.id === quizResultByQuestion[q.id].correctOptionId,
306
+ 'selected-wrong': quizResultByQuestion[q.id] && quizAnswers[q.id] === opt.id && opt.id !== quizResultByQuestion[q.id].correctOptionId,
307
+ 'selected-pending': !quizGrade && quizAnswers[q.id] === opt.id,
308
+ answered: !!quizGrade,
250
309
  }"
251
- :disabled="!!quizSubmitted[qi]"
252
- @click="submitQuizAnswer(qi, oi)"
310
+ :disabled="!!quizGrade"
311
+ :aria-pressed="quizAnswers[q.id] === opt.id"
312
+ @click="selectAnswer(q.id, opt.id)"
253
313
  >
254
314
  <span class="quiz-option-key">{{ String.fromCharCode(65 + oi) }}</span>
255
- <span class="quiz-option-text">{{ opt }}</span>
256
- <span v-if="quizSubmitted[qi] && oi === q.correctIndex" class="quiz-option-indicator"><i class="fa-solid fa-check"></i></span>
257
- <span v-if="quizSubmitted[qi] && quizAnswers[qi] === oi && oi !== q.correctIndex" class="quiz-option-indicator"><i class="fa-solid fa-xmark"></i></span>
315
+ <span class="quiz-option-text">{{ opt.text }}</span>
316
+ <span v-if="quizResultByQuestion[q.id] && opt.id === quizResultByQuestion[q.id].correctOptionId" class="quiz-option-indicator"><i class="fa-solid fa-check"></i></span>
317
+ <span v-if="quizResultByQuestion[q.id] && quizAnswers[q.id] === opt.id && opt.id !== quizResultByQuestion[q.id].correctOptionId" class="quiz-option-indicator"><i class="fa-solid fa-xmark"></i></span>
258
318
  </button>
259
319
  </div>
260
- <div v-if="quizSubmitted[qi] && q.explanation" class="quiz-explanation">
261
- <i class="fa-solid fa-lightbulb"></i> {{ q.explanation }}
320
+ <div v-if="quizResultByQuestion[q.id]?.explanation" class="quiz-explanation">
321
+ <i class="fa-solid fa-lightbulb"></i> {{ quizResultByQuestion[q.id]!.explanation }}
262
322
  </div>
263
323
  </div>
264
324
 
265
- <div v-if="quizScore" class="quiz-score">
266
- <div class="quiz-score-value">{{ quizScore.correct }} / {{ quizScore.total }}</div>
267
- <div class="quiz-score-label">correct answers</div>
325
+ <!-- Submit / result / retry -->
326
+ <div class="quiz-actions">
327
+ <button
328
+ v-if="!quizGrade"
329
+ class="quiz-submit-btn"
330
+ :disabled="!allQuizAnswered || quizSubmitting || !isAuthenticated"
331
+ @click="submitQuiz"
332
+ >
333
+ <i class="fa-solid fa-check-double"></i>
334
+ {{ quizSubmitting ? 'Grading...' : 'Submit Quiz' }}
335
+ </button>
336
+ <p v-if="!isAuthenticated" class="quiz-signin-hint">Sign in to submit this quiz.</p>
337
+ </div>
338
+
339
+ <div v-if="quizGrade" class="quiz-score" :class="{ passed: quizGrade.passed, failed: !quizGrade.passed }">
340
+ <div class="quiz-score-value">{{ quizGrade.correct }} / {{ quizGrade.total }}</div>
341
+ <div class="quiz-score-label">
342
+ {{ quizGrade.score }}% — {{ quizGrade.passed ? 'Passed' : 'Did not pass' }}
343
+ </div>
344
+ <button v-if="!quizGrade.passed" class="quiz-retry-btn" @click="retryQuiz">
345
+ <i class="fa-solid fa-rotate-right"></i> Try Again
346
+ </button>
268
347
  </div>
269
348
  </div>
270
349
 
@@ -279,7 +358,7 @@ const isOwner = computed(() => user.value?.id === path.value?.author?.id);
279
358
 
280
359
  <!-- Footer: Mark Complete + Prev/Next -->
281
360
  <footer class="lesson-footer">
282
- <div v-if="isAuthenticated" class="lesson-complete-row">
361
+ <div v-if="isAuthenticated && lesson.type !== 'quiz'" class="lesson-complete-row">
283
362
  <button v-if="!completed" class="lesson-complete-btn" @click="markComplete" :disabled="completing">
284
363
  <i class="fa-solid fa-check-circle"></i> {{ completing ? 'Marking...' : 'Mark as Complete' }}
285
364
  </button>
@@ -287,6 +366,11 @@ const isOwner = computed(() => user.value?.id === path.value?.author?.id);
287
366
  <i class="fa-solid fa-check-circle"></i> Completed
288
367
  </div>
289
368
  </div>
369
+ <div v-else-if="isAuthenticated && completed" class="lesson-complete-row">
370
+ <div class="lesson-completed-badge">
371
+ <i class="fa-solid fa-check-circle"></i> Completed
372
+ </div>
373
+ </div>
290
374
 
291
375
  <div class="lesson-nav-footer">
292
376
  <NuxtLink v-if="prevLesson" :to="`/learn/${slug}/${prevLesson.slug}`" class="lesson-nav-btn lesson-nav-prev">
@@ -396,10 +480,24 @@ const isOwner = computed(() => user.value?.id === path.value?.author?.id);
396
480
  .quiz-option-indicator { font-size: 12px; flex-shrink: 0; }
397
481
  .quiz-option.selected-correct .quiz-option-indicator { color: var(--green); }
398
482
  .quiz-option.selected-wrong .quiz-option-indicator { color: var(--red); }
483
+ .quiz-option.selected-pending { background: var(--accent-bg); border-color: var(--accent); }
484
+ .quiz-option.selected-pending .quiz-option-key { color: var(--accent); }
485
+ .quiz-option.selected-pending .quiz-option-text { color: var(--text); }
399
486
  .quiz-explanation { margin-top: 12px; padding: 10px 14px; background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); color: var(--accent); font-size: 13px; display: flex; align-items: flex-start; gap: 8px; line-height: 1.5; }
487
+ .quiz-actions { margin: 16px 0; display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
488
+ .quiz-submit-btn { padding: 10px 20px; background: var(--accent); color: var(--color-text-inverse); border: var(--border-width-default) solid var(--border); font-size: 13px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 8px; box-shadow: var(--shadow-md); font-family: inherit; }
489
+ .quiz-submit-btn:hover:not(:disabled) { box-shadow: var(--shadow-sm); transform: translate(1px, 1px); }
490
+ .quiz-submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
491
+ .quiz-signin-hint { font-size: 12px; color: var(--text-faint); font-family: var(--font-mono); }
400
492
  .quiz-score { text-align: center; padding: 20px; border: var(--border-width-default) solid var(--green); background: var(--green-bg); }
493
+ .quiz-score.passed { border-color: var(--green); background: var(--green-bg); }
494
+ .quiz-score.passed .quiz-score-value, .quiz-score.passed .quiz-score-label { color: var(--green); }
495
+ .quiz-score.failed { border-color: var(--red); background: var(--red-bg); }
496
+ .quiz-score.failed .quiz-score-value, .quiz-score.failed .quiz-score-label { color: var(--red); }
401
497
  .quiz-score-value { font-size: 28px; font-weight: 700; font-family: var(--font-mono); color: var(--green); }
402
498
  .quiz-score-label { font-size: 11px; font-family: var(--font-mono); color: var(--green); text-transform: uppercase; letter-spacing: 0.08em; }
499
+ .quiz-retry-btn { margin-top: 12px; padding: 8px 16px; background: var(--surface); color: var(--text); border: var(--border-width-default) solid var(--border); font-size: 12px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; font-family: inherit; }
500
+ .quiz-retry-btn:hover { background: var(--surface2); }
403
501
 
404
502
  /* Empty state */
405
503
  .lesson-empty { color: var(--text-faint); font-size: 13px; text-align: center; padding: 48px 0; display: flex; flex-direction: column; align-items: center; gap: 8px; }