Skip to content

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.

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

yaml
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:

yaml
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:

yaml
# 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 defaults khusus: field-nya diberi default_from dan widget: 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_from menemplat {dotted.path} dari render context (spec.context + slot user/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:

yaml
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 seleksi

binds:

  • source — nama blok list (Table/listing) yang jadi sumber seleksi.
  • param — field record terpilih yang diinjeksikan ke blok detail sebagai konteksnya (biasanya id, menggantikan peran :id route).
  • 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 skema PageSpec/BlockRef maupun renderer — ditracking di docs_internal/plan/todo.md. Saat ini master-detail dilakukan via param + route (:id) biasa.

2. data-entry (kind: Form) ​

Layout + perilaku input satu Entity, menggantikan form hasil derivasi:

yaml
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:

renderPerilakuKapan dipakai
modal (default)Dialog overlay di atas Page saat ini; route tak berubah, state di baliknya tetap adaEntity ringan (≤5 field), create/edit cepat tanpa kehilangan konteks list
drawerPanel slide-in dari kanan, sama sifatnya dengan modal tapi lebih lebarForm medium (5–12 field), khususnya columns: 2
separate_pageRoute sendiri, breadcrumb + URL sendiriEntity 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:

yaml
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":

PertanyaanJawaban
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/ multiple field itu, dan nilainya disimpan ke field tersebut. Input ad-hoc wajib punya type dan 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_when yang menyempitkan), bukan dipecah per asal.
  • render.mode adalah keputusan design-time seperti Form.render; bila tidak ditulis, container diturunkan dari jumlah input (≤5 modal, 5–12 drawer, >12 separate_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 field

label 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 ada

FormField.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.
PolaKapan dipakaiUI
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 manualDraft sengaja dipisah untuk direview orang lain duluTombol "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:

yaml
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:

yaml
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):

    1. field natural_key;
    2. field label_field — yang di-resolve server sebagai display_field → natural_key → name/title/number → id (04-spec-resolution-api.md §2);
    3. field status (state_machine.field);
    4. field bernama transaction_date;
    5. sisanya, sesuai urutan deklarasi field di Document.

    Yang tidak ikut: field computed dan field child.

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

yaml
kind: Table
spec:
  entity: order
  columns:
    - { field: number, label: "No." }
    - {
        field: total,
        label: "Total",
        format: currency,
        align: right,
        width: "140px",
      }
PropertiNilaiEfek
alignleft · center · rightPerataan teks sel header dan sel body kolom itu.
widthpanjang 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-align tidak diwarisi. Meratakan header saja menghasilkan judul rata kanan di atas angka rata kiri.
  • Header yang sortable membungkus labelnya di flex row; text-align tidak 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:

formatNilai yang diharapkanHasil
currency{amount, currency} / angkaRp50.000 — simbol & skala dari settings.currency
numberangka20.000 — pemisah ribuan dari settings.locale
datestring tanggalmengikuti settings.date_format
relativestring waktu"3d ago"
percentangka10%

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:

yaml
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:

yaml
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. Bila default diisi, 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:

FieldWajibDeskripsi
fieldyaNama field entity (boleh dot-path relation, mis. patient.name)
labeltidakLabel kontrol; fallback field
typetidakselect (default) · text · date · date_range
optidakOperator filter API — default eq (set operator backend §6)
defaulttidakNilai seed. Mendukung today / today() (resolver = tanggal server, UTC)
show_alltidakHanya tipe select: tampilkan opsi "All" (clear). Default true
all_labeltidakHanya 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.

yaml
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: true

Kontrak 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 kewajiban status_field) belum diimplementasikan — ditracking di docs_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 unik status_field — urut sesuai urutan transisi (state machine) atau urutan deklarasi enum_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 via transisi 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 di docs_internal/plan/kanban-full-implementation.md. Validasi server (guard state machine) tetap otoritas dan sudah berjalan.

Open — WIP limit. wip_limit per kolom (batas jumlah kartu, soft pre-check UX) belum ada di skema columns — ditracking di docs_internal/plan/kanban-full-implementation.md. Pengganti saat ini: max_cards_per_column (integer, level board) di KanbanSpec.

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:

yaml
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: true

Filter & 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: true tanpa position_field adalah 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).

yaml
apiVersion: formspec.dev/v1
kind: Calendar
metadata: { name: appointment-calendar, module: clinic }
spec:
  entity: appointment
  date_field: scheduled_at

Zero-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) — override label_field derived.
  • resource_field (opsional) — mengaktifkan view resource, satu lajur per nilai (biasanya field relation, mis. doctor_id).
  • color_field (opsional) — pewarnaan kategori.

Interaksi:

  • Klik event → detail/form (sama seperti link kolom Table).
  • Klik slot kosong → Form create dengan date_field ter-prefill.
  • Drag reschedule → memanggil action update yang mengubah date_field (dan end_field bergeser proporsional untuk rentang); validasi server-side otoritas (guard lifecycle + rules, ../backend/01-core-basic.md §5). Permission = permission update entity. Record submitted immutable 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:

yaml
- { 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:

yaml
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 melakukan create biasa di entity pakai data step terakumulasi — tiap field yang dibutuhkan entity itu wajib sudah resolved dari step sebelumnya.
  • on_complete.restart: true mengosongkan stepData, kembali ke step 0 — untuk alur gaya front-desk (daftar satu pasien, lanjut ke berikutnya). redirect navigasi ke path lain, diabaikan kalau restart: true. banner merender info dari submission yang baru selesai, di-resolve terhadap response.* (response API submit final) — bukan stepData (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) — stepData autosave ke localStorage kunci wizard:{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:

yaml
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 }
yaml
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 type

DashboardWidget = { 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 di docs_internal/plan/todo.md §5.7.

8. report dan print ​

kind: Report ​

Output tabular terparameterisasi:

yaml
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:

PropertiTableColumnReportColumn
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 :param pada route Page tujuan (/orders/:id) diisi dari record baris — konvensi yang sama belum dinyatakan untuk Calendar (§5) maupun Kanban. Selama itu, link diterima 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> per parameters[] saat eksekusi report.

kind: Print ​

Dokumen cetak untuk satu entity, multi-target output:

yaml
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}" }
FormatPipelineUkuran kertasKegunaan
pdf (default)Generate PDF server-sideA4, A5, Letter, Legal, customInvoice, surat jalan, laporan
thermalServer-side ESC/POS byte stream → printer mentahthermal_58mm, thermal_80mmStruk POS, slip apotek, tiket antrean
dotmatrixTeks polos + escape code server-side, printer continuous-feeddotmatrix_80col, dotmatrix_136colPick list gudang, print akuntansi legacy
htmlwindow.print() client-side + CSS @media print — tanpa render serverUkuran apa saja lewat CSS @pagePrint 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):

ModePerilaku
preview (default)Dialog pratinjau terbuka dan menunggu klik Cetak/Simpan PDF
directAksi 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=1 sempat 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.parent terjangkau; 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:

yaml
spec:
  entity: billing.invoice
  asset: modules/billing/assets/invoice.js
  binds:
    entities: [billing.customer]
  output: { format: html }
  • asset menggantikan layout deklaratif. header/body/footer wajib kosong — dua layout yang bisa bersamaan berarti salah satunya diam-diam diabaikan. (formspec validate menolaknya.)
  • binds menyatakan footprint entity/action yang boleh disentuh dokumen itu, ditegakkan client-side seperti needs: pada komponen. Bentuknya tipe yang sama dengan PageBinds milik Page 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.paper dalam mm (A5 → 148mm), bukan persen atau kelas responsif — sehingga line break identik di layar mana pun. Zoom pratinjau adalah transform: scale(), yang tidak mengubah layout. paper.margin dihormati (dipakai @page), tidak ada margin yang di-hardcode.
  • Cetak hanya dokumen. Saat mencetak, dokumen di-portal ke #print-root (di luar #root) dan @media print menyembunyikan seluruh anak body kecuali #print-root; kerangka app (sidebar/topbar/menu) dan chrome dialog itu sendiri (header, footer, tombol "Cetak") tidak ikut tercetak. #print-root sendiri display: none di 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: pdf mengunduh PDF server (GET /{ws}/_ui/print/{module}/{name}/{id}, Content-Disposition: attachment); format: html memakai dialog cetak peramban ("Save as PDF" di sana); thermal/dotmatrix adalah 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:

  1. @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.
  2. Lembar menyesuaikan kotak cetak, bukan kotak kertas. Di layar lembar adalah kotak kertas penuh (148mm) dengan margin sebagai padding; saat mencetak browser yang memiliki margin, jadi area tercetak hanya 148 − 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 QR size_mm: 60 dan seluruh font tercetak ~12% lebih kecil dari yang dideklarasikan. (min-height saat mencetak = tinggi area tercetak h − 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:

yaml
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 30
  • payload adalah payload, bukan nama field. Boleh satu token ("{qr_token}") atau URL relatif; token {dotted.path} diinterpolasi dari record yang dicetak.
  • absolute: true menambahkan origin dokumen yang sedang dicetak — browser memakai origin halaman (format: html), pipeline server memakai scheme+host permintaan (header X-Forwarded-Proto/-Host dihormati). 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. payload yang 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/POS GS ( k — bukan raster, jadi tetap tajam). Di thermal, size_mm dipetakan 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):

yaml
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: 20

Aturan: 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.

yaml
apiVersion: formspec.dev/v1
kind: ApprovalInbox
metadata: { name: my-approvals, module: core }
spec:
  realtime: true

Zero-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/description berasal dari steps[].title/description, dan display_fields dari steps[].display_fields — nilai record yang approver butuhkan untuk memutuskan, dibaca server (bukan oleh klien, yang belum tentu boleh membacanya). Entri display_fields membawa label + type dari deklarasi field entity, dan nilainya jatuh ke input pemohon (params baris 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_decide memisahkan “terdaftar” dari “boleh dijalankan”. Keanggotaan role pada step menentukan apa yang muncul; permission yang menggerbangi route transisi menentukan apa yang bisa dieksekusi. Baris dengan can_decide: false tetap ditampilkan (itu antrean caller) dan tombolnya non-aktif — bukan disembunyikan, karena tugas yang tak bisa ia kerjakan tetap perlu terlihat.
  • display_fields hanya terisi bila caller memegang {module}.{plural}.view. Tugasnya tetap terlihat; nilainya tidak.
  • Keputusan POST masuk 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.

yaml
apiVersion: formspec.dev/v1
kind: NotificationCenter
metadata: { name: notifications, module: core }
spec:
  realtime: true

Zero-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 skema PageSpec maupun renderer (Page saat ini hanya blocks/tabs) — ditracking di docs_internal/plan/todo.md. Kontrak di bawah adalah target desain.

yaml
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/ — lihat backend/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 deklarasiEngine auto-deriveKapan override?
Entity sajaDefault 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
PageRoute 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 block

public — Kontrol Route Auto-Generated ​

Setiap visual kind punya field public (default true):

publicPerilaku
true (default)Engine auto-generate Page wrapper + route /<module>/<kind-lowercase>/<name>. Kind bisa di-navigate langsung atau di-embed di Page lain.
falseTidak ada route. Kind hanya bisa tampil sebagai blok di dalam Page yang authored secara eksplisit.
yaml
kind: Form
metadata:
  name: quick-create-invoice
  module: billing
spec:
  public: false # embed-only — tidak punya route mandiri
  entity: billing.invoice
  mode: create

Prinsip 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: Form untuk 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.

Standar terbuka (CC0) dengan implementasi referensi.