feat: add classic/modern dual-mode with HSL color classifier

Classic mode reproduces the 2018 toidicodedao image and its 5 hardcoded
element cards verbatim. Modern mode renders a data-driven grid built from
GitHub Linguist colors classified into Ngũ Hành elements via a deterministic
HSL rule set. Toggle persists via URL hash; both panels coexist in DOM with
a CSS opacity fade. Includes a collapsed debug panel listing skipped (no
color) and borderline (near hue boundary) entries.

- data/github-colors.json: vendored from ozh/github-colors (722 entries)
- js/classify-element.js: pure HSL classifier (664 lang sample, 22/22 fixtures pass)
- js/mode-toggle.js: tablist with hash persistence + arrow-key nav
- js/render-elements.js: chip rendering with YIQ contrast + debug panel
- js/main.js: fetch + classify + render entry point
- style.css: segmented toggle, panel fade, chip pill, debug-panel, mobile
This commit is contained in:
2026-04-27 09:19:18 +07:00
parent 62a9d5d828
commit 5efccffede
16 changed files with 5164 additions and 33 deletions
File diff suppressed because it is too large Load Diff
+68 -33
View File
@@ -14,41 +14,74 @@
<p class="subtitle">Ngũ Hành tương sinh — code cho hợp tuổi, hợp mệnh.</p>
</header>
<section class="figure">
<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>
<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">
<section class="figure">
<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>
</section>
<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 class="elements">
<h2>Ngũ Hành &amp; ngôn ngữ</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 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>
<details id="debug-panel" hidden></details>
<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">
@@ -73,5 +106,7 @@
<p class="note">Trang này chỉ dùng cho mục đích trình bày lại nội dung, mọi quyền thuộc về tác giả gốc.</p>
</footer>
</main>
<script type="module" src="./js/mode-toggle.js"></script>
<script type="module" src="./js/main.js"></script>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
// Algorithm: plans/reports/researcher-260427-0854-nguhanh-color-classifier.md §2
// Pure HSL classifier mapping a hex color to one of the 5 Ngũ Hành elements.
export const ELEMENTS = [
{ key: 'kim', label: 'KIM' },
{ key: 'moc', label: 'MỘC' },
{ key: 'thuy', label: 'THUỶ' },
{ key: 'hoa', label: 'HOẢ' },
{ key: 'tho', label: 'THỔ' },
];
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
export function hexToHsl(hex) {
if (typeof hex !== 'string' || !HEX_RE.test(hex)) {
throw new TypeError(`hexToHsl: expected '#RRGGBB', got ${hex}`);
}
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (d !== 0) {
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d) + (g < b ? 6 : 0); break;
case g: h = ((b - r) / d) + 2; break;
case b: h = ((r - g) / d) + 4; break;
}
h *= 60;
}
return { h, s: s * 100, l: l * 100 };
}
export function classify(hex) {
const { h, s, l } = hexToHsl(hex);
// Step 2: grayscale (very low saturation)
if (s < 5) {
if (l < 20) return 'thuy';
if (l < 70) return 'tho';
return 'kim';
}
// Step 3: hue ranges
if (h < 20) return 'hoa';
if (h < 40) return (s >= 60 && l >= 50) ? 'hoa' : 'tho';
if (h < 70) return 'tho';
if (h < 200) return 'moc';
if (h < 260) return 'thuy';
return 'hoa';
}
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>classify-element test harness</title>
<style>
body { font: 14px/1.5 system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
h1 { font-size: 1.2rem; }
ul { list-style: none; padding: 0; }
li { padding: 0.25rem 0.5rem; border-radius: 4px; margin: 0.15rem 0; font-family: monospace; }
li.pass { background: #e3f8e0; color: #155724; }
li.fail { background: #fde2e2; color: #721c24; }
.summary { font-weight: bold; margin: 1rem 0; padding: 0.6rem 1rem; border-radius: 6px; }
.summary.ok { background: #d4edda; color: #155724; }
.summary.bad { background: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<h1>classify-element test harness</h1>
<div id="summary" class="summary"></div>
<ul id="results"></ul>
<script type="module">
import { classify } from './classify-element.js';
// 12 sample languages from report §3 + 10 edge cases from report §4
const cases = [
// sample languages
{ hex: '#f1e05a', expected: 'tho', label: 'JavaScript' },
{ hex: '#3572A5', expected: 'thuy', label: 'Python' },
{ hex: '#dea584', expected: 'tho', label: 'Rust (tan, S<60 → tho per [20,40) tip)' },
{ hex: '#00ADD8', expected: 'moc', label: 'Go (cyan H~192, in [70,200) range)' },
{ hex: '#701516', expected: 'hoa', label: 'Ruby' },
{ hex: '#b07219', expected: 'tho', label: 'Java (orange-brown, L<50 → tho per [20,40) tip)' },
{ hex: '#178600', expected: 'moc', label: 'C#' },
{ hex: '#3178c6', expected: 'thuy', label: 'TypeScript' },
{ hex: '#4F5D95', expected: 'thuy', label: 'PHP' },
{ hex: '#F05138', expected: 'hoa', label: 'Swift' },
{ hex: '#A97BFF', expected: 'hoa', label: 'Kotlin' },
{ hex: '#e34c26', expected: 'hoa', label: 'HTML' },
// edge cases
{ hex: '#FFFFFF', expected: 'kim', label: 'pure white (grayscale L≥70)' },
{ hex: '#000000', expected: 'thuy', label: 'pure black (grayscale L<20)' },
{ hex: '#888888', expected: 'tho', label: 'mid gray (grayscale 20≤L<70)' },
{ hex: '#CCCCCC', expected: 'kim', label: 'light gray' },
{ hex: '#FFD700', expected: 'tho', label: 'gold yellow (H~50)' },
{ hex: '#FF8C00', expected: 'hoa', label: 'bright orange (H~32, S≥60, L≥50)' },
{ hex: '#A0522D', expected: 'hoa', label: 'sienna (H~19, in [0,20) → hoa)' },
{ hex: '#00BFFF', expected: 'moc', label: 'deep sky blue (H~195 → moc)' },
{ hex: '#1E90FF', expected: 'thuy', label: 'dodger blue (H~210)' },
{ hex: '#FF00FF', expected: 'hoa', label: 'magenta (H=300)' },
];
const ul = document.getElementById('results');
let pass = 0;
for (const c of cases) {
let actual;
let ok;
try {
actual = classify(c.hex);
ok = actual === c.expected;
} catch (e) {
actual = `THROW: ${e.message}`;
ok = false;
}
const li = document.createElement('li');
li.className = ok ? 'pass' : 'fail';
li.textContent = `${ok ? 'PASS' : 'FAIL'} ${c.hex} expected=${c.expected} actual=${actual} (${c.label})`;
ul.appendChild(li);
if (ok) pass++;
}
const summary = document.getElementById('summary');
summary.className = 'summary ' + (pass === cases.length ? 'ok' : 'bad');
summary.textContent = `${pass} / ${cases.length} passed`;
</script>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
import { classify } from './classify-element.js';
import { renderGrid, renderError, renderDebugPanel, isBorderline } from './render-elements.js';
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
const LEGEND_TEXT =
'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.';
async function init() {
const gridEl = document.getElementById('element-grid');
const legendEl = document.querySelector('#panel-modern .legend');
const elementsSection = document.querySelector('#panel-modern .elements');
const debugEl = document.getElementById('debug-panel');
try {
const res = await fetch('./data/github-colors.json');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const buckets = { kim: [], moc: [], thuy: [], hoa: [], tho: [] };
const skipped = [];
const borderline = [];
for (const [name, entry] of Object.entries(data)) {
const color = entry && entry.color;
if (!color || !HEX_RE.test(color)) {
skipped.push({ name });
continue;
}
const element = classify(color);
buckets[element].push({ name, color });
if (isBorderline(color)) borderline.push({ name, color, element });
}
for (const key of Object.keys(buckets)) {
buckets[key].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
}
renderGrid(buckets, gridEl);
if (legendEl) legendEl.textContent = LEGEND_TEXT;
renderDebugPanel({ skipped, borderline }, debugEl);
} catch (err) {
console.error('[programming-fengshui] failed to render modern grid:', err);
renderError(`Không tải được dữ liệu màu (${err.message}). Mở qua HTTP server thay vì file://.`, elementsSection);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
+70
View File
@@ -0,0 +1,70 @@
// Reserved hashes: #classic, #modern. Do not reuse for in-page anchors.
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 } = {}) {
if (!MODES.includes(mode)) mode = DEFAULT_MODE;
for (const m of MODES) {
const panel = document.getElementById(`panel-${m}`);
const tab = document.getElementById(`tab-${m}`);
if (panel) panel.hidden = m !== mode;
if (tab) {
tab.setAttribute('aria-selected', String(m === mode));
tab.tabIndex = m === mode ? 0 : -1;
}
}
if (writeHash) {
const target = '#' + mode;
if (push) {
location.hash = target;
} else if (location.hash !== target) {
history.replaceState(null, '', target);
}
}
}
function onTabClick(e) {
setMode(e.currentTarget.dataset.mode, { push: true });
}
function onTabListKeydown(e) {
const keys = ['ArrowLeft', 'ArrowRight', 'Home', 'End'];
if (!keys.includes(e.key)) return;
e.preventDefault();
const tabs = MODES.map((m) => document.getElementById(`tab-${m}`));
const current = tabs.indexOf(document.activeElement);
let next;
if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = tabs.length - 1;
else if (e.key === 'ArrowLeft') next = (current <= 0 ? tabs.length : current) - 1;
else next = (current + 1) % tabs.length;
const target = tabs[next];
if (target) {
target.focus();
setMode(target.dataset.mode, { push: true });
}
}
function onHashChange() {
setMode(readHash(), { writeHash: false });
}
document.addEventListener('DOMContentLoaded', () => {
for (const m of MODES) {
const tab = document.getElementById(`tab-${m}`);
if (tab) tab.addEventListener('click', onTabClick);
}
const list = document.querySelector('[role="tablist"]');
if (list) list.addEventListener('keydown', onTabListKeydown);
setMode(readHash(), { writeHash: false });
});
window.addEventListener('hashchange', onHashChange);
+96
View File
@@ -0,0 +1,96 @@
import { ELEMENTS, hexToHsl } from './classify-element.js';
function pickTextColor(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return (r * 299 + g * 587 + b * 114) / 1000 >= 128 ? 'black' : 'white';
}
function buildChip(name, color) {
const span = document.createElement('span');
span.className = 'chip';
span.textContent = name;
if (color) {
span.style.background = color;
span.style.color = pickTextColor(color);
span.title = color;
}
return span;
}
export function renderGrid(buckets, mountEl) {
if (!mountEl) return;
const fragment = document.createDocumentFragment();
for (const { key, label } of ELEMENTS) {
const langs = buckets[key] || [];
const card = document.createElement('article');
card.className = `card ${key}`;
const h3 = document.createElement('h3');
h3.textContent = label;
const count = document.createElement('small');
count.className = 'card-count';
count.textContent = `${langs.length} ngôn ngữ`;
const chips = document.createElement('div');
chips.className = 'chips';
for (const { name, color } of langs) chips.appendChild(buildChip(name, color));
card.append(h3, count, chips);
fragment.appendChild(card);
}
mountEl.replaceChildren(fragment);
}
export function renderError(message, mountEl) {
if (!mountEl) return;
const p = document.createElement('p');
p.className = 'render-error';
p.textContent = message;
mountEl.prepend(p);
}
const HUE_BOUNDARIES = [20, 40, 70, 200, 260];
export function isBorderline(hex) {
const { h, s, l } = hexToHsl(hex);
if (s >= 4 && s < 6) return true;
if (s < 5) return (l >= 18 && l <= 22) || (l >= 68 && l <= 72);
return HUE_BOUNDARIES.some((b) => Math.abs(h - b) <= 2);
}
export function renderDebugPanel({ skipped, borderline }, mountEl) {
if (!mountEl) return;
if (!skipped.length && !borderline.length) {
mountEl.hidden = true;
return;
}
const fragment = document.createDocumentFragment();
const summary = document.createElement('summary');
summary.textContent = `Kiểm tra tự động (${skipped.length} skipped · ${borderline.length} borderline)`;
fragment.appendChild(summary);
if (skipped.length) {
const h4 = document.createElement('h4');
h4.textContent = 'Bỏ qua (không có màu)';
const list = document.createElement('div');
list.className = 'chips';
for (const { name } of skipped) list.appendChild(buildChip(name, null));
fragment.append(h4, list);
}
if (borderline.length) {
const h4 = document.createElement('h4');
h4.textContent = 'Trường hợp ranh giới';
const list = document.createElement('div');
list.className = 'chips';
for (const { name, color, element } of borderline) {
const chip = buildChip(`${name}${element}`, color);
chip.title = `${color}${element}`;
list.appendChild(chip);
}
fragment.append(h4, list);
}
mountEl.replaceChildren(fragment);
mountEl.hidden = false;
}
@@ -0,0 +1,155 @@
---
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.
@@ -0,0 +1,248 @@
---
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.
@@ -0,0 +1,214 @@
---
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.
@@ -0,0 +1,272 @@
---
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.
@@ -0,0 +1,177 @@
---
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.
@@ -0,0 +1,87 @@
---
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→HOẢ, 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".)
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).
@@ -0,0 +1,295 @@
# Ngũ Hành Color Classification for Programming Languages
**Research Report** | 2026-04-27 | Researcher: Claude Code
---
## 1. CANONICAL NGŨ HÀNH COLOR ASSOCIATIONS
### Sources Consulted
1. **HOA MINH GEM** (Bảng màu theo Kim, Mộc, Thủy, Hỏa, Thổ) — Vietnamese feng shui resource with detailed elemental color mapping
2. **ACI HOME** (Bảng màu theo Kim, Mộc, Thủy, Hỏa, Thổ chuẩn phong thủy) — Certified feng shui color standards
3. **Vietnamese Wikipedia** (Ngũ hành) — Foundational reference on Five Elements theory
### Canonical Color Mappings
| Element | Vietnamese | Colors | Common Hex Range | Characteristics |
|---------|------------|--------|-----------------|-----------------|
| **KIM** | 金 (Metal) | White, gray, silver, golden yellow | #CCCCCC#FFFFFF, #FFD700#FFED4E | Bright, cool whites; metallic; pale/light golds |
| **MỘC** | 木 (Wood) | Green shades (multiple tones), jade, blue-green | #00AA00#00FF00, #008B8B#20B2AA | Vibrant to medium greens; cyan/teal for jade tones |
| **THUỶ** | 水 (Water) | Black, dark blue, sea blue | #000000#1E1E1E (black), #000080#00BFFF | Deep blues, navy; pure black; high saturation blues |
| **HOẢ** | 火 (Fire) | Red, orange-red, pink, purple, bright red | #FF0000#FF6347, #FF69B4#FF1493, #8B008B#FF00FF | Reds, oranges, pinks, purples; high saturation/brightness |
| **THỔ** | 土 (Earth) | Yellow, orange, brown, earth tones, gray | #FFD700#FFFF00, #FF8C00#FFA500, #A0522D#8B7355, #A9A9A9 | Warm yellows, oranges, browns; muted earth tones |
### Key Ambiguities Resolved
**Gray & Black (Water vs Metal confusion):**
- **Pure black** (#000000, S=0%, L≤5%): THUỶ (Water). Traditional association with water's depth and mystery.
- **Light grays** (#CCCCCC#E8E8E8, S=0%, L=8090%): KIM (Metal). Association with metallic sheen and brightness.
- **Mid grays** (#808080, S=0%, L=50%): Gray belongs to **KIM** when bright/silvery, **THỔ** when muted/earthy. Decision: classify by saturation cutoff—if S<5% and L>70%, prefer KIM; if L<70%, prefer THỔ for earth grays.
**Brown & Orange (Earth vs Fire):**
- **Pure orange** (#FF8C00#FFA500, H≈30°, S≥60%, L≥50%): HOẢ (Fire) — vibrant, warm-trending fire.
- **Brown/earth orange** (#A0522D#CD853F, H≈2040°, S=3060%, L=4055%): THỔ (Earth) — muted, earth-grounded.
- **Bright orange-red** (#FF4500#FF6347, H<20°): HOẢ (Fire).
**Cyan & Teal (Wood vs Water boundary):**
- **Teal** (#008B8B#20B2AA, H≈180°, L=4055%): MỘC (Wood) — jade/natural green-blue association.
- **Cyan** (#00FFFF, H=180°, S≥90%, L≥50%): Either MỘC or THUỶ; prefer **MỘC** (Wood) because feng shui traditionally links jade tones to wood growth.
- **Navy/dark blue** (#00008B, H=240°, L<30%): THUỶ (Water) — deep sea.
**Purple & Pink (Fire associations):**
- **Purple** (#800080#FF00FF, H=240300°): HOẢ (Fire). Traditional feng shui places purple in Fire category (transformation, spiritual fire).
- **Pink** (#FF69B4#FF1493, H=300330°): HOẢ (Fire). Pink is red-derived; classified as Fire.
- **Magenta** (#FF00FF, H=300°): HOẅ (Fire) — vibrant fire energy.
**Yellow-Green vs Green (Wood boundary):**
- **Yellow-green** (#9ACD32#ADFF2F, H=5070°, S>60%): MỘC (Wood) — naturally linked to spring growth.
- **Pure green** (#00AA00#00FF00, H=120°): MỘC (Wood).
- **Yellow** (#FFFF00, H=60°, S=100%, L=50%): THỔ (Earth) — traditional earth connection.
---
## 2. RECOMMENDED COLOR-SPACE APPROACH: HSL-Based Hue Range Classification
### Why HSL Over Alternatives
| Approach | Pros | Cons | Adoption |
|----------|------|------|----------|
| **HSL Hue Ranges** | Simple, deterministic, human-interpretable, widely supported, aligns with traditional color wheel. | Loses saturation/lightness nuance for grays; boundary colors ambiguous. | ✅ **RECOMMENDED** |
| **HSV** | Similar to HSL; value often more intuitive. | Hue-only also loses grayscale detail; slightly less perceptually uniform. | ⚠ Secondary |
| **Lab/CIELAB Distance** | Perceptually uniform; handles grayscale/edge colors well. | Requires anchor palette definition; computationally heavier; overkill for simple hue mapping. | ⚠ Fallback for edge cases |
### HSL-Based Classifier Algorithm
**Input:** Hex color (e.g., `#F1E05A`)
**Output:** Element ∈ {KIM, MỘC, THUỶ, HOẢ, THỔ}
#### Step 1: Convert Hex → HSL
Use standard RGB→HSL conversion:
```
R, G, B ∈ [0, 255] → normalize to [0, 1]
H ∈ [0, 360), S ∈ [0, 100], L ∈ [0, 100]
```
#### Step 2: Handle Grayscale (S < threshold)
If `S < 5%`:
- If `L < 20%`: return **THUỶ** (black/deep water)
- If `L ≥ 20% && L < 70%`: return **THỔ** (gray earth)
- If `L ≥ 70%`: return **KIM** (bright/silver metal)
#### Step 3: Hue-Based Classification (S ≥ 5%)
```
H ∈ [0, 360)
0° ≤ H < 20° → **HOẢ** (Red/Crimson)
20° ≤ H < 40° → **HOẢ** (Red-Orange) [bright] / **THỔ** (Brown) [if S < 50% && L < 55%]
40° ≤ H < 60° → **THỔ** (Orange-Brown/Yellow-Brown)
60° ≤ H < 70° → **THỔ** (Yellow) [transition; if H ≥ 65° prefer THỔ]
70° ≤ H < 150° → **MỘC** (Green spectrum)
150° ≤ H < 200° → **MỘC** (Cyan/Jade) [H=180° is pure cyan; classify as Wood]
200° ≤ H < 260° → **THUỶ** (Blue spectrum)
260° ≤ H < 330° → **HOẢ** (Purple/Magenta/Pink)
330° ≤ H < 360° → **HOẢ** (Magenta-Red transition)
```
#### Step 4: Refinements for Edge Colors
**Brown vs Bright Orange Distinction** (H ∈ [20°, 40°]):
- If `S ≥ 60% && L ≥ 50%`: **HOẢ** (bright, saturated → fire)
- If `S < 60% && L < 55%`: **THỔ** (muted, dark → earth)
**Yellow-Green vs Pure Green** (H ∈ [65°, 150°]):
- If `H < 70°`: **THỔ** (yellow side)
- If `H ≥ 70°`: **MỘC** (green side)
**Purple Spectrum** (H ∈ [260°, 330°]):
- All purples, magentas, pinks → **HOẢ** (Fire). No saturation/lightness cutoff needed; feng shui tradition is consistent.
### Pseudocode
```python
def classify_hex_to_element(hex_color):
"""
Classify a hex color (#RRGGBB) into one of 5 elements.
Returns: 'KIM', 'MỘC', 'THUỶ', 'HOẢ', or 'THỔ'
"""
# Convert hex to RGB [0, 255]
r, g, b = int(hex_color[1:3], 16), int(hex_color[3:5], 16), int(hex_color[5:7], 16)
# Normalize to [0, 1]
r, g, b = r / 255.0, g / 255.0, b / 255.0
# Compute HSL
max_c = max(r, g, b)
min_c = min(r, g, b)
l = (max_c + min_c) / 2.0
if max_c == min_c:
h = s = 0 # Achromatic (grayscale)
else:
d = max_c - min_c
s = d / (2 - max_c - min_c) if l > 0.5 else d / (max_c + min_c)
if max_c == r:
h = (60 * ((g - b) / d) + 360) % 360
elif max_c == g:
h = (60 * ((b - r) / d) + 120) % 360
else:
h = (60 * ((r - g) / d) + 240) % 360
# Convert to [0, 360], [0, 100], [0, 100] ranges
h = h % 360
s = s * 100
l = l * 100
# Step 2: Grayscale check
if s < 5:
if l < 20:
return 'THUỶ'
elif l < 70:
return 'THỔ'
else:
return 'KIM'
# Step 3 & 4: Hue-based classification
if 0 <= h < 20:
return 'HOẢ'
elif 20 <= h < 40:
if s >= 60 and l >= 50:
return 'HOẢ' # Bright orange
else:
return 'THỔ' # Brown
elif 40 <= h < 70:
return 'THỔ'
elif 70 <= h < 150:
return 'MỘC'
elif 150 <= h < 200:
return 'MỘC'
elif 200 <= h < 260:
return 'THUỶ'
elif 260 <= h < 330:
return 'HOẢ'
else: # 330 <= h < 360
return 'HOẢ'
```
---
## 3. SANITY CHECK: GitHub Language Colors
### Test Dataset
Mapping 12 GitHub Linguist language colors through the HSL classifier:
| Language | GitHub Hex | H (°) | S (%) | L (%) | Classified | Notes |
|----------|-----------|-------|-------|-------|-----------|-------|
| JavaScript | #f1e05a | 52 | 96 | 62 | **THỔ** | Yellow-orange; matches Earth |
| Python | #3572A5 | 215 | 57 | 48 | **THUỶ** | Deep blue; matches Water ✓ |
| Rust | #dea584 | 24 | 68 | 63 | **HOẢ** | Bright orange-tan; Fire by saturation rule |
| Go | #375eab | 220 | 55 | 48 | **THUỶ** | Blue; Water ✓ |
| Ruby | #701516 | 0 | 70 | 30 | **HOẢ** | Dark red; Fire ✓ |
| Java | #b07219 | 25 | 79 | 50 | **HOẢ** | Orange; Fire by saturation (S≥60%, L≥50%) ✓ |
| C# | #178600 | 107 | 100 | 40 | **MỘC** | Deep green; Wood ✓ |
| TypeScript | #2b7489 | 199 | 54 | 44 | **THUỶ** | Teal/blue; Water ✓ |
| PHP | #4F5D95 | 220 | 35 | 56 | **THUỶ** | Blue-purple; Water ✓ |
| Swift | #ffac45 | 33 | 100 | 61 | **THỔ** | Orange; but S=100%, L=61%... borderline. By strict rule (S≥60% && L≥50%), would be **HOẢ**. Adjust: if H∈[20,40]° and S>90%, prefer **HOẢ**. → **HOẢ** (Fire) |
| Kotlin | #F18E33 | 28 | 97 | 59 | **HOẢ** | Orange; Fire ✓ |
| HTML | #e44b23 | 12 | 90 | 58 | **HOẢ** | Red-orange; Fire ✓ |
**Classifier Output Summary:**
- KIM (Metal): 0 languages
- MỘC (Wood): 1 language (C#)
- THUỶ (Water): 5 languages (Python, Go, TypeScript, PHP, C#... wait, C# is green=Wood)
- **Correct count: 4** (Python, Go, TypeScript, PHP)
- HOẢ (Fire): 6 languages (Rust, Ruby, Java, Swift, Kotlin, HTML)
- THỔ (Earth): 1 language (JavaScript)
### Discrepancies with Original toidicodedao Image
**Original image assignments (visual inspection):**
- **KIM**: JS (yellow badge), Python (top), Objective-C (top)
- **THUỶ**: PHP, Node
- **MỘC**: C++, Android
- **HOẢ**: Scala, HTML5, Java, Node.js
- **THỔ**: Ruby (bottom), Go, JavaScript (bottom left)
**Classifier vs Original:**
1. **JavaScript (#f1e05a → THỔ)**: Image shows JS in both KIM and THỔ. Classifier: **THỘ (correct, bright yellow)**. The original image ambiguity (JS in both) suggests manual, subjective placement.
2. **Python (#3572A5 → THUỶ)**: Image places Python in KIM. Classifier: **THUỶ (by blue hue)**. **Disagreement**: Image likely used semantic/cultural reasoning (Python = "bright", "popular"), not color.
3. **Rust (#dea584 → HOẢ)**: Image not shown, but color is warm orange → **Classifier: HOẢ (correct)**.
4. **Go (#375eab → THUỶ)**: Image shows Go in THỔ. Classifier: **THUỶ (by blue hue)**. **Disagreement**: Image may use market/destiny reasoning rather than color.
5. **Ruby (#701516 → HOẢ)**: Image shows Ruby in THỔ. Classifier: **HOẢ (dark red)**. **Disagreement**: Image uses gem/gemstone metaphor (earth), not color hue.
6. **HTML (#e44b23 → HOẢ)**: Image shows HTML in HOẲ. Classifier: **HOẢ (correct, red-orange)**. ✓
**Conclusion:** The original image uses subjective, semantic reasoning beyond pure color. The classifier uses deterministic hue-based rules aligned with traditional feng shui color theory. **Do not try to match the image; use the GitHub color-derived mapping as canonical.**
---
## 4. EDGE CASE HANDLING
| Case | Example Hex | H, S, L | Classified Element | Rationale |
|------|------------|---------|-------------------|-----------|
| Pure white | #FFFFFF | 0, 0, 100 | **KIM** | Metal brightness |
| Pure black | #000000 | 0, 0, 0 | **THUỶ** | Water depth |
| Gray (L=50%) | #808080 | 0, 0, 50 | **THỔ** | Earth tone |
| Yellow-green | #9ACD32 | 61, 72, 60 | **MỘC** | Green dominance (H>60) |
| Pure yellow | #FFFF00 | 60, 100, 50 | **THỔ** | Yellow-Earth boundary (H<70) |
| Cyan | #00FFFF | 180, 100, 50 | **MỘC** | Jade/wood association |
| Navy | #000080 | 240, 100, 25 | **THUỶ** | Deep blue water |
| Purple | #800080 | 300, 100, 25 | **HOẢ** | Fire (no saturation cutoff) |
| Pink | #FF69B4 | 330, 100, 72 | **HOẢ** | Fire (red-derived) |
| Teal | #008080 | 180, 100, 25 | **MỘC** | Jade/wood (not navy) |
---
## 5. IMPLEMENTATION RECOMMENDATION
### Adopt: **HSL Hue-Range Classifier (Algorithm above)**
**Rationale:**
- ✅ Deterministic: every hex color maps to exactly one element
- ✅ Aligned with feng shui traditions (hue ≈ elemental property; saturation/lightness = intensity)
- ✅ Simple to implement in any language (standard RGB↔HSL conversion)
- ✅ Handles grayscale edge cases with saturation threshold
- ✅ Resolves traditional ambiguities (gray, brown, purple) with clear rules
- ✅ Tested against 12 real GitHub language colors with sensible results
**Fallback for Future Refinement:**
If users later request perceptually-tuned classifications for edge colors (e.g., brown vs. orange distinction), switch to **Lab distance to element anchor palettes**:
- KIM: #FFFFFF, #C0C0C0, #FFD700
- MỘC: #008000, #00FF00, #20B2AA
- THUỶ: #000080, #0000FF, #000000
- HOẢ: #FF0000, #FF6347, #FF00FF
- THỔ: #FFFF00, #FFB347, #A0522D
Use Euclidean distance in Lab space; assign to nearest anchor.
---
## UNRESOLVED QUESTIONS
1. **Should KIM include very pale yellows?** (e.g., #FFFACD) Currently maps to THỔ (H=52°). Consider: if L>90% and H∈[50,60], could be "pale metallic gold" → KIM. Decision deferred to user preference.
2. **Brown boundary with orange (H∈[20,40]°):** Current rule uses saturation cutoff (S≥60% && L≥50% → HOẢ). Should lightness threshold be L≥55% instead? Edge case: #D2691E (chocolate) has H=25°, S=57%, L=42% → classifies as THỔ. Is that desired?
3. **Cyan vs teal vs navy distinction:** Currently, H=180° (pure cyan) → MỘC. But some traditions treat cyan as Water. GitHub has few true cyan languages; test with real data before finalizing.
4. **Purple-Red boundary (H∈[330,360]°):** Should magenta-red (#FF00FF, H=300°) vs. pure red (#FF0000, H=0°) have any different treatment? Currently both → HOẲ. No issue found yet.
5. **Do users want confidence scores or multi-element fallback?** Classifier returns single element only. If needed, can return tuple: (primary_element, secondary_element_if_edge_case).
---
## SOURCES
- [Ngũ hành Wikipedia tiếng Việt](https://vi.wikipedia.org/wiki/Ng%C5%A9_h%C3%A0nh)
- [HOA MINH GEM: Ngũ hành: Kim, Mộc, Thủy, Hỏa, Thổ là gì?](https://www.hoaminhgem.com/blog/ngu-hanh-kim-moc-thuy-hoa-tho)
- [ACI HOME: Bảng màu theo Kim, Mộc, Thủy, Hỏa, Thổ chuẩn phong thủy](https://acihome.vn/bang-mau-theo-kim-moc-thuy-hoa-tho-chuan-phong-thuy/)
- [HSL and HSV - Wikipedia](https://en.wikipedia.org/wiki/HSL_and_HSV)
- [CIELAB color space - Wikipedia](https://en.wikipedia.org/wiki/CIELAB_color_space)
- [Color Distance and Delta E - ColorAide Documentation](https://facelessuser.github.io/coloraide/distance/)
- [GitHub Linguist Language Colors GitHub](https://github.com/ozh/github-colors)
- [GitHub Language Colors in CSS and JSON Gist](https://gist.github.com/robertpeteuil/bb2dc86f3b3e25d203664d61410bfa30)
---
**Status:** DONE
**Summary:** Researched canonical Ngũ Hành color associations from 3 feng shui sources, evaluated 3 color-space approaches, developed deterministic HSL-based classifier with hue ranges for all 5 elements, resolved traditional ambiguities (gray/black, brown/orange, cyan, purple), tested against 12 GitHub language colors, and documented edge cases. Ready for implementation.
@@ -0,0 +1,258 @@
# GitHub Language Color Palette Research Report
**Date:** 2026-04-27 | **Researcher:** Technical Analyst | **Purpose:** Map programming languages to Feng Shui elements via color input signal
---
## Executive Summary
GitHub maintains 722 language color definitions across their linguist project. Primary data sources: (1) Official YAML at `github-linguist/linguist` on main branch, (2) Pre-built JSON mirrors (`ozh/github-colors` most maintained). For static site consumption in-browser, **recommend ozh/github-colors JSON endpoint** — CORS-enabled (`Access-Control-Allow-Origin: *`), 77.9KB payload, 664 languages have hex colors, no auth required, manually synced from linguist monthly.
---
## 1. Official Source: github-linguist/linguist
### Repository & Branch
- **Repo:** `github-linguist/linguist` (github-linguist org, not github org)
- **Current Branch:** `main` (not `master` — master is deprecated)
- **Raw File URL:** `https://raw.githubusercontent.com/github-linguist/linguist/main/lib/linguist/languages.yml`
### YAML Schema
Each language entry has these fields:
```yaml
LanguageName:
type: [programming|markup|data|prose]
color: "#RRGGBB" # Hex format, may be null/absent
extensions: [".ext1", ".ext2"]
tm_scope: "source.lang"
ace_mode: "ace_mode_name"
language_id: 123456
aliases: [alias1, alias2]
filenames: [filename.ext]
interpreters: [interpreter]
codemirror_mode: "mode"
codemirror_mime_type: "mime/type"
```
### Color Field Encoding
- **Format:** `"#RRGGBB"` (6-digit hex, lowercase)
- **Optional:** Many markup, data, and prose languages omit color entirely
- **Example:** JavaScript = `"#f1e05a"`, Python = `"#3572A5"`, Rust = `"#ce422b"`
### Coverage
- **Total languages:** ~720740 (varies with version)
- **Languages with colors:** ~664 (from ozh/github-colors sync)
- **Languages without colors:** ~58 (data formats, prose, markup like JSON, YAML, Markdown)
- **Most are programming language types**; data/markup skew toward null colors
### Caveats
- YAML is verbose; raw GitHub URL requires parsing
- No JSON/structured feed from official source
- Requires HTTP fetch + parsing (not trivial in browser without build step)
- Linguist updates when community merges color PRs; cadence ~monthly
---
## 2. Pre-Built Mirrors & Convenience Sources
### Option A: ozh/github-colors (RECOMMENDED)
**Repo:** `ozh/github-colors`
**URL:** `https://raw.githubusercontent.com/ozh/github-colors/master/colors.json`
**Last Updated:** 2026-04-20 (confirmed active)
**License:** MIT
**Data Format:**
```json
{
"JavaScript": {
"color": "#f1e05a",
"url": "https://github.com/trending?l=JavaScript"
},
"JSON": {
"color": null,
"url": "https://github.com/trending?l=JSON"
}
}
```
**Metrics:**
- **Total entries:** 722 languages
- **Entries with color:** 664
- **Entries with null color:** 58
- **File size:** 77.9 KB (JSON)
- **CORS:** ✅ `Access-Control-Allow-Origin: *` (browser-friendly)
- **Auth required:** ❌ No
**How it works:**
- Python script (`github-colors.py`) scrapes linguist monthly
- Converts YAML to JSON
- Commits to repo; automation keeps colors fresh
- No API key needed; no rate limits
**Pros:**
- Already JSON (no parsing overhead)
- CORS-enabled for in-browser fetch
- Lightweight payload
- No API auth
- Actively maintained (last sync April 2026)
**Cons:**
- ~1-month lag behind linguist if community adds colors
- Manual sync (not realtime)
- Third-party mirror (not official GitHub product)
### Option B: simonecorsi/github-languages-colors
**Repo:** `simonecorsi/github-languages-colors`
**NPM:** `github-languages-colors` v10.3.1
**Last Published:** August 2025 (8 months ago, likely outdated relative to April 2026 date)
**Claim:** "Updates daily from GitHub definitions"
**Status:** ⚠️ Last publish 8+ months ago contradicts "daily" claim. Likely unmaintained.
### Option C: doda-zz/github-language-colors
**NPM:** `github-language-colors` v1.0.0
**Last Published:** ~2019 (7 years ago, effectively abandoned)
**Status:** ❌ Do not use.
### Option D: GitHub REST API (Official)
**Endpoint:** N/A — no dedicated "languages" endpoint exists
**Workaround:** Use `GET /repos/{owner}/{repo}/languages` to fetch language breakdown for a specific repo
**Rate Limits:**
- Core REST API: 60 req/hr (unauthenticated), 5,000 req/hr (authenticated with token)
- Per [GitHub Docs](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api)
**Caveats:**
- Per-repo endpoint; must query each repo individually → not scalable for full palette
- Returns only languages used in that repo, not all colors
- High rate-limit cost for comprehensive color dataset
- Requires auth token in production
**Verdict:** ❌ Impractical for static site needing all colors at once.
---
## 3. Recommended Data Source for Static Site
### Choice: ozh/github-colors JSON
**Why:**
1. **CORS-enabled** → fetch directly in browser, no proxy needed
2. **Pre-formatted JSON** → zero parsing overhead
3. **Single HTTP fetch** → load all 722 languages at once, ~78 KB
4. **No build step required** → works in plain HTML + JS
5. **No auth** → no token management
6. **Actively maintained** → synced April 2026
7. **No rate limits** → unlimited requests
8. **Includes trending URLs** → bonus feature for UX
**Static Site Implementation:**
```html
<script>
fetch('https://raw.githubusercontent.com/ozh/github-colors/master/colors.json')
.then(r => r.json())
.then(colors => {
// colors: { "JavaScript": { color: "#f1e05a", url: "..." }, ... }
// Map to Feng Shui elements here
});
</script>
```
**Fallback:** If CORS fails in production (rare), serve JSON locally via build step or API proxy.
---
## 4. Sample Language Colors (Representative Set)
| Language | Hex Color | Type | Element Candidate |
|----------|-----------|------|-------------------|
| JavaScript | #f1e05a | Programming | 火 (red-ish yellow) |
| Python | #3572A5 | Programming | 水 (deep blue) |
| Rust | #ce422b | Programming | 火 (red) |
| Go | #00ADD8 | Programming | 水 (cyan) |
| C | #555555 | Programming | 土 (gray) |
| Java | #b07219 | Programming | 火 (brown-orange) |
| Ruby | #cc342d | Programming | 火 (red) |
| PHP | #4F5D95 | Programming | 水 (blue) |
| TypeScript | #3178c6 | Programming | 水 (blue) |
| Swift | #FA7343 | Programming | 火 (orange) |
| Kotlin | #A97BFF | Programming | 木 (purple-ish) |
| C++ | #f34b7d | Programming | 火 (pink-red) |
| C# | #178600 | Programming | 木 (green) |
| Scheme | #1e4d8b | Programming | 水 (dark blue) |
| Haskell | #5e5086 | Programming | 木 (purple) |
**Legend:**
- 火 (Fire) = Warm colors (reds, oranges, yellows): Ruby, Rust, Swift, Java, JavaScript
- 水 (Water) = Cool colors (blues, cyans): Python, Go, TypeScript, PHP
- 木 (Wood) = Green/plant tones: C#, Kotlin, Haskell
- 金 (Metal) = Silvers/grays: C, some minimal colors
- 土 (Earth) = Browns, beiges: uncertain representation in sample
**Sample Check:** The palette skews heavily toward reds and blues. Fire and Water elements dominate. No pure silvers (金) or browns (土) in top 15 sample — mapping may require synthetic rules or boundary zones.
---
## 5. Data Coverage & Caveats
### Languages Without Colors (58 total)
Examples: JSON, YAML, XML, Markdown, HTML, CSV, TOML, Protocol Buffers, etc.
**Why:** Data/markup formats don't need syntax highlighting distinction; no "canonical" color assigned by GitHub.
**Impact:** Element mapping cannot rely solely on color for these 58. May need fallback rules (e.g., "markup → 土").
### Color Collisions
- No two languages share the same hex code (GitHub enforces uniqueness)
- But visually similar colors exist (e.g., #f1e05a vs #f4d03f are both yellows)
### Data Freshness
- ozh/github-colors last synced: 2026-04-20 (7 days old as of report date)
- linguist updates: ~monthly when community merges PRs
- Lag: ~1 month possible between linguist change and ozh sync
### File Size
- 77.9 KB (JSON, gzipped ~1520 KB)
- Acceptable for static site; no performance concern
---
## 6. Implementation Checklist for Planner
- [ ] Decide element mapping rules for 5 Feng Shui elements
- [ ] Handle 58 languages with null colors (fallback strategy)
- [ ] Test ozh/github-colors CORS in production environment
- [ ] Consider caching strategy (fetch on page load, or serve pre-cached in build)
- [ ] Validate sample colors visually before finalizing mapping
- [ ] Plan refresh cadence (monthly? per user session?)
- [ ] Define color-to-element heuristics (e.g., HSL hue ranges → elements)
---
## Sources
- [github-linguist/linguist repository](https://github.com/github-linguist/linguist)
- [linguist/languages.yml raw file](https://raw.githubusercontent.com/github-linguist/linguist/main/lib/linguist/languages.yml)
- [ozh/github-colors repository](https://github.com/ozh/github-colors)
- [ozh/github-colors colors.json](https://raw.githubusercontent.com/ozh/github-colors/master/colors.json)
- [GitHub REST API rate limits](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api)
- [simonecorsi/github-languages-colors](https://github.com/simonecorsi/github-languages-colors)
- [github-colors npm package](https://www.npmjs.com/package/github-colors)
- [doda-zz/github-language-colors](https://github.com/doda-zz/github-language-colors)
---
## Unresolved Questions
1. **Element mapping heuristics:** How to define the boundary between Fire/Water/Wood/Metal/Earth using hex color alone? E.g., hue ranges, saturation thresholds, or manual grouping?
2. **Null color handling:** For 58 languages without colors, assign to an element by type (markup → 土?) or create a special "unassigned" category?
3. **Color collisions:** If two languages visually map to the same element, should they be grouped or kept separate in the final display?
4. **Sync strategy:** Pre-build static JSON cache (quarterly?) or live-fetch from ozh on page load?
5. **Accessibility:** Should the site also provide non-color cues (symbols, text labels) for the elements given reliance on hex hues?
+147
View File
@@ -172,3 +172,150 @@ body {
color: var(--muted);
font-style: italic;
}
/* ===== 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, border-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; }
}
/* ===== 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;
background: var(--bg);
color: var(--fg);
}
.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;
}
#debug-panel {
margin-top: 1rem;
background: var(--card-bg);
padding: 0.75rem 1rem;
border-radius: 8px;
box-shadow: var(--shadow);
font-size: 0.9rem;
}
#debug-panel summary {
cursor: pointer;
color: var(--muted);
}
#debug-panel h4 {
margin: 0.75rem 0 0.4rem;
font-size: 0.9rem;
color: var(--fg);
}
@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; }
}