diff --git a/docs/DESIGN-SYSTEM.md b/docs/DESIGN-SYSTEM.md index 5916dcd..ac9e651 100644 --- a/docs/DESIGN-SYSTEM.md +++ b/docs/DESIGN-SYSTEM.md @@ -15,7 +15,7 @@ Tanpa framework CSS, tanpa Node saat build/runtime. Font: Plus Jakarta Sans 400/ - Preferensi di `localStorage`: `dockify_palette` (`indigo` default) dan `dockify_theme` (`light|dark|system`, **default `system`**). Kunci lama `dockify-theme` dibaca sekali lalu dihapus. - Skrip bootstrap **inline di `
`** (sebelum CSS) menulis kedua atribut + ``; selama `system`, ikuti `matchMedia('(prefers-color-scheme: dark)')` dan perubahannya. Tanpa `@media (prefers-color-scheme)` di CSS. - CSS: lapis 2 didefinisikan per kombinasi: `:root[data-palette=indigo]`, `:root[data-palette=indigo][data-theme=dark]`, dst. (6 blok). Komponen hanya memakai token lapis 2 (`var(--ink)`, `var(--accent-fill)`...), tidak pernah hex. -- Kontrol: tombol ikon di header (siklus terang → gelap → sistem, ikon matahari/bulan/monitor, `aria-label` menyebut mode berikutnya) dan di **Settings → Appearance**: segmented "Palette" (Indigo / Navy / Teal) dan segmented "Theme" (Light / Dark / System). Halaman login ikut preferensi yang sama. +- Kontrol: tombol ikon di header (terang → gelap → sistem → lawan dari tema efektif saat itu, supaya setiap klik terlihat berubah; ikon matahari/bulan/monitor, `aria-label` menyebut mode berikutnya) dan di **Settings → Appearance**: segmented "Palette" (Indigo / Navy / Teal) dan segmented "Theme" (Light / Dark / System). Halaman login ikut preferensi yang sama. ## 3. Token lapis 2 (nama tetap, nilai per palet) Kegunaan tiap token sama dengan standar §3: `bg` latar halaman, `surface` kartu/header/sheet, `sunk` permukaan cekung (pill netral, wadah segmented), diff --git a/internal/http/router.go b/internal/http/router.go index 18c5497..7dfa977 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -260,7 +260,7 @@ func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serv return r } -//go:embed static/*.js static/*.css +//go:embed static/*.js static/*.css static/*.png static/*.webmanifest var staticFS embed.FS // serveStatic serves the vendored browser assets (see static/SOURCES.md); no directory listing. @@ -270,8 +270,15 @@ func serveStatic(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - // ponytail: a week, not immutable — file names do not change when an asset is upgraded. - w.Header().Set("Cache-Control", "public, max-age=604800") + 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") + w.Header().Set("Service-Worker-Allowed", "../") + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + } else { + // ponytail: a week, not immutable — file names do not change when an asset is upgraded. + w.Header().Set("Cache-Control", "public, max-age=604800") + } http.ServeFileFS(w, r, staticFS, "static/"+name) } @@ -286,6 +293,10 @@ 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/SOURCES.md b/internal/http/static/SOURCES.md index a274d0a..f18f824 100644 --- a/internal/http/static/SOURCES.md +++ b/internal/http/static/SOURCES.md @@ -10,3 +10,7 @@ names in `templates/layout.html` if they change. | xterm.min.js | https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js | fc1dd31b221e3e5f929486e07a80b477a8aaf9dce2b4f9c3ffe7dd25f370655d | | xterm.min.css | https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css | 64ee6c4db69b4224d3362aced0fd4cdd620e0e60b3d01566450ae2d4b9e81849 | | xterm-addon-fit.min.js | https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js | c78e5795c6487acdf24ab436798d4a9cec3848101b281bc65b061f39db714be1 | +| htmx-ext-preload.min.js | https://unpkg.com/htmx-ext-preload@2.1.2/dist/preload.min.js | 2ac8ccf77deee4f8e87366d6b606085eed755b02513746ead7573395e717d26e | +| htmx-ext-head-support.min.js | https://unpkg.com/htmx-ext-head-support@2.0.5/dist/head-support.min.js | 86e59ae1048bf02fb31a5842b4b559a1058b4c3ff190c258530ecb94f9fa6d56 | + +`icon-192.png` / `icon-512.png`: the "D" logo on `accent-fill` indigo (#4f46e5), rendered from SVG with Playwright; `manifest.webmanifest` and `sw.js` are first-party. diff --git a/internal/http/static/app.css b/internal/http/static/app.css index 5013266..a438607 100644 --- a/internal/http/static/app.css +++ b/internal/http/static/app.css @@ -245,6 +245,11 @@ hgroup p { color: var(--muted); margin: 0; font-size: 12.8px; } background: var(--surface); border-bottom: 1px solid var(--line); } +/* S9: 3px request progress under the header (shown by app.js for requests slower than 150 ms). */ +.progress { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: var(--accent-fill); transform-origin: left; animation: progress 8s cubic-bezier(.1,.6,.2,1) forwards; } +.progress[hidden] { display: none; } +@keyframes progress { from { transform: scaleX(0.05); } to { transform: scaleX(0.95); } } +@media (prefers-reduced-motion: reduce) { .progress { animation: none; transform: none; } } .top-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; height: 56px; padding: 0 16px; } .top-actions { display: flex; align-items: center; gap: 8px; } .top-actions form { margin: 0; } diff --git a/internal/http/static/app.js b/internal/http/static/app.js index acdd58a..7a58a99 100644 --- a/internal/http/static/app.js +++ b/internal/http/static/app.js @@ -82,6 +82,7 @@ function loadXterm(cb) { var l = document.createElement('link'); l.rel = 'stylesheet'; l.href = 'static/xterm.min.css?v=' + assetV; + l.setAttribute('hx-preserve', 'true'); document.head.appendChild(l); function add(src, next) { var s = document.createElement('script'); @@ -95,3 +96,85 @@ function loadXterm(cb) { } xtermLoading.then(cb, function () { xtermLoading = null; }); } + +// ---- S9: boosted navigation (hx-boost on ; only #main is swapped, header/tab bar persist) ---- +// Page scripts must be idempotent: run on DOMContentLoaded and again on htmx:afterSettle, guarded by data-init. +function formatTimes(root) { + [].forEach.call((root || document).querySelectorAll('time[datetime]:not([data-init])'), function (el) { + el.setAttribute('data-init', '1'); + var d = new Date(el.getAttribute('datetime')); + if (isNaN(d)) return; + var p = function (n) { return String(n).padStart(2, '0'); }; + el.textContent = d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()); + }); +} +function syncNav() { + var m = document.getElementById('main'), cur = m && m.getAttribute('data-nav'); + [].forEach.call(document.querySelectorAll('.main-nav a[data-nav]'), function (a) { + if (a.getAttribute('data-nav') === cur) a.setAttribute('aria-current', 'page'); + else a.removeAttribute('aria-current'); + }); +} +document.addEventListener('DOMContentLoaded', function () { formatTimes(); }); +if (window.htmx) { + var progressTimer = null; + function progress(show) { + clearTimeout(progressTimer); + var bar = document.getElementById('progress'); + if (!bar) return; + if (!show) { bar.hidden = true; return; } + progressTimer = setTimeout(function () { bar.hidden = false; }, 150); + } + document.addEventListener('htmx:beforeRequest', function (e) { + var d = e.detail; + // Preload requests are intercepted by the preload extension and must not flash the bar. + if (d.boosted && !(d.requestConfig.headers || {})['HX-Preloaded']) progress(true); + }); + ['htmx:afterSettle', 'htmx:responseError', 'htmx:sendError', 'htmx:timeout', 'htmx:swapError'].forEach(function (n) { + document.addEventListener(n, function () { progress(false); }); + }); + document.addEventListener('htmx:beforeSwap', function (e) { + 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) { + d.shouldSwap = false; + progress(false); + location.assign(d.xhr.responseURL || location.href); + 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) {} }); + } + }); + // head-support: never drop scripts/styles already loaded (fonts link mutates media=all, xterm is added at runtime). + document.addEventListener('htmx:removingHeadElement', function (e) { + var t = e.detail.headElement.tagName; + if (t === 'LINK' || t === 'SCRIPT') e.preventDefault(); + }); + document.addEventListener('htmx:addingHeadElement', function (e) { + var el = e.detail.headElement.firstElementChild; + if (!el) return; + var url = el.getAttribute('href') || el.getAttribute('src'); + 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 + if (e.detail.boosted) window.scrollTo(0, 0); + }); + // Back/forward: do not restore stale DOM snapshots; fetch the page again. + htmx.config.historyCacheSize = 0; +} +// Light PWA: the service worker only caches /static/*. +if ('serviceWorker' in navigator) { + window.addEventListener('load', function () { + navigator.serviceWorker.register('static/sw.js?v=' + assetV, { scope: './' }).catch(function () {}); + }); +} diff --git a/internal/http/static/charts.js b/internal/http/static/charts.js index a454bd5..146d930 100644 --- a/internal/http/static/charts.js +++ b/internal/http/static/charts.js @@ -250,7 +250,7 @@ function initLiveCharts(opts) { readChartDataFromHTML(); updateCharts(); - document.body.addEventListener('htmx:afterSwap', function(evt) { + function onSwap(evt) { if (evt.target.id === opts.targetId) { var rangeEl = evt.target.getAttribute('data-range'); if (rangeEl && rangeDurations[rangeEl]) { @@ -270,8 +270,16 @@ function initLiveCharts(opts) { } } } - }); + } + document.body.addEventListener('htmx:afterSwap', onSwap); + + // Boosted navigation keeps this document alive: drop the socket and listener when #main is replaced. + function dispose() { + disconnect(); + document.body.removeEventListener('htmx:afterSwap', onSwap); + } + (window.dockifyCleanups = window.dockifyCleanups || []).push(dispose); connect(); - return { disconnect: disconnect }; + return { disconnect: dispose }; } diff --git a/internal/http/static/console.js b/internal/http/static/console.js index 2002040..8aeba4b 100644 --- a/internal/http/static/console.js +++ b/internal/http/static/console.js @@ -40,7 +40,8 @@ function dockifyConsole(cfg) { if (term) { term.dispose(); term = null; } setStatus('Disconnected', 'badge-offline'); } - return { + var api = { + disconnect: disconnect, toggle: function () { var box = el(cfg.container), btn = el(cfg.toggle); if (box.hidden) { @@ -54,6 +55,7 @@ function dockifyConsole(cfg) { } }, }; + return api; } var serverConsole = null, appConsole = null; function toggleConsole() { @@ -110,3 +112,8 @@ function toggleAppConsole() { } }); appConsole.toggle(); } +// A boosted navigation replaces #main; close any open console so its socket does not leak. +(window.dockifyCleanups = window.dockifyCleanups || []).push(function () { + if (serverConsole) { serverConsole.disconnect(); serverConsole = null; } + if (appConsole) { appConsole.disconnect(); appConsole = null; } +}); diff --git a/internal/http/static/htmx-ext-head-support.min.js b/internal/http/static/htmx-ext-head-support.min.js new file mode 100644 index 0000000..edf446b --- /dev/null +++ b/internal/http/static/htmx-ext-head-support.min.js @@ -0,0 +1 @@ +(function(){var H=null;function M(){}function a(e,t){if(e&&e.indexOf("-1){const g=document.createElement("html");var r=e.replace(/