chore(plans): drop dual-mode plan (superseded), add todo.md for next session

- delete plans/260427-0854-color-element-mapping/ — feature shipped, classic
  panel later removed; plan no longer reflects the codebase.
- plans/todo.md captures: OG image regen, open questions from the latest
  UI/UX review, polish leftovers, and which reports are still load-bearing.
This commit is contained in:
2026-04-27 21:15:44 +07:00
parent 7bcf9b97b9
commit e46a73a6c8
7 changed files with 47 additions and 1153 deletions
@@ -1,155 +0,0 @@
---
title: "Phase 01 — Vendor color data + HSL classifier module"
status: pending
priority: P2
effort: 60m
---
## Context Links
- Plan overview: [plan.md](plan.md)
- Color data report: [../reports/researcher-260427-0855-github-language-colors.md](../reports/researcher-260427-0855-github-language-colors.md)
- Classifier algorithm report: [../reports/researcher-260427-0854-nguhanh-color-classifier.md](../reports/researcher-260427-0854-nguhanh-color-classifier.md)
- Existing files: `index.html`, `style.css`
## Overview
- **Priority:** P2 (foundation for phases 0204)
- **Status:** pending
- **Description:** Vendor `ozh/github-colors` JSON locally and implement a pure-function HSL classifier (`hex → element`) with an in-browser sanity-check harness.
## Key Insights
- Report #1 confirms `ozh/github-colors` JSON is CORS-friendly + 78KB, but vendoring locally avoids any runtime third-party dependency and keeps the page working offline / on `file://`.
- Report #2 provides a deterministic HSL algorithm with explicit Step-2 grayscale handling and Step-4 edge-case refinements. Translate Python pseudocode to JS literally — do not invent variations.
- Report #2 §3 lists 12 sample languages with expected outputs; report §4 lists 10 canonical edge colors. These are the test fixtures.
- Element keys must be lowercase (`kim`, `moc`, `thuy`, `hoa`, `tho`) so they match the existing CSS class names in `style.css` (lines 124133). Reports use uppercase Vietnamese — translate at module boundary.
## Requirements
### Functional
- Vendor `data/github-colors.json` (~78KB) at the documented schema: `{ "<Language>": { "color": "#hex"|null, "url": "..." } }`.
- Provide ES module `js/classify-element.js` exporting:
- `hexToHsl(hex: string): { h: number, s: number, l: number }` — H ∈ [0, 360), S/L ∈ [0, 100]
- `classify(hex: string): 'kim' | 'moc' | 'thuy' | 'hoa' | 'tho'` — implements report §2 Steps 24 verbatim
- `ELEMENTS` constant: ordered list `['kim', 'moc', 'thuy', 'hoa', 'tho']` with display labels (`KIM`, `MỘC`, `THUỶ`, `HOẢ`, `THỔ`)
- Provide `js/classify-element.test.html` — opens in any browser, runs assertions, prints green PASS / red FAIL list. No test framework. No build.
### Non-functional
- File size budget: `classify-element.js` ≤ 120 lines, `classify-element.test.html` ≤ 100 lines (project rule: <200 lines/file).
- Pure functions: no I/O, no DOM, no globals. Importable from Node for future tooling without changes.
- Hex input tolerated: `#RRGGBB` and `#rrggbb`. Throw on malformed input (don't silently mis-classify).
## Architecture
### Data flow
```
data/github-colors.json (static)
▼ (fetched in Phase 02)
{ "JavaScript": { color: "#f1e05a", url: "..." }, ... }
▼ (Phase 02 calls classify(entry.color))
classify-element.js ──▶ 'thy' | 'kim' | ...
```
In Phase 01, the classifier is exercised only by the test harness — no integration yet.
### Module contract (`js/classify-element.js`)
```js
// Input: '#RRGGBB' or '#rrggbb'
// Output: 'kim' | 'moc' | 'thuy' | 'hoa' | 'tho'
// Throws: TypeError if input is null/undefined/not a 7-char hex string.
export function classify(hex) { ... }
export function hexToHsl(hex) { ... } // exported for test visibility
export const ELEMENTS = [
{ key: 'kim', label: 'KIM' },
{ key: 'moc', label: 'MỘC' },
{ key: 'thuy', label: 'THUỶ' },
{ key: 'hoa', label: 'HOẢ' },
{ key: 'tho', label: 'THỔ' },
];
```
### Classifier rules (mirror report #2 §2)
1. Parse `#RRGGBB` → R, G, B ∈ [0, 255] → normalize → HSL.
2. Grayscale (`S < 5`):
- `L < 20``thuy`
- `20 ≤ L < 70``tho`
- `L ≥ 70``kim`
3. Hue ranges:
- `[0, 20)``hoa`
- `[20, 40)``hoa` if `S ≥ 60 && L ≥ 50` else `tho`
- `[40, 70)``tho`
- `[70, 200)``moc` (covers green + jade/cyan)
- `[200, 260)``thuy`
- `[260, 360)``hoa`
## Related Code Files
### Create
- `data/github-colors.json` (vendored, ~78KB)
- `js/classify-element.js`
- `js/classify-element.test.html`
### Modify
- None.
### Delete
- None.
## Implementation Steps
1. Fetch the JSON once: `curl -fsSL https://raw.githubusercontent.com/ozh/github-colors/master/colors.json -o data/github-colors.json`. Verify file is well-formed JSON (`python3 -m json.tool data/github-colors.json | head` is fine for a smoke check).
2. Create `js/classify-element.js`:
- Implement `hexToHsl(hex)` — port report #2 pseudocode line by line. Handle `max == min` achromatic case (S=0, H=0).
- Implement `classify(hex)` — call `hexToHsl`, then run Step 2 then Step 3, return lowercase string.
- Export `ELEMENTS` array.
- Add a leading file comment citing the algorithm source: `// Algorithm: plans/reports/researcher-260427-0854-nguhanh-color-classifier.md §2`.
3. Create `js/classify-element.test.html`:
- Plain HTML with `<script type="module">` importing `./classify-element.js`.
- Define a `cases` array with the 12 samples from report §3 + 10 edge colors from report §4.
- Loop, compare actual vs expected, append `<li>` to a `<ul>` with PASS (green) / FAIL (red).
- Display total counts at the bottom.
4. Open `js/classify-element.test.html` in a browser; confirm all 22 cases PASS. If any fail, fix algorithm — do NOT edit expected values.
## Todo List
- [ ] Download and vendor `data/github-colors.json`
- [ ] Implement `hexToHsl(hex)` in `js/classify-element.js`
- [ ] Implement `classify(hex)` with Step 2 + Step 3 rules
- [ ] Export `ELEMENTS` constant with `key` + `label`
- [ ] Build `js/classify-element.test.html` harness
- [ ] Add 12 sample-language cases (report §3)
- [ ] Add 10 edge-case cases (report §4)
- [ ] Run harness in browser; confirm 22/22 PASS
## Success Criteria
- `data/github-colors.json` exists, parses, contains ≥600 entries with non-null colors.
- `node --input-type=module -e "import {classify} from './js/classify-element.js'; console.log(classify('#f1e05a'))"` prints `tho`.
- Opening `js/classify-element.test.html` in a browser shows all 22 PASS lines, zero FAIL.
- Running `wc -l js/classify-element.js` returns ≤ 120.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| RGB→HSL math bug (off-by-one on hue) | Med | High (mis-classifies many languages) | Test harness with 22 cases catches it; mirror pseudocode literally |
| JSON download is rate-limited or moved | Low | Med | Try once; fall back to manually downloading via browser. URL is documented in report #1. |
| Vendored JSON gets stale | Med | Low | This is a joke site; staleness is acceptable. Document refresh command in `data/README.md` (one-liner) — defer if YAGNI |
| Edge case Swift `#ffac45` (report flagged as borderline) | Low | Low | Report §3 resolves it: classifies as HOẢ via current rules. Test case enforces this. |
## Security Considerations
- No secrets, no auth, no user input — pure data + pure function.
- JSON is third-party data; treat as untrusted: validate that color values match `/^#[0-9a-fA-F]{6}$/` before classifying. Skip on mismatch.
## Next Steps
- Phase 02 imports `classify` and `ELEMENTS` to render the grid. Do not wire up rendering here.
- If the test harness fails on a case, fix the classifier — do not change the expected value without updating report #2 first.
@@ -1,248 +0,0 @@
---
title: "Phase 02 — Dual-mode shell: toggle, panels, hash persistence"
status: pending
priority: P2
effort: 50m
---
## Context Links
- Plan overview: [plan.md](plan.md)
- Next phase (consumes `#panel-modern`): [phase-03-render-modern-grid.md](phase-03-render-modern-grid.md)
- Style polish (toggle CSS): [phase-04-style-polish.md](phase-04-style-polish.md)
- Existing files: `index.html` (current single-view markup), `style.css`
## Overview
- **Priority:** P2 (blocks Phase 03 + 04)
- **Status:** pending
- **Description:** Restructure `index.html` from a single-view page into a two-panel SPA-style page with a segmented Classic / Modern toggle. Move the existing static image + 5 cards verbatim into `#panel-classic`. Scaffold an empty `#panel-modern` that Phase 03 will populate. Add `js/mode-toggle.js` to handle hash-based persistence + keyboard navigation.
## Key Insights
- The 5 published static cards (current `index.html` lines 2752) and the original image (lines 1725) are the **canonical Classic content**. They must be moved byte-for-byte; nothing rewritten, nothing reworded. They become the reference comparison view for Modern.
- The toggle is the only new interactive control on the page. Everything else stays static. Keep `mode-toggle.js` minimal — under ~80 lines.
- URL hash is the persistence channel. Hash also enables sharing (`?` query string would mean a separate URL identity per mode and might confuse GH Pages routing — hash is safer).
- Both panels must exist in DOM at all times (locked decision). Toggling `hidden` is preferred over `display:none` in JS because it's both a CSS hook (`[hidden]`) AND a semantic signal to AT.
- Default = Classic. If hash is anything other than `#modern`, render Classic.
- Phase 04 owns the visual styling of the toggle + the panel fade transition. This phase ships a functional but unstyled toggle — that's fine.
## Requirements
### Functional
- Restructure `index.html`:
- Hero `<header>` unchanged.
- **New** segmented toggle directly after the hero, before any panel.
- **`#panel-classic`** wraps the existing `<section class="figure">` (image) + `<section class="elements">` (5 hardcoded cards). Add a small label "(theo bài gốc, 2018)" near the section heading. No content changes inside.
- **`#panel-modern`** is a sibling section, currently empty except for an h2 heading and the mount points Phase 03 will use (`<div id="element-grid" class="grid"></div>`, `<p class="legend"></p>`, `<p class="disclaimer">…</p>`). Place `<details><summary>Ảnh gốc</summary>…image…</details>` near the bottom (collapsed by default). The image inside is a **second `<img>` tag** with the same `src` — duplication is intentional and KISS-compliant (no JS needed to move the image between panels).
- Footer credit unchanged.
- Add `<script type="module" src="./js/mode-toggle.js"></script>` before `</body>`.
- `js/mode-toggle.js` responsibilities:
- On `DOMContentLoaded`: read `location.hash`, normalize (`#modern` → modern, anything else → classic), call `setMode(mode)`.
- `setMode(mode)` sets `hidden` on the inactive panel, removes from active, updates `aria-selected` + `tabindex` on the two `<button>`s, updates `location.hash` (using `history.replaceState` on initial load to avoid pushing a history entry; `location.hash = ...` on user clicks).
- Click handler on each tab → `setMode(button.dataset.mode)`.
- Keyboard handler on the `<div role="tablist">`: `ArrowLeft` / `ArrowRight` move focus between the two buttons and call `setMode` on the newly focused tab (auto-activate; standard tab pattern). `Home` / `End` jump to first / last (only 2 tabs, but harmless). `Enter` / `Space` no-op extra needed since clicking already works.
- Listen to `hashchange` so browser back/forward + manual hash edits also switch modes.
### Non-functional
- `js/mode-toggle.js` ≤ 90 lines.
- `index.html` total ≤ 130 lines after restructure (currently 78).
- Zero console errors. Zero external requests added.
- No layout shift between Classic load and Modern toggle beyond the natural content swap.
- Works on `file://` (no fetch in this phase).
## Architecture
### DOM contract (post-Phase-02)
```html
<body>
<main class="page">
<header class="hero">
<h1>Lựa chọn ngôn ngữ lập trình theo phong thuỷ</h1>
<p class="subtitle">Ngũ Hành tương sinh — code cho hợp tuổi, hợp mệnh.</p>
</header>
<div class="mode-toggle" role="tablist" aria-label="Chế độ hiển thị">
<button type="button" role="tab" id="tab-classic" data-mode="classic"
aria-controls="panel-classic" aria-selected="true" tabindex="0">
Cổ điển (bài gốc)
</button>
<button type="button" role="tab" id="tab-modern" data-mode="modern"
aria-controls="panel-modern" aria-selected="false" tabindex="-1">
Hiện đại (tự phân loại)
</button>
</div>
<section id="panel-classic" role="tabpanel" aria-labelledby="tab-classic">
<!-- existing <section class="figure"> moved here verbatim -->
<section class="figure">
<figure>
<img src="assets/ngon-ngu-lap-trinh-phong-thuy.png" alt="..." />
<figcaption>Sơ đồ ngôn ngữ lập trình xếp theo Ngũ Hành.</figcaption>
</figure>
</section>
<!-- existing <section class="elements"> moved here verbatim, with label tweak -->
<section class="elements">
<h2>Ngũ Hành &amp; ngôn ngữ <small class="mode-tag">(theo bài gốc, 2018)</small></h2>
<div class="grid">
<article class="card kim"><h3>KIM</h3><p>JavaScript, Objective-C, Python</p></article>
<article class="card thuy"><h3>THUỶ</h3><p>C#, PHP</p></article>
<article class="card moc"><h3>MỘC</h3><p>Android, C#</p></article>
<article class="card hoa"><h3>HOẢ</h3><p>Scala, HTML5, Java, Node.js</p></article>
<article class="card tho"><h3>THỔ</h3><p>JavaScript, Go, Ruby</p></article>
</div>
<p class="disclaimer">* Bảng phân loại mang tính giải trí, lấy từ ảnh gốc.</p>
</section>
</section>
<section id="panel-modern" role="tabpanel" aria-labelledby="tab-modern" hidden>
<section class="elements">
<h2>Ngũ Hành &amp; ngôn ngữ <small class="mode-tag">(tự phân loại theo màu GitHub)</small></h2>
<div class="grid" id="element-grid"></div>
<p class="legend"></p>
<p class="disclaimer">* Phân loại tự động theo màu chính thức GitHub Linguist + quy tắc HSL.</p>
</section>
<details class="original-image">
<summary>Ảnh gốc</summary>
<figure>
<img src="assets/ngon-ngu-lap-trinh-phong-thuy.png" alt="Sơ đồ phong thuỷ ngôn ngữ lập trình theo Ngũ Hành" />
<figcaption>Sơ đồ ngôn ngữ lập trình xếp theo Ngũ Hành.</figcaption>
</figure>
</details>
</section>
<footer class="credit"><!-- unchanged --></footer>
</main>
<script type="module" src="./js/mode-toggle.js"></script>
</body>
```
### Module contract (`js/mode-toggle.js`)
```js
// Public surface: none (self-initialising entry point).
// Side effects: mutates DOM (hidden, aria-selected, tabindex), listens to clicks, keys, hashchange.
const MODES = ['classic', 'modern'];
const DEFAULT_MODE = 'classic';
function readHash() {
const h = (location.hash || '').replace('#', '');
return MODES.includes(h) ? h : DEFAULT_MODE;
}
function setMode(mode, { writeHash = true, push = false } = {}) {
// toggle [hidden] on panels, aria-selected + tabindex on tabs,
// update hash via history.replaceState (push=false) or location.hash (push=true)
}
function onTabClick(e) { setMode(e.currentTarget.dataset.mode, { push: true }); }
function onTabListKeydown(e) { /* ←/→ Home End cycle + setMode */ }
function onHashChange() { setMode(readHash(), { writeHash: false }); }
document.addEventListener('DOMContentLoaded', () => {
// wire listeners; initial setMode(readHash(), { writeHash: false })
});
window.addEventListener('hashchange', onHashChange);
```
### Data flow
```
[page load]
mode-toggle.js DOMContentLoaded
├── readHash() → 'classic' | 'modern'
└── setMode(mode, { writeHash:false })
├── #panel-classic [hidden=mode!=='classic']
├── #panel-modern [hidden=mode!=='modern']
├── #tab-classic aria-selected, tabindex=0|-1
└── #tab-modern aria-selected, tabindex=0|-1
[tab click] → setMode(mode, { push:true }) → location.hash = '#mode'
[hashchange] → setMode(readHash(), { writeHash:false })
[arrow key on tablist] → focus next tab + setMode(...)
```
## Related Code Files
### Create
- `js/mode-toggle.js`
### Modify
- `index.html` — restructure: insert toggle, wrap existing image+cards into `#panel-classic`, add scaffolded `#panel-modern`, add module script tag. **The 5 cards inside `#panel-classic` must be byte-identical copies of current `index.html` lines 3049.**
### Delete
- None.
## Implementation Steps
1. Open `index.html`. Capture the exact text of lines 1752 (figure section + elements section) — these blocks move into `#panel-classic` unchanged except for the heading `<small class="mode-tag">` insertion.
2. Replace the body `<main>` content with the structure shown in the DOM contract above:
- Hero unchanged.
- Insert `<div class="mode-toggle" role="tablist">` with two buttons.
- Wrap the captured figure + elements blocks inside `<section id="panel-classic" role="tabpanel" aria-labelledby="tab-classic">`. Add `<small class="mode-tag">(theo bài gốc, 2018)</small>` inside the existing `<h2>`.
- Add `<section id="panel-modern" role="tabpanel" aria-labelledby="tab-modern" hidden>` with the empty grid mount, legend `<p>`, modern disclaimer, and the `<details><summary>Ảnh gốc</summary>…</details>` block (duplicate `<img>` reusing the same `src`).
- Footer credit untouched.
3. Add `<script type="module" src="./js/mode-toggle.js"></script>` immediately before `</body>`.
4. Create `js/mode-toggle.js` per the contract:
- Implement `readHash`, `setMode`, click handler, keydown handler, `hashchange` handler.
- On `DOMContentLoaded`, call `setMode(readHash(), { writeHash: false })` so initial render does not pollute history.
- Use `history.replaceState(null, '', '#' + mode)` when `writeHash: true && push: false`, `location.hash = mode` when `push: true`.
- Arrow key handler: compute next tab index, focus it (`.focus()`), call `setMode` to activate.
5. Smoke test (no server needed for this phase since no `fetch`):
- Open `index.html` directly. Default view = Classic, image + 5 cards render exactly as before.
- Click "Hiện đại" — Classic panel disappears, Modern panel appears (empty grid, legend empty, but `<details>Ảnh gốc</details>` is visible and collapsible).
- Refresh page — URL is `#modern`, Modern persists.
- Press ← arrow key while a tab has focus — focus and panel switch back to Classic; URL becomes `#classic`.
- Open `index.html#modern` directly — opens in Modern.
- Open `index.html#bogus` directly — falls back to Classic.
## Todo List
- [ ] Capture verbatim copy of current `index.html` lines 1752 for `#panel-classic`
- [ ] Insert toggle markup (`<div role="tablist">` + two `<button role="tab">`)
- [ ] Wrap figure + elements blocks into `#panel-classic` with `mode-tag` heading addition
- [ ] Scaffold `#panel-modern` with empty grid, legend `<p>`, disclaimer, and collapsed `<details>Ảnh gốc</details>` containing duplicate `<img>`
- [ ] Add `<script type="module" src="./js/mode-toggle.js">` before `</body>`
- [ ] Create `js/mode-toggle.js`: `readHash`, `setMode`, tab click handler
- [ ] Add arrow-key navigation (`←`/`→`/`Home`/`End`) on the tablist
- [ ] Add `hashchange` listener for back/forward
- [ ] Verify default = Classic; refresh persistence; deep link `#modern`; invalid hash fallback
## Success Criteria
- `index.html` opens with Classic active by default; image + 5 cards visually identical to pre-Phase-02 state.
- Clicking "Hiện đại" hides classic panel, shows modern panel (still empty pending Phase 03), URL hash becomes `#modern`.
- Refreshing page keeps the active mode.
- Browser back button after a toggle restores previous mode (works because we use `location.hash =` on user clicks).
- `aria-selected` and `tabindex` on the two `<button>`s reflect state at all times.
- Keyboard ←/→ cycles tabs and activates them.
- No console errors. `wc -l js/mode-toggle.js` ≤ 90.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Verbatim copy of static cards drifts (typo introduced during move) | Med | High (acceptance test #1 fails) | Copy block as-is, do not retype. Diff against original commit before merging. |
| Hash collision with future anchor links (`#section-id`) | Low | Med | Reserve `#classic` / `#modern` only; document in mode-toggle.js comment. |
| Loading at `index.html#modern` flashes Classic before swap | Med | Low | Set initial `hidden` on `#panel-modern` in HTML; JS `setMode` runs on `DOMContentLoaded` which fires before paint of dynamic content. If FOUC observed, move the `setMode` call inline-blocking before render. |
| Arrow-key handler steals focus when tabs are not focused | Low | Low | Attach keydown listener to the `<div role="tablist">` element only, not `document`. |
| Duplicate `<img>` in Modern panel double-loads the asset | Low | Low | Browser caches; same `src` resolves to one network request. Acceptable. |
| Browser ignores `<script type="module">` on `file://` (Firefox/old Chrome) | Low | Med | README already recommends `python3 -m http.server`. Document the limitation in commit body. |
## Security Considerations
- No user input is taken anywhere. Hash value is normalized against an allowlist (`MODES`) before any DOM mutation — no injection vector.
- All DOM mutations use property assignment (`el.hidden`, `el.setAttribute`, `el.tabIndex`); no `innerHTML`.
- Static file additions only.
## Next Steps
- Phase 03 mounts the dynamic grid into `#panel-modern .grid` (already scaffolded here) and populates the legend `<p>`.
- Phase 04 adds the visual styling of the toggle (segmented control look, focus ring, fade transition between panels).
- Phase 05 (optional) appends a `<details id="debug-panel">` inside `#panel-modern` after the legend.
@@ -1,214 +0,0 @@
---
title: "Phase 03 — Render dynamic element grid into #panel-modern"
status: pending
priority: P2
effort: 45m
---
## Context Links
- Plan overview: [plan.md](plan.md)
- Phase 01 (classifier dependency): [phase-01-data-and-classifier.md](phase-01-data-and-classifier.md)
- Phase 02 (panel scaffold dependency): [phase-02-dual-mode-shell.md](phase-02-dual-mode-shell.md)
- Style polish (consumes the chip markup produced here): [phase-04-style-polish.md](phase-04-style-polish.md)
- Existing files: `index.html` (post-Phase-02, with `#panel-modern .grid` scaffolded), `style.css` (existing `.card.kim/.thuy/.moc/.hoa/.tho`)
## Overview
- **Priority:** P2
- **Status:** pending
- **Description:** Populate the empty grid in `#panel-modern` (created by Phase 02) with 5 cards driven by `data/github-colors.json` and the Phase 01 classifier. Each card lists its languages as colored chips. Modern-only — Classic panel stays untouched. The collapsed `<details>Ảnh gốc</details>` block already exists in markup; this phase does not modify it.
## Key Insights
- Mount point is `#panel-modern .grid` (i.e. `document.getElementById('element-grid')`), **not** the legacy `<section class="elements">` block — that selector now exists inside both panels and only the Modern one should be populated by JS.
- The Classic cards (`#panel-classic .grid`) are static and must not be touched by this phase. This is a hard rule — JS queries by id, not by class, to avoid accidentally hitting Classic.
- Original image, hero, footer credit must stay byte-identical (Phase 02 already kept them so).
- Chips must be readable on their own background → compute YIQ contrast at render time and pick black or white text. Only branching needed for accessibility (WCAG-ish).
- Alphabetical sort is the only natural default; "popularity" is not in the JSON. YAGNI on ranking.
- Module loading on `file://` is restricted in some browsers (e.g. Chrome blocks `fetch` of local JSON). Document `python3 -m http.server 8080` in README — already present.
- Render runs on every page load regardless of active mode. Cost is acceptable (~664 chips into a hidden DOM node), and avoids a re-render race when the user switches to Modern.
## Requirements
### Functional
- On page load (`DOMContentLoaded`), fetch `./data/github-colors.json`.
- Filter entries with non-null, valid hex `color` (regex `/^#[0-9a-fA-F]{6}$/`).
- For each language: call `classify(color)` → bucket into one of 5 element groups.
- Sort each bucket alphabetically by language name (case-insensitive).
- Render 5 cards into `#element-grid` (KIM, MỘC, THUỶ, HOẢ, THỔ in that order). Each card shows:
- Heading (existing `<h3>` style)
- Language count: `<small class="card-count">123 ngôn ngữ</small>`
- Chip list: `<div class="chips">` containing one `<span class="chip" style="background:#hex; color:[black|white]" title="#hex">LangName</span>` per language.
- Skipped languages (null/invalid color): not rendered. No "unknown" bucket.
- Populate `<p class="legend">` (the empty `<p>` already in the markup) with the rule summary: `"Phân loại theo tông màu HSL: đỏ/tím/cam đậm → HOẢ, xanh lá/cyan → MỘC, xanh dương → THUỶ, vàng/nâu → THỔ, trắng/xám sáng → KIM."`
- Disclaimer paragraph already exists in the Modern panel markup; do not touch.
- The `<details>Ảnh gốc</details>` element exists in markup from Phase 02; this phase does not modify it.
### Non-functional
- File size budget: `js/render-elements.js` ≤ 130 lines, `js/main.js` ≤ 60 lines.
- Render must complete <100ms on a typical laptop (664 chips is trivial DOM work).
- No console errors. No external network requests at runtime (only relative-path fetch to vendored JSON).
- Graceful failure: if fetch fails, replace the legend `<p>` text and prepend an inline `.render-error` element inside `#panel-modern .elements` section. Do not break the rest of the page or affect Classic mode.
## Architecture
### Data flow
```
[page load]
js/main.js (DOMContentLoaded)
├── fetch('./data/github-colors.json')
│ │
│ ▼ (resolved to colors object)
│ ┌────────────────────────────┐
│ │ for each [name, {color}]: │
│ │ if !color → skip │
│ │ element = classify(color)│ (from classify-element.js)
│ │ buckets[element].push(…) │
│ └────────────────────────────┘
└──▶ render-elements.js: renderGrid(buckets, mountEl)
└─ writes 5 cards into #element-grid (inside #panel-modern)
└──▶ sets legend text on document.querySelector('#panel-modern .legend')
```
### DOM contract (the markup this phase mounts into — created by Phase 02)
```html
<section id="panel-modern" role="tabpanel" aria-labelledby="tab-modern" hidden>
<section class="elements">
<h2>Ngũ Hành &amp; ngôn ngữ <small class="mode-tag">(tự phân loại theo màu GitHub)</small></h2>
<div class="grid" id="element-grid"><!-- this phase populates --></div>
<p class="legend"><!-- this phase populates --></p>
<p class="disclaimer">* Phân loại tự động theo màu chính thức GitHub Linguist + quy tắc HSL.</p>
</section>
<details class="original-image">
<summary>Ảnh gốc</summary>
<figure>
<img src="assets/ngon-ngu-lap-trinh-phong-thuy.png" alt="..." />
<figcaption>Sơ đồ ngôn ngữ lập trình xếp theo Ngũ Hành.</figcaption>
</figure>
</details>
</section>
```
This phase **does not** modify `index.html`.
### Module contracts
```js
// js/render-elements.js
export function renderGrid(buckets, mountEl)
// buckets: { kim: [{name, color}], moc: [...], thuy: [...], hoa: [...], tho: [...] }
// mountEl: HTMLElement to render cards into (#element-grid)
export function renderError(message, mountEl)
// inline error fallback; mountEl is the .elements <section> inside #panel-modern
// js/main.js (entry)
// 1. fetch JSON
// 2. classify into buckets
// 3. call renderGrid(buckets, document.getElementById('element-grid'))
// 4. set legend text on document.querySelector('#panel-modern .legend')
// 5. on error: renderError into document.querySelector('#panel-modern .elements')
```
### Contrast helper
Inline in `render-elements.js` (≤ 6 lines):
```js
// YIQ-based: returns 'black' or 'white'
function pickTextColor(hex) {
const r = parseInt(hex.slice(1,3),16),
g = parseInt(hex.slice(3,5),16),
b = parseInt(hex.slice(5,7),16);
return (r*299 + g*587 + b*114) / 1000 >= 128 ? 'black' : 'white';
}
```
## Related Code Files
### Create
- `js/render-elements.js`
- `js/main.js`
### Modify
- None. (`index.html` already has the mount points from Phase 02.)
### Delete
- None.
## Implementation Steps
1. Create `js/render-elements.js`:
- Implement `pickTextColor(hex)` helper.
- Implement `renderGrid(buckets, mountEl)`:
- Build `DocumentFragment`. For each `{key, label}` in `ELEMENTS`:
- `<article class="card ${key}">` with `<h3>${label}</h3>`, `<small class="card-count">${langs.length} ngôn ngữ</small>`, and `<div class="chips">` containing one `<span class="chip">` per language. Use `textContent = name` (never `innerHTML`); set `style.background = color; style.color = pickTextColor(color); title = color`.
- Clear `mountEl` (`mountEl.replaceChildren(fragment)`).
- Implement `renderError(msg, mountEl)` → prepend `<p class="render-error">${msg}</p>` to `mountEl`.
- Export `renderGrid`, `renderError`. Import `ELEMENTS` from `./classify-element.js`.
2. Create `js/main.js`:
- Import `classify`, `ELEMENTS` from `./classify-element.js`, and `renderGrid`, `renderError` from `./render-elements.js`.
- On `DOMContentLoaded`:
- `fetch('./data/github-colors.json')``.json()`.
- Build `buckets = { kim:[], moc:[], thuy:[], hoa:[], tho:[] }`.
- Iterate entries: skip if `color` null or fails `/^#[0-9a-fA-F]{6}$/`. Otherwise push `{name, color}` into `buckets[classify(color)]`.
- Sort each bucket alphabetically (case-insensitive `localeCompare`).
- `renderGrid(buckets, document.getElementById('element-grid'))`.
- Set legend: `document.querySelector('#panel-modern .legend').textContent = "Phân loại theo tông màu HSL: đỏ/tím/cam đậm → HOẢ, xanh lá/cyan → MỘC, xanh dương → THUỶ, vàng/nâu → THỔ, trắng/xám sáng → KIM."`
- Wrap in try/catch → on failure call `renderError(msg, document.querySelector('#panel-modern .elements'))`.
- Add module script tag to `index.html`? **No** — Phase 02 added `js/mode-toggle.js`. Add a second tag `<script type="module" src="./js/main.js"></script>` before `</body>`. *Correction: this is the only `index.html` edit this phase makes — adding the second `<script type="module">` line.* Update file ownership accordingly.
3. Edit `index.html` (one line): add `<script type="module" src="./js/main.js"></script>` immediately after the existing `<script type="module" src="./js/mode-toggle.js"></script>`.
4. Local smoke test: `python3 -m http.server 8080`, open `http://localhost:8080`, switch to Modern via toggle, confirm:
- Modern grid: 5 cards render with chips
- JavaScript chip is yellow with black text and lives under THỔ
- Python chip is blue with white text under THUỶ
- C# chip is green under MỘC
- Classic panel still shows verbatim 5 cards from the 2018 mapping (untouched)
- `<details>Ảnh gốc</details>` expands and shows the original image
- No console errors
## Todo List
- [ ] Create `js/render-elements.js` with `renderGrid`, `renderError`, contrast helper
- [ ] Create `js/main.js` entry point (fetch + classify + render into `#element-grid`)
- [ ] Set legend text on `#panel-modern .legend`
- [ ] Add `<script type="module" src="./js/main.js">` to `index.html`
- [ ] Smoke test via `python3 -m http.server 8080` in both modes
- [ ] Verify the 12 sample languages from report §3 land in expected elements (Modern panel)
- [ ] Verify Classic panel unchanged
## Success Criteria
- Modern panel: all 5 cards render with ≥5 chips each.
- Each chip has `style="background:#hex; color:black|white"` and `title="#hex"`.
- Sample language placement matches Phase 01 acceptance list (12 languages).
- Total Modern render time <100ms (eyeball; no flash of empty content beyond network latency).
- Classic panel: visually identical to post-Phase-02 state — JavaScript/Objective-C/Python in KIM card, etc.
- View-source after switching to Modern shows populated chip markup inside `#element-grid`.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| `fetch('./data/...')` fails on `file://` (Chrome) | High | Med | Document `python3 -m http.server` in README (already done); show inline error if it happens (does not affect Classic mode) |
| 600+ chips cause horizontal scroll on mobile | Med | Med | Phase 04 handles wrap + chip sizing |
| `getElementById('element-grid')` collides with Classic if Phase 02 misnamed the Classic grid | Med | High | Phase 02 contract: only `#panel-modern .grid` carries `id="element-grid"`. Verify before Phase 03 starts. |
| Sort by name puts emoji/Unicode names oddly | Low | Low | `localeCompare` default is fine. |
| Module script load order: `main.js` runs before `mode-toggle.js` finishes | Low | Low | Both listen to `DOMContentLoaded`; order is irrelevant. Each operates on disjoint DOM nodes. |
## Security Considerations
- All chip text comes from JSON keys; the JSON is vendored from a trusted source. Still, set `chipEl.textContent = name` (never `innerHTML`) — defends against malformed entries.
- Style attribute uses validated hex only (regex-checked in `main.js`). No CSS injection vector.
- No `eval`, no `innerHTML` with interpolation.
- This phase only writes inside `#panel-modern`; Classic panel is read-never-write.
## Next Steps
- Phase 04 styles the chips for readability + responsive flow + the toggle / panel transition CSS.
- Phase 05 (optional) appends a debug panel inside `#panel-modern` listing skipped + borderline languages.
@@ -1,272 +0,0 @@
---
title: "Phase 04 — Toggle, panel transitions, chip styling, legend, responsive"
status: pending
priority: P2
effort: 35m
---
## Context Links
- Plan overview: [plan.md](plan.md)
- Phase 02 (toggle / panel structure dependency): [phase-02-dual-mode-shell.md](phase-02-dual-mode-shell.md)
- Phase 03 (chip markup dependency): [phase-03-render-modern-grid.md](phase-03-render-modern-grid.md)
- Existing file: `style.css` (current `.card`, `.grid`, color vars)
## Overview
- **Priority:** P2
- **Status:** pending
- **Description:** Append CSS for the new dual-mode UI: segmented toggle (`.mode-toggle`, tabs, focus ring), panel `[hidden]` + opacity transition, plus the chip / count / legend / error / responsive rules originally planned. Keep all existing rules.
## Key Insights
- Two distinct concerns in this phase: **(A)** the toggle + panel transition (new in this revision), **(B)** chip / legend / responsive (carried over from the original Phase 03 plan). Bundled because they live in the same `style.css` file and share design tokens — splitting would create two trivial phases.
- The existing grid uses `auto-fit, minmax(160px, 1fr)`. With long chip lists in Modern, cards become tall — fine, but on mobile (single column) we want them readable, not a wall of text.
- Existing CSS vars (`--kim`, `--thuy`, etc.) remain the right element accents. Chips use the language hex; card border-top stays element-coloured.
- Both panels coexist in DOM; toggling `[hidden]` is the canonical hide. CSS reinforces with `[hidden] { display: none; }` (browsers honor this, but explicitness helps when overriding for transitions).
- Opacity fade on switch: simple — apply `transition: opacity 150ms ease` to the panel, set `opacity: 0` while hidden via attribute, but `[hidden]` already removes from layout. KISS: skip cross-fade choreography. Just fade-in the becoming-visible panel via animation. Implementation: keep `[hidden] { display:none }`; when newly shown, the panel naturally appears. Add `@keyframes fadeIn` and apply to non-hidden panels. Cheap, no JS coordination.
- KISS: no animation beyond the existing `:hover translateY` and the new fade-in. No dark mode. No hover tooltips beyond browser-native `title`.
## Requirements
### Functional
#### A. Toggle + panel transitions
- `.mode-toggle` is a centered horizontal flex container; small gap; max-width matches the page content.
- Each `[role="tab"]` button: pill-shaped, neutral background when inactive (`var(--card-bg)` or transparent), accent background when `aria-selected="true"` (`var(--accent)` background, white text). Border, rounded corners, padding.
- Focus ring: visible 2px outline (`outline: 2px solid var(--gold); outline-offset: 2px`) on `:focus-visible` only.
- `[role="tabpanel"][hidden]``display: none` (default browser behavior; reinforced explicitly).
- `[role="tabpanel"]:not([hidden])` → fade in via `@keyframes fadeIn` (opacity 0 → 1 over 150ms). No layout shift.
- `.mode-tag` (the `<small>` inside h2): muted, italic, `0.85rem`.
#### B. Chip / legend / responsive (originally planned)
- `.chips` is a flex container that wraps.
- `.chip` is a small inline-block with rounded corners, padded, font-size ~0.78rem. No `text-shadow` (we already pick contrast color).
- `.card-count` is a muted small label under `<h3>`.
- `.legend` is centered, italic, `var(--muted)`, `0.85rem`.
- `.render-error` is centered, `var(--accent)` color, padded, dashed border.
- `.original-image` (the `<details>` block in Modern panel): inherit existing figure styles; `<summary>` should be readable (cursor pointer, muted color).
- Mobile (≤500px): chips slightly smaller; grid collapses to single column; toggle buttons shrink padding/font-size to stay on one row.
### Non-functional
- No new CSS file. Append to existing `style.css`. Total file should remain ≤ 280 lines (currently 175).
- No CSS frameworks, no preprocessors.
- Chips must remain readable on extreme colors (e.g. `#000000` Linguist entries). The `pickTextColor` from Phase 03 already handles this; CSS does not need to override.
- Animation respects `prefers-reduced-motion`: wrap `@keyframes fadeIn` usage in `@media (prefers-reduced-motion: no-preference)`.
## Architecture
No structural change. Just additive selectors.
### Selectors to add
```css
/* A. Toggle + panels */
.mode-toggle /* flex tablist wrapper */
.mode-toggle [role="tab"] /* tab button base */
.mode-toggle [role="tab"][aria-selected="true"] /* active tab */
.mode-toggle [role="tab"]:focus-visible /* focus ring */
[role="tabpanel"][hidden] /* explicit display:none */
.mode-tag /* "(theo bài gốc, 2018)" small tag in h2 */
.original-image /* <details> wrapping the duplicate image in Modern */
.original-image summary /* clickable summary styling */
@keyframes fadeIn /* opacity 0 → 1 */
/* B. Grid contents */
.chips /* flex wrap container inside .card */
.chip /* individual language pill */
.card-count /* small count under <h3> */
.legend /* one-line rule explanation */
.render-error /* fetch failure inline message */
/* Responsive */
@media (max-width: 500px) { ... }
@media (prefers-reduced-motion: no-preference) { /* fade-in */ }
```
### Visual hierarchy (modern panel)
```
.card
├── h3 (existing element-color title)
├── .card-count ← small muted "{n} ngôn ngữ"
└── .chips ← flex wrap
└── .chip ×N ← background = language hex, color = computed contrast
```
## Related Code Files
### Create
- None.
### Modify
- `style.css` — append new selectors at the end.
### Delete
- None.
## Implementation Steps
1. Open `style.css`, append a "Dual-mode toggle + panels" block after the existing `.card.tho h3 { ... }` rules (around line 133):
```css
/* ===== Dual-mode toggle + panels ===== */
.mode-toggle {
display: flex;
justify-content: center;
gap: 0.5rem;
margin: 1rem 0 1.5rem;
}
.mode-toggle [role="tab"] {
font: inherit;
padding: 0.45rem 1rem;
border-radius: 999px;
border: 1px solid var(--muted);
background: var(--card-bg);
color: var(--fg);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.mode-toggle [role="tab"][aria-selected="true"] {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.mode-toggle [role="tab"]:hover {
border-color: var(--accent);
}
.mode-toggle [role="tab"]:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
[role="tabpanel"][hidden] { display: none; }
.mode-tag {
font-size: 0.85rem;
font-style: italic;
color: var(--muted);
font-weight: normal;
margin-left: 0.4rem;
}
.original-image {
margin: 2rem 0 0;
background: var(--card-bg);
padding: 0.75rem 1rem;
border-radius: 12px;
box-shadow: var(--shadow);
}
.original-image summary {
cursor: pointer;
color: var(--muted);
font-style: italic;
}
.original-image figure { margin: 0.75rem 0 0; }
.original-image img { display: block; max-width: 100%; height: auto; border-radius: 8px; }
.original-image figcaption {
text-align: center;
margin-top: 0.5rem;
color: var(--muted);
font-size: 0.9rem;
}
@media (prefers-reduced-motion: no-preference) {
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
[role="tabpanel"]:not([hidden]) { animation: fadeIn 150ms ease; }
}
```
2. Append the chip / legend / error block:
```css
/* ===== Chip grid (modern panel) ===== */
.card-count {
display: block;
margin: 0 0 0.6rem;
color: var(--muted);
font-size: 0.8rem;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.chip {
display: inline-block;
padding: 0.18rem 0.55rem;
border-radius: 999px;
font-size: 0.78rem;
line-height: 1.4;
border: 1px solid rgba(0, 0, 0, 0.08);
white-space: nowrap;
}
.legend {
text-align: center;
font-style: italic;
color: var(--muted);
font-size: 0.85rem;
margin: 1rem 0 0;
}
.render-error {
text-align: center;
color: var(--accent);
padding: 1rem;
border: 1px dashed var(--accent);
border-radius: 8px;
}
@media (max-width: 500px) {
.chip { font-size: 0.72rem; padding: 0.15rem 0.45rem; }
.grid { grid-template-columns: 1fr; }
.mode-toggle [role="tab"] { padding: 0.35rem 0.75rem; font-size: 0.9rem; }
}
```
3. Reload `http://localhost:8080`. Verify:
- Toggle: two pill buttons centered above the panels; active tab is accent-coloured.
- Tab focus ring: gold outline appears when tabbing in via keyboard.
- Switching tabs: the becoming-visible panel fades in (≤150ms).
- Modern panel: chips wrap nicely inside cards; yellow JavaScript chip has black text; dark blue Python chip has white text.
- Mobile (Chrome devtools, 375px width): one card per row; tabs still on one line; chips readable.
- Classic panel: image + 5 cards visually unchanged from pre-Phase-04.
- `<details>Ảnh gốc</details>` (in Modern): summary line muted/italic; expanding shows the image card-styled.
## Todo List
- [ ] Append `.mode-toggle` flex container + button base + active + hover + focus-visible rules
- [ ] Append `[role="tabpanel"][hidden] { display:none }` reinforcement
- [ ] Append `.mode-tag` muted-italic style
- [ ] Append `.original-image` + `summary` + nested `figure/img/figcaption` styles
- [ ] Append `@keyframes fadeIn` inside `prefers-reduced-motion: no-preference` guard
- [ ] Append `.card-count` rule
- [ ] Append `.chips` flex wrap container rule
- [ ] Append `.chip` pill rule (no color/background — set inline in JS)
- [ ] Append `.legend` and `.render-error` rules
- [ ] Append `@media (max-width: 500px)` block (chip + grid + toggle adjustments)
- [ ] Visual smoke test desktop + 375px mobile, both modes, with reduced-motion on/off
## Success Criteria
- Toggle visually reads as a segmented control; active tab is unambiguous.
- Keyboard focus ring visible on tab focus (Tab key navigation).
- Panel switch produces a soft fade-in (no layout flash) with reduced-motion respected.
- Chips are rounded pills; do not overlap; wrap to next line when card width exhausted.
- Card with 100+ chips remains within page width (no horizontal scroll on a 320px viewport).
- Legend renders centered, italic, muted under the grid.
- Classic panel layout/colors visually identical to before this phase.
- `style.css` total length ≤ 280 lines.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Long card heights in Modern cause uneven grid rows | High | Low | Acceptable; grid auto-fit handles it. Don't force equal heights. |
| Chip with `#000` looks like a hole | Med | Low | Border on chip provides outline (rule already includes 1px border) |
| Mobile toggle wraps to two lines on very narrow screens | Med | Low | `@media` block reduces button padding. If still wraps at <320px, acceptable. |
| Fade-in animation runs on the *initial* visible panel on page load (Classic) | Med | Low | Acceptable; users see Classic fade in once, looks intentional. If undesired, gate with `:not([data-initial])` flag set by `mode-toggle.js`. |
| `prefers-reduced-motion` users still see fade due to a missed media query | Low | Low | Wrapping the keyframes definition + usage inside `@media (prefers-reduced-motion: no-preference)` ensures reduced-motion users get instant switches. |
| Active-tab contrast (white on `--accent`) fails AA on bright accents | Low | Med | Current `--accent` is `#b8312f` (dark red); white text passes AA. If accent changes, re-check. |
## Security Considerations
- Pure CSS, no security implications. No `url(...)`, no `@import` from external.
## Next Steps
- Phase 05 (optional) adds a debug panel inside `#panel-modern` — its `.render-error` style is already provided here; debug panel reuses standard `<details>` plus existing `.chip` styling.
@@ -1,177 +0,0 @@
---
title: "Phase 05 (optional) — Debug verify panel for unclassified / borderline cases"
status: pending
priority: P3
effort: 30m
---
## Context Links
- Plan overview: [plan.md](plan.md)
- Phase 02 (panel scaffold): [phase-02-dual-mode-shell.md](phase-02-dual-mode-shell.md)
- Phase 03 (grid render dependency): [phase-03-render-modern-grid.md](phase-03-render-modern-grid.md)
- Phase 04 (parallel-OK): [phase-04-style-polish.md](phase-04-style-polish.md)
- Algorithm reference: [../reports/researcher-260427-0854-nguhanh-color-classifier.md](../reports/researcher-260427-0854-nguhanh-color-classifier.md) §4 edge cases
## Overview
- **Priority:** P3 (optional; ship if time allows)
- **Status:** pending
- **Description:** Add a collapsible `<details>` debug panel inside `#panel-modern` (after the legend, before the disclaimer) that lists (a) skipped languages (no color), (b) borderline-classified languages near hue boundaries. Future contributors can sanity-check classifier output without rerunning the test harness. Modern-only — Classic panel is unaffected.
## Key Insights
- Borderline = classifier is using a "tipping rule" rather than a deep-bucket hue. Concretely: `H ∈ [18, 22)`, `H ∈ [38, 42)`, `H ∈ [68, 72)`, `H ∈ [198, 202)`, `H ∈ [258, 262)`. Width: ±2°. Anything inside = borderline.
- Grayscale bucket is also borderline-prone at `S ∈ [4, 6]` and at `L ∈ [18, 22] / [68, 72]`.
- This panel is for humans, not the build. Hidden by default via `<details>`.
- Reusing `.chip` styles from Phase 04 keeps CSS additions to zero.
- Mount point is **inside `#panel-modern`** only — never inside Classic. Hard rule.
## Requirements
### Functional
- After Modern grid renders, count + list:
1. **Skipped** (color is null/invalid): show count + a hidden chip list inside `<details>`.
2. **Borderline** (within ±2° of any hue boundary, OR `S ∈ [4,6]`, OR grayscale `L ∈ [18,22]/[68,72]`): show count + chip list with both the language hex and the assigned element label appended (e.g., `Foo (#a8e0c5 → moc)`).
- Both lists hidden by default; click summary to expand.
- If both lists are empty, hide the entire panel (no `<details>` rendered visibly — set `hidden`).
### Non-functional
- Append to `js/render-elements.js` (do NOT create a new file). Stay under 200 lines total.
- No CSS additions — reuse `.chip` and small text styles.
- Zero performance cost when the panel is collapsed (it's just plain DOM, not lazy).
## Architecture
### Data flow
```
js/main.js (Phase 03)
├── classifies all entries
│ └── also collects:
│ skipped: [{name}] (color was null/invalid)
│ borderline: [{name, color, element}] (HSL near boundary)
└── renderGrid(...) (existing, into #element-grid)
└── renderDebugPanel({ skipped, borderline }, mountEl) ← NEW
mountEl = document.getElementById('debug-panel') (inside #panel-modern)
```
### New module additions
```js
// js/render-elements.js (append)
export function renderDebugPanel({ skipped, borderline }, mountEl) { ... }
export function isBorderline(hex) { ... }
// js/main.js (modify)
// extend the per-entry loop to also push to skipped[] and borderline[] arrays
// after renderGrid, call renderDebugPanel(diag, document.getElementById('debug-panel'))
```
### Borderline detector (inline, ≤ 12 lines)
```js
import { hexToHsl } from './classify-element.js';
export function isBorderline(hex) {
const { h, s, l } = hexToHsl(hex);
if (s >= 4 && s < 6) return true; // grayscale tip
if (s < 5) return l >= 18 && l <= 22 || l >= 68 && l <= 72;
const boundaries = [20, 40, 70, 200, 260]; // 360-wrap not needed; 0/360 is identical
return boundaries.some(b => Math.abs(h - b) <= 2);
}
```
### DOM contract (inside #panel-modern)
```html
<section id="panel-modern" role="tabpanel" aria-labelledby="tab-modern" hidden>
<section class="elements">
<h2>...</h2>
<div class="grid" id="element-grid">...</div>
<p class="legend">...</p>
<details id="debug-panel" hidden>
<summary>Kiểm tra tự động (N skipped · M borderline)</summary>
<h4>Bỏ qua (không có màu)</h4>
<div class="chips">…skipped chips…</div>
<h4>Trường hợp ranh giới</h4>
<div class="chips">…borderline chips…</div>
</details>
<p class="disclaimer">...</p>
</section>
<details class="original-image">...</details>
</section>
```
The `hidden` attribute on `#debug-panel` is removed by JS only when at least one list is non-empty.
## Related Code Files
### Create
- None.
### Modify
- `js/render-elements.js` — append `renderDebugPanel` + `isBorderline` exports.
- `js/main.js` — collect `skipped` + `borderline` while iterating; call `renderDebugPanel` after `renderGrid`.
- `index.html` — add `<details id="debug-panel" hidden></details>` inside `#panel-modern .elements`, after the `<p class="legend">` and before `<p class="disclaimer">`.
### Delete
- None.
## Implementation Steps
1. In `js/render-elements.js` (append):
- Import `hexToHsl` from `./classify-element.js`.
- Add `isBorderline(hex)` per the snippet above.
- Add `renderDebugPanel({ skipped, borderline }, mountEl)`:
- If both empty → set `mountEl.hidden = true; return;`.
- Else build `<summary>` with counts.
- Build two `.chips` containers; for `skipped`, plain `<span class="chip">name</span>` (no color, default styling); for `borderline`, `<span class="chip" style="background:#hex;color:..." title="#hex → element">{name}</span>`.
- Set `mountEl.hidden = false`.
2. In `js/main.js`, during the iteration loop:
- When skipping (null/invalid color) → push `{ name }` to `skipped[]`.
- After `classify(color)`: if `isBorderline(color)`, push `{ name, color, element }` to `borderline[]`.
- After `renderGrid`, call `renderDebugPanel({ skipped, borderline }, document.getElementById('debug-panel'))`.
- Import `isBorderline` and `renderDebugPanel` from `./render-elements.js`.
3. In `index.html`, inside `#panel-modern .elements`, insert `<details id="debug-panel" hidden></details>` between the legend `<p>` and the disclaimer `<p>`.
4. Smoke test: open page, switch to Modern, expand the panel, eyeball the lists. Expected: ~58 skipped (data/markup formats — JSON, YAML, etc.); a small handful of borderline. Verify Classic panel unchanged.
## Todo List
- [ ] Add `isBorderline(hex)` helper in `render-elements.js`
- [ ] Add `renderDebugPanel(...)` exported function
- [ ] Extend `main.js` iteration to collect `skipped` + `borderline`
- [ ] Wire `renderDebugPanel` after `renderGrid`
- [ ] Add `<details id="debug-panel" hidden>` inside `#panel-modern .elements`
- [ ] Verify panel hides when both lists empty
- [ ] Verify "skipped" count ≈ 58 (matches report #1 §5)
- [ ] Verify Classic panel still unchanged (no debug panel leaks across)
## Success Criteria
- Debug panel exists in Modern only and is collapsed by default.
- Expanding shows two sub-sections with counts in `<summary>`.
- Skipped count ≈ 58 (report #1 expectation).
- Borderline list is non-empty but reasonable (say, < 50 entries).
- If you set `data/github-colors.json` to a synthetic dataset with no skipped + no borderline, the `<details>` element stays hidden.
- Classic panel: `#panel-classic` shows no debug panel, no chip clutter.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Borderline count too noisy (>200) | Med | Low | ±2° window already narrow; if too noisy, narrow to ±1° in a follow-up |
| Adding panel changes layout when collapsed | Low | Low | `<details>` collapses to a single line; visually negligible |
| Importing `hexToHsl` re-exposes internals | Low | None | `hexToHsl` is already exported from Phase 01 contract |
| Debug panel mount selector accidentally targets Classic | Low | High | Use `getElementById('debug-panel')`; only Modern has that id (verify in HTML edit) |
## Security Considerations
- Panel content is the same chip/textContent path as Phase 03. No new injection surface.
## Next Steps
- If borderline list reveals systematic issues → revisit classifier rules in `classify-element.js` and update the test cases in `classify-element.test.html` (and report #2 §3).
- This phase is the final one. Mark feature complete after manual review of the panel.
@@ -1,87 +0,0 @@
---
title: "Dual-mode (Classic / Modern) Ngũ Hành language page"
description: "Toggle between the published 2018 mapping and an HSL-classifier-driven dynamic grid."
status: pending
priority: P2
effort: 3h45m
branch: main
tags: [static-site, frontend, classifier, dual-mode, gh-pages]
created: 2026-04-27
---
## Goal
Extend `index.html` from a single static view into a dual-mode page: **Classic** (the 2018 published image + 5 hardcoded cards, verbatim) and **Modern** (collapsed source image + dynamic grid driven by GitHub Linguist colors classified into 5 Ngũ Hành elements via deterministic HSL rules). No build step. Single SPA, vanilla ES modules, deployed via existing GH Pages workflow.
## Modes
| Mode | Purpose | Contents |
|------|---------|----------|
| **Classic** *(default)* | Faithful reproduction of the 2018 source | Full-size original image + 5 hardcoded element cards (JS/Obj-C/Python in KIM, etc.) labelled "(theo bài gốc, 2018)" |
| **Modern** | Value-add extension | Dynamic grid + legend rendered from `data/github-colors.json` via classifier; original image collapsed into `<details>Ảnh gốc</details>` near bottom |
**Default = Classic.** Persisted via URL hash (`#classic` / `#modern`); shareable, no localStorage. Both panels live in the DOM at all times; toggle sets `hidden` on the inactive one with a CSS opacity fade.
## Architectural decisions (locked)
- **Toggle UI:** segmented control, two `<button role="tab">` inside `<div role="tablist">`. Keyboard: ←/→ cycle, Enter/Space activate. Placed between hero and panels.
- **Persistence:** URL hash only. Read on load; write on click. Invalid/missing → Classic.
- **Single DOM, two panels:** `#panel-classic` + `#panel-modern`, both `role="tabpanel"`. Toggle never re-renders.
- **Data source:** vendor `data/github-colors.json` from `ozh/github-colors`. `fetch('./data/github-colors.json')` (relative, same-origin, offline-friendly).
- **Module strategy:** vanilla ES modules; no bundler, no npm.
- **Classifier contract:** pure `classify(hex) → 'kim'|'moc'|'thuy'|'hoa'|'tho'` (lowercase to match existing CSS classes).
- **Animation:** CSS `opacity` transition only.
- **Out of scope:** framework, build tooling, npm install, GitHub API, popularity ranking, search, filter, dark mode, localStorage, tests beyond classifier harness.
## Phases
| # | File | Status | Effort | Owner files |
|---|------|--------|--------|-------------|
| 01 | [phase-01-data-and-classifier.md](phase-01-data-and-classifier.md) | pending | 60m | `data/github-colors.json` (new), `js/classify-element.js` (new), `js/classify-element.test.html` (new) |
| 02 | [phase-02-dual-mode-shell.md](phase-02-dual-mode-shell.md) | pending | 50m | `index.html` (restructure), `js/mode-toggle.js` (new) |
| 03 | [phase-03-render-modern-grid.md](phase-03-render-modern-grid.md) | pending | 45m | `js/render-elements.js` (new), `js/main.js` (new) |
| 04 | [phase-04-style-polish.md](phase-04-style-polish.md) | pending | 35m | `style.css` (modify) |
| 05 | [phase-05-debug-verify-panel.md](phase-05-debug-verify-panel.md) | pending (optional) | 30m | `js/render-elements.js` (modify), `index.html` (modify, modern panel only) |
Total: 3h10m core, +30m optional.
## Dependency graph
```
phase-01 ──▶ phase-03 ──┬──▶ phase-04
└──▶ phase-05 (optional)
phase-02 ──▶ phase-03
phase-02 ──▶ phase-04 (toggle/panel CSS)
```
Phase 02 is independent of Phase 01 but blocks Phase 03 (provides `#panel-modern` mount). Phase 04 needs both Phase 02 (toggle markup) and Phase 03 (grid markup) to exist.
## File ownership (no overlap between concurrent phases)
- Phase 01 owns: `data/`, `js/classify-element.js`, `js/classify-element.test.html`
- Phase 02 owns: `index.html` (full restructure: hero stays, classic panel wraps the existing image+cards, modern panel scaffolded empty, toggle inserted), `js/mode-toggle.js`
- Phase 03 owns: `js/render-elements.js`, `js/main.js`. Reads `#panel-modern .grid` (created by Phase 02). Does **not** edit `index.html`.
- Phase 04 owns: `style.css` (toggle, panel transitions, chip, legend, responsive)
- Phase 05 owns: appends to `js/render-elements.js`, adds `<details id="debug-panel">` inside `#panel-modern` only
`index.html` is touched by Phase 02 (structural) and Phase 05 (debug mount inside `#panel-modern`) — sequential, no overlap.
## Acceptance criteria (whole feature)
1. Page loads at `index.html` with **Classic** mode active by default; original image full-size and the 5 published-mapping cards (JS/Obj-C/Python in KIM, C#/PHP in THUỶ, Android/C# in MỘC, Scala/HTML5/Java/Node.js in HOẢ, JS/Go/Ruby in THỔ) render verbatim.
2. Clicking **Modern** toggle: classic panel hides, modern panel shows; dynamic grid populated by classifier with ≥5 chips per element; original image visible only after expanding `<details>Ảnh gốc</details>`.
3. URL hash updates to `#classic` / `#modern` on toggle. Refreshing the page restores the active mode. Sharing the URL with `#modern` opens directly in Modern.
4. Keyboard: Tab focuses the toggle; ←/→ cycle between tabs; Enter/Space activates the focused tab; `aria-selected` reflects state.
5. The 12 sample languages classify deterministically per the HSL rules (algorithm-verified): JS→THỔ, Python→THUỶ, Rust→THỔ, Go→MỘC, Ruby→HOẢ, Java→THỔ, C#→MỘC, TS→THUỶ, PHP→THUỶ, Swift→HOẢ, Kotlin→KIM, HTML→HOẢ. (Note: Rust/Java moved to THỔ vs report §3's preliminary table because their orange-brown hues fall below the L≥50 / S≥60 brightness gate that promotes [20,40) hues to HOẢ; Go moves to MỘC because its cyan hue H≈192 sits in the [70,200) MỘC range per the locked rule "MỘC covers green + jade/cyan"; Kotlin lands in KIM under the L≥70 "metallic shine" rule introduced 2026-04-27 to rebalance the empty KIM bucket.)
6. Languages with `color: null` are absent from the Modern grid.
7. Hero, footer credit unchanged. No console errors. No external network requests at runtime.
8. `js/classify-element.test.html` opens in browser and prints PASS for all 22 cases.
## Rollback
Phase commits revert in reverse order (05 → 04 → 03 → 02 → 01). **Phase 02 is the only destructive change to `index.html`** (restructures the published markup into panels). The full pre-Phase-02 markup must be quoted verbatim in the Phase 02 commit body for restoration. After Phase 02 revert, Classic mode markup must reappear as the original single-view layout with no toggle.
## Unresolved questions
1. Cap on chips per element in Modern? Default = show all alphabetical. Confirm if too noisy after first render.
2. Should hash changes via browser back/forward animate the panel switch? Default = yes (same `hashchange` listener).
+47
View File
@@ -0,0 +1,47 @@
# TODO — programming-fengshui
Last session ended: 2026-04-27 21:14 (Asia/Saigon). Branch: `main`.
Pick up here next time.
## Context for next session
- Site is single-mode now (classic panel removed); modern grid is the only view, 2018 source image lives under a `<details>` spoiler.
- Three segmented controls live above the grid: **Nguồn màu** (GitHub / GitLab), **Hiển thị** (TIOBE Top 20 / Tất cả ngôn ngữ), **Sắp xếp** (Mặc định / AZ / Cầu vồng).
- Cards have element-tinted backgrounds, count line picks up element color.
- Most recent UI/UX review and unresolved questions: `plans/reports/ui-ux-260427-2043-fengshui-page-review.md`.
## Open items (in priority order)
### 1. OG / social card image
- `index.html:21` `og:image` still points at the 2018 source image. Replace with a render of the current Ngũ Hành cards.
- Options: screenshot the live page at 1200×630 (need a working browser on this aarch64 host first — last attempt failed because cached puppeteer chrome is x86_64 ELF), or build an HTML→PNG via the `design` skill.
- Output: `assets/og-card.png`, then update the meta tag.
### 2. Resolve the open questions in the UI/UX review
From `plans/reports/ui-ux-260427-2043-fengshui-page-review.md` §7:
1. **5-card-row symbolism** — keep `auto-fit minmax(220px, 1fr)` (current) or force back to 5-col with a wider `--page-width`? The 5-element wheel is part of the joke; auto-fit may collapse to 23 cols on common viewports.
2. **Empty KIM bucket in TIOBE view** — KIM has 0 TIOBE Top 20 entries. Should we hide the empty card or keep the heading visible? Current state: heading + "0 ngôn ngữ" count.
3. **Persist user toggle choices** — view + sort + source currently reset on every load. Wire to URL query params (`?source=gitlab&view=all&sort=hue`) for shareable state. Probably P3 — small win, low cost.
4. **GitLab vs GitHub palette disparity tooltip** — GitLab has 91 entries vs GitHub's 664. Add a small note when GitLab is selected so users don't think it's broken.
5. **Strict AA vs decorative chip contrast** — review §2 brought worst cases above AA via the new `pickTextColor`, but a few still hover at 3.54.0:1 (Swift, MATLAB). Acceptable for decorative chips? Document the policy.
### 3. Polish leftovers from the review (none of them blocking)
- ARIA radio pattern is in place on the segmented controls — recheck with NVDA / VoiceOver if you have access.
- Anchor link underlines (`.credit a`, `.figure figcaption a`) use `border-bottom: 1px dotted` — at small sizes this can render sub-pixel. Migrate to native `text-decoration: underline; text-decoration-style: dotted;` when convenient.
- `.hero .subtitle` is italic — Be Vietnam Pro italic at small sizes can look slanted-rough on Linux. Consider dropping italic in favour of letter-spacing.
- Per-card "Top 5 non-TIOBE peek" when in TIOBE view — additive feature, see review §6.
- Subtle Lunar-New-Year SVG texture at ~3 % opacity for body bg — ditto §6.
### 4. Tests / verification
- The `classify-element.test.html` harness still passes 22/22 (last verified). Re-run when you make any classifier rule changes.
- No automated browser test for the page itself — if doing meaningful changes, spin up `python3 -m http.server 8765` and walk through each toggle by hand.
## Reference reports (do NOT delete)
- `plans/reports/researcher-260427-0854-nguhanh-color-classifier.md` — algorithm spec, referenced in `js/classify-element.js:1`.
- `plans/reports/researcher-260427-0855-github-language-colors.md` — GitHub Linguist data source rationale.
- `plans/reports/researcher-260427-1024-gitlab-colors-source.md` — GitLab data source.
- `plans/reports/brainstorm-260427-1046-kim-rebalance.md` — explains the current L≥70 "metallic shine" KIM rule.
- `plans/reports/uiux-260427-0927-audit-improvements.md` — first-pass UI audit (most items shipped).
- `plans/reports/ui-ux-260427-2043-fengshui-page-review.md` — most recent review; top-3 critical fixes shipped, polish items partly shipped.