Skip to content

Commit 3cd91b0

Browse files
authored
feat(nav): navigation rework phase 0 + command palette (name-mode)
2 parents 1751d5b + 6294637 commit 3cd91b0

24 files changed

Lines changed: 892 additions & 43 deletions
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
package web
2+
3+
import (
4+
"net/http"
5+
"net/url"
6+
"sort"
7+
"strings"
8+
9+
"github.com/labstack/echo/v5"
10+
"github.com/latebit-io/demarkus-library/internal/core/domain"
11+
)
12+
13+
// The command palette (ADR 0006 §3) — the known-item "front door". SSR/htmx-hard
14+
// (ADR 0003): it is an htmx active-search surface, never JSON and never a client
15+
// state island. The overlay markup and every result row are server-rendered;
16+
// typing fires an hx-get that swaps in an HTML fragment, and the whole thing
17+
// degrades to the /search route without JS. The current trail rides in on the
18+
// htmx HX-Current-URL header, so both the recent list (empty query) and the
19+
// trail-extending row links are computed server-side through the trail codec —
20+
// the URL stays the single source of truth, with no client-side trail logic.
21+
22+
// paletteMaxRows caps the rendered result list — a switcher shows the best
23+
// matches, not the whole catalog.
24+
const paletteMaxRows = 50
25+
26+
type paletteRow struct {
27+
Title string
28+
Loc string // world + path, the mono secondary line
29+
Status string
30+
URL string // a ready trail URL (rewind-on-dedup, else push)
31+
}
32+
33+
type paletteVM struct {
34+
Query string
35+
Rows []paletteRow
36+
}
37+
38+
// Palette renders the name-mode results fragment for the htmx active search. A
39+
// non-htmx request (no JS, or a direct hit) is redirected to /search: the
40+
// palette is a progressive enhancement, and /search is the durable, fully
41+
// server-rendered surface it degrades to.
42+
func (h *ReadingHandler) Palette(c *echo.Context) error {
43+
if c.Request().Header.Get("HX-Request") != "true" {
44+
target := "/search"
45+
if q := strings.TrimSpace(c.QueryParam("q")); q != "" {
46+
target += "?q=" + url.QueryEscape(q)
47+
}
48+
return c.Redirect(http.StatusSeeOther, target)
49+
}
50+
51+
t := currentTrail(c)
52+
q := strings.TrimSpace(c.QueryParam("q"))
53+
54+
var rows []paletteRow
55+
if q == "" {
56+
rows = recentRows(t) // "get back to where I was" is the common retrieval
57+
} else {
58+
world := c.QueryParam("world")
59+
if world == "" {
60+
world = paletteWorld(c, t, h.defaultWorld)
61+
}
62+
entries, err := h.reading.NameIndex(c.Request().Context(), c.QueryParam("scope"), world)
63+
if err != nil {
64+
// Surface real failures (re-login, unreachable world) instead of
65+
// rendering an outage as "no matches".
66+
return presentError(c, err, world, "/palette")
67+
}
68+
rows = matchRows(t, q, entries)
69+
}
70+
return c.Render(http.StatusOK, "palette-results", paletteVM{Query: q, Rows: rows})
71+
}
72+
73+
// currentTrail parses the reader's current trail from the htmx HX-Current-URL
74+
// header. Off a /t/ page (the floor, a permalink) or on any parse failure it is
75+
// the empty trail (Focus -1) — recent is then empty and a jump starts fresh.
76+
func currentTrail(c *echo.Context) trail {
77+
cur := c.Request().Header.Get("HX-Current-URL")
78+
if cur == "" {
79+
return trail{Focus: -1}
80+
}
81+
u, err := url.Parse(cur)
82+
if err != nil {
83+
return trail{Focus: -1}
84+
}
85+
rest, ok := strings.CutPrefix(u.Path, "/t/")
86+
if !ok {
87+
return trail{Focus: -1}
88+
}
89+
t, err := parseTrail(rest, u.Query().Get("focus"), u.Query().Get("reader"))
90+
if err != nil {
91+
return trail{Focus: -1}
92+
}
93+
return t
94+
}
95+
96+
// paletteWorld is the world the search scopes to by default: the focused pane's
97+
// world on a trail, the /w/<world>/ world on a permalink page, else the default
98+
// (the floor and other world-less pages).
99+
func paletteWorld(c *echo.Context, t trail, fallback string) string {
100+
if t.Focus >= 0 && t.Focus < len(t.Panes) && t.Panes[t.Focus].World != "" {
101+
return t.Panes[t.Focus].World
102+
}
103+
if w := worldFromURL(c.Request().Header.Get("HX-Current-URL")); w != "" {
104+
return w
105+
}
106+
return fallback
107+
}
108+
109+
// worldFromURL pulls the world out of a /w/<world>/... current-URL path,
110+
// returning "" when the path is not a /w/ permalink.
111+
func worldFromURL(cur string) string {
112+
if cur == "" {
113+
return ""
114+
}
115+
u, err := url.Parse(cur)
116+
if err != nil {
117+
return ""
118+
}
119+
rest, ok := strings.CutPrefix(u.Path, "/w/")
120+
if !ok {
121+
return ""
122+
}
123+
w, _, _ := strings.Cut(rest, "/")
124+
if dec, derr := url.PathUnescape(w); derr == nil {
125+
w = dec
126+
}
127+
return w
128+
}
129+
130+
// recentRows is the empty-query view: the trail in reverse (most-recent first),
131+
// the active pane skipped. Each row rewinds (focuses) its pane.
132+
func recentRows(t trail) []paletteRow {
133+
var rows []paletteRow
134+
for i := len(t.Panes) - 1; i >= 0; i-- {
135+
if i == t.Focus {
136+
continue
137+
}
138+
title, loc := paneLabel(t.Panes[i])
139+
rows = append(rows, paletteRow{Title: title, Loc: loc, URL: trailURL(trailFocused(t, i))})
140+
}
141+
return rows
142+
}
143+
144+
// matchRows fuzzy-filters the catalog and builds a trail-extending link per hit.
145+
// A jump pushes onto the end of the trail (trailAfterClick rewinds instead if
146+
// the doc is already on it).
147+
func matchRows(t trail, q string, entries []domain.IndexEntry) []paletteRow {
148+
idx := len(t.Panes) - 1 // -1 on an empty trail ⇒ a jump starts a fresh one
149+
ql := strings.ToLower(q)
150+
type hit struct {
151+
e domain.IndexEntry
152+
rank int
153+
}
154+
var hits []hit
155+
for _, e := range entries {
156+
if r, ok := matchRank(ql, strings.ToLower(e.Title+" "+e.World+e.Path)); ok {
157+
hits = append(hits, hit{e, r})
158+
}
159+
}
160+
sort.SliceStable(hits, func(i, j int) bool { return hits[i].rank < hits[j].rank })
161+
if len(hits) > paletteMaxRows {
162+
hits = hits[:paletteMaxRows]
163+
}
164+
rows := make([]paletteRow, 0, len(hits))
165+
for _, h := range hits {
166+
target := paneAddr{Kind: paneDoc, World: h.e.World, Value: h.e.Path}
167+
rows = append(rows, paletteRow{
168+
Title: h.e.Title,
169+
Loc: h.e.World + h.e.Path,
170+
Status: h.e.Status,
171+
URL: trailURL(trailAfterClick(t, idx, target)),
172+
})
173+
}
174+
return rows
175+
}
176+
177+
// matchRank scores a match: a substring hit ranks by its position (earlier is
178+
// better); a looser subsequence hit ranks behind every substring hit. Not a
179+
// match ⇒ ok=false.
180+
func matchRank(q, hay string) (int, bool) {
181+
if i := strings.Index(hay, q); i >= 0 {
182+
return i, true
183+
}
184+
j := 0
185+
for i := 0; i < len(hay) && j < len(q); i++ {
186+
if hay[i] == q[j] {
187+
j++
188+
}
189+
}
190+
if j == len(q) {
191+
return 1000, true
192+
}
193+
return 0, false
194+
}
195+
196+
// paneLabel gives a recent row its title + location from a pane address.
197+
func paneLabel(p paneAddr) (title, loc string) {
198+
switch p.Kind {
199+
case paneFloor:
200+
if p.World == "" {
201+
return "universe", ""
202+
}
203+
return p.World + " — map", p.World
204+
case paneTag:
205+
return "#" + p.Value, p.World
206+
default: // paneDoc, paneGraph
207+
name := strings.TrimSuffix(p.Value[strings.LastIndex(p.Value, "/")+1:], ".md")
208+
if name == "" {
209+
name = p.Value
210+
}
211+
if p.Kind == paneGraph {
212+
name = "graph: " + name
213+
}
214+
return name, p.World + p.Value
215+
}
216+
}

internal/adapter/inbound/web/reading_handlers_test.go

Lines changed: 89 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,22 @@ type fakeReading struct {
2727
worldMap domain.WorldMap
2828
worldMapErr error
2929

30-
draft domain.EditDraft
31-
editErr error
32-
publishErr error
33-
publishCand *domain.MergeCandidate // when set, Publish returns this merge candidate
34-
gotBody string
35-
gotMeta domain.PublishMeta
36-
gotVersion int
37-
backlink map[string][]domain.Ref // keyed by path; the graph store's reverse edges
38-
neighbor map[string]domain.Neighborhood
39-
recorded map[string][]domain.Ref // path → links RecordLinks captured
40-
called string
41-
calls []string
42-
gotTag string
30+
draft domain.EditDraft
31+
editErr error
32+
publishErr error
33+
publishCand *domain.MergeCandidate // when set, Publish returns this merge candidate
34+
gotBody string
35+
gotMeta domain.PublishMeta
36+
gotVersion int
37+
nameIndex []domain.IndexEntry
38+
nameIndexErr error
39+
40+
backlink map[string][]domain.Ref // keyed by path; the graph store's reverse edges
41+
neighbor map[string]domain.Neighborhood
42+
recorded map[string][]domain.Ref // path → links RecordLinks captured
43+
called string
44+
calls []string
45+
gotTag string
4346
}
4447

4548
func (f *fakeReading) record(method, key string) (domain.Document, error) {
@@ -80,6 +83,12 @@ func (f *fakeReading) Raw(_ context.Context, _, _ string) (domain.RawDocument, e
8083
return f.raw, f.err
8184
}
8285

86+
func (f *fakeReading) NameIndex(_ context.Context, _, _ string) ([]domain.IndexEntry, error) {
87+
f.called = "NameIndex"
88+
f.calls = append(f.calls, "NameIndex")
89+
return f.nameIndex, f.nameIndexErr
90+
}
91+
8392
func (f *fakeReading) ReadCached(_ context.Context, _, path string) (domain.Document, error) {
8493
return f.record("ReadCached", path)
8594
}
@@ -222,6 +231,69 @@ func TestReadingRoutesAreNoStore(t *testing.T) {
222231
}
223232
}
224233

234+
func TestPaletteHtmxRendersHTMLFragmentWithTrailLinks(t *testing.T) {
235+
svc := &fakeReading{nameIndex: []domain.IndexEntry{
236+
{Title: "Mission", Path: "/nib/mission.md", World: "world-a", Status: "accepted"},
237+
}}
238+
req := httptest.NewRequest(http.MethodGet, "/palette?q=mission", http.NoBody)
239+
req.Header.Set("HX-Request", "true")
240+
req.Header.Set("HX-Current-URL", "http://x/t/world-a/d/nib/index.md")
241+
rec := httptest.NewRecorder()
242+
readingApp(t, svc).ServeHTTP(rec, req)
243+
244+
if rec.Code != http.StatusOK {
245+
t.Fatalf("status = %d, want 200", rec.Code)
246+
}
247+
// SSR/htmx contract (ADR 0003): HTML, never JSON.
248+
if ct := rec.Header().Get("Content-Type"); strings.Contains(ct, "application/json") {
249+
t.Errorf("Content-Type = %q, must not be JSON", ct)
250+
}
251+
body := rec.Body.String()
252+
if !strings.Contains(body, "Mission") || !strings.Contains(body, "accepted") {
253+
t.Errorf("fragment missing title/status: %s", body)
254+
}
255+
// The row link extends the current trail (push) — computed server-side.
256+
if !strings.Contains(body, `href="/t/world-a/d/nib/index.md/~/world-a/d/nib/mission.md"`) {
257+
t.Errorf("fragment missing trail-extending link: %s", body)
258+
}
259+
if svc.called != "NameIndex" {
260+
t.Errorf("called = %q, want NameIndex", svc.called)
261+
}
262+
}
263+
264+
func TestPaletteNonHtmxDegradesToSearch(t *testing.T) {
265+
// No JS / direct hit: the palette is an enhancement, so it redirects to the
266+
// durable server-rendered /search surface.
267+
rec := get(readingApp(t, &fakeReading{}), "/palette?q=mission")
268+
if rec.Code != http.StatusSeeOther {
269+
t.Fatalf("status = %d, want 303", rec.Code)
270+
}
271+
if loc := rec.Header().Get("Location"); loc != "/search?q=mission" {
272+
t.Errorf("Location = %q, want /search?q=mission", loc)
273+
}
274+
}
275+
276+
func TestPaletteEmptyQueryShowsRecentTrail(t *testing.T) {
277+
// Empty query → recent: the trail in reverse, active pane excluded, no catalog read.
278+
svc := &fakeReading{}
279+
req := httptest.NewRequest(http.MethodGet, "/palette", http.NoBody)
280+
req.Header.Set("HX-Request", "true")
281+
req.Header.Set("HX-Current-URL", "http://x/t/world-a/d/nib/index.md/~/world-a/d/nib/mission.md")
282+
rec := httptest.NewRecorder()
283+
readingApp(t, svc).ServeHTTP(rec, req)
284+
285+
body := rec.Body.String()
286+
if !strings.Contains(body, "index") { // the non-focused pane shows as recent
287+
t.Errorf("recent view missing prior pane: %s", body)
288+
}
289+
if !strings.Contains(body, `href="/t/world-a/d/nib/index.md/~/world-a/d/nib/mission.md?focus=0"`) {
290+
t.Errorf("recent row should rewind via focus: %s", body)
291+
}
292+
if svc.called == "NameIndex" {
293+
t.Errorf("empty query must not read the catalog")
294+
}
295+
}
296+
225297
func TestDocRendersMargin(t *testing.T) {
226298
svc := &fakeReading{doc: domain.Document{
227299
Title: "ADR 7",
@@ -254,8 +326,10 @@ func TestDocRendersMargin(t *testing.T) {
254326
t.Errorf("doc page missing %q", want)
255327
}
256328
}
257-
if strings.Contains(body, `role="search"`) || strings.Contains(body, "type=\"search\"") {
258-
t.Errorf("global search box must be gone (ADR 0005 decision 5)")
329+
// ADR 0006 supersedes ADR 0005 d5: the command palette IS the global search
330+
// now — an SSR htmx active-search overlay rendered into every shell.
331+
if !strings.Contains(body, `id="palette"`) || !strings.Contains(body, `hx-get="/palette"`) {
332+
t.Errorf("command palette overlay must be present (ADR 0006)")
259333
}
260334
}
261335

internal/adapter/inbound/web/reading_routes.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ func noStore(next echo.HandlerFunc) echo.HandlerFunc {
2020
// ReadingRoutes registers the reading-room routes. A document's address is
2121
// (world, path):
2222
// - / the default trail (default world's default doc)
23+
// - /palette command palette results fragment, htmx (ADR 0006 §3)
2324
// - /t/<trail> the trail canvas (ADR 0005; format in trail.go)
2425
// - /w/:world/d/<path> a document, or the stacks when path ends in /
2526
// - /w/:world/g/<path> the graph neighborhood (links + backlinks)
@@ -42,6 +43,7 @@ func ReadingRoutes(e *echo.Echo, handler ReadingHandler, middleware ...echo.Midd
4243
// turnstile middleware runs after it.
4344
mw := append([]echo.MiddlewareFunc{noStore}, middleware...)
4445
e.GET("/", handler.Root, mw...)
46+
e.GET("/palette", handler.Palette, mw...)
4547
e.GET("/t/*", handler.Trail, mw...)
4648
e.GET("/w/:world/d/*", handler.Doc, mw...)
4749
e.GET("/w/:world/g/*", handler.GraphPage, mw...)

0 commit comments

Comments
 (0)