## 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).
244 lines
5.8 KiB
Go
244 lines
5.8 KiB
Go
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
|
||
}
|