Skip to content

Commit f29b3bd

Browse files
authored
Align the game page and library navigation with the web (#31)
Keep play on the game screen under a two-column hero, list likes and library empty states in navigation, and drop implied key labels from the footer.
1 parent 2b209d9 commit f29b3bd

21 files changed

Lines changed: 2534 additions & 274 deletions

‎README.md‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,12 @@ the auto-repeat heuristic.
7979

8080
| Key | Action |
8181
| --- | --- |
82-
| Tab / Shift+Tab | focus navigation, page content, or actions; move between form controls |
82+
| Ctrl+N | toggle navigation |
83+
| Tab | next item, action, form control, or dialog button |
84+
| Shift+Tab | previous item, action, form control, or dialog button |
85+
| R / P / A / U | Refresh / Play / Add / Uninstall the selected game |
86+
| / (Marketplace/Library) | jump to the Search row |
87+
| Ctrl+U (search) | clear the current query |
8388
| ↑/↓ | select a row or pause-menu item |
8489
| ←/→ (actions) | select the focused action |
8590
| enter | activate a focused item/button; ordinary game rows open details |
@@ -94,10 +99,11 @@ the auto-repeat heuristic.
9499
| Ctrl+C | quit and save local scores |
95100

96101
While a game runs, game inputs cannot operate the sidebar. Pause first, then
97-
Tab into navigation. The sidebar is 22 columns wide at 120+ terminal columns;
98-
below that it overlays the content on demand. Non-game content is capped at
99-
100 columns. Gameplay keeps its fixed cell footprint and hides the sidebar
100-
when needed to fit. See [TUI workflows](docs/tui.md) for the complete contract.
102+
Ctrl+N into navigation. When open, the 22-column sidebar pushes content at 120+
103+
terminal columns and overlays it on smaller terminals. Closing it restores the
104+
full content area. Non-game content is capped at 100 columns. Gameplay keeps
105+
its fixed cell footprint; navigation overlays rather than squeezing a game that
106+
would not fit beside it. See [TUI workflows](docs/tui.md) for the complete contract.
101107

102108
High scores persist to `~/.config/termcade/scores.json`, and are yours whether
103109
or not you have an account — see [Your history](#your-history).
@@ -109,14 +115,24 @@ Library, owner/game pages, and account/settings destinations. Browsing is
109115
anonymous. Library joins account games with installed packages, with clear
110116
installation/membership badges and Continue Playing entries.
111117

118+
Both lists have search by name, slug, or description (not owner). Marketplace
119+
searches the registry after 300ms without typing and follows all result pages;
120+
Library filters its displayed entries, including local-only games. Queries are
121+
remembered separately until cleared or the TUI exits. Searching hides Continue
122+
Playing, not sidebar games or account membership. Marketplace queries are limited
123+
by the API to 64 UTF-8 bytes after trimming.
124+
112125
In the **TUI**, Add saves account membership without downloading. Play installs
113126
a missing compatible account game; healthy installed games remain playable
114127
offline. Remove from Library changes only account membership; Uninstall here
115128
removes only the local package, after confirmation. No automatic updates replace
116129
an existing local copy.
117130

118-
Sign in from Settings without exiting the TUI: it shows the trusted pairing URL
119-
and one-time code, and opens a browser only when you choose Open browser.
131+
Sign in from the bottom nav item or Settings without exiting the TUI: a dialog
132+
centers the one-time code and trusted pairing URL (`/pair/ABCD-EFGH`, a
133+
clickable terminal link) and opens a browser only when you choose Open browser.
134+
The pair page looks up that code automatically after sign-in; approval stays
135+
explicit. Esc or Cancel returns to the underlying screen.
120136
Account authentication stays in the browser. Settings also supports handles,
121137
org/member administration, CLI-session revocation, account deletion, and
122138
revoke-then-clear sign-out. The normal pairing destination remains

‎docs/tui.md‎

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,58 @@ backend behavior, game ABI, and sandbox limits are not changed by this shell.
77
## Navigation and focus
88

99
- Marketplace is the initial destination, including while signed out.
10-
- Tab/Shift+Tab cycles navigation, content, and actions. Arrows select within the
11-
focused area; Enter activates. A normal game row opens details; Continue
12-
Playing and the explicit Play action launch the game.
13-
- At 120 or more columns the sidebar takes 22 columns. At smaller widths it is
14-
an on-demand overlay. Non-game content is centered and capped at 100 columns.
10+
- The navigation's Sign in / @username item is pinned at the bottom of the
11+
rail. Navigation has no local divider or key legend; the shared footer swaps
12+
to ↓ Tab · ↑ Shift+Tab · ✓ Enter · ✕ Esc while nav is focused. Library and installed
13+
offline games remain accessible
14+
while signed out; a long library scrolls without displacing the account row.
15+
- Ctrl+N directly toggles navigation and page content, even if an action
16+
previously had focus. It never steps through the footer. Tab is next and
17+
Shift+Tab is previous within the focused list, action row, form, dialog, or
18+
pause menu. They do not open or close navigation. Arrows still select
19+
within the focused area; Enter activates. A
20+
normal game row opens details; Continue Playing and the explicit Play action
21+
launch the game.
22+
- When open at 120 or more columns, navigation reserves 22 columns and pushes
23+
the content. On smaller terminals it overlays without reflowing the content.
24+
In both cases it stops above the main divider so page actions and keyboard
25+
hints stay fully visible. Closed navigation reserves no width. Non-game
26+
content stays centered and capped at 100 columns. If pushing would squeeze a
27+
fixed-size game, navigation uses the overlay instead, even on a wide terminal.
1528
- The supported baseline is 80×24. Lists and long text scroll. Page Up/Down
1629
moves list selection with the viewport, so actions do not target a hidden row.
17-
- During active gameplay, keys belong to the game. Esc/P pauses; Tab can then
30+
- During active gameplay, keys belong to the game. Esc/P pauses; Ctrl+N can then
1831
focus navigation. Leaving Play closes the guest and records an abandoned run.
1932
- Pause retains Resume, Restart, pixel selection for the next start, and Leave
2033
Play. Pixel changes do not stretch or mutate an existing guest framebuffer.
2134
- Forms consume typed/pasted characters. Tab moves between fields/buttons;
2235
left/right, Home/End, Backspace/Delete and Ctrl+U edit a field. Enter activates
2336
a button rather than silently submitting from a text field.
2437
- Ctrl+C quits from every state. Escape dismisses/backtracks outside gameplay.
38+
- Product pages/states share one bottom-anchored footer that spans the full
39+
terminal width. Its contents follow the focused context (navigation, page
40+
actions, forms, gameplay). Keyboard hints use ↓ Tab, ↑ Shift+Tab, ✓ Enter,
41+
and ✕ Esc. Marketplace/library game actions are keyed directly: ✕ Esc Back,
42+
↻ R Refresh, ▶ P Play, + A Add, and ⌫ U Uninstall.
43+
Pause choices and game hints are in this footer rather than over the artwork.
44+
Footer rows are reserved before content is laid out; games keep their fixed
45+
cell dimensions and show a too-small notice rather than being cropped.
2546

2647
## Library and package actions
2748

49+
Marketplace and Library have a Search row at the top of the list. Tab/Shift+Tab
50+
or arrows move onto it to type; `/` jumps there. Enter, Tab, or down moves to
51+
results without clearing. Esc is still Back. Ctrl+U clears the query. Queries
52+
survive visiting details or another destination during this TUI session.
53+
54+
Marketplace uses server name/slug/description search (not owner), debounced by
55+
300ms, and follows cursors through the full result set. An API/network failure
56+
shows an error rather than a complete-looking partial list. The API accepts at
57+
most 64 UTF-8 bytes after trimming. Library immediately filters its view using
58+
the same fields when metadata is available, including installed local-only games.
59+
Neither sidebar games nor shared membership are filtered. Continue Playing is
60+
hidden while the query is nonblank; an empty filtered list says No matching games.
61+
2862
Library is an ID-keyed union of account membership and installed packages.
2963
Installed versions are shown independently of the newest registry metadata.
3064

@@ -70,6 +104,22 @@ revoke. Account changes/expiry clear cached private member/session views.
70104

71105
## Local development pairing
72106

107+
Choose the bottom Sign in item (or Sign in within Settings) to open a centered
108+
dialog over the current screen. The dialog title is Sign in. Below it, the
109+
pairing URL, a blank line, the pairing code, and Open browser / Cancel options
110+
are centered. The URL is `/pair/ABCD-EFGH` so the pair page can look the device
111+
up automatically; it remains an OSC-8 hyperlink in terminals that support
112+
clicks. Approval in the browser is still explicit. Open browser is explicit,
113+
and passwords/email codes are never entered in the TUI. Tab or arrows select
114+
those options; Enter activates. Ctrl+N does nothing in the dialog. Esc
115+
closes it without navigating away. Successful sign-in also closes
116+
it. A paused game remains
117+
paused and open underneath. Very small terminals show a resize notice and
118+
accept only Esc/Ctrl+C until controls fit. Status sits above the divider; below
119+
it the hints are ↓ Tab, ↑ Shift+Tab, ✓ Enter, and ✕ Esc.
120+
Cancel stops waiting; if credential saving has begun, the shell reconciles
121+
actual state because a completed save cannot be undone by canceling its result.
122+
73123
Use the actual API/app ports printed by `make dev` in the backend checkout:
74124

75125
```sh

‎internal/registry/catalog_test.go‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package registry
22

33
import (
4+
"context"
45
"encoding/json"
56
"fmt"
67
"net/http"
@@ -9,10 +10,58 @@ import (
910
"path/filepath"
1011
"strings"
1112
"testing"
13+
"time"
1214

1315
"github.com/aviorstudio/termcade/sdk"
1416
)
1517

18+
func TestSearchCatalogFullTraversal(t *testing.T) {
19+
requests := 0
20+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21+
if r.URL.Query().Get("q") != "blocks & é" || r.URL.Query().Get("abi") != "" {
22+
t.Errorf("wrong search query: %s", r.URL.RawQuery)
23+
}
24+
requests++
25+
page := CatalogPage{Games: []Game{{ID: "owner/duplicate"}, {ID: fmt.Sprintf("owner/game-%d", requests)}}}
26+
if requests == 2 {
27+
page.Games = nil
28+
}
29+
if requests < 23 {
30+
page.Next = fmt.Sprint(requests)
31+
}
32+
json.NewEncoder(w).Encode(page)
33+
}))
34+
defer server.Close()
35+
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
36+
defer cancel()
37+
games, err := New(server.URL, "").WithContext(ctx).SearchCatalog(" blocks & é ")
38+
if err != nil || requests != 23 || len(games) != 23 {
39+
t.Fatalf("games=%d requests=%d err=%v", len(games), requests, err)
40+
}
41+
}
42+
43+
func TestSearchCatalogRejectsCursorCyclesAndCancellation(t *testing.T) {
44+
requests := 0
45+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
46+
requests++
47+
cursor := "A"
48+
if requests%2 == 0 {
49+
cursor = "B"
50+
}
51+
json.NewEncoder(w).Encode(CatalogPage{Games: []Game{{ID: "owner/partial"}}, Next: cursor})
52+
}))
53+
defer server.Close()
54+
games, err := New(server.URL, "").SearchCatalog("x")
55+
if err == nil || games != nil || requests != 3 {
56+
t.Fatalf("cycle returned partial success: %v %v %d", games, err, requests)
57+
}
58+
ctx, cancel := context.WithCancel(context.Background())
59+
cancel()
60+
if _, err := New(server.URL, "").WithContext(ctx).SearchCatalog("x"); err == nil {
61+
t.Fatal("canceled search succeeded")
62+
}
63+
}
64+
1665
// The catalog is paged, so browsing the marketplace is several requests. The
1766
// failure this guards against is quiet: a client that stops early shows a
1867
// short marketplace and nothing anywhere says a game is missing.
@@ -179,6 +228,9 @@ func TestCatalogExampleDecodes(t *testing.T) {
179228
if game.CreatedAt == "" || game.ReleasedAt == "" {
180229
t.Errorf("timestamps did not decode: %+v", game)
181230
}
231+
if game.Likes != 0 {
232+
t.Errorf("recorded likes = %d, want 0", game.Likes)
233+
}
182234
}
183235

184236
func TestGameExampleDecodes(t *testing.T) {

‎internal/registry/client.go‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ type Game struct {
101101
// was published; the second is absent on a game that has none.
102102
CreatedAt string `json:"created_at,omitempty"`
103103
ReleasedAt string `json:"released_at,omitempty"`
104+
Likes int `json:"likes"`
105+
Liked bool `json:"liked,omitempty"`
104106
}
105107

106108
// Resolved is which release to install and what it must hash to. The registry
@@ -303,7 +305,7 @@ type CatalogQuery struct {
303305
Cursor string
304306
// Limit is 1-200; zero lets the registry choose.
305307
Limit int
306-
// Search matches a game's name, id or description.
308+
// Search matches a game's name, slug or description (not its owner).
307309
Search string
308310
// ABI restricts to games this arcade can run. Set by Games(); the registry
309311
// treats zero as "do not filter".
@@ -361,6 +363,36 @@ func (c *Client) Games() ([]Game, error) {
361363
// play. Availability belongs on each action, not in an invisible list filter.
362364
func (c *Client) Catalog() ([]Game, error) { return c.catalog(CatalogQuery{}) }
363365

366+
// SearchCatalog walks the full public result set, including short pages. Unlike
367+
// the legacy Games convenience method it never silently truncates at 20 pages.
368+
// Callers should supply a cancellable/deadlined client context.
369+
func (c *Client) SearchCatalog(search string) ([]Game, error) {
370+
query := CatalogQuery{Search: strings.TrimSpace(search)}
371+
var all []Game
372+
seen := map[string]bool{}
373+
ids := map[string]bool{}
374+
for {
375+
page, err := c.CatalogPage(query)
376+
if err != nil {
377+
return nil, err
378+
}
379+
for _, game := range page.Games {
380+
if !ids[game.ID] {
381+
all = append(all, game)
382+
ids[game.ID] = true
383+
}
384+
}
385+
if page.Next == "" {
386+
return all, nil
387+
}
388+
if seen[page.Next] {
389+
return nil, fmt.Errorf("marketplace returned a repeated page cursor")
390+
}
391+
seen[page.Next] = true
392+
query.Cursor = page.Next
393+
}
394+
}
395+
364396
func (c *Client) catalog(query CatalogQuery) ([]Game, error) {
365397
var all []Game
366398
for range maxCatalogPages {
@@ -502,6 +534,21 @@ func (c *Client) LibraryRemove(author, slug string) error {
502534
return c.do(http.MethodDelete, "/v1/library/"+author+"/"+slug, nil, nil)
503535
}
504536

537+
type LikeState struct {
538+
Likes int `json:"likes"`
539+
Liked bool `json:"liked"`
540+
}
541+
542+
func (c *Client) Like(author, slug string) (LikeState, error) {
543+
var out LikeState
544+
return out, c.do(http.MethodPut, "/v1/games/"+author+"/"+slug+"/like", nil, &out)
545+
}
546+
547+
func (c *Client) Unlike(author, slug string) (LikeState, error) {
548+
var out LikeState
549+
return out, c.do(http.MethodDelete, "/v1/games/"+author+"/"+slug+"/like", nil, &out)
550+
}
551+
505552
// Library lists the games on this account, newest addition first. It is the
506553
// server's copy: what you have added anywhere, not what is installed here.
507554
func (c *Client) Library() ([]Game, error) {

‎internal/registry/contract/catalog.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
1313
"has_package": true,
1414
"created_at": "2026-07-30T09:00:00Z",
15-
"released_at": "2026-08-01T12:00:00Z"
15+
"released_at": "2026-08-01T12:00:00Z",
16+
"likes": 0
1617
}
1718
],
1819
"next": "MQBzbHVnAGJyaWNrb3VnaABnLWJyaWNr"

‎internal/registry/contract/game.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@
1010
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
1111
"has_package": true,
1212
"created_at": "2026-07-30T09:00:00Z",
13-
"released_at": "2026-08-01T12:00:00Z"
13+
"released_at": "2026-08-01T12:00:00Z",
14+
"likes": 0
1415
}

‎internal/registry/contract/library.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"has_package": true,
1313
"created_at": "2026-07-30T09:00:00Z",
1414
"released_at": "2026-08-01T12:00:00Z",
15+
"likes": 0,
1516
"activity": {
1617
"id": "aviorstudio/brickough",
1718
"personal_best": 4242,

‎internal/registry/device.go‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"net/http"
8+
"net/url"
89
"regexp"
910
"strings"
1011
"time"
@@ -59,6 +60,24 @@ type DevicePoll struct {
5960
Interval int64 `json:"interval"`
6061
}
6162

63+
// PairingURLWithCode appends the displayed user code as /pair/ABCD-EFGH so
64+
// opening the link can look the device up. Invalid codes are not attached.
65+
func PairingURLWithCode(base, code string) string {
66+
code = strings.ToUpper(strings.TrimSpace(code))
67+
if base == "" || !userCodeRE.MatchString(code) {
68+
return base
69+
}
70+
parsed, err := url.Parse(base)
71+
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
72+
return base
73+
}
74+
if strings.TrimRight(parsed.Path, "/") != "/pair" {
75+
return base
76+
}
77+
parsed.Path = "/pair/" + code
78+
return parsed.String()
79+
}
80+
6281
func (c *Client) StartDevice(ctx context.Context, deviceName string) (DeviceRound, error) {
6382
deviceName = safeText(deviceName, 200)
6483
if deviceName == "" {

‎internal/registry/device_test.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,3 +258,19 @@ func TestRevokedCredentialIsNotReturnedAsSession(t *testing.T) {
258258
t.Fatalf("revocation error = %v, want ErrLoginRequired", err)
259259
}
260260
}
261+
262+
func TestPairingURLWithCodeAttachesOnlyAValidUserCode(t *testing.T) {
263+
base := "https://app.termca.de/pair"
264+
got := PairingURLWithCode(base, " abcd-efgh ")
265+
if got != base+"/ABCD-EFGH" {
266+
t.Fatalf("got %q", got)
267+
}
268+
for _, code := range []string{"", "not-a-code", "ABCD-EFGH/../x", "https://evil.example"} {
269+
if PairingURLWithCode(base, code) != base {
270+
t.Fatalf("attached invalid code %q", code)
271+
}
272+
}
273+
if PairingURLWithCode("https://user:pass@app.termca.de/pair", "ABCD-EFGH") != "https://user:pass@app.termca.de/pair" {
274+
t.Fatal("credentials in pairing URL were preserved into a complete link")
275+
}
276+
}

‎internal/shell/menu.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
)
1111

1212
var (
13-
logoStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#3fc4c9"))
13+
logoStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e6c945"))
1414
selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e6c945"))
1515
normalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f2f2f2"))
1616
)

0 commit comments

Comments
 (0)