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 .btn dengan tiga varian warna, dua ukuran, dan status :disabled yang memakai opacity serta cursor: not-allowed.
  • Komponen formulir .field, .label, .input, dan .hint dengan status sukses dan galat lewat kelas, bukan pseudo-class.
  • Komponen komposit: kartu .card dengan susunan flex, navigation bar position: 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-kode yang 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:

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:

text
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 #

BagianFokus
Bagian 1: Design TokensToken primitif dan semantik, skala spasi, radius, bayangan, tipografi, dan badge pertama
Bagian 2: Buttons dan InputsKeluarga .btn tiga varian dua ukuran, .field, .input, dan status validasi
Bagian 3: Cards dan NavigationKartu flex, navigation bar sticky, tab, dan grid galeri tiga kolom
Bagian 4: Themes dan DocumentationOverride [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:

bash
node scripts/css-test/run_tests.mjs --filter component-library

Ringkasan 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.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

styles/komponen.css

Unduh berkas
css
/* =========================================================
   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
javascript
// ============================================================
// 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
javascript
// ============================================================
// 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
javascript
// ============================================================
// 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
javascript
// ============================================================
// 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" },
];