Files
projax/web/graph.go
mAi 5dcacff520 feat(phase 4b): dark/light theme toggle + file-upload permanently out-of-scope
## Slice A — explicit dark/light toggle

projax now ships with two palettes and a 1y cookie to remember the choice.
Dark is the new default; ☀ button in the header nav flips to light and
writes projax_theme=light. Server reads the cookie via themeFromRequest(r)
and injects Theme + ThemeColor into every template via the centralised
render(w, r, …) path, so first paint never flashes the wrong theme. Inline
JS in layout.tmpl handles the toggle without a server roundtrip.

Every panel colour now lives in a CSS variable under
:root[data-theme=dark|light]; the only hardcoded hex values left are
inside those two :root blocks. A future palette tweak is one edit, not
30 selectors. Graph node colours, kind-badges, highlights and warn/ok/bad
all have parallel dark/light values picked for contrast.

Standalone SVG download bakes the light palette inline because the
downloaded asset has no parent :root providing vars — m's existing
snapshots stay print-friendly regardless of his current cookie.

Login page keeps its embedded dark CSS — it's the gateway, intentionally
always dark.

Tests: TestThemeDefaultIsDark, TestThemeCookieRoundTrips,
TestThemeCookieUnknownFallsBackToDark, TestThemeTogglePagesShareSameTheme,
TestThemeToggleScriptPresent, TestThemeColorMetaHelper. Full suite green.

## Slice B — file-upload permanently out of scope (m, 2026-05-17)

docs/design.md moves "File uploads / in-projax storage" from the §3c
parked list to a permanent "Out of scope (decided 2026-05-17)" clause
with the rationale: PER is the cross-reference index, not the file
system. docs/standards/per.md gains the same explicit clause so future
shifts working from the PER standard see the constraint where they
look. Memory note filed so future workers don't re-propose multipart
uploads, attachments tables, or documents buckets.

## docs/design.md §13 Theming

Documents the toggle approach, cookie semantics, palette table, the
standalone-SVG carve-out, the login-page exception, and the 4b
out-of-scope (prefers-color-scheme detection, per-page overrides,
transitions on swap).
2026-05-17 18:14:08 +02:00

244 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package web
import (
"net/http"
"github.com/m/projax/internal/graph"
"github.com/m/projax/store"
)
// graphNode is the template-facing shape: position + style hints derived from
// the underlying item's management/status/tags.
type graphNodeView struct {
ID string
Slug string
Title string
Path string
Pos graph.Pos
Tags []string
TagsShown []string // capped to 3 with "+N" overflow logic via TagOverflow
TagOverflow int
Status string
Management []string
MgmtClass string // "mai" | "self" | "external" | "mixed" | "unmanaged"
StatusOp float64 // 1.0 active, 0.6 done, 0.3 archived
PathCount int // ×N badge when > 1
Matched bool // matches the filter (when filter is active)
}
// graphPayload is everything the SVG template needs.
type graphPayload struct {
Nodes []graphNodeView
Edges []graph.Edge
CanvasWidth float64
CanvasHeight float64
Isolate bool // when true, dim-only stays off — non-match nodes hidden entirely
NodeW, NodeH float64
// Standalone toggles the inline :root palette in graph_svg.tmpl. Set to
// true on ?download=svg responses (no outer HTML page providing vars);
// false when the SVG is embedded inside the layout chrome.
Standalone bool
}
func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) {
items, err := s.Store.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
linkKinds, err := s.linkKindsByItem(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
allTags, err := s.Store.AllTags(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
filter := ParseTreeFilter(r.URL.Query())
isolate := r.URL.Query().Get("isolate") == "1"
// Build layout-input nodes from every live item (the graph deliberately
// shows the full DAG; the filter dims non-matches via opacity unless
// isolate=1 hides them).
nodes := make([]graph.Node, 0, len(items))
for _, it := range items {
nodes = append(nodes, graph.Node{
ID: it.ID,
Slug: it.Slug,
ParentIDs: it.ParentIDs,
})
}
opts := graph.Opts{NodeWidth: 130, NodeHeight: 44, HGap: 28, VGap: 90, MarginX: 40, MarginY: 32}
layout, err := graph.Compute(nodes, opts)
if err != nil {
s.fail(w, r, err)
return
}
// Filter matching: every node carries its match state so the template can
// branch on the dim/isolate behaviour.
byID := make(map[string]*store.Item, len(items))
for _, it := range items {
byID[it.ID] = it
}
var views []graphNodeView
visibleEdges := layout.Edges
for _, it := range items {
pos, ok := layout.Positions[it.ID]
if !ok {
continue
}
matched := filter.Matches(it, linkKinds[it.ID])
if isolate && filter.Active() && !matched {
continue
}
v := graphNodeView{
ID: it.ID,
Slug: it.Slug,
Title: it.Title,
Path: it.PrimaryPath(),
Pos: pos,
Tags: it.Tags,
Status: it.Status,
Management: it.Management,
MgmtClass: managementClass(it.Management),
StatusOp: statusOpacity(it.Status, it.Archived),
PathCount: len(it.Paths),
Matched: matched,
}
if len(it.Tags) > 3 {
v.TagsShown = it.Tags[:3]
v.TagOverflow = len(it.Tags) - 3
} else {
v.TagsShown = it.Tags
}
views = append(views, v)
}
if isolate && filter.Active() {
// Drop edges referencing removed nodes.
visible := map[string]struct{}{}
for _, v := range views {
visible[v.ID] = struct{}{}
}
kept := visibleEdges[:0]
for _, e := range visibleEdges {
if _, ok := visible[e.ParentID]; !ok {
continue
}
if _, ok := visible[e.ChildID]; !ok {
continue
}
kept = append(kept, e)
}
visibleEdges = kept
}
payload := graphPayload{
Nodes: views,
Edges: visibleEdges,
CanvasWidth: layout.CanvasWidth,
CanvasHeight: layout.CanvasHeight,
Isolate: isolate,
NodeW: opts.NodeWidth,
NodeH: opts.NodeHeight,
}
// Download mode: serve raw SVG with attachment headers.
if r.URL.Query().Get("download") == "svg" {
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="projax-graph.svg"`)
payload.Standalone = true
s.renderRaw(w, "graph_svg", payload)
return
}
data := map[string]any{
"Title": "graph",
"P": payload,
"Filter": filter,
"Isolate": isolate,
"AllTags": allTags,
"Total": len(items),
"Matched": countMatches(items, filter, linkKinds),
}
s.render(w, r, "graph", data)
}
// renderRaw is like render but writes the body to w without the page-layout
// chrome — used for the SVG download path.
func (s *Server) renderRaw(w http.ResponseWriter, name string, data any) {
t, ok := s.pages[name]
if !ok {
http.Error(w, "unknown page: "+name, http.StatusInternalServerError)
return
}
entry := "graph-svg"
if err := t.ExecuteTemplate(w, entry, data); err != nil {
s.Logger.Error("render svg", "page", name, "err", err)
}
}
func managementClass(m []string) string {
hasMai, hasSelf, hasExt := false, false, false
for _, x := range m {
switch x {
case "mai":
hasMai = true
case "self":
hasSelf = true
case "external":
hasExt = true
}
}
count := 0
for _, b := range []bool{hasMai, hasSelf, hasExt} {
if b {
count++
}
}
if count == 0 {
return "unmanaged"
}
if count > 1 {
return "mixed"
}
switch {
case hasMai:
return "mai"
case hasSelf:
return "self"
case hasExt:
return "external"
}
return "unmanaged"
}
func statusOpacity(status string, archived bool) float64 {
if archived {
return 0.3
}
switch status {
case "done":
return 0.6
case "archived":
return 0.3
default:
return 1.0
}
}
func countMatches(items []*store.Item, f TreeFilter, linkKinds map[string]map[string]struct{}) int {
if !f.Active() {
return len(items)
}
n := 0
for _, it := range items {
if f.Matches(it, linkKinds[it.ID]) {
n++
}
}
return n
}