# Sanggraloka One — Design System (UX/IX)

Versi 1.0 · 7 Juli 2026 · Pemilik: **agent UX/IX**.
Rujukan wajib untuk agent UX/IX (membangun) & module engineer (memakai).
Kontrak turunan dari `PM-PLAN-SANGGRALOKA-ONE.md` §8.

> **Prinsip tunggal:** design = presentasi (warna, komponen, layout). Ia
> TERPISAH dari functionality. UX/IX memiliki `src/View/` + `public/assets/`;
> module engineer hanya memanggil `UiBuilder` dan tidak pernah menulis
> HTML/CSS mentah. Kontrak antar-tim = signature method `UiBuilder` +
> nama token `tokens.css` (additive-only: boleh tambah, tak boleh hapus/ubah).

---

## 1. Aturan restyle (brief owner — dikunci)

Boleh: poles tampilan, spacing, hierarki, tipografi, kartu, sidebar, top bar,
tabel, tombol, form, responsivitas.
DILARANG: membangun ulang dari nol · menghapus fungsi · mengubah logika
bisnis · rename ID/kelas/route/atribut data penting · memindah konten.
Gaya: dashboard premium 2026 — soft shadow, sudut membulat, ruang putih
seimbang, palet tenang, KPI card jelas, tanpa animasi berlebih. Bootstrap 5
best practice bila relevan; CSS bersih & maintainable.

## 2. Token brand (mode-stable, di `tokens.css` blok `:root`)

```
--brand-dark:  #0E1A16   /* sidebar, top bar, teks di atas terang    */
--brand-green: #1B5E3B   /* primer: judul, angka KPI, aksen          */
--brand-teal:  #0E7C66   /* sekunder: ikon, garis, hover             */
--brand-amber: #D99A06   /* tersier: highlight, badge SELL, notif    */
--brand-grad:  linear-gradient(135deg,#1B5E3B 0%,#0E7C66 55%,#3E9C6B 100%);
```

Netral & permukaan (nilai default tema Terang):
```
--surface-page: #EEF2EE   --surface-card: #F4F6F1   --surface-2: #FFFFFF
--text-primary: #1A1A18   --text-secondary: #5F5E5A  --text-muted: #8A897F
--border: rgba(0,0,0,.10) --border-strong: rgba(0,0,0,.2)  --radius: 8px
--radius-card: 12px       --shadow-card: 0 2px 10px rgba(14,26,22,.05)
```

Semantik & label modul (konsisten di semua tema):
```
FIX  → teks #0C447C di #E6F1FB   (maintenance)
CREW → teks #085041 di #E1F5EE   (SDM)
SELL → teks #633806 di #FAEEDA   (sales)
success #0F6E56 · warning #854F0B · danger #A32D2D
```

Aturan: **tidak ada hex hard-coded** di `app.css` di luar blok token; komponen
memakai `var(--…)`. Ganti brand/tema = edit satu file token.

## 3. Tiga tema per-karyawan (`[data-theme]`)

`Layout` menaruh atribut `data-theme` di elemen pembungkus dari preferensi
user (dipilih di Profil Saya; default `terang`). Tiap tema hanya menimpa
nilai token — komponen & logika tidak berubah.

```css
[data-theme="terang"]{ --surface-page:#EEF2EE; --surface-2:#FFFFFF;
  --text-primary:#1A1A18; --accent:var(--brand-green); }
[data-theme="senja"]{  --surface-page:#F4ECDB; --surface-2:#FFFDF7;
  --text-primary:#3A2F1A; --text-secondary:#6B5A3A;
  --accent:#8A4B12; --accent-grad:linear-gradient(135deg,#D99A06,#B5632A); }
[data-theme="malam"]{  --surface-page:#0E1A16; --surface-2:#16241D;
  --surface-card:#1A2C23; --text-primary:#E4F0E8; --text-secondary:#9FB8AB;
  --text-muted:#7F9A8B; --border:rgba(255,255,255,.08);
  --accent:#5EC99A; --accent-grad:linear-gradient(135deg,#0E7C66,#3E9C6B); }
```

| Tema | Suasana | Cocok |
|---|---|---|
| **Rimba Terang** (`terang`) | terang, segar, ruang putih | default; shift pagi/siang, admin |
| **Sawah Senja** (`senja`) | hangat krem-amber, kalem | yang suka nuansa lembut |
| **Hutan Malam** (`malam`) | gelap hijau hutan, low-glare | MOD/shift malam, hemat mata |

Syarat: tiap tema lulus kontras WCAG AA; badge FIX/CREW/SELL terbaca di
ketiganya (di Malam pakai bg translusen + teks terang). Tema baru = tambah
blok `[data-theme="…"]`, tanpa sentuh kode modul.

## 4. Ikon — Tabler outline, tema luxury & alam

Hanya Tabler **outline** (webfont), warna & ukuran ikut `font-size`/`color`.
Alam: `ti-leaf` `ti-butterfly` `ti-plant-2` `ti-seeding` `ti-droplet`
`ti-mountain` `ti-flower` `ti-feather` `ti-tree` `ti-sun` `ti-moon-stars`.
Luxury/kelas: `ti-diamond` `ti-award` `ti-sparkles` `ti-star`.
Nav modul: `ti-layout-dashboard` `ti-checklist` `ti-calendar` `ti-users`
`ti-school` `ti-heart-handshake` `ti-tools` `ti-layout-kanban`.
Ikon dekoratif → `aria-hidden="true"`; tombol ikon-saja → `aria-label`.

## 5. Kontrak komponen `UiBuilder` (additive-only)

Method existing (JANGAN diubah signature-nya): `statGrid, card, table,
filterBar, sortLink, tableSortable, tabs, badge, statusBadge, field,
formOpen/formClose, button, printButton, costPanel, rupiah, kv,
inlineActionForm, attentionList, priorityBadge, checkboxField, severityBadge,
photoGallery, weekCalendar, healthBadge, miniBarChart, decisionCard, csvLink`.

Komponen BARU untuk modul CREW/SELL/task (ditambah UX/IX sesuai
`COMPONENT-REQUESTS.md`): `pageHeader` (hero judul + breadcrumb), `metricCard`
(KPI), `kanbanBoard` (papan task terpadu, kartu ber-badge FIX/CREW/SELL),
`calendarMonth` (overlay multi-sumber), `guestCard`, `wizardSteps` (payroll
run), `emptyState`, `sidebarNav` (grup per modul per role), `treeTable`
(tabel drill-down collapsible generik hierarki 2 level — §12).

Aturan pakai (module engineer): panggil `$this->ui->…`; **dilarang** menulis
`<div>`/`style="…"` di file `Page`. Butuh tampilan baru → catat di
`docs/COMPONENT-REQUESTS.md`, pakai placeholder (`card`+`table`) sampai final.

## 6. Anatomi layar (pola premium 2026)

- **Shell:** sidebar kiri (brand-dark) nav bergrup Bersama / SDM / Sales /
  Maintenance — item aktif ber-gradien; item hanya dirender bila role berhak
  (server-side). Top bar: search, lonceng (amber), avatar inisial.
- **Hero halaman:** panel gradien brand + breadcrumb uppercase + ikon alam
  samar (opacity ~.12).
- **KPI:** grid `metricCard` (label 11–13px muted + angka 22–24px/600 brand).
- **Listing:** WAJIB toolbar filter + kolom sortable (pola `SortSpec`/`filterBar`).
- **Kartu:** `--surface-2`, border 0.5px, radius 12px, `--shadow-card`.
- **Papan task:** kolom open/in-progress/done; kartu border-kiri warna sumber
  (FIX biru, CREW hijau, SELL amber), badge sumber, overdue merah.
- **UNIVERSAL TASK (0019, dari 8 Jul 2026):** papan task punya 2 mode via
  toggle "Papan Status" / "Kanban Workflow". Papan Status kini **5 kolom**
  seragam (Menunggu/On Progress/Done/Not Done/Cancel — label tampilan;
  nilai DB tetap open/in_progress/done/not_done/cancelled, `overdue` jadi
  flag turunan due_date bukan status). Kanban Workflow: pilih journey
  template (`App\Support\TaskJourneys`) → kolom = tahapan template + kolom
  Done — **template baru otomatis dapat mode kanban tanpa kode tambahan**
  (lihat skill `workflow-creator` + `OBJECT-STANDARD.md` §6b). Pindah kartu
  = form "→ tahap berikutnya" (bukan drag-drop — codebase ini belum punya
  mekanisme drag-drop kanban; pola sama dgn detail & pindah status di
  `MktQuotesPage`). Kartu staged tampilkan chip progres tahap (mis.
  "Indent 3/5") di baris meta `kanbanCard`. Kaitan KPI (`kpi_definition_id`)
  di-resolve otomatis per departemen (`HrKpiDefinition::resolveTaskIndicator`)
  — lihat `TaskBoardService::createTask()`/`HrKpiService::taskKpiStats()`.
- Responsif: desktop admin & tablet kiosk kantin; grid `auto-fit minmax`.

## 7. Deliverable UX/IX (Gelombang 1)

`tokens.css` (brand + 3 tema) · `app.css` refactor berlapis (token→base→
komponen→utilitas, nol hex liar) · `UiBuilder.php` (+komponen baru,
additive) · `Layout.php` (shell bermodul + `data-theme` dari preferensi) ·
`styleguide.php`+`StyleguidePage.php` (galeri semua komponen × 3 tema) ·
file ini.

## 8. Gate QC design

Nol HTML/CSS mentah & inline style di `src/Page/*` modul · semua warna dari
token · `styleguide.php` render bersih di 3 tema · brand = palet Sanggraloka
(bukan biru HotelFix) · kontras AA · signature `UiBuilder` lama utuh.

## 9. Addendum implementasi (Gelombang 1, terealisasi)

Token tambahan di `tokens.css` (di luar §2/§3, semua turunan brand — tak
mengubah nilai lama): `--surface-card`, `--radius-card`, `--shadow-card`,
`--shadow-pop`, `--accent-grad`, semantik `--bg-*`/`--border-*` (success/
warning/danger), label modul `--fix|crew|sell-{text,bg,line}`, `--on-primary`,
`--on-amber`, `--on-dark`, `--scrim`, `--print-line`, `--sidebar-{bg,text,
muted,hover}`, `--topbar-bg`. Alias kompatibilitas: `--surface-1`/`--surface-0`
(peta ke card/page) & `--text-accent`/`--bg-accent`/`--border-accent` (dipetakan
ke brand hijau menggantikan biru HotelFix) agar kelas app.css lama tetap valid.

Method `UiBuilder` BARU (signature persis, untuk module engineer):
- `pageHeader(string $title, array $crumbs=[], string $subtitle='', string $actionsHtml='', string $icon='ti-leaf'): string`
- `metricCard(string $label, string $value, array $opts=[]): string` (opts: hint|icon|accent|trend)
- `metricGrid(string $cardsHtml): string`
- `kanbanBoard(array $columns): string` (kolom: key,label,cards[]; kartu: title,href?,source=fix|crew|sell,badge?,meta?,overdue?)
- `kanbanCard(array $c): string`
- `calendarMonth(array $weeks, string $legendHtml=''): string` (chip.kind=roster|leave|task|booking)
- `guestCard(array $data): string` · `guestGrid(string $cardsHtml): string`
- `wizardSteps(array $steps, int $activeIndex): string`
- `emptyState(string $title, string $text='', string $icon='ti-mood-empty', string $actionHtml=''): string`
- `sidebarNav(array $groups): string`

Kontrak Shell: `Layout` memanggil `Acl::visibleMenu($user)` bila ada (server-
side, sumber tunggal menu per role). Selama method itu belum dibuat Shell
Integrator, `Layout` memakai fallback internal (gating per-role identik daftar
lama + ikon Tabler). Bentuk balikan yang diharapkan dari `visibleMenu()`: list
grup `['label','items'=>[['href','label','icon'?]]]` atau item langsung
`['href','label','icon'?]`; ikon boleh dikosongkan (Layout melengkapi via peta).

## 10. Addendum — Modul PENGERJAAN TEST & isi materi University (F7)

Permintaan owner (8 Jul 2026): halaman course University sebelumnya hanya
menampilkan REFERENSI path materi ("ARTICLE materi/x.md"), bukan isinya.
Ditambahkan `App\Service\HrMaterialRenderer` (module engineer) yang meresolve
entri `hr_courses.materials` (path relatif `content_root`, lihat
`docs/IMPORT-CONTENT.md`) lewat realpath-gate anti-traversal (hanya
.html/.md/.json, wajib tetap di dalam `content_root`) lalu mengembalikan
HTML/data siap tampil; file yang belum ada -> emptyState "materi belum
tersedia: {path}", bukan kegagalan keras. Config baru: `content_root`
(`config/config.php`/`config.sample.php`, default `hotelcrew/docs` sejajar
folder `sanggraloka-one/`).

Method `UiBuilder` BARU (signature persis, additive — komponen materi & test):
- `articleView(string $html): string` — bungkus HTML materi (HTML asli
  di-strip `<script>`+event handler, atau hasil konversi Markdown ringan)
  dengan tipografi konsisten (heading/paragraf/list/tabel/kode).
- `flashcardDeck(array $cards): string` — dek kartu (`front`/`back`), flip
  murni CSS (checkbox tersembunyi + `:has()`, tanpa JS wajib).
- `quizForm(array $questions, string $action, array $opts=[]): string` —
  satu kartu bernomor per soal, opsi radio ber-value INDEX opsi (cocok
  dengan `answer` index di `question_bank`, lihat `HrTest::grade()`). `opts`:
  `hidden` (field tersembunyi tambahan), `meta` (teks info di atas form).
  Dipakai baik test course (`course.php`) maupun placement test
  (`university.php`) — bentuk soal identik.
- `quizResult(array $r): string` — badge lulus/gagal + skor vs pass_grade +
  info percobaan + pesan remedial/batas tercapai. `$r`: `passed, score,
  pass_grade, attempts_used, max_attempts, locked?`.

Kelas CSS baru (app.css, blok "ARTICLE VIEW, FLASHCARD, QUIZ" sebelum
RESPONSIF): `.article-view` (+`table`/`code`/`img`), `.flashcard-deck`/
`.flashcard`/`.flashcard__inner`/`.flashcard__face(--front|--back)`,
`.quiz-question`/`.quiz-options`/`.quiz-option`, `.quiz-result(__meta|__note)`.
Semua warna via token existing (`--brand-green`, `--surface-2`, `--bg-accent`,
dst) — nol hex baru, konsisten §2/§8.

Grading TIDAK diduplikasi: `HrCoursePage`/`HrUniversityPage` (placement)
tetap memanggil `HrUniversityService::submitAttempt()` (existing) yang
memanggil `HrTest::grade()` + `HrTestAttempt::record()` +
`HrCompetencyService::setLevel()` (kompetensi provisional level 3 bila
lulus). Perbaikan bug lama yang ikut ditemukan saat mengintegrasikan modul
ini: `HrTest::questionsForDisplay()` sebelumnya hanya membaca key `q` —
konten resmi hasil import (`TEST-FORMAT.md`) memakai key `text`; dan form
lama mengirim TEKS opsi sebagai value (tidak pernah cocok dengan `answer`
yang berupa index), sehingga sebelum perbaikan ini test course/placement
tidak mungkin dinyatakan lulus lewat UI. Diperbaiki: `questionsForDisplay()`
membaca `text` dgn fallback `q`, dan `quizForm()` memakai INDEX opsi sebagai
value radio.

## 11. Addendum — Modal test interaktif satu-soal-per-layar (F7)

Permintaan owner (8 Jul 2026): ubah pengalaman test/quiz jadi modal
interaktif — soal satu per layar, progress+timer, layar tinjau jawaban,
hasil tampil sbg kartu skor-ring sesudah submit. **Progressive enhancement
murni** di atas `quizForm()`/`quizResult()` lama (keduanya DIPERTAHANKAN,
dipakai sbg fallback no-JS) — tidak ada perubahan kontrak/route/grading.

Method `UiBuilder` BARU (additive):
- `quizModal(array $questions, string $action, array $opts=[]): string` —
  markup gabungan trigger + dialog modal + form (radio value = index opsi,
  sama seperti `quizForm()`). `opts` tambahan dari `quizForm()`:
  `test_title`, `trigger_label`, `time_limit_minutes` (dari
  `hr_tests.time_limit_minutes`). TANPA JS: `.s1quiz__dialog` tampil inline
  (CSS default), SEMUA `.s1quiz__step` terlihat sekaligus, tombol
  `.s1quiz__submit` selalu tampak — identik `quizForm()` secara fungsional.
- `quizResultModal(array $r, string $backHref=''): string` — kartu hasil
  premium (skor ring `conic-gradient` CSS-only, badge LULUS/BELUM LULUS,
  CTA kembali/tutup); `$r` sama persis dgn `quizResult()` (delegasi logika
  lulus/gagal, tidak diduplikasi).

JS baru `public/assets/app.js`, IIFE `S1Quiz` (vanilla, tanpa library):
saat `[data-s1quiz]` ditemukan di halaman, menambah class
`.s1quiz--enhanced` (mengaktifkan CSS mode modal), lalu `.s1quiz--open`
(trigger diklik) & `.s1quiz--review` (di layar tinjau) — semua state
visual dipetakan CSS, JS tidak pernah tahu jawaban benar (hanya mengecek
radio `:checked` per soal utk progress/review, submit = form POST biasa).
Fitur: step next/prev + lompat dari grid tinjau, timer mm:ss (amber <20%
sisa, auto-submit saat habis + pesan `aria-live`), focus trap + Esc
(konfirmasi bila ada jawaban terisi), navigasi panah kiri/kanan + Enter.
`required` native dilepas hanya saat enhanced (native validation tidak
cocok dgn step tersembunyi `display:none`) — diganti validasi custom
(konfirmasi bila ada soal kosong saat "Kirim Jawaban", TETAP bisa lanjut —
mis. auto-submit habis waktu).

Kelas CSS baru (app.css, 2 blok di AKHIR file): **S1QUIZ** (`.s1quiz*`,
state `--enhanced/--open/--review`) & **S1RESULTMODAL** (`.s1resultmodal*`,
ring via `--s1ring-pct` custom property). Semua warna via token existing
(`--scrim`, `--accent-grad`, `--fill-success`, `--text-warning`, dst).

Deteksi "baru saja submit" (utk memilih `quizResultModal()` vs
`quizResult()` biasa pada kunjungan berikutnya): `HrCoursePage`/
`HrUniversityPage::handlePost()` menambah query `submitted=1` ke redirect
PRG existing (satu-satunya sentuhan di luar View — alur grading/flash tidak
berubah, hanya string URL); `render()` membaca
`$this->req->getInt('submitted') === 1` utk memilih bingkai. Diverifikasi
end-to-end (server uji port 8128, DB live): HTML nol string `"answer"` pada
kedua mode (course & placement), submit benar → redirect `&submitted=1` →
kartu hasil ring tampil, `hr_test_attempts` bertambah.

## Escape idempoten — UiBuilder::e() / Layout $e (bug UAT #14)

`UiBuilder::e()` dan closure `$e` lokal di `Layout::open()`/`Layout::close()`
sekarang IDEMPOTEN: `htmlspecialchars(html_entity_decode($s, ENT_QUOTES|ENT_HTML5, "UTF-8"), ENT_QUOTES, "UTF-8")`
— decode dulu baru encode. Akibatnya komponen (`pageHeader`, `card`,
`badge`/`statusBadge`, `objectLink`, `kv` key, `<title>`/`<h1>` Layout, dll)
boleh menerima string RAW ATAU string yang terlanjur di-escape pemanggil
(mis. `$this->ui->e()` dipanggil dua kali) — hasil akhir tetap single-escape
yang benar (`Linen & laundry` tampil sekali, bukan `&amp;amp;`). Standar
penulisan call-site TETAP kirim raw ke komponen (jangan sengaja
double-escape) — idempotensi ini jaring pengaman mekanisme, bukan lisensi
menulis kode baru yang meng-escape dua kali.

## 12. Addendum — Tabel drill-down collapsible (`treeTable`, task #16 owner)

Permintaan owner (faculty.php?id=1): gabungkan kartu "Kompetensi fakultas" +
"Course per level" jadi SATU tabel drill-down — baris induk (kompetensi) bisa
dibuka/tutup menampilkan baris anaknya (course). Dibangun di **core
framework** (bukan khusus halaman) supaya reusable untuk hierarki 2 level apa
pun — sudah diterapkan di 3 halaman sebagai bukti:

- `faculty.php` (`HrFacultyDetailPage`): induk = kompetensi fakultas, anak =
  course kompetensi itu. Course tanpa `competency_id` -> grup
  "(Tanpa kompetensi)" di akhir. Menggantikan 2 kartu terpisah.
- `category.php` (`AssetCategoryDetailPage`): induk = sub-kategori
  (`asset_categories.parent_id` = kategori ini) + grup pertama "Aset kategori
  ini" (aset langsung milik kategori, bukan sub-kategori); anak = aset-aset
  grup itu. Bila tak ada sub-kategori, hasilnya tetap satu grup tunggal —
  identik tabel lama. Ringkasan jumlah/nilai aset (kartu terpisah)
  DIPERTAHANKAN di atasnya.
- `department.php` (`HrDepartmentDetailPage`): induk = posisi departemen,
  anak = karyawan pemegang posisi itu; karyawan tanpa posisi -> grup
  "(Tanpa posisi)". Menggantikan kartu "Posisi departemen" lama. Kartu
  "Karyawan departemen" (TableSpec sortable+export) TETAP dipertahankan di
  bawahnya (opsi yang dipilih dari 2 alternatif owner — sort/export by
  employee masih berguna terpisah dari hierarki posisi->karyawan).

Method `UiBuilder` BARU (additive): `treeTable(array $head, array $groups,
array $opts=[]): string`. `$groups` = list of `['row'=>list sel induk,
'children'=>list<list sel anak>, 'open'?=>bool override per grup]`; `$opts`
= `['collapsed'=>bool default false, 'empty'=>string]`. Sel (induk maupun
anak) boleh HTML (objectLink/badge) — tidak di-escape ulang, sama seperti
`table()`/`tableRaw()` (pemanggil bertanggung jawab `->e()` teks mentah).
Grouping (mis. course per `competency_id`, aset per kategori, karyawan per
`position_id`) disusun di **Page** dari query Model existing — Model TIDAK
diubah kecuali menambah kolom `id` yang memang sudah terseleksi.

Markup & progressive enhancement (app.css blok **TREE-TABLE**, app.js IIFE
**S1Tree**):
- SEMUA baris (induk `.tree-row--parent` + anak `.tree-row--child`) SELALU
  hadir di HTML — **TANPA JS** tabel terbaca linear biasa (anak
  berindentasi via `.tree-row__child-cell`, caret hanya dekoratif, tidak ada
  `display:none` server-side).
- Baris induk ber-atribut `data-tree-toggle data-tree-id="{i}"
  data-tree-open="1|0"` + `role="button" tabindex="0"
  aria-expanded="true|false"` (semuanya server-rendered, terbaca lewat
  view-source/curl tanpa eksekusi JS). Baris anak ber-`data-tree-parent="{i}"`.
- **DENGAN JS** (`S1Tree`): klik atau Enter/Space pada baris induk memutar
  caret (kelas `.is-open`, `ti-chevron-right` rotate 90°) + toggle kelas
  `.tree-row--hidden` pada anak-anaknya (kelas ini HANYA disematkan JS —
  tidak pernah ada di markup awal) + memperbarui `aria-expanded`. Status awal
  tiap grup diterapkan JS dari `data-tree-open` sesudah `DOMContentLoaded`.

Diverifikasi end-to-end (server uji port 8148, DB live, login `[QC] Demo
GM`): `faculty.php?id=1` & `id=2`, `category.php?id=4` & `id=6`,
`department.php?id=6` (Housekeeping — punya grup "(Tanpa posisi)") & `id=7`
(HR) semua 200, satu tabel gabungan per halaman, HTML memuat SEMUA baris
anak (dicek via `fetch()` isi respons — bukan hanya DOM ter-render), atribut
`data-tree-toggle`/`aria-expanded`/`data-tree-parent` hadir di HTML mentah,
`app.js` termuat, `node --check app.js` bersih, `php -l` bersih di 4 file
diubah (`UiBuilder.php` + 3 halaman). Klik toggle disimulasikan via
`el.click()` di browser preview (bukan curl) — baris anak berpindah ke
`.tree-row--hidden` & `aria-expanded` berubah `true`→`false`, konsisten.
Link objectLink dalam baris (asset/location/employee/position/course/
competency) sampel diuji GET 200. Tidak ada PHP warning nyata pada respons
(pola `badge--warning` sempat menghasilkan false-positive saat grep kasar
`/warning/i` — dipastikan bukan error PHP asli via regex `Warning:` literal).
Data kategori/sub-kategori seed saat ini tidak memiliki `parent_id` terisi
(nol sub-kategori nyata) — jalur multi-grup `category.php` diverifikasi via
code review (filter `parent_id === id`), jalur fallback satu-grup teruji
end-to-end dengan data nyata.

## 13. Addendum — Empty-state ber-tombol "+ Buat" (task #18 owner)

Permintaan owner: SETIAP tabel/daftar yang KOSONG (mis. "Belum ada jadwal PM
ber-scope kategori ini" di `category.php`) menampilkan TOMBOL menuju form
pembuatan item baru — link ke halaman form yang SUDAH ADA (modal tidak
wajib), ROLE ACCESS tetap dihormati (tanpa hak → tombol tidak dirender).
Dibangun UNIVERSAL (deklaratif), mengikuti pola sort/link/export §5/§9.

Method baru:
- `UiBuilder::createButton(?array $action): string` — `null`/href kosong →
  `''`; selain itu `'<a class="btn btn--sm btn--primary …">+ {label}</a>'`.
  `$action = ['href','label'?,'capability'?]`. Bila key `'capability'` hadir
  & bukan `null`, digerbang OTOMATIS via `Acl::can(Auth::user()['role'],
  capability)` (`'*'` super_admin dihormati) — aman dipanggil dgn aksi mentah
  (belum tergerbang) MAUPUN aksi yang sudah tergerbang (tanpa key
  `'capability'` → selalu dirender, tak digerbang dobel).

Method existing diperluas (ADDITIVE, parameter/opsi baru OPSIONAL — halaman
lama yang tak mengisinya berperilaku identik):
- `table(array $head, array $rows, string $emptyText='Tidak ada data.', ?array $create=null): string`
- `tableSortable(..., string $emptyText='Tidak ada data.', ?array $create=null): string`
- `treeTable(array $head, array $groups, array $opts=[])` — baca `$opts['create']`
- `ListTable::render(..., array $opts)` — baca `$opts['create']` (bentuk sama), diteruskan ke `tableSortable()`

Tombol dirender HANYA saat data KOSONG (di bawah teks empty existing, di
dalam `<td>` yang sama) — tampilan tabel berisi data TIDAK berubah.

`ObjectRegistry` — field `'create'` OPSIONAL per tipe di `MAP`
(`['href'=>string relatif,'label'=>string,'capability'=>?string]`, href =
halaman yang SUDAH punya form tambah nyata — diverifikasi grep
`formOpen`/`handlePost` sebelum diisi, JANGAN mengarang form) + helper:

```php
public static function createAction(array $user, string $type): ?array
```

`null` bila tipe tak mendeklarasikan `'create'` ATAU `$user` tanpa
kapabilitasnya (pola sama `canView()`/`canAutocomplete()` — `$user`
eksplisit, TIDAK query DB); selain itu `['href'=>base_url absolut+halaman,
'label']`, siap dioper langsung ke `createButton()`/opsi `create` (sudah
tergerbang, tanpa key `'capability'` lagi).

Tipe terdaftar per 9 Jul 2026 (href → capability): `work_order` → report.php,
`report.create`; `part` → parts.php, `asset.manage`; `vendor` → vendors.php,
`asset.manage`; `partner` → partners.php, `sell.commercial.view`; `booking`
→ bookings.php, `sell.commercial.view`; `quotation` → quotes.php,
`sell.commercial.view`; `product` → products.php, `sell.view`; `kol` →
kol.php, `sell.view`. Kapabilitas dipilih = SAMA dengan gerbang
`$allowedRoles` halaman form tujuannya (diverifikasi baris demi baris —
tidak menambah maupun mengurangi akses yang sudah ada). Tipe LAIN
(`employee`, `competency`, `course`, `faculty`, `position`, `department`,
`category`) SENGAJA TIDAK diisi `'create'` — diverifikasi tidak ada form
tambah nyata di halaman list-nya (master data dikelola admin/import/proses
lain); mengisi tanpa form nyata = mengarang fitur, dilarang §1.

Diterapkan (9 Jul 2026): `AssetCategoryDetailPage` (kartu "Jadwal PM" →
pm-schedules.php `schedule.manage`; rujukan checklist default kosong →
checklist-builder.php `checklist.manage`), `PartDetailPage` (WO menunggu
part → report.php via `ObjectRegistry::createAction('work_order')`),
`VendorDetailPage` (kontrak kosong → contracts.php `asset.manage`, deklarasi
langsung — `ContractListPage` belum dukung pra-isi `?vendor_id=`),
`MktProductDetailPage` & `MktPartnerPage` (booking/penawaran kosong →
bookings.php/quotes.php via `ObjectRegistry::createAction()`, lewat opsi
`create` `ListTable::render`/`table()`), `HrEmployeeDetailPage` (cuti kosong
→ leaves.php, capability `null` — `$allowedRoles` halaman ini subset
`Role::CREW_ROLES` pemegang akses leaves.php, tak perlu gerbang tambahan).
Dilewati (form tambah sudah ADA DI HALAMAN YANG SAMA, tombol silang-halaman
akan redundan): kontrak mitra di `MktPartnerPage` (form "Tambah kontrak"
sudah inline di kartu bawah halaman itu sendiri).

Verifikasi: `php -l` bersih semua file diubah; role tanpa kapabilitas
(dicoba `qc.employee` pada halaman yang boleh ia buka) tidak melihat tombol;
role dengan kapabilitas (`qc.gm`) melihat tombol & GET href-nya 200.

## §13 Tombol "‹ Kembali ke ..." UNIVERSAL — `App\Support\BackTrail` (task #12)

Mandat owner: SEMUA halaman object/detail punya tombol back yang (a)
tertulis JELAS kembali ke mana, (b) mencatat referrer — datang dari halaman
lokasi (mis. lokasi/kategori/list nav lain) → "‹ Kembali ke {nama/label
asal}"; tanpa referrer valid → "‹ Kembali ke Daftar {Object}". UNIVERSAL =
nol kerja per-halaman (bukan per-Page seperti pola `$backBtn` manual lama
di `AssetCategoryDetailPage`/`HrFacultyDetailPage`/`HrPositionDetailPage`/
`HrKpiDefinitionDetailPage`/`HrDepartmentDetailPage`, yang TETAP dibiarkan
apa adanya — tombol itu action di kartu "page-hero", bukan tombol
back-navigasi baru ini; keduanya boleh tampil bersamaan, tidak saling
mengganggu).

**Titik integrasi = `App\View\Layout::open()`, BUKAN `UiBuilder::pageHeader()`**
— temuan riset sebelum implementasi: `pageHeader()` (komponen "page-hero"
premium) HANYA dipanggil segelintir halaman detail lama (`category.php`,
`faculty.php`, `position.php`, `department.php`, `kpi-definition.php`), dan
selalu tampil BERSAMA header polos `Layout::open()` (dua header sekaligus).
Halaman detail INTI (`asset.php`, `location.php`, `work-order.php`, dan
~15 lainnya) TIDAK memanggil `pageHeader()` sama sekali — hanya
`Layout::open()`, yang dipanggil `BasePage::run()` utk **SETIAP** halaman
web tanpa kecuali. Itulah SATU TITIK yang benar-benar universal & tanpa
kerja per-halaman; mengaitkan HANYA di `pageHeader()` akan melewatkan
halaman inti tsb. `pageHeader()` TIDAK diubah (signature maupun perilaku).

Deteksi "halaman ini = detail": `isset($_GET['id'])` di `Layout::open()` —
sengaja sesederhana itu (bukan cek tipe registry) supaya benar-benar
zero-config utk halaman baru mana pun yang memakai pola `?id=`. Halaman
tanpa `id` (list, dsb) tidak pernah dirender tombolnya.

`App\Support\BackTrail::resolve(array $user, string $currentBasename): ?array{href,label}`
— prioritas:
1. `HTTP_REFERER` same-origin (host+port persis `$_SERVER['HTTP_HOST']`) &
   basename ≠ halaman sekarang:
   - referrer = halaman `page`/`public_page` suatu tipe `ObjectRegistry`
     ber-`?id=` numerik → `SELECT` kolom `name` object itu (prepared,
     scoped `property_id` user aktif, identifier tabel/kolom HARDCODE dari
     registry — pola persis `AutocompleteController::ident()`) → "Kembali
     ke {nama object}".
   - selain itu → `Acl::labelForHref($refBasename)` (helper baru, scan
     `menuBlueprint()` tanpa gating role — murni kosmetik) → "Kembali ke
     {label nav}".
   - keduanya gagal (referrer tak dikenal / lookup kosong) → lanjut ke
     fallback.
2. Fallback (tanpa referrer valid): halaman sekarang = tipe registry yang
   mendeklarasikan `'list'`+`'label'` (field baru `ObjectRegistry::MAP`,
   ADDITIVE) → "Kembali ke Daftar {label}" + href ke halaman list-nya.
   Tipe tanpa `'list'`/`'label'` → `null`, TIDAK render apa pun (bukan
   bug).

Referrer HANYA kosmetik (label/tujuan tampilan) — BUKAN otorisasi; halaman
tujuan tetap menjaga aksesnya sendiri (`$allowedRoles`/`ObjectRegistry`/
`Acl` seperti biasa, tidak diubah oleh `BackTrail`).

`ObjectRegistry::MAP` field `'list'`/`'label'` per 9 Jul 2026 (tipe
ber-`page` yang SUDAH mengisi): `asset`→asset-list.php/Aset,
`category`→asset-list.php/Kategori Aset (konvensi existing kode, lihat
`AssetCategoryDetailPage::$backBtn`), `location`→locations.php/Lokasi,
`work_order`→work-orders.php/Work Order, `employee`→employees.php/Karyawan,
`partner`→partners.php/Mitra, `kol`→kol.php/KOL, `part`→parts.php/
Sparepart, `vendor`→vendors.php/Vendor, `product`→products.php/Produk,
`booking`→bookings.php/Booking, `quotation`→quotes.php/Penawaran,
`competency`→competencies.php/Kompetensi, `course`→course.php/Course,
`candidate`→recruitment.php/Kandidat, `task`→tasks.php/Task,
`position`→jobdesks.php/Posisi, `faculty`→university.php/Fakultas,
`kpi_definition`→kpi-hr.php/Indikator KPI, `complaint`→complaints.php/
Komplain (halaman belum ada — object-creator lain in-flight, disiapkan
lebih dulu). **Sengaja DILEWATI**: `department` — belum ada halaman
"daftar departemen" nyata (`HrDepartmentDetailPage` sendiri jatuh ke
`employees.php` "Kembali ke karyawan" saat not-found, tapi itu daftar
KARYAWAN bukan daftar departemen — mengisinya akan menghasilkan label
"Kembali ke Daftar Departemen" nyasar ke halaman karyawan; dibiarkan
`null` = tidak render tombol back di `department.php`, dicatat sbg gap
utk agent lain yang membangun halaman daftar departemen).

`Acl::labelForHref(string $href): ?string` (helper baru, ADDITIVE) — scan
`menuBlueprint()` cari href, kembalikan label-nya; `null` bila tak
terdaftar. TANPA gating role (beda dari `visibleMenu()`) — murni kosmetik
navigasi, dipakai `BackTrail` saja.

CSS `.page-back` (app.css, append) — muted (`var(--text-muted)`, hover
`var(--brand-green)`), ikon `ti-arrow-left`, `display:inline-flex`,
dirender SEBELUM `.page-head` (baris sendiri, di atas judul — bukan di
dalam flex row judul).

**Halaman ter-cover**: SEMUA halaman web (setiap `BasePage` subclass) lewat
`Layout::open()` — termasuk 5 halaman lama yang juga memanggil
`pageHeader()` (tombol back baru ini tampil sendiri di atas, TIDAK
menghilangkan `$backBtn` action lama di kartu "page-hero" — dua elemen
berbeda, tidak konflik).

Verifikasi (server lokal 8146, login `qc.gm`/`password`, curl + cookie +
header `Referer`): (a) `location.php?id=20` + `Referer: .../locations.php`
→ "Kembali ke Lokasi"; (b) `asset.php?id=1` + `Referer: .../location.php?id=20`
→ "Kembali ke Kamar 0803" (nama lokasi asal, dari DB); (c) `asset.php?id=1`
tanpa Referer → "Kembali ke Daftar Aset"; (d) ketiga href back di atas → GET
200; (e) `work-orders.php` (list, tanpa `?id=`) → NOL `.page-back`; (f)
Referer beda origin (`evil.example.com`) → diabaikan, fallback "Kembali ke
Daftar Lokasi". Diuji juga lintas modul: `work-order.php`, `category.php`,
`employee.php`, `faculty.php`, `kpi-definition.php`, `position.php` (semua
menampilkan label yang benar) & `department.php` (sengaja NOL `.page-back`,
sesuai gap tercatat di atas) — semua respons bersih tanpa
Warning/Notice/Fatal. `php -l` bersih: `BackTrail.php`, `ObjectRegistry.php`,
`Acl.php`, `Layout.php`.

## §14 PK/FK di setiap Export Excel — `TableSpec` (task #21 owner, 9 Jul 2026)

Permintaan owner: SEMUA export Excel (`ListTable`/`TableSpec` — satu-satunya
gerbang export) menyertakan PRIMARY KEY baris + FOREIGN KEY object yang
tampil, supaya file mudah di-VLOOKUP, TANPA mengubah tampilan layar sama
sekali. Diimplementasikan murni di `TableSpec::exportHeader()`/`exportRow()`
(bukan di `XlsxExporter`/`CsvExporter` — keduanya tetap generic sink header+
rows, jadi CSV fallback otomatis ikut aturan yang sama tanpa kode ganda):

- Kolom export PERTAMA selalu `id` (PK baris, dibaca dari `$row['id']` —
  kunci yang sama yang sudah dipakai `ListTable::applyObjectLinks()` untuk
  auto-link tampilan).
- Setelah tiap kolom `export=true` yang mendeklarasikan `'object'` (OBJECT-
  STANDARD §2), disisipkan satu kolom FK tambahan berjudul nama field
  id-nya sendiri (`object_id`, mis. `location_id`/`asset_id`/
  `assignee_employee_id` — BUKAN label terjemahan Indonesia), berisi nilai
  numerik id yang sama yang dipakai `objectLink()` untuk tampilan.
- **Dedup:** kolom FK DILEWATI bila `object_id`-nya sudah `'id'` — pola ini
  muncul saat kolom object menaut ke baris itu sendiri (mis. `wo_number` di
  `WorkOrderListPage` dgn `object_id => 'id'`, dipakai utk auto-link ke
  `work-order.php?id=`). Tanpa dedup ini, setiap listing akan punya DUA
  kolom berjudul `id` dengan nilai identik — membingungkan, bukan
  VLOOKUP-friendly. Baris keputusan ini murni penilaian teknis (bukan
  instruksi eksplisit owner), didokumentasikan agar tidak dianggap bug.
- Nilai id (PK maupun FK) ditulis sebagai ANGKA (`<v>` numeric cell) di
  xlsx, bukan teks — via `TableSpec::idValue()` (cast int bila numerik,
  string kosong bila `null`/kosong, TIDAK fatal).
- **Layar TIDAK berubah**: mekanisme murni di jalur `exportHeader()`/
  `exportRow()`, dipanggil HANYA dari `ListTable::emitExport()` (cabang
  `?export=xlsx|csv`). `headDefs()` (header tabel tampilan) & row-formatter
  Page tidak disentuh.
- **Baris tanpa `id`**: bila hasil query Page adalah hand-picked/agregat
  tanpa kunci `'id'` di array baris (bukan `SELECT alias.*`), sel PK
  dibiarkan kosong (TIDAK fatal). Audit 9 Jul 2026 atas 35 halaman
  `TableSpec`: 4 pengecualian — `AdminUsersPage`
  (`UserPropertyAccess::listForProperty`, id hanya sbg `access_id`),
  `HrCompetenciesPage` (`HrEmployeeComp::matrix`), `HrCompetencyDetailPage`
  (`HrEmployeeComp::holdersOf`), `HrKpiDefinitionDetailPage`
  (`HrKpiDefinition::tasksLinked`, id hanya sbg `task_id`). Follow-up
  opsional (bukan bug, tidak fatal): tambahkan `id` polos ke SELECT Model
  terkait bila owner ingin PK ikut ter-export di keempat halaman itu.

Verifikasi (server lokal 8151, `php -d extension=zip` supaya xlsx asli
[bukan fallback CSV], login `qc.gm`/`password`): `work-orders.php?export=xlsx`
→ unzip `xl/worksheets/sheet1.xml` — kolom A = `id` (angka, mis. 123), F =
`location_id` (angka), H = `asset_id` (angka), J = `assignee_employee_id`
(angka); baris tanpa aset/teknisi ter-link → sel kosong, TIDAK fatal; TIDAK
ada kolom `id` duplikat dari `wo_number` (object_id-nya `'id'`, di-dedup).
`employees.php?export=xlsx` → `id`, `department_id`, `position_id` terisi
angka. `partners.php?export=xlsx` → `id` terisi angka (tabel ini tidak
punya kolom object, jadi tanpa kolom FK tambahan). Jumlah baris export =
jumlah baris tampilan (dibandingkan langsung: work-orders 55, employees
115, partners 3 — sama persis). Tampilan layar `work-orders.php`/
`employees.php`/`partners.php` diverifikasi TIDAK menampilkan kolom `id`
baru (header `<thead>` identik sebelum→sesudah). `?export=csv` (fallback
path) diuji juga — header+baris ikut aturan sama. `php -l` bersih:
`TableSpec.php` (satu-satunya file yang diubah — `XlsxExporter.php`/
`CsvExporter.php`/`ListTable.php` tidak disentuh).

## §15 Komponen DATE RANGE universal (task #25 owner, 9 Jul 2026)

Permintaan owner: satu komponen rentang tanggal dipakai lintas sistem
(bukan dua `<input type="date">` lepas + placeholder "YYYY-MM-DD"),
dicontohkan eksplisit di form pengajuan cuti.

- `UiBuilder::dateRangeField(label, nameFrom, nameTo, opts)` — dua input
  `type="date"` terhubung + 4 chip preset (Hari ini/Minggu ini/Bulan
  ini/Bulan lalu). `$opts`: `value_from`, `value_to`, `min`, `max`,
  `required`. Markup ber-`data-s1dr`/`data-s1dr-from`/`data-s1dr-to`/
  `data-s1dr-preset`.
- `UiBuilder::filterBar()` — field baru `type => 'daterange'` (butuh
  `name_from`/`name_to` alih-alih `name` tunggal), ADDITIVE: field lain
  (`text`/`select`/`autocomplete`) tidak berubah sama sekali. Nilai
  current diambil dari `$current[name_from]`/`$current[name_to]` seperti
  filter lain.
- `app.js` IIFE `S1DateRange` (progressive enhancement, murni presentasi):
  klik chip mengisi kedua input (rentang dibatasi s/d hari ini kecuali
  "Bulan lalu" — periode yang sudah tertutup penuh); validasi client
  "sampai ≥ dari" via `setCustomValidity` pada input kedua (blokir submit
  browser-native). TANPA JS: dua `<input type="date">` biasa tetap
  terkirim & berfungsi penuh (chip diam saja) — SERVER TETAP wajib
  validasi ulang di pemakai, komponen ini bukan satu-satunya penjagaan.
- CSS: `app.css` §"DATE RANGE" (akhir file) — `.field--daterange`,
  `.daterange__presets`, `.daterange__chip`, `.daterange__inputs`,
  `.daterange__sep`. Token existing saja, tidak ada hex baru.

**Diterapkan (form/aksi):**
- `HrLeavePage` (`leaves.php`, form "Ajukan cuti/ijin") — ganti dua
  `field(..., 'date', ...)` (`from_date`/`to_date`) jadi satu
  `dateRangeField()`. Validasi server "sampai < dari" SUDAH ada sebelum
  komponen ini (`strtotime($to) < strtotime($from)`) — tidak diubah,
  hanya UI-nya yang naik kelas.

**Diterapkan (filter periode, `filterBar` type `daterange`):**
- `HrAttendancePage` (`attendance.php`) — filter `from`/`to` (dua text
  placeholder YYYY-MM-DD) → satu `dateRangeField`. Model tidak diubah
  (sudah menerima `from`/`to`).
- `WorkOrderListPage` (`work-orders.php`) — filter `due_from`/`due_to` →
  `dateRangeField`. Model tidak diubah.
- `LogbookPage` (`logbook.php`) — filter `date_from`/`date_to` (riwayat
  log book) → `dateRangeField`. Model tidak diubah.
- `MktBookingsPage` (`bookings.php`) — filter tanggal event BARU
  ditambahkan (`from`/`to`); `MktBooking::listForProperty()` SUDAH
  mendukung param ini dari sebelumnya (dipakai pemanggil lain), di sini
  baru diekspos ke filter bar halaman.
- `HrComplaintsPage` (`complaints.php`) — filter tanggal pencatatan BARU
  (`date_from`/`date_to` atas `hc.created_at`); param ditambah ADDITIVE
  di `HrComplaint::listForProperty()` (default perilaku lama dipertahankan
  bila param tak dikirim).

**Direkomendasikan (ditemukan saat audit, BELUM diterapkan — bukan
5 wajib di atas):** `MktQuotesPage`/`MktKolPage` (tanggal follow-up),
`ContractListPage` (masa berlaku kontrak vendor), `CompliancePage`
(tanggal expiry dokumen), `HrPayrollPage`/`HrPayrollParamsPage`
(periode payroll) — semua sudah punya field tanggal tunggal, kandidat
baik untuk `dateRangeField` bila owner ingin diperluas lagi.

Verifikasi (server lokal 8152, login `qc.hr_admin`/`qc.supervisor`/
`qc.gm`, password `password`):
- `leaves.php`: markup `dateRangeField` tampil dgn 4 chip; klik chip
  "Bulan ini" mengisi `from_date=2026-07-01`/`to_date=2026-07-09`
  (hari ini) & valid; set manual `to < from` → `setCustomValidity`
  aktif (blokir submit browser). Submit paksa (`form.submit()`, skip
  validasi browser) dgn `to_date < from_date` → server menolak
  ("Tanggal selesai tidak boleh sebelum tanggal mulai."), TIDAK ada
  baris tersimpan.
- `attendance.php`: GET manual `?from=2026-07-08&to=2026-07-08` (jalur
  no-JS) → 56 baris (rentang bulan berjalan) turun jadi 1 baris; input
  ter-render kembali dgn value dari GET (fallback no-JS terbukti).
- `work-orders.php`: `?due_from=2026-07-08&due_to=2026-07-08` → 55 baris
  jadi 23 baris.
- `complaints.php`: `?date_from=2026-07-10&date_to=2026-07-10`
  (tanggal depan, tanpa data) → 0 baris ("Belum ada komplain
  tercatat."); `?date_from=2026-07-09&date_to=2026-07-09` → 5 baris
  (semua data ada di tanggal itu).
- `bookings.php`: tanpa filter 5 baris (2026-07-02 s/d 2026-09-21);
  `?from=2026-07-01&to=2026-07-10` → 2 baris (hanya yang masuk rentang).
- `logbook.php` tidak diuji live (role `dept_head`/`technician`/
  `property_admin`/`super_admin` — akun QC demo tidak mencakup role ini,
  dan akun bernama seperti `depthead.eng` memaksa ganti password pada
  login pertama sehingga TIDAK dicoba, DB verify berbagi MySQL dgn live
  8123); diverifikasi via code review — pola `filterBar` identik persis
  dgn `attendance.php`/`work-orders.php` yang sudah terbukti hidup.
- `php -l` bersih di semua file diubah: `UiBuilder.php`, `HrLeavePage.php`,
  `HrAttendancePage.php`, `WorkOrderListPage.php`, `LogbookPage.php`,
  `MktBookingsPage.php`, `HrComplaintsPage.php`, `HrComplaint.php`
  (Model). `node --check app.js` bersih.

## §15 Addendum — Gamifikasi Hotel University (task #28 owner, UX/IX)

Hotel University (`university.php`/`course.php`) dirombak **GAME-LIKE**,
murni presentasi+derivasi dari data yang SUDAH ADA
(`hr_test_attempts`, `hr_employee_comp`) — **TANPA TABEL BARU** dan **TANPA
menyentuh konten soal/materi**. Spek F7 (SPEK-HOTELCREW-MASTER) memang
meminta leaderboard & badge per departemen.

### Rumus XP & Level (mekanik game internal — BUKAN fakta resort)

Dihitung on-the-fly oleh `App\Service\HrGamificationService` (Model
additive, read-only, method `summaryForUser()`/`leaderboardForProperty()`/
`xpForPassedScore()`):

- Tiap **attempt LULUS**: `10 XP dasar + bonus skor max(0, floor((score-80)/2))`.
- Tiap **kompetensi** yang levelnya berasal dari `evidence_type='test'`
  (hasil lulus course/placement University, BUKAN assessment manual
  praktikal/cert): `25 XP x level kompetensi saat ini`. Keterbatasan
  eksplisit: `hr_employee_comp` hanya menyimpan level TERKINI (tanpa
  riwayat kenaikan) — jadi ini snapshot "25 x level sekarang" per
  kompetensi ber-evidence test, bukan akumulasi tiap kali naik level.

**Level pemain** (nama netral, bukan istilah resort), threshold XP kumulatif:

| Level | XP min |
|---|---|
| Pemula | 0 |
| Penjelajah | 100 |
| Praktisi | 250 |
| Ahli | 500 |
| Master | 1000 |

**Streak** = hari berturut-turut ada attempt, dihitung mundur dari attempt
TERAKHIR user; bila attempt terakhir lebih lama dari kemarin, streak
dianggap putus (0), bukan angka basi.

**Peta level per fakultas** ("path" 1..N di kartu profil): level course
DIDERIVASI dari **pola judul course existing** `{FAC}-L{n}-W{nn}` (mis.
`HK-L1-W03` → Level 1) via regex `-L(\d)-` — course tanpa pola ini masuk
grup "Umum" (selalu terbuka, tak ikut gate terkunci/berjalan/selesai).
Level N terkunci hingga SEMUA course Level N-1 (dalam hasil filter aktif)
lulus; bila Level N-1 tak ada dalam data, gate dianggap terbuka. Ini
DILIHAT dari data riil, bukan kolom baru — di beberapa fakultas seed hanya
Level 1/2 yang ada (Level 3/4 belum ada course-nya), path hanya menampilkan
level yang benar-benar punya course.

**Badge** (derivasi, tanpa tabel baru — katalog di
`UiBuilder::gameBadgeCatalog()`, SEMUA badge selalu tampil, yg belum
diraih pudar bukan disembunyikan — pola sama `objectLink` "tampil apa
adanya"):

- **First Try** — ada test yang attempt PERTAMA (kronologis) user itu
  langsung lulus.
- **Perfectionist** — ada attempt dengan skor >= 100.
- **Fast Learner** — lulus > 3 test/course berbeda dalam satu bulan
  kalender yang sama.

**Leaderboard** — top 10 XP **bulan kalender berjalan** per departemen,
dari attempt LULUS bulan ini saja (rumus dasar+bonus skor sama; bonus
kompetensi TIDAK disertakan karena `assessed_at` tak presisi ke bulan
attempt asalnya). Nama → `UiBuilder::objectLink('employee', ...)`
(OBJECT-STANDARD §2). Empty-safe (belum ada attempt bulan ini →
`emptyState`, bukan tabel kosong polos).

### Komponen `UiBuilder` baru (additive-only)

`playerCard()`, `levelBadge()`, `xpBar()`, `facultyLevelPath()`,
`courseCard()` + `courseCardGrid()`, `leaderboardCard()`, `badgeShowcase()`.
`quizResultModal()` dapat param opsional `$r['xp']` (backward-compatible) —
menampilkan baris "+XP" amber sesudah lulus (course.php).

### Perubahan presentasi di Page

- **`university.php`** (`HrUniversityPage`): kartu "Profil Pemain"
  (playerCard) di atas; "Peta Level Fakultas" (facultyLevelPath) per
  fakultas hasil filter aktif; **daftar course jadi GRID KARTU**
  (courseCardGrid, bukan tabel datar — `ListTable::render()` TETAP
  dipanggil apa adanya murni untuk efek sampingnya menangani
  `?export=xlsx/csv` + audit log, HTML-nya sendiri tak lagi ditampilkan);
  "Leaderboard — XP bulan ini"; kartu "Badge". Tabel drill-down
  `treeTable` di `faculty.php` (HrFacultyDetailPage, admin) TIDAK diubah.
- **`course.php`** (`HrCoursePage`): course LULUS kini SELALU tampil via
  `quizResultModal()` (ring skor besar + badge lulus), bukan hanya sesaat
  sesudah submit — jadi kunjungan berikutnya pun tetap lihat ring & "+XP"
  yang didapat. Confetti CSS ringan (`.s1resultmodal--pass::after`,
  gradient radial + `@keyframes s1confetti`, dihormati
  `prefers-reduced-motion`) — TANPA JS baru.
- Grading (`HrUniversityService::submitAttempt`/`HrTest::grade`) dan
  konten soal/materi **tidak disentuh sama sekali**.

### Token & tema

Amber (`--brand-amber`) = XP/bintang/level; hijau (`--fill-success`/
`--text-success`) = selesai/lulus; abu (`--text-muted`/`--border`) =
terkunci/belum. Semua warna dari token existing (tidak ada hex baru selain
tint amber translusen `rgba(217,154,6,.16)` utk level tinggi) — otomatis
mengikuti 3 tema (Terang/Sawah Senja/Hutan Malam) sama seperti
badge/metricCard yang sudah terbukti AA-kontras di malam (§4.4 changelog).

### Verifikasi (server uji port 8155, DB live tidak diganggu)

- `qc.employee` (0 attempt) → kartu pemain empty-safe (XP 0, Pemula,
  "Belum ada streak", "0 course lulus dari 0 percobaan"), semua badge
  pudar, level path GEN/HK tampil (Level 1 tersedia, Level 2 terkunci,
  Umum tersedia) — nol error.
- `qc.gm` (5 attempt, 4 lulus) → XP 152 (77 dari attempt + 75 bonus
  kompetensi), level Penjelajah, streak 2 hari, leaderboard 3 baris (77/
  16/14 XP) — cocok dgn hitungan manual. `course.php?id=4` (attempt skor
  100) → ring 100%, "+20 XP", kelas `.s1resultmodal--pass` (confetti
  gated) — nol error.
- 3 tema dicoba via `profile.php` set_theme (senja pada `qc.hr_admin`,
  malam pada `qc.gm`) → `data-theme` berubah sesuai di `university.php`,
  nol Warning/Notice/Fatal/Deprecated/Uncaught pada seluruh HTML
  (`university.php`, `course.php` utk 3 akun, 2 course, 2 tema non-default).
  Akun QC dikembalikan ke `terang` (default) sesudah verifikasi — DB live
  dipakai bersama 8123, bukan DB terpisah.
- `php -l` bersih: `HrGamificationService.php` (baru), `UiBuilder.php`,
  `HrUniversityPage.php`, `HrCoursePage.php`. `node --check app.js` bersih
  (app.js sendiri TIDAK diubah — confetti murni CSS).
