diff --git a/web/server.go b/web/server.go
index 7125d8f..eeba124 100644
--- a/web/server.go
+++ b/web/server.go
@@ -169,6 +169,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
"templates/layout.tmpl",
"templates/tree.tmpl",
"templates/tree_section.tmpl",
+ "templates/tree_card.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
@@ -178,6 +179,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
// Standalone tree-section template for HTMX fragment responses.
treeSection, err := template.New("tree_section").Funcs(funcs).ParseFS(templatesFS,
"templates/tree_section.tmpl",
+ "templates/tree_card.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
@@ -440,8 +442,14 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
return
}
filter := ParseTreeFilter(r.URL.Query())
+ viewSet := PageViewTypes("/")
+ view := ParseViewType(r.URL.Query(), viewSet)
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
counts := computeChipCounts(items, filter, linkKinds, tags)
+ // Phase 5i Slice B: the card view renders a flat grid of matched items
+ // (no tree structure). Build from items + filter directly rather than
+ // reusing the post-prune `roots` (which still keeps ancestors).
+ cardItems := flatMatchedItems(items, filter, linkKinds)
data := map[string]any{
"Title": "tree",
"Roots": roots,
@@ -455,6 +463,9 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
"Projects": parentOptionsFromItems(items),
"BasePath": "/",
"ProjectChipTarget": "#tree-section",
+ "ViewType": view,
+ "ViewTypeChips": ViewTypeChips("/", filter, view),
+ "CardItems": cardItems,
// ActiveTags kept for backwards-compat with the old template path; removed
// after the template migrates fully.
"ActiveTags": filter.Tags,
diff --git a/web/server_test.go b/web/server_test.go
index 6af9aaf..b7c8b85 100644
--- a/web/server_test.go
+++ b/web/server_test.go
@@ -295,6 +295,41 @@ func TestMultiParentBothPathsRouteToSameRow(t *testing.T) {
}
}
+// TestTreeRendersCardGridWhenViewTypeIsCard verifies Phase 5i Slice B
+// dispatch: `?view_type=card` renders the flat tile grid instead of the
+// forest, and the view-type chip strip is present in either view. Unknown
+// view_type values fall back to list with the chip-strip showing list as
+// active.
+func TestTreeRendersCardGridWhenViewTypeIsCard(t *testing.T) {
+ srv, pool := mustServer(t)
+ defer pool.Close()
+ h := srv.Routes()
+ // List view (default): forest markup expected; tree-card-grid absent.
+ _, listBody := get(t, h, "/")
+ if !strings.Contains(listBody, `
`) {
+ t.Error("default GET / should render the tree forest")
+ }
+ if strings.Contains(listBody, `class="tree-card-grid"`) {
+ t.Error("default GET / should not render the card grid")
+ }
+ if !strings.Contains(listBody, `view-type-chip-row`) {
+ t.Error("view-type chip strip should appear on every view")
+ }
+ // Card view: card grid present, forest absent.
+ _, cardBody := get(t, h, "/?view_type=card")
+ if !strings.Contains(cardBody, `class="tree-card-grid"`) {
+ t.Error("GET /?view_type=card should render the card grid")
+ }
+ if strings.Contains(cardBody, `
`) {
+ t.Error("GET /?view_type=card should not render the tree forest")
+ }
+ // Unknown view_type falls back to list.
+ _, unknownBody := get(t, h, "/?view_type=junk")
+ if !strings.Contains(unknownBody, `
`) {
+ t.Error("unknown view_type should fall back to list")
+ }
+}
+
// TestProjectFilterScopesTreeToDescendants verifies the Phase 5i Slice A
// project scope semantics end-to-end: ?project= narrows / to the picked
// item + descendants; ?project_descendants=0 narrows further to the picked
diff --git a/web/static/style.css b/web/static/style.css
index 3b85c13..10cb8df 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -206,6 +206,29 @@ table.classify input, table.classify select { width: 100%; }
.proj-chip .proj-clear:hover { opacity: 1; }
.proj-desc-chip:hover { color: var(--fg); border-color: var(--accent); }
.proj-picker select { font-size: 0.85em; padding: 1px 4px; }
+/* Phase 5i Slice B — view-type chip strip + card grid. */
+.view-type-chip {
+ display: inline-block; font-size: 0.78em; padding: 1px 8px; border-radius: 999px;
+ background: var(--surface); border: 1px solid var(--border); color: var(--muted); text-decoration: none;
+ text-transform: capitalize;
+}
+.view-type-chip:hover { color: var(--fg); border-color: var(--accent); }
+.view-type-chip.chip-locked { opacity: 0.4; }
+.view-type-chip.chip-locked:hover { color: var(--muted); border-color: var(--border); cursor: not-allowed; }
+.tree-card-grid {
+ display: grid; gap: 12px; padding: 12px 0;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+}
+.tree-card {
+ border: 1px solid var(--border); border-radius: 6px; background: var(--surface);
+ padding: 10px 12px;
+}
+.tree-card-head { display: flex; flex-direction: column; gap: 2px; margin-bottom: 6px; }
+.tree-card-title { font-weight: 500; color: var(--fg); text-decoration: none; }
+.tree-card-title:hover { color: var(--accent); }
+.tree-card-slug { font-size: 0.78em; }
+.tree-card-meta { display: flex; flex-wrap: wrap; gap: 4px; margin: 0; font-size: 0.78em; }
+.tree-card-empty { grid-column: 1 / -1; padding: 24px; color: var(--muted); }
#tree-filterbar small { opacity: 0.75; margin-left: 2px; }
.tree-section .empty { padding: 24px; color: var(--muted); }
.tree-section .clear { color: var(--bad); }
diff --git a/web/templates/tree_card.tmpl b/web/templates/tree_card.tmpl
new file mode 100644
index 0000000..5ec3f54
--- /dev/null
+++ b/web/templates/tree_card.tmpl
@@ -0,0 +1,29 @@
+{{/*
+Phase 5i Slice B — card view for /tree. Renders the filtered item set as a
+flat tile grid (no forest, no ancestor-keep). One tile per matched item,
+ordered by primary path. Reuses the per-item field set the list view emits;
+the visual difference is layout, not data shape.
+*/}}
+{{define "tree-card"}}
+
+ {{end}}
{{end}}
diff --git a/web/tree_filter.go b/web/tree_filter.go
index fc17926..0b4e515 100644
--- a/web/tree_filter.go
+++ b/web/tree_filter.go
@@ -439,6 +439,20 @@ func sortItems(in []*store.Item) {
sort.Slice(in, func(i, j int) bool { return in[i].Slug < in[j].Slug })
}
+// flatMatchedItems returns every item that passes the filter directly — no
+// ancestor-keep, no DAG shape. Used by Phase 5i Slice B's card view: a flat
+// grid of tiles for the filtered set. Stable order by primary path.
+func flatMatchedItems(items []*store.Item, f TreeFilter, linkKinds map[string]map[string]struct{}) []*store.Item {
+ out := make([]*store.Item, 0, len(items))
+ for _, it := range items {
+ if f.Matches(it, linkKinds[it.ID]) {
+ out = append(out, it)
+ }
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].PrimaryPath() < out[j].PrimaryPath() })
+ return out
+}
+
// ChipCount packages a chip label, the URL that toggles it, the count it
// would yield if it were toggled on (or current count if already on), and a
// flag for whether it's currently active. Used by the template for every
diff --git a/web/view_type.go b/web/view_type.go
new file mode 100644
index 0000000..5cbe3e4
--- /dev/null
+++ b/web/view_type.go
@@ -0,0 +1,153 @@
+package web
+
+import (
+ "net/url"
+ "strings"
+)
+
+// View type enum — Phase 5i Slice B. Five values per m's Q1 + Q3 picks
+// (2026-05-26): timeline is a first-class view_type alongside the four m
+// originally named.
+const (
+ ViewTypeCard = "card"
+ ViewTypeList = "list"
+ ViewTypeCalendar = "calendar"
+ ViewTypeKanban = "kanban"
+ ViewTypeTimeline = "timeline"
+)
+
+// allViewTypes is the canonical ordered set used by validators and template
+// rendering. Adding a value here is one of the few places that needs to stay
+// in lockstep with the `view_type` CHECK constraint in migration 0016
+// (lands in slice D).
+var allViewTypes = []string{
+ ViewTypeCard,
+ ViewTypeList,
+ ViewTypeCalendar,
+ ViewTypeKanban,
+ ViewTypeTimeline,
+}
+
+// ViewTypeSet is the per-route catalog: which view types each Views-supporting
+// page accepts. Tree supports list (default) + card today; kanban joins in
+// slice C. Dashboard, calendar, and timeline are locked to their native shape
+// for slice B — accepting a different view_type silently falls back to the
+// default (no errors; the chip-strip surface signals "this view is locked").
+type ViewTypeSet struct {
+ Default string
+ Allowed []string
+}
+
+// Has reports whether vt is part of the route's allowed set.
+func (s ViewTypeSet) Has(vt string) bool {
+ for _, v := range s.Allowed {
+ if v == vt {
+ return true
+ }
+ }
+ return false
+}
+
+// Resolve returns vt if it is in the allowed set, otherwise the default. Used
+// by handlers when parsing `?view_type=`; unknown / forbidden values fall back
+// gracefully without 4xx.
+func (s ViewTypeSet) Resolve(vt string) string {
+ if s.Has(vt) {
+ return vt
+ }
+ return s.Default
+}
+
+// PageViewTypes returns the catalog for the named route. Routes outside the
+// Views system (graph, admin/*) get an empty set; their handlers don't call
+// this. The narrow tree/dashboard set is the seed; slices C–E grow it.
+func PageViewTypes(route string) ViewTypeSet {
+ switch route {
+ case "/", "tree":
+ return ViewTypeSet{
+ Default: ViewTypeList,
+ // Card joins in slice B; kanban lands in slice C — until then,
+ // `?view_type=kanban` falls back to list with the chip strip
+ // labelling it "coming soon" via the template.
+ Allowed: []string{ViewTypeList, ViewTypeCard},
+ }
+ case "/dashboard", "dashboard":
+ // Dashboard is locked to its Phase 5h tabbed-tiles surface in slice B.
+ // The view_type chip is informational only here; switching templates
+ // for card vs list on /dashboard is a follow-up slice (the tabbed
+ // tiles ARE the card view conceptually, so the work is mostly
+ // renaming labels).
+ return ViewTypeSet{
+ Default: ViewTypeCard,
+ Allowed: []string{ViewTypeCard},
+ }
+ case "/timeline", "timeline":
+ return ViewTypeSet{
+ Default: ViewTypeTimeline,
+ Allowed: []string{ViewTypeTimeline},
+ }
+ case "/calendar", "calendar":
+ return ViewTypeSet{
+ Default: ViewTypeCalendar,
+ Allowed: []string{ViewTypeCalendar},
+ }
+ }
+ return ViewTypeSet{}
+}
+
+// ParseViewType pulls `view_type` from q and falls back to the route's
+// default. Unknown values map to the default (no error path for the user).
+func ParseViewType(q url.Values, set ViewTypeSet) string {
+ raw := strings.ToLower(strings.TrimSpace(q.Get("view_type")))
+ if raw == "" {
+ return set.Default
+ }
+ return set.Resolve(raw)
+}
+
+// ViewTypeChip is one entry in the view-type chip strip rendered above the
+// section. Active marks the currently-rendered view; URL is the toggle target.
+type ViewTypeChip struct {
+ Label string
+ URL string
+ Active bool
+ // Locked is true for view types that aren't in the route's allowed set
+ // today (e.g. kanban on /tree before slice C). Rendered greyed-out with a
+ // "coming soon" title attribute. Clicking still navigates (so the URL
+ // remains shareable), but lands on the default with the chip strip
+ // showing the desired view as un-toggled.
+ Locked bool
+}
+
+// ViewTypeChips builds the chip strip for `route` given the current filter
+// and view. Currently emits chips for every value in allViewTypes; entries
+// outside the route's allowed set surface as Locked.
+func ViewTypeChips(route string, filter TreeFilter, current string) []ViewTypeChip {
+ set := PageViewTypes(route)
+ base := route
+ if base == "tree" {
+ base = "/"
+ }
+ out := make([]ViewTypeChip, 0, len(allViewTypes))
+ for _, vt := range allViewTypes {
+ urlStr := filter.URLOn(base)
+ // Embed the chosen view_type into the URL. We use a tiny query
+ // rewrite because the filter does NOT carry the view_type — keeping
+ // it out of TreeFilter (the design doc's call: render state, not
+ // filter state).
+ if vt != set.Default {
+ if strings.Contains(urlStr, "?") {
+ urlStr += "&view_type=" + vt
+ } else {
+ urlStr += "?view_type=" + vt
+ }
+ }
+ out = append(out, ViewTypeChip{
+ Label: vt,
+ URL: urlStr,
+ Active: vt == current,
+ Locked: !set.Has(vt),
+ })
+ }
+ return out
+}
diff --git a/web/view_type_test.go b/web/view_type_test.go
new file mode 100644
index 0000000..e84655b
--- /dev/null
+++ b/web/view_type_test.go
@@ -0,0 +1,93 @@
+package web
+
+import (
+ "net/url"
+ "testing"
+)
+
+// TestParseViewTypeFallsBackOnUnknown verifies that ParseViewType returns the
+// route's default for empty / unknown / forbidden values, and the requested
+// value when allowed. Slice B routes its picks through PageViewTypes for the
+// per-route allowed set.
+func TestParseViewTypeFallsBackOnUnknown(t *testing.T) {
+ cases := []struct {
+ route, raw, want string
+ }{
+ {"/", "", ViewTypeList}, // default for tree
+ {"/", "card", ViewTypeCard}, // allowed on tree
+ {"/", "list", ViewTypeList}, // explicit default
+ {"/", "kanban", ViewTypeList}, // not allowed yet → default
+ {"/", "junk", ViewTypeList}, // unknown → default
+ {"/dashboard", "", ViewTypeCard}, // default for dashboard
+ {"/dashboard", "list", ViewTypeCard}, // not allowed in slice B → default
+ {"/timeline", "card", ViewTypeTimeline}, // locked
+ {"/calendar", "kanban", ViewTypeCalendar}, // locked
+ }
+ for _, tc := range cases {
+ set := PageViewTypes(tc.route)
+ q := url.Values{}
+ if tc.raw != "" {
+ q.Set("view_type", tc.raw)
+ }
+ got := ParseViewType(q, set)
+ if got != tc.want {
+ t.Errorf("route=%s raw=%q → %q, want %q", tc.route, tc.raw, got, tc.want)
+ }
+ }
+}
+
+// TestViewTypeChipsMarkLockedAndActive asserts the chip strip emits one entry
+// per canonical view_type and that the locked + active flags follow from the
+// route's allowed set + the current pick.
+func TestViewTypeChipsMarkLockedAndActive(t *testing.T) {
+ filter := TreeFilter{Status: []string{"active"}}
+ chips := ViewTypeChips("/", filter, ViewTypeCard)
+ if len(chips) != 5 {
+ t.Fatalf("expected 5 chips (one per view_type), got %d", len(chips))
+ }
+ byLabel := map[string]ViewTypeChip{}
+ for _, c := range chips {
+ byLabel[c.Label] = c
+ }
+ if !byLabel[ViewTypeCard].Active {
+ t.Error("card chip should be Active when current=card")
+ }
+ if byLabel[ViewTypeList].Active {
+ t.Error("list chip should not be Active when current=card")
+ }
+ if byLabel[ViewTypeList].Locked {
+ t.Error("list is allowed on /; chip should not be Locked")
+ }
+ for _, locked := range []string{ViewTypeCalendar, ViewTypeKanban, ViewTypeTimeline} {
+ if !byLabel[locked].Locked {
+ t.Errorf("%s should be Locked on / in slice B (not yet implemented)", locked)
+ }
+ }
+}
+
+// TestViewTypeChipURLPreservesFilter ensures that a chip click on a filtered
+// tree carries the filter forward (so flipping to card view doesn't lose
+// `?tag=work`).
+func TestViewTypeChipURLPreservesFilter(t *testing.T) {
+ filter := TreeFilter{Status: []string{"active"}, Tags: []string{"work"}}
+ chips := ViewTypeChips("/", filter, ViewTypeList)
+ for _, c := range chips {
+ if c.Label == ViewTypeCard {
+ // Card URL must include tag=work AND view_type=card.
+ if !contains([]string{c.URL}, c.URL) || !urlContains(c.URL, "tag=work") || !urlContains(c.URL, "view_type=card") {
+ t.Errorf("card chip URL missing filter or view_type: %q", c.URL)
+ }
+ return
+ }
+ }
+ t.Fatal("card chip missing from set")
+}
+
+func urlContains(u, needle string) bool {
+ for i := 0; i+len(needle) <= len(u); i++ {
+ if u[i:i+len(needle)] == needle {
+ return true
+ }
+ }
+ return false
+}