Add gallery core and command: routes, sidebar, search, code panel, browser hook

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-17 01:55:25 -04:00
1 parent 727de5f110
commit ff50f9b35a
10 files changed
+750 -2

No files matched your search

+3 -2
View File
@@ -70,8 +70,9 @@ health, home, marketing, content, desktop, geo · 6 gallery · 7 cmd/gallery
## Gallery entry
Each component adds one `Entry` in `gallery/demos_<category>*.go`: ID, Name, Category, Summary, Demo. The demo reports
every event through `log`. The code panel shows the demo function's own source.
Each component adds one `Entry` in `gallery/demos_<category>*.go`: ID, Name, Category, Summary, Demo. Each file's list
joins `entries` in `gallery/demos.go`. The demo is a top-level function and reports every event through `log`. The
code panel shows the demo function's own source, cut from the embedded `demos*.go`.
## Tests
+7
View File
@@ -0,0 +1,7 @@
//go:build !(js && wasm)
package main
import "git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/gallery"
func hook(*gallery.App) {}
+30
View File
@@ -0,0 +1,30 @@
//go:build js && wasm
package main
import (
"syscall/js"
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/gallery"
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound"
)
// hook exports window.__crychic for Playwright.
func hook(app *gallery.App) {
obj := js.Global().Get("Object").New()
js.Global().Get("Object").Call("defineProperty", obj, "ready", map[string]any{
"get": js.FuncOf(func(js.Value, []js.Value) any { return app.Snapshot().Built }),
})
obj.Set("page", js.FuncOf(func(js.Value, []js.Value) any { return app.Snapshot().Page }))
obj.Set("stats", js.FuncOf(func(js.Value, []js.Value) any {
return map[string]any{"components": len(gallery.Entries()), "categories": len(gallery.Categories)}
}))
obj.Set("recent", js.FuncOf(func(js.Value, []js.Value) any {
var out []any
for _, c := range sound.Recent() {
out = append(out, c.String())
}
return out
}))
js.Global().Set("__crychic", obj)
}
+28
View File
@@ -0,0 +1,28 @@
// Command gallery opens the CrychicUI gallery on desktops and browsers.
package main
import (
"log"
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/gallery"
"github.com/egoist/mygo"
"github.com/egoist/mygo/ui"
)
func main() {
app := gallery.New("/")
hook(app)
mygo.App.WhenReady(func() {
mygo.NewWindow(mygo.WindowOptions{
Title: "CrychicUI",
Width: 1280,
Height: 820,
MinWidth: 720,
MinHeight: 480,
Content: ui.View(app.View),
})
})
if err := mygo.App.Run(); err != nil {
log.Fatal(err)
}
}
+109
View File
@@ -0,0 +1,109 @@
package gallery
import (
"log/slog"
"strings"
"sync"
"github.com/egoist/mygo/ui"
"github.com/egoist/mygo/ui/editor"
)
const keepEvents = 6
// App is the gallery that desktop and browser share.
type App struct {
router *ui.Router
query string
mode int
events []string
codes map[string]*editor.State
mu sync.Mutex
snap Snapshot
}
// Snapshot is what the browser hook reads between frames.
type Snapshot struct {
Page string
Built bool
}
// New opens the gallery at path.
func New(path string) *App {
return &App{router: ui.NewRouter(path), codes: map[string]*editor.State{}}
}
// Snapshot returns the state of the last built frame.
func (a *App) Snapshot() Snapshot {
a.mu.Lock()
defer a.mu.Unlock()
return a.snap
}
func (a *App) View(c *ui.Context) {
switch a.mode {
case 1:
c.SetTheme(ui.LightTheme())
case 2:
c.SetTheme(ui.DarkTheme())
}
ui.Column(c).Fill().Children(func() {
a.topBar(c)
ui.Row(c).Grow(1).AlignItems(ui.Stretch).Children(func() {
a.sidebar(c)
ui.Column(c).Grow(1).Children(func() {
if strings.TrimSpace(a.query) != "" {
a.results(c)
return
}
a.pages(c)
})
})
a.statusBar(c)
})
a.mu.Lock()
a.snap = Snapshot{Page: a.router.Location(), Built: true}
a.mu.Unlock()
}
// Go opens path and clears the search.
func (a *App) Go(path string) {
a.query = ""
if path != a.router.Location() {
slog.Debug("crychic: gallery go", "path", path)
a.router.Push(path)
}
}
// logger keeps a demo's events for the status strip.
func (a *App) logger(e Entry) func(string) {
return func(ev string) {
// The strip shows the event; the log never does.
slog.Debug("crychic: gallery event", "entry", e.Name)
a.events = append(a.events, e.Name+": "+ev)
a.events = a.events[max(0, len(a.events)-keepEvents):]
}
}
func (a *App) code(e Entry) *editor.State {
st, ok := a.codes[e.Path()]
if !ok {
buf := editor.NewBuffer(e.Code())
buf.SetLanguage(editor.LanguageFor("demo.go"))
st = &editor.State{Buffer: buf, ReadOnly: true}
a.codes[e.Path()] = st
}
return st
}
func matches(q string) []Entry {
q = strings.ToLower(strings.TrimSpace(q))
var out []Entry
for _, e := range entries {
if strings.Contains(strings.ToLower(e.Name+" "+e.Category+" "+e.Summary), q) {
out = append(out, e)
}
}
return out
}
+106
View File
@@ -0,0 +1,106 @@
package gallery
import (
"fmt"
"log/slog"
"strings"
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit"
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound"
"github.com/egoist/mygo/ui"
)
var modes = []string{"System", "Light", "Dark"}
func (a *App) topBar(c *ui.Context) {
t := c.Theme()
ui.Row(c).FillWidth().Gap(t.Space(3)).Padding(t.Space(2), t.Space(4)).AlignItems(ui.Center).
Border(1, t.Border).Children(func() {
ui.Text(c, "CrychicUI").Bold().FontSize(t.Rem(1.15))
ui.Spacer(c)
ui.SearchField(c, &a.query).Label("Search components").Width(240)
if ui.Segmented(c, &a.mode, modes...).Label("Appearance").Changed() {
slog.Debug("crychic: gallery appearance", "mode", modes[a.mode])
}
on, glyph := sound.Enabled(), "volume-x"
if on {
glyph = "volume-2"
}
ui.Icon(c, icon.Must(glyph)).Size(16, 16)
if ui.Switch(c, &on).Label("Sound").Changed() {
sound.SetEnabled(on)
slog.Debug("crychic: gallery sound", "on", on)
}
})
}
func (a *App) sidebar(c *ui.Context) {
page := "/"
if parts := strings.Split(a.router.Path(), "/"); len(parts) > 2 && parts[1] == "c" {
page = "/c/" + parts[2]
}
if ui.Sidebar(c, &page, func() {
ui.SidebarItem(c, "/", icon.Must("house"), "Home")
ui.SidebarSection(c, "Components", nil, func() {
for _, k := range Categories {
ui.SidebarItem(c, "/c/"+k.Key, icon.Must(k.Icon), k.Name).Children(func() {
ui.Badge(c, fmt.Sprint(len(inCategory(k.Key))))
})
}
})
}).Width(220).Changed() {
a.Go(page)
}
}
func (a *App) statusBar(c *ui.Context) {
t := c.Theme()
ui.Row(c).FillWidth().Gap(t.Space(3)).Padding(t.Space(1.5), t.Space(4)).Border(1, t.Border).
Children(func() {
kit.Muted(c, "Log")
ui.Text(c, strings.Join(a.events, " · ")).MaxLines(1).Grow(1).Shrink(1)
kit.Muted(c, "Cues")
var cues []string
for _, q := range recentCues(8) {
cues = append(cues, q.String())
}
ui.Text(c, strings.Join(cues, " ")).MaxLines(1)
})
}
func recentCues(n int) []sound.Cue {
r := sound.Recent()
return r[max(0, len(r)-n):]
}
func (a *App) results(c *ui.Context) {
t := c.Theme()
found := matches(a.query)
ui.Scroll(c).Grow(1).Children(func() {
ui.Column(c).FillWidth().Gap(t.Space(1)).Padding(t.Space(6)).Children(func() {
ui.Text(c, fmt.Sprintf("%d results", len(found))).Bold()
for _, e := range found {
a.entryRow(c, e)
}
})
})
}
func (a *App) entryRow(c *ui.Context, e Entry) {
t := c.Theme()
row := ui.Row(c).Key(e.Path()).FillWidth().Gap(t.Space(3)).Padding(t.Space(2), t.Space(3)).Radius(t.Radius).
Label(e.Name)
bg := t.Background
if row.Hovered() {
bg = t.SurfaceHover
}
row.Background(row.AnimateColor("bg", bg, ui.Snappy)).Children(func() {
kit.Muted(c, fmt.Sprintf("%04d", e.ID)).Width(t.Rem(3))
ui.Text(c, e.Name).Bold().Width(t.Rem(12))
kit.Muted(c, e.Summary).MaxLines(1).Grow(1).Shrink(1)
})
if row.Clicked() {
a.Go(e.Path())
}
}
+3
View File
@@ -0,0 +1,3 @@
package gallery
var entries []Entry
+185
View File
@@ -0,0 +1,185 @@
package gallery
import (
"strings"
"testing"
"testing/fstest"
"time"
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound"
"github.com/egoist/mygo/ui"
)
func pingDemo(c *ui.Context, log func(string)) {
if ui.Button(c, "Ping").Clicked() {
log("pinged")
}
}
var pingEntry = Entry{ID: 1, Name: "PingBox", Category: "layout", Summary: "Answers a ping.", Demo: pingDemo}
// withEntry registers pingEntry for one test.
func withEntry(t *testing.T) {
t.Helper()
old, oldCode := entries, codes["pingDemo"]
entries, codes["pingDemo"] = []Entry{pingEntry}, "func pingDemo() {}\n"
t.Cleanup(func() { entries, codes["pingDemo"] = old, oldCode })
}
func open(path string) (*App, *ui.Tester) {
a := New(path)
return a, ui.NewTester(a.View, 1280, 820)
}
func TestHome(t *testing.T) {
withEntry(t)
a, tt := open("/")
for _, s := range []string{"Components", "Categories", "Sound cues", "Layout", "Geo", "1 components"} {
if !tt.HasText(s) {
t.Errorf("home lacks %q", s)
}
}
if s := a.Snapshot(); !s.Built || s.Page != "/" {
t.Errorf("snapshot %+v", s)
}
if err := tt.Click("Layout"); err != nil {
t.Fatal(err)
}
tt.Advance(time.Second)
if a.Snapshot().Page != "/c/layout" || !tt.HasText("PingBox") {
t.Errorf("page %q, texts %q", a.Snapshot().Page, tt.Texts())
}
}
func TestComponentPage(t *testing.T) {
withEntry(t)
a, tt := open(pingEntry.Path())
for _, s := range []string{"PingBox", "Answers a ping.", "0001 · layout", "Code"} {
if !tt.HasText(s) {
t.Errorf("page lacks %q", s)
}
}
if got := a.codes[pingEntry.Path()].Buffer.String(); got != codes["pingDemo"] {
t.Errorf("code = %q", got)
}
if err := tt.Click("Ping"); err != nil {
t.Fatal(err)
}
if !tt.HasText("PingBox: pinged") {
t.Errorf("log strip lacks the event: %q", tt.Texts())
}
}
func TestNotFound(t *testing.T) {
withEntry(t)
for _, p := range []string{"/c/nope", "/c/layout/nope", "/x"} {
if _, tt := open(p); !tt.HasText("Not found") {
t.Errorf("%s: no Not found", p)
}
}
}
func TestSearch(t *testing.T) {
withEntry(t)
a, tt := open("/")
a.query = "ping"
tt.Frame()
if !tt.HasText("1 results") {
t.Fatalf("texts %q", tt.Texts())
}
if err := tt.Click("PingBox"); err != nil {
t.Fatal(err)
}
tt.Advance(time.Second)
if a.query != "" || a.Snapshot().Page != pingEntry.Path() {
t.Errorf("query %q, page %q", a.query, a.Snapshot().Page)
}
}
func TestAppearance(t *testing.T) {
_, tt := open("/")
px := func() ui.Color { p := tt.Image().RGBAAt(1200, 400); return ui.Color{R: p.R, G: p.G, B: p.B, A: 255} }
if err := tt.Click("Dark"); err != nil {
t.Fatal(err)
}
tt.Advance(time.Second)
if got, want := px(), ui.DarkTheme().Background; got.R != want.R || got.G != want.G || got.B != want.B {
t.Errorf("dark background %v, want %v", got, want)
}
if err := tt.Click("Light"); err != nil {
t.Fatal(err)
}
tt.Advance(time.Second)
if got, want := px(), ui.LightTheme().Background; got.R != want.R || got.G != want.G || got.B != want.B {
t.Errorf("light background %v, want %v", got, want)
}
}
func TestSoundSwitch(t *testing.T) {
t.Cleanup(func() { sound.SetEnabled(false) })
_, tt := open("/")
if err := tt.Click("Sound"); err != nil {
t.Fatal(err)
}
if !sound.Enabled() {
t.Fatal("switch did not enable sound")
}
if err := tt.Click("Sound"); err != nil {
t.Fatal(err)
}
if sound.Enabled() {
t.Error("switch did not mute sound")
}
}
func TestCueStrip(t *testing.T) {
sound.Play(sound.Success)
if _, tt := open("/"); !strings.Contains(strings.Join(tt.Texts(), " "), "success") {
t.Errorf("cue strip lacks success: %q", tt.Texts())
}
}
func TestSources(t *testing.T) {
raw := "func demoR() string {\n\treturn `a\n}\nb`\n}"
got := sources(fstest.MapFS{"demos_x.go": {Data: []byte(
"package gallery\r\n\r\n// A doc.\r\nfunc demoA(c int) {\r\n\tx()\r\n}\r\n\r\nfunc demoB() { x() }\r\n\r\n" +
"func (e Entry) M() {\r\n}\r\n\r\n" + strings.ReplaceAll(raw, "\n", "\r\n") + "\r\n")}})
want := map[string]string{"demoA": "func demoA(c int) {\n\tx()\n}", "demoB": "func demoB() { x() }", "demoR": raw}
if len(got) != len(want) {
t.Errorf("sources = %q", got)
}
for k, v := range want {
if got[k] != v {
t.Errorf("%s = %q, want %q", k, got[k], v)
}
}
defer func() {
if recover() == nil {
t.Error("broken file did not panic")
}
}()
sources(fstest.MapFS{"demos_y.go": {Data: []byte("package gallery\nfunc demoB() {\n")}})
}
func TestSlug(t *testing.T) {
for in, want := range map[string]string{"Stack": "stack", "KPIGrid": "kpi-grid", "Base64Tool": "base64-tool"} {
if got := (Entry{Name: in}).Slug(); got != want {
t.Errorf("slug(%s) = %s, want %s", in, got, want)
}
}
}
func TestCategories(t *testing.T) {
seen := map[string]bool{}
for _, k := range Categories {
icon.Must(k.Icon)
if seen[k.Key] {
t.Errorf("category %s twice", k.Key)
}
seen[k.Key] = true
}
if len(Categories) != 38 {
t.Errorf("categories = %d, want 38", len(Categories))
}
}
+119
View File
@@ -0,0 +1,119 @@
package gallery
import (
"fmt"
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit"
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound"
"github.com/egoist/mygo/ui"
"github.com/egoist/mygo/ui/editor"
)
func (a *App) pages(c *ui.Context) {
a.router.View(c, func(r *ui.Route) {
switch {
case r.Match("/"):
r.Title("CrychicUI")
a.home(c)
case r.Match("/c/{cat}"):
k, ok := category(r.Param("cat"))
if !ok {
notFound(c)
return
}
r.Title(k.Name)
a.categoryPage(c, k)
case r.Match("/c/{cat}/{slug}"):
for _, e := range inCategory(r.Param("cat")) {
if e.Slug() == r.Param("slug") {
r.Title(e.Name)
a.entryPage(c, e)
return
}
}
notFound(c)
default:
notFound(c)
}
})
}
func page(c *ui.Context, fn func()) {
t := c.Theme()
ui.Scroll(c).Grow(1).Children(func() {
ui.Column(c).FillWidth().MaxWidth(t.Rem(64)).Gap(t.Space(4)).Padding(t.Space(8)).Children(fn)
})
}
func notFound(c *ui.Context) {
page(c, func() {
ui.Text(c, "Not found").Bold().FontSize(c.Theme().Rem(1.5))
kit.Muted(c, "No component lives at this address.")
})
}
func (a *App) home(c *ui.Context) {
t := c.Theme()
page(c, func() {
ui.Text(c, "CrychicUI").Bold().FontSize(t.Rem(2))
kit.Muted(c, "Components for MyGo native UI, in MyGo's own look, with motion and sound.")
ui.Row(c).Gap(t.Space(4)).Children(func() {
stat(c, "Components", len(entries))
stat(c, "Categories", len(Categories))
stat(c, "Sound cues", len(sound.Cues()))
})
ui.Row(c).FillWidth().Wrap().Gap(t.Space(3)).Children(func() {
for _, k := range Categories {
a.categoryCard(c, k)
}
})
})
}
func stat(c *ui.Context, label string, n int) {
t := c.Theme()
kit.Card(c).Padding(t.Space(3), t.Space(4)).Gap(t.Space(1)).Children(func() {
kit.Muted(c, label)
ui.Text(c, fmt.Sprint(n)).Bold().FontSize(t.Rem(1.5))
})
}
func (a *App) categoryCard(c *ui.Context, k Category) {
t := c.Theme()
card := kit.Press(kit.Lift(kit.Card(c).Key(k.Key).Width(t.Rem(13)).Padding(t.Space(3)).Gap(t.Space(1)).
Label(k.Name)))
card.Children(func() {
ui.Icon(c, icon.Must(k.Icon)).Size(16, 16)
ui.Text(c, k.Name).Bold()
kit.Muted(c, fmt.Sprintf("%d components", len(inCategory(k.Key))))
})
if card.Clicked() {
a.Go("/c/" + k.Key)
}
}
func (a *App) categoryPage(c *ui.Context, k Category) {
page(c, func() {
ui.Text(c, k.Name).Bold().FontSize(c.Theme().Rem(1.5))
list := inCategory(k.Key)
kit.Muted(c, fmt.Sprintf("%d components", len(list)))
for _, e := range list {
a.entryRow(c, e)
}
})
}
func (a *App) entryPage(c *ui.Context, e Entry) {
t := c.Theme()
page(c, func() {
kit.Muted(c, fmt.Sprintf("%04d · %s", e.ID, e.Category))
ui.Text(c, e.Name).Bold().FontSize(t.Rem(1.5))
kit.Muted(c, e.Summary)
kit.Card(c).Key(e.ID).FillWidth().Padding(t.Space(6)).Children(func() {
e.Demo(c, a.logger(e))
})
ui.Text(c, "Code").Bold()
editor.View(c, a.code(e)).FillWidth().Height(t.Rem(22))
})
}
+160
View File
@@ -0,0 +1,160 @@
// Package gallery shows every component live.
package gallery
import (
"embed"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"reflect"
"runtime"
"strings"
"unicode"
"github.com/egoist/mygo/ui"
)
// Entry is one component's page.
type Entry struct {
ID int
Name string
Category string
Summary string
Demo func(c *ui.Context, log func(string))
}
// Category is a component package shown in the sidebar.
type Category struct {
Key, Name, Icon string
}
var Categories = []Category{
{"layout", "Layout", "layout-grid"},
{"typography", "Typography", "type"},
{"button", "Button", "mouse-pointer-click"},
{"display", "Display", "id-card"},
{"input", "Input", "text-cursor-input"},
{"choice", "Choice", "list-checks"},
{"feedback", "Feedback", "message-square-warning"},
{"motion", "Motion", "move"},
{"fx", "Effects", "sparkles"},
{"form", "Form", "clipboard-list"},
{"datetime", "Date & Time", "calendar-clock"},
{"color", "Color", "palette"},
{"navigation", "Navigation", "compass"},
{"overlay", "Overlay", "layers"},
{"data", "Data", "table"},
{"chart", "Chart", "chart-column"},
{"media", "Media", "image"},
{"chat", "Chat", "message-circle"},
{"agent", "Agent", "bot"},
{"code", "Code", "code"},
{"files", "Files", "folder"},
{"shop", "Shop", "shopping-cart"},
{"finance", "Finance", "wallet"},
{"dashboard", "Dashboard", "layout-dashboard"},
{"schedule", "Schedule", "calendar-days"},
{"project", "Project", "kanban"},
{"mail", "Mail", "mail"},
{"social", "Social", "users"},
{"account", "Account", "user-round"},
{"devtools", "Devtools", "terminal"},
{"game", "Game", "gamepad-2"},
{"learn", "Learn", "graduation-cap"},
{"health", "Health", "heart-pulse"},
{"home", "Home", "house"},
{"marketing", "Marketing", "megaphone"},
{"content", "Content", "book-open"},
{"desktop", "Desktop", "monitor"},
{"geo", "Geo", "map-pin"},
}
// Entries lists every registered component in ID order.
func Entries() []Entry { return entries }
func category(key string) (Category, bool) {
for _, k := range Categories {
if k.Key == key {
return k, true
}
}
return Category{}, false
}
func inCategory(key string) []Entry {
var out []Entry
for _, e := range entries {
if e.Category == key {
out = append(out, e)
}
}
return out
}
// Slug turns KPIGrid into kpi-grid.
func (e Entry) Slug() string {
r := []rune(e.Name)
var b strings.Builder
for i, c := range r {
if i > 0 && unicode.IsUpper(c) {
prev := r[i-1]
endsAcronym := unicode.IsUpper(prev) && i+1 < len(r) && unicode.IsLower(r[i+1])
if unicode.IsLower(prev) || unicode.IsDigit(prev) || endsAcronym {
b.WriteByte('-')
}
}
b.WriteRune(unicode.ToLower(c))
}
return b.String()
}
func (e Entry) Path() string { return "/c/" + e.Category + "/" + e.Slug() }
//go:embed demos*.go
var demoFiles embed.FS
var codes = sources(demoFiles)
// Code is the source of the entry's demo function.
func (e Entry) Code() string {
name := funcName(e.Demo)
src, ok := codes[name]
if !ok {
panic("gallery: no source for " + name)
}
return src
}
func funcName(f any) string {
n := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name()
return n[strings.LastIndex(n, ".")+1:]
}
// sources indexes every top-level function by name.
func sources(fsys fs.FS) map[string]string {
names, err := fs.Glob(fsys, "*.go")
if err != nil {
panic(err)
}
out := map[string]string{}
for _, f := range names {
b, err := fs.ReadFile(fsys, f)
if err != nil {
panic(err)
}
// A Windows checkout may bring CRLF.
src := strings.ReplaceAll(string(b), "\r\n", "\n")
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, f, src, 0)
if err != nil {
panic(err)
}
for _, d := range file.Decls {
if fn, ok := d.(*ast.FuncDecl); ok && fn.Recv == nil {
out[fn.Name.Name] = src[fset.Position(fn.Pos()).Offset:fset.Position(fn.End()).Offset]
}
}
}
return out
}