feat: add CSS live reload for dev setup
Two-part solution for automatic CSS updates during development: 1. `bb watch-theme` — polls src/ui/*.css and tokens.edn every 500ms, rebuilds dist/theme.css and copies to dev targets on change. 2. `dev/css-live-reload.js` — browser-side script that polls /theme.css and hot-swaps the stylesheet without a full page reload (no FOUC). The watcher runs automatically in the hiccup tmux pane when using `bb dev-all`. It can also be run standalone with `bb watch-theme`. The live-reload script is included in all three dev targets (hiccup, replicant, squint) and copied by `bb build-theme`.
This commit is contained in:
12
bb.edn
12
bb.edn
@@ -16,7 +16,15 @@
|
|||||||
(let [adapter (slurp "dev/theme-adapter.js")]
|
(let [adapter (slurp "dev/theme-adapter.js")]
|
||||||
(spit "dev/replicant/public/theme-adapter.js" adapter)
|
(spit "dev/replicant/public/theme-adapter.js" adapter)
|
||||||
(spit "dev/squint/public/theme-adapter.js" adapter)
|
(spit "dev/squint/public/theme-adapter.js" adapter)
|
||||||
(println "Copied theme-adapter.js to dev targets")))}
|
(println "Copied theme-adapter.js to dev targets"))
|
||||||
|
(let [lr (slurp "dev/css-live-reload.js")]
|
||||||
|
(spit "dev/replicant/public/css-live-reload.js" lr)
|
||||||
|
(spit "dev/squint/public/css-live-reload.js" lr)
|
||||||
|
(println "Copied css-live-reload.js to dev targets")))}
|
||||||
|
|
||||||
|
watch-theme
|
||||||
|
{:doc "Watch src/ui/*.css and tokens.edn, rebuild theme on changes"
|
||||||
|
:task (load-file "scripts/watch-theme.bb")}
|
||||||
|
|
||||||
test
|
test
|
||||||
{:doc "Run all unit tests"
|
{:doc "Run all unit tests"
|
||||||
@@ -125,7 +133,7 @@
|
|||||||
session "ui-dev"]
|
session "ui-dev"]
|
||||||
(shell {:continue true} "tmux kill-session -t" session)
|
(shell {:continue true} "tmux kill-session -t" session)
|
||||||
(shell "tmux new-session -d -s" session
|
(shell "tmux new-session -d -s" session
|
||||||
(str "PORT=" hport " bb dev-hiccup"))
|
(str "bash -c 'bb watch-theme & PORT=" hport " bb dev-hiccup'"))
|
||||||
(shell "tmux split-window -h -t" session
|
(shell "tmux split-window -h -t" session
|
||||||
(str "bash -c 'cd dev/replicant && SHADOW_HTTP_PORT=" rport " npx shadow-cljs watch app'"))
|
(str "bash -c 'cd dev/replicant && SHADOW_HTTP_PORT=" rport " npx shadow-cljs watch app'"))
|
||||||
(shell "tmux split-window -v -t" session
|
(shell "tmux split-window -v -t" session
|
||||||
|
|||||||
42
dev/css-live-reload.js
Normal file
42
dev/css-live-reload.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// CSS Live Reload — polls /theme.css and hot-swaps the stylesheet on change.
|
||||||
|
// No full page reload needed. Included automatically in dev pages.
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
var POLL_MS = 500;
|
||||||
|
var link = document.querySelector('link[rel="stylesheet"][href*="theme"]');
|
||||||
|
if (!link) return;
|
||||||
|
|
||||||
|
var prevHash = 0;
|
||||||
|
|
||||||
|
function djb2(str) {
|
||||||
|
var h = 5381;
|
||||||
|
for (var i = 0; i < str.length; i++) {
|
||||||
|
h = ((h << 5) + h + str.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function check() {
|
||||||
|
fetch('/theme.css', { cache: 'no-store' })
|
||||||
|
.then(function(r) { return r.text(); })
|
||||||
|
.then(function(css) {
|
||||||
|
var h = djb2(css);
|
||||||
|
if (prevHash && h !== prevHash) {
|
||||||
|
// Clone link and swap — avoids FOUC
|
||||||
|
var next = link.cloneNode();
|
||||||
|
next.href = '/theme.css?t=' + Date.now();
|
||||||
|
link.parentNode.insertBefore(next, link.nextSibling);
|
||||||
|
next.onload = function() {
|
||||||
|
link.parentNode.removeChild(link);
|
||||||
|
link = next;
|
||||||
|
};
|
||||||
|
console.log('[css-live-reload] theme.css updated');
|
||||||
|
}
|
||||||
|
prevHash = h;
|
||||||
|
})
|
||||||
|
.catch(function() { /* ignore network errors */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
check();
|
||||||
|
setInterval(check, POLL_MS);
|
||||||
|
})();
|
||||||
@@ -503,6 +503,7 @@
|
|||||||
[:script (h/raw theme-persistence-script)]]
|
[:script (h/raw theme-persistence-script)]]
|
||||||
[:body
|
[:body
|
||||||
[:script {:src "/theme-adapter.js" :defer true}]
|
[:script {:src "/theme-adapter.js" :defer true}]
|
||||||
|
[:script {:src "/css-live-reload.js" :defer true}]
|
||||||
(sidebar/sidebar-layout {}
|
(sidebar/sidebar-layout {}
|
||||||
(app-sidebar active-page port)
|
(app-sidebar active-page port)
|
||||||
(sidebar/sidebar-overlay {})
|
(sidebar/sidebar-overlay {})
|
||||||
@@ -534,6 +535,11 @@
|
|||||||
:headers {"Content-Type" "application/javascript"}
|
:headers {"Content-Type" "application/javascript"}
|
||||||
:body (slurp "dev/theme-adapter.js")}
|
:body (slurp "dev/theme-adapter.js")}
|
||||||
|
|
||||||
|
(= path "/css-live-reload.js")
|
||||||
|
{:status 200
|
||||||
|
:headers {"Content-Type" "application/javascript"}
|
||||||
|
:body (slurp "dev/css-live-reload.js")}
|
||||||
|
|
||||||
(resolve-page path)
|
(resolve-page path)
|
||||||
{:status 200
|
{:status 200
|
||||||
:headers {"Content-Type" "text/html; charset=utf-8"}
|
:headers {"Content-Type" "text/html; charset=utf-8"}
|
||||||
|
|||||||
@@ -47,5 +47,6 @@
|
|||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script src="/js/main.js"></script>
|
<script src="/js/main.js"></script>
|
||||||
<script src="/theme-adapter.js" defer></script>
|
<script src="/theme-adapter.js" defer></script>
|
||||||
|
<script src="/css-live-reload.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -47,5 +47,6 @@
|
|||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src=".compiled/dev/squint.mjs"></script>
|
<script type="module" src=".compiled/dev/squint.mjs"></script>
|
||||||
<script src="/theme-adapter.js" defer></script>
|
<script src="/theme-adapter.js" defer></script>
|
||||||
|
<script src="/css-live-reload.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
44
scripts/watch-theme.bb
Normal file
44
scripts/watch-theme.bb
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bb
|
||||||
|
;; Watch src/ui/*.css and src/theme/tokens.edn for changes.
|
||||||
|
;; On change, rebuild dist/theme.css and copy to dev targets.
|
||||||
|
|
||||||
|
(require '[babashka.fs :as fs]
|
||||||
|
'[clojure.java.io :as io]
|
||||||
|
'[clojure.string :as str]
|
||||||
|
'[ui.css.gen :as gen])
|
||||||
|
|
||||||
|
(defn source-files []
|
||||||
|
(concat (map str (fs/glob "src/ui" "*.css"))
|
||||||
|
["src/theme/tokens.edn"]))
|
||||||
|
|
||||||
|
(defn get-mtimes []
|
||||||
|
(into {}
|
||||||
|
(keep (fn [p]
|
||||||
|
(when (fs/exists? p)
|
||||||
|
[p (fs/last-modified-time p)])))
|
||||||
|
(source-files)))
|
||||||
|
|
||||||
|
(defn rebuild! []
|
||||||
|
(gen/build-theme! {:input "src/theme/tokens.edn" :output "dist/theme.css"})
|
||||||
|
(let [css (slurp "dist/theme.css")]
|
||||||
|
(spit "dev/replicant/public/theme.css" css)
|
||||||
|
(io/make-parents "dev/squint/public/theme.css")
|
||||||
|
(spit "dev/squint/public/theme.css" css)))
|
||||||
|
|
||||||
|
(defn changed-filenames [prev curr]
|
||||||
|
(->> (keys curr)
|
||||||
|
(filter (fn [p] (not= (get prev p) (get curr p))))
|
||||||
|
(map (fn [p] (last (str/split p #"/"))))))
|
||||||
|
|
||||||
|
(println "[watch-theme] Watching src/ui/*.css and src/theme/tokens.edn ...")
|
||||||
|
(loop [prev (get-mtimes)]
|
||||||
|
(Thread/sleep 500)
|
||||||
|
(let [curr (get-mtimes)]
|
||||||
|
(when (not= prev curr)
|
||||||
|
(println (str "[watch-theme] Changed: " (str/join ", " (changed-filenames prev curr))))
|
||||||
|
(try
|
||||||
|
(rebuild!)
|
||||||
|
(println "[watch-theme] Rebuilt and copied.")
|
||||||
|
(catch Exception e
|
||||||
|
(println (str "[watch-theme] ERROR: " (.getMessage e))))))
|
||||||
|
(recur curr)))
|
||||||
Reference in New Issue
Block a user