Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/DESIGN-SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<head>`** (sebelum CSS) menulis kedua atribut + `<meta name="theme-color">`; 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),
Expand Down
17 changes: 14 additions & 3 deletions internal/http/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}

Expand All @@ -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)
})
}
Expand Down
4 changes: 4 additions & 0 deletions internal/http/static/SOURCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions internal/http/static/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
83 changes: 83 additions & 0 deletions internal/http/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -95,3 +96,85 @@ function loadXterm(cb) {
}
xtermLoading.then(cb, function () { xtermLoading = null; });
}

// ---- S9: boosted navigation (hx-boost on <body>; 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 <meta name=theme-color>
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 () {});
});
}
14 changes: 11 additions & 3 deletions internal/http/static/charts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand All @@ -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 };
}
9 changes: 8 additions & 1 deletion internal/http/static/console.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -54,6 +55,7 @@ function dockifyConsole(cfg) {
}
},
};
return api;
}
var serverConsole = null, appConsole = null;
function toggleConsole() {
Expand Down Expand Up @@ -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; }
});
1 change: 1 addition & 0 deletions internal/http/static/htmx-ext-head-support.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions internal/http/static/htmx-ext-preload.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file added internal/http/static/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added internal/http/static/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 9 additions & 3 deletions internal/http/static/list.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,13 @@
});
apply();
}
document.addEventListener('DOMContentLoaded', function () {
[].forEach.call(document.querySelectorAll('[data-list]'), initList);
});
// Idempotent: runs on load and after every htmx swap; data-init guards against double wiring.
function init() {
[].forEach.call(document.querySelectorAll('[data-list]:not([data-init])'), function (r) {
r.setAttribute('data-init', '1');
initList(r);
});
}
document.addEventListener('DOMContentLoaded', init);
document.addEventListener('htmx:afterSettle', init);
})();
13 changes: 13 additions & 0 deletions internal/http/static/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "Dockify",
"short_name": "Dockify",
"start_url": ".",
"scope": ".",
"display": "standalone",
"theme_color": "#f5f7fb",
"background_color": "#f5f7fb",
"icons": [
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
]
}
23 changes: 23 additions & 0 deletions internal/http/static/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Light PWA service worker: caches /static/* only (cache-first, versioned by ?v=AssetVersion in the URL).
// HTML, API and WebSocket traffic always goes to the network.
var CACHE = 'dockify-static-' + (new URL(location.href).searchParams.get('v') || '0'); // registered as sw.js?v=AssetVersion
self.addEventListener('install', function () { self.skipWaiting(); });
self.addEventListener('activate', function (e) {
e.waitUntil(caches.keys().then(function (keys) {
return Promise.all(keys.filter(function (k) { return k !== CACHE && k.indexOf('dockify-static-') === 0; }).map(function (k) { return caches.delete(k); }));
}).then(function () { return self.clients.claim(); }));
});
self.addEventListener('fetch', function (e) {
var req = e.request, url = new URL(req.url);
if (req.method !== 'GET' || url.origin !== location.origin) return;
var dir = location.pathname.replace(/sw\.js$/, ''); // .../static/
if (url.pathname.indexOf(dir) !== 0 || url.pathname === location.pathname) return;
e.respondWith(caches.open(CACHE).then(function (c) {
return c.match(req).then(function (hit) {
return hit || fetch(req).then(function (res) {
if (res.ok) c.put(req, res.clone());
return res;
});
});
}));
});
Loading
Loading