mirror of
https://github.com/tiennm99/ai-labs.git
synced 2026-10-18 08:14:06 +00:00
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). * 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) * 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) * 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. * 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). * 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. * 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. ---------
This commit is contained in:
1 parent
7bba22131b
commit
89c5152780
22 files changed
+1977
-12
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 7–9) 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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user