Katalog Kind — Tier Page
Version: 0.1.0 · Status: Draft
Draft: isi di bawah kontrak yang berlaku. Setiap kind di sini adalah instance VisualSpecKind
tier: page— skema shell-agnostic, satu definisi untuk semua shell.
1. kind: Page — Routing & Komposisi
Layar ber-route yang menyusun blok. Ini kind dasar tier page — Form/Table (§2, §3) sendiri tidak independen routable, cuma tampil sebagai blok di dalam sebuah Page atau lewat route CRUD per-entity turunan framework.
apiVersion: formspec.dev/v1
kind: Page
metadata:
name: order-detail
module: billing
spec:
route: /orders/:id
title: "Order {order.number}"
blocks:
- form: { ref: order-edit, id: ":id", mode: view }
- table: { ref: order-payments, param: { order_id: ":id" } }
- component:
asset: billing/assets/payment-timeline.js
props: { order_id: ":id" }
layout: { columns: 2 }Aturan: route unik per App; :params satu-satunya sintaks route dinamis; blok mereferensikan Form/Table lewat nama atau meng-inline component (07-component-kinds.md §4). Full-custom page = satu entry component: tanpa blocks/tabs.
Blok section: — presentasi deklaratif. Page bisa memuat blok section: (closed set: hero, feature_grid, card, carousel, cta) — region presentasi penuh-lebar tanpa data binding dan tanpa auth. Generik dan reusable di App mana pun (public no-nav, sidebar-nav, topnav, ...), bukan milik satu archetype. Murni deklaratif — nol field styling; seluruh token visual hidup di kind: Theme (05-app-kinds.md §6), tidak pernah inline.
spec:
route: /
title: "Home"
blocks:
- section:
type: hero
title: "Toko Kami"
subtitle: "Belanja mudah, aman, dan cepat."
cta: { label: "Lihat Katalog", href: /listing/product-catalog }
- section:
type: feature_grid
title: "Kenapa Kami"
items:
- { icon: zap, title: "Cepat", text: "24 jam." }
- section:
type: carousel
autoplay: true
items: [{ title: "A", text: "..." }, { title: "B", text: "..." }]Varian tabs — beberapa sub-layar dalam satu route:
spec:
route: /settings
tabs:
- { label: General, form: { ref: settings-general } }
- { label: Tax, form: { ref: settings-tax } }
- { label: Products, table: { ref: product-list } }blocks dan tabs mutually exclusive. Renderer memperlakukan tiap tab sebagai resource yang di-permission-check independen.
Entry baris: picker pada child field
Blok transaksional khusus tidak ada — dan itu disengaja. "Pilih baris dari katalog lalu kirim" adalah pola yang sama di banyak domain (pesanan, pesanan pembelian, hitung stok, perpindahan stok, jurnal, resep, checklist), jadi deklarasinya diletakkan di field child-nya, bukan di kind atau halaman:
# entity (cafe-order.order)
- name: lines
type: child
child:
picker:
entity: cafe-master.menu-item
lookup:
{ entity: cafe-master.menu-item-price, key: menu_item_id, field: price }
display:
{ name_field: name, image_field: photo, columns: 3, search: true }
map:
{
ref_field: menu_item_id,
name_field: name_snapshot,
lookup_field: unit_price_snapshot,
quantity_field: quantity,
max_quantity: 20,
}Kontraknya (semua aturan, termasuk lookup — nilai per baris dari entity lain, dan scope-nya yang ditegakkan server) ada di ../backend/01-core-basic.md §1.3.
Yang perlu diketahui di sisi Page/Form:
Halaman cukup mereferensikan Form biasa — tidak ada blok khusus:
blocks: [{ form: { ref: order-form-qr, mode: create } }].Tata letaknya dinyatakan Form lewat
render.picker_panel:inline(default) — tile di atas child grid; child grid tetap editor barisnya. Cocok saat menambah baris adalah aksi sesekali.aside— tile dan editor baris di kolom sendiri (katalog jadi permukaan utama); child grid untuk field itu tidak dirender dua kali. Cocok untuk entry baris yang intensif (kasir, hitung stok).
Nilai yang tidak diisi user (kanal, sesi, waktu) tidak lagi lewat kunci
defaultskhusus: field-nya diberidefault_fromdanwidget: hidden— primitif umum yang berlaku di Form mana pun:yaml- { field: channel, widget: hidden, default_from: "qr_table" } - { field: transaction_date, widget: hidden, default_from: "{now}" } - { field: branch_id, widget: hidden, default_from: "{session.branch_id}" }default_frommenemplat{dotted.path}dari render context (spec.context+ slotuser/route), plus{now}/{today}. Token yang tak bisa diselesaikan dibiarkan verbatim (terlihat di payload) — bukan jadi string kosong yang menyamar sebagai field wajib terisi.Submit, label tombol, pesan sukses, redirect, dan event mengikuti
kind: Form— bukan kosakata sendiri.
Sisi kasir (numpad uang, kembalian, pembayaran gabungan) tetap terbuka: widget uang belum ada, dan layar POS sebagai kind tersendiri belum ditetapkan.
Pola Tabbed Resources: untuk app dengan banyak master-data kecil (jenis kelamin, status pernikahan, spesialisasi), memberi tiap satu entry menu sendiri bikin sidebar berantakan. Kelompokkan resource kecil terkait di bawah satu kind: Page ber-tabs — satu entry menu, satu route, sub-layar terorganisir. Ini keputusan pengelompokan design-time.
Pola Configuration Page: untuk setting sistem (parameter key-value yang strukturnya dikunci developer, nilainya diubah admin) — kind: Page ber-tabs, tiap tab mereferensikan kind: Form mode: edit atas Entity characteristic: reference dengan id sentinel (misal "0"). Renderer tidak boleh merender tombol "New Item"/"Delete" untuk Entity reference — hanya action Update yang disurfacekan.
Backend otomatis mendukung find-or-create untuk pola ini: ketika GET /{entity}/{id} gagal karena record belum ada, framework mencari record yang sudah ada untuk workspace tersebut. Jika tidak ada, framework auto-create record baru dengan nilai default dari entity spec. Lihat backend/01-core-basic.md §1.1 untuk detail implementasi.
1.1 Master-detail (split view)
Pola dua blok bersisian di mana seleksi baris pada blok list menggerakkan blok detail — tanpa navigasi route. Deklaratif lewat binds pada blok detail; bukan kind baru, cuma pola komposisi Page:
apiVersion: formspec.dev/v1
kind: Page
metadata: { name: order-workbench, module: billing }
spec:
route: /orders/workbench
layout: { mode: split } # master kiri (sempit), detail kanan (lebar)
blocks:
- table: { ref: order-list } # master — sumber seleksi
- form:
ref: order-detail
binds: { source: order-list, param: id } # detail mengikuti seleksibinds:
source— nama blok list (Table/listing) yang jadi sumber seleksi.param— field record terpilih yang diinjeksikan ke blok detail sebagai konteksnya (biasanyaid, menggantikan peran:idroute).- Blok detail refetch saat seleksi berubah; tanpa seleksi ia menampilkan empty-state.
layout.mode: split membagi master + detail bersisian; tanpa mode ini blok tersusun vertikal biasa (default). Common case: order + order lines, journal + entries, master data + panel detail. Enforcement permission tetap per-blok (04-spec-resolution-api.md §4) — split cuma menautkan seleksi, tidak melonggarkan gating.
Open —
binds/layout.mode: split. Master-detail belum didukung skemaPageSpec/BlockRefmaupun renderer — ditracking didocs_internal/plan/todo.md. Saat ini master-detail dilakukan viaparam+ route (:id) biasa.
2. data-entry (kind: Form)
Layout + perilaku input satu Entity, menggantikan form hasil derivasi:
apiVersion: formspec.dev/v1
kind: Form
metadata:
name: order-edit
module: billing
spec:
entity: order
mode: edit # create | edit | view
render: { mode: separate_page } # modal | drawer | separate_page (default: modal)
sections:
- title: Customer
columns: 2
fields:
- { field: customer_id, widget: relation-picker }
- { field: member_tier, read_only: true }
- title: Totals
visible_when: "fields.items != null and len(fields.items) > 0"
fields:
- {
field: total,
read_only: true,
compute: "sum([i.quantity * i.price for i in fields.items])",
}
actions:
- { action: checkout, label: "Checkout", style: primary }render — keputusan container design-time, bukan runtime:
render | Perilaku | Kapan dipakai |
|---|---|---|
modal (default) | Dialog overlay di atas Page saat ini; route tak berubah, state di baliknya tetap ada | Entity ringan (≤5 field), create/edit cepat tanpa kehilangan konteks list |
drawer | Panel slide-in dari kanan, sama sifatnya dengan modal tapi lebih lebar | Form medium (5–12 field), khususnya columns: 2 |
separate_page | Route sendiri, breadcrumb + URL sendiri | Entity padat (12+ field, child table, validasi kompleks), butuh deep-link |
Entity yang sama boleh punya banyak Form dengan render berbeda (mis. modal quick-create + separate_page full-edit) — keputusan per-Form, ditegakkan renderer (tidak ada switch runtime; butuh mode lain → deklarasikan Form kedua).
Aturan: tiap field wajib ada di Entity; tiap action wajib ada dan permission-gated otomatis (04-spec-resolution-api.md §4). Vocabulary perilaku client tertutup: visible_when, readonly_when, required_when, compute (FormSpecExpr, 08-formspec-expr.md) — begitu butuh efek imperatif, field itu jadi custom widget (07-component-kinds.md §4). rules field dari Entity manifest ditegakkan client-side untuk UX; validasi server-side tetap otoritas — cek client bukan pernah keamanan.
render menerima bentuk objek { mode: separate_page } (bentuk kanonik, sesuai skema dan renderer) maupun shorthand skalar render: separate_page — keduanya disetarakan saat parse.
2.0 Input action & transisi — satu dialog, deklarasi di manifest
Action dan transisi state machine mengumpulkan inputnya dari params.inputs pada deklarasi transisi/action, bukan dari komponen UI per-permukaan:
state_machine:
transitions:
- from: [paid, in_kitchen, ready, served]
to: cancelled
via: void-order
params:
inputs:
- name: void_reason # nama == field Entity → MERUJUK field itu
widget: textarea
required_when: "fields.status == 'paid'"
render: { mode: modal }Yang membuat ini generik bukan "satu Form untuk semua transisi":
| Pertanyaan | Jawaban |
|---|---|
| Berapa komponen dialognya? | Satu (ActionInputDialog), dipakai DetailPage, Table (baris + massal), Kanban. |
| Berapa kali author menulis form-nya? | Nol — deklarasi input adalah form-nya; tidak ada asset terpisah. |
| Berapa deklarasinya? | Per transisi/action. Permission, conditions, intersepsi approval, dan emit semuanya melekat pada transisi tertentu — satu form bersama tidak bisa mengekspresikannya. |
| Dipakai ulang antar transisi? | Ya, lewat params.inputs_from → Entity.spec.input_sets (dalam satu entity). |
Aturannya:
- Input yang namanya sama dengan field Entity mewarisi tipe/
options/multiplefield itu, dan nilainya disimpan ke field tersebut. Input ad-hoc wajib punyatypedan nilainya hanya diteruskan sebagai payload. - Field dirender widget router yang sama dengan Form (
FormFieldWidget) dan divalidasi builder zod yang sama, jadi tidak ada kosakata widget kedua yang perlu dijaga sinkron. - Vocabulary perilaku yang dipakai juga sama:
visible_when,readonly_when,required_when,compute. Karena itu transisi dengan beberapa state asal tetap satu deklarasi (required_whenyang menyempitkan), bukan dipecah per asal. render.modeadalah keputusan design-time sepertiForm.render; bila tidak ditulis, container diturunkan dari jumlah input (≤5modal, 5–12drawer, >12separate_page).- Untuk aksi massal, satu dialog mengumpulkan nilai untuk seluruh seleksi dan payload yang sama dikirim per baris — nilainya sama untuk semua baris, bukan dikumpulkan per baris.
Caption field (normatif). Setiap caption field/kolom memakai presedensi yang sama di semua kind (Form, Table, Listing, Report, Wizard, detail Page):
label eksplisit di manifest → title field di Entity → nama fieldlabel yang dideklarasikan penulis selalu menang; fallback hanya mengisi yang kosong. Konsekuensinya: mendeklarasikan kind: Form/Table — biasanya demi urutan, section, visible_when, atau lebar kolom, bukan demi label — tidak boleh menurunkan caption field menjadi nama mentah. Renderer wajib menerapkan presedensi ini pada manifest authored maupun hasil derivasi, karena keduanya adalah tipe yang sama (§9: derivasi "tak bisa dibedakan dari manifest YAML"). Sebelum aturan ini ditetapkan, hanya jalur derivasi yang membaca title entity — sehingga min_purchase dengan title: "Minimum Belanja" tampil sebagai min_purchase begitu entity-nya punya Form authored, dan field yang sama tampil berbeda antara Form dan tabel di halaman yang sama.
Help field (normatif). Teks bantuan di bawah input punya presedensi yang sama bentuknya, dengan dua kosakata yang berbeda nama:
help eksplisit di manifest → description field di Entity → tidak adaFormField.help dan Field.description berarti satu hal yang sama bagi pengguna; dua nama itu ada karena satu milik Form dan satu milik Entity. help yang dideklarasikan penulis selalu menang — itu jalur override per-form. Bila tidak ada, description entity yang dipakai. Bila keduanya kosong, tidak ada elemen yang dirender (bukan string kosong — elemen kosong terbaca sebagai baris kosong).
Konsekuensinya, dan ini yang mengikat: description pada field Entity adalah teks yang dibaca pengguna akhir, bukan catatan desain. Menulis description: "compute dari branch.service_charge_percent" akan menampilkan kalimat itu di bawah input pada setiap form yang memuat field tersebut. Detail implementasi ditulis sebagai komentar YAML (# …), bukan description.
metadata.description Entity mengikuti aturan yang sama untuk level section: section pertama Form yang tidak mendeklarasikan description sendiri mewarisi deskripsi Entity. Ini string yang dipakai sebagai subtitle drawer/dialog Form, sehingga Form authored tidak jatuh ke teks generik ("Fill in the details for this …") saat Entity-nya sudah dideskripsikan.
Batasnya, dan ini disengaja: help adalah alat input. Ia dirender di Form (dan langkah Wizard) pada mode create/edit; di mode view ia tidak dirender, dan permukaan baca-saja (Table, Listing, detail Page, Kanban, sel child) tidak menampilkan help sama sekali — deskripsi field tidak tersedia di sana, bukan tersembunyi.
2.1 Pola UI: Lifecycle vs Plain CRUD
Renderer memilih pola UI berdasar apakah reserved action submit aktif di Entity (../backend/01-core-basic.md §1.2) — bukan berdasar characteristic: transaction (dua flag itu independen: characteristic murni soal periode akuntansi, pola UI murni soal apakah lifecycle draft→submit bermakna secara bisnis).
submit dinonaktifkan eksplisit
→ Plain CRUD: satu tombol "Save", tanpa tombol Submit,
tanpa konsep draft ditampilkan (doc_status null)
submit AKTIF (default)
→ Pilih satu dari tiga pola, lewat hint manifest `ui:`.
Default kalau tidak dideklarasikan: 2-step + auto-save.| Pola | Kapan dipakai | UI |
|---|---|---|
| 2-step + auto-save (default) | Entity kompleks, butuh review (Invoice, Order, Contract) | Auto-save senyap saat draft (debounced update), satu tombol "Submit" eksplisit |
| 2-step manual | Draft sengaja dipisah untuk direview orang lain dulu | Tombol "Save Draft" + "Submit" terpisah |
1-step (create-submit) | Entry cepat volume tinggi (POS, antrean klinik) | Satu tombol, pakai reserved action create-submit (../backend/01-core-basic.md §1.2) — tanpa konsep draft di UI, atomik |
Dua tombol standar (Save Draft/auto-save, Submit) selalu otomatis tersedia dari model tanpa perlu dideklarasikan; create-submit menambah jalur cepat opsional, bukan mengganti keduanya.
3. table-list (kind: Table)
Daftar ber-filter/sort/paginasi; kolom terderivasi dari entity:
apiVersion: formspec.dev/v1
kind: Table
metadata: { name: order-list, module: billing }
spec:
entity: order
columns:
- { field: number, link: order-detail }
- { field: customer.name }
- { field: total, format: currency }
- { field: status, widget: badge }
filters:
- { field: status, label: Status, type: select }
- { field: created_at, label: "Created", type: date_range }
default_sort: -created_at # "field" = asc, "-field" = desc
search: true
realtime: true
row_actions: [mark-paid, void]
bulk_actions: [export]realtime: true = auto-subscribe + patch baris di tempat (04-spec-resolution-api.md §5). row_actions/ bulk_actions permission-gated otomatis, sama seperti action Form.
Navigasi dari row action (view:). Satu entri row_actions boleh membuka view resource alih-alih memanggil action entity:
row_actions:
- {
action: print,
label: "Cetak Kartu",
icon: printer,
view: "print:table-tent-card",
}view memakai kosakata <kind>:<name> yang sama dengan MenuItem.view; id record disertakan bila rute kind itu menerima :id (mis. print, yang mendaftarkan /print/<name> dan /print/<name>/:id). Ini jalur pemicu untuk view yang routable tetapi bukan action entity — print dan export adalah builtin renderer (tidak punya permission entity), sehingga gatingnya memakai izin view atas entity sumber view itu. formspec validate menolak view yang tidak me-resolve ke view terdaftar: target yang menuju ke mana-mana lebih buruk daripada tombol yang tidak ada.
3.1 Prioritas & Overflow Kolom (derivasi — normatif)
Table tanpa columns: eksplisit menderivasi kolomnya dari entity. Derivasi tidak boleh membuang field secara diam-diam. Default normatif:
Renderer menampilkan N kolom prioritas pertama inline. N adalah ketetapan renderer, bukan angka di kontrak ini — shadcn-shell hari ini
DERIVED_TABLE_VISIBLE_COLUMNS = 8(renderers/shadcn-shell/02-derivation-engine.md§2). Shell lain bebas memilih N dan wajib menyediakan jalan akses ke sisanya.Urutan prioritas (sort-nya stabil, jadi urutan deklarasi field dipertahankan di dalam satu tier):
- field
natural_key; - field
label_field— yang di-resolve server sebagaidisplay_field→natural_key→name/title/number→id(04-spec-resolution-api.md§2); - field status (
state_machine.field); - field bernama
transaction_date; - sisanya, sesuai urutan deklarasi field di Document.
Yang tidak ikut: field
computeddan fieldchild.- field
Field sisa yang tak muat tetap terjangkau lewat row expand (baris dibuka menampilkan kolom overflow) — tak pernah hilang. Klik baris tetap membuka Page detail derived.
columns:eksplisit menang penuh dan tidak dipotong: developer memilih persis kolom dan urutannya, tanpa overflow otomatis di atasnya — daftar 15 kolom dirender 15 (horizontal scroll bila perlu), keputusan sadar. Window prioritas hanya berlaku untuk daftar derived; pembedanya adalah asal kolom (authored vs derived), bukan panjangnya — spesifikasi hasil merge tidak bisa dibedakan dari spesifikasi authored, jadi renderer tidak boleh menebak dari jumlah kolom.
Renderer dilarang memotong keras daftar kolom derived tanpa jalan akses balik — pemotongan tanpa expand adalah data-loss, bukan layout.
3.1.1 Presentasi kolom: align & width (normatif)
Tiap TableColumn boleh menyatakan presentasinya. Keduanya mengikat: nilai yang dideklarasikan renderer terapkan, bukan diabaikan diam-diam.
kind: Table
spec:
entity: order
columns:
- { field: number, label: "No." }
- {
field: total,
label: "Total",
format: currency,
align: right,
width: "140px",
}| Properti | Nilai | Efek |
|---|---|---|
align | left · center · right | Perataan teks sel header dan sel body kolom itu. |
width | panjang CSS (120px, 10rem) | Lebar kolom — diterapkan pada sel header (<th>), yaitu kotak yang dipakai browser untuk menentukan lebar kolom. |
Dua hal yang mudah salah:
- Perataan harus kena sel body juga.
<td>adalah sibling<th>, bukan keturunannya —text-aligntidak diwarisi. Meratakan header saja menghasilkan judul rata kanan di atas angka rata kiri. - Header yang sortable membungkus labelnya di flex row;
text-aligntidak bisa menggeser anak flex, jadi header itu juga butuh perataan flex (justify-*).
align adalah himpunan tertutup di skema — nilai di luar left/center/right ditolak validasi, bukan diam-diam diabaikan. width tidak divalidasi bentuknya (panjang CSS bebas), tetapi ia wajib diterapkan.
Nilai default: tanpa align/width, kolom mengikuti default tabel (label kiri, lebar otomatis). Kolom yang diderivasi (tanpa columns: eksplisit) tidak menyatakan perataan — derivasi tidak menebak dari tipe field.
Kind Listing (§10) memakai TableColumn yang sama dan tunduk pada aturan ini.
3.1.2 Kosakata format & sel relasi (normatif)
TableColumn.format memilih bagaimana nilai mentah ditampilkan:
format | Nilai yang diharapkan | Hasil |
|---|---|---|
currency | {amount, currency} / angka | Rp50.000 — simbol & skala dari settings.currency |
number | angka | 20.000 — pemisah ribuan dari settings.locale |
date | string tanggal | mengikuti settings.date_format |
relative | string waktu | "3d ago" |
percent | angka | 10% |
format adalah himpunan tertutup (TableCellFormat) — setiap nama diimplementasikan renderer sel, jadi salah ketik ditolak validasi alih-alih diam-diam mencetak nilai mentah.
Himpunan ini bukan ReportFormat meski mirip: laporan tidak punya relative/number, dan sel tabel tidak punya datetime (kolom datetime menderivasi relative — §3.1). Menyamakan keduanya memaksa satu permukaan menerima nama yang tidak bisa ia render; karena itu datetime pada kolom tabel ditolak dengan petunjuk bahwa ia format laporan.
format tidak pernah diturunkan dari tipe field. decimal/integer sama seringnya sebuah kuantitas (quantity_on_hand) maupun sebuah pengenal (line_number, kode); renderer tidak menebak, jadi kolom yang ingin pemisah ribuan menyatakannya. Kolom tanpa format menampilkan nilai apa adanya.
Skala field menang atas skala global. Untuk format: number pada field decimal yang punya scale sendiri, skala field itulah yang dipakai — bukan settings.decimal_scale. Field scale: 3 yang dicetak dengan skala global 2 akan membulatkan digit yang tersimpan: itu salah menyatakan data, bukan pilihan tampilan.
Sel relasi menampilkan label, bukan kunci. Field belongs_to disimpan sebagai foreign key (branch_id), tetapi API juga mengirim record terkait pada alias bersaudara (branch: { id, name, … }). Kolom yang menyebut field itu — baik sebagai field: branch_id maupun sebagai dot-path field: branch.name — menampilkan label record terkait (label_field entity tujuan, fallback name/title/code). Yang tidak boleh terjadi: menampilkan UUID padahal namanya ada di baris yang sama.
Alias mengikuti aturan server (renderers/jsonb-persist): patient_id → patient, selain itu nama resource. Bila API tidak mengirim objek terkait (mis. field yang tak boleh dibaca pemanggil), sel menampilkan kunci mentahnya — bukan kosong.
Wajib untuk Table dan Listing; keduanya memakai resolver yang sama.
3.2 Inline & Batch Editing
Opsional, opt-in per Table:
kind: Table
spec:
entity: product
inline_edit: true
batch_edit: [price, category_id]inline_edit: true — sel bisa disunting in-place. Kolom yang editable dibatasi field yang rules-nya mengizinkan (derived: field readonly/compute/immutable, atau di luar permission update, tidak editable). Commit sel = action update biasa per baris, membawa version (CAS, ../backend/01-core-basic.md §5) — mismatch → 409 CONFLICT, baris ditandai stale, tak pernah menimpa senyap. Guard lifecycle tetap berlaku: baris submitted menolak inline-edit (update ditolak server, ../backend/01-core-basic.md §1.2).
batch_edit: [field, ...] — pilih beberapa baris → set nilai satu/lebih field → framework mengeksekusi action update per baris, tiap baris divalidasi server-side independen (rules, guard, CAS). Partial failure dilaporkan per baris: baris yang gagal ditampilkan dengan alasannya, baris sukses tetap commit — tak pernah all-or-nothing diam-diam, tak pernah menelan error. Permission = permission update entity; baris yang caller tak berhak tak masuk seleksi editable.
3.3 Kontrak Filter (dipakai bersama Table & Kanban)
Model filter data kind generik, dipakai identik oleh Table dan Kanban (serta kind lain yang me-list record). Setiap filter dideklarasikan sebagai objek FilterSpec dan memegang salah satu dari dua peran:
spec:
filters:
- { field: transaction_date, label: "Tanggal", type: date, default: today }
- { field: polyclinic_id, label: "Poliklinik", type: select }
fixed_filters:
- { field: tenant_id, default: tenant-1 }filters— kontrol yang bisa diubah user di UI. Biladefaultdiisi, kontrol ter-seed nilai itu saat dibuka (user tetap bisa mengganti/mengosongkan).fixed_filters— filter immutable, server-side: selalu digabung ke request list, tidak dirender sebagai kontrol, dan tidak bisa dihapus user. Dipakai untuk scope yang tak boleh diganggu (mis. pin satu tanggal, satu tenant, satu konteks halaman).
FilterSpec:
| Field | Wajib | Deskripsi |
|---|---|---|
field | ya | Nama field entity (boleh dot-path relation, mis. patient.name) |
label | tidak | Label kontrol; fallback field |
type | tidak | select (default) · text · date · date_range |
op | tidak | Operator filter API — default eq (set operator backend §6) |
default | tidak | Nilai seed. Mendukung today / today() (resolver = tanggal server, UTC) |
show_all | tidak | Hanya tipe select: tampilkan opsi "All" (clear). Default true |
all_label | tidak | Hanya tipe select: caption opsi "All" (clear). Default "(ALL)" |
Nilai terkirim ke API sebagai field[op]=value (mis. transaction_date[eq]=2026-08-07), sehingga DB mem-filter sebelum baris dikirim. fixed_filters selalu menang atas pilihan user bila field-nya sama. today() meniadakan perbedaan zona waktu: memakai tanggal server (RFC3339 UTC), bukan tanggal lokal browser — sama dengan konvensi widget query.
4. kanban
Papan kolom drag-drop — instance VisualSpecKind tier: page (02-visual-spec-kind.md), dan contoh unggulan kontrak itu. Operasional: tiap kartu satu record entity, tiap kolom satu nilai status, drag antar kolom = transisi state.
apiVersion: formspec.dev/v1
kind: Kanban
metadata: { name: support-board, module: helpdesk }
spec:
entity: ticket
status_field: status # wajib — field state machine/enum yang jadi kolom
realtime: trueKontrak saat ini: status_field wajib — menunjuk field yang nilainya jadi kolom (field state machine bisnis entity ../backend/02-core-extended.md §1, atau field enum biasa). columns eksplisit berisi nilai status yang ditampilkan sebagai kolom.
Open — zero-config derivasi kolom. Derivas kolom otomatis dari state machine/
group_by(menghilangkan kewajibanstatus_field) belum diimplementasikan — ditracking didocs_internal/plan/kanban-full-implementation.md.
Derivasi kolom:
columns:eksplisit menang penuh — setiap entry{ status, label, color }(nilai, urutan).- Tanpa
columns:eksplisit, renderer menderivasi kolom dari nilai unikstatus_field— urut sesuai urutan transisi (state machine) atau urutan deklarasienum_values.
Derivasi kartu: field kartu diderivasi seperti prioritas kolom Table (§3.1) — natural key/title-ish, transaction_date; field status implisit (sudah jadi kolom, tak diulang di kartu). Override lewat card_template ({ title, subtitle, badge, assignee, fields, component }).
Drag-drop = state transition:
- Menjatuhkan kartu ke kolom lain memanggil action
viatransisi yang cocok (from= kolom asal,to= kolom tujuan). Guard state machine dievaluasi server-side — otoritas. Permission drag = permission action transisi itu (04-spec-resolution-api.md§4); caller tanpa permission itu tak bisa men-drag kartu ke kolom tersebut. - Transisi yang tak dideklarasikan → tak ada drop target; kalaupun dipaksa, server menolaknya (
STATE_TRANSITION_ERROR, § core-extended §1).
Open —
drag_guard. Pre-check UX sebelum drop (FormSpecExpr,08-formspec-expr.md) belum diimplementasikan — ditracking didocs_internal/plan/kanban-full-implementation.md. Validasi server (guard state machine) tetap otoritas dan sudah berjalan.
Open — WIP limit.
wip_limitper kolom (batas jumlah kartu, soft pre-check UX) belum ada di skemacolumns— ditracking didocs_internal/plan/kanban-full-implementation.md. Pengganti saat ini:max_cards_per_column(integer, level board) diKanbanSpec.
Realtime: realtime: true = subscribe event updated/created, kartu pindah kolom di tempat saat status berubah dari klien lain (04-spec-resolution-api.md §5).
Empty/overflow: kolom kosong tetap terlihat sebagai drop target. Kolom dengan banyak kartu paginasi cursor-based — renderer tak boleh diam-diam memotong kartu tanpa indikator "muat lebih" (prinsip no-silent-drop yang sama dengan Table §3.1).
Override penuh:
spec:
entity: ticket
status_field: status
columns:
- { status: low, label: Low }
- { status: normal, label: Normal, color: blue }
- { status: urgent, label: Urgent, color: red }
card_template:
title: number
subtitle: subject
fields: [assignee.name, created_at]
realtime: trueFilter & scope tanggal. Kanban memakai kontrak filter yang sama dengan Table (§3.3). Filter type: date dengan default: today membuat board terbuka ter-scope ke satu tanggal (mis. antrean hari ini) dan tetap bisa diganti user via date picker; fixed_filters mem-pin scope yang tak bisa diganti user. Nilai filter dikirim server-side (field[op]=value).
Within-column ordering. Renderer dapat mengurutkan kartu dalam satu kolom dan mengizinkan operator mengubah urutan via drag-to-reorder dalam kolom:
sortable: true— mengaktifkan drag-to-reorder dalam kolom. Renderer otomatis mengirim?sort=<position_field>ke API, sehingga kartu tampil sesuai urutan posisi.position_field: "nama_field"— field entity yang menyimpan nilai posisi (biasanya integer). Renderer mengupdate field ini via PATCH saat kartu di-drag ke posisi baru.sortable: truetanpaposition_fieldadalah konfigurasi tidak valid — manifest validation wajib menolaknya.
Saat kartu dipindah antar kolom, renderer juga mengisi position_field dengan max(posisi_kolom_tujuan) + 1 sehingga kartu baru muncul di urutan terakhir kolom tujuan.
Kapan pakai Kanban vs Table: Kanban kalau status adalah dimensi kerja utama dan pemindahan status adalah aksi utama (support queue, order fulfillment, triage board). Table kalau operasi utama adalah sort/filter/edit banyak kolom.
5. calendar
View kalender atas entity yang punya field tanggal/waktu — instance VisualSpecKind tier: page. Untuk penjadwalan (appointment, delivery planning).
apiVersion: formspec.dev/v1
kind: Calendar
metadata: { name: appointment-calendar, module: clinic }
spec:
entity: appointment
date_field: scheduled_atZero-config: dengan entity + date_field, renderer merender view month (default), menempatkan event pada tanggalnya, judul dari label_field entity (04-spec-resolution-api.md §2). Klik event → Page detail/Form entity itu.
View: views: [month, week, day, resource], default month. View resource = satu lajur per nilai resource_field (mis. dokter/ruangan) untuk resource scheduling.
Field:
date_field(wajib) — tanggal/datetime awal event.end_field(opsional) — event rentang (start–end); tanpa ini event titik-waktu.title_field(opsional) — overridelabel_fieldderived.resource_field(opsional) — mengaktifkan viewresource, satu lajur per nilai (biasanya field relation, mis.doctor_id).color_field(opsional) — pewarnaan kategori.
Interaksi:
- Klik event → detail/form (sama seperti
linkkolom Table). - Klik slot kosong → Form create dengan
date_fieldter-prefill. - Drag reschedule → memanggil action
updateyang mengubahdate_field(danend_fieldbergeser proporsional untuk rentang); validasi server-side otoritas (guard lifecycle + rules,../backend/01-core-basic.md§5). Permission = permissionupdateentity. Recordsubmittedimmutable tak bisa di-drag (§ core-basic §1.2) — renderer menonaktifkan drag untuknya. realtime: true= event muncul/pindah in-place (04-spec-resolution-api.md§5).
Recurrence (normatif). Field recurrence pada entity wajib berformat RRULE (RFC 5545) — standar yang sama dipakai iCalendar/Google Calendar/Outlook — bukan grammar bikinan sendiri, supaya interop (export/import .ics) gratis dan tooling expansion yang sudah matang bisa langsung dipakai:
- { name: recurrence, type: string } # nilai: "FREQ=WEEKLY;BYDAY=MO;INTERVAL=2"Expansion terjadi saat baca/render, bukan materialized rows di PersistBackend — Calendar meng-expand RRULE jadi instance konkret untuk rentang tanggal yang sedang di-view (bulan/minggu/hari), lewat pustaka expansion RRULE standar di sisi renderer. Ini murni komputasi tampilan; tidak butuh dukungan Query Builder backend (../backend/02-core-extended.md §16) untuk kasus umum tanpa exception.
Di luar cakupan v1 (Open — exception per-instance). Mengubah/membatalkan satu occurrence tanpa mengubah pattern-nya (mis. "pertemuan tanggal 5 dipindah ke jam 15:00, sisanya tetap") butuh model data exception tersendiri (row terpisah yang mereferensikan tanggal asli + override) — belum dispesifikasikan, ditunda ke iterasi berikutnya. Calendar v1 menampilkan seluruh occurrence hasil expansion RRULE tanpa exception; drag reschedule (di atas) berlaku ke field tanggal Entity itu sendiri, bukan ke satu occurrence dari pattern berulang.
Bukan pengganti recurring job. Recurrence di sini murni untuk menampilkan/mengedit pola tanggal yang dilihat manusia di Calendar — bukan mekanisme untuk menjalankan action terjadwal berkala (mis. tutup buku bulanan, generate invoice periodik). Kebutuhan itu domain modul resmi formspec/scheduler di atas primitive yang ada (../platform/06-datastore.md §2 "Set primitive tertutup"), bukan Calendar.
6. wizard
Proses bisnis sekuensial multi-step lintas entity; framework mengurus navigasi stepper, validasi per-step, dependency antar-field, autosave per-instance, dan perilaku completion:
apiVersion: formspec.dev/v1
kind: Wizard
metadata:
name: patient-registration
module: clinic
spec:
title: "Patient Registration — {step.title}"
entity: visit # tanpa `action`: step akhir create biasa di entity ini
on_complete:
restart: true # reset stepData/currentStep ke 0, bukan navigasi keluar
banner:
- { label: "Queue Number", field: response.queue_number }
steps:
- title: "Find Patient"
layout: search_select
entity: patient
search_fields: [nik, name, phone]
allow_create: true # tombol "New Patient" kalau tidak ketemu
- title: "Select Poly & Doctor"
required: [polyclinic_id, doctor_id]
fields:
- {
field: polyclinic_id,
entity: polyclinic,
type: dropdown,
required: true,
}
- {
field: doctor_id,
entity: doctor,
type: dropdown,
required: true,
depends_on: polyclinic_id,
}
on_prev: discard-poly-selection
- title: "Confirm & Submit"
on_enter: prefill-visit-defaults
summary:
- { label: "Patient", field: patient.name }Aturan:
action(level wizard) opsional. Kalau diisi: action server-side yang atomik menulis seluruh data step saat submit final, wajib ada di minimal satu entity yang terlibat. Kalau tidak diisi: step akhir melakukancreatebiasa dientitypakai data step terakumulasi — tiap field yang dibutuhkan entity itu wajib sudah resolved dari step sebelumnya.on_complete.restart: truemengosongkanstepData, kembali ke step 0 — untuk alur gaya front-desk (daftar satu pasien, lanjut ke berikutnya).redirectnavigasi ke path lain, diabaikan kalaurestart: true.bannermerender info dari submission yang baru selesai, di-resolve terhadapresponse.*(response API submit final) — bukanstepData(sudah dikosongkan saat restart).required: [field, ...]di level step menggerbang tombol Next.- Hook step:
on_enter(saat step jadi aktif, termasuk lewat Back),on_next(sebelum maju),on_prev(saat keluar lewat Previous) — ketiganya opsional, best-effort (gagal tidak memblokir navigasi). depends_on= filter chain client-side; UX-only, validasi server tetap otoritas.- Step sekuensial — renderer menegakkan penyelesaian step N sebelum N+1 bisa diakses; Back selalu diizinkan (data step N-1 tetap tersimpan).
- Wizard punya route sendiri (
/wizard/:name); state step di URL (?step=2) untuk deep-link; tiap instance wizard yang terbuka diidentifikasi?instance=<id>(auto-generate) —stepDataautosave kelocalStoragekunciwizard:{name}:{instance}, sehingga multi-tab dan refresh tidak saling menimpa. Tidak ada draft row sisi server. - UI custom di dalam step lewat
component:— component menerima props{ wizard, step, data, formspec }.
Relasi ke kind lain: Wizard adalah komposisi stateful dari step mirip-Form dengan shell stepper. Kalau prosesnya cuma section form linear tanpa penegakan sekuensial, pakai kind: Form dengan sections biasa (§2) — bukan Wizard.
7. dashboard
Grid slot widget (02-visual-spec-kind.md §4, 07-component-kinds.md §2–§3 untuk kontrak Widget dan slot filling-nya). Dashboard mereferensikan widget by name — widget didefinisikan terpisah sebagai kind: Widget:
apiVersion: formspec.dev/v1
kind: Dashboard
metadata: { name: sales-today, module: billing }
spec:
customizable: true # user boleh tambah/hapus/urutkan dari katalog widget
defaults: [sales-today-stat, gl-cashflow-chart]
refresh: 60 # atau realtime: true
widgets:
- ref: sales-today-stat
layout: { x: 0, y: 0, w: 4, h: 2 }
- ref: gl-cashflow-chart
layout: { x: 4, y: 0, w: 8, h: 4 }
config: { range: 30d }apiVersion: formspec.dev/v1
kind: Widget
metadata: { name: sales-today-stat, module: billing }
spec:
title: "Today's Revenue"
type: metric # metric | chart | table | list
entity: sales-daily-summary
config: { field: total } # specifik per typeDashboardWidget = { ref, layout: {x,y,w,h}, config }; WidgetSpec = { title, type, entity?, query?, refresh_secs?, size?, config? }. Visibilitas katalog widget derived dari permission, mekanisme customizable — lihat 07-component-kinds.md §2–§3.
Open — rendering widget. Renderer widget (
stat/chart/table/list) dan kanvas dashboard belum sepenuhnya diimplementasikan — skema kontrak di atas sudah final; eksekusi ditracking didocs_internal/plan/todo.md§5.7.
8. report dan print
kind: Report
Output tabular terparameterisasi:
apiVersion: formspec.dev/v1
kind: Report
metadata: { name: sales-by-category, module: billing }
spec:
title: "Sales by Category"
entity: order
required_permission: reports.sales-by-category
parameters:
- { field: date_from, label: "Dari", type: date, required: true }
- { field: date_to, label: "Sampai", type: date, required: true }
columns:
- { field: number, label: "No." }
- { field: customer.name, label: "Customer" }
- { field: category, label: "Kategori" }
- { field: total, label: "Total", aggregate: sum, format: currency }
groups:
- { field: category, label: "Kategori" }
totals:
- { label: "Total", field: total, fn: sum }
export: [xlsx, csv]entity selalu query entity, permission-checked — Report tidak pernah meng-embed SQL (kontrak "gabungkan sources by join_key" adalah urusan PersistBackend, ../backend/02-core-extended.md §6). Export berjalan sebagai async job (../backend/01-core-basic.md §5 call: async); file mendarat di download tray.
ReportColumn vs TableColumn (S16). Keduanya menggambarkan satu kolom, tetapi hidup di kind yang berbeda dan tidak saling menyalin. Perbedaannya dinyatakan berdampingan supaya penulis spec tidak menyalin bentuk satu ke yang lain:
| Properti | TableColumn | ReportColumn |
|---|---|---|
field, label | ✅ | ✅ |
format | ✅ (bebas) | ✅ enum (currency/date/datetime/percentage) |
aggregate | — | ✅ enum (sum/avg/count/min/max) |
widget | ✅ | ✅ (set yang sama) |
sortable, width, align, link | ✅ | — |
Open —
TableColumn.link. Kolom yang nilainya menjadi tautan ke sebuah Page (link: order-detail) dideklarasikan di skema dan dipakai contoh di atas, tetapi belum ada renderer yang menerapkannya dan belum ada manifest yang memakainya. Yang belum ditetapkan: bagaimana:parampada route Page tujuan (/orders/:id) diisi dari record baris — konvensi yang sama belum dinyatakan untukCalendar(§5) maupunKanban. Selama itu,linkditerima skema tetapi tidak berefek; jangan andalkan ia bernavigasi.
ReportColumn.aggregate dan .format adalah himpunan tertutup — setiap nama diimplementasikan engine/renderer, jadi agregat atau format yang tidak dikenal tidak bisa ditulis (dulu string bebas, sehingga salah ketik lolos dan mencetak nilai mentah). ReportColumn.widget memakai set yang sama dengan TableColumn.widget (badge/boolean/image/qrcode), sehingga laporan bisa menampilkan badge atau QR, bukan hanya nilai mentah.
Open —
source.filter. Filter parameterized deklaratif (source: { entity, filter }dengan":param"placeholder) belum didukung skema — parameter saat ini dikirim sebagai filter query?<field>=<value>perparameters[]saat eksekusi report.
kind: Print
Dokumen cetak untuk satu entity, multi-target output:
apiVersion: formspec.dev/v1
kind: Print
metadata: { name: receipt, module: billing }
spec:
entity: order
output:
format: pdf # pdf | thermal | dotmatrix | html
paper: { size: A5, margin: 12mm }
header: { logo: true, title: "Receipt {order.number}" }
body:
- fields: [number, paid_at, customer.name]
- child_table: { field: items, columns: [product_id, quantity, price] }
- totals: { field: total, format: currency }
footer: { text: "Thank you — {tenant.name}" }| Format | Pipeline | Ukuran kertas | Kegunaan |
|---|---|---|---|
pdf (default) | Generate PDF server-side | A4, A5, Letter, Legal, custom | Invoice, surat jalan, laporan |
thermal | Server-side ESC/POS byte stream → printer mentah | thermal_58mm, thermal_80mm | Struk POS, slip apotek, tiket antrean |
dotmatrix | Teks polos + escape code server-side, printer continuous-feed | dotmatrix_80col, dotmatrix_136col | Pick list gudang, print akuntansi legacy |
html | window.print() client-side + CSS @media print — tanpa render server | Ukuran apa saja lewat CSS @page | Print browser-native, preview-sebelum-print |
Aturan: semua format kecuali html render server-side, hasil ke download tray; html render di browser. Kertas custom (custom: { width, height, unit }) divalidasi saat formspec validate. Print programatik: ctx.print(entity_id, "receipt") — pemilihan format per-manifest Print, bukan per-panggilan. ⏸️ Belum diimplementasi (todo 5.25.21): Print saat ini hanya dipicu dari UI (row action atau rute).
output.print_mode: unduh/cetak langsung vs pratinjau
output.print_mode dipilih per manifest (closed set: preview | direct; kosong = preview, perilaku yang sudah berlaku):
| Mode | Perilaku |
|---|---|
preview (default) | Dialog pratinjau terbuka dan menunggu klik Cetak/Simpan PDF |
direct | Aksi format berjalan begitu record termuat, sekali: html → dialog cetak peramban; pdf/thermal/dotmatrix → unduhan artifact server |
Yang menentukan aksi adalah format, bukan namanya: direct tidak memaksa window.print() pada dokumen yang dirender server (unduhan), dan tidak memaksa unduhan pada html (dialog cetak).
Dua batas yang disengaja:
- Opt-in per manifest, bukan per panggilan. Tidak ada parameter URL yang membalik mode — sebuah klik dari daftar tidak boleh diam-diam berubah menjadi cetak. (
?print=1sempat ada dan dicabut 2026-10-08 karena alasan itu.) - Sekali per pembukaan. Efek penggerak tidak berulang karena re-render; bila record gagal dimuat, tidak ada yang dijalankan (mencetak lembar sebelum record ada akan menaruh token
{…}literal di atas kertas).
Relasi bersarang: {relation.relation.field}
Interpolasi {dotted.path} menembus relasi belongs_to dua hop pada pembacaan single-record (detail, rute print, ekspor server). Jadi dokumen yang mencetak dining-table dapat menulis {branch.name} (hop 1) dan{branch.parent.name} (hop 2).
Dua batas yang harus diketahui penulis:
- Kedalaman 2. Dari sebuah meja,
branch.parentterjangkau; hop ketiga tidak. - Hanya jalur single-record. Daftar (
List) tetap satu hop — memperluas setiap baris dua level akan membesarkan payload dan jumlah query untuk data yang tidak dibaca satu sel tabel pun. Jadi{branch.parent.name}resolve di detail/print, tetapi tidak di dalam kolom Table.
Siklus dihentikan, bukan diikuti: relasi yang menunjuk kembali ke record yang sudah ada di jalur itu tidak di-expand lagi (mis. branch.parent_id yang membentuk lingkaran).
Dokumen berbasis asset
Dokumen yang tidak terbentuk dari header/body/footer dapat diserahkan sepenuhnya ke komponen asset — escape hatch yang sama dengan Page mode: custom:
spec:
entity: billing.invoice
asset: modules/billing/assets/invoice.js
binds:
entities: [billing.customer]
output: { format: html }assetmenggantikan layout deklaratif.header/body/footerwajib kosong — dua layout yang bisa bersamaan berarti salah satunya diam-diam diabaikan. (formspec validatemenolaknya.)bindsmenyatakan footprint entity/action yang boleh disentuh dokumen itu, ditegakkan client-side sepertineeds:pada komponen. Bentuknya tipe yang sama denganPageBindsmilikPage mode: custom— bukan salinan.- Menunggu sebelum mencetak. Markup asset datang dari modul yang di-import asinkron, dan saat mencetak lembar di-mount ulang di
#print-root; snapshot yang diambil terlalu cepat akan mencetak halaman kosong. Karena itu cetak menunggu salinan itu melapor siap, dengan batas waktu. Bila tidak pernah siap (asset 404), tidak ada yang dicetak — pengguna diberi tahu untuk mencoba lagi, bukan diserahkan lembar kosong.
Pratinjau: dialog, kertas fixed, ekspor
Rute /print/<name> dan /print/<name>/:id bukan halaman dokumen; ia halaman tipis dengan judul, format, ukuran kertas, dan tombol Pratinjau & Cetak yang membuka dialog. Dokumen dirender di dalam dialog.
- Kertas fixed dalam milimeter. Lebar kertas =
output.paperdalammm(A5 →148mm), bukan persen atau kelas responsif — sehingga line break identik di layar mana pun. Zoom pratinjau adalahtransform: scale(), yang tidak mengubah layout.paper.margindihormati (dipakai@page), tidak ada margin yang di-hardcode. - Cetak hanya dokumen. Saat mencetak, dokumen di-portal ke
#print-root(di luar#root) dan@media printmenyembunyikan seluruh anakbodykecuali#print-root; kerangka app (sidebar/topbar/menu) dan chrome dialog itu sendiri (header, footer, tombol "Cetak") tidak ikut tercetak.#print-rootsendiridisplay: nonedi layar — ia saudara#root, jadi tanpa itu lembar yang di-portal akan menambah tinggi halaman dan memunculkan scrollbar kanan/bawah saat mencetak. Aturan@page { size; margin }disuntik hanya selagi mencetak. - "Simpan PDF" jujur per format — tidak ada override format per-panggilan:
format: pdfmengunduh PDF server (GET /{ws}/_ui/print/{module}/{name}/{id},Content-Disposition: attachment);format: htmlmemakai dialog cetak peramban ("Save as PDF" di sana);thermal/dotmatrixadalah byte perangkat, bukan dokumen peramban — aksinya mengunduh byte stream, PDF tidak ditawarkan. - Tanpa
:id(menelusuri template) dialog tetap bisa dibuka, tetapi aksi cetak dan unduh nonaktif.
Ukuran fisik 1:1 (dan kenapa itu rapuh). Yang dicetak harus seukuran manifest, bukan melebar mengikuti kertas printer. Dua hal menjaga itu:
@page { size: <w>mm <h>mm; margin: <m>mm }memakai dimensi eksplisit, bukan nama (A5) — supaya ukuran tidak diam-diam menjadi default printer, dan browser memilih ukuran itu di dialog cetak.- Lembar menyesuaikan kotak cetak, bukan kotak kertas. Di layar lembar adalah kotak kertas penuh (
148mm) dengan margin sebagaipadding; saat mencetak browser yang memiliki margin, jadi area tercetak hanya148 − 2×10 = 128mm. Lembar wajib muat di situ (width: auto). Kalau tidak, ia lebih lebar dari area tercetak dan browser menskalakan ulang seluruh dokumen untuk memuatnya — terukur ≈88%, sehingga QRsize_mm: 60dan seluruh font tercetak ~12% lebih kecil dari yang dideklarasikan. (min-heightsaat mencetak = tinggi area tercetakh − 2m, bukan tinggi kertas, agar tidak melimpah ke halaman kedua.)
Karena size eksplisit, PDF yang dihasilkan berukuran halaman itu (terukur MediaBox 148.2 × 209.9mm) — bukan ukuran default printer.
Yang tidak bisa dijamin CSS: kalau di dialog cetak pengguna memilih kertas lain (mis. printer kantor tidak punya A5) atau mengubah Scale, browser menskalakan dokumen ke kertas itu. Itu keputusan perangkat, bukan dokumen — CSS tidak bisa memaksanya. Yang bisa dilakukan sudah dilakukan: ukuran dipilihkan otomatis, dan dokumen muat tepat di area tercetak sehingga pada kertas yang benar hasilnya 1:1.
QR, font, dan elemen lain konsisten satu sama lain. Semuanya memakai geometri tetap yang sama: QR size_mm → px (mm × 96/25.4), font dalam px (1px = 1/96 inci), kertas dalam mm. Karena tidak ada satu pun yang bergantung pada lebar viewport, ketiganya selalu sebanding satu sama lain — di layar mana pun, dan di atas kertas. Yang berubah hanyalah skala cetak keseluruhan (=1 pada kertas yang sesuai), dan perubahan itu menggeser semuanya bersama-sama, bukan merusak proporsi. Warna dokumen juga dipertahankan (print-color-adjust: exact) supaya shading yang terlihat di pratinjau ikut tercetak.
Konsekuensinya: dokumen tidak boleh memuat satu pun ukuran yang bergantung layar — tidak ada kelas responsif (sm:/md:/…) dan tidak ada satuan viewport (vw/vh/%) di dalam dokumen. Saat mencetak, viewport layout browser bukan lebar layar melainkan kotak konten halaman, jadi mengikuti viewport pun tidak akan terlihat di kertas — tetapi di pratinjau ia akan berbeda dari hasil cetak, dan itu yang dilarang. (Aturan ini dijaga oleh e2e/print-fidelity.spec.ts: hasil cetak dari 6 ukuran layar — mobile sampai 4K — harus identik.)
Pratinjau dari row action: overlay, bukan navigasi. Row action bernama print ({ action: print, view: "print:<name>" }) tidak bernavigasi ke rute print. Ia menuliskan dua param ke URL permukaan yang sedang aktif:
/dining-tables?print=<name>&print_id=<record>PrintPreviewHost (di-mount shell, pola yang sama dengan OverlayHost) membaca keduanya dan membuka dialog di atas permukaan itu. Alasan:
- Pengguna meminta mencetak satu baris, bukan meninggalkan daftar. Overlay ditutup di tempat; daftar tetap utuh (halaman, filter, posisi).
- Back browser menutup overlay, karena state-nya ada di URL — sama seperti modal form.
- Bekerja dari permukaan mana pun (Table, Kanban, …), tidak hanya dari rute yang bisa menavigasi ke rute print.
Nilai print_id adalah route segment baris tersebut — nilai yang sama yang akan menempati :id di rute print — sehingga entitas publik/QR mencetak identik. Menutup dialog hanya menghapus kedua param itu; param lain (page, filter) tidak tersentuh. Nama yang tidak resolve tidak merender apa pun (row action-nya sudah divalidasi saat build).
Rute /print/<name>[/:id] tetap ada sebagai alamat dokumen — untuk link langsung, entri menu, atau dibuka di tab sendiri.
Print programatik: ctx.print(entity_id, "receipt") — pemilihan format per-manifest Print, bukan per-panggilan.
Body item qrcode
Dokumen cetak bisa memuat QR — kartu meja, struk digital, tiket antrean:
body:
- totals: { field: total_amount, format: currency }
- qrcode:
payload: "/status/{guest_token}" # `{dotted.path}` — sama seperti header/footer
label: "Scan untuk struk digital"
absolute: true # origin dokumen ditambahkan di depan
size_mm: 30 # default 30payloadadalah payload, bukan nama field. Boleh satu token ("{qr_token}") atau URL relatif; token{dotted.path}diinterpolasi dari record yang dicetak.absolute: truemenambahkan origin dokumen yang sedang dicetak — browser memakai origin halaman (format: html), pipeline server memakai scheme+host permintaan (headerX-Forwarded-Proto/-Hostdihormati). Origin adalah pengetahuan deployment, bukan data record, jadi ia tidak disimpan di entity; payload yang sudah berupa URL absolut dibiarkan apa adanya.- Token yang tidak ter-resolve = elemen dihilangkan. Order yang dibuat di kasir tidak punya token tamu, jadi struknya tercetak tanpa QR — lebih baik daripada mencetak kode yang menuju
/status/dan tampak berfungsi.payloadyang kosong secara literal ditolak schema (minLength: 1), karena itu cacat manifest, bukan cacat data. - Ketiga pipeline merender:
html(klien, SVG),pdf(PNG ditanam di halaman),thermal(perintah QR native ESC/POSGS ( k— bukan raster, jadi tetap tajam). Di thermal,size_mmdipetakan ke module size (dot per modul), karena kertas 58mm tidak punya presisi milimeter.
Nilai money diformat, bukan di-stringify. Field dan totals bertipe money adalah objek {amount, currency}; dokumen cetak menampilkannya sebagai Rp62.500 — simbol dan pengelompokan mengikuti settings.currency + settings.locale, sama seperti permukaan lain. Angka biasa (mis. quantity) tidak pernah diperlakukan sebagai uang.
9. timeline / timeseries
Feed kronologis vertikal, dikelompokkan per tanggal — untuk audit trail append-only, activity log, rekam medis (ditulis sekali, tidak pernah diubah):
apiVersion: formspec.dev/v1
kind: Timeline
metadata:
name: patient-medical-history
module: clinic
spec:
entity: medical_record
bind_param: patient_id # konteks filter — dari route/parent page/nilai tetap
bind_value: ":patient_id"
display:
title_field: visit_date
subtitle_field: doctor.name
content_field: diagnosis_and_notes
icon_field: visit_type
group_by: date # date | month | year | none
sort: desc
page_size: 20Aturan: renderer tidak boleh menampilkan tombol create/edit/delete untuk entity Timeline — entity itu SEBAIKNYA menonaktifkan action update/delete (disabled: true, ../backend/01-core-basic.md §5), hanya menyisakan create sisi server; kalaupun masih ada, renderer mengabaikannya untuk Timeline — kind ini sendiri yang jadi guard. Infinite scroll cursor-based pakai created_at. Realtime: subscribe event created, card baru masuk di atas tanpa mengganggu posisi scroll. Custom card lewat display.component.
Kapan pakai Timeline vs Table: Timeline kalau urutan waktu adalah narasi utama (rekam medis, audit log, activity feed) — pada dasarnya cerita read-only. Table kalau user perlu sort/filter/operate baris — permukaan operasional.
10. listing
Katalog publik (e-commerce, movie search) — pasangan alami App access: public (biasanya app_renderer: no-nav, 05-app-kinds.md §4). Secara struktural mirip table-list (§3) tapi tanpa asumsi Auth-wrap dari App renderer-nya, dan tanpa row_actions/bulk_actions yang menyiratkan operasi tulis terautentikasi.
11. approval-inbox
Task-queue "persetujuan saya" — daftar step approval Workflow (../backend/02-core-extended.md §2) yang menunggu tindakan caller. Instance VisualSpecKind tier: page. Tipis: mesin approval hidup di backend, kind ini hanya permukaan standarnya.
apiVersion: formspec.dev/v1
kind: ApprovalInbox
metadata: { name: my-approvals, module: core }
spec:
realtime: trueZero-config: sumber adalah step Workflow pending yang eligible untuk caller (keanggotaan role per step, § core-extended §2) — lintas semua entity/module dalam App, permission-filtered otomatis. Caller hanya melihat approval yang boleh ia tindak; "pemohon tak pernah menyetujui permintaannya sendiri" ditegakkan backend, bukan disembunyikan di UI. Tiap baris menampilkan entity terkait + ringkasan + langkah saat ini; klik baris → detail entity (konteks penuh sebelum memutuskan).
Sumbernya bukan entity, jadi ia punya surface sendiri. Barisnya hidup di tabel framework formspec_workflow_approval, sehingga tidak ada route entity yang bisa mengeksposnya. Kontrak HTTP-nya:
GET /{ws}/_ui/workflow/approvals?app={app}
POST /{ws}/_ui/workflow/approvals/{id} → {"decision":"approve"|"reject"}Item yang dikembalikan: {id, entity, record_id, workflow, from, to, active_step, total_steps, title, description, display_fields, can_decide, created_at}. Aturan yang mengikat renderer:
title/descriptionberasal daristeps[].title/description, dandisplay_fieldsdaristeps[].display_fields— nilai record yang approver butuhkan untuk memutuskan, dibaca server (bukan oleh klien, yang belum tentu boleh membacanya). Entridisplay_fieldsmembawalabel+typedari deklarasi field entity, dan nilainya jatuh ke input pemohon (paramsbaris approval) bila record belum memilikinya — transisi yang di-intercept tidak menulis apa pun sampai approval selesai, jadi tanpa fallback itu justru field keputusan (void_reason) tampil kosong.can_decidememisahkan “terdaftar” dari “boleh dijalankan”. Keanggotaan role pada step menentukan apa yang muncul; permission yang menggerbangi route transisi menentukan apa yang bisa dieksekusi. Baris dengancan_decide: falsetetap ditampilkan (itu antrean caller) dan tombolnya non-aktif — bukan disembunyikan, karena tugas yang tak bisa ia kerjakan tetap perlu terlihat.display_fieldshanya terisi bila caller memegang{module}.{plural}.view. Tugasnya tetap terlihat; nilainya tidak.- Keputusan
POSTmasuk lewat mesin approval yang sama dengan halaman record: quorum, larangan menyetujui permintaan sendiri, audit bertanda tangan, dan emit event transisi semuanya berlaku.
Action inline approve/reject per baris = pencatatan approval bertanda tangan di Workflow (§ core-extended §2); reject mengikuti on_reject. Transisi yang di-intercept baru eksekusi setelah quorum seluruh step tercapai — di luar tanggung jawab kind ini. Badge count = jumlah pending caller. filters/search opsional seperti Table.
12. notification-center
Permukaan in-app notifikasi yang terkirim ke user saat ini — sumbernya module resmi formspec/notify (bridge delivery notification dari Subscription, ../backend/02-core-extended.md §3). Instance VisualSpecKind tier: page.
apiVersion: formspec.dev/v1
kind: NotificationCenter
metadata: { name: notifications, module: core }
spec:
realtime: trueZero-config: daftar notifikasi caller (terurut terbaru), badge unread, aksi mark-read (per item + mark-all). Notifikasi per-user dan tenant/workspace- scoped seperti semua data. realtime: true default — item baru masuk in-place dan badge unread naik (04-spec-resolution-api.md §5). Klik notifikasi → navigasi ke deep-link entity/Page yang dirujuknya (bila ada). Template pesan & channel provider (email/push/in-app) hidup di formspec/notify, bukan di kontrak ini (§ core-extended §3) — kind ini hanya permukaan in-app-nya.
13. Custom Page — Escape Hatch Expert
Untuk layar yang tak berpola sama sekali: kind: Page dengan mode: custom menyerahkan seluruh rendering ke kode programmer, sambil tetap men-declare footprint backend yang ia konsumsi.
Open —
mode: custom/binds. Custom Page belum didukung skemaPageSpecmaupun renderer (Page saat ini hanyablocks/tabs) — ditracking didocs_internal/plan/todo.md. Kontrak di bawah adalah target desain.
apiVersion: formspec.dev/v1
kind: Page
metadata: { name: dispatch-console, module: logistics }
spec:
route: /dispatch
mode: custom
asset: logistics/assets/dispatch-console.js
binds:
entities: [shipment, vehicle, driver]
actions: [shipment.assign, shipment.dispatch]
subscribe: [logistics.shipment]binds adalah footprint consent/permission Page ini — peran yang sama dengan needs: milik component (07-component-kinds.md §4): entity/service/action yang boleh ia sentuh. Panggilan di luar binds gagal client-side, dan tak pernah diotorisasi server-side juga (enforcement selalu di resource, ../backend/01-core-basic.md §5).
Yang diinjeksikan ke asset (kontrak mount 07-component-kinds.md §4): client typed per entity yang di-bind, formspec.api, formspec.subscribe, formspec.navigate, formspec.ui, dan token formspec.theme. Programmer menguasai 100% markup — tapi wajib mengikuti Shell tempat ia hidup: di shadcn shell berarti React + shadcn (stack_family, 01-visual-hierarchy.md §3); kode custom tak lintas Shell.
Beda dari full-custom via satu component: (§1): keduanya menyerahkan render ke asset, tapi mode: custom men-declare footprint backend di level Page (bukan per-blok needs:) dan tak punya blocks/tabs sama sekali — Page itu sepenuhnya milik programmer. Ini anak tangga teratas kontrol frontend: Form terkelola → custom widget → component → custom Page → headless form engine → raw formspec.api (07-component-kinds.md §4).
14. Derivasi Otomatis & UI 3-Layer Wrapping
FormSpec UI mengikuti model 3-layer wrapping yang ketat. Memahami model ini adalah kunci untuk tahu kapan perlu mendeklarasikan UI kind vs membiarkan engine men-derive semuanya secara otomatis.
┌─────────────────────────────────────────────┐
│ PAGE (route + composition) │
│ /app/klinik/invoice/create │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ FORM / TABLE (layout override) │ │
│ │ visible_when, readonly_when, ... │ │
│ │ │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ ENTITY (data model) │ │ │
│ │ │ fields, state_machine, │ │ │
│ │ │ permissions, actions │ │ │
│ │ └─────────────────────────────────┘ │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘Layer 0 — Entity (selalu ada)
Tiap Entity otomatis menghasilkan, tanpa manifest UI sama sekali:
- REST API endpoint (UI surface:
/_ui/entity/— lihatbackend/01-core-basic.md§8.1) - Table — list/browse view dengan kolom terderivasi (§3.1)
- Form create — form input data baru
- Form edit — form ubah data existing
- Page detail — halaman detail satu record (read-only)
- Entry navigasi turunan di menu App (dikelompokkan per module Entity)
Ini mencakup 80-95% kebutuhan UI aplikasi bisnis. Developer tidak perlu menulis satu pun UI kind untuk mayoritas entity.
Aturan Wrapping — Kapan Override Diperlukan
| Kamu deklarasi | Engine auto-derive | Kapan override? |
|---|---|---|
Entity saja | Default Table + Form(create) + Form(edit) + Page(detail) | Field order/layout khusus, hide field, group field, validasi custom, multi-entity composition |
Form (public: true) | Auto-wrapped dalam Page, route /<module>/form/<name> | Form ini perlu Page kustom (multi-tab, side panel, master-detail) |
Table (public: true) | Auto-wrapped dalam Page, route /<module>/table/<name> | Table ini perlu Page kustom |
Page | Route langsung — tidak ada wrapping tambahan | — (Page selalu eksplisit) |
Form/Table (public: false) | Tidak punya route; hanya bisa di-embed di Page lain | — |
Decision Flow
Apakah auto-derived UI dari Entity cukup?
├── YA → Done. Tidak perlu deklarasi UI kind apapun.
└── TIDAK → Apa yang perlu diubah?
├── Urutan/label/hide field → deklarasi kind: Form
├── Kolom/sort/filter → deklarasi kind: Table
├── Komposisi multi-entity → deklarasi kind: Page (blocks/tabs)
├── Dashboard/report/wizard → deklarasi UI kind sesuai
└── Custom component → deklarasi kind: Page dengan asset blockpublic — Kontrol Route Auto-Generated
Setiap visual kind punya field public (default true):
public | Perilaku |
|---|---|
true (default) | Engine auto-generate Page wrapper + route /<module>/<kind-lowercase>/<name>. Kind bisa di-navigate langsung atau di-embed di Page lain. |
false | Tidak ada route. Kind hanya bisa tampil sebagai blok di dalam Page yang authored secara eksplisit. |
kind: Form
metadata:
name: quick-create-invoice
module: billing
spec:
public: false # embed-only — tidak punya route mandiri
entity: billing.invoice
mode: createPrinsip Kunci
- Entity dulu, override belakangan. Tulis semua Entity, jalankan
formspec dev, lihat hasil auto-derived-nya, baru putuskan mana yang butuh override. - Jangan over-engineer. Mayoritas entity tidak butuh UI kind sama sekali. Kalau menulis
kind: Formuntuk setiap entity, itu anti-pattern. - Override minimal. Kalau cuma perlu mengubah 1-2 field, tulis Form dengan hanya field yang berbeda — sisanya tetap auto-derived.
- Page = komposisi. Page dipakai saat satu layar butuh banyak entity (master-detail, tabs, multi-block). Bukan untuk sekadar mengubah tampilan satu entity — itu domain Form/Table.