@abreen/tada 1.14.1 → 1.15.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 CHANGED
@@ -27,7 +27,7 @@ A static site generator. The successor to Presto.
27
27
  * Renders step-by-step source highlighting, output, and memory diagrams
28
28
  - Slides mode: separate content by `---` and present sections as full-screen slides
29
29
  - PDF files are copied into `dist/`
30
- - Text of each PDF page is extracted using `mutool` (if present) and indexed
30
+ * Text of each PDF page is extracted using `mutool` (if present) and indexed
31
31
  - External link handling (special visual treatment for external links)
32
32
  - Internal link validation at build time (broken links fail the build)
33
33
  - Internal links automatically prefixed with base path, if specified
@@ -37,7 +37,8 @@ A static site generator. The successor to Presto.
37
37
  * `<<< details ... <<<` renders a collapsible box
38
38
  * `::: section ... :::` renders a special section with a fancy background
39
39
  * `!!! note ... !!!` and `!!! warning ... !!!` render alert boxes
40
- * `??? question ... ???` renders a Q & A section; answers hidden until click
40
+ * `??? question ... ???` renders a Q&A section; answer is hidden until click
41
+ - To display clickable multiple choice options, use a [task list][task-list]
41
42
  * `+++ ... +++ ... +++` renders a two-column layout
42
43
  * Special heading subtitles with `## Heading # A subtitle here`
43
44
  * `{{{ _partial.md }}}` syntax for including partials
@@ -387,3 +388,4 @@ which results in:
387
388
  [jep467]: https://openjdk.org/jeps/467
388
389
  [katex]: https://katex.org/
389
390
  [homebrew]: https://brew.sh/
391
+ [task-list]: https://github.github.com/gfm/#task-list-items-extension-
@@ -24,6 +24,7 @@ import type { RenderDependencyCollector, SiteVariables } from '../types';
24
24
  const DETAILS_PATTERN = /^details\s+(.*)$/;
25
25
  const ALERT_PATTERN = /^(note|warning)(?:\s+"(.+)"|\s+(.+))?$/;
26
26
  const QUESTION_PATTERN = /^question\s+(.+)$/;
27
+ const MULTIPLE_CHOICE_MARKER_LENGTH = 4;
27
28
 
28
29
  interface CreateMarkdownOptions {
29
30
  filePath?: string;
@@ -36,6 +37,16 @@ interface CreateMarkdownOptions {
36
37
  dependencyCollector?: RenderDependencyCollector;
37
38
  }
38
39
 
40
+ interface MultipleChoiceOption {
41
+ correct: boolean;
42
+ inline: Token;
43
+ }
44
+
45
+ interface MultipleChoiceMeta {
46
+ question: string;
47
+ options: MultipleChoiceOption[];
48
+ }
49
+
39
50
  function capitalize(str: string): string {
40
51
  return str.charAt(0).toUpperCase() + str.slice(1);
41
52
  }
@@ -123,6 +134,132 @@ export function createMarkdown(
123
134
  return token;
124
135
  }
125
136
 
137
+ function cloneToken(token: Token): Token {
138
+ const cloned = new Token(token.type, token.tag, token.nesting);
139
+ cloned.attrs = token.attrs?.map(([name, value]) => [name, value]) ?? null;
140
+ cloned.block = token.block;
141
+ cloned.children = token.children?.map(cloneToken) ?? null;
142
+ cloned.content = token.content;
143
+ cloned.hidden = token.hidden;
144
+ cloned.info = token.info;
145
+ cloned.level = token.level;
146
+ cloned.map = token.map ? [token.map[0], token.map[1]] : null;
147
+ cloned.markup = token.markup;
148
+ cloned.meta = token.meta;
149
+ return cloned;
150
+ }
151
+
152
+ function parseMultipleChoiceOption(
153
+ inline: Token,
154
+ ): MultipleChoiceOption | null {
155
+ const firstChild = inline.children?.[0];
156
+ if (firstChild?.type !== 'text') {
157
+ return null;
158
+ }
159
+
160
+ let correct: boolean;
161
+ if (firstChild.content.startsWith('[ ] ')) {
162
+ correct = false;
163
+ } else if (
164
+ firstChild.content.startsWith('[x] ') ||
165
+ firstChild.content.startsWith('[X] ')
166
+ ) {
167
+ correct = true;
168
+ } else {
169
+ return null;
170
+ }
171
+
172
+ const optionInline = cloneToken(inline);
173
+ optionInline.content = inline.content.slice(MULTIPLE_CHOICE_MARKER_LENGTH);
174
+ if (optionInline.children?.[0]?.type === 'text') {
175
+ optionInline.children[0].content = optionInline.children[0].content.slice(
176
+ MULTIPLE_CHOICE_MARKER_LENGTH,
177
+ );
178
+ }
179
+ if (slides) {
180
+ sanitizeTokenForSlides(optionInline);
181
+ }
182
+
183
+ return { correct, inline: optionInline };
184
+ }
185
+
186
+ function findQuestionCloseIndex(tokens: Token[], openIndex: number): number {
187
+ const level = tokens[openIndex].level;
188
+ for (let i = openIndex + 1; i < tokens.length; i++) {
189
+ const token = tokens[i];
190
+ if (token.type === 'container_question_close' && token.level === level) {
191
+ return i;
192
+ }
193
+ }
194
+ return -1;
195
+ }
196
+
197
+ function multipleChoiceError(message: string): Error {
198
+ return new Error(
199
+ (filePath ? `${filePath}: ` : '') + `multiple choice question ${message}`,
200
+ );
201
+ }
202
+
203
+ function parseMultipleChoiceQuestion(
204
+ tokens: Token[],
205
+ openIndex: number,
206
+ closeIndex: number,
207
+ ): MultipleChoiceMeta | null {
208
+ const questionMatch = tokens[openIndex].info.trim().match(QUESTION_PATTERN);
209
+ if (!questionMatch) {
210
+ return null;
211
+ }
212
+
213
+ const bodyStart = openIndex + 1;
214
+ const bodyEnd = closeIndex - 1;
215
+ if (
216
+ bodyStart > bodyEnd ||
217
+ tokens[bodyStart].type !== 'bullet_list_open' ||
218
+ tokens[bodyEnd].type !== 'bullet_list_close'
219
+ ) {
220
+ return null;
221
+ }
222
+
223
+ const options: MultipleChoiceOption[] = [];
224
+ for (let i = bodyStart + 1; i < bodyEnd; ) {
225
+ const listItemOpen = tokens[i];
226
+ const paragraphOpen = tokens[i + 1];
227
+ const inline = tokens[i + 2];
228
+ const paragraphClose = tokens[i + 3];
229
+ const listItemClose = tokens[i + 4];
230
+ if (
231
+ listItemOpen?.type !== 'list_item_open' ||
232
+ paragraphOpen?.type !== 'paragraph_open' ||
233
+ inline?.type !== 'inline' ||
234
+ paragraphClose?.type !== 'paragraph_close' ||
235
+ listItemClose?.type !== 'list_item_close'
236
+ ) {
237
+ return null;
238
+ }
239
+
240
+ const option = parseMultipleChoiceOption(inline);
241
+ if (!option) {
242
+ return null;
243
+ }
244
+
245
+ options.push(option);
246
+ i += 5;
247
+ }
248
+
249
+ if (options.length === 0) {
250
+ return null;
251
+ }
252
+
253
+ const correctCount = options.filter(option => option.correct).length;
254
+ if (correctCount !== 1) {
255
+ throw multipleChoiceError(
256
+ `must include exactly one correct option marked with [x], but found ${correctCount}`,
257
+ );
258
+ }
259
+
260
+ return { question: questionMatch[1], options };
261
+ }
262
+
126
263
  function isInlineOnlyTokenStream(tokens: Token[]): boolean {
127
264
  return (
128
265
  tokens.length === 1 &&
@@ -434,6 +571,89 @@ export function createMarkdown(
434
571
  },
435
572
  });
436
573
 
574
+ markdown.core.ruler.before(
575
+ 'slides_transform',
576
+ 'multiple_choice_questions',
577
+ state => {
578
+ const transformedTokens: Token[] = [];
579
+ for (let i = 0; i < state.tokens.length; i++) {
580
+ const token = state.tokens[i];
581
+ if (token.type !== 'container_question_open') {
582
+ transformedTokens.push(token);
583
+ continue;
584
+ }
585
+
586
+ const closeIndex = findQuestionCloseIndex(state.tokens, i);
587
+ if (closeIndex === -1) {
588
+ transformedTokens.push(token);
589
+ continue;
590
+ }
591
+
592
+ const multipleChoice = parseMultipleChoiceQuestion(
593
+ state.tokens,
594
+ i,
595
+ closeIndex,
596
+ );
597
+ if (!multipleChoice) {
598
+ transformedTokens.push(...state.tokens.slice(i, closeIndex + 1));
599
+ i = closeIndex;
600
+ continue;
601
+ }
602
+
603
+ const multipleChoiceToken = new Token(
604
+ 'question_multiple_choice',
605
+ '',
606
+ 0,
607
+ );
608
+ multipleChoiceToken.block = true;
609
+ multipleChoiceToken.level = token.level;
610
+ multipleChoiceToken.map = token.map;
611
+ multipleChoiceToken.meta = multipleChoice;
612
+ transformedTokens.push(multipleChoiceToken);
613
+ i = closeIndex;
614
+ }
615
+
616
+ state.tokens = transformedTokens;
617
+ },
618
+ );
619
+
620
+ markdown.renderer.rules.question_multiple_choice = (
621
+ tokens,
622
+ idx,
623
+ renderOptions,
624
+ env,
625
+ ) => {
626
+ const meta = tokens[idx].meta as MultipleChoiceMeta;
627
+ const question = markdown.renderInline(meta.question);
628
+ const optionsHtml = meta.options
629
+ .map(option => {
630
+ const correctAttribute = option.correct ? ' data-correct=""' : '';
631
+ const optionHtml = markdown.renderer.render(
632
+ [option.inline],
633
+ renderOptions,
634
+ env,
635
+ );
636
+ return (
637
+ '<div class="question-multiple-choice-option"' +
638
+ correctAttribute +
639
+ '>' +
640
+ optionHtml +
641
+ '</div>'
642
+ );
643
+ })
644
+ .join('');
645
+
646
+ return (
647
+ '<div class="question question-multiple-choice">' +
648
+ '<p class="question-q"><span class="question-label">Q.</span><span>' +
649
+ question +
650
+ '</span></p>' +
651
+ '<div class="question-multiple-choice-options">' +
652
+ optionsHtml +
653
+ '</div></div>\n'
654
+ );
655
+ };
656
+
437
657
  /*
438
658
  * Customize markdown-it-footnote renderer.
439
659
  *
@@ -42,6 +42,10 @@ function hashTraceFiles(files: { name: string; content: string }[]): string {
42
42
  return `sha256-${hasher.digest('hex').slice(0, 16)}`;
43
43
  }
44
44
 
45
+ function safeSvgIdPart(value: string): string {
46
+ return value.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
47
+ }
48
+
45
49
  function buildTraceOutputPath(
46
50
  relDir: string,
47
51
  traceName: string,
@@ -181,13 +185,19 @@ export function chunkTraceOutput(
181
185
 
182
186
  const layoutSteps = allSteps.map(step => ({ ...step, ...filterStep(step) }));
183
187
  const layout = computeLayout(layoutSteps, ignoreFields);
188
+ const svgIdPrefix = safeSvgIdPart(traceName) || 'trace';
184
189
 
185
190
  let chunkEntries: TraceChunkEntry[] = [];
186
191
  let chunkIndex = 0;
187
192
  const chunkFiles: { name: string; content: string }[] = [];
188
193
 
189
- for (const step of allSteps) {
190
- const svg = generateStepSvg(step, layout);
194
+ for (const [stepIndex, step] of allSteps.entries()) {
195
+ const svg = generateStepSvg(
196
+ step,
197
+ layout,
198
+ undefined,
199
+ `trace-${svgIdPrefix}-${stepIndex}`,
200
+ );
191
201
  chunkEntries.push({
192
202
  file: path.basename(step.file),
193
203
  line: step.line,
@@ -545,6 +545,7 @@ function generateArrows(
545
545
  stackLayouts: FrameLayout[],
546
546
  heapObjMap: Map<string, HeapObjInfo>,
547
547
  dims: { rowH: number; fontSize: number },
548
+ markerIds: { arrowhead: string; arrowheadDim: string },
548
549
  ): string[] {
549
550
  const parts: string[] = [];
550
551
 
@@ -558,7 +559,8 @@ function generateArrows(
558
559
  const ep = heapTargetBoundaryPoint(sx, sy, target, heap[target.id], dims);
559
560
  const cpx = Math.round((sx + ep.x) / 2);
560
561
  const cls = style === 'fg' ? 'trace-arrow' : 'trace-arrow-dim';
561
- const marker = style === 'fg' ? 'trace-arrowhead' : 'trace-arrowhead-dim';
562
+ const marker =
563
+ style === 'fg' ? markerIds.arrowhead : markerIds.arrowheadDim;
562
564
  return (
563
565
  `<path class="${cls}" d="` +
564
566
  `M ${sx} ${sy} Q ${cpx} ${sy}, ${ep.x} ${ep.y}" ` +
@@ -577,7 +579,7 @@ function generateArrows(
577
579
  if (Math.abs(tx - sx) < 4) {
578
580
  return (
579
581
  `<path class="trace-arrow-heap" d="M ${sx} ${sy} L ${tx} ${ty}" ` +
580
- `marker-end="url(#trace-arrowhead-dim)"/>`
582
+ `marker-end="url(#${markerIds.arrowheadDim})"/>`
581
583
  );
582
584
  }
583
585
 
@@ -600,7 +602,7 @@ function generateArrows(
600
602
 
601
603
  return (
602
604
  `<path class="trace-arrow-heap" d="${path}" ` +
603
- `marker-end="url(#trace-arrowhead-dim)"/>`
605
+ `marker-end="url(#${markerIds.arrowheadDim})"/>`
604
606
  );
605
607
  }
606
608
 
@@ -644,7 +646,7 @@ function generateArrows(
644
646
  `<path class="trace-arrow-heap" d="` +
645
647
  `M ${sx} ${sy} C ${Math.round(cp1x)} ${Math.round(cp1y)}, ` +
646
648
  `${Math.round(cp2x)} ${Math.round(cp2y)}, ${ep.x} ${ep.y}" ` +
647
- `marker-end="url(#trace-arrowhead-dim)"/>`
649
+ `marker-end="url(#${markerIds.arrowheadDim})"/>`
648
650
  );
649
651
  }
650
652
 
@@ -772,6 +774,7 @@ export function generateStepSvg(
772
774
  step: TraceStep,
773
775
  layout: TraceLayout,
774
776
  fontSize: number = DEFAULT_FONT_SIZE,
777
+ markerIdPrefix = 'trace',
775
778
  ): string {
776
779
  const { stack, heap } = filterStep(step);
777
780
 
@@ -836,6 +839,10 @@ export function generateStepSvg(
836
839
 
837
840
  // Build SVG
838
841
  const parts: string[] = [];
842
+ const markerIds = {
843
+ arrowhead: `${markerIdPrefix}-arrowhead`,
844
+ arrowheadDim: `${markerIdPrefix}-arrowhead-dim`,
845
+ };
839
846
  parts.push(
840
847
  `<svg class="trace-memory" viewBox="0 0 ${totalWidth} ${totalHeight}" ` +
841
848
  `width="${totalWidth}" height="${totalHeight}" ` +
@@ -845,13 +852,13 @@ export function generateStepSvg(
845
852
  // Defs: arrowhead markers
846
853
  parts.push('<defs>');
847
854
  parts.push(
848
- '<marker id="trace-arrowhead" markerWidth="8" markerHeight="6" ' +
855
+ `<marker id="${markerIds.arrowhead}" markerWidth="8" markerHeight="6" ` +
849
856
  'refX="8" refY="3" orient="auto" markerUnits="strokeWidth">',
850
857
  );
851
858
  parts.push('<polygon class="trace-arrowhead" points="0 0, 8 3, 0 6"/>');
852
859
  parts.push('</marker>');
853
860
  parts.push(
854
- '<marker id="trace-arrowhead-dim" markerWidth="8" markerHeight="6" ' +
861
+ `<marker id="${markerIds.arrowheadDim}" markerWidth="8" markerHeight="6" ` +
855
862
  'refX="8" refY="3" orient="auto" markerUnits="strokeWidth">',
856
863
  );
857
864
  parts.push('<polygon class="trace-arrowhead-dim" points="0 0, 8 3, 0 6"/>');
@@ -884,6 +891,7 @@ export function generateStepSvg(
884
891
  stackLayouts,
885
892
  heapObjMap,
886
893
  dims,
894
+ markerIds,
887
895
  );
888
896
  for (const arrow of arrowParts) {
889
897
  parts.push(arrow);
@@ -293,6 +293,16 @@ solved directly without any further recursive calls. For recursive methods that
293
293
  process strings, the base case is the empty string.
294
294
  ???
295
295
 
296
+ If the body is a [task list][task-list] with exactly one `[x]` option, it
297
+ becomes a multiple choice question with clickable options:
298
+
299
+ ??? question Which is correct for obtaining the number of characters in a `String`?
300
+ - [ ] `word.size()`
301
+ - [x] `word.length()`
302
+ - [ ] `word.count()`
303
+ - [ ] `word.length`
304
+ ???
305
+
296
306
 
297
307
  ### Generic section
298
308
 
@@ -438,3 +448,4 @@ Here's a time range:
438
448
  [flavored]: https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-tables
439
449
  [presto]: https://github.com/abreen/presto
440
450
  [shiki]: https://shiki.style/
451
+ [task-list]: https://github.github.com/gfm/#task-list-items-extension-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abreen/tada",
3
- "version": "1.14.1",
3
+ "version": "1.15.0",
4
4
  "type": "module",
5
5
  "description": "A static site generator",
6
6
  "license": "MIT",
package/src/_base.scss CHANGED
@@ -60,6 +60,19 @@ html {
60
60
  background: var(--bg-color);
61
61
  }
62
62
 
63
+ .visually-hidden {
64
+ position: absolute !important;
65
+ width: 1px !important;
66
+ height: 1px !important;
67
+ padding: 0 !important;
68
+ margin: -1px !important;
69
+ overflow: hidden !important;
70
+ white-space: nowrap !important;
71
+ border: 0 !important;
72
+ clip: rect(0, 0, 0, 0) !important;
73
+ clip-path: inset(50%) !important;
74
+ }
75
+
63
76
  a {
64
77
  color: var(--link-color);
65
78
  text-decoration-line: none;
@@ -1,4 +1,64 @@
1
1
  export default (window: Window) => {
2
+ const appendResultText = (option: HTMLElement, text: string) => {
3
+ const result = window.document.createElement('span');
4
+ result.className = 'visually-hidden question-multiple-choice-result';
5
+ result.textContent = text;
6
+ option.append(result);
7
+ };
8
+
9
+ const multipleChoiceBlocks = window.document.querySelectorAll<HTMLElement>(
10
+ '.question-multiple-choice',
11
+ );
12
+ multipleChoiceBlocks.forEach(block => {
13
+ const options = Array.from(
14
+ block.querySelectorAll<HTMLElement>('.question-multiple-choice-option'),
15
+ );
16
+ options.forEach(option => {
17
+ option.setAttribute('role', 'button');
18
+ option.setAttribute('tabindex', '0');
19
+ });
20
+
21
+ const reveal = (selected: HTMLElement) => {
22
+ block.setAttribute('data-revealed', '');
23
+ selected.setAttribute('data-selected', '');
24
+ options.forEach(option => {
25
+ option.removeAttribute('role');
26
+ option.removeAttribute('tabindex');
27
+ if (option.hasAttribute('data-correct')) {
28
+ appendResultText(
29
+ option,
30
+ option === selected ? 'Selected answer, correct' : 'Correct answer',
31
+ );
32
+ } else if (option === selected) {
33
+ appendResultText(option, 'Selected answer, incorrect');
34
+ }
35
+ });
36
+ };
37
+
38
+ options.forEach(option => {
39
+ option.addEventListener('click', (e: MouseEvent) => {
40
+ if (block.hasAttribute('data-revealed')) {
41
+ return;
42
+ }
43
+
44
+ e.preventDefault();
45
+ e.stopPropagation();
46
+ reveal(option);
47
+ });
48
+ option.addEventListener('keydown', (e: KeyboardEvent) => {
49
+ if (block.hasAttribute('data-revealed')) {
50
+ return;
51
+ }
52
+
53
+ if (e.key === 'Enter' || e.key === ' ') {
54
+ e.preventDefault();
55
+ e.stopPropagation();
56
+ reveal(option);
57
+ }
58
+ });
59
+ });
60
+ });
61
+
2
62
  const bodies =
3
63
  window.document.querySelectorAll<HTMLElement>('.question-a-body');
4
64
  bodies.forEach(el => {
@@ -13,10 +73,22 @@ export default (window: Window) => {
13
73
  el.removeAttribute('aria-label');
14
74
  };
15
75
 
16
- el.addEventListener('click', reveal);
76
+ el.addEventListener('click', (e: MouseEvent) => {
77
+ if (el.hasAttribute('data-revealed')) {
78
+ return;
79
+ }
80
+
81
+ e.stopPropagation();
82
+ reveal();
83
+ });
17
84
  el.addEventListener('keydown', (e: KeyboardEvent) => {
85
+ if (el.hasAttribute('data-revealed')) {
86
+ return;
87
+ }
88
+
18
89
  if (e.key === 'Enter' || e.key === ' ') {
19
90
  e.preventDefault();
91
+ e.stopPropagation();
20
92
  reveal();
21
93
  }
22
94
  });
@@ -1,6 +1,8 @@
1
1
  @use '../mixins' as *;
2
2
 
3
3
  .question {
4
+ --question-label-width: 1.2em;
5
+
4
6
  margin-bottom: 2rem;
5
7
  }
6
8
 
@@ -17,7 +19,7 @@
17
19
 
18
20
  .question-label,
19
21
  .question-a-label {
20
- flex-shrink: 0;
22
+ flex: 0 0 var(--question-label-width);
21
23
  margin: 0;
22
24
  font-weight: 700;
23
25
  color: var(--theme-color-text);
@@ -32,17 +34,20 @@
32
34
 
33
35
  .question-a-body {
34
36
  flex: 1;
37
+ }
38
+
39
+ .js .question-a-body {
35
40
  cursor: pointer;
36
41
 
37
42
  @include user-select(none);
38
43
  }
39
44
 
40
- .question-a-body:focus-visible {
45
+ .js .question-a-body:focus-visible {
41
46
  outline: 2px solid var(--theme-color);
42
47
  outline-offset: -2px;
43
48
  }
44
49
 
45
- .question-a-body[data-revealed] {
50
+ .js .question-a-body[data-revealed] {
46
51
  &:focus,
47
52
  &:focus-visible {
48
53
  outline: none;
@@ -68,7 +73,7 @@
68
73
  }
69
74
  }
70
75
 
71
- .question-a-body:not([data-revealed]) > * {
76
+ .js .question-a-body:not([data-revealed]) > * {
72
77
  color: transparent;
73
78
  pointer-events: none;
74
79
  background-color: var(--bg2-color);
@@ -76,14 +81,14 @@
76
81
  box-shadow: none;
77
82
  }
78
83
 
79
- .question-a-body:not([data-revealed]) > * * {
84
+ .js .question-a-body:not([data-revealed]) > * * {
80
85
  color: transparent;
81
86
  background-color: var(--bg2-color);
82
87
  border-color: transparent;
83
88
  box-shadow: none;
84
89
  }
85
90
 
86
- .question-a-body::after {
91
+ .js .question-a-body::after {
87
92
  display: block;
88
93
  font-size: var(--font-size-small);
89
94
  font-style: italic;
@@ -97,22 +102,112 @@
97
102
  }
98
103
  }
99
104
 
100
- .question-a-body[data-revealed]::after {
105
+ .js .question-a-body[data-revealed]::after {
101
106
  opacity: 0;
102
107
  }
103
108
 
104
109
  @media (hover: none) {
105
- .question-a-body::after {
110
+ .js .question-a-body::after {
106
111
  content: 'Tap to reveal';
107
112
  }
108
113
  }
109
114
 
110
- .question-a-body[data-revealed] {
115
+ .js .question-a-body[data-revealed] {
111
116
  cursor: unset;
112
117
 
113
118
  @include user-select(text);
114
119
  }
115
120
 
121
+ .question-multiple-choice {
122
+ --question-correct-color: hsl(142deg 58% 36%);
123
+ --question-correct-icon: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10' fill='none' stroke='black' stroke-width='1.5'/><path d='M7 12.5 10.5 16 17 8.5' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'/></svg>");
124
+ --question-incorrect-color: hsl(0deg 72% 48%);
125
+ --question-incorrect-icon: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M7 7 17 17 M17 7 7 17' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round'/></svg>");
126
+ --question-result-icon-size: 1.35em;
127
+ }
128
+
129
+ @media (prefers-color-scheme: dark) {
130
+ .question-multiple-choice {
131
+ --question-correct-color: hsl(142deg 58% 52%);
132
+ --question-incorrect-color: hsl(0deg 82% 62%);
133
+ }
134
+ }
135
+
136
+ .question-multiple-choice-options {
137
+ display: flex;
138
+ flex-direction: column;
139
+ gap: 0.75em;
140
+ margin-left: calc(var(--question-label-width) + 0.5em);
141
+ }
142
+
143
+ .question-multiple-choice-option {
144
+ position: relative;
145
+ padding: 0.5em calc(var(--question-result-icon-size) + 1em) 0.5em 0.5em;
146
+ cursor: pointer;
147
+ border: 0.125em solid var(--bg2-color);
148
+ border-radius: var(--border-radius);
149
+
150
+ @include user-select(none);
151
+
152
+ @media (prefers-reduced-motion: no-preference) {
153
+ transition:
154
+ border-color 0.25s,
155
+ box-shadow 0.25s;
156
+ }
157
+ }
158
+
159
+ .question-multiple-choice-option::after {
160
+ position: absolute;
161
+ top: 50%;
162
+ right: 0.5em;
163
+ display: none;
164
+ width: var(--question-result-icon-size);
165
+ height: var(--question-result-icon-size);
166
+ pointer-events: none;
167
+ content: '';
168
+ transform: translateY(-50%);
169
+ }
170
+
171
+ .question-multiple-choice-option:focus-visible {
172
+ outline: 2px solid var(--theme-color);
173
+ outline-offset: 2px;
174
+ }
175
+
176
+ .question-multiple-choice[data-revealed] {
177
+ .question-multiple-choice-option {
178
+ cursor: unset;
179
+
180
+ @include user-select(text);
181
+
182
+ &:focus,
183
+ &:focus-visible {
184
+ outline: none;
185
+ }
186
+ }
187
+
188
+ .question-multiple-choice-option[data-correct] {
189
+ border-color: var(--question-correct-color);
190
+
191
+ &::after {
192
+ display: block;
193
+ color: var(--question-correct-color);
194
+
195
+ @include contained-mask(var(--question-correct-icon));
196
+ }
197
+ }
198
+
199
+ .question-multiple-choice-option[data-selected]:not([data-correct]) {
200
+ border-color: var(--question-incorrect-color);
201
+
202
+ &::after {
203
+ display: block;
204
+ color: var(--question-incorrect-color);
205
+
206
+ @include contained-mask(var(--question-incorrect-icon));
207
+ }
208
+ }
209
+ }
210
+
116
211
  @media print {
117
212
  .question-a-body {
118
213
  cursor: default;
@@ -134,4 +229,21 @@
134
229
  display: none;
135
230
  }
136
231
  }
232
+
233
+ .question-multiple-choice-option {
234
+ cursor: default;
235
+
236
+ @include user-select(text);
237
+ }
238
+
239
+ .question-multiple-choice-option[data-correct] {
240
+ border-color: var(--question-correct-color);
241
+
242
+ &::after {
243
+ display: block;
244
+ color: var(--question-correct-color);
245
+
246
+ @include contained-mask(var(--question-correct-icon));
247
+ }
248
+ }
137
249
  }
@@ -23,7 +23,7 @@ function isInteractive(
23
23
  target instanceof view.Element ? target : target.parentElement;
24
24
  return Boolean(
25
25
  element?.closest(
26
- 'a, button, input, select, textarea, summary, label, [role="button"], [contenteditable=""], [contenteditable="true"], .question-a-body',
26
+ 'a, button, input, select, textarea, summary, label, [role="button"], [contenteditable=""], [contenteditable="true"]',
27
27
  ),
28
28
  );
29
29
  }
@@ -33,6 +33,7 @@
33
33
  <meta property="og:author" content="<%= page.author.name %>" />
34
34
  <% } %>
35
35
  <title><%= page.title %><%= site.titlePostfix %></title>
36
+ <script>document.documentElement.classList.add('js')</script>
36
37
  <% if (!isWatchMode) { %>
37
38
  <script type="speculationrules">
38
39
  {
@@ -47,10 +48,9 @@
47
48
  <% } %>
48
49
  <script>document.head.appendChild(document.createElement('style')).textContent='.time-zone-label[hidden],select.time-zone[hidden]{display:revert}'</script>
49
50
  <noscript><style>
50
- .question-a-body:not([data-revealed]) > * { color: unset; background-color: unset; }
51
- .question-a-body:not([data-revealed]) > * * { color: unset; background-color: unset; }
52
- .question-a-body::after { display: none; }
53
- .question-a-body { cursor: unset; }
51
+ .question-multiple-choice-option { cursor: unset; }
52
+ .question-multiple-choice-option[data-correct] { border-color: hsl(142deg 58% 36%); }
53
+ .question-multiple-choice-option[data-correct]::after { display: block; background-color: hsl(142deg 58% 36%); -webkit-mask-image: var(--question-correct-icon); mask-image: var(--question-correct-icon); -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; -webkit-mask-position: center center; mask-position: center center; -webkit-mask-size: contain; mask-size: contain; }
54
54
  </style></noscript>
55
55
  </head>
56
56