From 284952fb543df229a261b38b72279891671ee9ae Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Tue, 18 Aug 2026 15:18:08 -0700 Subject: [PATCH 1/2] fix(content): stop offering links that no longer resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three repos went private on GitHub while the ledger still believed they were public, so every visitor who clicked "코드 보기" on Studio Apartment, YouTube Rhythm Game or Unclog LA landed on a GitHub 404. Setting the private flag is enough: the Private rule already withholds the URL and keeps the demo link, and youtube-rhythm-game correctly falls out of the sitemap now that the repo link was the only place it had to go. toolsmith had drifted the other way — flagged private while the repo is public under MIT, so the site withheld a link GitHub already advertises back at this very page. minddump's tombstone pointed at a Vercel deployment that now 404s. The tombstone still carries supersededBy: kindling, which is where a visitor should go anyway. These were found by resolving every link in the ledger rather than trusting it. Worth repeating whenever repo visibility changes — a unit test cannot see a 404. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt --- content/graveyard.json | 1 - content/projects.json | 9 +++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/content/graveyard.json b/content/graveyard.json index 4c60502..bb5f43b 100644 --- a/content/graveyard.json +++ b/content/graveyard.json @@ -25,7 +25,6 @@ "retroKo": "React Three Fiber와 Blender 파이프라인으로 burn 연출까지 만들고 Play Store 리스팅도 올렸지만 결국 삭제했습니다. 익명 감정 배출이라는 핵심만 남겨 shipwright 기반의 Kindling으로 다시 시작했습니다.", "retroEn": "I built the burn effect with React Three Fiber and a Blender pipeline and even put up a Play Store listing, but ultimately took it down. I kept only the core — anonymous emotional release — and restarted as Kindling on the shipwright starter.", "techStack": ["TypeScript", "Turborepo", "Next.js", "Expo", "Three.js", "React Three Fiber"], - "website": "https://minddump-seven.vercel.app", "supersededBy": "kindling", "private": true }, diff --git a/content/projects.json b/content/projects.json index 0c3bad8..d7fb293 100644 --- a/content/projects.json +++ b/content/projects.json @@ -39,7 +39,8 @@ ], "status": "active", "website": "https://toolsmith.writingdeveloper.blog", - "private": true, + "github": "https://github.com/writingdeveloper/toolsmith", + "private": false, "featured": false, "screenshot": "/images/projects/toolsmith.webp", "screenshotAltKo": "toolsmith 홈 화면. 상단 바에 'No upload · everything runs in your browser' 배지와 테마·언어 선택 드롭다운이 있고, 그 아래 큰 제목과 검색창이 놓여 있다. IMAGES 섹션에 이미지 변환·압축·크기 조정·배경 제거·업스케일·클릭 잘라내기 카드 여섯 개가 2열로 늘어서 있고, 이어지는 DOCUMENTS 섹션에 PDF 병합과 분할 카드가 보인다.", @@ -198,7 +199,7 @@ "status": "launched", "website": "https://studio-apartment.vercel.app", "github": "https://github.com/writingdeveloper/studio-apartment", - "private": false, + "private": true, "featured": false, "screenshot": "/images/projects/studio-apartment.webp", "screenshotAltKo": "스튜디오 아파트먼트 도입 화면. 검은 배경 가운데 한국어/English 언어 선택 버튼과, 값이 싸서 고른 낡은 아파트로 오늘 이사 왔다는 한국어 도입 문장이 놓여 있고, 맨 아래에 '클릭하여 시작' 안내가 있다.", @@ -289,7 +290,7 @@ ], "status": "building", "github": "https://github.com/writingdeveloper/youtube-rhythm-game", - "private": false, + "private": true, "featured": false }, { @@ -308,7 +309,7 @@ "status": "launched", "website": "https://unclog-la.vercel.app", "github": "https://github.com/writingdeveloper/unclog-la", - "private": false, + "private": true, "featured": false, "screenshot": "/images/projects/unclog-la.webp", "screenshotAltKo": "Unclog LA 플레이 화면. 가운데 로스앤젤레스 지도 위에 색깔별 메트로 노선이 그려져 있고, 왼쪽 패널에는 노선 건설·정책 토글과 노선 목록이, 오른쪽 패널에는 대중교통 분담률 5.2%와 일일 이용객 342,407명 같은 지표가, 아래쪽에는 2025년에서 2050년까지의 연도 슬라이더와 'Advance Year' 버튼이 있다.", From 36076449b79a1cec2795aff7d3c9de6ef588fff3 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Tue, 18 Aug 2026 15:18:23 -0700 Subject: [PATCH 2/2] feat(content): add til-shorts and mv-analyzer, and guard the grouping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two substantial projects had been shipping outside the ledger. til-shorts is a seven-stage production pipeline that turns subjects with a recorded engineering setback into English-language shorts. Assets come from the studios factory and are referenced by id — the repo never copies one — and every stage sits behind a gate: two sources and a fact-check table before research closes, a three-second average cut and -14 LUFS before review, and a human press for production approval. The channel URL is deliberately not linked here. mv-analyzer quantitatively compares hit and buried music videos entirely locally, and is the rarer thing: it reversed one of its own findings when a within-channel matched reanalysis killed the premiere-release effect, and publishes the reversal. Both are private with no public destination, so both are thin pages the sitemap correctly skips. The home page's ALL WORK grouping moves to lib as HOME_GROUPS, because it fails silently in both directions — it drops a slug it cannot resolve and never renders a project nobody listed, with no error either way. Nothing but a test catches that, and a test needs it importable without React. Verified by mutation: dropping a slug, inventing one, and duplicating one each fail exactly one of the three new assertions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt --- content/projects.json | 36 ++++++++++++++++++++++++++++++ src/app/[locale]/LedgerHome.tsx | 19 +++------------- src/lib/__tests__/projects.test.ts | 29 ++++++++++++++++++++++++ src/lib/projects.ts | 22 ++++++++++++++++++ 4 files changed, 90 insertions(+), 16 deletions(-) diff --git a/content/projects.json b/content/projects.json index d7fb293..c30f26b 100644 --- a/content/projects.json +++ b/content/projects.json @@ -378,6 +378,42 @@ "private": true, "featured": false }, + { + "name": "til-shorts", + "slug": "til-shorts", + "descriptionKo": "기록에 난관과 해결이 남아 있는 대상을 골라 영어권 유튜브 숏츠로 만드는 제작 파이프라인. 영상과 음성 자산은 전부 로컬 생성 팩토리(studios)에서 나오고, 이 레포는 자산을 복제하지 않고 id로만 참조하면서 지금 어느 단계인지, 무엇이 왜 탈락했는지라는 상태만 파일로 관리합니다. 후보에서 게시까지 7단계를 두고 단계마다 게이트를 걸었습니다. 출처 두 개 이상과 나레이션 문장 수만큼의 사실 대조표, 그리고 쓰지 않기로 한 숫자까지 적어야 사실 대조가 끝나고, 컷 평균 3초 이하와 라우드니스 -14 LUFS를 통과해야 검토로 넘어가며, 제작 승인만은 자동화하지 않고 사람이 누릅니다. 조립·컷·오버레이·자막·색보정·효과음·업로드를 Node 도구 사슬로 묶고, 공개 뒤에는 YouTube Data API로 실적을 스냅샷으로 받아 다음 편 설계의 근거로 씁니다. 첫 측정에서 조회의 98.8%가 숏츠 피드에서 오고 검색은 0.4%였다는 것, 그리고 더 많이 본 편이 오히려 첫 몇 초에서 시청자를 더 많이 잃었다는 부정 결과를 그대로 기록했습니다. 처음엔 r/todayilearned 소재로 시작했지만 고정 4막(대상, 난관, 발상 전환, 탄생)이 성립하려면 소재 자체에 난관과 해결이 있어야 한다는 걸 확인하고 소재 조건을 다시 정의했습니다.", + "descriptionEn": "A production pipeline that turns subjects whose engineering setbacks and fixes survive in the record into English-language YouTube Shorts. Every video and voice asset comes out of my local generation factory (studios); this repo never copies an asset, referencing them by id only, and owns just the state — what stage an episode is at, and what was rejected and why. Seven stages run from candidate to published, each behind a gate. Fact-checking is not done until there are two or more sources, a check table with a row per narration sentence, and a written list of the numbers I decided not to use; an episode reaches review only after clearing a three-second average cut length and -14 LUFS; and production approval is the one step never automated — a human presses it. Assembly, cuts, overlays, subtitles, grading, sound effects and upload are chained through Node tooling, and after publishing the YouTube Data API snapshots performance so the next episode is designed on evidence. The first measurement is recorded as it came: 98.8% of views arrived from the Shorts feed against 0.4% from search, and — a negative result kept rather than buried — the episode that won on views actually lost more viewers in its opening seconds. It started on r/todayilearned material, then redefined what qualifies once it was clear the fixed four acts (subject, setback, reframe, birth) only hold when the subject itself contains a setback and a fix.", + "techStack": [ + "JavaScript", + "Node.js", + "FFmpeg", + "YouTube Data API", + "Model Context Protocol" + ], + "status": "active", + "private": true, + "featured": false + }, + { + "name": "mv-analyzer", + "slug": "mv-analyzer", + "descriptionKo": "유튜브 뮤직비디오가 왜 묻히고 왜 퍼지는지를 100% 로컬로 정량 분석하는 개인 연구 프로젝트. 구독자 대비 조회수의 상위 25편과 하위 25편을 비교하는 극단군 설계로 보컬로이드 50편을 분석하고 K-pop 50편으로 재현했습니다. 분당 컷 수(23.9 대 5.5), 중앙 샷 길이(1.5초 대 6.2초), 썸네일 텍스트 유무(9/25 대 24/25) 같은 지표에서 효과크기를 Cliff's δ로 냈고, 가사 주제·감정·곡 길이·업로드 시각처럼 갈리지 않는 항목은 장르 관습으로 따로 적었습니다. 수집은 yt-dlp, 장면 경계는 PySceneDetect, 오디오는 librosa, 장면 의미 태깅과 하드섭 OCR은 로컬 VLM(Ollama qwen2.5-vl), 가사 받아쓰기는 demucs와 faster-whisper로 처리하고 16GB VRAM 안에서 GPU 단계가 겹치지 않도록 직렬화했습니다. 1차 결과 중 프리미어 공개가 유리하다는 항목은 같은 채널 안에서 55쌍을 짝지어 다시 분석하자 효과가 사라져서(Wilcoxon p=0.46), 채널 수준 교란으로 판정하고 자기 결과를 뒤집은 기록을 그대로 남겼습니다. IMRaD 논문 초안까지 나왔고 데이터 신뢰성 검증이 끝나면 공개할 예정입니다.", + "descriptionEn": "A personal research project that quantitatively analyzes why some YouTube music videos travel and others are buried, running 100% locally. An extreme-group design compares the top 25 and bottom 25 by views-per-subscriber across 50 Vocaloid MVs, replicated on 50 K-pop MVs. Effect sizes are reported as Cliff's δ for cuts per minute (23.9 vs 5.5), median shot length (1.5s vs 6.2s) and whether the thumbnail carries text (9/25 vs 24/25) — and the signals that do not separate the groups (lyric themes, sentiment, song length, upload time) are written up separately as genre convention rather than quietly dropped. Collection runs on yt-dlp, scene boundaries on PySceneDetect, audio on librosa, scene tagging and hard-sub OCR on a local VLM (Ollama qwen2.5-vl), and lyric transcription on demucs plus faster-whisper, with GPU stages serialized so they never overlap inside 16 GB of VRAM. One cycle-one finding — that a premiere release helps — vanished under a within-channel matched reanalysis of 55 pairs (Wilcoxon p=0.46), so it is recorded as channel-level confounding and the project publishes the reversal of its own result. An IMRaD paper draft is complete; the code opens once data reliability is further validated.", + "techStack": [ + "Python", + "yt-dlp", + "PySceneDetect", + "OpenCV", + "librosa", + "Ollama", + "faster-whisper", + "demucs", + "FFmpeg" + ], + "status": "active", + "private": true, + "featured": false + }, { "name": "ai-4080-ops", "slug": "ai-4080-ops", diff --git a/src/app/[locale]/LedgerHome.tsx b/src/app/[locale]/LedgerHome.tsx index 717750f..80808f9 100644 --- a/src/app/[locale]/LedgerHome.tsx +++ b/src/app/[locale]/LedgerHome.tsx @@ -3,6 +3,7 @@ import { Link } from '@/i18n/navigation' import type { Project, Tombstone } from '@/types/content' import type { PostMeta } from '@/lib/mdx' import { predecessorOf } from '@/lib/lineage' +import { HOME_GROUPS } from '@/lib/projects' // ── Builder's Ledger home ────────────────────────────────────────────────── // Immersive, full-bleed redesign generated in Claude Design and ported here: @@ -20,20 +21,6 @@ const BORDER = 'var(--l-border)' const STACK = ['TypeScript', 'Next.js', 'React', 'Three.js', 'NestJS', 'Electron', 'Python'] -// ALL WORK grouping — implies the 39-project scale without listing everything. -// -// Only the first six of each list render (the rest collapse into "+N more"), so -// order is editorial, not incidental: appending new work to the end would have -// hidden the newest and strongest entries behind older ones. Each group leads -// with what a first-time reader should see — a shipped store game and the only -// Unreal title, the newest live product, the one public repo with measured -// results — and trails off into the long tail. -const CATEGORY: Record<'products' | 'games' | 'tools', string[]> = { - products: ['soursea', 'healframe', 'drymora', 'toolsmith', 'rentrights', 'receipt-tracker', 'fitcheck', 'zodiacly', 'transit-la', 'kindling', 'growgle', 'argus-fusion', 'observer-of-lines'], - games: ['coinrace', 'wishing-stones', 'hoverslam', 'normandy-cliff-defense', 'tantrum-tower', 'mini-games', 'unclog-la', 'youtube-rhythm-game', 'liminal-bestiary', 'studio-apartment'], - tools: ['citefirst', 'devdeck', 'studios', 'sitedeck', 'marketdeck', 'notro', 'unitwise', 'amazon-chat-archiver', 'sitesmith', 'kl125-controller', 'ai-4080-ops', 'nag-coach', 'piano-scribe', 'comfyui-web', 'shipwright', 'dont-touch'], -} - // YAML frontmatter dates (`publishedAt: 2026-06-10`) parse to a Date at runtime // even though PostMeta types it as string — normalize to a YYYY-MM-DD string so // it never renders as a raw [object Date] React child. @@ -136,8 +123,8 @@ export function LedgerHome({ projects, tombstones, posts, locale }: { const bySlug = new Map(projects.map((p) => [p.slug, p])) const playCount = projects.filter((p) => p.playStore).length - const groups = (Object.keys(CATEGORY) as Array).map((key) => { - const items = CATEGORY[key].map((slug) => bySlug.get(slug)).filter(Boolean) as Project[] + const groups = (Object.keys(HOME_GROUPS) as Array).map((key) => { + const items = HOME_GROUPS[key].map((slug) => bySlug.get(slug)).filter(Boolean) as Project[] return { key, items } }) diff --git a/src/lib/__tests__/projects.test.ts b/src/lib/__tests__/projects.test.ts index 0af2e83..a7d05b0 100644 --- a/src/lib/__tests__/projects.test.ts +++ b/src/lib/__tests__/projects.test.ts @@ -3,6 +3,7 @@ import { getHireStats, hasIndexablePage, HIRE_CASE_STUDIES, + HOME_GROUPS, PROJECT_SHOWCASE, projectHref, sortProjectsFeaturedFirst, @@ -175,3 +176,31 @@ describe('HIRE_CASE_STUDIES', () => { } }) }) + +describe('HOME_GROUPS', () => { + // The home page resolves these slugs against the ledger and drops whatever + // does not match, so both kinds of drift are silent: a slug that no longer + // exists just disappears, and a project nobody listed never renders at all. + // Neither shows up as an error, a warning, or a visibly broken page — which + // is exactly why it has to fail here. + const grouped = Object.values(HOME_GROUPS).flat() + const ledger = (projectsData.projects as Project[]).map((p) => p.slug) + + it('lists every project in the ledger', () => { + const listed = new Set(grouped) + const missing = ledger.filter((slug) => !listed.has(slug)) + expect(missing, `not shown on the home page: ${missing.join(', ')}`).toEqual([]) + }) + + it('names no project the ledger does not have', () => { + const known = new Set(ledger) + const ghosts = grouped.filter((slug) => !known.has(slug)) + expect(ghosts, `named on the home page but not a project: ${ghosts.join(', ')}`).toEqual([]) + }) + + it('puts each project in exactly one group', () => { + const seen = new Set() + const dupes = grouped.filter((slug) => (seen.has(slug) ? true : (seen.add(slug), false))) + expect(dupes, `listed more than once: ${dupes.join(', ')}`).toEqual([]) + }) +}) diff --git a/src/lib/projects.ts b/src/lib/projects.ts index 1f2579b..926cb38 100644 --- a/src/lib/projects.ts +++ b/src/lib/projects.ts @@ -113,3 +113,25 @@ export function getHireStats( * A slug that stops resolving fails the test suite rather than the page. */ export const HIRE_CASE_STUDIES = ['rentrights', 'healframe', 'argus-fusion'] as const + +/** + * How the home page's ALL WORK section groups the ledger, in render order. + * + * Only the first six of each list render (the rest collapse into "+N more"), so + * order is editorial, not incidental: appending new work to the end would have + * hidden the newest and strongest entries behind older ones. Each group leads + * with what a first-time reader should see — a shipped store game and the only + * Unreal title, the newest live product, the one public repo with measured + * results — and trails off into the long tail. + * + * It lives here rather than in the page for the reason APP_CATEGORY does, plus + * one the page could not offer: the home page silently drops a slug it cannot + * resolve and silently omits a project nobody listed, so the only thing that + * catches a drifted list is a test, and a test needs this importable without + * pulling in React. + */ +export const HOME_GROUPS: Record<'products' | 'games' | 'tools', string[]> = { + products: ['soursea', 'healframe', 'drymora', 'toolsmith', 'rentrights', 'receipt-tracker', 'fitcheck', 'zodiacly', 'transit-la', 'kindling', 'growgle', 'argus-fusion', 'observer-of-lines'], + games: ['coinrace', 'wishing-stones', 'hoverslam', 'normandy-cliff-defense', 'tantrum-tower', 'mini-games', 'unclog-la', 'youtube-rhythm-game', 'liminal-bestiary', 'studio-apartment'], + tools: ['citefirst', 'devdeck', 'studios', 'til-shorts', 'mv-analyzer', 'sitedeck', 'marketdeck', 'notro', 'unitwise', 'amazon-chat-archiver', 'sitesmith', 'kl125-controller', 'ai-4080-ops', 'nag-coach', 'piano-scribe', 'comfyui-web', 'shipwright', 'dont-touch'], +}