fuzzyworld

fuzzyworld-tools / commit

CI pages: countdown bar and "Next reload in N s." (CSS only)

b447a9f272c0b690572450be52aeda890581b50d · Pavel Safronov · 2026-11-03 00:44:04 -0800 · parents e9fdc5db1d2d

Claude Code usage: this commit 16.4K out / 1.8M cached (first commit of its session: everything since it began) · cost unknown (no per-model trailer) · session 12 tally 84 in / 16.4K out / 1.8M cached · usage by commit and session

CI pages: countdown bar and "Next reload in N s." (CSS only)

Pages that reload every 15 s now show a thin top bar that empties as the
reload nears, and a countdown beside the reload note. Both are pure CSS
(animated @property integer shown through a counter), because the site's
CSP is script-src 'none'. Tests in tests/test_gen.py; README "Watching CI"
updated.

FW-Tokens-In: 84
FW-Tokens-Out: 16419
FW-Tokens-Cached: 1766019
FW-Session: 64546368-887a-4153-869c-3a66559e514e
FW-Rtk-Saved: 4319144
FW-Rtk-Cmds: 13308

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 README.md         |  4 +++-
 server/gen.py     | 27 +++++++++++++++++++++++++--
 tests/test_gen.py | 32 ++++++++++++++++++++++++++++++++
 3 files changed, 60 insertions(+), 3 deletions(-)

diff --git a/README.md b/README.md
index d9c1a8f..b630563 100644
--- a/README.md
+++ b/README.md
@@ -235,7 +235,9 @@ coordinator service; the coordinator is files on DreamHost.
 
 **Watching CI:** `code.fuzzyworld.net/ci/` shows what is running (each job ✓/✗/● with its time), what is queued
 and the last 50 results. While anything is queued or running, that page and the running pipeline's page reload
-themselves every 15 seconds (`<meta http-equiv="refresh">`; no JavaScript). Private repos are never named there,
+themselves every 15 seconds (`<meta http-equiv="refresh">`; no JavaScript). A thin bar along the top empties as the reload nears, and "Next reload in N s."
+counts down beside the reload note, both done in CSS (the site allows no scripts; the countdown needs a browser with
+`@property`, otherwise only the bar shows; the bar is hidden for `prefers-reduced-motion`). Private repos are never named there,
 only counted. In the terminal, `fw ci-watch` shows everything, private repos included.
 
 **Job definitions: `.fw-ci.yml`**, at the repo root, in a subset of GitLab CI's YAML format, so an old
diff --git a/server/gen.py b/server/gen.py
index eacb455..e6e3789 100755
--- a/server/gen.py
+++ b/server/gen.py
@@ -243,11 +243,34 @@ def page(title, body, crumbs=None, view=None, site="code", refresh=None, section
 {body}
 </main>
 {footer}
+{reload_timer(refresh)}
 </body>
 </html>
 """
 
 
+def reload_timer(refresh):
+    """A thin bar along the top that empties as the page's meta refresh nears, and a countdown in
+    any `.reload-in` element (user request, 2026-11-03: the CI page said it reloads every 15
+    seconds but not when). Pure CSS, because the site's CSP is `script-src 'none'`: the countdown is
+    an animated integer property shown through a counter. Starts at page load, as the refresh does.
+    Browsers without @property support show the bar only."""
+    if not refresh:
+        return ""
+    n = int(refresh)
+    return f"""<div class="reload-bar" aria-hidden="true"></div>
+<style>
+.reload-bar {{ position: fixed; top: 0; left: 0; height: 3px; width: 100%; background: currentColor; opacity: .45;
+  transform-origin: left; animation: reload-bar {n}s linear forwards; }}
+@keyframes reload-bar {{ from {{ transform: scaleX(1); }} to {{ transform: scaleX(0); }} }}
+@property --reload-left {{ syntax: "<integer>"; inherits: false; initial-value: {n}; }}
+@keyframes reload-count {{ from {{ --reload-left: {n}; }} to {{ --reload-left: 0; }} }}
+.reload-in::before {{ content: "Next reload in " counter(reload-left) " s."; counter-reset: reload-left var(--reload-left);
+  animation: reload-count {n}s steps({n}) forwards; }}
+@media (prefers-reduced-motion: reduce) {{ .reload-bar {{ display: none; }} }}
+</style>"""
+
+
 def write(path, content):
     os.makedirs(os.path.dirname(path), exist_ok=True)
     tmp = path + ".tmp"
@@ -393,7 +416,7 @@ def build_ci_pages(view):
                 f'<p class="meta">Branch <code>{esc(p.get("ref", ""))}</code> · '
                 f'<a href="{url(name, "commit", sha)}.html">commit</a> · started {esc(p.get("started", "").replace("T", " ").replace("Z", " UTC"))} · '
                 f'{"running" if running else duration(p.get("duration"))} · runner {esc(p.get("runner", ""))}</p>'
-                + (f'<p class="meta">This page reloads every {CI_REFRESH} seconds while the pipeline runs.</p>' if running else "")
+                + (f'<p class="meta">This page reloads every {CI_REFRESH} seconds while the pipeline runs. <span class="reload-in"></span></p>' if running else "")
                 + (f'<p>{esc(p["message"])}</p>' if p.get("message") else "")
                 + "".join(jobs))
         write(page_path, page(f"{name} CI {sha[:12]}", body, [f'<a href="{url(name)}/">{esc(name)}</a>', "CI"], view=view,
@@ -512,7 +535,7 @@ def build_ci_dashboard():
     notice = runner_notice(pending)
     body = ('<h1>CI</h1>'
             '<p class="meta">Pipelines run on whichever runner is available (a home server, sometimes a laptop), one at a time, as pushes queue them. '
-            + (f'This page reloads every {CI_REFRESH} seconds while anything is queued or running.' if busy else "Nothing is running.")
+            + (f'This page reloads every {CI_REFRESH} seconds while anything is queued or running. <span class="reload-in"></span>' if busy else "Nothing is running.")
             + (f' Also {hidden} job{"s" if hidden != 1 else ""} for private repositories.' if hidden else "") + '</p>'
             + (f'<p><strong>{esc(notice)}</strong></p>' if notice else "")
             + (f'<h2>Running</h2><table class="log">{run_rows}</table>' if run_rows else "")
diff --git a/tests/test_gen.py b/tests/test_gen.py
new file mode 100644
index 0000000..5ffdd8d
--- /dev/null
+++ b/tests/test_gen.py
@@ -0,0 +1,32 @@
+"""Tests for the page furniture in server/gen.py. Run: python3 -m unittest discover tests"""
+import os
+import sys
+import unittest
+
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "server"))
+import gen  # noqa: E402
+
+
+class ReloadTimer(unittest.TestCase):
+    def test_refreshing_page_has_meta_refresh_bar_and_countdown(self):
+        html = gen.page("T", '<p>x <span class="reload-in"></span></p>', refresh=15)
+        self.assertIn('<meta http-equiv="refresh" content="15">', html)
+        self.assertIn('class="reload-bar"', html)
+        self.assertIn("animation: reload-bar 15s linear", html)     # the bar's length is the refresh interval
+        self.assertIn("counter-reset: reload-left var(--reload-left)", html)   # the text countdown is CSS-only
+        self.assertIn("steps(15)", html)                            # one step a second
+        self.assertNotIn("<script", html)                           # the site's CSP is script-src 'none'
+        self.assertIn("prefers-reduced-motion", html)
+
+    def test_page_without_refresh_has_none_of_it(self):
+        html = gen.page("T", "<p>x</p>")
+        for marker in ("http-equiv", "reload-bar", "reload-in"):
+            self.assertNotIn(marker, html)
+
+    def test_reload_timer_empty_without_interval(self):
+        self.assertEqual(gen.reload_timer(None), "")
+        self.assertEqual(gen.reload_timer(0), "")
+
+
+if __name__ == "__main__":
+    unittest.main()