Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/preview-pack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kit": minor
---

Add a `preview` package and a `shellcade-kit preview pack` CLI subcommand for authoring the arcade's games-screen preview loops. Game authors write a `preview/` directory (a `preview.yaml` manifest plus plain-text frame files, 46×7 cells, optional ANSI SGR color) and pack it into a single self-contained `preview.scp` bundle that ships as a release asset. `preview.Pack` validates and compiles the authoring directory; `preview.Load` parses a bundle back into a playable `Animation`. The format enforces the frame-text contract at pack time — SGR-only escapes, no C1/invalid-UTF-8/variation-selector/ZWJ/keycap hazards, 1–64 frames, per-frame durations clamped to 80–10000ms, pad-never-truncate, and a 64 KiB bundle cap — and the arcade re-runs the identical checks on intake. See the new "Preview loops" section in GUIDE.md.
60 changes: 60 additions & 0 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,66 @@ Authoring tips:
The `smoke` package exposes the same machinery as Go API (`smoke.Parse`,
`smoke.Run`, `smoke.RenderANSI`) if you want shots inside your own tests.

## Preview loops: the games-screen art block

The arcade's games screen shows a little looping animation beside each game's
blurb — a hand-authored preview loop, distinct from the automatic smoke shots.
You author it as a `preview/` directory: a `preview.yaml` manifest plus one
plain-text file per frame, and `shellcade-kit preview pack` compiles the whole
thing into a single self-contained `preview.scp` bundle that ships as a release
asset alongside your `.wasm`.

```
preview/
preview.yaml
01-idle.frame
02-spin.frame
03-win.frame
```

The manifest lists frames in playback order, each with a file and a duration:

```yaml
frames:
- file: 01-idle.frame
ms: 900 # hold this frame 900ms
- file: 02-spin.frame
ms: 120
- file: 03-win.frame
ms: 600
```

Looping is implicit — playback runs the frames in order, then loops from the
last back to the first. There is nothing to configure.

Each frame file is plain text: **at most 7 rows of at most 46 display cells**
(the art block's interior — the arcade draws the border). Short rows and short
frames are space-padded for you; the packer **never truncates**, so an oversize
row is a hard error naming the offending file and line — trim the art yourself.
Wide glyphs (CJK, most emoji) count as two cells.

The frame-text contract is **printable text plus ANSI SGR color only**:

- `ESC[…m` styling sequences are allowed (colors, bold, reverse). Any other
escape — cursor movement, screen clears, anything that isn't SGR — is
rejected, as are raw control characters and invalid UTF-8.
- **No variation selectors (VS16 `U+FE0F`), zero-width joiners, or keycap
combiners.** These render at terminal-dependent widths: a frame that measures
46 cells in the packer can overflow the art block on a real terminal and
desync every column to its right. Compose your art from single, unambiguous
glyphs. (Styling need not be closed at end of line — the packer resets it
before the padding.)

Pack it and ship the bundle:

```sh
shellcade-kit preview pack preview/ -o preview.scp
```

The bundle is small (capped at 64 KiB) and versioned; the arcade re-runs every
one of these checks when it ingests your bundle, so a preview that
`preview pack` accepts is one the arcade will accept.

## The full loop

| Stage | Command | What it proves |
Expand Down
6 changes: 5 additions & 1 deletion cmd/shellcade-kit/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// shellcade-kit lint-width <path>... lint source for wide-glyph width-contract violations only
// shellcade-kit play <gamedir|game.wasm> [flags] play the game in a local 80x24 terminal room
// shellcade-kit smoke <gamedir|game.wasm> run the game's smoke.yaml and write the shot files
// shellcade-kit preview pack <dir> [-o out.scp] compile a games-screen preview loop into a preview.scp bundle
//
// check/play/smoke accept either a built .wasm or the game directory — a
// directory is built first (TinyGo for go.mod, cargo wasm32-wasip1 for
Expand Down Expand Up @@ -109,6 +110,9 @@ func main() {
fmt.Fprintln(os.Stderr, "shellcade-kit:", err)
os.Exit(1)
}
case "preview":
// preview pack <dir> [-o out.scp]: compile a games-screen preview loop.
runPreview(os.Args[2:])
default:
usage()
}
Expand Down Expand Up @@ -145,7 +149,7 @@ func printVersion() {
}

func usage() {
fmt.Fprintln(os.Stderr, "usage: shellcade-kit version | new [--rust] [--license ID] <name> | check <gamedir|game.wasm> | lint-width <path>... | meta <game.wasm> | play <gamedir|game.wasm> [flags] | smoke <gamedir|game.wasm> [--out dir]")
fmt.Fprintln(os.Stderr, "usage: shellcade-kit version | new [--rust] [--license ID] <name> | check <gamedir|game.wasm> | lint-width <path>... | meta <game.wasm> | play <gamedir|game.wasm> [flags] | smoke <gamedir|game.wasm> [--out dir] | preview pack <dir> [-o out.scp]")
os.Exit(2)
}

Expand Down
47 changes: 47 additions & 0 deletions cmd/shellcade-kit/preview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package main

import (
"flag"
"fmt"
"os"

"github.com/shellcade/kit/v2/preview"
)

// preview.go is the author-facing preview tooling:
// `shellcade-kit preview pack <dir>` validates an authoring directory
// (preview.yaml + frame files) and writes the single preview.scp bundle the
// arcade's games screen plays — the bundle a game ships as a release asset.

func runPreview(args []string) {
if len(args) == 0 || args[0] != "pack" {
fmt.Fprintln(os.Stderr, "usage: shellcade-kit preview pack <dir> [-o out.scp]")
os.Exit(2)
}
fs := flag.NewFlagSet("preview pack", flag.ExitOnError)
out := fs.String("o", "preview.scp", "output bundle path")
fs.Parse(args[1:])
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "usage: shellcade-kit preview pack <dir> [-o out.scp]")
os.Exit(2)
}
dir := fs.Arg(0)
fs.Parse(fs.Args()[1:]) // accept flags after the positional dir too
if fs.NArg() != 0 {
// A stray positional would silently swallow any flags after it
// (flag stops at the first non-flag) — reject it loudly instead.
fmt.Fprintf(os.Stderr, "preview pack: unexpected argument %q\nusage: shellcade-kit preview pack <dir> [-o out.scp]\n", fs.Arg(0))
os.Exit(2)
}

data, err := preview.Pack(dir)
if err != nil {
fmt.Fprintf(os.Stderr, "preview pack: %v\n", err)
os.Exit(1)
}
if err := os.WriteFile(*out, data, 0o644); err != nil {
fmt.Fprintf(os.Stderr, "preview pack: %v\n", err)
os.Exit(1)
}
fmt.Printf("packed %s (%d bytes)\n", *out, len(data))
}
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/shellcade/kit/v2
go 1.25.11

require (
github.com/charmbracelet/x/ansi v0.11.7
github.com/charmbracelet/x/term v0.2.2
github.com/extism/go-pdk v1.1.3
github.com/extism/go-sdk v1.7.1
Expand All @@ -14,9 +15,13 @@ require (
)

require (
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
golang.org/x/sys v0.46.0 // indirect
Expand Down
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE=
Expand All @@ -18,6 +24,10 @@ github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca h1:T54Ema1
github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
Expand Down
108 changes: 108 additions & 0 deletions preview/pack.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package preview

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"

"gopkg.in/yaml.v3"

"github.com/charmbracelet/x/ansi"
)

// pack.go turns an authoring directory (preview.yaml + frame files) into the
// single preview.scp bundle. Errors name the offending file/row/sequence so
// authors can fix art without reading source. The packer pads short
// rows/frames but NEVER truncates: an oversize row is the author's call to
// make, not ours.

// ManifestName is the manifest file every authoring directory carries.
const ManifestName = "preview.yaml"

// manifest is the authoring manifest: frames in playback order, each with its
// file and duration. Looping is implicit — there is nothing to configure.
type manifest struct {
Frames []manifestFrame `yaml:"frames"`
}

type manifestFrame struct {
File string `yaml:"file"`
MS int `yaml:"ms"`
}

// Pack reads dir's manifest and frame files and returns the bundle bytes.
func Pack(dir string) ([]byte, error) {
raw, err := os.ReadFile(filepath.Join(dir, ManifestName))
if err != nil {
return nil, fmt.Errorf("read manifest: %w", err)
}
var m manifest
dec := yaml.NewDecoder(strings.NewReader(string(raw)))
dec.KnownFields(true)
if err := dec.Decode(&m); err != nil {
return nil, fmt.Errorf("%s: %w", ManifestName, err)
}
if len(m.Frames) < MinFrames || len(m.Frames) > MaxFrames {
return nil, fmt.Errorf("%s lists %d frames; want %d..%d", ManifestName, len(m.Frames), MinFrames, MaxFrames)
}

b := bundleJSON{V: Version, Frames: make([]frameJSON, len(m.Frames))}
for i, entry := range m.Frames {
if entry.File == "" {
return nil, fmt.Errorf("%s frames[%d]: missing file", ManifestName, i)
}
if entry.MS == 0 {
return nil, fmt.Errorf("%s frames[%d] (%s): missing ms", ManifestName, i, entry.File)
}
if entry.MS < 0 {
return nil, fmt.Errorf("%s frames[%d] (%s): negative ms", ManifestName, i, entry.File)
}
lines, err := packFrame(dir, entry.File)
if err != nil {
return nil, err
}
b.Frames[i] = frameJSON{MS: clampMS(entry.MS), Lines: lines}
}

out, err := json.Marshal(b)
if err != nil {
return nil, fmt.Errorf("encode bundle: %w", err)
}
if len(out) > MaxBundleBytes {
return nil, fmt.Errorf("packed bundle is %d bytes; limit %d", len(out), MaxBundleBytes)
}
return out, nil
}

// packFrame reads, validates, and pads one frame file to exactly Rows×Cols.
func packFrame(dir, name string) ([]string, error) {
if filepath.IsAbs(name) || strings.Contains(name, "..") {
return nil, fmt.Errorf("frame file %q: name must be relative to the preview directory", name)
}
raw, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
return nil, fmt.Errorf("read frame: %w", err)
}
src := strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n")
if len(src) > Rows {
return nil, fmt.Errorf("%s has %d rows; a frame is at most %d", name, len(src), Rows)
}
lines := make([]string, Rows)
for r := range lines {
if r >= len(src) {
lines[r] = strings.Repeat(" ", Cols)
continue
}
line := src[r]
if err := validateText(line); err != nil {
return nil, fmt.Errorf("%s row %d: %w", name, r+1, err)
}
if w := ansi.StringWidth(line); w > Cols {
return nil, fmt.Errorf("%s row %d is %d cells; the art block is %d — trim the art, the packer never truncates", name, r+1, w, Cols)
}
lines[r] = pad(line)
}
return lines, nil
}
Loading
Loading