v0.0.1.0 chore: scaffold Hình Học Sống (Astro + GitHub Pages) (#1)

* chore: add gstack skill routing rules to CLAUDE.md

Append a "## Skill routing" section so future Claude Code sessions in this repo route
each task through the matching gstack skill (e.g., /investigate for bugs, /ship for PRs,
/office-hours for product brainstorming).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: scaffold Astro 5 + TypeScript strict with base /try-gstack/

Initialize the project per the autoplan-locked decisions:
- Astro 5 SSG, output static, base path /try-gstack/ for GitHub Pages subdirectory hosting
- TypeScript strict (Astro's strict tsconfig + path alias ~/* -> src/*)
- @astrojs/sitemap for SEO meta
- bun as the package manager (lockfile committed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: Vietnamese landing page with Be Vietnam Pro + i18n stub

Implement the locked design decisions D2 (typography) and D3 (palette) plus the i18n
discipline from autoplan eng review:
- Be Vietnam Pro single family (woff2, weights 400/500/700, vietnamese subset) self-hosted
  via @fontsource so KaTeX/CDN-blocking ISPs cannot break the page
- font-feature-settings: "kern", "locl" for proper VN diacritic positioning
- 17px body / 1.6 line-height / max-w-prose 56ch (denser than English)
- 3-color SGK-aligned palette in tailwind config: pair1 #D7263D, pair2 #1B998B, pair3 #F46036
- BaseLayout with lang="vi", canonical URL, OpenGraph (vi_VN), X-Frame-Options DENY
- src/i18n/vi.ts holds every user-facing string; t() helper resolves the active locale.
  Adding English later means adding en.ts; no string churn through templates.
- Landing page lists three grade cards with "Sắp ra mắt" status (modules ship later)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: bootstrap Vitest with pure geom-engine vec module

Establish the math-engine boundary and test discipline from autoplan eng decision E2:
- src/geom-engine/ is pure (no DOM imports allowed); first module is vec.ts
- Vec2 helpers: vec, add, sub, scale, dot, len, dist, normalize, approxEqualLen
- EPSILON_LEN=0.5 viewBox units justified vs ~4px human drag precision
- scale() normalizes IEEE-754 -0 back to +0 so consumers don't see signed-zero ghosts
- Vitest config gates the module at 95% line/function/statement, 90% branch
- 16 unit tests covering commutativity, mutation safety, orthogonality, normalization,
  and EPSILON tolerance behavior

Property tests via fast-check come with the first canvas module; this commit establishes
the test scaffold so adding them later is one dependency away.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: GitHub Actions test workflow + Pages deploy via deploy-pages@v4

Wire the autoplan-locked CI/CD pipeline (decision E3) for GitHub Pages hosting:
- ci.yml: typecheck + tests + build on every PR and push to main
- deploy.yml: build + actions/deploy-pages@v4 on push to main, concurrency-grouped
  so a force-push retry doesn't abort an in-flight rollback
- Build env pins SITE_URL=https://tiennm99.github.io and SITE_BASE=/try-gstack so
  astro.config.mjs produces correct canonical/OG URLs against the subdirectory host
- bun 1.3.13 pinned for both workflows

Lighthouse-CI + size-limit gates deferred until the first canvas module ships
(no JS bundle to budget yet — current pages are zero-JS).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: VERSION 0.0.1.0 + CHANGELOG + RUNBOOK + README rewrite

Initial 4-digit gstack version (matches package.json), changelog entry for the scaffold,
operations runbook covering rollback and the deferred .vn domain trigger (500 sessions/30d
OR 1 organic teacher share OR 5+ modules shipped). README rewritten as the project README
for Hình Học Sống with the locked architectural decisions visible to teammates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: TODOS.md tracking deferred autoplan items

Capture the deferred work surfaced by the /office-hours + /autoplan reviews so the
backlog is visible to teammates and bisectable from the source-of-truth design doc.
Organized by component (Phase 0 distribution, TheoremCanvas, Module 3/1/2, testing
infrastructure, retention, domain) then priority P0–P4. Per the gstack TODOS.md
format conventions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 23:43:37 +07:00
committed by GitHub
parent adeeb70e64
commit d8c7ed0e2c
22 changed files with 1977 additions and 12 deletions
+33
View File
@@ -0,0 +1,33 @@
name: CI
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Typecheck
run: bun run typecheck
- name: Unit tests
run: bun run test
- name: Build
run: bun run build
+52
View File
@@ -0,0 +1,52 @@
name: Deploy to GitHub Pages
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages-deploy
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build
run: bun run build
env:
SITE_URL: https://tiennm99.github.io
SITE_BASE: /try-gstack
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+33
View File
@@ -0,0 +1,33 @@
# build output
dist/
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
bun-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# editor
.vscode/
.idea/
# tooling
.eslintcache
*.tsbuildinfo
# test output
coverage/
playwright-report/
test-results/
+18
View File
@@ -0,0 +1,18 @@
# Changelog
All notable changes to **Hình Học Sống** are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: 4-digit MAJOR.MINOR.PATCH.MICRO per gstack.
## [0.0.1.0] - 2026-04-29
### Added
- Astro project scaffold with TypeScript strict mode and `base: '/try-gstack/'` for GitHub Pages subdirectory hosting.
- Vietnamese-first BaseLayout with `lang="vi"`, canonical URL, OpenGraph, robots meta, `X-Frame-Options: DENY`.
- Be Vietnam Pro typography (self-hosted via `@fontsource/be-vietnam-pro`, Vietnamese subset, weights 400/500/700) with locked sizes/line-heights and `font-feature-settings: kern, locl`.
- Tailwind 3 with the locked SGK 3-color palette (#D7263D / #1B998B / #F46036) and `max-w-prose: 56ch`.
- Landing page (hub) with three placeholder grade cards (lớp 7 / 8 / 9), each marked "Sắp ra mắt".
- `src/i18n/vi.ts` + `t()` helper — every user-facing string routed through i18n from day 1 so English can ship later by adding `en.ts`.
- `src/geom-engine/` pure module (no DOM imports) with `Vec2`, `add/sub/scale/dot/len/dist/normalize`, `EPSILON_LEN = 0.5`, and `approxEqualLen` helper.
- Vitest with v8 coverage, 95% line/function/statement and 90% branch threshold against the geom-engine module.
- GitHub Actions: `ci.yml` runs typecheck + tests + build on PRs and pushes to `main`; `deploy.yml` deploys to GitHub Pages from `main` via `actions/deploy-pages@v4`.
- `RUNBOOK.md` with rollback procedure and domain-purchase trigger thresholds (500 sessions / 30d, 1 organic teacher share, or ≥5 modules shipped).
+18
View File
@@ -57,3 +57,21 @@ For **all** web browsing, use the `/browse` skill from gstack.
- `/unfreeze`
- `/gstack-upgrade`
- `/learn`
## Skill routing
When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill.
Key routing rules:
- Product ideas/brainstorming → invoke /office-hours
- Strategy/scope → invoke /plan-ceo-review
- Architecture → invoke /plan-eng-review
- Design system/plan review → invoke /design-consultation or /plan-design-review
- Full review pipeline → invoke /autoplan
- Bugs/errors → invoke /investigate
- QA/testing site behavior → invoke /qa or /qa-only
- Code review/diff check → invoke /review
- Visual polish → invoke /design-review
- Ship/deploy/PR → invoke /ship or /land-and-deploy
- Save progress → invoke /context-save
- Resume context → invoke /context-restore
+29 -12
View File
@@ -1,21 +1,38 @@
# try-gstack
# Hình Học Sống
A sandbox repo for trying out [gstack](https://github.com/garrytan/gstack) — a collection of slash-command skills for Claude Code covering planning, code review, deploys, design, browser automation, and more.
Interactive Vietnamese THCS (lớp 79) geometry visualizer. Drag the points; watch the theorems hold. Aligned to the SGK curriculum.
## Purpose
> Sandbox repo currently doubling as a [gstack](https://github.com/garrytan/gstack) trial — see `CLAUDE.md`.
This repo exists to experiment with gstack workflows in isolation, without polluting other projects. Use it to:
## Status
- Explore the available gstack slash commands
- Test setup steps (`/setup-deploy`, `/setup-browser-cookies`, `/setup-gbrain`)
- Try planning and review flows (`/plan-eng-review`, `/plan-design-review`, `/review`, `/ship`)
- Practice with the `/browse` skill instead of `mcp__claude-in-chrome__*` tools
Scaffold only (v0.0.1.0). Modules are placeholders — landing page lists three grades with "Sắp ra mắt" badges. See the design doc at `~/.gstack/projects/tiennm99-try-gstack/` for the full plan.
## Installing gstack
## Develop
```sh
git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
cd ~/.claude/skills/gstack && ./setup
bun install
bun run dev # http://localhost:4321/try-gstack/
bun run test # Vitest (geom-engine unit tests)
bun run typecheck # Astro check + TS strict
bun run build # Static output to dist/
```
See `CLAUDE.md` for the project-level convention on which gstack skills are in scope here.
## Deploy
Auto-deploys to GitHub Pages from `main` via `actions/deploy-pages@v4`. See `RUNBOOK.md` for rollback + domain-migration procedure.
## Architecture (locked decisions, per autoplan review)
- **Static**: Astro SSG, `base: '/try-gstack/'`, output `dist/`
- **Typography**: Be Vietnam Pro single family, weights 400/500/700, woff2 only, Vietnamese subset
- **Math engine**: pure `src/geom-engine/` module (no DOM imports), Vitest unit tests; property tests with `fast-check` to be added with first canvas module
- **Canvas**: vanilla SVG + Pointer Events + `setPointerCapture` (no Konva) — to be added with first canvas module
- **Math rendering**: KaTeX, bundled locally + SSR-rendered — to be added with first canvas module
- **Animation**: Web Animations API + `requestAnimationFrame` (no GSAP / Motion One / Lottie)
- **Analytics**: Cloudflare Web Analytics, deferred until 50+ daily sessions
- **i18n**: every string via `t()` from `src/i18n/vi.ts`; English added by adding `en.ts`
## License
TBD.
+52
View File
@@ -0,0 +1,52 @@
# Runbook — Hình Học Sống
## Deployment
Production deploys from `main` via GitHub Actions (`.github/workflows/deploy.yml`).
- Live URL: `https://tiennm99.github.io/try-gstack/`
- Build: `bun run build` (Astro static, output to `dist/`)
- Deploy mechanism: `actions/upload-pages-artifact@v3` + `actions/deploy-pages@v4`
- Concurrency: `pages-deploy` group, cancel-in-progress disabled (so a force-pushed retry doesn't abort an in-flight rollback)
## Rollback
GitHub Pages keeps the last successful deployment. To roll back:
1. Find the offending commit on `main`: `git log --oneline main`
2. Revert it: `git revert <sha>` and push to `main`
3. The next workflow run rebuilds and re-deploys the prior good state
4. Verify the live site
If the workflow itself is broken (e.g., bad `deploy.yml` change), use the GitHub UI to re-run a previous successful deployment from the **Deployments** tab.
## CI
- `.github/workflows/ci.yml` runs typecheck + Vitest + build on every PR and push to `main`. Build failure on `main` blocks the deploy job.
- Local equivalent: `bun install && bun run typecheck && bun run test && bun run build`
## Domain migration trigger
Per autoplan plan, buy a `.vn` / `.com.vn` domain only when ANY of:
1. **500 unique sessions** in any rolling 30-day window post-launch, OR
2. **1 organic teacher share** (Facebook group, Zalo, or school chat — verified, not founder-initiated), OR
3. **≥5 modules shipped** (signals content sustainability and amortizes the domain cost)
If none hit within 90 days of soft launch, stay on `tiennm99.github.io/try-gstack/`.
When the trigger fires:
1. Register the domain (VN registration: passport scan + address proof + MIC filing, 714 days)
2. Add `CNAME` file at repo root with the new domain (e.g. `hinhhocsong.vn`)
3. Configure custom domain in GitHub Pages settings (Settings → Pages → Custom domain)
4. Update `astro.config.mjs`: change `SITE_BASE` env from `/try-gstack` to `/`, point `SITE_URL` at the new domain. Or update the workflow `env:` block.
5. Update OpenGraph + canonical URLs (handled automatically once `astro.config.mjs` reflects the new domain)
6. Wait 24h, then update sitemap submission in Google Search Console
7. Set up 301 redirects (GitHub Pages handles this automatically once the custom domain is the primary)
## Things to NOT do
- Never `git push --force` to `main`.
- Never edit `VERSION` or `package.json.version` independently — they must agree (`/ship` enforces this).
- Never change `astro.config.mjs` `base` without simultaneously updating the deploy workflow `env:` and any hardcoded internal links.
+145
View File
@@ -0,0 +1,145 @@
# TODOS
Tracked work, organized by component then priority (P0 highest → P4 lowest). See `~/.gstack/projects/tiennm99-try-gstack/tiennm99-main-design-20260429-160610.md` for the full plan.
## Distribution & Validation (Phase 0, pre-build)
- **Phase 0 SERP audit**
**Priority:** P0
**What:** 2-hour Google.com.vn / YouTube VN / TikTok VN audit on `góc nội tiếp`, `tam giác bằng nhau`, `tam giác đồng dạng`. Document positions 110 + dominant content type.
**Why:** Determines whether text page can win SERP or whether we pivot to video-first.
**Output:** `competitive-landscape.md` companion file.
- **Phase 0 TikTok / Shorts validation**
**Priority:** P0
**What:** 5 short videos (1030s) of mock drag interactions (Figma + Lottie + screen-record). Post to test the visual-wow thesis BEFORE writing module code.
**Why:** If videos flop, the entire visualizer wedge is dead before a weekend is invested.
- **Vetted Facebook group entry**
**Priority:** P1
**What:** Join "Hỏi đáp Toán THCS", "Toán học vui", "Giáo viên Toán". Post one helpful, non-self-promotional answer in each. Earn standing.
**Why:** Distribution plan depends on these groups; founder must have presence before the launch post.
## TheoremCanvas primitive (weekend 1, before any module)
- **Build TheoremCanvas as single HTML file first**
**Priority:** P1
**What:** `<TheoremCanvas mode="..." initial={...} client:visible />` API. SVG canvas + Pointer Events drag + setPointerCapture per vertex. AbortController teardown. Per autoplan eng decision E1.
**Where:** prototype as standalone `prototype.html`, then port into `src/components/TheoremCanvas.astro` once API feels right.
- **Vertex drag affordance + 48px hit-target**
**Priority:** P1
**What:** 14px filled circle, 2px white stroke, 4px drop shadow, 48px transparent square hit-target, `tabindex=0`, focused = 3px #D7263D ring. Per Design Decision D1.
- **Keyboard navigation for vertices**
**Priority:** P1
**What:** Tab cycles vertices; arrows ±4px; shift+arrow ±16px; Enter snaps to nearest interesting position. Per Design F6.1.
## Module 3 — Lớp 9 Góc nội tiếp (weekend 2, hero module)
- **Drag M around circle, project to circle constraint**
**Priority:** P1
**What:** `M = center + r·normalize(pointer center)` every `pointermove`. No free movement. Per Eng failure mode #4.
- **Theorem panel + 3 worked SGK-style examples**
**Priority:** P1
- **First-load coaching state** (per Design F2.2)
**Priority:** P2
**What:** Animated ghost-finger gesture for 1.5s, "Kéo điểm M để khám phá" caption, dismissible, persisted in localStorage.
- **ARIA-live announcements on dragend**
**Priority:** P2
**What:** "Góc AMB bằng 47 độ" announced via `aria-live="polite"`. Per Design F6.4.
## Module 1 — Lớp 7 Tam giác bằng nhau (weekend 3)
- **SSS / SAS / ASA / AAS / cạnh huyền-góc nhọn / cạnh huyền-cạnh góc vuông detectors**
**Priority:** P1
**What:** Pure geometry functions in `src/geom-engine/congruence.ts`. Use `EPSILON_LEN = 0.5`. Color-pair highlighting + green "Hai tam giác bằng nhau" badge.
**Note:** Rigid-motion overlay animation EXPLICITLY DROPPED per autoplan (was scope creep).
- **SGK tick-mark encoding**
**Priority:** P1
**What:** 1/2/3 ticks for matching sides, 1/2/3 arcs for matching angles. Always paired with the 3-color palette (#D7263D / #1B998B / #F46036). Per Design Decision D3 — single largest a11y + pedagogical unlock.
## Module 2 — Lớp 8 Tam giác đồng dạng (weekend 4)
- **Similarity ratio + AA/SAS/SSS-similar detectors**
**Priority:** P1
**What:** `src/geom-engine/similarity.ts`. Live ratio display via numeric text-node updates only (KaTeX template rendered at build, never re-parsed during drag).
- **Scale slider clamped to [0.5, 2.0]**
**Priority:** P1
**What:** `min=0.5, max=2, step=0.05`. Cannot reach 0. Per Eng failure mode #3.
## Testing & CI infrastructure (with first canvas module)
- **fast-check property tests**
**Priority:** P1
**What:** `∀ M on arc, |∠AMB ∠AM₀B| < 0.5°` (inscribed-angle invariance). `∀ pointer, dist(projectToCircle(p, c), c.center) = c.r ± ε`.
- **Playwright e2e + multi-touch tests**
**Priority:** P1
**What:** Per-module happy-path + keyboard-path + multi-touch-path. Use `device: 'Pixel 5'` for touch profiles. Per autoplan test plan.
- **size-limit bundle gate**
**Priority:** P2
**What:** 200KB gz JS / 50KB gz CSS / 80KB woff2 fonts. CI fail-fast.
- **Lighthouse-CI gate**
**Priority:** P2
**What:** Mobile preset, ≥90 Perf + A11y. Run against `bun preview`.
- **axe-playwright a11y tests**
**Priority:** P2
**What:** Zero axe-violations on every module page.
- **Visual regression for tick-marks + Vietnamese diacritics**
**Priority:** P2
**What:** Argos / Playwright snapshot baselines.
## Retention & Distribution (post-MVP)
- **Zalo / Telegram subscribe CTA on every module**
**Priority:** P2
**What:** "Nhận theorem mới mỗi tuần" — turns hub into sequence. Per CEO finding (theme 4).
- **Lớp-9 course-spine "next theorem" footer card**
**Priority:** P2
**What:** Each lớp-9 module links forward to next theorem. Per Design F3.5.
- **OpenGraph image generation per module**
**Priority:** P2
**What:** Pre-rendered SVG-to-PNG of canvas hero state. Critical for FB / Zalo shares.
- **Schema.org `LearningResource` meta**
**Priority:** P3
**What:** Per-module structured data for Google rich results.
## Domain & infra (post-validation trigger)
- **Buy `.vn` / `.com.vn` domain**
**Priority:** P1
**What:** TRIGGER: 500 sessions/30d OR 1 organic teacher share OR ≥5 modules shipped (per RUNBOOK). VN registration takes 714 days.
- **Cloudflare Web Analytics**
**Priority:** P2
**What:** JS-injected, no cookies, no consent banner. Activate after first 50 daily sessions.
- **`PUBLIC_VERSION` env wiring**
**Priority:** P3
**What:** Currently `index.astro` reads `import.meta.env.PUBLIC_VERSION ?? '0.0.1.0'` — env never set. Wire it from CI build (read VERSION file) so footer doesn't rot on bump.
## Out of scope (deferred indefinitely or to v2)
- English / bilingual support
- Embed-as-iframe API (CSP frame-ancestors work)
- Comments via Giscus
- Account / auth / email capture
- Browser extension overlay (out of static scope)
- Anki-style spaced-repetition deck (different product shape)
- Print → digital QR bridge (distribution experiment, not engineering)
- Teacher-tool / B2B2C demo mode (build after Module 3 ships)
## Completed
+1
View File
@@ -0,0 +1 @@
0.0.1.0
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import sitemap from '@astrojs/sitemap';
const siteUrl = process.env.SITE_URL ?? 'https://tiennm99.github.io';
const basePath = process.env.SITE_BASE ?? '/try-gstack';
export default defineConfig({
site: siteUrl,
base: basePath,
trailingSlash: 'always',
output: 'static',
integrations: [tailwind({ applyBaseStyles: false }), sitemap()],
build: {
assets: 'assets',
},
});
+1157
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "try-gstack",
"version": "0.0.1.0",
"private": true,
"type": "module",
"description": "Hình Học Sống — Interactive Vietnamese THCS geometry visualizer (lớp 7-9). Static site, drag-to-explore theorems.",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"typecheck": "astro check",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@astrojs/sitemap": "^3.4.0",
"@astrojs/tailwind": "^6.0.0",
"@fontsource/be-vietnam-pro": "^5.2.0",
"astro": "^5.14.0",
"tailwindcss": "^3.4.17"
},
"devDependencies": {
"@astrojs/check": "^0.9.9",
"@types/node": "^22.10.0",
"typescript": "^5.7.0",
"vitest": "^3.2.0"
}
}
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import {
add,
approxEqualLen,
dist,
dot,
EPSILON_LEN,
len,
normalize,
scale,
sub,
vec,
} from './vec';
describe('add', () => {
it('is commutative', () => {
const a = vec(2, 3);
const b = vec(-1, 4);
expect(add(a, b)).toEqual(add(b, a));
});
it('returns a new object (does not mutate)', () => {
const a = vec(1, 2);
const b = vec(3, 4);
const before = { ...a };
add(a, b);
expect(a).toEqual(before);
});
});
describe('sub', () => {
it('is the inverse of add', () => {
const a = vec(5, 7);
const b = vec(2, 1);
expect(sub(add(a, b), b)).toEqual(a);
});
});
describe('scale', () => {
it('multiplies both components by k', () => {
expect(scale(vec(2, 3), 4)).toEqual(vec(8, 12));
});
it('handles k = 0', () => {
expect(scale(vec(7, -3), 0)).toEqual(vec(0, 0));
});
});
describe('dot', () => {
it('computes the standard inner product', () => {
expect(dot(vec(1, 2), vec(3, 4))).toBe(11);
});
it('returns 0 for orthogonal vectors', () => {
expect(dot(vec(1, 0), vec(0, 1))).toBe(0);
});
});
describe('len and dist', () => {
it('len of (3,4) is 5', () => {
expect(len(vec(3, 4))).toBe(5);
});
it('dist is symmetric', () => {
const a = vec(1, 2);
const b = vec(4, 6);
expect(dist(a, b)).toBeCloseTo(dist(b, a));
});
it('dist of a point with itself is 0', () => {
expect(dist(vec(7, -3), vec(7, -3))).toBe(0);
});
});
describe('normalize', () => {
it('returns a unit vector for non-zero input', () => {
const n = normalize(vec(3, 4));
expect(len(n)).toBeCloseTo(1, 10);
});
it('returns the zero vector for the zero vector', () => {
expect(normalize(vec(0, 0))).toEqual(vec(0, 0));
});
it('preserves direction', () => {
const n = normalize(vec(2, 0));
expect(n).toEqual(vec(1, 0));
});
});
describe('approxEqualLen with EPSILON_LEN', () => {
it('treats values within epsilon as equal', () => {
expect(approxEqualLen(10, 10 + EPSILON_LEN / 2)).toBe(true);
});
it('rejects values outside epsilon', () => {
expect(approxEqualLen(10, 10 + EPSILON_LEN * 2)).toBe(false);
});
it('uses 0.5 as the default tolerance (per autoplan eng decision)', () => {
expect(EPSILON_LEN).toBe(0.5);
});
});
+47
View File
@@ -0,0 +1,47 @@
export interface Vec2 {
readonly x: number;
readonly y: number;
}
export const EPSILON_LEN = 0.5;
export const EPSILON_ANGLE_DEG = 0.5;
export function vec(x: number, y: number): Vec2 {
return { x, y };
}
export function add(a: Vec2, b: Vec2): Vec2 {
return { x: a.x + b.x, y: a.y + b.y };
}
export function sub(a: Vec2, b: Vec2): Vec2 {
return { x: a.x - b.x, y: a.y - b.y };
}
export function scale(a: Vec2, k: number): Vec2 {
// `+ 0` normalizes IEEE-754 -0 back to +0 so consumers comparing coordinates
// with === or Object.is don't see a signed-zero ghost from k=0 paths.
return { x: a.x * k + 0, y: a.y * k + 0 };
}
export function dot(a: Vec2, b: Vec2): number {
return a.x * b.x + a.y * b.y;
}
export function len(a: Vec2): number {
return Math.hypot(a.x, a.y);
}
export function dist(a: Vec2, b: Vec2): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}
export function normalize(a: Vec2): Vec2 {
const l = len(a);
if (l === 0) return { x: 0, y: 0 };
return { x: a.x / l, y: a.y / l };
}
export function approxEqualLen(a: number, b: number, eps = EPSILON_LEN): boolean {
return Math.abs(a - b) < eps;
}
+11
View File
@@ -0,0 +1,11 @@
import { vi } from './vi';
const locales = { vi } as const;
export type LocaleKey = keyof typeof locales;
const defaultLocale: LocaleKey = 'vi';
export function t(): typeof vi {
return locales[defaultLocale];
}
+40
View File
@@ -0,0 +1,40 @@
export const vi = {
site: {
title: 'Hình Học Sống',
tagline: 'Học hình bằng cách kéo',
description:
'Trang web tương tác giúp học sinh THCS (lớp 79) hiểu hình học bằng cách kéo và quan sát các định lý sống động.',
},
hub: {
intro:
'Mỗi định lý là một hình động. Kéo các điểm và xem định lý vẫn đúng. Đã chuẩn theo sách giáo khoa.',
chooseGrade: 'Chọn lớp của bạn:',
},
grade: {
'lop-7': {
title: 'Lớp 7',
hero: 'Tam giác bằng nhau',
blurb: 'SSS, SAS, ASA — kéo hai tam giác để xem khi nào chúng bằng nhau.',
status: 'sap-ra-mat',
},
'lop-8': {
title: 'Lớp 8',
hero: 'Tam giác đồng dạng',
blurb:
'AA, SAS, SSS đồng dạng. Kéo để xem tỉ số cạnh giữ nguyên khi tam giác phóng to.',
status: 'sap-ra-mat',
},
'lop-9': {
title: 'Lớp 9',
hero: 'Góc nội tiếp',
blurb:
'Kéo điểm M trên đường tròn — góc nội tiếp giữ nguyên khi M ở cùng cung.',
status: 'sap-ra-mat',
},
},
status: {
'sap-ra-mat': 'Sắp ra mắt',
},
} as const;
export type Locale = typeof vi;
+35
View File
@@ -0,0 +1,35 @@
---
import '~/styles/global.css';
import { t } from '~/i18n';
interface Props {
title?: string;
description?: string;
}
const copy = t();
const { title = copy.site.title, description = copy.site.description } = Astro.props;
const canonical = new URL(Astro.url.pathname, Astro.site).href;
---
<!doctype html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:locale" content="vi_VN" />
<meta name="twitter:card" content="summary" />
<meta http-equiv="X-Frame-Options" content="DENY" />
<meta name="robots" content="index, follow" />
<title>{title}</title>
</head>
<body>
<slot />
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
---
import BaseLayout from '~/layouts/BaseLayout.astro';
import { t } from '~/i18n';
const copy = t();
const grades = [
{ key: 'lop-7' as const, ...copy.grade['lop-7'] },
{ key: 'lop-8' as const, ...copy.grade['lop-8'] },
{ key: 'lop-9' as const, ...copy.grade['lop-9'] },
];
---
<BaseLayout>
<main class="mx-auto max-w-prose px-5 py-10">
<header class="mb-10">
<h1 class="mb-2">{copy.site.title}</h1>
<p class="text-lg" style="color:#444">{copy.site.tagline}</p>
</header>
<section class="mb-10">
<p>{copy.hub.intro}</p>
</section>
<section>
<h2 class="mb-4">{copy.hub.chooseGrade}</h2>
<ul class="space-y-3">
{
grades.map((grade) => (
<li class="rounded-lg border border-neutral-300 p-4">
<div class="flex items-baseline justify-between gap-3">
<strong>{grade.title}</strong>
<span class="text-sm uppercase tracking-wide" style="color:#888">
{copy.status[grade.status]}
</span>
</div>
<div class="mt-1 font-medium">{grade.hero}</div>
<p class="mt-2 text-base">{grade.blurb}</p>
</li>
))
}
</ul>
</section>
<footer class="mt-16 border-t border-neutral-200 pt-6 text-sm" style="color:#888">
Phiên bản v{import.meta.env.PUBLIC_VERSION ?? '0.0.1.0'}
</footer>
</main>
</BaseLayout>
+59
View File
@@ -0,0 +1,59 @@
@import '@fontsource/be-vietnam-pro/400.css';
@import '@fontsource/be-vietnam-pro/500.css';
@import '@fontsource/be-vietnam-pro/700.css';
@import '@fontsource/be-vietnam-pro/vietnamese-400.css';
@import '@fontsource/be-vietnam-pro/vietnamese-500.css';
@import '@fontsource/be-vietnam-pro/vietnamese-700.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-feature-settings: 'kern', 'locl';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html {
font-family: 'Be Vietnam Pro', system-ui, sans-serif;
}
body {
font-size: 17px;
line-height: 1.6;
color: #111;
background: #fff;
}
h1,
h2,
h3 {
line-height: 1.4;
font-weight: 700;
}
h1 {
font-size: 28px;
}
h2 {
font-size: 22px;
}
p {
max-width: 56ch;
margin: 0 0 1em;
}
a {
color: #1B998B;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
a:focus-visible {
outline: 3px solid #D7263D;
outline-offset: 2px;
border-radius: 2px;
}
+21
View File
@@ -0,0 +1,21 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {
fontFamily: {
sans: ['"Be Vietnam Pro"', 'system-ui', 'sans-serif'],
},
colors: {
// Locked palette per autoplan Decision D3 (a11y + SGK tick-marks).
pair1: '#D7263D',
pair2: '#1B998B',
pair3: '#F46036',
},
maxWidth: {
prose: '56ch',
},
},
},
plugins: [],
};
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["src/*"]
},
"verbatimModuleSyntax": false
},
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist", "node_modules"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/geom-engine/**/*.ts'],
exclude: ['src/geom-engine/**/*.test.ts'],
thresholds: {
lines: 95,
functions: 95,
branches: 90,
statements: 95,
},
},
},
});