Skip to content

Commit 0feb70f

Browse files
committed
feat(nav): worlds-only floor + reference-only world map
1 parent ebefd02 commit 0feb70f

6 files changed

Lines changed: 238 additions & 122 deletions

File tree

internal/adapter/inbound/web/floor.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,62 @@ func floorSVG(floor domain.Floor, t trail, idx int) template.HTML {
9191
return template.HTML(b.String()) //nolint:gosec // built here from escaped parts; node text/attrs all pass html.EscapeString
9292
}
9393

94+
// floorCards renders the universe as worlds-only "door" cards (ADR 0006 §5):
95+
// the universe lists worlds, never loose documents. Each card reads as a door at
96+
// rest — a container glyph, the world name, a trailing chevron — affordances a
97+
// document row never has. Federated/remote worlds (portals) get a dashed,
98+
// external-link treatment: visibly "a server you connect to," not a local world
99+
// you enter. This is the default cold-entry view; floorSVG is the "view as map".
100+
func floorCards(floor domain.Floor, t trail, idx int) template.HTML {
101+
if len(floor.Worlds) == 0 {
102+
return template.HTML(`<p class="floor-empty">The universe is empty — no worlds visible to your identity.</p>`) //nolint:gosec // static markup
103+
}
104+
var b strings.Builder
105+
b.WriteString(`<ul class="worlds">`)
106+
for _, fw := range floor.Worlds {
107+
cls := "world-card"
108+
var href string
109+
if fw.Portal {
110+
cls += " federated"
111+
href = trailURL(trailAfterClick(t, idx, paneAddr{Kind: paneDoc, World: fw.World.Name, Value: "/"}))
112+
} else {
113+
href = trailURL(trailAfterClick(t, idx, paneAddr{Kind: paneFloor, World: fw.World.Name}))
114+
}
115+
if fw.Err {
116+
cls += " gone"
117+
}
118+
fmt.Fprintf(&b, `<li><a class="%s" href="%s">`, cls, html.EscapeString(href))
119+
b.WriteString(`<span class="world-glyph" aria-hidden="true">▤</span>`)
120+
fmt.Fprintf(&b, `<span class="world-name">%s</span>`, html.EscapeString(fw.World.Name))
121+
switch {
122+
case fw.Portal:
123+
b.WriteString(`<span class="world-tag">federated · sign-in</span><span class="world-chev" aria-hidden="true">↗</span>`)
124+
case fw.Err:
125+
b.WriteString(`<span class="world-tag">unreadable</span>`)
126+
default:
127+
b.WriteString(`<span class="world-chev" aria-hidden="true">›</span>`)
128+
}
129+
b.WriteString(`</a></li>`)
130+
}
131+
b.WriteString(`</ul>`)
132+
return template.HTML(b.String()) //nolint:gosec // built from html.EscapeString'd parts only
133+
}
134+
135+
// floorViewToggle is the "view as map / worlds" switch (ADR 0006 §5): the
136+
// universe is worlds-by-default, with the federation topology a deliberate
137+
// secondary view at ?view=map.
138+
func floorViewToggle(t trail, mapView bool) template.HTML {
139+
base := trailURL(t)
140+
if mapView {
141+
return template.HTML(`<a class="floor-view" href="` + html.EscapeString(base) + `">← worlds</a>`) //nolint:gosec // escaped
142+
}
143+
sep := "?"
144+
if strings.Contains(base, "?") {
145+
sep = "&"
146+
}
147+
return template.HTML(`<a class="floor-view" href="` + html.EscapeString(base+sep+"view=map") + `">view as map →</a>`) //nolint:gosec // escaped
148+
}
149+
94150
// floorSystem renders one authorized world: the world node (zooms into the
95151
// world map) plus its top-importance documents as satellites on an orbit ring.
96152
func floorSystem(b *strings.Builder, fw domain.FloorWorld, c floorPoint, t trail, idx int) {

internal/adapter/inbound/web/floor_test.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,35 @@ func TestFloorChunkRoundTrip(t *testing.T) {
4343
}
4444
}
4545

46+
func TestFloorCardsWorldsOnly(t *testing.T) {
47+
floor := testFloor()
48+
floor.Worlds = append(floor.Worlds, domain.FloorWorld{
49+
World: domain.WorldInfo{Name: "remote.example.org"}, Portal: true,
50+
})
51+
tr := trail{Panes: []paneAddr{{Kind: paneFloor}}, Focus: 0}
52+
out := string(floorCards(floor, tr, 0))
53+
54+
// Worlds render as door cards; no loose documents at the universe level.
55+
if !strings.Contains(out, `class="world-card"`) {
56+
t.Error("expected world door cards")
57+
}
58+
if strings.Contains(out, "Hub") || strings.Contains(out, "ADR 0005") {
59+
t.Errorf("universe must list worlds only, not documents: %s", out)
60+
}
61+
// Authorized world card → zoom to its map.
62+
if !strings.Contains(out, `href="/t/u/~/team-a/u/"`) {
63+
t.Errorf("world card should link to its map: %s", out)
64+
}
65+
// Federated/remote world → dashed federated door with an external root link.
66+
if !strings.Contains(out, "world-card federated") || !strings.Contains(out, "federated · sign-in") {
67+
t.Errorf("portal world should render as a federated door: %s", out)
68+
}
69+
// Unreadable world still renders, tagged (absence would read as nonexistence).
70+
if !strings.Contains(out, "unreadable") {
71+
t.Errorf("unreadable world should render, tagged: %s", out)
72+
}
73+
}
74+
4675
func TestFloorSVGNodesAndLinks(t *testing.T) {
4776
tr := trail{Panes: []paneAddr{{Kind: paneFloor}}, Focus: 0}
4877
svg := string(floorSVG(testFloor(), tr, 0))
@@ -120,8 +149,10 @@ func TestTrailFloorPaneFocusedLive(t *testing.T) {
120149
if got := strings.Join(svc.calls, ","); got != want {
121150
t.Errorf("calls = %q, want %q", got, want)
122151
}
123-
if !strings.Contains(rec.Body.String(), `svg class="floor"`) {
124-
t.Errorf("floor svg missing from body pane")
152+
// ADR 0006 §5: the universe renders worlds-only door cards by default
153+
// (the SVG topology is the secondary ?view=map).
154+
if !strings.Contains(rec.Body.String(), `class="world-card"`) {
155+
t.Errorf("floor body pane missing world cards")
125156
}
126157

127158
svc2 := &fakeReading{floor: testFloor()}

internal/adapter/inbound/web/templates/page.html

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,10 +170,14 @@
170170
.floor-edge { stroke: var(--faint); stroke-width: 1.5; }
171171
.floor-portal-node { fill: var(--paper); stroke: var(--muted);
172172
stroke-width: 1.5; stroke-dasharray: 3 2; }
173-
/* World map (zoom level 2): the "+N more" aggregate bubble opens a dir's
174-
listing pane. Directory hubs reuse .floor-world; docs reuse .floor-doc. */
175-
.world-map-more { fill: var(--faint); stroke: var(--muted); stroke-width: 1.5; }
173+
/* World map (zoom level 2), reference-only (ADR 0006 §5): linked docs in a
174+
connected ring (reuse .floor-doc + .graph-edge), orphans floated in a band
175+
below — dashed + dimmed so "unconnected" reads at a glance. */
176176
.world-map .floor-doc-label { fill: var(--ink); }
177+
.world-map-caption { font-family: system-ui, sans-serif; font-size: 11px; fill: var(--muted); }
178+
.world-map-band { font-family: system-ui, sans-serif; font-size: 10px; fill: var(--muted);
179+
text-transform: uppercase; letter-spacing: .05em; }
180+
.floor-doc.world-map-orphan { stroke-dasharray: 3 2; opacity: .7; }
177181
.world-map-new { margin-top: .6rem; font-family: system-ui, sans-serif; font-size: .85rem; }
178182

179183
/* ── Backlinks: the observed-links map made visible (R3). Last margin
@@ -320,6 +324,28 @@
320324
font-size: .7rem; color: var(--muted); }
321325
@media (max-width: 640px) { .palette-loc { display: none; } }
322326

327+
/* ── World index (ADR 0006 §5): the universe lists worlds as doors, never
328+
loose documents. A card carries door affordances (glyph, chevron) a
329+
document row never has; federated worlds read as a remote server. ── */
330+
.floor-view { font-family: system-ui, sans-serif; font-size: .75rem;
331+
color: var(--muted); text-decoration: none; }
332+
.floor-view:hover { color: var(--ink); }
333+
.worlds { list-style: none; margin: 1rem 0 0; padding: 0;
334+
display: grid; gap: .6rem; }
335+
.world-card { display: flex; align-items: center; gap: .7rem;
336+
padding: .7rem .9rem; border: 1px solid var(--faint); border-radius: 8px;
337+
text-decoration: none; color: inherit; font-family: system-ui, sans-serif; }
338+
.world-card:hover { border-color: var(--muted); background: var(--faint); }
339+
.world-glyph { font-size: 1.1rem; color: var(--muted); }
340+
.world-name { font-weight: 600; }
341+
.world-chev { margin-left: auto; color: var(--muted); }
342+
.world-tag { margin-left: auto; font-size: .65rem; text-transform: uppercase;
343+
letter-spacing: .04em; color: var(--muted); border: 1px solid var(--faint);
344+
border-radius: 4px; padding: 0 .35rem; }
345+
.world-card.federated { border-style: dashed; }
346+
.world-card.federated .world-chev { margin-left: .4rem; }
347+
.world-card.gone { opacity: .55; }
348+
323349
/* ── Trail dock (ADR 0006 §2): bottom orientation strip. Fixed; <details>
324350
gives zero-JS minimize. System-ui sans — an instrument, not prose. ── */
325351
body.canvas-page { padding-bottom: 4.5rem; }

internal/adapter/inbound/web/trail_handlers.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ func (h *ReadingHandler) Trail(c *echo.Context) error {
9090
var err error
9191
scope := "universe"
9292
if addr.World == "" {
93-
pane, err = h.floorPaneView(ctx, t, i)
93+
pane, err = h.floorPaneView(ctx, t, i, c.QueryParam("view") == "map")
9494
} else {
9595
scope = addr.World
9696
pane, err = h.worldMapPaneView(ctx, t, i, addr, authed)
@@ -159,7 +159,7 @@ func (h *ReadingHandler) Trail(c *echo.Context) error {
159159
// floorPaneView builds the universe pane: floor data (focused-live like
160160
// every pane), rendered as trail-aware SVG. The floor has no margin — its
161161
// trust signals are ON the nodes (status strokes, importance sizing).
162-
func (h *ReadingHandler) floorPaneView(ctx context.Context, t trail, i int) (paneVM, error) {
162+
func (h *ReadingHandler) floorPaneView(ctx context.Context, t trail, i int, mapView bool) (paneVM, error) {
163163
focused := i == t.Focus
164164
var floor domain.Floor
165165
var err error
@@ -187,7 +187,13 @@ func (h *ReadingHandler) floorPaneView(ctx context.Context, t trail, i int) (pan
187187
World: "universe",
188188
}
189189
if mode != "spine" {
190-
vm.Content = floorSVG(floor, t, i)
190+
// Worlds-only door cards by default (ADR 0006 §5); the SVG topology is
191+
// the deliberate "view as map" secondary view.
192+
body := floorCards(floor, t, i)
193+
if mapView {
194+
body = floorSVG(floor, t, i)
195+
}
196+
vm.Content = floorViewToggle(t, mapView) + body
191197
}
192198
return vm, nil
193199
}

0 commit comments

Comments
 (0)