Màn hình và spec đánh số
Thiết kế portfolio
Mockup đã chốt của site public và admin kiểu Linear. Bám các màn này, đừng thiết kế lại. Light và dark dùng token trong spec; không hard-code nội dung trên trang.
Bản nháp, một số màn vẫn đang bind token
Hệ thống thiết kế portfolio và UI spec (Parker)
Chỉ design và planning. Doc này là nguồn cho tab LAB của plan 30 ngày (plans/study-plan-30d-redesign.md): lab trở thành một project xuyên suốt, portfolio full-stack của Parker.
- Stack: Next.js App Router + TypeScript (
apps/web) với shadcn/ui (Radix + Tailwind v4) và TanStack Query, Table, Form và Virtual; NestJS (apps/api); MySQL 8 trong Docker Compose; Prisma là ORM duy nhất; server-side session cho auth; shared zod schema, type và query key trongpackages/contracts; component trongpackages/ui. - Nguyên tắc: typography first (3.2); không hard-code content, mọi thứ hiện trên UI đều đến từ API/CMS (1.5, 4.5); quyết định backend được giải thích cho backend beginner và build từ dưới lên (1.6, 6).
- Bản sửa v2: sau khi review mockup v1 ("quá trần"), lớp visual được làm dày hơn, lấy longpham.tech làm reference: phân tích ở 2.1–2.2, token mới ở 3.1–3.1b, motion ở 3.4, component ở 4.7, wireframe ở 5.1–5.6, phần thêm cho admin ở 5.11, rebuild brief ở 7.
- Source: CV của Parker (đã cũ, ghi "4+ years"; hiện anh có ~5 năm với Vue/Nuxt/TypeScript).
- Privacy: thông tin liên hệ là
<email>/<phone>ở mọi nơi, kể cả seed và fixture. Bản thân file CV không được commit. - § refs: Heading là
<h2 id="s-N">N. Title</h2>(chapter) và<h3 id="s-N-M">N.M Title</h3>(section; cho phép hậu tố chữ, ví dụ3.1b).§1.5map tới heading có text bắt đầu bằng1.5, và tới HTML id tường minhs-1-5(s-+ chữ/số,.→-). Ưu tiên#s-1-5hơn GitHub auto-slug, vì auto-slug bỏ dấu chấm (15-content-model-dbcms).
1. Phân tích CV
Trong chương này: các mục đánh số bên dưới.
| § | Mục |
|---|---|
| 1.1 | Định vị |
| 1.2 | Câu chuyện portfolio kể |
| 1.3 | Case study đưa lên (theo thứ tự) |
| 1.4 | Gap cần bổ sung (hỏi Parker, hoặc đánh TODO trong CMS) |
| 1.5 | Content model (DB/CMS) |
| 1.6 | Lộ trình backend cho backend beginner (một cách làm cho mỗi thứ) |
1.1 Định vị
Headline (en): Senior Frontend Engineer. 5 years shipping data-dense product UIs in Vue, Nuxt and TypeScript, now building with Next.js. Headline (vi): Senior Frontend Engineer. 5 năm xây dựng giao diện sản phẩm nhiều dữ liệu với Vue, Nuxt và TypeScript, hiện phát triển với Next.js.
Sub-line: Fintech (Web3/ReFi) · enterprise (Samsung SDS) · e-commerce tooling. Hà Nội, làm việc với team Hàn, Mỹ và Anh.
Tên hiển thị: Parker Vu, kèm tên pháp lý Vu Van Hung (Vũ Văn H…ng: confirm dấu với Parker trước khi seed) trên About và trong JSON-LD. GitHub: parker2808.
1.2 Câu chuyện portfolio kể
Mỗi role trên CV đều là một migration: chuyển cart giữa Shopify/BigCommerce/WooCommerce/Magento (LitGroup), chuyển logic MSP App sang MSP Advanced mới (Samsung SDS), lead Particula từ v1 (Vue 2) sang v2 (Nuxt 3, Vue 3, PrimeVue, TanStack Query). Sợi dây xuyên suốt là:
"Mình đẩy product tiến lên mà không làm gãy chúng."
Ba hồi:
- Gốc backend (2021): API và data integrity cho migration xuyên 4+ platform e-commerce; nói chuyện với khách US/UK khi data gãy.
- Frontend enterprise (2022–2024): platform Samsung SDS với team Hàn, 5–15 người, độ tin cậy nhờ test và bug triage.
- Ownership (2024–nay): main frontend developer tại Particula, lead rewrite v1→v2, build core component, đưa Playwright spec vào.
Bản thân portfolio là hồi 4, đồng thời là câu trả lời cho dòng yếu nhất trên CV ("React/Next JS (a bit)", "Backend (basic)"): một app production Next.js + NestJS có CMS, build công khai.
1.3 Case study đưa lên (theo thứ tự)
| # | Case study | Vì sao đứng đầu | Góc tiếp cận |
|---|---|---|---|
| 1 | Particula v2: Vue 2 → Nuxt 3 migration (Kobizo, 2024–nay) | Signal seniority mạnh nhất: ownership, chiến lược migration, quyết định stack (Axios → TanStack Query), testing | Quyết định strangler vs big-bang, component library trên PrimeVue, server-state cache, Playwright spec. Domain: rating RWA trên 20+ asset class |
| 2 | Cart migration tool (LitGroup, 2021–2022) | Phủ full-stack: API Flask/Laravel, rồi frontend Vue 2 + TS + Pinia cho cùng product | State dataset lớn, UX progress/error cho job dài, xử lý lỗi hướng tới khách |
| 3 | Samsung SDS MSP Advanced + App Expansion (CMC Global, 2022–2024) | Scale enterprise và delivery xuyên văn hóa (team Hàn, team 15 người) | Re-platform logic sang design mới, UI quản lý issue theo Jira, unit test. An toàn NDA: không screenshot, dùng diagram vẽ lại |
| 4 | Portfolio này (2026) | Bằng chứng React/Next.js, NestJS, MySQL, auth, realtime | Architecture, lựa chọn rendering theo từng route, thiết kế CMS, đạt budget a11y/perf |
1.4 Gap cần bổ sung (hỏi Parker, hoặc đánh TODO trong CMS)
- Không có metric nào. Thu thập ít nhất một con số cho mỗi case study: số page/route đã migrate, số component trong library v2, LCP hoặc bundle size trước/sau, số Playwright spec và thời gian CI, xu hướng số bug, số record hoặc store migrate mỗi tháng, tỉ lệ migration thành công, số support ticket đã resolve.
- Số năm và title đã cũ. "4+" → "5 years"; Objective → một câu định vị (1.1). Chỉ dùng "Senior Frontend Engineer" nếu Parker ổn với cách gọi đó; không thì "Frontend Engineer (5 yrs)".
- Date chồng nhau. CMC Global kết thúc tháng 6/2024, Kobizo bắt đầu tháng 2/2024, và Particula ghi "main frontend since July 2024". Confirm ngày thật; timeline hiện theo tháng nên overlap sẽ nhìn thấy.
- Typo và naming. "Particular" vs "Particula"; "we also using", "specifictly", "sometime". Giải thích tên nội bộ ("Lego framework" = UI framework nội bộ của Samsung; "MSP").
- Skills list phẳng. Đổi thành skill nhóm theo độ sâu: Daily (Vue 3, Nuxt 3, TypeScript, Pinia, TanStack Query, Tailwind, PrimeVue, Playwright), Shipped before (Vue 2/Vuex, Vitest, REST/GraphQL/OAuth, Flask, Laravel, MySQL), Growing (React, Next.js, NestJS). Bỏ GPA và boilerplate "Version control".
- Thiếu bằng chứng seniority: mentoring, code review, quyết định design, RFC. Xin 2 câu chuyện cụ thể (cũng feed lab Day 25–27).
- Không có link. URL product live (site public của Particula), GitHub repo, và sau này là blog. Thêm
<email>và LinkedIn làm placeholder. - Trách nhiệm, không phải outcome. Viết lại bullet theo verb + what + result ("Led the Vue 2 → Nuxt 3 rewrite of Particula's investor app; shipped N core components; replaced Axios with TanStack Query, cutting duplicate requests by X%").
1.5 Content model (DB/CMS)
Quy tắc: nếu visitor đọc được, và nội dung đó nói về Parker hoặc site, thì nó là một row trong database và edit được trong admin. Chỉ những thứ này ở lại trong code: design token, chuỗi UI chrome (label button và form, validation và empty/error message generic, aria label, "min read", format date và number) trong apps/web/messages/{en,vi}.json, và danh sách route. Mục 4.5 giải thích UI enforce rule này thế nào.
Quy ước (cố ý làm đơn giản). Một table cho mỗi thứ trên page; không dùng pattern khôn lỏi.
| Quy ước | Nghĩa là gì | Vì sao (góc nhìn beginner) |
|---|---|---|
id Int @id @default(autoincrement()) |
Mỗi row nhận 1, 2, 3… | Dễ đọc trong DB GUI và trong URL kiểu /admin/projects/3 |
createdAt, updatedAt trên mọi table |
Prisma tự điền | History miễn phí để sort và hiện "Updated 2d ago" |
Hai cột cho mỗi text dịch: titleEn, titleVi |
English bắt buộc mới publish, Vietnamese thì optional | Chỉ 2 ngôn ngữ, nên không cần table dịch phụ hay join thêm. Nhìn cả hai giá trị cạnh nhau trong Adminer. Ngôn ngữ thứ 3 sẽ cần một migration, chấp nhận được |
sortOrder Int, isVisible Boolean |
Admin reorder và ẩn row được | Một pattern cho mọi list (nav, skill, section, project) |
status enum DRAFT | PUBLISHED | ARCHIVED + publishedAt |
Chỉ trên project và post | Public API chỉ trả PUBLISHED; "delete" trên UI nghĩa là archive, nên không mất gì |
Foreign key projectId Int + @relation |
Một project có nhiều metric (1-to-many) | DB từ chối metric trỏ tới project không tồn tại |
skills Skill[] ở cả hai phía |
Many-to-many; Prisma tạo join table ẩn | Không phải tự viết join table |
slug String @unique |
Tên URL kiểu particula-v2 |
DB đảm bảo hai project không dùng chung một URL |
Text dài là @db.Text |
Markdown được lưu dạng plain text | Render ra HTML trên Next server, không bao giờ lưu HTML |
Singleton (SiteSettings, Profile) là table đúng một row, id = 1 |
Seed tạo ra | Cùng tool với mọi table khác; không file config đặc biệt |
Shorthand field bên dưới: title{En,Vi} nghĩa là hai cột titleEn và titleVi. ? = optional. Mọi table còn có id, createdAt, updatedAt.
| Nhóm | Entity | Fields | Ghi chú |
|---|---|---|---|
| Site | SiteSettings (1 row) |
siteName, domain, defaultLocale, seoTitle{En,Vi}, seoDescription{En,Vi}, ogImageId?, footerNote{En,Vi}, contactIntro{En,Vi}, replyTimeNote{En,Vi}, contactEmail (<email>), availability (OPEN | NOT_LOOKING | FREELANCE), availabilityNote{En,Vi}, analyticsEnabled |
SEO mặc định cho page không có SEO riêng |
| Site | NavItem |
location (HEADER | FOOTER), label{En,Vi}, href (route nội bộ kiểu /projects, hoặc URL đầy đủ), sortOrder, isVisible |
Admin chọn href từ list route thật, hoặc gõ một URL |
| Site | SocialLink |
kind (GITHUB | LINKEDIN | EMAIL | RSS | OTHER), label{En,Vi}, url, showInHeader, sortOrder, isVisible |
GitHub parker2808; email là mailto:<email> |
| Site | PageSection |
page (HOME | ABOUT | PROJECTS | NOTES | CONTACT), key (ví dụ hero, featured, work, skills, notes, cta), title{En,Vi}?, intro{En,Vi}?, ctaLabel{En,Vi}?, sortOrder, isVisible; unique trên (page, key) |
Được seed. Admin reorder, ẩn và đổi title section được; mỗi key map tới một component trong code, nên admin không invent được loại section mới |
| About | Profile (1 row) |
displayName, legalName, city, country, timezone, careerStartDate, headline{En,Vi}, subheadline{En,Vi}, bio{En,Vi} (md), languages{En,Vi}, avatarId?, resumeEnId?, resumeViId?, phone? (<phone>, private) |
"Years of experience" được compute từ careerStartDate, nên không bao giờ cũ như "4+" trên CV. Public API không bao giờ trả phone |
| About | Experience |
company, companyUrl?, location, employmentType (FULL_TIME | CONTRACT | FREELANCE), role{En,Vi}, summary{En,Vi}, highlights{En,Vi} (md bullet list), startDate, endDate? (trống = hiện tại), sortOrder, isVisible, skills Skill[] |
LitGroup là 2 row (Backend 2021, Frontend 2022); timeline group row theo company |
| About | Education |
school, degree{En,Vi}, major{En,Vi}, startDate, endDate, isVisible |
Không có cột GPA |
| About | Principle |
title{En,Vi}, body{En,Vi}, sortOrder, isVisible |
Card "How I work" |
| About | SkillCategory |
name{En,Vi}, sortOrder |
Frontend, Testing, Backend, Data, Workflow |
| About | Skill |
name, slug @unique, categoryId, depth (DAILY | SHIPPED | GROWING), sortOrder, isVisible |
Link tới project và experience |
| Work | Project |
slug @unique, title{En,Vi}, tagline{En,Vi}, client, role{En,Vi}, teamSize?, startDate, endDate?, versionFrom?, versionTo? (ví dụ v1, v2 cho version tag), isFeatured, isConfidential, liveUrl?, repoUrl?, coverId?, problem{En,Vi}, constraints{En,Vi}, approach{En,Vi}, decisions{En,Vi}, outcome{En,Vi}, lessons{En,Vi} (tất cả md), seoTitle{En,Vi}?, seoDescription{En,Vi}?, status, publishedAt?, sortOrder, experienceId?, skills Skill[] |
Một cột cho mỗi section của case study, editor giữ dạng form đơn giản |
| Work | ProjectMetric |
projectId, label{En,Vi}, before?, after?, value, sortOrder |
Drive stat before → after |
| Work | ProjectImage |
projectId, mediaId, caption{En,Vi}?, sortOrder |
Ẩn khi isConfidential |
| Writing | Post |
slug @unique, title{En,Vi}, excerpt{En,Vi}, body{En,Vi} (md), seoTitle{En,Vi}?, seoDescription{En,Vi}?, coverId?, readingMinutes (compute lúc save), isFeatured, status, publishedAt?, tags Tag[] |
Post không có bodyVi vẫn list trên /vi với badge "EN" |
| Writing | Tag |
slug @unique, name{En,Vi} |
|
| System | Media |
fileName (tên lưu), originalName, mimeType, sizeBytes, width?, height?, alt{En,Vi}, uploadedById |
File trên disk, metadata trong DB. Alt text bắt buộc trước khi bất cứ thứ gì dùng image đó được publish |
| System | ContactTopic |
label{En,Vi}, sortOrder, isVisible |
Option của select Topic trên contact form |
| System | ContactMessage |
name, email, topicId?, message (Text), locale, status (NEW | READ | REPLIED | SPAM), ipHash |
Không bao giờ public |
| System | User |
email @unique, name, passwordHash, role (OWNER | EDITOR | VIEWER), isActive, lastLoginAt? |
Seed tạo owner từ env var |
| System | Session |
tokenHash @unique, userId, expiresAt, userAgent? |
Một row cho mỗi browser đang login; logout xóa row |
| System | AuditLog |
userId, action (CREATE | UPDATE | PUBLISH | ARCHIVE | LOGIN), entityType, entityId, summary |
Append-only, API ghi |
| System | PageView |
path, locale, referrerHost?, device, visitorHash |
Không cookie; hash dùng salt đổi mỗi ngày |
Bổ sung v2 (lớp rich). Mọi thứ section mới hiện ra vẫn là một row; không có gì decorative mang content. Table mới theo cùng quy ước (sortOrder, isVisible, cặp En/Vi).
| Nhóm | Entity / field | Fields | Ghi chú |
|---|---|---|---|
| About | ProfileStat (mới) |
label{En,Vi}, value String?, suffix? (ví dụ +, %), source (MANUAL | YEARS_COMPUTED | PROJECT_COUNT), iconKey, sortOrder, isVisible |
Stats band trên Home. YEARS_COMPUTED đọc Profile.careerStartDate, PROJECT_COUNT đếm project đã publish, nên số không bao giờ cũ. Seed: số năm, 4 product đã ship, 2 framework migration, 1 enterprise (Samsung SDS) |
| Work | Domain (mới) |
slug @unique, name{En,Vi}, description{En,Vi}, hue (JADE | AMBER | BLUE | VIOLET | ROSE), iconKey, sortOrder, isVisible |
Card "Domains" trên Home, pill filter trên list project, pill trên case study. Seed: Fintech / Web3 ReFi (amber), Enterprise platforms (blue), E-commerce tooling (violet), Developer experience & testing (jade) |
| Work | Project.domainId? (FK mới) |
Domain 1──* Project |
Một domain mỗi project để filter đơn giản |
| Work | Project.keyFacts{En,Vi} |
Text, một fact mỗi dòng, 2–4 dòng | Grid 2×2 KeyFacts trên case study |
| Work | Project.factChips{En,Vi} |
Text, cách nhau bằng dấu phẩy, ≤ 6 | FactChips trên case study (ví dụ "Vue 2 → Nuxt 3, 0 downtime, 6 months") |
| About | ProcessStep (mới) |
title{En,Vi}, caption{En,Vi}, iconKey, sortOrder, isVisible |
Flow "How I migrate a frontend" trên Home. Seed (5): Audit → Tokens & primitives → Strangler routes → Contract tests → Cut over |
| About | Profile.heroKicker{En,Vi}?, Profile.headlineAccent{En,Vi}? |
Chuỗi ngắn | Pill kicker phía trên H1; accent phải là substring của headline (validate lúc save) |
| About | Principle.iconKey? |
Ô icon trên card principle | |
| About | Skill.showInMarquee |
Boolean, mặc định false |
Tech marquee trên home |
| Writing | Post.takeaways{En,Vi}? |
Text, một dòng một ý, ≤ 5 | Card "Key takeaways" ở cuối post |
| Site | SocialLink.showInCta |
Boolean | Button outline trong CTA band (tối đa 2) |
| Site | SiteSettings.ambientEffects, SiteSettings.marqueeEnabled |
Boolean, mặc định true |
Kill switch cho grid/orb/glow và animation marquee (marquee lúc đó render thành một hàng tĩnh) |
| Site | PageSection.key (giá trị mới) |
stats, marquee, domains, process, principles |
Seed kèm title; vẫn một component cho mỗi key |
| Site | iconKey (tất cả mục trên) |
String từ allowlist cố định ~40 tên lucide trong packages/contracts/icons.ts |
zod reject key lạ; admin hiện icon picker. Không upload SVG, nên icon luôn khớp theme |
Quan hệ nhìn nhanh:
SiteSettings(1) Profile(1) NavItem* SocialLink* PageSection* Principle* Education*
ProfileStat* ProcessStep* (v2)
SkillCategory 1──* Skill *──* Project 1──* ProjectMetric
Domain 1──* Project (v2)
*──* Experience 1──* Project (optional link)
Project 1──* ProjectImage *──1 Media
Post *──* Tag ContactTopic 1──* ContactMessage
User 1──* Session User 1──* AuditLog User 1──* Media (uploadedBy)
Ví dụ quy ước trong apps/api/prisma/schema.prisma:
model Project {
id Int @id @default(autoincrement())
slug String @unique
titleEn String
titleVi String?
taglineEn String
taglineVi String?
problemEn String @db.Text
problemVi String? @db.Text
status ContentStatus @default(DRAFT)
publishedAt DateTime?
sortOrder Int @default(0)
metrics ProjectMetric[]
skills Skill[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, sortOrder])
@@map("project")
}
Hai shape API. Public endpoint (GET /public/...?locale=vi) chọn ngôn ngữ trên server và trả field phẳng (title, không phải titleEn/titleVi), fallback sang English với lang: "en" trên nhóm field để UI set lang trên element đó. Admin endpoint trả cả hai cột để edit. Component vì thế không chứa logic locale.
Seed (prisma/seed.ts) load content từ CV: site settings, profile (placeholder cho <email> / <phone>), nav, social link, page section, 4 row experience, education, ~25 skill trong 5 category, 4 project ở DRAFT, 3 principle, contact topic, user owner. 0 post. v2 thêm 4 domain (mỗi project được link), 4 profile stat, 5 process step, showInMarquee trên ~10 skill, keyFacts/factChips nháp cho Particula, và các PageSection key mới.
1.6 Lộ trình backend cho backend beginner (một cách làm cho mỗi thứ)
Các mảnh nói chuyện với nhau thế nào:
Browser ──► Next.js (apps/web, :3000) ──/api/* rewrite──► NestJS (apps/api, :4000) ──Prisma──► MySQL (:3306, Docker)
Server Components fetch controllers → services data on a Docker volume
Khi dev, chỉ MySQL và Adminer (web UI để nhìn vào DB) chạy trong Docker; Next và Nest chạy với pnpm dev để restart và debug vẫn nhanh. Day 21 cả ba chạy trong Compose, đúng cách chúng sẽ chạy trên production.
| Lựa chọn | Chọn | Vì sao chọn cái này | Không chọn, và vì sao |
|---|---|---|---|
| ORM | Prisma | Một file schema.prisma đọc được là source of truth; prisma migrate dev viết SQL giúp bạn và bạn đọc được; generated client autocomplete mọi query; Prisma Studio hiện data; NestJS docs có Prisma recipe chính thức |
TypeORM: class decorator và shortcut synchronize: true có thể sửa table thầm lặng. Drizzle: rất tốt, nhưng giả định bạn đã quen SQL |
| Validation | Schema zod trong packages/contracts, dùng bởi Next form và bởi ZodValidationPipe nhỏ trên Nest |
Một schema validate contact form trên browser rồi validate lại trên API | DTO class-validator: chỉ Nest, nên rule sẽ viết hai lần |
| Auth | Email + password, server-side session trong MySQL, password hash bằng argon2, session id trong cookie httpOnly, Secure, SameSite=Lax. AuthGuard load session; @Roles('OWNER') + RolesGuard check user.role |
Model dễ suy nhất: đã login = tồn tại một row Session; logout hoặc "sign out everywhere" = xóa row. Next rewrite /api/* sang Nest, nên browser thấy một origin: không phải setup CORS và cookie là first-party |
JWT access + refresh token: refresh rotation và revocation là concept thêm, không lợi gì với 1–3 user admin. Auth.js/NextAuth: nhét auth vào Next trong khi rule data nằm ở Nest. Hosted auth (Clerk, Auth0): bỏ qua mục tiêu học |
| CSRF | Cookie SameSite=Lax + API reject request mutating có Origin không phải site |
Hai dòng middleware, không token phải quản | Double-submit CSRF token: không cần khi same-origin + Lax |
| Realtime | Server-Sent Events (@Sse() trong Nest), proxy qua Next |
Một chiều server → browser là đủ cho notification; HTTP thuần, cookie auth chạy tự động, browser tự reconnect | WebSockets/Socket.IO: protocol hai chiều, handshake auth riêng |
| Upload | Nest FileInterceptor (multer) → file trên Docker volume uploads/, một row Media mỗi file, Nest serve |
Không cần tài khoản cloud; sau này đổi sang storage tương thích S3 phía sau cùng MediaService |
Upload presigned thẳng lên S3: nhiều moving part ngay ngày đầu |
| Rate limit | @nestjs/throttler trên login và contact |
Làm cho Nest, một decorator | n/a |
Glossary backend (dùng trong các ngày lab):
| Thuật ngữ | Nghĩa thông thường |
|---|---|
| Image / container | Image là chương trình đã đóng gói (MySQL 8); container là bản đang chạy của nó |
| Volume | Folder Docker giữ bên ngoài container, nên data sống sót sau docker compose down |
Port mapping 3306:3306 |
Port máy bạn → port của container |
| Environment variable | Config nằm ngoài code (DATABASE_URL, password); sống trong .env, không bao giờ commit |
| Migration | File SQL có version, thay đổi table; chạy theo thứ tự, commit vào git |
| Seed | Script đổ data khởi đầu vào DB |
| Primary key / foreign key | id của row / cột trỏ tới id của table khác |
| Index | Cấu trúc lookup giúp WHERE và ORDER BY trên cột đó nhanh |
| Transaction | Nhiều write thành công hoặc fail cùng nhau |
| Module / controller / service (Nest) | Folder của một feature / class map URL sang method / class chứa logic thật và lời gọi DB |
| Pipe / guard (Nest) | Chạy trước controller: pipe validate hoặc transform input, guard nói yes/no (đã login? đúng role?) |
| Session / cookie | Row phía server "browser này đang login là X" / giá trị nhỏ browser gửi lại mỗi request |
Thang backend (mỗi bậc chỉ cần các bậc dưới nó; số ngày nằm ở mục 6):
- Docker: chạy MySQL + Adminer, hiểu volume, port,
.env. - SQL bằng tay: tạo một table, insert, select, join, rồi bỏ đi.
- Nest cơ bản: một module, controller và service,
GET /health. - Prisma: model đầu, một migration, một seed; đọc SQL được generate.
- Read API: Nest + Prisma, public
GETendpoint, error handling, validation. - Relation: 1-to-many và many-to-many,
includevsselect. - Write API: create, update, archive, reorder trong một transaction; lỗi unique-constraint →
409. - Performance cơ bản: index,
EXPLAIN, tránh N+1 query. - Auth: user, password hashing, session, guard, role.
- Feature: contact + rate limit, upload, publish → revalidate, SSE, audit log, analytics.
- Ops: mọi thứ trong Compose, health check,
prisma migrate deploy, backup bằngmysqldump.
2. Hướng design
Trong chương này: Concept (v2): "Readable release notes, with atmosphere." Interface lấy typography làm trục, Linear-calm, mỗi project đọc như một changelog entry đã edit kỹ: version tag, diff before → after và metadata mono.
| § | Mục |
|---|---|
| 2.1 | Phân tích reference: longpham.tech (chụp 1440 và 390, Oct 2026) |
| 2.2 | Cái nào lấy, chỉnh lại, hoặc bỏ cho Parker |
Concept (v2): "Readable release notes, with atmosphere." Interface lấy typography làm trục, Linear-calm, mỗi project đọc như một changelog entry đã edit kỹ: version tag, diff before → after và metadata mono. v2 thêm một lớp atmosphere nhẹ (surface nhuốm jade, grid có mask, glow mềm, scroll reveal) và loại section giàu hơn (stats, tech marquee, domain card, diagram migration-flow) để page sống mà không hại việc đọc.
- Typography vẫn là design. Atmosphere nằm phía sau content và không bao giờ nằm dưới body text; page long-form (case study, post) giữ nền trơn phía sau measure 66ch. Recruiter lướt heading và số trong 10 giây; engineer đọc 2.000 từ case study mà không mỏi (mục 3.2).
- Vì sao release notes: sợi xuyên suốt của CV là migration (v1 → v2). Motif version và diff biến điều đó thành ngôn ngữ visual, và cùng mức kiềm chế đó scale được sang admin dày đặc.
- Accent: jade/teal. Gật đầu với Vue green (gốc của anh) và domain ReFi/carbon của Particula. v2 dùng đậm hơn, nhưng chỉ ở ba chỗ: cụm accent trên hero, số stat, và CTA primary (kèm glow ở dark mode). Mọi chỗ khác giữ tiết chế: link, focus, một highlight mỗi card.
- Reference: longpham.tech (atmosphere, nhịp section, stats + marquee, domain card, cấu trúc card và page case study; xem 2.1), Linear (mật độ admin, command palette, status pill), Vercel Geist và Stripe docs (type hierarchy, prose, code block), Practical Typography (measure, line-height), changelog Raycast và Linear (entry có version), GitHub diff view.
- Tone: tự tin, cụ thể, trầm. Câu ngắn, số thật, không "passionate developer". Motion mang tính functional hoặc ambient-và-chậm, không bao giờ giật mắt, và luôn tắt dưới
prefers-reduced-motion. Light và dark đều first-class; lớp atmosphere có biến thể light (2.2).
2.1 Phân tích reference: longpham.tech (chụp 1440 và 390, Oct 2026)
Nó là gì. Một site Next.js + Tailwind v4 (Geist + Geist Mono), dark-only, nhuốm xanh (#06110C background, #4ADE80 primary), khoảng 5.000px cao trên desktop. Header sticky, rồi: hero → stats band → tech marquee → domain card → principle đánh số → feature split kèm flow diagram → certification card → card case study featured → CTA band → footer tối giản. Page case study và list blog dùng cùng ngôn ngữ card.
Vì sao nó giàu trong khi bản mình trần (cùng mức kiềm chế, khác mật độ):
| Thành phần | Reference | Mockup v1 của mình |
|---|---|---|
| Độ sâu surface | Ba surface nhuốm (background, surface, surface-2) + band full-width xen kẽ với border-y |
Một background phẳng, chỉ có card |
| Atmosphere | Pattern grid masked bởi radial gradient dưới hero; 2 orb blur lớn nổi (14s / 18s); radial glow sau CTA | Không có |
| Hero | Kicker pill kiểu terminal (>_ Solution Architect · 13+ years), H1 60px với cụm accent màu, button primary có glow, SVG diagram animate (node pulse) bên phải |
Tên + đoạn văn + card git-log nhỏ |
| Proof nhìn một cái | Stats band: 4 số lớn màu primary, ô icon, label mono uppercase | Hai dòng diff trong featured block |
| Độ rộng phủ | Tech marquee vô hạn gồm pill mono; domain card có ô icon, count ("2 systems"), hue theo domain | Một hàng skill badge |
| Section header | Icon + H2 + subtitle muted một dòng; eyebrow mono ("SUMMARY", "SCOPE") trên inner page | H2 trần |
| Card | Category pill theo hue domain, mũi tên góc ↗, title chuyển primary, lift −2px + border sáng hơn + shadow lúc hover, tech chip mono |
Card tĩnh, badge xám |
| Process | Principle đánh số (01 - dạng mono); flow diagram dọc (Ingest → Core → Guardrails → Action) cạnh feature card 2×2 |
Không có |
| Page case study | Back link, pill category + "Confidential", meta grid với label mono, card key-facts 2×2, eyebrow mỗi section, bullet ▹ màu primary, requirement card cạnh nhau, fact chip mono, "More in |
Bản cùng cấu trúc nhưng nhạt hơn |
| Motion | reveal-in (fade + trượt 16px, 0.5s) mỗi section, orb float, node/ring pulse, icon bob, marquee; tất cả tắt dưới reduced motion |
Chỉ hover |
| CTA + footer | CTA band căn giữa, icon sparkle, glow, button primary + outline; footer một dòng mono | Band trần |
Điểm yếu cần tránh: dark-only (không có light mode); nhiều text xanh-xám muted 12–14px; placeholder {{TODO: …}} hiện trên case study live (cần check lúc publish); animation trang trí (icon bob, ring pulse) chạy mãi; email hiện dạng plain text; page rất dài mà không có in-page navigation; ảnh certification là screenshot PDF.
2.2 Cái nào lấy, chỉnh lại, hoặc bỏ cho Parker
| Ý tưởng | Quyết định | Map sang Parker và constraint của mình thế nào |
|---|---|---|
| Surface nhuốm + band | Lấy | Neutral dark nhuốm về jade (3.1); surface --band cho section full-width xen kẽ trên Home và About. Light mode có band jade ấm rất nhạt. |
| Grid có mask + orb + glow | Chỉnh | Chỉ trên hero home và CTA band, không bao giờ sau prose. Light: grid 6% jade, một orb 10%. Dark: grid 8%, hai orb 18%. Orb nổi chậm (20s / 26s), dừng khi ra khỏi màn và dưới reduced motion. Owner có thể tắt effect (SiteSettings.ambientEffects). |
| Kicker pill + cụm accent | Lấy | Kicker từ Profile.heroKicker (ví dụ > Senior Frontend · 5 yrs · Vue/Nuxt/TS); cụm accent từ Profile.headlineAccent, --primary đặc (không gradient text, để contrast vẫn đo được). |
| Diagram hero | Chỉnh | Thay vì architecture graph generic: một migration diagram (node v1 Vue 2 → node v2 Nuxt 3 với component tree "rewire"), dựng từ versionFrom/versionTo của project featured + tối đa 6 skill. SVG, CSS-animated, tĩnh dưới reduced motion. |
| Stats band | Lấy | Row ProfileStat: số năm (compute từ careerStartDate), product đã ship (compute từ project đã publish), framework migration, enterprise platform (giá trị seed ở 1.5). Số đếm lên một lần lúc xem lần đầu. |
| Tech marquee | Chỉnh | Từ Skill.showInMarquee. Pause lúc hover/focus, có button pause nhìn thấy, nửa nhân đôi aria-hidden, và trở thành list wrap tĩnh dưới reduced motion. |
| Domain card | Lấy | Entity Domain: Fintech / Web3 (ReFi), Enterprise platforms (Samsung SDS), E-commerce tooling, Developer experience & testing. Ô icon, hue, số project, mô tả một dòng. |
| Principle đánh số | Lấy | Row Principle có sẵn, render 01 - Migrate in slices với số mono. |
| Flow diagram + feature 2×2 | Chỉnh | "How I migrate a frontend": row ProcessStep (Audit → Tokens & primitives → Strangler routes → Contract tests → Cut over) dạng flow dọc cạnh 4 feature card (từ Principle). |
| Certification | Bỏ | Parker không có gì để show; slot này thành "Worked with" (team/công ty dạng text chip) chỉ khi có content. |
| Card case study | Lấy | Pill theo hue domain, mũi tên ↗, hover lift, tech chip mono, kèm dòng DiffStat của mình (thứ reference thiếu: số). |
| Cấu trúc page case study | Lấy | Back link, pill, meta grid, key-facts 2×2, eyebrow, bullet ▹, decision card, fact chip, "More in |
| List blog dạng card | Chỉnh | Hàng card với date · minutes mono và chip #tag; giữ group theo năm và search. |
| CTA band + footer | Lấy | CTA band có glow, button primary "Copy email" (địa chỉ không bao giờ hiện dạng text nhìn thấy) và tối đa 2 button outline từ SocialLink.showInCta. Footer giữ một dòng + build hash mono. |
| Dark-only, animation chạy mãi, TODO lọt | Không lấy | Light + dark; motion ambient chỉ ở 2 chỗ; validation lúc publish chặn {{TODO và field bắt buộc đang trống. |
3. Design tokens
Trong chương này: Token dùng tên biến của shadcn/ui (
--background,--primary,--muted-foreground, …) làm lớp semantic, kèm vài extension (--subtle-foreground,--success,--warning,--info,--primary-subtle).
| § | Mục |
|---|---|
| 3.1 | Color (semantic, tên shadcn) |
| 3.1b | Atmosphere và hue theo domain (v2) |
| 3.2 | Typography (cốt lõi của design) |
| 3.3 | Spacing, radius, elevation |
| 3.4 | Motion |
| 3.5 | Breakpoints và z-index |
| 3.6 | Theme shadcn: CSS variables (packages/ui/src/styles/globals.css) |
| 3.7 | Mapping Tailwind v4 (cùng file, @theme inline) |
Token dùng tên biến của shadcn/ui (--background, --primary, --muted-foreground, …) làm lớp semantic, kèm vài extension (--subtle-foreground, --success, --warning, --info, --primary-subtle). Một naming scheme nghĩa là component shadcn chạy không sửa, và custom component của mình dùng cùng utility (bg-primary, text-muted-foreground).
Mọi cặp color bên dưới đã đo bằng công thức relative luminance WCAG 2.x. Text cần ≥ 4.5:1 (normal) và ≥ 3:1 (≥ 24px, hoặc ≥ 18.66px bold). Border input và focus ring cần ≥ 3:1 (SC 1.4.11). Divider trang trí (--border) được miễn và không bao giờ là ranh giới duy nhất của một control.
3.1 Color (semantic, tên shadcn)
Thay đổi v2: neutral dark được nhuốm về jade (hue ≈ 165°) thay vì gray thuần, cho độ sâu mà không thêm màu. Light giữ warm neutral. Mọi cặp dark đã đo lại.
| Token | Light | Dark (v2) | Dùng cho | Contrast (đã đo, light / dark) |
|---|---|---|---|---|
--background / --foreground |
#FAFAF9 / #18181B |
#0A0F0E / #E8EFED |
Page và body text | 16.96 / 16.55 ✅ |
--card / --card-foreground |
#FFFFFF / #18181B |
#111817 / #E8EFED |
Card, input, table | ≥ 16 / ≥ 15 ✅ |
--popover / --popover-foreground |
#FFFFFF / #18181B |
#172120 / #E8EFED |
Menu, dialog, command palette | ≥ 16 / 14.12 ✅ |
--band (ext, v2) |
#F3F5F3 |
#0D1412 |
Band section full-width xen kẽ | muted-fg trên band 7.05 / 8.20, subtle 4.82, primary 5.00 ✅ |
--muted |
#F2F2EF |
#172120 |
Code bg, table hover, skeleton base, ô icon | n/a |
--muted-foreground |
#52525B |
#9DB0AB |
Text phụ | 7.40 bg, 6.89 muted / 8.49 bg, 7.91 card, 7.24 muted ✅ |
--subtle-foreground (ext) |
#6B6B74 |
#809590 |
Meta, placeholder, timestamp | 5.05 bg, 4.70 muted / 6.09 bg, 5.67 card, 5.19 muted ✅ |
--primary / --primary-foreground |
#0F766E / #FFFFFF |
#2DD4BF / #042F2B |
Link, button primary, cụm accent, số stat | primary trên bg 5.24 / 10.37, trên card 5.47 / 9.67; fg trên primary 5.47 / 7.80 ✅ |
--primary-hover (ext) |
#115E59 |
#5EEAD4 |
Hover/pressed | white trên nó 7.58 ✅ |
--primary-subtle / --primary-subtle-foreground (ext) |
#E3F2EF / #115E59 |
#0F2E2B / #5EEAD4 |
Row selected, tag active, kicker pill | 6.58 / 9.82 ✅ |
--secondary, --accent (+ foreground) |
#F2F2EF / #18181B |
#172120 / #E8EFED |
Button secondary; hover bg của shadcn (không phải brand color) | 15.80 / 14.12 ✅ |
--destructive / --destructive-foreground |
#B42318 / #FFFFFF |
#F97066 / #0A0F0E |
Error, action destructive | 6.29 / 6.93; fg trên nó 6.57 / 6.93 ✅ |
--success trên --success-subtle (ext) |
#067647 trên #E6F1EC |
#47CD89 trên #1A2F25 |
Published, sent | 5.45 bg, 4.92 subtle / 9.53, 7.01 ✅ |
--warning trên --warning-subtle (ext) |
#93370D trên #F4EBE7 |
#FDB022 trên #3A2F18 |
Warning, thiếu bản dịch | 7.20, 6.40 / 10.49, 7.13 ✅ |
--info trên --info-subtle (ext) |
#1D4ED8 trên #E8EDFB |
#7AA2F7 trên #1B2540 |
Callout info | 6.42, 5.72 / 7.67, 6.02 ✅ |
--destructive-subtle (ext) |
#F7E9E8 |
#3A1F1F |
Panel error | destructive trên nó 5.56 / 5.40 ✅ |
--border |
#E4E4E0 |
#233230 |
Divider, outline card (trang trí) | chỉ trang trí |
--border-strong (ext, v2) |
#CFCFC9 |
#2F4542 |
Border card lúc hover, cạnh band | chỉ trang trí (hover còn lift + shadow) |
--input |
#8A8A93 |
#62807A |
Border input, checkbox, switch | 3.42 trên card / 4.19 trên card ✅ |
--ring |
#0F766E |
#2DD4BF |
Focus ring, 2px + offset 2px | 5.24 / 10.37 ✅ |
--chart-1…5 |
#0F766E, #1D4ED8, #93370D, #6B6B74, #067647 |
#2DD4BF, #7AA2F7, #FDB022, #809590, #47CD89 |
Chart, luôn có label | ≥ 3:1 vs bg ✅ |
Family --sidebar |
--sidebar: #F4F4F2, --sidebar-accent: #EAEAE6, --sidebar-primary: #0F766E, --sidebar-border: #E4E4E0 |
--sidebar: #0C1211, --sidebar-accent: #172120, --sidebar-primary: #2DD4BF, --sidebar-border: #233230 |
shadcn Sidebar (admin) |
muted-fg trên sidebar 7.02 / 8.32 ✅ |
Chuyển theme: next-themes với attribute="class" (mặc định shadcn, .dark trên <html>), default system, disableTransitionOnChange. Chọn theme là UI preference, không phải content.
3.1b Atmosphere và hue theo domain (v2)
Token atmosphere (trang trí; không bao giờ mang text, không bao giờ nằm sau body copy):
| Token | Light | Dark | Dùng cho |
|---|---|---|---|
--grid-line |
rgb(15 118 110 / .06) |
rgb(45 212 191 / .08) |
Grid 44px trên hero home và CTA band, mask bằng radial-gradient(80% 60% at 50% 0, #000 40%, transparent) |
--glow |
rgb(15 118 110 / .10) |
rgb(45 212 191 / .18) |
Orb (blur 80px) và radial glow sau CTA |
--glow-button |
none (dùng --shadow-1) |
0 0 24px -6px rgb(45 212 191 / .55) |
Button primary trên hero và CTA thôi |
--card-glass |
rgb(255 255 255 / .72) |
rgb(17 24 23 / .72) |
Card nằm trên grid (+ backdrop-filter: blur(8px)); fallback --card đặc khi prefers-reduced-transparency: reduce hoặc không hỗ trợ backdrop-filter |
Hue theo domain (cho Domain.hue, dùng trên domain card, pill case study và accent cạnh trên của card). Pill luôn hiện icon + text label; hue không bao giờ là cue duy nhất. Tint = hue 10% trên #FFFFFF (light) / 14% trên --card (dark).
| Hue key | Text / tint light | Text / tint dark | Contrast (text trên tint, light / dark) | Domain gợi ý |
|---|---|---|---|---|
jade |
#0F766E / #E7F1F1 |
#2DD4BF / #15322F |
4.76 / 7.38 ✅ | Developer experience & testing |
amber |
#8A4B08 / #F3EDE6 |
#FBBF24 / #322F19 |
5.85 / 8.08 ✅ | Fintech / Web3 (ReFi) |
blue |
#1D4ED8 / #E8EDFB |
#7AA2F7 / #202B36 |
5.72 / 5.71 ✅ | Enterprise platforms |
violet |
#6D28D9 / #F0EAFB |
#C4B5FD / #2A2E37 |
6.04 / 7.37 ✅ | E-commerce tooling |
rose |
#B4235A / #F8E9EF |
#FDA4C4 / #322C2F |
5.38 / 7.34 ✅ | Dự phòng (ví dụ "This portfolio") |
3.2 Typography (cốt lõi của design)
Mục tiêu, theo thứ tự: (1) mọi dấu tiếng Việt render sạch ở mọi size, (2) case study và note long-form đọc thoải mái 10+ phút, (3) hierarchy rõ với ít size, (4) code không mơ hồ.
Family (3, mỗi cái một việc).
| Vai trò | Family | Weight được load | Vì sao |
|---|---|---|---|
| Heading (H1–H3 public) | Be Vietnam Pro | 600, 700 | Thiết kế tại Việt Nam cho tiếng Việt: dấu như ế ộ ữ ẳ được vẽ như một phần của chữ, không chồng, nên heading vẫn sắc. Hơi rộng hơn Inter, giúp contrast heading/body |
| Body và toàn bộ UI (kể cả admin) | Inter | 400, 500, 600 (variable) | Đủ subset vietnamese, x-height cao, rất dễ đọc ở 14–18px, tabular figure (tnum) cho metric và table, character variant cv05 (l có đuôi) và cv08 (I có serif) để Il1 không bao giờ giống nhau |
| Code, version tag, kbd | JetBrains Mono | 400, 600 | Có subset vietnamese (comment code vi render được), x-height cao, 0/O, 1/l/I rõ. Ligature tắt (font-variant-ligatures: none) để !== và => trông đúng như lúc gõ |
Load bằng next/font/google, subsets: ['latin', 'latin-ext', 'vietnamese'], display: 'swap', adjustFontFallback bật (fallback khớp metric, không layout shift). Expose thành --font-heading, --font-sans, --font-mono. Không load gì khác; tổng ≤ 5 file font.
Scale: 8 size public (mỗi cái một việc), 3 size admin. v2 thêm text-display (chỉ hero home) và text-eyebrow (chỉ label mono). Hierarchy dùng size và weight và space; đừng thêm size, hãy đổi weight hoặc spacing.
| Token | Size (mobile → desktop) | Line-height | Weight · family | Dùng cho |
|---|---|---|---|---|
text-display (v2) |
40px → 64px (clamp(2.5rem, 1.5rem + 4vw, 4rem)) |
1.1 en / 1.2 vi | 700 · heading, -0.02em |
Chỉ H1 hero home (kèm cụm accent) |
text-title |
34px → 48px (clamp(2.125rem, 1.6rem + 2.2vw, 3rem)) |
1.2 (1.25 cho vi) | 700 · heading | H1 page, số stat, H2 CTA band |
text-heading |
26px → 30px | 1.3 | 600 · heading | H2 (section, markdown ##) |
text-subheading |
20px | 1.4 | 600 · heading | H3, title card, markdown ### |
text-prose |
18px | 1.75 | 400 · sans | Long-form: case study, note, bio, sub-line hero |
text-body |
16px | 1.6 | 400/500 · sans | UI và copy ngắn, mô tả card |
text-meta |
14px | 1.5 | 500 · sans (mono cho date, version, chip) | Meta, caption, tag, chip |
text-eyebrow (v2) |
12px | 1.4 | 500 · mono, uppercase, tracking 0.08em, --primary hoặc --subtle-foreground |
Eyebrow ("CASE STUDY", "OUTCOME"), label stat. Tối đa 4 từ; không bao giờ thành câu |
admin text-ui-title |
20px | 1.4 | 600 · sans | Title page admin |
admin text-ui |
14px | 1.5 | 400/500 · sans | Toàn bộ UI admin, table, input |
admin text-ui-meta |
12.5px | 1.4 | 500 · sans | Timestamp, count, kbd |
Markdown #### trở xuống render thành text-prose 600 (không size mới). Post và case study bắt đầu từ ##; title page là H1 duy nhất.
Rule long-form (component Prose).
- Measure: max
66ch(≈ 680px ở Inter 18px); không bao giờ rộng hơn 75ch, không bao giờ hẹp hơn 45ch trên mobile (padding page 20px). - Spacing: paragraph cách
1.1em;##nhận2.2emtrên và0.6emdưới;###1.6em/0.5em. Space trên heading luôn lớn hơn dưới, để nó dính với content của chính nó. - List:
0.4emgiữa item, bullet treo (padding-inline-start: 1.25em). - Link:
--primary, underline luôn bật trong prose (text-underline-offset: 0.2em,text-decoration-thickness: 1px, 2px lúc hover). Color không bao giờ là cue duy nhất. - Emphasis: bold 600, italic là italic thật; không paragraph ALL CAPS.
- Number:
font-variant-numeric: tabular-numstrong metric, table và date. - Quote và callout: border trái 3px, cùng size với body, text
--muted-foreground; không pull quote oversized. - Image: rộng bằng measure, caption
text-metaphía dưới.
Code.
- Block: JetBrains Mono 15px (
0.875emcủa prose 17–18px), line-height 1.65, background--muted,rounded-lg(8px), padding 16px, thanh filename optional, scroll ngang (vùng focusable vớiaria-label), toggle wrap. Max width thiết kế ~80 character. Shiki với theme light và dark chỉnh theo palette (comment dùng--subtle-foreground, ≥ 4.5:1). - Inline:
0.9em, background--muted, padding 0.15em × 0.35em,rounded-sm(4px), không đổi màu.
An toàn tiếng Việt.
- Line-height không bao giờ dưới 1.2 (heading) hoặc 1.5 (text UI); dấu chồng (
Ỗ,ặ) bị cắt nếu thấp hơn. - Letter-spacing ≥
-0.015emtrên heading,0trên body; không bao giờ track body text tiếng Việt. - Mọi container
overflow: hiddenhoặcline-clampnhậnpadding-block: 0.15emđể dấu trên và dưới không bị cắt. langđược set trên<html>theo locale, và trên mọi element hiện English fallback trong page vi.- Chuỗi vi dài hơn ~10–15%: button, nav và table header phải chứa được vi ở 360px mà không truncate.
- Chuỗi QA dùng trong mọi type story:
Hưởng ứng - Ở Đồng Tháp, những người thợ xây đã hoàn thành ngôi nhà mới. 0O 1lI {}.
3.3 Spacing, radius, elevation
| Spacing | 0 0 · 0.5 2px · 1 4px · 1.5 6px · 2 8px · 3 12px · 4 16px · 5 20px · 6 24px · 8 32px · 10 40px · 12 48px · 16 64px · 24 96px · 32 128px (base 4px) |
|---|---|
Radius (shadcn, từ --radius: 0.5rem) |
rounded-sm 4px (badge, kbd, inline code) · rounded-md 6px (input, button) · rounded-lg 8px (card, menu, code block) · rounded-xl 12px (dialog, media) · rounded-full (avatar, pill) |
| Chiều cao control | public 40px (touch target 44px trên mobile) · admin 32px mặc định, 28px compact; icon button 32/28 với hit area 44px trên touch |
| Elevation | Light | Dark | Dùng cho |
|---|---|---|---|
--shadow-0 |
none | none | Phẳng (hầu hết surface; dùng border) |
--shadow-1 |
0 1px 2px rgb(16 24 40 / .06) |
0 0 0 1px rgb(255 255 255 / .06) |
Header sticky, card resting trên band |
--shadow-2 |
0 4px 12px -2px rgb(16 24 40 / .10), 0 2px 4px -2px rgb(16 24 40 / .06) |
0 0 0 1px rgb(255 255 255 / .08), 0 8px 24px rgb(0 0 0 / .5) |
Popover, dropdown, toast, card hover (v2) |
--shadow-3 |
0 16px 40px -8px rgb(16 24 40 / .18) |
0 0 0 1px rgb(255 255 255 / .10), 0 24px 48px rgb(0 0 0 / .6) |
Dialog, command palette |
Dark mode thể hiện elevation bằng surface sáng hơn kèm hairline trong 1px, không phải chỉ bằng shadow.
3.4 Motion
| Token | Value | Dùng cho |
|---|---|---|
--dur-instant |
80ms | Press, tick checkbox |
--dur-fast |
140ms | Hover, color, focus |
--dur-base |
200ms | Menu, tooltip, toast in |
--dur-slow |
320ms | Dialog, sheet, reveal section page |
--ease-out |
cubic-bezier(0.16, 1, 0.3, 1) |
Entering |
--ease-in |
cubic-bezier(0.7, 0, 0.84, 0) |
Leaving (dùng duration 0.75×) |
--ease-in-out |
cubic-bezier(0.65, 0, 0.35, 1) |
Moving/reordering |
Rule (v2):
- Chỉ animate
opacityvàtransform(kèmbox-shadow/border-colorlúc hover). Không scroll-jacking, không parallax gắn vị trí scroll, không effect cursor-follow. - Mọi effect dưới đây là CSS-only trừ reveal và count-up, dùng một hook
IntersectionObserverdùng chung (useInView,once: true). Không animation library. - Animation ambient chạy chỉ trên hero home và CTA band, pause khi ra khỏi màn (
animation-play-state: pausedqua cùng observer) và khi tab bị ẩn.
| Effect | Ở đâu | Spec | Fallback reduced-motion |
|---|---|---|---|
| Section reveal | Section Home và About, key-facts case study, grid card | opacity 0 → 1, translateY(12px) → 0, --dur-slow (320ms) --ease-out, một lần, khi 15% visible. Grid stagger 60ms mỗi item, tối đa 6 item (item sau hiện cùng item thứ 6) |
Content hiện ngay, không transform |
| Card hover / focus-within | Card project, domain, post | Border → --border-strong, translateY(-2px), --shadow-2, title → --primary, mũi tên ↗ dịch 2px lên-phải; --dur-fast |
Chỉ đổi color và border, không lift |
| Button hover | Button primary | Background → --primary-hover; ở dark hero/CTA thì --glow-button cũng đậm hơn |
Giống vậy, không đổi glow |
| Orb | Hero (2), CTA (1) | Hình tròn 320–420px của --glow, filter: blur(80px), nổi ±24px trong 20s / 26s ease-in-out infinite |
Tĩnh, cùng vị trí |
| Migration diagram hero | Hero home | Node fade in theo thứ tự (stagger 60ms), stroke connector vẽ một lần (stroke-dashoffset, 900ms), rồi node "pulse" opacity .6 ↔ 1 mỗi 3s chỉ trên node active (v2) |
Vẽ đủ, không pulse |
| Stat count-up | Stats band | Số đếm từ 0 tới value một lần, 700ms --ease-out, tabular-nums để width không nhảy; giá trị thật nằm trong DOM từ đầu (aria-label = giá trị cuối) |
Hiện giá trị cuối |
| Tech marquee | Dưới stats | Hai track giống nhau translate −50% trong 40s linear infinite; pause lúc hover và keyboard focus; button pause/play nhìn thấy (aria-pressed); track thứ hai aria-hidden |
Một list chip tĩnh, wrap |
| Theme switch | Global | Không transition (disableTransitionOnChange) |
n/a |
| Route transition | List project ↔ case study | View Transitions API: morph ảnh cover, 320ms | Instant |
- Không bao giờ animate: body text, prose, code, table row, UI admin (admin chỉ giữ transition menu/dialog/toast từ shadcn).
@media (prefers-reduced-motion: reduce): mọi effect trên fallback như đã liệt kê; duration →0.01mstrừ fade opacity ở--dur-fast; timeline render bung hết.@media (prefers-reduced-transparency: reduce):--card-glass→--cardđặc, ẩn orb.
3.5 Breakpoints và z-index
| Breakpoint | Min width | Layout |
|---|---|---|
base |
0 | Grid 4-col, gutter 16px, padding page 20px |
sm |
640px | 4-col, type lớn hơn |
md |
768px | 8-col, gutter 24px; sidebar admin thành rail |
lg |
1024px | 12-col, gutter 32px; sidebar admin full |
xl |
1280px | Container cap 1120px (public) |
2xl |
1536px | Editor admin hiện 3 pane |
| Token z-index | Value | Dùng cho |
|---|---|---|
--z-base |
0 | Content |
--z-raised |
10 | Header table sticky, TOC sticky |
--z-header |
100 | Header site, topbar admin |
--z-sidebar |
200 | Sidebar admin mobile |
--z-dropdown |
300 | Menu, combobox, tooltip |
--z-overlay |
400 | Backdrop dialog/sheet |
--z-modal |
500 | Dialog, sheet, command palette |
--z-toast |
600 | Toast, notification realtime |
--z-skip |
700 | Skip link (khi focus) |
3.6 Theme shadcn: CSS variables (`packages/ui/src/styles/globals.css`)
shadcn/ui (Tailwind v4, style new-york, baseColor: neutral, cssVariables: true) được init trong packages/ui với setup monorepo (components.json ở cả packages/ui và apps/web). globals.css được generate sẽ bị thay bằng block dưới; component shadcn lúc đó nhận palette của mình mà không sửa màu từng component. Dùng hex thay OKLCH mặc định của shadcn để giá trị khớp đúng bảng contrast.
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.5rem;
--background: #FAFAF9; --foreground: #18181B;
--card: #FFFFFF; --card-foreground: #18181B;
--popover: #FFFFFF; --popover-foreground: #18181B;
--primary: #0F766E; --primary-foreground: #FFFFFF;
--secondary: #F2F2EF; --secondary-foreground: #18181B;
--muted: #F2F2EF; --muted-foreground: #52525B;
--accent: #F2F2EF; --accent-foreground: #18181B;
--destructive: #B42318; --destructive-foreground: #FFFFFF;
--border: #E4E4E0; --input: #8A8A93; --ring: #0F766E;
--chart-1: #0F766E; --chart-2: #1D4ED8; --chart-3: #93370D; --chart-4: #6B6B74; --chart-5: #067647;
--sidebar: #F4F4F2; --sidebar-foreground: #18181B;
--sidebar-primary: #0F766E; --sidebar-primary-foreground: #FFFFFF;
--sidebar-accent: #EAEAE6; --sidebar-accent-foreground: #18181B;
--sidebar-border: #E4E4E0; --sidebar-ring: #0F766E;
/* extensions */
--subtle-foreground: #6B6B74;
--primary-hover: #115E59;
--primary-subtle: #E3F2EF; --primary-subtle-foreground: #115E59;
--success: #067647; --success-subtle: #E6F1EC;
--warning: #93370D; --warning-subtle: #F4EBE7;
--info: #1D4ED8; --info-subtle: #E8EDFB;
--destructive-subtle: #F7E9E8;
/* v2: bands, atmosphere, domain hues */
--band: #F3F5F3; --border-strong: #CFCFC9;
--grid-line: rgb(15 118 110 / .06); --glow: rgb(15 118 110 / .10);
--glow-button: var(--shadow-1); --card-glass: rgb(255 255 255 / .72);
--hue-jade: #0F766E; --hue-jade-subtle: #E7F1F1;
--hue-amber: #8A4B08; --hue-amber-subtle: #F3EDE6;
--hue-blue: #1D4ED8; --hue-blue-subtle: #E8EDFB;
--hue-violet: #6D28D9; --hue-violet-subtle: #F0EAFB;
--hue-rose: #B4235A; --hue-rose-subtle: #F8E9EF;
--shadow-1: 0 1px 2px rgb(16 24 40 / .06);
--shadow-2: 0 4px 12px -2px rgb(16 24 40 / .10), 0 2px 4px -2px rgb(16 24 40 / .06);
--shadow-3: 0 16px 40px -8px rgb(16 24 40 / .18);
--dur-instant: 80ms; --dur-fast: 140ms; --dur-base: 200ms; --dur-slow: 320ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--z-raised: 10; --z-header: 100; --z-sidebar: 200; --z-dropdown: 300;
--z-overlay: 400; --z-modal: 500; --z-toast: 600; --z-skip: 700;
--container: 1120px; --measure: 66ch;
}
.dark {
--background: #0A0F0E; --foreground: #E8EFED;
--card: #111817; --card-foreground: #E8EFED;
--popover: #172120; --popover-foreground: #E8EFED;
--primary: #2DD4BF; --primary-foreground: #042F2B;
--secondary: #172120; --secondary-foreground: #E8EFED;
--muted: #172120; --muted-foreground: #9DB0AB;
--accent: #172120; --accent-foreground: #E8EFED;
--destructive: #F97066; --destructive-foreground: #0A0F0E;
--border: #233230; --input: #62807A; --ring: #2DD4BF;
--chart-1: #2DD4BF; --chart-2: #7AA2F7; --chart-3: #FDB022; --chart-4: #809590; --chart-5: #47CD89;
--sidebar: #0C1211; --sidebar-foreground: #E8EFED;
--sidebar-primary: #2DD4BF; --sidebar-primary-foreground: #042F2B;
--sidebar-accent: #172120; --sidebar-accent-foreground: #E8EFED;
--sidebar-border: #233230; --sidebar-ring: #2DD4BF;
--subtle-foreground: #809590;
--primary-hover: #5EEAD4;
--primary-subtle: #0F2E2B; --primary-subtle-foreground: #5EEAD4;
--success: #47CD89; --success-subtle: #1A2F25;
--warning: #FDB022; --warning-subtle: #3A2F18;
--info: #7AA2F7; --info-subtle: #1B2540;
--destructive-subtle: #3A1F1F;
--band: #0D1412; --border-strong: #2F4542;
--grid-line: rgb(45 212 191 / .08); --glow: rgb(45 212 191 / .18);
--glow-button: 0 0 24px -6px rgb(45 212 191 / .55); --card-glass: rgb(17 24 23 / .72);
--hue-jade: #2DD4BF; --hue-jade-subtle: #15322F;
--hue-amber: #FBBF24; --hue-amber-subtle: #322F19;
--hue-blue: #7AA2F7; --hue-blue-subtle: #202B36;
--hue-violet: #C4B5FD; --hue-violet-subtle: #2A2E37;
--hue-rose: #FDA4C4; --hue-rose-subtle: #322C2F;
--shadow-1: 0 0 0 1px rgb(255 255 255 / .06);
--shadow-2: 0 0 0 1px rgb(255 255 255 / .08), 0 8px 24px rgb(0 0 0 / .5);
--shadow-3: 0 0 0 1px rgb(255 255 255 / .10), 0 24px 48px rgb(0 0 0 / .6);
}
@media (prefers-reduced-motion: reduce) {
:root { --dur-instant: 0.01ms; --dur-base: 0.01ms; --dur-slow: 0.01ms; }
*, *::before, *::after { scroll-behavior: auto !important; }
.marquee-track, .orb, .diagram-pulse { animation: none !important; }
}
@media (prefers-reduced-transparency: reduce) {
:root, .dark { --card-glass: var(--card); }
.orb { display: none; }
}
3.7 Mapping Tailwind v4 (cùng file, `@theme inline`)
@theme inline biến mọi variable thành utility theo theme đang active: bg-background, text-muted-foreground, text-subtle-foreground, bg-primary-subtle, border-input, ring-ring, rounded-lg, shadow-2, font-heading, text-prose, bg-band, border-border-strong, bg-hue-amber-subtle text-hue-amber, text-display, text-eyebrow, z-modal, duration-base, ease-out.
@theme inline {
--color-background: var(--background); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card-foreground: var(--card-foreground);
--color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground);
--color-primary-hover: var(--primary-hover);
--color-primary-subtle: var(--primary-subtle);
--color-primary-subtle-foreground: var(--primary-subtle-foreground);
--color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground);
--color-subtle-foreground: var(--subtle-foreground);
--color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground);
--color-destructive-subtle: var(--destructive-subtle);
--color-success: var(--success); --color-success-subtle: var(--success-subtle);
--color-warning: var(--warning); --color-warning-subtle: var(--warning-subtle);
--color-info: var(--info); --color-info-subtle: var(--info-subtle);
--color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring);
--color-band: var(--band); --color-border-strong: var(--border-strong);
--color-card-glass: var(--card-glass);
--color-hue-jade: var(--hue-jade); --color-hue-jade-subtle: var(--hue-jade-subtle);
--color-hue-amber: var(--hue-amber); --color-hue-amber-subtle: var(--hue-amber-subtle);
--color-hue-blue: var(--hue-blue); --color-hue-blue-subtle: var(--hue-blue-subtle);
--color-hue-violet: var(--hue-violet); --color-hue-violet-subtle: var(--hue-violet-subtle);
--color-hue-rose: var(--hue-rose); --color-hue-rose-subtle: var(--hue-rose-subtle);
--color-chart-1: var(--chart-1); --color-chart-2: var(--chart-2); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px); /* 4px badges, kbd, inline code */
--radius-md: calc(var(--radius) - 2px); /* 6px buttons, inputs */
--radius-lg: var(--radius); /* 8px cards, menus, code blocks */
--radius-xl: calc(var(--radius) + 4px); /* 12px dialogs, media */
--font-heading: var(--font-be-vietnam-pro), var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-jetbrains-mono), ui-monospace, SFMono-Regular, monospace;
--text-display: clamp(2.5rem, 1.5rem + 4vw, 4rem); --text-display--line-height: 1.1;
--text-title: clamp(2.125rem, 1.6rem + 2.2vw, 3rem); --text-title--line-height: 1.2;
--text-heading: clamp(1.625rem, 1.5rem + 0.5vw, 1.875rem); --text-heading--line-height: 1.3;
--text-subheading: 1.25rem; --text-subheading--line-height: 1.4;
--text-prose: 1.125rem; --text-prose--line-height: 1.75;
--text-body: 1rem; --text-body--line-height: 1.6;
--text-meta: 0.875rem; --text-meta--line-height: 1.5;
--text-eyebrow: 0.75rem; --text-eyebrow--line-height: 1.4; --text-eyebrow--letter-spacing: 0.08em;
--text-ui-title: 1.25rem; --text-ui-title--line-height: 1.4;
--text-ui: 0.875rem; --text-ui--line-height: 1.5;
--text-ui-meta: 0.78125rem; --text-ui-meta--line-height: 1.4;
--shadow-1: var(--shadow-1); --shadow-2: var(--shadow-2); --shadow-3: var(--shadow-3);
--shadow-glow: var(--glow-button);
--ease-out: var(--ease-out); --ease-in: var(--ease-in); --ease-in-out: var(--ease-in-out);
--z-index-raised: var(--z-raised); --z-index-header: var(--z-header); --z-index-sidebar: var(--z-sidebar);
--z-index-dropdown: var(--z-dropdown); --z-index-overlay: var(--z-overlay);
--z-index-modal: var(--z-modal); --z-index-toast: var(--z-toast); --z-index-skip: var(--z-skip);
--breakpoint-sm: 40rem; --breakpoint-md: 48rem; --breakpoint-lg: 64rem;
--breakpoint-xl: 80rem; --breakpoint-2xl: 96rem;
}
@layer base {
* { @apply border-border outline-ring/50; }
body { @apply bg-background text-foreground font-sans text-body antialiased; font-feature-settings: "cv05", "cv08"; }
h1, h2, h3 { @apply font-heading; letter-spacing: -0.015em; }
:lang(vi) .text-display { line-height: 1.2; }
code, kbd, pre { font-variant-ligatures: none; }
:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
}
Size mặc định của Tailwind vẫn dùng được vì component shadcn dùng text-sm (14px, bằng text-ui / text-meta) và text-xs (12px, badge và kbd). App code và product component chỉ được dùng 11 size có tên (8 public + 3 admin); một lint rule gắn cờ text-(xs|sm|base|lg|xl|[2-9]xl) bên ngoài components/ui/, nên size public thứ 9 không lọt được. Component không bao giờ dùng class palette thô (teal-700); eslint-plugin-tailwindcss với allowlist enforce điều này.
4. Components và layout
Trong chương này: Primitive là shadcn/ui (behavior của Radix, token của mình) như list ở 4.0; product component compose chúng.
| § | Mục |
|---|---|
| 4.0 | Base layer: shadcn/ui + TanStack |
| 4.1 | Primitive contract: variant, state, a11y (packages/ui) |
| 4.2 | Public site |
| 4.3 | Admin (kiểu Linear) |
| 4.4 | Layout và quy tắc grid |
| 4.5 | UI data-driven: không hard-code content |
| 4.6 | Data fetching: Next App Router + TanStack Query |
| 4.7 | Component rich-layer (v2) |
Primitive là shadcn/ui (behavior của Radix, token của mình) như list ở 4.0; product component compose chúng. Mọi component interactive đều có default / hover / focus-visible / active / disabled kèm các state thêm được liệt kê, và một story Storybook hoặc Ladle cho từng state (light + dark).
4.0 Base layer: shadcn/ui + TanStack
Primitive là component shadcn/ui (Radix UI + Tailwind), copy vào packages/ui/src/components/ui/* bằng pnpm dlx shadcn@latest add … và mình chịu trách nhiệm phần source đó. Behavior và a11y lấy từ Radix; styling lấy từ token ở section 3. Product component (ProjectCard, Timeline, DataTable, …) nằm ở packages/ui/src/components/* và compose các component shadcn. TanStack lo data layer: Query (server state gọi Nest API), Table (admin table, headless), Form (contact form và admin editor, dùng zod qua Standard Schema), Virtual (chỉ list dài).
| shadcn component | Dùng cho | Custom |
|---|---|---|
button, button-group |
Mọi action | Size sm 28, default 32, lg 40; thêm prop loading (giữ width, aria-busy, spinner); variant link gạch chân |
input, textarea, input-group, label, field |
Mọi form | field (không phụ thuộc library) wrap field TanStack Form: FieldLabel, FieldDescription, FieldError wire vào aria-describedby / aria-invalid. Không dùng form dựa trên RHF của shadcn |
select, checkbox, radio-group, switch, toggle-group |
Input, locale switch, period picker (7d/30d/90d) | không |
popover + command |
Combobox, multi-select tag/skill (recipe combobox của shadcn), palette ⌘K (command trong dialog) |
Option creatable cho tag |
calendar + popover |
Date picker (ngày experience, ngày publish) | Mode precision theo tháng cho experience |
dialog, alert-dialog, sheet, drawer |
Modal, confirm xóa, mobile nav, admin property trên mobile | alert-dialog cho destructive; drawer (vaul) chỉ trên mobile |
dropdown-menu, context-menu |
Row action, user menu, theme menu | Gợi ý shortcut bằng kbd |
tabs |
Tab locale admin (EN/VI), editor Write/Preview | Dot completeness trên từng tab |
table |
Markup nền cho TanStack Table | Density compact 32 / default 40, sticky header |
pagination |
List notes public, admin table | Admin dùng prev/next + page size |
badge |
Tag, status, base của VersionTag |
Variant neutral, primary, success, warning, destructive, info; subtle / outline |
card |
Project card, stat card, settings group | Flat (--shadow-0), hover --shadow-1 trên link |
skeleton |
Mọi loading state | Tắt shimmer khi reduced motion |
empty |
Mọi empty state | Icon + một dòng + action |
alert |
Error state inline, NDA callout, warning "missing translation" | Variant info, warning, destructive |
sonner |
Toast, realtime notification | Theo theme; role="status"; error dùng role="alert" |
tooltip, kbd |
Icon button, gợi ý shortcut | Delay 500ms |
sidebar |
Admin shell | Collapse được thành icon rail (collapsible="icon"), [ để toggle |
breadcrumb, separator, avatar, scroll-area |
Admin topbar, case-study header, media grid | không |
navigation-menu |
Public header (desktop) | Link thường, không mega-menu |
accordion, collapsible |
Timeline highlight, TOC mobile, settings kiểu FAQ | Mở sẵn khi reduced motion |
progress |
Upload progress | Kèm text phần trăm |
chart (Recharts) |
Area chart dashboard, sparkline | Dùng --chart-*; data table ẩn cho screen reader |
resizable |
Split editor admin (write / preview) | Handle resize được bằng bàn phím |
spinner |
Loading của button, indicator background refetch | không |
Không lấy từ shadcn (custom, build trên các primitive ở trên): Prose, CodeBlock (Shiki), MarkdownEditor (CodeMirror 6), Timeline, DiffStat, VersionTag, StatusPill, SaveStatus, SectionBoundary, DataTable (TanStack Table + shadcn table), MediaLibrary.
4.1 Primitive contract: variant, state, a11y (`packages/ui`)
| Component | Variant | State thêm | A11y |
|---|---|---|---|
Button |
primary, secondary, ghost, danger, link; size sm 28, md 32, lg 40; icon |
loading (spinner, giữ width, aria-busy) |
<button> luôn có; icon-only cần aria-label; disabled dùng aria-disabled khi vẫn phải focus được vì tooltip |
Input, Textarea |
md, lg; có icon prefix/suffix |
invalid, readOnly |
<label for>; error qua aria-describedby + aria-invalid; không bao giờ lấy placeholder làm label |
Select, Combobox, MultiCombobox (tag) |
single, multi, creatable | loading, empty |
Pattern ARIA combobox, typeahead, Esc đóng và trả focus |
Checkbox, Radio, Switch |
sm, md | indeterminate (checkbox) |
Input native bên dưới; switch dùng role="switch" |
Badge / Tag |
neutral, accent, success, warning, danger; solid / subtle / outline |
removable (tag) | Status không bao giờ chỉ dựa vào màu: luôn có text label |
VersionTag |
from, to (render v1 → v2) |
n/a | Font mono; mũi tên có aria-label="to" |
Kbd |
sm | n/a | <kbd>; shortcut cũng được list trong help dialog |
Tooltip |
top/right/bottom/left | n/a | Chỉ bổ sung, mở cả khi focus, delay 500ms, Esc để dismiss |
Dialog, ConfirmDialog, Sheet |
sm 400, md 560, lg 720; sheet right/bottom |
submitting |
Focus trap, trả focus về trigger, aria-labelledby; confirm destructive khi xóa phải gõ đúng slug |
DropdownMenu, ContextMenu |
có icon, shortcut, submenu | item checked |
Roving tabindex, phím mũi tên, typeahead |
Tabs |
underline, pill |
n/a | Pattern tablist; sync URL ở admin |
Toast |
info, success, warning, danger, action |
stacked, pause khi hover | role="status" (polite) / role="alert" chỉ cho error; ≥ 5s, pause khi focus |
CommandPalette (⌘K / Ctrl K) |
public (nav, theme, locale, search post) / admin (create, jump, action) | loading, empty |
Dialog + listbox; kết quả announce qua live region |
Skeleton, EmptyState, ErrorState |
inline, block, page | retry | Skeleton aria-hidden; container aria-busy="true"; error có nút retry và request id |
Avatar |
24/32/40/96 | fallback initials | alt = tên, hoặc rỗng khi decorative |
Prose |
prose (long-form), compact (excerpt) |
n/a | Áp rule long-form ở 3.2 lên output markdown: heading có anchor link, table scroll trong region focus được |
CodeBlock |
có filename, line highlight, diff (+/-) |
copied | Nút copy có confirmation aria-live; Shiki lúc build, cả hai theme |
4.2 Public site
| Component | Ghi chú |
|---|---|
SkipLink |
Focusable đầu tiên, nhảy tới <main id="content"> |
SiteHeader |
Logo wordmark, nav từ row NavItem (seed: Work, About, Notes, Contact), nút ⌘K, LocaleSwitch, ThemeToggle. Sticky, --background mờ 80% + backdrop blur, --shadow-1 sau khi scroll. Mobile: nút menu mở Sheet full-height |
LocaleSwitch |
Segmented VI / EN; link sang cùng page ở locale kia (hoặc index của locale đó nếu thiếu), set hreflang |
ThemeToggle |
Menu light / dark / system; aria-pressed trên option đang active |
Hero |
Tên, headline, sub-line, 2 CTA (View work, Contact), availability badge |
MetricStat / DiffStat |
Value + label; variant diff hiện before → after bằng mono với gutter +/- giống một diff row. Số dùng tnum |
ProjectCard |
Cover (16:10), title, tagline, client, period, VersionTag, 3 tech tag; cả card là một link (title là link, card dùng ::after stretch), hover --shadow-1 và cover scale 1.02 (không scale khi reduced motion). confidential hiện cover dạng diagram + badge NDA |
Timeline / TimelineItem |
Rail dọc, company, role, dates (Feb 2024 - now), phase nested, highlight, tech tag. <ol> với <time datetime> |
SkillGroup |
Group theo depth (Daily / Shipped / Growing), không phải progress bar |
CaseStudyHeader |
Breadcrumb, title, tagline, meta grid (client, role, team, period, stack), cover |
SectionNav (TOC) |
Sticky trên lg+, scroll-spy với aria-current="true"; collapse thành <details> trên mobile |
PostCard, PostMeta |
Title, excerpt, date, reading time, tag |
SearchField + TagFilter |
Search là form role="search", debounce 200ms, sync URL (?q=&tag=), số kết quả trong live region |
Pagination |
Prev/next + số trang, nav aria-label="Pagination" |
ContactForm |
Name, email, subject (select), message, honeypot, submit; validate inline lúc blur, summary lúc submit; success thì thay form bằng status panel |
SiteFooter |
Social link, RSS, "Built with Next.js + NestJS, source on GitHub", hash deploy cuối cùng bằng mono |
OgImage |
Template route handler: 1200×630, --background dark, title bằng Be Vietnam Pro, VersionTag, tên + domain |
4.3 Admin (kiểu Linear)
| Component | Ghi chú |
|---|---|
AppShell |
Sidebar 240px (collapse được thành rail 56px, [ để toggle), topbar 48px, content. Mobile: sidebar là Sheet |
Sidebar |
Workspace switcher (Parker), search (⌘K), group: Dashboard, Inbox (số unread); Content: Projects, Posts, Experience, Skills; Site: Site settings (nav, section, social, contact topic), Profile, Media; Insights: Analytics, Audit log; Users (chỉ owner). Build trên shadcn sidebar; item active --sidebar-accent + thanh trái primary |
Topbar |
Breadcrumb, page action, NotificationBell, user menu |
DataTable |
TanStack Table (headless) trên shadcn table. Column sort được, resize được, ẩn được; chọn row có shift-range; thanh bulk action trượt lên; sticky header; density compact 32 / default 40; bàn phím: j/k di chuyển, x chọn, Enter mở, e edit, ⌫ xóa (confirm). Pagination phía server, filter nằm trong URL. State: loading (skeleton row), empty (kèm CTA create), no results (clear filter), error |
FilterBar |
Chip: Status is Draft, Tag is react; menu add-filter; saved view |
StatusPill |
draft (neutral, vòng rỗng), published (success, vòng đặc), archived (subtle, icon archive). Icon + text. Build trên badge |
PropertyList |
Editor key/value pane phải (status, slug, dates, tag, featured, locale completeness) giống issue property của Linear |
LocaleTabs |
EN ● / VI ○ với dot completeness; switch giữ scroll; field required còn thiếu chỉ block publish của locale đó |
MarkdownEditor |
CodeMirror 6, toolbar (heading, bold, link, code, image, callout), slash menu, paste/drop image → upload, mode split/preview/write, word count, autosave mỗi 2s idle, SaveStatus |
SaveStatus |
Saving… / Saved 12s ago / Offline, changes kept locally / Conflict: newer version (kèm compare) |
MediaUploader, MediaLibrary |
Drop zone, progress từng file + cancel (AbortController), grid có warning thiếu alt-text, select dialog |
StatCard, Sparkline, AreaChart |
Value, delta so với period trước (text + mũi tên, không chỉ màu), sparkline với <title> và data table ẩn |
ActivityFeed / AuditRow |
Avatar actor, verb, entity link, relative time với absolute trong tooltip; expander diff |
NotificationBell + realtime toast |
SSE (EventSource, section 4.6); contact message mới → toast "New message from Ana" với action Open, unread badge tăng. aria-live="polite", không bao giờ cướp focus |
RoleGate |
Ẩn hoặc disable action theo role; server vẫn enforce |
EmptyState (admin) |
Không illustration: icon, một dòng, primary action, gợi ý bàn phím |
4.4 Layout và quy tắc grid
- Public container:
max-width: var(--container)(1120px), page padding 20px → 32px (md) → 40px (lg). Prose maxvar(--measure)(66ch). Grid: 4 / 8 / 12 column, gutter 16 / 24 / 32px. - Band (v2): section
Bandfull-width (--band+border-y) xen kẽ với background thường để page dài có nhịp; content bên trong vẫn nằm trong container. Không bao giờ hai band liền nhau; không bao giờ band nằm trong prose. Atmosphere (grid/orb) chỉ ở home hero và CTA band. - Vertical rhythm: gap section 64px mobile, 96px desktop (trong band: padding 64/80px); trong một section, heading → content 24px; gap card grid 24px.
- Home và list: project grid 1 col (
base), 2 col (md), project featured span full width trênlg(cover trái 7 col, text phải 5). - Case study / post: 12-col trên
lg: content col 1–8, TOC sticky col 10–12. Media full-bleed được phép tràn ra tới width của container, không bao giờ ra viewport. - Admin: fluid; sidebar 240 + content. List là table full-width. Editor:
lg= editor + properties pane (320px);2xl= editor + live preview + properties. Không bao giờ scroll ngang cả page, chỉ region table (focus được,aria-label). - Touch: mọi target ≥ 44×44px trên coarse pointer (
@media (pointer: coarse)phóng admin control lên 40px và hit area lên 44px). - Content width theo locale: text vi dài hơn ~10–15%; button và nav phải chứa được label vi, không truncate ở 360px.
4.5 UI data-driven: không hard-code content
- Text lấy từ đâu. Content (mọi thứ ở section 1.5) lấy từ API. UI chrome lấy từ
apps/web/messages/{en,vi}.jsonquanext-intl(t('contact.send')). Không còn nguồn nào khác. Enforce bằngreact/jsx-no-literals(allowlist: punctuation,·,→) trênapps/web/app/**vàpackages/ui/src/components/**. - Component là pure. Product component nhận typed props từ
packages/contracts(HeroSection({ profile, settings })) và không bao giờ fetch. Page và section container fetch rồi pass data xuống. Fixture trongpackages/contracts/fixtures/*chỉ dùng cho story và test; seed data (section 1.5) là nguồn content thật duy nhất. - Thứ tự và visibility. Home và About render
sections.filter(s => s.isVisible).sort(bySortOrder).map(s => <SectionBoundary key={s.key} …>{registry[s.key](s)}</SectionBoundary>).registrymap từngPageSection.keysang một container component trong code. - Mọi section data-driven có 3 state ngoài happy path, wrap bởi một component,
SectionBoundary=<ErrorBoundary>(với TanStackQueryErrorResetBoundaryphía client) +<Suspense fallback={skeleton}>+ checkisEmpty:
| State | Public site | Admin |
|---|---|---|
| Loading | skeleton đúng shape section, cùng height với section thật (không layout shift). Section server-render stream vào sau Suspense |
Skeleton row/card; background refetch hiện spinner trên header, không bao giờ thay data bằng skeleton |
| Empty | Section optional (featured, notes, skills, principles) bị ẩn, kèm cả nav anchor của section. List chính (projects, notes) hiện empty với chrome message ("No notes yet") |
empty với create action và shortcut ("New post · C") |
| Error | Inline alert (chrome text "Couldn't load this section" + Retry) trong khung section; phần còn lại của page vẫn render. ISR tiếp tục serve page tốt gần nhất nếu background revalidation fail |
alert với Retry (refetch / reset) và request id từ body error của API |
| Partial translation | Page vi hiện field English với lang="en" và badge nhỏ "EN" |
Tab locale hiện warning dot; chỉ block publish khi thiếu English |
- Data quan trọng.
GET /public/site(settings, profile, nav, social link, section) cấp data cho layout. Nếu fail,app/[locale]/error.tsxrender page chrome-only tối giản ("Something went wrong", Retry); không có fallback hard-code cho tên hay headline. - SEO.
generateMetadatađọcseoTitle/seoDescriptioncủa entity, fallback sangtitle/excerpt, rồiSiteSettings. OG image render từ cùng data đó.
4.6 Data fetching: Next App Router + TanStack Query
Quy tắc nhanh: public page server-render từ API rồi cache; admin là client app chạy bằng TanStack Query.
| Surface | Cách render | Fetching | Cache / freshness |
|---|---|---|---|
| Public layout, home, about, list projects, case study, list notes (trang đầu), post | React Server Components, static hoặc ISR | apiServer() = fetch(API_URL + path, { next: { tags, revalidate: 3600 } }) trong Server Components. Không dùng TanStack trên các render này |
Tag theo entity (site, projects, project:{slug}, posts, post:{slug}). Lúc publish/update, API gọi POST /api/revalidate (secret header) → revalidateTag(...). Page cập nhật trong vài giây, không rebuild |
| Notes search + tag filter (client island) | Client component dưới list đã server-render | useQuery({ queryKey: qk.public.posts({ locale, q, tag, page }), placeholderData: keepPreviousData }); initialData = trang đầu đã server-render khi q rỗng |
staleTime: 60s; input debounce 200ms; request abort qua signal mà TanStack truyền vào queryFn |
| Contact form | Client component (TanStack Form) | useMutation({ mutationFn: submitContact }), trong đó submitContact là Server Action validate bằng zod schema dùng chung rồi gọi POST /public/contact |
Không cache. Chạy được khi tắt JS (form action = đúng Server Action đó) |
| Page-view beacon | Client | navigator.sendBeacon('/api/public/views') |
n/a |
| Admin layout | Server Component | Đọc session cookie, gọi GET /auth/me; redirect /admin/login khi 401 |
cache: 'no-store' |
| Admin page (list, editor, dashboard) | Server Component prefetch + client component | Server: queryClient.prefetchQuery(...) rồi <HydrationBoundary state={dehydrate(queryClient)}>. Client: useSuspenseQuery cùng key và queryFn, nên first paint đã có data, navigation sau là client-side |
Default bên dưới |
Default của query client (admin): staleTime: 30_000, gcTime: 5 * 60_000, refetchOnWindowFocus: true, retry: (n, err) => err.status >= 500 && n < 2 (không bao giờ retry 4xx), mutation retry: 0. Một QueryClient mỗi request phía server, một cái mỗi tab browser phía client (helper getQueryClient() theo guide TanStack Next.js).
Query key (packages/contracts/src/query-keys.ts), một factory để invalidation đoán được:
export const qk = {
public: {
site: (locale: Locale) => ['public', 'site', locale] as const,
posts: (p: PostListParams) => ['public', 'posts', p] as const,
},
admin: {
me: ['admin', 'me'] as const,
list: (entity: Entity, p: ListParams) => ['admin', entity, 'list', p] as const,
detail: (entity: Entity, id: number) => ['admin', entity, 'detail', id] as const,
all: (entity: Entity) => ['admin', entity] as const,
inbox: { unread: ['admin', 'inbox', 'unread'] as const },
stats: (range: '7d' | '30d' | '90d') => ['admin', 'stats', range] as const,
audit: (p: AuditParams) => ['admin', 'audit', p] as const,
},
};
Pattern:
| Pattern | Chỗ nào | Cách làm |
|---|---|---|
| List chạy theo URL | Mọi admin table | Sort, filter, page và page size nằm trong URL (?status=DRAFT&sort=-updatedAt&page=2). URL parse bằng zod schema ListParams → query key → TanStack Table chạy với manualPagination, manualSorting, manualFiltering; placeholderData: keepPreviousData giữ row trong lúc trang kế load |
| Optimistic update | Toggle status (draft ↔ published), reorder (sortOrder), mark message đã đọc, toggle isVisible |
onMutate: cancel query của key, snapshot, setQueryData với thay đổi. onError: restore snapshot, error toast. onSettled: invalidateQueries(qk.admin.all(entity)) |
| Write không optimistic | Create, delete/archive, upload, publish kèm validation | Hiện loading trên button; lúc success setQueryData(detail) từ response + invalidate list; navigate hoặc toast |
| Autosave | Editor | Listener onChange của TanStack Form, debounce 1.5s → useMutation(PATCH); body mang updatedAt làm version. API trả 409 nếu row bị đổi chỗ khác → SaveStatus hiện "Conflict" với "Load latest / Overwrite". Success ghi response vào qk.admin.detail |
| Realtime | Inbox, dashboard | Một EventSource('/api/admin/events') trong admin layout. On contact.created: setQueryData(qk.admin.inbox.unread, n => n + 1), invalidateQueries(qk.admin.list('messages', …)), toast sonner với "Open". On content.published: invalidate entity đó |
| Infinite + virtual list | Audit log, media library (hàng trăm đến hàng nghìn row) | useInfiniteQuery (cursor = last id) + useVirtualizer từ TanStack Virtual; fetch trang kế khi virtual row cuối visible. Admin table thường phân trang (25/50/100) và không virtualise |
| Prefetch on intent | Admin row, sidebar link | queryClient.prefetchQuery(qk.admin.detail(...)) lúc hover/focus sau 100ms |
| Error shape | Mọi API error | Nest trả { statusCode, code, message, requestId, fieldErrors? }. apiClient throw ApiError; form map fieldErrors lên field TanStack Form; boundary hiện message + requestId |
Cách dùng TanStack Form: contact form, login, mọi admin editor (profile, settings, project, post, experience, skill), và cell edit inline (nav label, sort). Field array cho row ProjectMetric và nav item. Validator là zod schema từ packages/contracts (validators: { onChange: schema, onSubmit: schema }), đúng bộ mà Nest ZodValidationPipe chạy.
4.7 Component rich-layer (v2)
Tất cả là product component trong packages/ui, nhận props (section 4.5), mỗi cái có skeleton / empty / error do SectionBoundary lo.
| Component | Data | Anatomy | State | A11y |
|---|---|---|---|---|
Atmosphere |
SiteSettings.ambientEffects |
Layer absolute: grid --grid-line đã mask + 1–2 orb; variant="hero" | "cta" |
Off → không render gì | aria-hidden, pointer-events: none; không bao giờ nằm trong Prose |
Band |
none | Wrapper section full-width với background --band và border-y |
n/a | Landmark section với aria-labelledby trỏ H2 của nó |
SectionHeader |
PageSection.title/intro, iconKey |
Icon tile (28px, bg --muted, icon --primary) + H2 (text-heading) + intro một dòng (text-body, muted) + link phải optional |
Không intro → chỉ title | Icon aria-hidden |
Eyebrow |
chrome hoặc PageSection |
Mono, text-eyebrow, uppercase, --primary |
n/a | Plain text, không phải heading |
KickerPill |
Profile.heroKicker |
Glyph prompt mono >_ + text trong pill --primary-subtle, cao 28px |
Thiếu → ẩn | Glyph prompt aria-hidden |
AccentHeadline |
Profile.headline, headlineAccent |
H1 với substring accent wrap trong <span class="text-primary"> |
Accent không tìm thấy trong headline → H1 thường (admin validate) | Một H1 mỗi page |
MigrationDiagram |
Project.versionFrom/To featured, tối đa 6 Skill |
SVG: node trái v1 · Vue 2, node phải v2 · Nuxt 3, 3–6 component node đang rewire giữa chúng, label bằng mono |
Thiếu version → hiện tên ProcessStep thành chuỗi dọc |
role="img" + aria-label build từ data ("Migration from Vue 2 to Nuxt 3: …"); text alternative bên dưới trong sr-only |
StatBand / StatItem |
ProfileStat[] |
2×2 (mobile) / 4-col (desktop): icon tile, số (text-title, heading font, --primary, tabular-nums), label uppercase mono |
< 2 stat visible → ẩn section | Số + label trong một element; count-up không đổi aria-label |
TechMarquee |
Skill[] có showInMarquee |
Chip mono (--card, --border, 28px), 2 track; mask fade mép; nút pause ở cuối bên phải |
< 6 skill → hàng tĩnh | Track hai aria-hidden; nút pause aria-pressed; tĩnh khi reduced motion |
DomainCard |
Domain + số project |
Icon tile nhuộm hue, title, description một dòng, count mono ("2 projects") góc trên phải, mép trên 2px theo hue lúc hover | 0 project → ẩn card | Cả card link tới /projects?domain=slug |
PrincipleList |
Principle[] |
List 2-col: số mono 01 - + title (600) + body |
Ẩn nếu empty | <ol> |
ProcessFlow |
ProcessStep[] |
Chuỗi dọc các step card nối bằng connector ↓; step active (cái giữa) dùng --primary-subtle và icon phát sáng |
< 3 step → ẩn | <ol>, connector aria-hidden |
FeatureCard (2×2) |
Principle hoặc ProcessStep |
Icon tile + title + body 2 dòng trên --card-glass |
n/a | Không phải link trừ khi có href |
CaseCard |
Project + Domain + metric top |
Domain pill (icon + name, nhuộm hue), icon góc ↗, title (text-subheading), tagline 2–3 dòng, dòng DiffStat (metric top), tech chip mono (tối đa 4, "+2") |
Không metric → chỉ chip; confidential → pill "NDA" | Card là một link (title link + ::after stretch); ↗ aria-hidden |
KeyFacts |
Project.keyFacts (2–4 dòng) |
Grid 2×2 card nhỏ, mỗi cái có hue dot và một câu | < 2 → ẩn | <ul> |
FactChips |
Project.factChips |
Hàng chip mono (text-meta, bg --muted) |
Empty → ẩn | <ul> |
RequirementColumns |
Project.constraints tách theo ### Functional / ### Non-functional trong md |
Hai card cạnh nhau, bullet ▹ màu --primary |
Một heading → một card | Heading vẫn là h3 |
PostCard |
Post |
Hàng card: date · minutes bằng mono, title (text-subheading) + ↗, excerpt 2 dòng, chip #tag mono |
n/a | Title là link |
CtaBand |
PageSection(HOME, cta) + SocialLink.showInCta |
Căn giữa: icon tile sparkle, H2, intro, primary button + tối đa 2 outline button; Atmosphere variant="cta" phía sau |
Không title → ẩn | Email là nút "Copy email" kèm toast; address chỉ nằm trong mailto: |
ContentProgress |
none | Thanh 2px --primary dưới header trên case study và post, width = scroll progress |
Ẩn khi reduced motion | aria-hidden (TOC mới là navigation accessible) |
5. UI spec từng page
Trong chương này: Route: public dưới
app/[locale]/(site)/…vớilocale ∈ {en, vi}(defaulten,vigợi ý tự động từAccept-Language, không bao giờ ép); admin dướiapp/admin/(cms)/…(không localize, UI bằng en; field content edit theo locale).
| § | Mục |
|---|---|
| 5.0 | Data, state và component theo từng screen |
| 5.1 | Home /[locale] (v2) |
| 5.2 | About + experience timeline /[locale]/about (v2) |
| 5.3 | List projects /[locale]/projects (v2) |
| 5.4 | Project case study /[locale]/projects/[slug] (v2) |
| 5.5 | List blog /[locale]/notes và post /[locale]/notes/[slug] (v2) |
| 5.6 | Contact /[locale]/contact (v2) |
| 5.7 | Admin dashboard /admin |
| 5.8 | Admin list/table /admin/posts (cùng pattern cho projects, skills, experience, media, inbox, audit) |
| 5.9 | Admin editor /admin/posts/[id] (projects dùng cùng shell với field section case-study) |
| 5.10 | Admin site & sections /admin/site (settings, navigation, sections, social links, contact topics) |
| 5.11 | Thay đổi admin cho v2 (nhỏ; admin layout giữ nguyên) |
Route: public dưới app/[locale]/(site)/… với locale ∈ {en, vi} (default en, vi gợi ý tự động từ Accept-Language, không bao giờ ép); admin dưới app/admin/(cms)/… (không localize, UI bằng en; field content edit theo locale).
Mọi public page: pattern <title> {page seoTitle} · {SiteSettings.siteName}, description từ CMS, canonical, hreflang alternate, OG image từ template, JSON-LD (Person trên home/about, CreativeWork trên case study, BlogPosting trên post, BreadcrumbList trên page detail). Sitemap và RSS theo locale.
5.0 Data, state và component theo từng screen
Mọi text trong wireframe bên dưới là seed data từ CV hoặc chrome string; không cái nào được gõ cứng vào component. "Ẩn" nghĩa là section và anchor của nó bị gỡ khi empty (section 4.5).
| Screen · section | Data (endpoint → props) | Render | Skeleton | Empty | Error | shadcn component |
|---|---|---|---|---|---|---|
| Layout: header, footer | GET /public/site?locale → nav, socialLinks, settings |
RSC, tag site |
n/a (thuộc shell) | Nav 0 item thì chỉ hiện logo | Page chrome error.tsx |
navigation-menu, sheet, toggle-group, dropdown-menu, button, command + dialog |
| Home · hero | site.profile, site.settings.availability* |
RSC | Title + 2 dòng + 2 button | n/a (singleton đã seed) | error.tsx (critical) |
badge, button |
| Home · featured | GET /public/projects?featured=1&limit=1 |
RSC, tag projects |
Cover + 3 dòng + 2 stat | Ẩn | Inline alert + Retry |
card, badge |
| Home · selected work | GET /public/projects?limit=3 |
RSC | 3 card skeleton | Ẩn | Inline alert |
card, badge |
| Home · hero diagram (v2, thay panel career-log của v1) | project featured versionFrom/To + tối đa 6 Skill linked |
RSC | Block hình diagram | Ẩn diagram, hero text span full width | Ẩn | card |
| Home · skills | GET /public/skills (group theo depth) |
RSC, tag skills |
3 hàng chip | Ẩn | Inline alert |
badge |
| Home · latest notes | GET /public/posts?limit=3 |
RSC, tag posts |
3 hàng | Ẩn | Inline alert |
separator |
| Home · contact CTA | PageSection(HOME, cta).title/ctaLabel |
RSC | n/a | Ẩn nếu không có title | n/a | button |
| Home · stats band (v2) | GET /public/site → profileStats (value computed resolve phía server) |
RSC, tag site |
4 tile (block icon + số + label) | Ẩn nếu < 2 visible | Inline alert |
không (custom StatBand) |
| Home · tech marquee (v2) | GET /public/skills?marquee=1 |
RSC, tag skills |
1 hàng 8 block chip | Ẩn nếu 0; hàng tĩnh nếu < 6 | Ẩn (bản decorative trùng skills) | badge, button (pause) |
| Home · domains (v2) | GET /public/domains (kèm count project published) |
RSC, tag domains |
4 card | Ẩn; card 0 project bị ẩn | Inline alert |
card |
| Home · process (v2) | GET /public/process-steps |
RSC, tag site |
2×2 card + chuỗi 5-step | Ẩn nếu < 3 step | Inline alert |
card |
| Home · CTA band (v2) | PageSection(HOME, cta) + socialLinks.filter(showInCta) |
RSC | n/a | Ẩn nếu không có title | n/a | button |
| Case study · key facts + chips (v2) | project.keyFacts, project.factChips, project.domain |
RSC (cùng request) | 2×2 block | Ẩn từng cái nếu empty | Thuộc error.tsx của page |
badge, card |
| About · intro | site.profile.bio, resumeEnId/ViId |
RSC | Avatar + 3 dòng | n/a | error.tsx |
avatar, button |
| About · timeline | GET /public/experience |
RSC | 3 item | Empty list chính: empty |
Inline alert |
accordion, badge |
| About · skills, education, principles | GET /public/skills, /education, /principles |
RSC | Chip / 1 hàng / 3 card | Ẩn | Inline alert |
badge, card |
| List projects | GET /public/projects?tag&skill |
RSC; filter là link (?skill=nuxt) re-render phía server |
4 card | empty + "Clear filters" |
Inline alert |
toggle-group, popover + command, card, badge |
| Case study | GET /public/projects/:slug |
RSC, generateStaticParams, tag project:{slug} |
Header + cover + 2 stat + paragraph | 404 qua notFound() |
Segment error.tsx |
breadcrumb, badge, alert (NDA), collapsible (TOC mobile) |
| List notes | Trang đầu RSC; search/tag qua TanStack Query | RSC + client island | 5 hàng | empty ("No notes yet" / "No results for …" + Clear) |
Inline alert + Retry (refetch) |
input-group, toggle-group, pagination, spinner |
| Post | GET /public/posts/:slug |
RSC, tag post:{slug} |
Title + meta + paragraph | 404 | Segment error.tsx |
breadcrumb, badge, button (copy), separator |
| Contact | site.settings.contact*, GET /public/contact-topics |
Page RSC, form client | n/a | Topic select ẩn nếu 0 topic | Error lúc submit: alert phía trên form, giữ input |
field, input, textarea, select, button, alert |
| Admin shell | GET /auth/me, số unread |
RSC guard + client | Sidebar skeleton | n/a | Redirect login khi 401 | sidebar, breadcrumb, dropdown-menu, avatar, sonner, command |
| Admin dashboard | GET /admin/stats?range, /admin/messages?limit=5, /admin/audit?limit=8, /admin/health |
Prefetch + useSuspenseQuery từng card |
Skeleton từng card | Từng card: "No views yet", "Inbox zero", … | alert + Retry từng card |
card, chart, toggle-group, badge, skeleton, empty |
| Admin list | GET /admin/{entity}?… |
Prefetch + TanStack Table | 10 skeleton row | empty + create action |
alert + Retry |
table, checkbox, dropdown-menu, popover + command (filter), pagination, alert-dialog, badge |
| Admin editor | GET /admin/{entity}/:id + Media, Tags, Skills |
Prefetch + TanStack Form | Form skeleton | n/a (404 → "Not found" + back) | alert + Retry; error lúc save nằm trong SaveStatus |
tabs, field, input, textarea, select, switch, calendar, popover + command, resizable, sheet/drawer (properties mobile), progress |
| Admin site & sections | GET /admin/site, /admin/nav, /admin/sections, /admin/social, /admin/contact-topics |
Prefetch + TanStack Form/Table | Form + row | empty từng list |
alert + Retry |
tabs, table, switch, field, button (move up/down) |
5.1 Home `/[locale]` (v2)
Mục đích: trong 10 giây, anh ấy là ai, ship cái gì, proof bằng số, và một cách để act. Thứ tự section mặc định (từ PageSection, admin có thể reorder/ẩn): hero → stats → marquee → featured case study → domains → selected work → how I migrate (process) → skills by depth → latest notes → CTA band. Band (--band) xen kẽ: stats+marquee, domains, process. Chú giải: ░ = grid đã mask, ◌ = orb/glow, [ ] = button, ‹› = pill.
Desktop (1440, container 1120):
┌────────────────────────────────────────────────────────────────────────────────────┐
│ ◆ parker.dev Work About Notes Contact [Search ⌘K] ‹EN|VI› ◐ │ sticky, blur
├────────────────────────────────────────────────────────────────────────────────────┤
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░◌◌◌░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ Atmosphere hero
│░ ‹>_ Senior Frontend · 5 yrs · Vue/Nuxt/TS› ┌──────────────────────────┐ ░│
│░ │ ┌────┐ ┌────┐ │ ░│
│░ I move products forward │ │ v1 │──┐ ┌──▶│ v2 │ │ ░│ MigrationDiagram
│░ without breaking them. ← accent in --primary │ │Vue2│ ├─┤ │Nuxt│ │ ░│ (nodes pulse on v2)
│░ (text-display 64px) │ └────┘ │ │ └────┘ │ ░│
│░ │ ○Pinia ○Query ○PrimeVue │ ░│
│░ Parker Vu · Senior Frontend Engineer. 5 years │ ○Tailwind ○Playwright │ ░│
│░ shipping data-dense product UIs… (text-prose) └──────────────────────────┘ ░│
│░ ● Open to senior roles · Hanoi, GMT+7 ░│
│░ [ View case studies → ]glow [ Download CV ] ░│
├════════════════════════════════════════════════════════════════════════════════════┤ band
│ ┌──┐ ┌──┐ ┌──┐ ┌──┐ │
│ │▣ │ 5+ │▣ │ 4 │▣ │ 3 │▣ │ 20+ │ StatBand
│ └──┘ YEARS └──┘ PRODUCTS └──┘ CLIENT └──┘ ASSET CLASSES │ numbers --primary
│ SHIPPING SHIPPED COUNTRIES RATED (PARTICULA) │ labels eyebrow
│ ───────────────────────────────────────────────────────────────────────────────── │
│ ‹Vue 3›‹Nuxt 3›‹TypeScript›‹Pinia›‹TanStack Query›‹PrimeVue›‹Playwright›‹Vitest› ⏸ │ TechMarquee (fades)
├════════════════════════════════════════════════════════════════════════════════════┤
│ ▣ Featured case study All work → │ SectionHeader
│ ┌───────────────────────────────────────────┐ ‹▣ Fintech / Web3› v1 → v2 │
│ │ cover 16:10 (project image or diagram) │ Particula v2 │
│ │ │ Rewriting an RWA ratings app │
│ │ │ without stopping delivery. │
│ └───────────────────────────────────────────┘ − bundle 412kB → 255kB │
│ + e2e specs 0 → 120 │
│ ‹Nuxt 3›‹TanStack›‹Playwright› │
│ Read case study ↗ │
├════════════════════════════════════════════════════════════════════════════════════┤ band
│ ▣ Domains Products I've shipped, grouped by the problem they solve │
│ ┌────────────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐ │
│ │ ▣amber 1 project │ │ ▣blue 2 projects │ │ ▣violet 1 project │ │ DomainCard ×4
│ │ Fintech / Web3 (ReFi) │ │ Enterprise platforms │ │ E-commerce tooling │ │ (3+1 or 4-col)
│ │ RWA ratings, carbon… │ │ Samsung SDS ops tools… │ │ Cart migrations across │ │
│ └────────────────────────┘ └────────────────────────┘ └────────────────────────┘ │
├════════════════════════════════════════════════════════════════════════════════════┤
│ ▣ Selected work All projects → │
│ ┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────┐│
│ │‹▣ Fintech› ↗ │ │‹▣ E-commerce› ↗ │ │‹▣ Enterprise› ‹NDA› ↗││ CaseCard ×3
│ │ Particula v2 │ │ Cart migration tool │ │ MSP Advanced ││ hover: lift, title
│ │ Nuxt 3 rewrite of… │ │ Flask APIs + Vue 2 UI… │ │ Re-platformed MSP… ││ → primary
│ │ − bundle 412→255kB │ │ + carts 4 platforms │ │ + team 15 · KR ││
│ │ ‹Nuxt 3›‹TanStack›‹+2› │ │ ‹Flask›‹Vue 2›‹Pinia› │ │ ‹Vue 2›‹Vuex› ││
│ └──────────────────────────┘ └──────────────────────────┘ └──────────────────────┘│
├════════════════════════════════════════════════════════════════════════════════════┤ band
│ ‹EYEBROW: PROCESS› │
│ How I migrate a frontend ┌────────────────────────────┐ │
│ Short intro (text-prose, muted, 2 lines) │ 01 Audit · tokens, states │ │ ProcessFlow
│ ┌───────────────────┐ ┌───────────────────┐ └─────────────┬──────────────┘ │
│ │▣ Migrate in slices│ │▣ Test the contract│ ┌─────────────▼──────────────┐ │
│ │ 2-line body │ │ 2-line body │ │ 03 Strangler routes ◉glow │ │ active step
│ └───────────────────┘ └───────────────────┘ └─────────────┬──────────────┘ │
│ ┌───────────────────┐ ┌───────────────────┐ ┌─────────────▼──────────────┐ │
│ │▣ Server state ≠ UI│ │▣ Write it down │ │ 05 Cut over · delete v1 │ │
│ └───────────────────┘ └───────────────────┘ └────────────────────────────┘ │
├════════════════════════════════════════════════════════════════════════════════════┤
│ ▣ What I work with Daily ‹…› · Shipped before ‹…› · Growing ‹…› │ 3 columns
├────────────────────────────────────────────────────────────────────────────────────┤
│ ▣ Latest notes All notes → │
│ ┌────────────────────────────────────────────────────────────────────────────────┐ │
│ │ OCT 12 · 6 MIN Vue → React: what transfers, what doesn't ↗ #react #vue │ │ PostCard rows ×3
│ └────────────────────────────────────────────────────────────────────────────────┘ │
├────────────────────────────────────────────────────────────────────────────────────┤
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░◌◌◌◌◌░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ CtaBand (glow)
│ ✦ │
│ Have a product that needs to move forward? │
│ Senior frontend roles or a migration done right. │
│ [ ✉ Copy email ]glow [ in LinkedIn ] [ ⌥ GitHub ] │
├────────────────────────────────────────────────────────────────────────────────────┤
│ © 2026 Parker Vu · Hung Vu Van - Senior Frontend Work Notes CV ⌥ in ✉ · a1b2c3 │ mono footer
└────────────────────────────────────────────────────────────────────────────────────┘
Mobile (390):
┌──────────────────────────────┐
│ ◆ parker.dev ⌘K ◐ ☰ │
├──────────────────────────────┤
│░‹>_ Senior Frontend · 5 yrs›░│ atmosphere (1 orb)
│░ I move products ░│
│░ forward without ░│ text-display 40px
│░ breaking them. ░│ accent line in primary
│░ Parker Vu · Senior FE… ░│
│░ [ View case studies → ] ░│ full width
│░ [ Download CV ] ░│
│░ ┌─────────────────────────┐░│ MigrationDiagram
│░ │ v1 Vue2 ──▶ v2 Nuxt3 │░│ (compact, horizontal)
│░ └─────────────────────────┘░│
├══════════════════════════════┤ band
│ 5+ YEARS │ 4 PRODUCTS │ StatBand 2×2
│ 3 COUNTRIES │ 20+ CLASSES │
│ ‹Vue 3›‹Nuxt 3›‹TS›‹Pinia› ⏸ │ marquee
├══════════════════════════════┤
│ ▣ Featured case study │
│ [cover] │
│ ‹Fintech› Particula v2 v1→v2 │
│ − bundle 412kB → 255kB │
│ Read case study ↗ │
├══════════════════════════════┤ band
│ ▣ Domains │
│ [DomainCard] (stacked ×4) │
├──────────────────────────────┤
│ ▣ Selected work │
│ [CaseCard] (stacked ×3) │
├══════════════════════════════┤ band
│ PROCESS · How I migrate │
│ 01 → 02 → 03◉ → 04 → 05 │ vertical flow
│ [Feature] [Feature] (1 col) │
├──────────────────────────────┤
│ Skills · Notes (cards) │
├──────────────────────────────┤
│░ ✦ Have a product…? ░│ CTA band
│░ [ Copy email ] [in] [⌥] ░│
│ footer (2 lines, mono) │
└──────────────────────────────┘
Giá trị metric là placeholder đến khi Parker cung cấp số thật (1.4 item 1). Stat không có số thật thì giữ ẩn (isVisible=false), không bịa.
5.2 About + experience timeline `/[locale]/about` (v2)
Section: intro tách (avatar card + bio) → stats strip (tái dùng StatBand, compact) → timeline với year rail → principles (list 01 -) → skills theo depth → education → card download CV.
Desktop:
┌────────────────────────────────────────────────────────────────────────────────────┐
│ header │
├────────────────────────────────────────────────────────────────────────────────────┤
│ ‹EYEBROW: ABOUT› │
│ ┌─────────────────────┐ Hi, I'm Parker (Hung Vu Van). (text-title H1) │
│ │ avatar 1:1 │ Bio, 2–3 paragraphs, text-prose, 66ch. │
│ │ rounded-xl │ │
│ │ Hanoi · GMT+7 │ ‹Vietnamese› ‹English› ‹Works with KR · US · UK teams› │
│ │ [Download CV ↓] │ │
│ └─────────────────────┘ │
├════════════════════════════════════════════════════════════════════════════════════┤ band
│ 5+ YEARS · 4 PRODUCTS · 3 COUNTRIES · 20+ ASSET CLASSES (compact StatBand) │
├════════════════════════════════════════════════════════════════════════════════════┤
│ ▣ Experience │
│ 2024 ─ NOW ●━━ Kobizo · Frontend Developer ‹▣ Fintech› Case study ↗ │
│ (sticky) ┃ Particula: main frontend, led v1 → v2. │
│ ┃ ▹ highlight ▹ highlight ▹ highlight │
│ ┃ ‹Vue 3›‹Nuxt 3›‹PrimeVue›‹TanStack›‹Playwright› │
│ 2022 ─ 2024 ●━━ CMC Global · Frontend Developer ‹▣ Enterprise› ‹NDA› │
│ ┃ Samsung SDS: MSP Advanced, MSP App Expansion │
│ 2021 ─ 2022 ●━━ LitGroup · Fullstack Developer ‹▣ E-commerce› │
│ ┣ Frontend · 2022 · Vue 2, TS, Pinia │
│ ┗ Backend · 2021 · Flask, Laravel, store APIs │
├════════════════════════════════════════════════════════════════════════════════════┤ band
│ ‹EYEBROW: PRINCIPLES› How I work │
│ 01 - Migrate in slices 02 - Test the contract, not the pixels │
│ 03 - Server state is not UI state 04 - Write the decision down │
├────────────────────────────────────────────────────────────────────────────────────┤
│ ▣ Skills DAILY ‹…› SHIPPED BEFORE ‹…› GROWING ‹…› │
│ ▣ Education Hanoi University of Industry · Computer Science · 2017–2021 │
└────────────────────────────────────────────────────────────────────────────────────┘
Mobile:
┌──────────────────────────────┐
│ ABOUT │
│ [avatar 96] Hi, I'm Parker │
│ Bio (prose) │
│ ‹VI›‹EN›‹KR·US·UK teams› │
│ [ Download CV ↓ ] │
├══════════════════════════════┤
│ StatBand 2×2 │
├══════════════════════════════┤
│ ● 2024 - now │ year on top of each item
│ ┃ Kobizo · Frontend Dev │
│ ┃ ▹ … ▹ … ‹chips› │ highlights in accordion
│ ● 2022 - 2024 CMC Global │
│ ● 2021 - 2022 LitGroup │
├══════════════════════════════┤
│ 01 - … 02 - … (1 col) │
│ Skills · Education │
└──────────────────────────────┘
5.3 List projects `/[locale]/projects` (v2)
Header với eyebrow + H1 + intro, một hàng filter domain (domain pill kèm count, URL ?domain=) và một tech combobox; rồi một CaseCard featured rộng, rồi grid 2-col (desktop). Mỗi card hiện domain pill, ↗, DiffStat top, chip.
Desktop:
┌────────────────────────────────────────────────────────────────────────────────────┐
│ header │
├────────────────────────────────────────────────────────────────────────────────────┤
│ ‹EYEBROW: WORK› │
│ Case studies (text-title) │
│ Four products I moved forward, 2021 - now. (text-prose) │
│ ‹All 4› ‹▣ Fintech 1› ‹▣ Enterprise 2› ‹▣ E-commerce 1› [Tech: any ▾] │
├────────────────────────────────────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────────────────────────────────────────────┐ │
│ │ ┌───────────────────────────────┐ ‹▣ Fintech / Web3› v1 → v2 ↗ │ │ featured, wide
│ │ │ cover 16:10 │ Particula v2 │ │
│ │ └───────────────────────────────┘ Tagline (2 lines) · Kobizo · 2024 - now │ │
│ │ − bundle 412→255kB + specs 0→120 │ │
│ └────────────────────────────────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────┐ ┌───────────────────────────────────────┐ │
│ │‹▣ E-commerce› ↗ │ │‹▣ Enterprise› ‹NDA› ↗ │ │
│ │ Cart migration tool │ │ MSP Advanced (Samsung SDS) │ │
│ │ Flask APIs + Vue 2 UI for… │ │ Re-platformed MSP App logic… │ │
│ │ + platforms 4 · LitGroup · 2021–22 │ │ team 15 · CMC Global · 2022–24 │ │
│ │ ‹Flask›‹Laravel›‹Vue 2›‹Pinia› │ │ ‹Vue 2›‹Vuex› │ │
│ └───────────────────────────────────────┘ └───────────────────────────────────────┘ │
│ Empty filter: empty state "No case studies for Svelte" + [Clear filters] │
└────────────────────────────────────────────────────────────────────────────────────┘
Mobile: filter pill scroll ngang (có fade mép), tech combobox bên dưới, card xếp chồng, featured card = card thường với cover ở trên.
5.4 Project case study `/[locale]/projects/[slug]` (v2)
Thứ tự: back link → pill (domain, NDA) → H1 + tagline → meta grid (label eyebrow mono) → cover → key facts 2×2 → Outcome stats → từng section có eyebrow (SUMMARY Problem, LIMITS Constraints, SCOPE Requirements thành hai card, HOW Approach + diagram, TRADE-OFFS Key decisions thành card, RESULT Outcome, RETRO What I'd do differently) → fact chips → card "More in --background thường.
Desktop:
┌────────────────────────────────────────────────────────────────────────────────────┐
│ header ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ (2px reading progress) │
├────────────────────────────────────────────────────────────────────────────────────┤
│░ ← All case studies ░│ faint grid
│░ ‹▣ Fintech / Web3› ‹v1 → v2› ░│
│░ Particula v2: Vue 2 → Nuxt 3 (text-title) ░│
│░ Rewriting an RWA ratings app without stopping delivery. (text-prose) ░│
│░ CLIENT ROLE TEAM PERIOD STACK ░│ eyebrow labels
│░ Particula Main frontend 6 Feb 2024 - now Nuxt 3 · TanStack · … ░│
├────────────────────────────────────────────────────────────────────────────────────┤
│ ┌────────────────────────────── cover 21:9 (rounded-xl) ─────────────────────────┐ │
│ └────────────────────────────────────────────────────────────────────────────────┘ │
├───────────────────────────────────────────────────────────┬────────────────────────┤
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │ ON THIS PAGE │
│ │● Strangler migration: │ │● TanStack Query replaced│ │ ▌Key facts │ KeyFacts 2×2
│ │ Nuxt 3 beside Vue 2 │ │ Axios as server state │ │ Outcome │
│ └─────────────────────────┘ └─────────────────────────┘ │ Problem │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │ Requirements │
│ │● Core component library │ │● 120 Playwright specs │ │ Approach │
│ └─────────────────────────┘ └─────────────────────────┘ │ Decisions │
│ RESULT │ Retro │
│ ## Outcome ┌ − bundle 412→255kB ┐ ┌ + specs 0→120 ┐ │ │
│ SUMMARY │ STACK │
│ ## Problem prose 18px/1.75, 66ch, ▹ bullets │ ‹Nuxt 3›‹Vue 3›… │
│ SCOPE │ │
│ ## Requirements │ Live site ↗ │
│ ┌ Functional ─────────────┐ ┌ Non-functional ─────────┐ │ │
│ │ ▹ ratings dashboards │ │ ▹ no stale ratings │ │ │
│ └─────────────────────────┘ └─────────────────────────┘ │ │
│ HOW · ## Approach prose + diagram (figure + caption) │ │
│ TRADE-OFFS · ## Key decisions │ │
│ ┌ Axios → TanStack Query ─────────────────────────────┐ │ │
│ │ OPTIONS · CHOICE · WHY · COST (eyebrow labels) │ │ │
│ └─────────────────────────────────────────────────────┘ │ │
│ RETRO · ## What I'd do differently │ │
│ ‹fact chip›‹fact chip›‹fact chip› │ │
├───────────────────────────────────────────────────────────┴────────────────────────┤
│ More in Fintech / Web3 [CaseCard] [CaseCard] │
│ ← MSP Advanced Cart migration tool → │
└────────────────────────────────────────────────────────────────────────────────────┘
Mobile: meta grid thành 2 column; key facts 1 column; TOC thành collapsible sticky "On this page ▾" dưới header; requirement card xếp chồng.
5.5 List blog `/[locale]/notes` và post `/[locale]/notes/[slug]` (v2)
List: eyebrow WRITING + H1 + intro → search + tag chip → group theo năm các hàng PostCard (card với OCT 12, 2026 · 6 MIN mono, title + ↗, excerpt, chip #tag). Post featured (isFeatured) được card rộng hơn, cover nằm trên đầu list.
List desktop:
┌────────────────────────────────────────────────────────────────────────────────────┐
│ WRITING │
│ Notes [⌕ Search notes… ⌘K ] │
│ Things I learned shipping frontends. │
│ ‹all 12› ‹#vue› ‹#react› ‹#nuxt› ‹#testing› ‹#architecture› │
├────────────────────────────────────────────────────────────────────────────────────┤
│ 2026 ──────────────────────────────────────────────────────────────────────────── │
│ ┌────────────────────────────────────────────────────────────────────────────────┐ │
│ │ OCT 12, 2026 · 6 MIN │ │
│ │ Vue → React: what transfers, what doesn't ↗ (subheading) │ │
│ │ Excerpt, 2 lines, text-body muted… │ │
│ │ #react #vue │ │
│ └────────────────────────────────────────────────────────────────────────────────┘ │
│ ┌ SEP 30, 2026 · 8 MIN · TanStack Query in Nuxt 3 ↗ … ───────────────────────────┐ │
└────────────────────────────────────────────────────────────────────────────────────┘
Post: cột đọc giữ nguyên (66ch, background thường). v2 thêm eyebrow NOTE · #react, reading progress bar, bullet ▹, callout với icon tile, header code block với language badge + copy, card "Key takeaways" ở cuối (từ Post.takeaways, optional), và các PostCard related.
Post desktop:
┌────────────────────────────────────────────────────────────────────────────────────┐
│ header ━━━━━━━━━━━━━━ (progress) │
├────────────────────────────────────────────────────────────┬───────────────────────┤
│ ← Notes │ │
│ NOTE · #REACT #VUE │ ON THIS PAGE │
│ Vue → React: what transfers, what doesn't (text-title) │ ▌Reactivity │
│ Oct 12, 2026 · Updated Oct 14 · 6 min · Đọc bằng tiếng Việt│ Effects │
│ ───────────────────────────────────────────────────────── │ Data fetching │
│ prose 18/1.75, 66ch │ │
│ ┌ ▣ Callout ────────────────────────────────────────────┐ │ Copy link │
│ │ Treat React state as a value you replace… │ │ │
│ └───────────────────────────────────────────────────────┘ │ │
│ ┌ components/Counter.tsx ─────────── ‹tsx› [copy] ┐ │ │
│ │ code 15px JetBrains Mono, diff lines tinted │ │ │
│ └─────────────────────────────────────────────────┘ │ │
│ ┌ KEY TAKEAWAYS ────────────────────────────────────────┐ │ │
│ │ ▹ … ▹ … ▹ … │ │ │
│ └───────────────────────────────────────────────────────┘ │ │
├────────────────────────────────────────────────────────────┴───────────────────────┤
│ Related notes [PostCard] [PostCard] [PostCard] ← Prev Next → │
└────────────────────────────────────────────────────────────────────────────────────┘
Mobile (list và post): card full width; search trên tag; tag scroll ngang; TOC collapsible; code block scroll ngang trong region focus được.
5.6 Contact `/[locale]/contact` (v2)
Layout tách trên grid mờ: trái = eyebrow, H1, intro (SiteSettings.contactIntro), note thời gian reply, channel card (Copy email, LinkedIn, GitHub, mỗi cái có icon tile), availability badge; phải = form card trên --card-glass. Success thay form bằng status card (icon tile check, message, "Back to case studies").
Desktop:
┌────────────────────────────────────────────────────────────────────────────────────┐
│░ CONTACT ┌────────────────────────────────────┐░│
│░ Let's talk about your product. │ Name * Email * │░│
│░ Senior frontend roles or a migration │ [ ] [ ] │░│
│░ done right. (text-prose) │ Topic │░│
│░ ● Open to senior roles · replies in 2 days │ [ Job opportunity ▾ ] │░│
│░ ┌───────────────────────────────────────┐ │ Message * │░│
│░ │ ▣ Email [ Copy email ] │ │ [ ] │░│
│░ ├───────────────────────────────────────┤ │ [ ] │░│
│░ │ ▣ LinkedIn linkedin.com/in/… ↗ │ │ 0 / 2000 │░│
│░ ├───────────────────────────────────────┤ │ [ Send message → ]glow │░│
│░ │ ▣ GitHub github.com/parker2808 ↗│ └────────────────────────────────────┘░│
│░ └───────────────────────────────────────┘ ░│
└────────────────────────────────────────────────────────────────────────────────────┘
Mobile: channel card trước (xếp chồng), form bên dưới, submit full-width. Validation: error inline dưới field + summary alert lúc submit; input giữ value khi error.
5.7 Admin dashboard `/admin`
KPI (7/30/90 ngày): views, unique visitors, contact messages, published posts; chart views; top pages; inbox preview (realtime); recent activity (audit); content health (draft, thiếu bản dịch vi, image không có alt).
Mobile:
┌───────────────────────────────┐
│ ☰ Dashboard 🔔3 (P)│
├───────────────────────────────┤
│ [7d][30d][90d] │
│ ┌─────────────┐┌─────────────┐│
│ │Views 2,140 ││Visitors 980 ││
│ │▲ 12% ╱╲╱ ││▲ 4% ╱╲_ ││
│ └─────────────┘└─────────────┘│
│ ┌─────────────┐┌─────────────┐│
│ │Messages 6 ││Published 2 ││
│ └─────────────┘└─────────────┘│
├───────────────────────────────┤
│ Inbox (3 new) View all │
│ ● Ana · Job opportunity · 2m │
├───────────────────────────────┤
│ Content health │
│ 2 drafts · 3 missing VI · 1 alt│
├───────────────────────────────┤
│ Activity │
└───────────────────────────────┘
Desktop:
┌────────────┬─────────────────────────────────────────────────────────┐
│ ◆ Parker ▾ │ Dashboard [7d][30d][90d] 🔔3 (P)│
│ 🔍 Search ⌘K├─────────────────────────────────────────────────────────┤
│ │ ┌──────────┐┌──────────┐┌──────────┐┌──────────┐ │
│ Dashboard │ │Views ││Visitors ││Messages ││Published │ │
│ Inbox 3 │ │2,140 ▲12%││980 ▲4% ││6 ▼1 ││2 │ │
│ ─ Content │ └──────────┘└──────────┘└──────────┘└──────────┘ │
│ Projects │ ┌───────────────────────────────────┐┌────────────────┐ │
│ Posts │ │ Views (area chart, 30d) ││ Top pages │ │
│ Experience │ │ ││ /projects/pa… │ │
│ Skills │ │ ││ /notes/vue-… │ │
│ Media │ └───────────────────────────────────┘└────────────────┘ │
│ ─ Insights │ ┌───────────────────────┐┌──────────────────────────────┐│
│ Analytics │ │ Inbox View → ││ Recent activity ││
│ Audit log │ │ ● Ana Job opp. 2m ││ Parker published "Vue → R…" ││
│ ─ │ │ Minh Freelance 1d ││ Parker updated Particula v2 ││
│ Settings │ └───────────────────────┘└──────────────────────────────┘│
│ │ Content health: 2 drafts · 3 missing VI · 1 image no alt │
└────────────┴─────────────────────────────────────────────────────────┘
Realtime: new message → toast bottom-right "New message from Ana [Open]", Inbox badge 3→4.
Realtime: message mới → toast góc dưới phải "New message from Ana [Open]", Inbox badge 3→4.
5.8 Admin list/table `/admin/posts` (cùng pattern cho projects, skills, experience, media, inbox, audit)
Mobile (table thành list các row, filter trong sheet):
┌───────────────────────────────┐
│ ☰ Posts [+ New] │
├───────────────────────────────┤
│ [🔍 Search ] [Filter (2)] [⋯] │
│ Status: Draft ✕ Tag: react ✕ │
├───────────────────────────────┤
│ ○ Vue → React: what transfers │
│ Draft · EN ● VI ○ · 2h ago │
│ ───────────────────────────── │
│ ● TanStack Query in Nuxt 3 │
│ Published · EN ● VI ● · 3d │
└───────────────────────────────┘
Desktop:
┌────────────┬─────────────────────────────────────────────────────────┐
│ sidebar │ Posts [Views ▾] [+ New post C] │
│ ├─────────────────────────────────────────────────────────┤
│ │ [🔍 Filter posts… /] Status is Draft ✕ + Filter │
│ ├──┬──────────────────────────┬──────────┬───────┬───────┬─┤
│ │☐ │ Title ↓ │ Status │ Lang │Updated│⋯│
│ ├──┼──────────────────────────┼──────────┼───────┼───────┼─┤
│ │☐ │ Vue → React: what trans… │ ○ Draft │ EN VI○│ 2h │⋯│
│ │☑ │ TanStack Query in Nuxt 3 │ ● Publ. │ EN VI │ 3d │⋯│
│ │☑ │ Playwright at Particula │ ◷ Sched. │ EN │ 5d │⋯│
│ ├──┴──────────────────────────┴──────────┴───────┴───────┴─┤
│ │ 1–25 of 42 ‹ 1 2 › │
│ │ ┌──────── 2 selected · Publish · Archive · Delete · ✕ ─┐│
└────────────┴─────────────────────────────────────────────────────────┘
Keys: j/k move · x select · Enter open · C new · / search · ⌫ delete (confirm)
Keys: j/k di chuyển · x chọn · Enter mở · C tạo mới · / search · ⌫ xóa (confirm)
5.9 Admin editor `/admin/posts/[id]` (projects dùng cùng shell với field section case-study)
Mobile (editor full screen; properties trong bottom sheet):
┌───────────────────────────────┐
│ ← Saved 4s ago [⚙] [Publish]│
├───────────────────────────────┤
│ [EN ●] [VI ○] │
│ Title │
│ Vue → React: what transfers │
│ [Write][Preview] │
│ B I H 🔗 </> 🖼 │
│ ┌───────────────────────────┐ │
│ │ markdown… │ │
│ │ │ │
│ └───────────────────────────┘ │
│ 1,240 words · 6 min │
└───────────────────────────────┘
Desktop (lg: editor + properties; 2xl: thêm live preview):
┌────────────┬────────────────────────────────────────┬────────────────┐
│ sidebar │ Posts / Vue → React Saved 4s ago │ [Preview] [Pub]│
│ ├────────────────────────────────────────┼────────────────┤
│ │ [EN ●] [VI ○ missing title, body] │ Status ○ Draft│
│ │ Title │ Slug vue-react│
│ │ Vue → React: what transfers │ Tags [react]+ │
│ │ Excerpt │ Publish date ▾│
│ │ One-sentence summary… │ Featured ☐ │
│ │ ┌ B I H1 H2 🔗 </> 🖼 callout [Split]┐│ Cover [img] │
│ │ │ ## Reactivity ││ SEO title │
│ │ │ In Vue, `ref()`… ││ SEO desc 0/160│
│ │ │ / ← slash menu: image, code, callout││ ────────────── │
│ │ │ [drop image to upload ▓▓▓░ 60% ✕] ││ History │
│ │ └─────────────────────────────────────┘│ Parker · 2h │
│ │ 1,240 words · 6 min read │ Created · 1d │
└────────────┴────────────────────────────────────────┴────────────────┘
Publish is blocked with an inline list when required fields are missing (per locale) or images lack alt.
⌘S save now · ⌘↵ publish · ⌘⇧P preview · Esc leaves (confirm if unsaved).
Publish bị block kèm list inline khi thiếu field required (theo locale) hoặc image thiếu alt. ⌘S save ngay · ⌘↵ publish · ⌘⇧P preview · Esc thoát (confirm nếu chưa save).
5.10 Admin site & sections `/admin/site` (settings, navigation, sections, social links, contact topics)
Chỗ edit mọi thứ không phải project/post/experience/skill, nên không text nào hiện ra lại cần sửa code. Tab: General · Navigation · Sections · Social · Contact. Reorder là drag-and-drop và nút "Move up / Move down" (thân thiện bàn phím và screen reader); mỗi lần move là một update sortOrder optimistic.
Mobile:
┌───────────────────────────────┐
│ ☰ Site [Save] │
├───────────────────────────────┤
│ [General][Nav][Sections] ▸ │
├───────────────────────────────┤
│ Home sections [EN][VI] │
│ ≡ Hero ● shown ⋯ │
│ ≡ Featured ● shown ⋯ │
│ ≡ Selected work ● shown ⋯ │
│ ≡ Skills ○ hidden ⋯ │
│ ≡ Latest notes ● shown ⋯ │
│ ⋯ → Move up · Move down · Edit│
└───────────────────────────────┘
Desktop:
┌────────────┬─────────────────────────────────────────────────────────┐
│ sidebar │ Site Saved 3s ago │
│ │ [General] [Navigation] [Sections] [Social] [Contact] │
│ ├─────────────────────────────────────────────────────────┤
│ │ Page [Home ▾] │
│ │ ┌──┬───────────────┬──────────────────────┬────────┬───┐ │
│ │ │≡ │ Section │ Title (EN · VI) │ Shown │ │ │
│ │ ├──┼───────────────┼──────────────────────┼────────┼───┤ │
│ │ │≡ │ hero │ (uses profile) │ [●] │ ⋯ │ │
│ │ │≡ │ featured │ Featured case study · │ [●] │ ⋯ │ │
│ │ │≡ │ work │ Selected work · Dự án │ [●] │ ⋯ │ │
│ │ │≡ │ skills │ What I work with · ○ │ [ ] │ ⋯ │ │
│ │ └──┴───────────────┴──────────────────────┴────────┴───┘ │
│ │ Edit row → sheet: title EN/VI, intro EN/VI, CTA label │
└────────────┴─────────────────────────────────────────────────────────┘
Sidebar thêm group Site: Site settings, Profile, Media. Bản thân admin UI là chrome English-only; mọi field content hiện EN và VI cạnh nhau qua tab locale.
5.11 Thay đổi admin cho v2 (nhỏ; admin layout giữ nguyên)
Admin giữ vẻ Linear điềm tĩnh: không atmosphere, band, orb, reveal hay marquee trong /admin. Nó chỉ thêm editor và check mà public site giàu hơn cần.
| Chỗ | Thay đổi | Component |
|---|---|---|
| Sidebar → Content | List mới: Stats, Domains, Process steps. Cùng pattern DataTable + sheet editor như Skills (drag + Move up/down, switch isVisible, reorder optimistic) |
table, sheet, switch |
| Stats editor | Label EN/VI, select source; value + suffix chỉ hiện với MANUAL; preview read-only của value computed cho các nguồn còn lại |
select, field, input |
| Domains editor | Name/description EN/VI, hue picker (5 swatch kèm tên, radio-group, hiện preview pill light và dark), icon picker, project count read-only |
radio-group, popover + command |
| Icon picker (dùng chung) | Grid search được của lucide icon trong allowlist; lưu iconKey; navigate bằng bàn phím (phím mũi tên trong grid, Enter chọn) |
popover, command, toggle-group |
| Profile editor | Field mới: hero kicker, headline accent (error inline nếu không phải substring của headline, theo locale) | field, input |
| Project editor | Tab mới "Card & facts": domain select, key facts (2–4 dòng, counter), fact chips (tag input, ≤ 6) | select, textarea, badge |
| Post editor | Field "Key takeaways" (≤ 5 dòng) dưới body | textarea |
| Skills table | Cột switch "Marquee" mới; header hiện "n of 6+ needed" | switch |
| Social links | Switch mới "Show in CTA" (tối đa 2, cái còn lại disable khi đã bật 2) | switch, tooltip |
| Site → General | Switch mới: "Ambient effects (grid, glow)" và "Animate tech marquee" | switch |
| Site → Sections | Key mới stats, marquee, domains, process, principles xuất hiện thành row với control reorder/ẩn sẵn có |
existing |
| Publish validation | Block publish khi bất kỳ field EN nào chứa {{, TODO, TBD hoặc lorem (site reference hiện {{TODO}} thô trên production; mình thì không được), và khi project không có domain |
List alert trong editor |
| Dashboard → Content health | Thêm dòng: "Projects without a domain", "Case studies with < 2 key facts", "Stats hidden (fewer than 2 visible)", "Marquee needs 6+ skills" | Card sẵn có |
Live preview (2xl) |
Render đúng public component (CaseCard, KeyFacts, StatBand) với draft props, light và dark (toggle trên preview header) |
toggle-group |
6. Map ngày Lab (high level)
Trong chương này: "Drill" Lab mỗi ngày build một slice, khớp topic ngày đó trong
plans/study-plan-30d-redesign.mdsection 3.
"Drill" Lab mỗi ngày build một slice, khớp topic ngày đó trong plans/study-plan-30d-redesign.md section 3. Repo: monorepo portfolio/ (apps/web Next.js, apps/api NestJS + Prisma, packages/ui shadcn + product component, packages/contracts zod schema, type, query key, fixture; docker-compose.yml).
Hai track khớp nhau thế nào:
- Backend leo thang ở section 1.6, từng bậc, từ Docker lên. Không ngày backend nào giả định concept mà ngày sau mới dạy. Mỗi bước backend nêu đúng một concept nó dạy.
- Frontend Day 1–6 build component trên fixture (chỉ story/test). Từ Day 7 mọi page đọc từ API; không page nào ship content hard-code.
- Admin endpoint mở trên
localhostthôi đến Day 16 mới gắn guard (API bind127.0.0.1lúc dev cho đến lúc đó). - Cột "Xong khi" thành Output của callout
introtrong lab.
| Ngày | Topic trong plan | Frontend slice | Backend / DB step (concept) | Xong khi |
|---|---|---|---|---|
| 1 | Inventory hệ thống UI | Monorepo; shadcn init trong packages/ui; theme CSS (3.6–3.7); font; page type specimen |
Docker: docker compose up MySQL 8 + Adminer; .env, volume, port (rung 1) |
Specimen render light/dark với QA string vi; Adminer login được |
| 2 | UI state | Story skeleton, empty, alert, SectionBoundary, StatusPill |
SQL by hand trong Adminer: CREATE TABLE, INSERT, SELECT, JOIN, primary/foreign key; rồi drop (rung 2) |
State story + day-02.sql |
| 3 | Form accessible + bàn phím | Contact form: TanStack Form + shadcn field, error inline + summary, skip link (fixture topic) |
Nest basics: nest new, module/controller/service, GET /health (rung 3) |
Check a11y của form pass; curl /health → ok |
| 4 | UI data-heavy responsive | DataTable (TanStack Table + table), list row mobile, sticky header (fixture) |
Prisma: SiteSettings, Profile, NavItem, SocialLink, PageSection; migration đầu; đọc SQL; seed (rung 4) |
Table ở 360/1280px; row đã seed thấy trong Prisma Studio |
| 5 | Overlay, focus, test | Palette dialog, alert-dialog, command, sonner; Vitest + Testing Library |
Read API: PrismaService, GET /public/site?locale (server chọn language), global exception filter với requestId (rung 5) |
Test palette xanh; /public/site trả JSON đã localize |
| 6 | Component API với TS | ProjectCard, VersionTag, DiffStat, badge variant, typed từ packages/contracts |
Validation: zod schema trong contracts + ZodValidationPipe; PATCH /admin/profile (rung 5) |
PATCH invalid → 400 với fieldErrors |
| 7 | Review tuần 1 | Home từ /public/site trong RSC: section registry, thứ tự, ẩn section empty, error.tsx |
Review: vẽ ERD từ 1.5; giải thích migration vs seed thành tiếng | Home chỉ render content từ DB; toggle isVisible trong Adminer ẩn một section |
| 8 | JS execution model | Post TOC scroll-spy (IntersectionObserver), search input debounce (fixture) |
Relations 1-to-many: Experience, Education, Principle, SkillCategory → Skill; seed từ CV (rung 6) |
Timeline seed data khớp CV |
| 9 | Promises, cancellation | apiClient + ApiError, TanStack Query provider, factory qk, signal truyền vào fetch |
Many-to-many: Project, ProjectMetric, ProjectImage, Project↔Skill; include vs select (rung 6) |
Request bị abort thấy trong DevTools; 4 project đã seed |
| 10 | TS cho frontend model | Type response public vs admin; chrome message next-intl en/vi |
Posts + tags; public list endpoint chỉ trả PUBLISHED, đã localize (rung 5–6) |
GET /public/posts?locale=vi fallback sang EN với lang |
| 11 | Vue reactivity → hooks | useDebouncedValue, useMediaQuery vs composable; list admin projects trên API thật (useSuspenseQuery) |
Write API pattern trên projects: create, update, archive; 404; slug unique → 409 (rung 7) |
Create/edit/archive một project từ admin |
| 12 | Nuxt rendering + fetching | Rendering matrix (4.6) vs Nuxt; list projects + case study trong RSC với tag | Query params: pagination, sort, filter parse bằng zod (rung 5) | rendering.md; /projects?skill=nuxt chạy được |
| 13 | State ownership, cache | List state chạy theo URL; toggle status và reorder optimistic | CRUD còn lại (posts, experience, skills, nav, sections, social, topics); reorder trong transaction (rung 7) | Toggle rollback khi ép 500 |
| 14 | Review tuần 2: performance | Lighthouse budget, next/image, font loading, bundle check |
Indexes + EXPLAIN, Prisma query log, sửa một N+1 (rung 8) |
Budget report; một query plan trước/sau |
| 15 | React mental model | About + Timeline dạng RSC, island accordion |
Users + passwords: model User, argon2, seed owner từ env, POST /auth/login tạo row Session + cookie (rung 9) |
Login set cookie httpOnly |
| 16 | State, refs, controlled inputs | MarkdownEditor (CodeMirror) + TanStack Form autosave + SaveStatus; login page |
Guards: AuthGuard, @Roles + RolesGuard, GET /auth/me, logout; admin route giờ được protect; 409 khi updatedAt stale (rung 9) |
Viewer không publish được (403); autosave sống sót qua refresh |
| 17 | Effects, cleanup, boundaries | EventSource trong admin layout → toast + invalidate inbox; cleanup lúc unmount; error.tsx từng segment |
Contact + SSE: POST /public/contact, @nestjs/throttler, event stream @Sse() (rung 10) |
Message gửi từ public page hiện live trong admin |
| 18 | App Router layouts | Segment [locale], layout (site) và admin, check session phía server, loading.tsx; UI media library với progress + cancel |
Uploads: FileInterceptor, limit size/MIME, row Media, file trên volume (rung 10) |
Upload + cancel chạy được; locale switch trên mọi page |
| 19 | Next data fetching + cache | Case study + post với generateStaticParams; route POST /api/revalidate; admin prefetch HydrationBoundary |
Publish workflow: publish set publishedAt, API gọi revalidate webhook (rung 10) |
Publish cập nhật public page trong vài giây |
| 20 | Server Actions, middleware, SEO | Contact qua mutation Server Action (chạy khi tắt JS); generateMetadata từ SEO field; OG image; sitemap; RSS |
SEO data: endpoint sitemap/slug, default SiteSettings (rung 10) |
OG preview hiện title từ DB; form chạy khi tắt JS |
| 21 | Review tuần 3: Next slice | Playwright e2e: draft → publish → public page | Ops: đủ 3 service trong Compose, health check, prisma migrate deploy (rung 11) |
docker compose up chạy cả site; e2e xanh |
| 22 | Design một admin dashboard | Dashboard card, chart, period toggle-group, content health |
Analytics: endpoint beacon PageView, aggregate GROUP BY theo ngày (rung 10) |
Dashboard hiện views thật |
| 23 | Surface data-heavy | Audit log với useInfiniteQuery + TanStack Virtual; thanh bulk action |
Audit log do service ghi; cursor pagination; bulk update trong transaction (rung 10) | 5,000 audit row đã seed scroll mượt |
| 24 | Security, release, resilience | CSP + security header, flag cookie secure lúc prod; CI (lint, test, e2e) | Hardening: check Origin, session expiry, backup mysqldump + test restore (rung 11) |
Restore từ backup chạy được; CI xanh |
| 25 | Behavioral: intro, impact | Không code: viết hero, about, profile trong admin dùng 1.1–1.2; nhập metric thật | n/a | Content lưu trong CMS (EN) |
| 26 | Behavioral: project, React gap | Viết case study Particula + "This portfolio" trong editor | n/a | 2 case study đã publish |
| 27 | Leadership + collaboration | Viết case study Samsung SDS (an toàn NDA) + cart-migration; note đầu "Vue → React" | n/a | 2 case study + 1 post đã publish |
| 28 | Mock round 1 | Audit a11y (axe, bàn phím, screen reader trên 3 page); list fix | n/a | Audit report, fix đã merge |
| 29 | Weak spots + company fit | Bản dịch vi nhập trong CMS; page 404/500; launch checklist | n/a | Lighthouse ≥ 95 trên 4 page chính; không còn badge "EN" trên page chính |
| 30 | Full mock + retro | Deploy production; retro post publish qua CMS; walkthrough demo interview | Deploy runbook | Live site + retro note |
Ghi chú cho agent thực thi:
- Ngày review và mock (7, 14, 21, 28, 30) giữ Drill theo agenda có giờ; bước build là một item có giờ trong đó.
- Budget tab Lab (210 words) nghĩa là mỗi Drill chỉ nêu slice và done-check. Spec nằm ở đây; link section, ví dụ "Spec: portfolio-design-system §5.4".
- Bước backend nên link glossary (1.6) lần đầu một term xuất hiện.
- Day 25–27 tái dùng content behavioral: các story Parker rehearsal chính là case study anh ấy publish.
Phần việc rich-layer v2, gấp vào các ngày sẵn có (không thêm ngày mới; mỗi item là một step thêm trong Drill ngày đó):
| Ngày | Frontend step thêm | Backend / DB step thêm | Xong khi |
|---|---|---|---|
| 1 | Token v2: tinted dark, --band, --border-strong, atmosphere token, domain hue, text-display, text-eyebrow trên specimen page |
n/a | Specimen hiện band, hue pill và eyebrow ở light và dark, tất cả AA |
| 2 | Story Atmosphere, Band, Eyebrow, SectionHeader; hook useInView + utility reveal với fallback reduced-motion |
n/a | Story toggle reduced motion và reduced transparency; không gì chuyển động |
| 6 | CaseCard (hover lift, domain pill, DiffStat), DomainCard, StatBand, KeyFacts, FactChips, PostCard |
n/a | Card pass parity hover bàn phím (focus-within trông như hover) |
| 7 | Thứ tự section Home v2 từ registry: stats, marquee, domains, process, CTA band | Seed key PageSection mới |
Ẩn marquee trong Adminer thì nó biến mất |
| 8 | ProcessFlow, PrincipleList, TechMarquee (nút pause) |
Table ProfileStat, ProcessStep; Principle.iconKey, Skill.showInMarquee (ôn 1-to-many) |
Stats band hiện số năm computed từ careerStartDate |
| 9 | SVG MigrationDiagram từ data project featured |
Table Domain + FK Project.domainId; column keyFacts, factChips; seed 4 domain |
GET /public/domains trả project count qua _count |
| 10 | n/a | Post.takeaways; publish validation cho {{, TODO, lorem |
Publish post có TODO → 400 với fieldErrors |
| 13 | List admin Stats/Domains/Process; icon + hue picker | CRUD cho 3 table mới | Reorder domain; thứ tự home cập nhật sau revalidate |
| 14 | Budget motion và paint: hero LCP ≤ 2.0s, orb trên composited layer, CLS 0 từ count-up và reveal | n/a | Lighthouse perf ≥ 95 khi bật ambient effects |
| 28 | Audit reduced-motion và reduced-transparency trên home, case study, post | n/a | Audit note cho thấy fallback của mọi effect đều chạy |
7. Brief để rebuild mockup (v2)
Trong chương này: Cho model đang rebuild
mockups/portfolio/.
| § | Mục |
|---|---|
| 7.1 | Rule global (áp cho mọi public screen) |
| 7.2 | Screen |
Cho model đang rebuild mockups/portfolio/. Verdict của Parker với v1 là "too bare/plain". Screen v1 (01–09) là một surface trung tính, heading nhỏ, card flat và không imagery. Screen mới phải nhìn giàu hơn trong một ánh mắt nhưng vẫn giữ mọi type rule ở 3.2 và mọi contrast pair ở 3.1. Làm theo section 2, 3, 4.7 và 5.1–5.6 của spec này. Đừng copy layout, copy hay palette xanh của site reference.
7.1 Rule global (áp cho mọi public screen)
| # | Phải khác v1 nhìn thấy được | Spec |
|---|---|---|
| G1 | Section xen kẽ giữa --background và sọc --band full-width với border-y; không chạy dài các section cùng màu |
3.1, 4.4 |
| G2 | Dark mode dùng neutral nhuộm jade (bg #0A0F0E, card #111817), không phải gray #0B0C0E của v1 |
3.1, 3.6 |
| G3 | Home hero và CTA band nằm trên grid 44px đã mask với 1–2 orb mềm (--glow); không atmosphere chỗ nào khác |
3.1b, 4.7 Atmosphere |
| G4 | Mọi section title có eyebrow uppercase mono phía trên, và trên home có icon tile 28px bên cạnh | 3.2 text-eyebrow, 4.7 SectionHeader |
| G5 | Card có hover/focus state nhìn thấy: lift −2px, --border-strong, --shadow-2, title chuyển --primary, mũi tên góc ↗. Mỗi screen capture một card ở hover state (thêm .is-hover vào card đó) |
Bảng motion 3.4 |
| G6 | Project mang domain pill (icon + name) theo hue của nó; hue chỉ lấy từ bảng 5-hue | 3.1b |
| G7 | Icon là SVG inline copy từ lucide (stroke 1.75, 16/20px). Không emoji, không icon font | 4.7 |
| G8 | Size body và prose giữ đúng như 3.2 (16/18px, 1.6/1.75). "Giàu hơn" đến từ surface, card và icon, không bao giờ từ text nhỏ hơn, nhạt hơn hay contrast thấp hơn | 3.2 |
| G9 | Content: placeholder dựa trên CV như v1 (Parker Vu / Hung Vu Van, Hanoi, parker2808, Particula v2, LitGroup cart migration, Samsung SDS MSP, This portfolio). Không phone hay email: hiện <email> chỗ nào lẽ ra có address, và nút "Copy email" copy placeholder |
1.5 |
| G10 | Mỗi screen có bản light; home, case study và post thêm bản dark (?dark=1) |
3.6 |
Spec screenshot: Playwright + system Chrome, DPR 2, full page, width 1440 (desktop) và 390 (mobile). Capture với reducedMotion: 'no-preference' nhưng đóng băng animation ở trạng thái cuối (thêm class .capture trên <html> để set animation: none !important; transition: none !important và hiện content đã reveal cùng giá trị count-up cuối), nên orb, glow và hover state nhìn thấy được nhưng ảnh là deterministic. Tên file bên dưới; lưu vào artifacts và giữ screenshot.mjs là entry point duy nhất.
7.2 Screen
| File | Screen | Phải khác v1 nhìn thấy được | Content dùng |
|---|---|---|---|
01-home-light.png (1440) |
Home, light | (1) Hero: kicker pill >_ Senior Frontend · 5 yrs · Vue/Nuxt/TS, H1 ở text-display (64px) với cụm accent bằng --primary, hero sub-line ở text-prose, primary + outline button, và MigrationDiagram (node v1 → v2 trên một card) bên phải thay vì box git log của v1; grid + orb phía sau. (2) Stats band mới (4 tile, icon + số lớn + label mono) trên một band. (3) Hàng tech marquee mới với nút pause nhìn thấy. (4) Featured case study thành CaseCard lớn với cover trông thật (gradient nhuộm + khung screenshot UI với block UI giả), domain pill, dòng DiffStat và fact chips, không phải placeholder grid flat. (5) Hàng domains 4-card mới với icon tile theo hue và project count. (6) Selected work card: domain pill, ↗, DiffStat, chip; một card ở hover state. (7) Band process mới: feature card 2×2 + flow dọc 5-step với step giữa được highlight. (8) Skills 3 column với heading theo depth. (9) Notes dạng hàng PostCard với #tags. (10) CTA band với grid, glow, icon tile, H2, nút "Copy email" + GitHub + LinkedIn, thay CTA một dòng plain của v1. (11) Footer: một dòng mono (tên, footer nav, social icon, deploy hash), như wireframe 5.1 |
Wireframe section 5.1; stats: "5+ years", "4 products shipped", "2 framework migrations", "1 enterprise platform"; domains như seed v2 ở 1.5 |
02-home-dark.png (1440) |
Home, dark | Cùng structure như 01 trong dark theme nhuộm jade; orb và grid hiện hơn (giá trị dark), primary button với --glow-button; contrast band nhìn thấy giữa #0A0F0E và #0D1412 |
Giống 01 |
03-case-study-light.png (1440) |
Case study Particula v2 | (1) Header: back link, domain pill + status pill, H1, tagline, meta grid với label eyebrow (ROLE, TEAM, TIMELINE, STACK). (2) Cover 21:9 với screenshot UI có khung, không phải grid flat. (3) Card Key facts 2×2. (4) Mỗi section bắt đầu bằng eyebrow (SUMMARY, LIMITS, HOW, TRADE-OFFS, RESULT, RETRO). (5) Requirement columns (functional / non-functional) với bullet ▹. (6) Decisions dạng card nhỏ với "Chose / Over / Because". (7) Metric before → after dạng card DiffStat. (8) Hàng Fact chips. (9) "More in Fintech / Web3" với 2 CaseCard. (10) TOC sticky bên phải với item active được đánh dấu, và reading-progress bar 2px dưới header (hiện ở ~40%) |
Section 5.4; Particula: Vue 2 → Nuxt 3, RWA ratings, ReFi, team 6 người, 2024–now |
03b-case-study-dark.png (1440, mới) |
Case study, dark | Giống 03 bản dark | Giống |
04-blog-post-light.png (1440) |
Post "Vue → React: what transfers, what doesn't" | (1) Eyebrow NOTE + date · minutes mono + tag chip trên H1. (2) Callout với icon tile, không chỉ border trái. (3) Code block với thanh header (filename + language badge + nút copy). (4) Card Key takeaways ở cuối (3–5 bullet ▹, background --primary-subtle). (5) Prev/next là hai PostCard. (6) TOC sticky + progress bar. Prose giữ 18px / 1.75 / 66ch |
Section 5.5 |
04b-blog-list-light.png (1440, mới) |
List notes | Search input với icon + hint ⌘K, tag pill kèm count, list PostCard (date · minutes, title, ↗, excerpt, #tags), một card ở hover state |
Section 5.5; 3–5 post với title dựa trên CV |
05-home-mobile-light.png (390) |
Home, mobile | Hero với kicker, text-display 40px, diagram dưới button (đã scale), stats 2×2, marquee một hàng, domain card 1 column, process chỉ chuỗi dọc (feature card bên dưới), CTA band full width với button xếp chồng; header có hamburger. Không truncated; không scroll ngang page |
Wireframe mobile section 5.1 |
06-admin-dashboard.png (1440, dark) |
Admin dashboard | Giữ layout. Chỉ đổi: card Content health list các check v2 mới ("Projects without a domain", "Case studies with < 2 key facts", "Marquee needs 6+ skills"); sidebar hiện item Stats, Domains và Process mới. Dùng token dark đã nhuộm. Không grid, orb hay band | Section 5.11 |
07-admin-projects-table.png (1440) |
Admin projects table | Giữ layout. Thêm cột Domain (hue pill) và cột count "Key facts" | Section 5.11 |
08-admin-project-editor.png (1440) |
Admin project editor | Giữ layout. Hiện tab mới "Card & facts" đang active: domain select, textarea key facts kèm counter, input fact chips, và live preview hiện đúng CaseCard |
Section 5.11 |
09-home-skeletons.png (1440) |
Home loading state | Skeleton shape khớp section v2: hero với block diagram, 4 stat tile, hàng chip marquee, featured card, 4 domain card, chuỗi process. Atmosphere vẫn hiện (nó không phải content); không shimmer khi reduced motion | Các row section 5.0 |
10-about-light.png (1440, mới) |
About + timeline | Intro tách (avatar card với city + availability pill, bio), stats strip compact, timeline với year rail và group theo company (LitGroup hiện 2 role), principles đánh số (01 -), skills theo depth, hàng education, card download CV |
Section 5.2; 4 experience row từ CV |
11-projects-light.png (1440, mới) |
List projects | Filter pill domain kèm count (hue dot), tech combobox, featured card rộng rồi grid CaseCard 2-column, một card ở hover state |
Section 5.3; 4 project |
12-contact-light.png (1440, mới) |
Contact | Layout tách trên grid mờ: trái H1 + intro + note thời gian reply + channel card (Email <email> với "Copy email", GitHub parker2808, LinkedIn); phải form trên --card-glass với topic select; một field hiện ở error state |
Section 5.6 |
Acceptance check trước khi trả: (a) mọi screen có ít nhất một band, một eyebrow và một icon tile (trừ admin); (b) body text 16px và prose 18px mọi nơi; (c) check nhanh contrast của muted text trên --band và mọi hue pill đạt 4.5:1 ở cả hai theme; (d) search mockups/portfolio/*.html tìm string dạng email, +84 và gmail không ra gì; (e) screenshot 390px không overflow ngang.