`; header, tab bar, dan head tetap. `login.html` tidak memuat htmx; form logout `hx-boost="false"`.
+- Elemen htmx lain di dalam halaman (stats card, segmented) wajib `hx-target` sendiri dan `hx-select="unset"` agar tidak mewarisi `#main`.
+- Skrip halaman **idempoten**: dipanggil pada `DOMContentLoaded` dan `htmx:afterSettle`, dengan guard `data-init` (lihat `list.js`, `formatTimes` di `app.js`). Listener ke `document`/`window` dari skrip inline dilarang (menumpuk setiap swap): pasang ke `#main` atau elemen di dalamnya. Sumber daya hidup (WebSocket, xterm) mendaftar fungsi pembersih ke `window.dockifyCleanups`; `app.js` memanggilnya pada `htmx:beforeSwap` target `#main`.
+- Form dengan `onsubmit` yang bisa membatalkan harus memvalidasi lagi di `htmx:beforeRequest` (htmx tetap mengirim saat `onsubmit` mengembalikan false).
+- Respons tanpa `#main` (redirect ke login) jatuh ke load penuh; 4xx/5xx boosted tetap di-swap (halaman error).
+- Preload: atribut `preload="mousedown"` (touchstart otomatis) pada nav dan tautan baris; server menjawab permintaan `HX-Preloaded` dengan `Cache-Control: private, max-age=5`.
+- PWA: `static/manifest.webmanifest`, `sw.js?v=AssetVersion` (hanya `/static/*`, cache-first; HTML/API tidak pernah di-cache), `Service-Worker-Allowed: ../`.
+
Bahasa UI Inggris, label tanpa titik dua, tombol kata kerja spesifik, tanpa emoji.
## 8. Checklist PR UI
diff --git a/docs/UI-REVAMP-PLAN.md b/docs/UI-REVAMP-PLAN.md
index 2225ce7..02f0a38 100644
--- a/docs/UI-REVAMP-PLAN.md
+++ b/docs/UI-REVAMP-PLAN.md
@@ -98,6 +98,7 @@ PR dengan bukti (screenshot 3 viewport × 2 tema untuk PR UI, tabel perf sebelum
| S6 | Form (apps add/edit, servers add/edit, settings, import/export): label 12.8px, input 16px, select bergaya, modal → sheet, baris env/file/domain memakai grid kolom tetap; Settings → Appearance: segmented Palette (3) dan Theme (Light/Dark/System) (K10) | Inline style = 0 di template form; iOS tidak zoom; palet dan tema tersimpan dan bertahan setelah reload | Sonnet | screenshot 390 |
| S7 | `tests/e2e/ui.mjs` (K8) + perbaikan temuan perf/a11y/kontras | Semua target §9/§10 terpenuhi; tabel sebelum/sesudah | Haiku jalankan (low), Sonnet perbaiki | tabel perf, laporan kontras |
| S8 | Audit akhir leader (worktree `../dockify-audit`), perbarui `SPEC.md` §UI Style Guide → rujuk `docs/DESIGN-SYSTEM.md`, `README` screenshot, release patch | Checklist §11 semua tercentang; `./scripts/release.sh` | Fable | temuan bernomor, tag rilis |
+| S9 | Navigasi instan: `hx-boost` di `` (hanya `#main` di-swap), ekstensi `preload` + `head-support` (vendor di `static/`), garis progres 3px, PWA ringan (manifest, ikon 192/512, `sw.js` cache `/static/*`), skrip halaman idempoten | Klik tab ≤150 ms (preload) / ≤250 ms (tanpa) di CPU 4x + 80 ms; header tidak dirender ulang; 10 bolak-balik tanpa kebocoran node; `ui.mjs` lolos dengan auth; login/logout tanpa boost | Sonnet (medium) | `docs/ui-revamp/perf-s9.md`, screenshot 390 |
Perkiraan: 8 PR + 1 PR dokumen ini. Eksekutor: satu sesi Sonnet (`dockify · eksekusi ui`), effort medium, dibuat setelah S0 selesai; ditutup setelah S7 di-merge.
diff --git a/docs/ui-revamp/perf-s9.md b/docs/ui-revamp/perf-s9.md
new file mode 100644
index 0000000..605b68a
--- /dev/null
+++ b/docs/ui-revamp/perf-s9.md
@@ -0,0 +1,27 @@
+# Perf S9 (hx-boost + preload + head-support), cabang `feat/ui-s9-boost`
+
+`UI_USER=admin UI_PASS=uji123 node tests/e2e/ui.mjs http://127.0.0.1:18090
` (build mock, viewport 390, CPU 4x, latensi 80 ms, median 5).
+Sebelum = `perf-sesudah.md` (S7, navigasi dokumen penuh); sesudah = run ini.
+
+## Navigasi antar halaman utama
+
+| Kasus | Sebelum (S7) | Sesudah (S9) | Target |
+| --- | --- | --- | --- |
+| Klik tab Servers → Apps, boost, preload (touchstart dulu) | - | **65 ms** (`htmx:beforeRequest` → `afterSettle`) | ≤150 ms ✅ |
+| Sama, tanpa preload | - | **153 ms** | ≤250 ms ✅ |
+| `goto` penuh dashboard / servers / apps / app-detail (DCL) | 300 / 301 / 301 / 328 ms | 190 / 180 / 190 / 197 ms | (bukan target; sama persis halaman penuh) |
+
+Header tidak dirender ulang (`isConnected` true), `aria-current` dan `document.title` mengikuti halaman, scroll ke atas, Back/Forward bekerja.
+Kebocoran: 10 bolak-balik daftar ↔ detail app (grafik live + WebSocket): node DOM 236 → 236, daftar `dockifyCleanups` kosong di halaman daftar.
+
+## Transfer dingin (di kawat)
+
+| Halaman | KB S7 → S9 | Request S7 → S9 |
+| --- | --- | --- |
+| dashboard | 126 → 207 | 7 → 12 |
+| servers | 120 → 196 | 7 → 12 |
+| apps | 122 → 203 | 7 → 12 |
+| app-detail | 192 → 206 | 10 → 13 |
+
+Kenaikan cold-load (+~80 KB, +5 request) berasal dari htmx 2.0.4 + 2 ekstensi + charts.js + console.js yang kini dimuat di semua halaman, plus manifest/ikon.
+Sekali dimuat, service worker meng-cache `/static/*` (cache-first); kompresi gzip HTTP tetap tugas terpisah yang sudah dicatat.
diff --git a/internal/http/router.go b/internal/http/router.go
index 7dfa977..dcbe1f5 100644
--- a/internal/http/router.go
+++ b/internal/http/router.go
@@ -270,6 +270,9 @@ func serveStatic(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
+ if strings.HasSuffix(name, ".webmanifest") {
+ w.Header().Set("Content-Type", "application/manifest+json")
+ }
if name == "sw.js" {
// The worker script must always be revalidated; it controls the whole app path (scope ".").
w.Header().Set("Cache-Control", "no-cache")
@@ -293,10 +296,6 @@ func securityHeaders(next http.Handler) http.Handler {
h.Set("Content-Security-Policy", contentSecurityPolicy)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
- // htmx preload (hover/touch prefetch) relies on the browser cache: let the prefetched page live a few seconds.
- if r.Method == http.MethodGet && r.Header.Get("HX-Preloaded") == "true" {
- h.Set("Cache-Control", "private, max-age=5")
- }
next.ServeHTTP(w, r)
})
}
diff --git a/internal/http/static/app.js b/internal/http/static/app.js
index 7a58a99..f7bd28d 100644
--- a/internal/http/static/app.js
+++ b/internal/http/static/app.js
@@ -115,7 +115,25 @@ function syncNav() {
else a.removeAttribute('aria-current');
});
}
-document.addEventListener('DOMContentLoaded', function () { formatTimes(); });
+// Auto-refresh of status pages (deploying/initializing): re-fetches itself; the timer is a page cleanup.
+var pollTimer = null;
+function startPoll() {
+ clearTimeout(pollTimer);
+ var m = document.getElementById('main'), ms = m && Number(m.getAttribute('data-poll'));
+ if (!ms || !window.htmx) return;
+ pollTimer = setTimeout(function () {
+ htmx.ajax('GET', location.href, { target: '#main', select: '#main', swap: 'outerHTML' });
+ }, ms);
+ (window.dockifyCleanups = window.dockifyCleanups || []).push(function () { clearTimeout(pollTimer); });
+}
+// Everything a page needs after its #main arrives (swap or history restore); idempotent.
+function pageInit() {
+ formatTimes();
+ syncNav();
+ if (window.dockifyApplyTheme) window.dockifyApplyTheme(); // head merge replaces
+ startPoll();
+}
+document.addEventListener('DOMContentLoaded', function () { formatTimes(); startPoll(); });
if (window.htmx) {
var progressTimer = null;
function progress(show) {
@@ -137,19 +155,20 @@ if (window.htmx) {
var d = e.detail;
if (d.boosted) {
// Login redirect, session expiry, anything without #main: fall back to a full page load.
- if (d.xhr.responseText.indexOf('id="main"') < 0) {
+ var doc = new DOMParser().parseFromString(d.xhr.responseText, 'text/html');
+ if (!doc.getElementById('main')) {
d.shouldSwap = false;
progress(false);
- location.assign(d.xhr.responseURL || location.href);
+ // A GET can be repeated as a normal navigation; a POST must not be replayed as one.
+ if (d.requestConfig.verb === 'get') location.assign(d.xhr.responseURL || location.href);
+ else showToast('The server did not return a page. Reload and check the result.', true);
return;
}
// 4xx/5xx pages (error.html) are shown like any other page.
if (d.xhr.status >= 400) { d.shouldSwap = true; d.isError = false; }
}
if (d.target && d.target.id === 'main') {
- var c = window.dockifyCleanups || [];
- window.dockifyCleanups = [];
- c.forEach(function (f) { try { f(); } catch (err) {} });
+ runCleanups();
}
});
// head-support: never drop scripts/styles already loaded (fonts link mutates media=all, xterm is added at runtime).
@@ -164,11 +183,17 @@ if (window.htmx) {
if (url && document.head.querySelector((el.tagName === 'LINK' ? 'link[href="' : 'script[src="') + url + '"]')) e.preventDefault();
});
document.addEventListener('htmx:afterSettle', function (e) {
- formatTimes();
- syncNav();
- if (window.dockifyApplyTheme) window.dockifyApplyTheme(); // head merge replaces
+ pageInit();
if (e.detail.boosted) window.scrollTo(0, 0);
});
+ // Back/Forward never fire beforeSwap/afterSettle: close live sockets before the restored body is swapped in, re-init after.
+ function runCleanups() {
+ var c = window.dockifyCleanups || [];
+ window.dockifyCleanups = [];
+ c.forEach(function (f) { try { f(); } catch (err) {} });
+ }
+ document.addEventListener('htmx:historyCacheMissLoad', runCleanups);
+ document.addEventListener('htmx:historyRestore', pageInit);
// Back/forward: do not restore stale DOM snapshots; fetch the page again.
htmx.config.historyCacheSize = 0;
}
diff --git a/internal/http/static/list.js b/internal/http/static/list.js
index dab768c..ee52ae0 100644
--- a/internal/http/static/list.js
+++ b/internal/http/static/list.js
@@ -59,4 +59,5 @@
}
document.addEventListener('DOMContentLoaded', init);
document.addEventListener('htmx:afterSettle', init);
+ document.addEventListener('htmx:historyRestore', init);
})();
diff --git a/internal/http/static/manifest.webmanifest b/internal/http/static/manifest.webmanifest
index 340ebd2..eba7499 100644
--- a/internal/http/static/manifest.webmanifest
+++ b/internal/http/static/manifest.webmanifest
@@ -1,8 +1,9 @@
{
"name": "Dockify",
"short_name": "Dockify",
- "start_url": ".",
- "scope": ".",
+ "id": "../",
+ "start_url": "../",
+ "scope": "../",
"display": "standalone",
"theme_color": "#f5f7fb",
"background_color": "#f5f7fb",
diff --git a/internal/http/templates.go b/internal/http/templates.go
index 9fac301..a490c17 100644
--- a/internal/http/templates.go
+++ b/internal/http/templates.go
@@ -287,6 +287,10 @@ func Render(w http.ResponseWriter, r *http.Request, status int, name string, dat
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ // htmx preload (hover/touch prefetch) relies on the browser cache: let a prefetched page live a few seconds.
+ if status == http.StatusOK && r.Method == http.MethodGet && r.Header.Get("HX-Preloaded") == "true" {
+ w.Header().Set("Cache-Control", "private, max-age=5")
+ }
w.WriteHeader(status)
buf.WriteTo(w)
}
diff --git a/internal/http/templates/apps_add.html b/internal/http/templates/apps_add.html
index 343b810..58c7484 100644
--- a/internal/http/templates/apps_add.html
+++ b/internal/http/templates/apps_add.html
@@ -472,6 +472,10 @@ Basic auth
document.getElementById('advanced-section').hidden = !adv;
});
});
+// Boosted submit: htmx sends the request even when onsubmit returned false, so validate again here and cancel.
+document.getElementById('app-form').addEventListener('htmx:beforeRequest', function (e) {
+ if (!validateServer()) e.preventDefault();
+});
var serverSel = document.getElementById('server-select');
if (serverSel) serverSel.addEventListener('change', function () { document.getElementById('server-error').hidden = true; });
(function () {
diff --git a/internal/http/templates/apps_detail.html b/internal/http/templates/apps_detail.html
index c52954e..70abef6 100644
--- a/internal/http/templates/apps_detail.html
+++ b/internal/http/templates/apps_detail.html
@@ -1,9 +1,9 @@
-{{template "head" .}}{{if eq .App.Status "deploying"}}{{end}}
+{{template "head" .}}
{{template "nav" .}}
-
+
{{if .Flash}}
{{.Flash}}
{{end}}
Apps
@@ -116,7 +116,6 @@
Resource usage
+