diff --git a/web/public/dev-placeholders/block.svg b/web/public/dev-placeholders/block.svg
new file mode 100644
index 00000000..cf62addc
--- /dev/null
+++ b/web/public/dev-placeholders/block.svg
@@ -0,0 +1,4 @@
+
diff --git a/web/public/dev-placeholders/inline-1.svg b/web/public/dev-placeholders/inline-1.svg
new file mode 100644
index 00000000..20902743
--- /dev/null
+++ b/web/public/dev-placeholders/inline-1.svg
@@ -0,0 +1,4 @@
+
diff --git a/web/public/dev-placeholders/inline-2.svg b/web/public/dev-placeholders/inline-2.svg
new file mode 100644
index 00000000..ee34fb0c
--- /dev/null
+++ b/web/public/dev-placeholders/inline-2.svg
@@ -0,0 +1,4 @@
+
diff --git a/web/public/dev-placeholders/inline-A.svg b/web/public/dev-placeholders/inline-A.svg
new file mode 100644
index 00000000..1d459ce7
--- /dev/null
+++ b/web/public/dev-placeholders/inline-A.svg
@@ -0,0 +1,4 @@
+
diff --git a/web/public/dev-placeholders/inline-equiv.svg b/web/public/dev-placeholders/inline-equiv.svg
new file mode 100644
index 00000000..2e579aea
--- /dev/null
+++ b/web/public/dev-placeholders/inline-equiv.svg
@@ -0,0 +1,4 @@
+
diff --git a/web/scripts/__fixtures__/validate-md/invalid-image-bad-inline.md b/web/scripts/__fixtures__/validate-md/invalid-image-bad-inline.md
new file mode 100644
index 00000000..c99897e9
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/invalid-image-bad-inline.md
@@ -0,0 +1,3 @@
+Non-boolean value for the `inline` parameter must surface as a validation error.
+
+
diff --git a/web/scripts/__fixtures__/validate-md/invalid-image-non-numeric-dim.md b/web/scripts/__fixtures__/validate-md/invalid-image-non-numeric-dim.md
new file mode 100644
index 00000000..809ce169
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/invalid-image-non-numeric-dim.md
@@ -0,0 +1,3 @@
+Non-numeric value for a dimension parameter must surface as a validation error.
+
+
diff --git a/web/scripts/__fixtures__/validate-md/invalid-image-only-height.md b/web/scripts/__fixtures__/validate-md/invalid-image-only-height.md
new file mode 100644
index 00000000..db479dfb
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/invalid-image-only-height.md
@@ -0,0 +1,3 @@
+Image URL carrying only `height` (no matching `width`) must surface as a validation error.
+
+
diff --git a/web/scripts/__fixtures__/validate-md/invalid-image-only-width.md b/web/scripts/__fixtures__/validate-md/invalid-image-only-width.md
new file mode 100644
index 00000000..b52e3d62
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/invalid-image-only-width.md
@@ -0,0 +1,3 @@
+Image URL carrying only `width` (no matching `height`) must surface as a validation error — partial dimensions defeat layout reservation.
+
+
diff --git a/web/scripts/__fixtures__/validate-md/invalid-image-unknown-param.md b/web/scripts/__fixtures__/validate-md/invalid-image-unknown-param.md
new file mode 100644
index 00000000..fc4223cc
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/invalid-image-unknown-param.md
@@ -0,0 +1,3 @@
+Unknown query parameter on an image URL — typo guard against e.g. `widht` instead of `width`.
+
+
diff --git a/web/scripts/__fixtures__/validate-md/invalid-image-zero-dim.md b/web/scripts/__fixtures__/validate-md/invalid-image-zero-dim.md
new file mode 100644
index 00000000..ddd9043c
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/invalid-image-zero-dim.md
@@ -0,0 +1,3 @@
+Zero or negative dimension value must surface as a validation error — `next/image` rejects non-positive sizes.
+
+
diff --git a/web/scripts/__fixtures__/validate-md/invalid-list-bad-style.md b/web/scripts/__fixtures__/validate-md/invalid-list-bad-style.md
new file mode 100644
index 00000000..46384ce7
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/invalid-list-bad-style.md
@@ -0,0 +1,6 @@
+Unknown style identifier on the list directive must surface as a validation error.
+
+:::list{style=mystery}
+- First item.
+- Second item.
+:::
diff --git a/web/scripts/__fixtures__/validate-md/invalid-list-extra-attr.md b/web/scripts/__fixtures__/validate-md/invalid-list-extra-attr.md
new file mode 100644
index 00000000..2767fae2
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/invalid-list-extra-attr.md
@@ -0,0 +1,6 @@
+Unknown extra attribute alongside the valid `style` must surface as a validation error so typos cannot pass silently.
+
+:::list{style=lower-alpha-parens foo=bar}
+- First item.
+- Second item.
+:::
diff --git a/web/scripts/__fixtures__/validate-md/invalid-list-no-style.md b/web/scripts/__fixtures__/validate-md/invalid-list-no-style.md
new file mode 100644
index 00000000..777ef2b6
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/invalid-list-no-style.md
@@ -0,0 +1,6 @@
+A `:::list` directive without the required `style` attribute must surface as a validation error.
+
+:::list
+- First item.
+- Second item.
+:::
diff --git a/web/scripts/__fixtures__/validate-md/invalid-quote-empty.md b/web/scripts/__fixtures__/validate-md/invalid-quote-empty.md
new file mode 100644
index 00000000..d25ab0c7
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/invalid-quote-empty.md
@@ -0,0 +1,3 @@
+An empty `:quote[]` directive must surface as a validation error — there's nothing for the renderer to wrap.
+
+The committee replied :quote[] without further explanation.
diff --git a/web/scripts/__fixtures__/validate-md/invalid-quote-with-attrs.md b/web/scripts/__fixtures__/validate-md/invalid-quote-with-attrs.md
new file mode 100644
index 00000000..e6294a49
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/invalid-quote-with-attrs.md
@@ -0,0 +1,3 @@
+The inline `:quote` directive does not accept any attributes — passing one must surface as a validation error.
+
+The committee replied :quote[your proof is correct]{class=highlighted} without further comment.
diff --git a/web/scripts/__fixtures__/validate-md/valid-image-dimensions.md b/web/scripts/__fixtures__/validate-md/valid-image-dimensions.md
new file mode 100644
index 00000000..b2d7242b
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/valid-image-dimensions.md
@@ -0,0 +1,3 @@
+Block image with intrinsic dimensions on the URL — the renderer reserves layout up front so there is no CLS jump when the file arrives:
+
+
diff --git a/web/scripts/__fixtures__/validate-md/valid-image-inline-with-dimensions.md b/web/scripts/__fixtures__/validate-md/valid-image-inline-with-dimensions.md
new file mode 100644
index 00000000..72896343
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/valid-image-inline-with-dimensions.md
@@ -0,0 +1,3 @@
+Inline image carrying both intrinsic dimensions and the inline flag — the most common shape for problem images that sit mid-sentence:
+
+We use the symbol  to denote equivalence in the rest of the proof.
diff --git a/web/scripts/__fixtures__/validate-md/valid-image-inline.md b/web/scripts/__fixtures__/validate-md/valid-image-inline.md
new file mode 100644
index 00000000..49ec21f1
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/valid-image-inline.md
@@ -0,0 +1,3 @@
+Inline image flowing with the surrounding sentence text — useful for tiny diagrams referenced mid-prose:
+
+The angle marked  appears twice in the configuration.
diff --git a/web/scripts/__fixtures__/validate-md/valid-list-bullet-explicit.md b/web/scripts/__fixtures__/validate-md/valid-list-bullet-explicit.md
new file mode 100644
index 00000000..486820f3
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/valid-list-bullet-explicit.md
@@ -0,0 +1,7 @@
+Explicit bullet style is accepted by the directive even though the bare `- ` form would render identically — the directive accepts every style identifier, including the two that have a native markdown shorthand.
+
+:::list{style=bullet}
+- First observation about the configuration.
+- Second observation about the configuration.
+- Third observation about the configuration.
+:::
diff --git a/web/scripts/__fixtures__/validate-md/valid-list-lower-alpha-parens.md b/web/scripts/__fixtures__/validate-md/valid-list-lower-alpha-parens.md
new file mode 100644
index 00000000..d1177232
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/valid-list-lower-alpha-parens.md
@@ -0,0 +1,7 @@
+Lower-case alphabetic markers with parentheses — the most common multi-part question style:
+
+:::list{style=lower-alpha-parens}
+- Find the minimum of $f(x) = x^2 + \frac{1}{x}$ on $(0, \infty)$.
+- Prove the inequality is strict.
+- Determine when equality is approached.
+:::
diff --git a/web/scripts/__fixtures__/validate-md/valid-list-lower-roman-parens.md b/web/scripts/__fixtures__/validate-md/valid-list-lower-roman-parens.md
new file mode 100644
index 00000000..95fc8d7a
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/valid-list-lower-roman-parens.md
@@ -0,0 +1,7 @@
+Lower-case roman numerals with parentheses:
+
+:::list{style=lower-roman-parens}
+- Consider the case $n = 1$.
+- Now suppose $n > 1$ and the claim holds for $n - 1$.
+- Combine the two to conclude.
+:::
diff --git a/web/scripts/__fixtures__/validate-md/valid-list-number-parens.md b/web/scripts/__fixtures__/validate-md/valid-list-number-parens.md
new file mode 100644
index 00000000..89d68169
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/valid-list-number-parens.md
@@ -0,0 +1,7 @@
+Numbered list with parenthesised digits — common in problem statements that step through cases:
+
+:::list{style=number-parens}
+- First we show that $a > 0$.
+- Therefore $\sqrt{a}$ is well-defined.
+- We apply the AM-GM inequality.
+:::
diff --git a/web/scripts/__fixtures__/validate-md/valid-list-upper-alpha-parens.md b/web/scripts/__fixtures__/validate-md/valid-list-upper-alpha-parens.md
new file mode 100644
index 00000000..a0e159f1
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/valid-list-upper-alpha-parens.md
@@ -0,0 +1,7 @@
+Upper-case alphabetic markers with parentheses:
+
+:::list{style=upper-alpha-parens}
+- Triangle $ABC$ is acute.
+- Triangle $ABC$ is right-angled at $C$.
+- Triangle $ABC$ is obtuse at one vertex.
+:::
diff --git a/web/scripts/__fixtures__/validate-md/valid-list-upper-roman.md b/web/scripts/__fixtures__/validate-md/valid-list-upper-roman.md
new file mode 100644
index 00000000..711ea7e1
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/valid-list-upper-roman.md
@@ -0,0 +1,7 @@
+Upper-case roman numerals followed by a colon — used to label major cases:
+
+:::list{style=upper-roman}
+- All roots are real and distinct.
+- Exactly two roots coincide.
+- All three roots coincide.
+:::
diff --git a/web/scripts/__fixtures__/validate-md/valid-quote-in-list.md b/web/scripts/__fixtures__/validate-md/valid-quote-in-list.md
new file mode 100644
index 00000000..9db74d67
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/valid-quote-in-list.md
@@ -0,0 +1,7 @@
+Inline quote nested inside a styled list — common interaction in problem statements that quote source material per case:
+
+:::list{style=lower-alpha-parens}
+- The first source claims :quote[every prime greater than 3 has the form $6k \pm 1$].
+- The second source claims :quote[the sum of the first $n$ odd numbers equals $n^2$].
+- Reconcile the two claims with a direct argument.
+:::
diff --git a/web/scripts/__fixtures__/validate-md/valid-quote-inline.md b/web/scripts/__fixtures__/validate-md/valid-quote-inline.md
new file mode 100644
index 00000000..210db21d
--- /dev/null
+++ b/web/scripts/__fixtures__/validate-md/valid-quote-inline.md
@@ -0,0 +1,3 @@
+Inline quote rendered as `...` — mirrors the AST `QuoteText` block produced by `\uv{}` in SK/CS source:
+
+The committee's reply was simply :quote[your proof is correct, but unnecessarily long], which left the contestant unsure how to revise.
diff --git a/web/scripts/__tests__/__snapshots__/validate-md.test.ts.snap b/web/scripts/__tests__/__snapshots__/validate-md.test.ts.snap
index 7db1b402..e86a34ae 100644
--- a/web/scripts/__tests__/__snapshots__/validate-md.test.ts.snap
+++ b/web/scripts/__tests__/__snapshots__/validate-md.test.ts.snap
@@ -1,5 +1,53 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+exports[`validate-md fixtures > invalid-image-bad-inline.md 1`] = `
+{
+ "errorFirstLine": "Image URL has invalid inline="yes" (expected "true" or "false")",
+ "stage": "parse",
+ "verdict": "fail",
+}
+`;
+
+exports[`validate-md fixtures > invalid-image-non-numeric-dim.md 1`] = `
+{
+ "errorFirstLine": "Image URL has invalid width="abc" (expected a positive integer)",
+ "stage": "parse",
+ "verdict": "fail",
+}
+`;
+
+exports[`validate-md fixtures > invalid-image-only-height.md 1`] = `
+{
+ "errorFirstLine": "Image URL has only one of width/height — both must be specified together",
+ "stage": "parse",
+ "verdict": "fail",
+}
+`;
+
+exports[`validate-md fixtures > invalid-image-only-width.md 1`] = `
+{
+ "errorFirstLine": "Image URL has only one of width/height — both must be specified together",
+ "stage": "parse",
+ "verdict": "fail",
+}
+`;
+
+exports[`validate-md fixtures > invalid-image-unknown-param.md 1`] = `
+{
+ "errorFirstLine": "Image URL has unknown parameter "widht"",
+ "stage": "parse",
+ "verdict": "fail",
+}
+`;
+
+exports[`validate-md fixtures > invalid-image-zero-dim.md 1`] = `
+{
+ "errorFirstLine": "Image URL has invalid width="0" (expected a positive integer)",
+ "stage": "parse",
+ "verdict": "fail",
+}
+`;
+
exports[`validate-md fixtures > invalid-katex-bad-environment.md 1`] = `
{
"errorFirstLine": "KaTeX parse error: No such environment: notathing at position 7: \\begin{̲n̲o̲t̲a̲t̲h̲i̲n̲g̲}̲ x = 1 \\end{not…",
@@ -112,6 +160,46 @@ exports[`validate-md fixtures > invalid-katex-unmatched-left.md 1`] = `
}
`;
+exports[`validate-md fixtures > invalid-list-bad-style.md 1`] = `
+{
+ "errorFirstLine": "Unknown list style "mystery" on :::list directive (expected one of: bullet, number-dot, number-parens, lower-roman-parens, upper-roman, lower-alpha-parens, upper-alpha-parens)",
+ "stage": "parse",
+ "verdict": "fail",
+}
+`;
+
+exports[`validate-md fixtures > invalid-list-extra-attr.md 1`] = `
+{
+ "errorFirstLine": "Unknown attribute(s) on :::list directive: foo",
+ "stage": "parse",
+ "verdict": "fail",
+}
+`;
+
+exports[`validate-md fixtures > invalid-list-no-style.md 1`] = `
+{
+ "errorFirstLine": "Missing \`style\` attribute on :::list directive (expected one of: bullet, number-dot, number-parens, lower-roman-parens, upper-roman, lower-alpha-parens, upper-alpha-parens)",
+ "stage": "parse",
+ "verdict": "fail",
+}
+`;
+
+exports[`validate-md fixtures > invalid-quote-empty.md 1`] = `
+{
+ "errorFirstLine": ":quote directive has empty content",
+ "stage": "parse",
+ "verdict": "fail",
+}
+`;
+
+exports[`validate-md fixtures > invalid-quote-with-attrs.md 1`] = `
+{
+ "errorFirstLine": ":quote directive does not accept attributes",
+ "stage": "parse",
+ "verdict": "fail",
+}
+`;
+
exports[`validate-md fixtures > valid-blockquote.md 1`] = `
{
"verdict": "ok",
@@ -136,6 +224,24 @@ exports[`validate-md fixtures > valid-image.md 1`] = `
}
`;
+exports[`validate-md fixtures > valid-image-dimensions.md 1`] = `
+{
+ "verdict": "ok",
+}
+`;
+
+exports[`validate-md fixtures > valid-image-inline.md 1`] = `
+{
+ "verdict": "ok",
+}
+`;
+
+exports[`validate-md fixtures > valid-image-inline-with-dimensions.md 1`] = `
+{
+ "verdict": "ok",
+}
+`;
+
exports[`validate-md fixtures > valid-inline-math.md 1`] = `
{
"verdict": "ok",
@@ -154,18 +260,54 @@ exports[`validate-md fixtures > valid-list-bullet.md 1`] = `
}
`;
+exports[`validate-md fixtures > valid-list-bullet-explicit.md 1`] = `
+{
+ "verdict": "ok",
+}
+`;
+
+exports[`validate-md fixtures > valid-list-lower-alpha-parens.md 1`] = `
+{
+ "verdict": "ok",
+}
+`;
+
+exports[`validate-md fixtures > valid-list-lower-roman-parens.md 1`] = `
+{
+ "verdict": "ok",
+}
+`;
+
exports[`validate-md fixtures > valid-list-nested.md 1`] = `
{
"verdict": "ok",
}
`;
+exports[`validate-md fixtures > valid-list-number-parens.md 1`] = `
+{
+ "verdict": "ok",
+}
+`;
+
exports[`validate-md fixtures > valid-list-numbered.md 1`] = `
{
"verdict": "ok",
}
`;
+exports[`validate-md fixtures > valid-list-upper-alpha-parens.md 1`] = `
+{
+ "verdict": "ok",
+}
+`;
+
+exports[`validate-md fixtures > valid-list-upper-roman.md 1`] = `
+{
+ "verdict": "ok",
+}
+`;
+
exports[`validate-md fixtures > valid-math-align.md 1`] = `
{
"verdict": "ok",
@@ -184,6 +326,18 @@ exports[`validate-md fixtures > valid-prose.md 1`] = `
}
`;
+exports[`validate-md fixtures > valid-quote-in-list.md 1`] = `
+{
+ "verdict": "ok",
+}
+`;
+
+exports[`validate-md fixtures > valid-quote-inline.md 1`] = `
+{
+ "verdict": "ok",
+}
+`;
+
exports[`validate-md fixtures > valid-realistic-problem.md 1`] = `
{
"verdict": "ok",
diff --git a/web/src/app/[locale]/dev/renderer-preview/page.tsx b/web/src/app/[locale]/dev/renderer-preview/page.tsx
new file mode 100644
index 00000000..c475b90e
--- /dev/null
+++ b/web/src/app/[locale]/dev/renderer-preview/page.tsx
@@ -0,0 +1,149 @@
+import { RichMathEditorRenderer } from '@/components/shared/components/rich-math-editor/components/RichMathEditorRenderer'
+
+/**
+ * One large markdown document that exercises every shape the renderer handles.
+ * Used by the dev-only preview page below to surface visual regressions when
+ * changing pipeline plugins, sanitize rules, or rendering components.
+ */
+const SAMPLE_MARKDOWN = `
+# Renderer catalog
+
+A single document exercising every shape the markdown renderer supports — text styling, math, lists (default and custom-marker), spoilers, quotes, code, tables, images, and the interactions between them. Mount it whenever pipeline plugins, the sanitize schema, or render components change to spot regressions visually.
+
+## Text and inline math
+
+A paragraph with **bold**, *italic*, inline math $x^2 + y^2 = z^2$, and a [link to example](https://example.com).
+
+## Display math
+
+$$
+\\int_0^1 x^2 \\, dx = \\frac{1}{3}
+$$
+
+## Default lists
+
+A default bullet list:
+
+- First bullet item.
+- Second bullet item.
+- Third bullet item.
+
+A default numbered list:
+
+1. First numbered item.
+2. Second numbered item.
+3. Third numbered item.
+
+## Custom list styles
+
+Number with parentheses — \`(1) (2) (3)\`:
+
+:::list{style=number-parens}
+- First case: $a > 0$.
+- Second case: $a = 0$.
+- Third case: $a < 0$.
+:::
+
+Lower roman with parentheses — \`(i) (ii) (iii)\`:
+
+:::list{style=lower-roman-parens}
+- Base case $n = 1$.
+- Inductive step $n \\to n + 1$.
+- Conclusion.
+:::
+
+Upper roman with colon suffix — \`I: II: III:\`:
+
+:::list{style=upper-roman}
+- All three roots are real and distinct.
+- Exactly two roots coincide.
+- All three roots coincide.
+:::
+
+Lower alpha with parentheses — \`(a) (b) (c)\`:
+
+:::list{style=lower-alpha-parens}
+- Find the minimum of $f(x) = x^2 + \\frac{1}{x}$.
+- Prove the inequality is strict.
+- Determine when equality is approached.
+:::
+
+Upper alpha with parentheses — \`(A) (B) (C)\`:
+
+:::list{style=upper-alpha-parens}
+- Triangle $ABC$ is acute.
+- Triangle $ABC$ is right-angled.
+- Triangle $ABC$ is obtuse.
+:::
+
+## Spoiler
+
+:::spoiler[Click to reveal]
+Hidden hint with math: try $u = x^2$.
+:::
+
+## Block quote
+
+> Quoted excerpt that spans the full width and renders with a left border.
+
+## Inline quote
+
+The committee's reply was simply :quote[your proof is correct, but unnecessarily long], which left the contestant unsure how to revise. The browser renders this with locale-aware quotation marks via the native \`\` element.
+
+## Code
+
+\`\`\`python
+def fibonacci(n):
+ return n if n < 2 else fibonacci(n - 1) + fibonacci(n - 2)
+\`\`\`
+
+## Table
+
+| Style | Marker |
+| ----- | ------ |
+| bullet | • |
+| number-dot | 1. |
+
+## Images
+
+A block image without parameters — sized at runtime, no layout reservation:
+
+
+
+A block image carrying intrinsic dimensions on the URL — \`next/image\` reserves layout up front so there is no CLS jump while the asset loads:
+
+
+
+An inline image flowing with surrounding text via \`?inline=true\` — the angle marked  appears mid-sentence without breaking onto its own line.
+
+An inline image carrying both inline and dimension flags — we use the symbol  to denote equivalence in the rest of the proof.
+
+## Compositions
+
+Inline quote inside a custom-marker list:
+
+:::list{style=lower-alpha-parens}
+- The first source claims :quote[every prime greater than 3 has the form $6k \\pm 1$].
+- The second source claims :quote[the sum of the first $n$ odd numbers equals $n^2$].
+- Reconcile the two claims.
+:::
+
+Inline images inside a custom-marker list:
+
+:::list{style=upper-roman}
+- The first configuration is shown here: .
+- The second configuration is shown here: .
+- Compare the two configurations.
+:::
+`
+
+/**
+ * Dev-only visual catalog of the markdown renderer.
+ */
+export default function RendererPreviewPage() {
+ return (
+
+
+
+ )
+}
diff --git a/web/src/app/globals.css b/web/src/app/globals.css
index 7f31e74a..576dbb12 100644
--- a/web/src/app/globals.css
+++ b/web/src/app/globals.css
@@ -254,6 +254,46 @@ input[type='checkbox']:focus:not(:focus-visible) {
content: '(' counter(list-item, lower-alpha) ')\00a0\00a0';
}
+/*
+ Custom list marker styles emitted by the :::list{style=...} directive.
+ Class names match the kebab-case AST style identifiers verbatim.
+ bullet and number-dot get no override — they use the default