Dua proyek sebelumnya melatih kita menyusun CSS untuk satu halaman. Proyek ini melatih keterampilan yang berbeda: menyusun CSS untuk komponen yang dipakai ulang di banyak halaman dan banyak tim. Kita membangun library kecil bernama Rakit UI dalam satu file styles/komponen.css, bergerak dari lapisan paling dalam ke paling luar: design tokens, keluarga tombol dan input formulir, kartu dan navigation, lalu ditutup tema gelap dan gaya halaman dokumentasi. Urutan ini disengaja — setiap bagian hanya boleh mengonsumsi token dan komponen yang sudah ada, tidak pernah menulis nilai literal sendiri.
Tujuan Proyek #
Target yang kita capai di akhir bagian keempat:
- Dua lapis design tokens: token primitif (
--zinc-600,--teal-600) dan token semantik (--color-primary,--color-surface) yang menyembunyikan primitif di balik peran. - Keluarga tombol
.btndengan tiga varian warna, dua ukuran, dan status:disabledyang memakaiopacitysertacursor: not-allowed. - Komponen formulir
.field,.label,.input, dan.hintdengan status sukses dan galat lewat kelas, bukan pseudo-class. - Komponen komposit: kartu
.carddengan susunan flex, navigation barposition: sticky, dan tab dengan garis bawah 2px yang tidak menggeser layout. - Tema gelap sepenuhnya berbasis override token semantik pada selector
[data-theme="gelap"]— nol perubahan pada komponen. - Komponen dokumentasi: grid swatch token dan blok kode
.contoh-kodeyang tetap gelap di kedua tema.
Nilai terpenting dari proyek ini bukan jumlah komponennya, melainkan arah ketergantungannya: komponen merujuk token, dokumentasi merujuk komponen, dan tidak ada jalur balik.
Prasyarat #
Proyek ini memakai konsep dari dua Section arsitektur tanpa mengulang teorinya:
- Design tokens: Design Tokens (css-architecture) , Design Tokens (themes dan design systems) , serta Custom Properties sebagai mekanisme teknisnya.
- Sistem desain: Color Palettes , Spacing Scale , Typography Scale , dan Component States .
- Komponen dan penamaan: Component-Based CSS dan Naming Conventions .
- Tema: Dark Mode dan Theme Switching Overview .
Pengalaman dari Proyek: Website Portofolio
sangat membantu, terutama kebiasaan menulis komponen dari token. Satu perbedaan disengaja: portofolio memakai penamaan token berbahasa Indonesia (--warna-primer), sedangkan Rakit UI memakai --color-* ala pustaka publik — dua konvensi yang sama sahnya selalu konsisten dipakai di dalam satu proyek.
Struktur Folder Proyek #
Struktur berikut adalah kondisi akhir setelah empat bagian selesai:
component-library/
├── index.html halaman dokumentasi library
├── styles/
│ └── komponen.css satu file CSS utuh yang berkembang
├── assets/
│ ├── logo-komponen.svg logo pada navigation bar
│ └── ikon-tema.svg ikon tombol pengalih tema
├── tests/
│ ├── design-tokens.spec.js
│ ├── buttons-dan-inputs.spec.js
│ ├── cards-dan-navigation.spec.js
│ └── themes-dan-documentation.spec.js
└── bagian/
├── design-tokens/
├── buttons-dan-inputs/
├── cards-dan-navigation/
└── themes-dan-documentation/Sama seperti proyek sebelumnya, styles/komponen.css adalah satu file yang di-update di tiap bagian, bukan diganti. Komentar penanda Bagian N di dalam file menandai asal setiap kelompok aturan, dan override tema ditumpuk paling akhir agar menang dalam cascade tanpa menaikkan specificity.
Daftar Bagian #
| Bagian | Fokus |
|---|---|
| Bagian 1: Design Tokens | Token primitif dan semantik, skala spasi, radius, bayangan, tipografi, dan badge pertama |
| Bagian 2: Buttons dan Inputs | Keluarga .btn tiga varian dua ukuran, .field, .input, dan status validasi |
| Bagian 3: Cards dan Navigation | Kartu flex, navigation bar sticky, tab, dan grid galeri tiga kolom |
| Bagian 4: Themes dan Documentation | Override [data-theme="gelap"], swatch token, dan blok kode dokumentasi |
Menjalankan Halaman Uji Otomatis #
Setiap bagian memiliki halaman uji mandiri di bawah /tests/css/proyek/component-library/<topic>.html, misalnya uji Bagian 1
. Elemen probe di dalamnya membawa atribut data-expected berisi JSON pasangan properti dan nilai computed; harness membuka halaman di headless Chromium dan membandingkan hasil getComputedStyle() dengan nilai tersebut.
Jalankan seluruh uji proyek ini dengan satu perintah:
node scripts/css-test/run_tests.mjs --filter component-libraryRingkasan asersi per bagian juga tersedia sebagai tests/<topic>.spec.js yang dapat dibaca dan diunduh melalui bagian Berkas Sumber. Untuk pemeriksaan manual, buka halaman uji di browser, tekan F12, lalu bandingkan panel Computed dengan tabel asersi di akhir setiap bagian.
Definisikan token primitif dan semantik untuk warna, spasi, radius, bayangan, dan tipografi sebagai fondasi seluruh komponen library.
Buka → # Bagian 2: Buttons dan InputsBangun keluarga tombol dengan varian dan ukuran dari token, serta komponen input formulir lengkap dengan label, hint, dan status validasi.
Buka → # Bagian 3: Cards dan NavigationSusun kartu konten berbasis flex, navigation bar sticky, tab dengan garis bawah stabil, dan grid galeri tiga kolom untuk halaman dokumentasi.
Buka → # Bagian 4: Themes dan DocumentationAktifkan tema gelap lewat override token semantik pada selector atribut, lalu gayai halaman dokumentasi dengan swatch token dan blok kode.
Buka →Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
styles/komponen.css
Unduh berkas/* =========================================================
Proyek: Component Library (Rakit UI) — styles/komponen.css
File utuh library yang berkembang mengikuti empat bagian:
Bagian 1 (Design Tokens), Bagian 2 (Buttons dan Inputs),
Bagian 3 (Cards dan Navigation), Bagian 4 (Themes dan
Documentation). Komentar "Bagian N" menandai kelompok
aturan yang dibahas pada setiap bagian.
========================================================= */
/* Bagian 1: Design Tokens --------------------------------- */
:root {
/* Token primitif: skala netral zinc */
--zinc-50: #fafafa;
--zinc-100: #f4f4f5;
--zinc-200: #e4e4e7;
--zinc-400: #a1a1aa;
--zinc-500: #71717a;
--zinc-600: #52525b;
--zinc-700: #3f3f46;
--zinc-800: #27272a;
--zinc-900: #18181b;
/* Token primitif: skala teal untuk warna primer */
--teal-500: #14b8a6;
--teal-600: #0d9488;
--teal-700: #0f766e;
/* Token primitif: warna status */
--hijau-600: #16a34a;
--merah-600: #dc2626;
/* Token semantik: peran, bukan warna */
--color-bg: #fafafa;
--color-surface: #ffffff;
--color-text: #18181b;
--color-muted: #52525b;
--color-border: #e4e4e7;
--color-primary: #0d9488;
--color-primary-hover: #0f766e;
--color-on-primary: #ffffff;
--color-primary-soft: rgb(13 148 136 / 10%);
--color-success: #16a34a;
--color-danger: #dc2626;
/* Skala spasi (kelipatan 4px) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
/* Radius */
--radius-sm: 6px;
--radius-md: 10px;
--radius-full: 999px;
/* Bayangan */
--shadow-sm: 0 1px 2px rgb(0 0 0 / 6%);
--shadow-md: 0 4px 12px rgb(0 0 0 / 10%);
/* Tipografi */
--font-sans: system-ui, sans-serif;
--font-mono: ui-monospace, monospace;
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 20px;
--text-xl: 24px;
}
* {
margin: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: 1.5;
color: var(--color-text);
background-color: var(--color-bg);
}
.wadah {
max-width: 960px;
margin: 0 auto;
padding: var(--space-6);
}
.badge {
display: inline-flex;
align-items: center;
column-gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-full);
background-color: var(--color-primary-soft);
color: var(--color-primary);
font-size: var(--text-sm);
font-weight: 500;
}
/* Bagian 2: Buttons dan Inputs ---------------------------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
column-gap: var(--space-2);
padding: var(--space-2) var(--space-4);
font: inherit;
font-size: var(--text-base);
font-weight: 500;
border: 1px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
text-decoration: none;
transition: background-color 150ms ease, border-color 150ms ease,
color 150ms ease;
}
.btn-primer {
background-color: var(--color-primary);
color: var(--color-on-primary);
}
.btn-primer:hover {
background-color: var(--color-primary-hover);
}
.btn-kedua {
background-color: var(--color-surface);
color: var(--color-text);
border-color: var(--color-border);
}
.btn-hantu {
background-color: transparent;
color: var(--color-primary);
}
.btn-kecil {
padding: var(--space-1) var(--space-3);
font-size: var(--text-sm);
}
.btn-besar {
padding: var(--space-3) var(--space-6);
font-size: var(--text-lg);
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.field {
display: grid;
row-gap: var(--space-2);
margin-bottom: var(--space-4);
}
.label {
font-size: var(--text-sm);
font-weight: 500;
}
.input {
width: 100%;
padding: var(--space-2) var(--space-3);
font: inherit;
color: var(--color-text);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.input:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-color: var(--color-primary);
}
.input:disabled {
background-color: var(--color-bg);
color: var(--color-muted);
cursor: not-allowed;
}
.input-sukses {
border-color: var(--color-success);
}
.input-galat {
border-color: var(--color-danger);
}
.hint {
font-size: var(--text-xs);
color: var(--color-muted);
}
.hint-galat {
color: var(--color-danger);
}
/* Bagian 3: Cards dan Navigation -------------------------- */
.navbar {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
column-gap: var(--space-4);
padding: var(--space-3) var(--space-6);
background-color: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.nav-brand {
display: inline-flex;
align-items: center;
column-gap: var(--space-2);
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text);
text-decoration: none;
}
.nav-menu {
display: flex;
column-gap: var(--space-6);
list-style: none;
}
.nav-tautan {
color: var(--color-muted);
font-weight: 500;
text-decoration: none;
}
.nav-tautan:hover {
color: var(--color-primary);
}
.tabs {
display: flex;
column-gap: var(--space-2);
border-bottom: 1px solid var(--color-border);
}
.tab {
padding: var(--space-2) var(--space-3);
font: inherit;
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-muted);
background-color: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
}
.tab-aktif {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.grid-kartu {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-6);
margin-top: var(--space-6);
}
.card {
display: flex;
flex-direction: column;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.card-shadow {
box-shadow: var(--shadow-md);
}
.card-badan {
display: grid;
row-gap: var(--space-2);
padding: var(--space-6);
}
.card-judul {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-text);
}
.card-teks {
font-size: var(--text-sm);
line-height: 1.5;
color: var(--color-muted);
}
.card-aksi {
display: flex;
column-gap: var(--space-2);
margin-top: var(--space-2);
}
/* Bagian 4: Themes dan Documentation ---------------------- */
[data-theme="gelap"] {
--color-bg: #18181b;
--color-surface: #27272a;
--color-text: #fafafa;
--color-muted: #a1a1aa;
--color-border: #3f3f46;
--color-primary-soft: rgb(20 184 166 / 16%);
--shadow-md: 0 4px 12px rgb(0 0 0 / 40%);
}
.tema-sampel {
padding: var(--space-6);
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.docs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-4);
margin-top: var(--space-4);
}
.swatch {
display: block;
width: 64px;
height: 40px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.swatch-nama {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-muted);
}
.contoh-kode {
padding: var(--space-3) var(--space-4);
background-color: var(--zinc-900);
color: var(--zinc-200);
font-family: var(--font-mono);
font-size: var(--text-sm);
border-radius: var(--radius-md);
overflow-x: auto;
}
tests/buttons-dan-inputs.spec.js
Unduh berkas// ============================================================
// Spesifikasi uji — Proyek: Component Library
// Bagian 2: Buttons dan Inputs
// ============================================================
// Berkas ini merangkum asersi yang diverifikasi oleh harness
// pada halaman uji:
// /tests/css/proyek/component-library/buttons-dan-inputs.html
// Setiap entri memasangkan elemen probe dengan properti CSS
// dan nilai computed yang diharapkan.
const asersi = [
// .btn-primer: geometri dari kelas dasar, warna dari token semantik.
{ elemen: ".btn-primer", properti: "background-color", diharapkan: "rgb(13, 148, 136)" },
{ elemen: ".btn-primer", properti: "color", diharapkan: "rgb(255, 255, 255)" },
{ elemen: ".btn-primer", properti: "padding-top", diharapkan: "8px" },
{ elemen: ".btn-primer", properti: "padding-left", diharapkan: "16px" },
{ elemen: ".btn-primer", properti: "border-radius", diharapkan: "10px" },
{ elemen: ".btn-primer", properti: "font-weight", diharapkan: "500" },
// .btn-besar: ukuran menimpa padding dan font-size kelas dasar.
{ elemen: ".btn-besar", properti: "font-size", diharapkan: "20px" },
{ elemen: ".btn-besar", properti: "padding-top", diharapkan: "12px" },
// .btn-kedua: permukaan putih dengan border dari token garis.
{ elemen: ".btn-kedua", properti: "background-color", diharapkan: "rgb(255, 255, 255)" },
{ elemen: ".btn-kedua", properti: "color", diharapkan: "rgb(24, 24, 27)" },
{ elemen: ".btn-kedua", properti: "border-top-color", diharapkan: "rgb(228, 228, 231)" },
// .btn:disabled: pseudo-class pada tombol dengan atribut disabled.
{ elemen: "button.btn[disabled]", properti: "opacity", diharapkan: "0.55" },
{ elemen: "button.btn[disabled]", properti: "cursor", diharapkan: "not-allowed" },
// .input: border, radius, dan tipografi dari token.
{ elemen: ".input", properti: "border-top-color", diharapkan: "rgb(228, 228, 231)" },
{ elemen: ".input", properti: "border-radius", diharapkan: "6px" },
{ elemen: ".input", properti: "font-size", diharapkan: "16px" },
{ elemen: ".input", properti: "background-color", diharapkan: "rgb(255, 255, 255)" },
// .input-galat: border berganti ke warna bahaya.
{ elemen: ".input-galat", properti: "border-top-color", diharapkan: "rgb(220, 38, 38)" },
// .hint: teks bantu kecil dan redup.
{ elemen: ".hint", properti: "font-size", diharapkan: "12px" },
{ elemen: ".hint", properti: "color", diharapkan: "rgb(82, 82, 91)" },
// .field: grid dengan jarak baris 8px dan pemisah antar field 16px.
{ elemen: ".field", properti: "row-gap", diharapkan: "8px" },
{ elemen: ".field", properti: "margin-bottom", diharapkan: "16px" },
];
tests/cards-dan-navigation.spec.js
Unduh berkas// ============================================================
// Spesifikasi uji — Proyek: Component Library
// Bagian 3: Cards dan Navigation
// ============================================================
// Berkas ini merangkum asersi yang diverifikasi oleh harness
// pada halaman uji:
// /tests/css/proyek/component-library/cards-dan-navigation.html
// Setiap entri memasangkan elemen probe dengan properti CSS
// dan nilai computed yang diharapkan.
const asersi = [
// .navbar: menempel di atas halaman dengan z-index tinggi.
{ elemen: ".navbar", properti: "position", diharapkan: "sticky" },
{ elemen: ".navbar", properti: "top", diharapkan: "0px" },
{ elemen: ".navbar", properti: "z-index", diharapkan: "100" },
{ elemen: ".navbar", properti: "padding-top", diharapkan: "12px" },
{ elemen: ".navbar", properti: "column-gap", diharapkan: "16px" },
// .nav-menu: flex dengan jarak antar tautan dari token spasi.
{ elemen: ".nav-menu", properti: "display", diharapkan: "flex" },
{ elemen: ".nav-menu", properti: "column-gap", diharapkan: "24px" },
// .card: permukaan putih, border token, radius dari token bentuk.
{ elemen: ".card", properti: "background-color", diharapkan: "rgb(255, 255, 255)" },
{ elemen: ".card", properti: "border-top-color", diharapkan: "rgb(228, 228, 231)" },
{ elemen: ".card", properti: "border-radius", diharapkan: "10px" },
// .card-badan: padding token space-6, jarak baris token space-2.
{ elemen: ".card-badan", properti: "padding-top", diharapkan: "24px" },
{ elemen: ".card-badan", properti: "row-gap", diharapkan: "8px" },
// .card-judul dan .card-teks: tipografi dari token teks.
{ elemen: ".card-judul", properti: "font-size", diharapkan: "20px" },
{ elemen: ".card-judul", properti: "font-weight", diharapkan: "600" },
{ elemen: ".card-teks", properti: "font-size", diharapkan: "14px" },
{ elemen: ".card-teks", properti: "color", diharapkan: "rgb(82, 82, 91)" },
// .tab-aktif: garis bawah 2px primer di atas garis transparan.
{ elemen: ".tab-aktif", properti: "color", diharapkan: "rgb(13, 148, 136)" },
{ elemen: ".tab-aktif", properti: "border-bottom-width", diharapkan: "2px" },
{ elemen: ".tab-aktif", properti: "border-bottom-color", diharapkan: "rgb(13, 148, 136)" },
// .kartu-galeri: bukti perhitungan grid tiga kolom pada wadah 960px.
{ elemen: ".kartu-galeri", properti: "width", diharapkan: "288px" },
];
tests/design-tokens.spec.js
Unduh berkas// ============================================================
// Spesifikasi uji — Proyek: Component Library
// Bagian 1: Design Tokens
// ============================================================
// Berkas ini merangkum asersi yang diverifikasi oleh harness
// pada halaman uji:
// /tests/css/proyek/component-library/design-tokens.html
// Setiap entri memasangkan elemen probe dengan properti CSS
// dan nilai computed yang diharapkan.
const asersi = [
// Token tersimpan di :root (elemen html) dan terbaca apa adanya.
{ elemen: "html", properti: "--color-primary", diharapkan: "#0d9488" },
{ elemen: "html", properti: "--space-6", diharapkan: "24px" },
{ elemen: "html", properti: "--radius-md", diharapkan: "10px" },
{ elemen: "html", properti: "--text-base", diharapkan: "16px" },
// body mengonsumsi token semantik: latar zinc-50, teks zinc-900.
{ elemen: "body", properti: "background-color", diharapkan: "rgb(250, 250, 250)" },
{ elemen: "body", properti: "color", diharapkan: "rgb(24, 24, 27)" },
{ elemen: "body", properti: "font-size", diharapkan: "16px" },
// .wadah: lebar maksimum 960px dengan padding dari token spasi.
{ elemen: ".wadah", properti: "max-width", diharapkan: "960px" },
{ elemen: ".wadah", properti: "padding-top", diharapkan: "24px" },
// .badge: latar primer lunak, teks primer, bentuk pil dari token.
{ elemen: ".badge", properti: "background-color", diharapkan: "rgba(13, 148, 136, 0.1)" },
{ elemen: ".badge", properti: "color", diharapkan: "rgb(13, 148, 136)" },
{ elemen: ".badge", properti: "border-radius", diharapkan: "999px" },
{ elemen: ".badge", properti: "font-size", diharapkan: "14px" },
{ elemen: ".badge", properti: "padding-top", diharapkan: "4px" },
];
tests/themes-dan-documentation.spec.js
Unduh berkas// ============================================================
// Spesifikasi uji — Proyek: Component Library
// Bagian 4: Themes dan Documentation
// ============================================================
// Berkas ini merangkum asersi yang diverifikasi oleh harness
// pada halaman uji:
// /tests/css/proyek/component-library/themes-dan-documentation.html
// Setiap entri memasangkan elemen probe dengan properti CSS
// dan nilai computed yang diharapkan.
const asersi = [
// Tema terang tetap berlaku di luar wadah data-theme.
{ elemen: "body", properti: "background-color", diharapkan: "rgb(250, 250, 250)" },
{ elemen: ".kartu-terang", properti: "background-color", diharapkan: "rgb(255, 255, 255)" },
{ elemen: ".kartu-terang", properti: "border-top-color", diharapkan: "rgb(228, 228, 231)" },
// Wadah gelap: token semantik ditimpa, latar dan garis mengikuti.
{ elemen: ".tema-sampel", properti: "background-color", diharapkan: "rgb(24, 24, 27)" },
{ elemen: ".tema-sampel", properti: "padding-top", diharapkan: "24px" },
{ elemen: ".tema-sampel", properti: "border-top-color", diharapkan: "rgb(63, 63, 70)" },
// Komponen di dalam wadah gelap ikut berganti tanpa aturan baru.
{ elemen: ".kartu-gelap", properti: "background-color", diharapkan: "rgb(39, 39, 42)" },
{ elemen: ".teks-gelap", properti: "color", diharapkan: "rgb(161, 161, 170)" },
// Custom property terwariskan: badge gelap membaca surface baru.
{ elemen: ".badge-gelap", properti: "--color-surface", diharapkan: "#27272a" },
{ elemen: ".badge-gelap", properti: "background-color", diharapkan: "rgba(20, 184, 166, 0.16)" },
// Komponen dokumentasi: swatch token berukuran tetap.
{ elemen: ".swatch", properti: "width", diharapkan: "64px" },
{ elemen: ".swatch", properti: "height", diharapkan: "40px" },
{ elemen: ".swatch", properti: "border-radius", diharapkan: "6px" },
// .contoh-kode memakai primitif zinc: tetap gelap di kedua tema.
{ elemen: ".contoh-kode", properti: "background-color", diharapkan: "rgb(24, 24, 27)" },
{ elemen: ".contoh-kode", properti: "color", diharapkan: "rgb(228, 228, 231)" },
{ elemen: ".contoh-kode", properti: "font-size", diharapkan: "14px" },
{ elemen: ".contoh-kode", properti: "padding-top", diharapkan: "12px" },
{ elemen: ".contoh-kode", properti: "border-radius", diharapkan: "10px" },
];