feat(web): SvelteKit PWA with the Nordic design system

The interface for phase 1: sign-in and first-run setup, Today, Upcoming,
project views, sharing, Cmd+K search and the keyboard shortcuts.

Quick add highlights what the parser understood underneath the text as
you type — a mirrored layer behind a transparent input, sliced by byte
offset because the server counts bytes and JavaScript counts UTF-16
units, which disagree on exactly the Danish letters this app is full of.

Every local action is optimistic and reconciled afterwards: ticking a
task off strikes it through and animates it out before the request
leaves, and rolls back with a message if the server disagrees.

The Content-Security-Policy is computed at startup by hashing the inline
scripts in the built index.html. Both of them change on every frontend
build, so a hard-coded hash would go stale silently and show up only as
a blank page in production. The alternative, 'unsafe-inline', would drop
the one protection that makes an injected script in a task title inert.

Today is derived from the store by date rather than from the ids that
came back with the first request — filtering against a captured list
meant a task added through quick add never appeared until a reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 00:03:20 +02:00
co-authored by Claude Opus 5
parent dff86dd23d
commit 12a9597919
25 changed files with 4776 additions and 15 deletions
+8 -15
View File
@@ -36,6 +36,9 @@ type Server struct {
loginLimiter *limiter
resetLimiter *limiter
// Computed once from the built index.html; see csp.go.
csp string
router chi.Router
}
@@ -50,6 +53,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
loginLimiter: newLimiter(10, 15*time.Minute),
resetLimiter: newLimiter(5, time.Hour),
}
s.csp = contentSecurityPolicy(scriptHashes(web))
r := chi.NewRouter()
r.Use(middleware.RequestID)
@@ -57,7 +61,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Use(requestLogger(log))
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
r.Use(securityHeaders)
r.Use(s.securityHeaders)
// Unauthenticated, and deliberately outside /api/v1: the Rune healthcheck and
// Docker both need this to answer before anyone has logged in.
@@ -256,25 +260,14 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, name, info.ModTime(), seeker)
}
// securityHeaders sets the headers that are the same on every response. The CSP is
// strict because verdande serves only its own assets: there is no CDN to allow, and
// nothing in the app loads a script from anywhere else.
func securityHeaders(next http.Handler) http.Handler {
// securityHeaders sets the headers that are the same on every response.
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy", strings.Join([]string{
"default-src 'self'",
"img-src 'self' data: blob:",
"style-src 'self' 'unsafe-inline'",
"script-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'none'",
"form-action 'self'",
}, "; "))
h.Set("Content-Security-Policy", s.csp)
next.ServeHTTP(w, r)
})
}