feat: enable Số học + Đại số topic tiles + landings

This commit is contained in:
2026-05-03 13:15:47 +07:00
parent 1877bc91cd
commit 8d6b5153dc
9 changed files with 491 additions and 7 deletions
+5 -2
View File
@@ -4,8 +4,10 @@ Toán tương tác cho học sinh THCS Việt Nam (lớp 6-9). Số học, Đạ
## Status
3 bài hình học đầu tiên đã ra mắt. Số học và Đại số vẫn "Sắp ra mắt".
5 bài đã ra mắt — số học, đại số, hình học đều có ít nhất một bài.
- Lớp 6 — Ước chung lớn nhất (Euclid): `/so-hoc/uoc-chung-lon-nhat/`
- Lớp 7 — Hiệu hai bình phương: `/dai-so/hieu-hai-binh-phuong/`
- Lớp 7 — Tam giác bằng nhau (SSS): `/hinh-hoc/tam-giac-bang-nhau/`
- Lớp 8 — Tam giác đồng dạng: `/hinh-hoc/tam-giac-dong-dang/`
- Lớp 9 — Góc nội tiếp: `/hinh-hoc/goc-noi-tiep/`
@@ -34,7 +36,8 @@ Live URL: https://tiennm99.github.io/mathmax/
- **Static**: SvelteKit + `@sveltejs/adapter-static`, `paths.base = '/mathmax'`, output `build/`.
- **Styling**: Tailwind 3 (PostCSS) + Be Vietnam Pro (woff2 qua `@fontsource`). Tick palette `colors.pair.{1,2,3}` được khai báo trong `tailwind.config.js`.
- **Language**: JavaScript only (Svelte 5, JSDoc qua `jsconfig.json` với `checkJs: true`).
- **Math engine**: `src/lib/geom-engine/` — module thuần (không phụ thuộc DOM): `vec`, `triangle`, `circle`, `ticks`. Vitest unit tests đi kèm.
- **Math engines**: `src/lib/geom-engine/` (vec, triangle, circle, ticks) và `src/lib/numtheory-engine/` (gcd, lcm, gcdSteps). Module thuần, không phụ thuộc DOM. Vitest unit tests đi kèm.
- **Math typography**: `src/lib/components/tex.svelte` — wrapper KaTeX duy nhất. SSR qua `renderToString`, không cần JS phía client để hiển thị.
- **Lessons**: mỗi bài là một `+page.svelte`; copy tiếng Việt colocate trong `src/lib/lessons/<slug>/copy.vi.js`.
- **Drag**: Svelte action `use:draggable` (`src/lib/actions/draggable.svelte.js`) — Pointer Events + bàn phím mũi tên cho a11y.
- **i18n**: Hiện chỉ có tiếng Việt. Site chrome ở `src/lib/i18n/site.vi.js`. English thêm sau bằng cách tạo `*.en.js` song song.
+4
View File
@@ -55,3 +55,7 @@ When the trigger fires:
## Initial CI note
Initial CI uses `npm install` (no lockfile committed yet). After first successful run, user commits `package-lock.json` locally and we switch CI to `npm ci` in a follow-up PR.
## Bundle notes
- **KaTeX** (`katex@^0.16`) is loaded site-wide via `import 'katex/dist/katex.min.css'` in `+layout.svelte`. CSS + woff2 fonts add ~280KB total to the static asset payload. Math is server-rendered via `katex.renderToString` (in `src/lib/components/tex.svelte`), so KaTeX **JavaScript runs only at build time** — the runtime cost is the CSS + fonts, not the JS module. If Lighthouse perf drops below 90 after content growth, consider scoping CSS imports per-route.
+4 -4
View File
@@ -26,14 +26,14 @@ export const topics = {
'so-hoc': {
title: 'Số học',
blurb: 'Phép tính, ước-bội, phân số, số nguyên. Trực quan hoá thuật toán và quy luật.',
status: 'comingSoon',
href: null,
status: 'live',
href: '/so-hoc/',
},
'dai-so': {
title: 'Đại số',
blurb: 'Biểu thức, phương trình, hàm số. Thao tác kéo-thả các đối tượng đại số.',
status: 'comingSoon',
href: null,
status: 'live',
href: '/dai-so/',
},
'hinh-hoc': {
title: 'Hình học',
+4 -1
View File
@@ -1,3 +1,5 @@
import { vi as gcdCopy } from './uoc-chung-lon-nhat/copy.vi.js';
import { vi as diffSquaresCopy } from './hieu-hai-binh-phuong/copy.vi.js';
import { vi as sssCopy } from './tam-giac-bang-nhau/copy.vi.js';
import { vi as similarityCopy } from './tam-giac-dong-dang/copy.vi.js';
import { vi as inscribedCopy } from './goc-noi-tiep/copy.vi.js';
@@ -7,8 +9,9 @@ import { vi as inscribedCopy } from './goc-noi-tiep/copy.vi.js';
* gradeLabel: string, intro: string, [k: string]: any}} LessonCopy
*/
// Order: by topic (số học → đại số → hình học), then by grade ascending.
/** @type {LessonCopy[]} */
export const lessons = [sssCopy, similarityCopy, inscribedCopy];
export const lessons = [gcdCopy, diffSquaresCopy, sssCopy, similarityCopy, inscribedCopy];
/** @param {string} topic */
export function lessonsByTopic(topic) {
+1
View File
@@ -1,5 +1,6 @@
<script>
import '../app.css';
import 'katex/dist/katex.min.css';
let { children } = $props();
</script>
+59
View File
@@ -0,0 +1,59 @@
<script>
import { base } from '$app/paths';
import { t } from '$lib/i18n/index.js';
import { lessonsByTopic } from '$lib/lessons/registry.js';
const copy = t();
const topic = copy.topics['dai-so'];
const lessons = lessonsByTopic('dai-so');
</script>
<svelte:head>
<title>{topic.title}{copy.site.title}</title>
<meta name="description" content={topic.blurb} />
</svelte:head>
<header class="border-b border-slate-200 bg-white">
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
<a href={base + '/'} class="text-xl font-bold text-indigo-600 tracking-tight">MathMax</a>
<nav aria-label="Điều hướng chính"></nav>
</div>
</header>
<main class="bg-slate-50 min-h-screen">
<section class="max-w-4xl mx-auto px-4 py-12">
<nav class="mb-6 text-sm">
<a href={base + '/'} class="text-indigo-600 hover:underline">{copy.lessonChrome.backToHub}</a>
</nav>
<header class="mb-8">
<h1 class="text-4xl font-bold text-slate-900 mb-2">{topic.title}</h1>
<p class="text-lg text-slate-600">{topic.blurb}</p>
</header>
<ul class="grid md:grid-cols-2 gap-4" aria-label="Danh sách bài học đại số">
{#each lessons as lesson (lesson.slug)}
<li>
<a
href={base + `/dai-so/${lesson.slug}/`}
class="block bg-white rounded-2xl border border-slate-200 p-5 transition hover:border-indigo-400 hover:shadow-sm"
>
<div class="flex items-baseline justify-between gap-3 mb-2">
<span class="text-xs font-semibold uppercase tracking-wide text-slate-500">{lesson.gradeLabel}</span>
<span class="text-xs text-emerald-700">{copy.status.live}</span>
</div>
<h2 class="text-lg font-bold text-slate-900 mb-1">{lesson.title}</h2>
<p class="text-sm text-slate-600 leading-relaxed">{lesson.intro}</p>
</a>
</li>
{/each}
</ul>
</section>
</main>
<footer class="border-t border-slate-200 bg-white">
<div class="max-w-4xl mx-auto px-4 py-6 text-center text-sm text-slate-500">
© {new Date().getFullYear()} ·
<a href="https://github.com/tiennm99/mathmax" class="text-indigo-600 hover:underline" target="_blank" rel="noopener noreferrer">Mã nguồn</a>
</div>
</footer>
@@ -0,0 +1,187 @@
<script>
import { base } from '$app/paths';
import { t } from '$lib/i18n/index.js';
import { vi as m } from '$lib/lessons/hieu-hai-binh-phuong/copy.vi.js';
import Tex from '$lib/components/tex.svelte';
const copy = t();
const U = 18; // px per math unit
const PAD = 24;
const PAIR1 = '#D7263D';
const PAIR2 = '#1B998B';
let a = $state(5);
let b = $state(2);
// Clamp b ≤ a — guard prevents feedback loops.
$effect(() => {
if (b > a) b = a;
});
const aSafe = $derived(Math.max(2, Math.min(10, Math.trunc(Number(a)) || 2)));
const bSafe = $derived(Math.max(1, Math.min(aSafe, Math.trunc(Number(b)) || 1)));
const aSq = $derived(aSafe * aSafe);
const bSq = $derived(bSafe * bSafe);
const diff = $derived(aSq - bSq);
const product = $derived((aSafe + bSafe) * (aSafe - bSafe));
// Square diagram dimensions.
const sqViewW = $derived(10 * U + PAD * 2);
const sqViewH = $derived(10 * U + PAD * 2);
const sqSize = $derived(aSafe * U);
const cutSize = $derived(bSafe * U);
const stripH = $derived(bSafe * U); // top-strip height
const stripW = $derived((aSafe - bSafe) * U); // top-strip width
// Rectangle diagram dimensions (a+b) × (a-b).
const rectViewW = $derived(20 * U + PAD * 2);
const rectViewH = $derived(10 * U + PAD * 2);
const rectW = $derived((aSafe + bSafe) * U);
const rectH = $derived((aSafe - bSafe) * U);
const rectLeftW = $derived(aSafe * U);
const rectRightW = $derived(bSafe * U);
const liveTex = $derived(
`${aSafe}^2 - ${bSafe}^2 = (${aSafe} + ${bSafe})(${aSafe} - ${bSafe}) = ${diff}`
);
const identityTex = 'a^2 - b^2 = (a + b)(a - b)';
</script>
<svelte:head>
<title>{m.title}{copy.site.title}</title>
<meta name="description" content={m.intro} />
</svelte:head>
<header class="border-b border-slate-200 bg-white">
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
<a href={base + '/'} class="text-xl font-bold text-indigo-600 tracking-tight">MathMax</a>
</div>
</header>
<main class="bg-slate-50 min-h-screen">
<article class="max-w-3xl mx-auto px-4 py-8">
<nav class="mb-4 text-sm">
<a href={base + '/dai-so/'} class="text-indigo-600 hover:underline">{copy.lessonChrome.backToTopic}</a>
</nav>
<header class="mb-6">
<div class="text-sm uppercase tracking-wide text-slate-500">{m.gradeLabel}</div>
<h1 class="text-3xl font-bold text-slate-900 mt-1 mb-2">{m.title}</h1>
<p class="text-slate-700 leading-relaxed">{m.intro}</p>
</header>
<section class="mb-6 bg-white rounded-lg border border-slate-200 p-5">
<p class="text-sm text-slate-500 mb-3">{m.instruction}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<label class="block">
<span class="text-sm font-semibold text-slate-700">{m.sliderALabel}: <span class="tabular-nums text-indigo-700">{aSafe}</span></span>
<input
type="range" min="2" max="10" step="1"
bind:value={a}
aria-valuetext={`a bằng ${aSafe}`}
class="mt-1 w-full accent-indigo-600"
/>
</label>
<label class="block">
<span class="text-sm font-semibold text-slate-700">{m.sliderBLabel}: <span class="tabular-nums text-indigo-700">{bSafe}</span></span>
<input
type="range" min="1" max={aSafe} step="1"
bind:value={b}
aria-valuetext={`b bằng ${bSafe}`}
class="mt-1 w-full accent-indigo-600"
/>
</label>
</div>
</section>
<section class="mb-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-white rounded-lg border border-slate-200 p-3">
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2 text-center">{m.diagramSquareLabel}</h3>
<svg
viewBox="0 0 {sqViewW} {sqViewH}"
class="block w-full"
role="img"
aria-label={m.diagramSquareLabel}
>
<!-- L-shape pieces -->
{#if stripW > 0}
<rect x={PAD} y={PAD} width={stripW} height={stripH} fill={PAIR1} fill-opacity="0.55" stroke={PAIR1} stroke-width="1" />
{/if}
{#if rectH > 0}
<rect x={PAD} y={PAD + stripH} width={sqSize} height={sqSize - stripH} fill={PAIR2} fill-opacity="0.55" stroke={PAIR2} stroke-width="1" />
{/if}
<!-- Cut corner outline (white square, dashed) -->
<rect x={PAD + stripW} y={PAD} width={cutSize} height={cutSize} fill="white" stroke="#94a3b8" stroke-width="1" stroke-dasharray="4 3" />
<!-- Outer square outline -->
<rect x={PAD} y={PAD} width={sqSize} height={sqSize} fill="none" stroke="#1f2937" stroke-width="1.5" />
<!-- Labels -->
<text x={PAD + sqSize / 2} y={PAD - 8} text-anchor="middle" font-size="13" fill="#1f2937" font-weight="600">a = {aSafe}</text>
<text x={PAD - 8} y={PAD + sqSize / 2} text-anchor="end" dominant-baseline="middle" font-size="13" fill="#1f2937" font-weight="600">a</text>
{#if cutSize > 0}
<text x={PAD + stripW + cutSize / 2} y={PAD - 4} text-anchor="middle" font-size="11" fill="#64748b">b = {bSafe}</text>
<text x={PAD + sqSize + 4} y={PAD + cutSize / 2} text-anchor="start" dominant-baseline="middle" font-size="11" fill="#64748b">b</text>
{/if}
</svg>
</div>
<div class="bg-white rounded-lg border border-slate-200 p-3">
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2 text-center">{m.diagramRectLabel}</h3>
<svg
viewBox="0 0 {rectViewW} {rectViewH}"
class="block w-full"
role="img"
aria-label={m.diagramRectLabel}
>
{#if rectH > 0}
<!-- Left part: a × (a-b), originated from L bottom -->
<rect x={PAD} y={PAD} width={rectLeftW} height={rectH} fill={PAIR2} fill-opacity="0.55" stroke={PAIR2} stroke-width="1" />
<!-- Right part: b × (a-b), originated from L top strip rotated 90° -->
<rect x={PAD + rectLeftW} y={PAD} width={rectRightW} height={rectH} fill={PAIR1} fill-opacity="0.55" stroke={PAIR1} stroke-width="1" />
<!-- Outer outline -->
<rect x={PAD} y={PAD} width={rectW} height={rectH} fill="none" stroke="#1f2937" stroke-width="1.5" />
{:else}
<!-- Degenerate a = b: zero-height rectangle. Show a flat line w/ note. -->
<line x1={PAD} y1={PAD + 5} x2={PAD + rectW} y2={PAD + 5} stroke="#1f2937" stroke-width="1.5" />
<text x={PAD + rectW / 2} y={PAD + 30} text-anchor="middle" font-size="12" fill="#64748b" font-style="italic">a = b → a b = 0 → diện tích = 0</text>
{/if}
<!-- Labels -->
<text x={PAD + rectW / 2} y={PAD - 8} text-anchor="middle" font-size="13" fill="#1f2937" font-weight="600">a + b = {aSafe + bSafe}</text>
{#if rectH > 0}
<text x={PAD - 8} y={PAD + rectH / 2} text-anchor="end" dominant-baseline="middle" font-size="13" fill="#1f2937" font-weight="600">a b = {aSafe - bSafe}</text>
{/if}
</svg>
</div>
</section>
<p class="mb-6 text-sm text-slate-600 italic">{m.rearrangeNote}</p>
<section class="mb-6 rounded-lg border border-slate-200 bg-white p-5">
<h2 class="text-lg font-bold text-slate-900 mb-3">{m.numericTitle}</h2>
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm tabular-nums mb-4" aria-live="polite">
<div><span class="font-semibold">a²:</span> {aSq}</div>
<div><span class="font-semibold">b²:</span> {bSq}</div>
<div><span class="font-semibold" style="color:{PAIR1}"> b²:</span> {diff}</div>
<div><span class="font-semibold" style="color:{PAIR2}">(a + b)(a b):</span> {product}</div>
</div>
<div class="text-slate-800">
<Tex display math={liveTex} />
</div>
</section>
<section class="mb-8">
<h2 class="text-lg font-bold text-slate-900 mb-2">{m.identityTitle}</h2>
<p class="rounded-lg bg-slate-100 p-4 text-slate-800 mb-3">{m.identityStatement}</p>
<Tex display math={identityTex} />
</section>
<section class="mb-10">
<h2 class="text-lg font-bold text-slate-900 mb-2">{m.exampleTitle}</h2>
<p class="text-slate-700 leading-relaxed">{m.exampleBody}</p>
</section>
<footer class="border-t border-slate-200 pt-4 text-sm text-slate-500">
{m.nextTeaser}
</footer>
</article>
</main>
+59
View File
@@ -0,0 +1,59 @@
<script>
import { base } from '$app/paths';
import { t } from '$lib/i18n/index.js';
import { lessonsByTopic } from '$lib/lessons/registry.js';
const copy = t();
const topic = copy.topics['so-hoc'];
const lessons = lessonsByTopic('so-hoc');
</script>
<svelte:head>
<title>{topic.title}{copy.site.title}</title>
<meta name="description" content={topic.blurb} />
</svelte:head>
<header class="border-b border-slate-200 bg-white">
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
<a href={base + '/'} class="text-xl font-bold text-indigo-600 tracking-tight">MathMax</a>
<nav aria-label="Điều hướng chính"></nav>
</div>
</header>
<main class="bg-slate-50 min-h-screen">
<section class="max-w-4xl mx-auto px-4 py-12">
<nav class="mb-6 text-sm">
<a href={base + '/'} class="text-indigo-600 hover:underline">{copy.lessonChrome.backToHub}</a>
</nav>
<header class="mb-8">
<h1 class="text-4xl font-bold text-slate-900 mb-2">{topic.title}</h1>
<p class="text-lg text-slate-600">{topic.blurb}</p>
</header>
<ul class="grid md:grid-cols-2 gap-4" aria-label="Danh sách bài học số học">
{#each lessons as lesson (lesson.slug)}
<li>
<a
href={base + `/so-hoc/${lesson.slug}/`}
class="block bg-white rounded-2xl border border-slate-200 p-5 transition hover:border-indigo-400 hover:shadow-sm"
>
<div class="flex items-baseline justify-between gap-3 mb-2">
<span class="text-xs font-semibold uppercase tracking-wide text-slate-500">{lesson.gradeLabel}</span>
<span class="text-xs text-emerald-700">{copy.status.live}</span>
</div>
<h2 class="text-lg font-bold text-slate-900 mb-1">{lesson.title}</h2>
<p class="text-sm text-slate-600 leading-relaxed">{lesson.intro}</p>
</a>
</li>
{/each}
</ul>
</section>
</main>
<footer class="border-t border-slate-200 bg-white">
<div class="max-w-4xl mx-auto px-4 py-6 text-center text-sm text-slate-500">
© {new Date().getFullYear()} ·
<a href="https://github.com/tiennm99/mathmax" class="text-indigo-600 hover:underline" target="_blank" rel="noopener noreferrer">Mã nguồn</a>
</div>
</footer>
@@ -0,0 +1,168 @@
<script>
import { base } from '$app/paths';
import { t } from '$lib/i18n/index.js';
import { vi as m } from '$lib/lessons/uoc-chung-lon-nhat/copy.vi.js';
import { gcd, lcm, gcdSteps } from '$lib/numtheory-engine/index.js';
import Tex from '$lib/components/tex.svelte';
const copy = t();
let a = $state(48);
let b = $state(18);
let stepIdx = $state(0);
// Sanitize inputs into the [1, 999] integer range without throwing.
const aSafe = $derived(Math.max(1, Math.min(999, Math.trunc(Number(a)) || 1)));
const bSafe = $derived(Math.max(1, Math.min(999, Math.trunc(Number(b)) || 1)));
const steps = $derived(gcdSteps(aSafe, bSafe));
const visibleSteps = $derived(steps.slice(0, stepIdx));
const finished = $derived(stepIdx >= steps.length);
const result = $derived(finished ? gcd(aSafe, bSafe) : null);
const lcmValue = $derived(result !== null && result !== 0 ? lcm(aSafe, bSafe) : null);
// Reset trace whenever inputs change.
$effect(() => {
aSafe; bSafe;
stepIdx = 0;
});
const lcmTex = $derived(
lcmValue !== null && result !== null
? `\\text{BCNN}(${aSafe}, ${bSafe}) = \\frac{${aSafe} \\cdot ${bSafe}}{${result}} = ${lcmValue}`
: ''
);
const gcdTex = $derived(
result !== null ? `\\text{ƯCLN}(${aSafe}, ${bSafe}) = ${result}` : ''
);
function stepOnce() { if (stepIdx < steps.length) stepIdx += 1; }
function autoFinish() { stepIdx = steps.length; }
function reset() { stepIdx = 0; }
</script>
<svelte:head>
<title>{m.title}{copy.site.title}</title>
<meta name="description" content={m.intro} />
</svelte:head>
<header class="border-b border-slate-200 bg-white">
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
<a href={base + '/'} class="text-xl font-bold text-indigo-600 tracking-tight">MathMax</a>
</div>
</header>
<main class="bg-slate-50 min-h-screen">
<article class="max-w-3xl mx-auto px-4 py-8">
<nav class="mb-4 text-sm">
<a href={base + '/so-hoc/'} class="text-indigo-600 hover:underline">{copy.lessonChrome.backToTopic}</a>
</nav>
<header class="mb-6">
<div class="text-sm uppercase tracking-wide text-slate-500">{m.gradeLabel}</div>
<h1 class="text-3xl font-bold text-slate-900 mt-1 mb-2">{m.title}</h1>
<p class="text-slate-700 leading-relaxed">{m.intro}</p>
</header>
<section class="mb-6 bg-white rounded-lg border border-slate-200 p-5">
<p class="text-sm text-slate-500 mb-3">{m.instruction}</p>
<div class="grid grid-cols-2 gap-4 mb-4">
<label class="block">
<span class="text-sm font-semibold text-slate-700">{m.inputALabel}</span>
<input
type="number" min="1" max="999" step="1"
bind:value={a}
class="mt-1 w-full border border-slate-300 rounded-md px-3 py-2 text-lg tabular-nums focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</label>
<label class="block">
<span class="text-sm font-semibold text-slate-700">{m.inputBLabel}</span>
<input
type="number" min="1" max="999" step="1"
bind:value={b}
class="mt-1 w-full border border-slate-300 rounded-md px-3 py-2 text-lg tabular-nums focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</label>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
onclick={stepOnce}
disabled={finished}
class="px-4 py-2 rounded-md bg-indigo-600 text-white font-semibold hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:bg-slate-300 disabled:cursor-not-allowed"
>{m.stepBtn}</button>
<button
type="button"
onclick={autoFinish}
disabled={finished}
class="px-4 py-2 rounded-md bg-white border border-indigo-600 text-indigo-700 font-semibold hover:bg-indigo-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>{m.autoBtn}</button>
<button
type="button"
onclick={reset}
class="px-4 py-2 rounded-md bg-white border border-slate-300 text-slate-700 font-semibold hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-slate-400"
>{m.resetBtn}</button>
</div>
</section>
<section class="mb-6">
<h2 class="text-lg font-bold text-slate-900 mb-2">{m.rowDescription}</h2>
<div aria-live="polite">
<table class="w-full border-collapse text-sm tabular-nums">
<thead>
<tr class="border-b-2 border-slate-300 text-slate-600">
<th class="text-left py-2 px-3 font-semibold">#</th>
<th class="text-right py-2 px-3 font-semibold">{m.tableHeaderA}</th>
<th class="text-right py-2 px-3 font-semibold">{m.tableHeaderQ} · {m.tableHeaderB}</th>
<th class="text-right py-2 px-3 font-semibold">+ {m.tableHeaderR}</th>
</tr>
</thead>
<tbody>
{#each visibleSteps as step, i (i)}
<tr class="border-b border-slate-200">
<td class="py-2 px-3 text-slate-500">{i + 1}</td>
<td class="py-2 px-3 text-right font-semibold text-slate-900">{step.a}</td>
<td class="py-2 px-3 text-right text-slate-700">= {step.q} · {step.b}</td>
<td class="py-2 px-3 text-right text-slate-700">+ {step.r}</td>
</tr>
{/each}
{#if visibleSteps.length === 0}
<tr><td colspan="4" class="py-4 px-3 text-center text-slate-400 italic">Nhấn "{m.stepBtn}" để bắt đầu</td></tr>
{/if}
</tbody>
</table>
</div>
</section>
{#if result !== null}
<section class="mb-6 rounded-lg border border-emerald-200 bg-emerald-50 p-5" aria-live="polite">
<div class="text-emerald-800 font-semibold text-lg mb-2">
{m.resultLabel}(<span class="tabular-nums">{aSafe}</span>, <span class="tabular-nums">{bSafe}</span>) = <span class="tabular-nums">{result}</span>
</div>
<div class="text-slate-800 mb-3">
<Tex display math={gcdTex} />
</div>
{#if lcmValue !== null}
<div class="text-slate-800">
<div class="text-sm font-semibold text-slate-600 mb-1">{m.lcmLabel}:</div>
<Tex display math={lcmTex} />
</div>
{/if}
</section>
{/if}
<section class="mb-8">
<h2 class="text-lg font-bold text-slate-900 mb-2">{m.theoremTitle}</h2>
<p class="rounded-lg bg-slate-100 p-4 text-slate-800">{m.theoremStatement}</p>
</section>
<section class="mb-10">
<h2 class="text-lg font-bold text-slate-900 mb-2">{m.exampleTitle}</h2>
<p class="text-slate-700 leading-relaxed">{m.exampleBody}</p>
</section>
<footer class="border-t border-slate-200 pt-4 text-sm text-slate-500">
{m.nextTeaser}
</footer>
</article>
</main>