Compare commits

..

4 Commits

Author SHA1 Message Date
Alex Dunmow
098a2091b9 feat(theme): --settle capture delay for reveal transitions and canvas heroes
Scroll-reveal themes (gotham .reveal, scifi-clean data-sc-reveal) start
content at opacity:0 under JS and fade in ~0.5s after the observer fires;
capturing on WaitVisible(main) catches mid-transition ghosts. --settle adds
a post-wait delay so transitions and canvas animations (cyberpunk rain)
finish before the screenshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:23:43 +08:00
Alex Dunmow
e71bf16d71 feat(theme): --scale for retina gallery captures
deviceScaleFactor pass-through to chromedp EmulateViewport; --scale 2
produces 2880x1800 PNGs for hidpi store galleries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:21:41 +08:00
Alex Dunmow
dda752bfc1 feat(theme): --modes light,dark for --pages gallery captures
Sets the bn-theme cookie + emulated prefers-color-scheme before navigation
so each page can be captured in both color modes (Wave B screenshot sets).
Mode-suffixed filenames (NN-<slug>-<mode>.png); empty --modes keeps the
old single site-default capture and naming.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:53:49 +08:00
Alex Dunmow
ddf2dd59ef chore: rename dev host localdev.blockninjacms.com -> blockninja.dev 2026-07-05 20:50:18 +08:00
4 changed files with 104 additions and 27 deletions

View File

@ -53,7 +53,7 @@ artifact and never compile.
Global `--host` flag: orchestrator base URL. **Default is Global `--host` flag: orchestrator base URL. **Default is
`https://my.blockninjacms.com` — PRODUCTION.** For local dev always pass `https://my.blockninjacms.com` — PRODUCTION.** For local dev always pass
`--host https://my.localdev.blockninjacms.com` (or set it as the active host via `--host https://my.blockninja.dev` (or set it as the active host via
`ninja login`). Never publish dev/test plugins to the prod registry by omitting `ninja login`). Never publish dev/test plugins to the prod registry by omitting
`--host`. `--host`.

View File

@ -23,9 +23,11 @@ func newThemeCmd() *cobra.Command {
func newThemeScreenshotCmd() *cobra.Command { func newThemeScreenshotCmd() *cobra.Command {
var gallery, slug, out, mobileOut, themeName, waitSelector string var gallery, slug, out, mobileOut, themeName, waitSelector string
var pages, screenshotsDir string var pages, screenshotsDir, modes string
var mobile bool var mobile bool
var width, height int var width, height int
var scale float64
var settle time.Duration
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "screenshot", Use: "screenshot",
Short: "Render this theme's showcase page and write preview.png into the repo", Short: "Render this theme's showcase page and write preview.png into the repo",
@ -55,7 +57,7 @@ the rest of the CLI.`,
themeName = mod.Plugin.Name themeName = mod.Plugin.Name
} }
if gallery == "" { if gallery == "" {
return fmt.Errorf("--gallery is required (the gallery CMS site base, e.g. https://showcase.localdev.blockninjacms.com)") return fmt.Errorf("--gallery is required (the gallery CMS site base, e.g. https://showcase.blockninja.dev)")
} }
ctx := context.Background() ctx := context.Background()
@ -68,6 +70,8 @@ the rest of the CLI.`,
Height: height, Height: height,
WaitSelector: waitSelector, WaitSelector: waitSelector,
Timeout: 45 * time.Second, Timeout: 45 * time.Second,
Scale: scale,
Settle: settle,
}) })
if err != nil { if err != nil {
return err return err
@ -86,6 +90,8 @@ the rest of the CLI.`,
Height: 844, Height: 844,
WaitSelector: waitSelector, WaitSelector: waitSelector,
Timeout: 45 * time.Second, Timeout: 45 * time.Second,
Scale: scale,
Settle: settle,
}) })
if err != nil { if err != nil {
return err return err
@ -101,33 +107,48 @@ the rest of the CLI.`,
// files the .bnp packer ships under screenshots/ and the registry // files the .bnp packer ships under screenshots/ and the registry
// ingests into the plugin gallery at publish. // ingests into the plugin gallery at publish.
if pages != "" { if pages != "" {
modeList, err := parseModes(modes)
if err != nil {
return err
}
if err := os.MkdirAll(screenshotsDir, 0o755); err != nil { if err := os.MkdirAll(screenshotsDir, 0o755); err != nil {
return fmt.Errorf("create %s: %w", screenshotsDir, err) return fmt.Errorf("create %s: %w", screenshotsDir, err)
} }
for i, pageSlug := range strings.Split(pages, ",") { n := 0
for _, pageSlug := range strings.Split(pages, ",") {
pageSlug = strings.TrimSpace(pageSlug) pageSlug = strings.TrimSpace(pageSlug)
if pageSlug == "" { if pageSlug == "" {
continue continue
} }
for _, mode := range modeList {
pURL := shot.PreviewURL(gallery, pageSlug, themeName) pURL := shot.PreviewURL(gallery, pageSlug, themeName)
fmt.Fprintf(os.Stderr, "capturing page %s: %s\n", pageSlug, pURL) fmt.Fprintf(os.Stderr, "capturing page %s (%s): %s\n", pageSlug, modeLabel(mode), pURL)
ppng, err := shot.Capture(ctx, shot.Options{ ppng, err := shot.Capture(ctx, shot.Options{
URL: pURL, URL: pURL,
Width: width, Width: width,
Height: height, Height: height,
WaitSelector: waitSelector, WaitSelector: waitSelector,
Timeout: 45 * time.Second, Timeout: 45 * time.Second,
Mode: mode,
Scale: scale,
Settle: settle,
}) })
if err != nil { if err != nil {
return err return err
} }
dest := filepath.Join(screenshotsDir, fmt.Sprintf("%02d-%s.png", i+1, slugFileName(pageSlug))) n++
name := fmt.Sprintf("%02d-%s.png", n, slugFileName(pageSlug))
if mode != "" {
name = fmt.Sprintf("%02d-%s-%s.png", n, slugFileName(pageSlug), mode)
}
dest := filepath.Join(screenshotsDir, name)
if err := os.WriteFile(dest, ppng, 0o644); err != nil { if err := os.WriteFile(dest, ppng, 0o644); err != nil {
return fmt.Errorf("write %s: %w", dest, err) return fmt.Errorf("write %s: %w", dest, err)
} }
fmt.Printf("wrote %s (%d bytes)\n", dest, len(ppng)) fmt.Printf("wrote %s (%d bytes)\n", dest, len(ppng))
} }
} }
}
return nil return nil
}, },
} }
@ -142,9 +163,36 @@ the rest of the CLI.`,
cmd.Flags().IntVar(&height, "height", 900, "Desktop viewport height") cmd.Flags().IntVar(&height, "height", 900, "Desktop viewport height")
cmd.Flags().StringVar(&pages, "pages", "", "Comma-separated page slugs to capture into --screenshots-dir (e.g. /,/blog,/about)") cmd.Flags().StringVar(&pages, "pages", "", "Comma-separated page slugs to capture into --screenshots-dir (e.g. /,/blog,/about)")
cmd.Flags().StringVar(&screenshotsDir, "screenshots-dir", "screenshots", "Directory for --pages captures (packed into the .bnp)") cmd.Flags().StringVar(&screenshotsDir, "screenshots-dir", "screenshots", "Directory for --pages captures (packed into the .bnp)")
cmd.Flags().DurationVar(&settle, "settle", 0, "Extra wait after --wait matches before capturing (e.g. 1500ms; lets reveal transitions and canvas animations finish)")
cmd.Flags().Float64Var(&scale, "scale", 1, "Device scale factor (2 = retina PNGs at Width*2 x Height*2)")
cmd.Flags().StringVar(&modes, "modes", "", `Comma-separated color modes for --pages captures: "light", "dark" or "light,dark" (sets the bn-theme cookie per capture; empty = site default, unsuffixed filenames)`)
return cmd return cmd
} }
// parseModes validates the --modes flag: empty means one site-default capture
// (mode ""), otherwise each entry must be "light" or "dark".
func parseModes(modes string) ([]string, error) {
if strings.TrimSpace(modes) == "" {
return []string{""}, nil
}
var out []string
for _, m := range strings.Split(modes, ",") {
m = strings.TrimSpace(m)
if m != "light" && m != "dark" {
return nil, fmt.Errorf("--modes: %q is not a valid mode (want light or dark)", m)
}
out = append(out, m)
}
return out, nil
}
func modeLabel(mode string) string {
if mode == "" {
return "default"
}
return mode
}
// slugFileName turns a page slug into a filename fragment ("/" → "home", // slugFileName turns a page slug into a filename fragment ("/" → "home",
// "/blog/post" → "blog-post"). // "/blog/post" → "blog-post").
func slugFileName(slug string) string { func slugFileName(slug string) string {

View File

@ -11,6 +11,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/chromedp/cdproto/emulation"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
) )
@ -22,6 +24,9 @@ type Options struct {
FullPage bool // capture the full scroll height, not just the viewport FullPage bool // capture the full scroll height, not just the viewport
WaitSelector string // CSS selector to wait for before capturing (e.g. "section") WaitSelector string // CSS selector to wait for before capturing (e.g. "section")
Timeout time.Duration // overall navigation+capture timeout Timeout time.Duration // overall navigation+capture timeout
Mode string // "" (site default), "light" or "dark" — sets the bn-theme cookie + emulated prefers-color-scheme before navigation
Scale float64 // device scale factor; 0 = 1.0. 2 captures retina PNGs (Width*2 × Height*2 px)
Settle time.Duration // extra wait after WaitSelector before capturing — lets scroll-reveal transitions and canvas animations finish (0 = capture immediately)
} }
// PreviewURL composes the gallery-page URL with the render-only override. // PreviewURL composes the gallery-page URL with the render-only override.
@ -65,15 +70,39 @@ func Capture(ctx context.Context, opts Options) ([]byte, error) {
defer cancelTimeout() defer cancelTimeout()
var buf []byte var buf []byte
tasks := chromedp.Tasks{ viewport := []chromedp.EmulateViewportOption(nil)
chromedp.EmulateViewport(int64(opts.Width), int64(opts.Height)), if opts.Scale > 0 {
chromedp.Navigate(opts.URL), viewport = append(viewport, chromedp.EmulateScale(opts.Scale))
} }
tasks := chromedp.Tasks{
chromedp.EmulateViewport(int64(opts.Width), int64(opts.Height), viewport...),
}
if opts.Mode == "light" || opts.Mode == "dark" {
u, err := url.Parse(opts.URL)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", opts.URL, err)
}
host := u.Hostname()
mode := opts.Mode
tasks = append(tasks,
chromedp.ActionFunc(func(ctx context.Context) error {
return network.SetCookie("bn-theme", mode).
WithDomain(host).WithPath("/").Do(ctx)
}),
emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{
{Name: "prefers-color-scheme", Value: mode},
}),
)
}
tasks = append(tasks, chromedp.Navigate(opts.URL))
if opts.WaitSelector != "" { if opts.WaitSelector != "" {
tasks = append(tasks, chromedp.WaitVisible(opts.WaitSelector, chromedp.ByQuery)) tasks = append(tasks, chromedp.WaitVisible(opts.WaitSelector, chromedp.ByQuery))
} else { } else {
tasks = append(tasks, chromedp.WaitReady("body", chromedp.ByQuery)) tasks = append(tasks, chromedp.WaitReady("body", chromedp.ByQuery))
} }
if opts.Settle > 0 {
tasks = append(tasks, chromedp.Sleep(opts.Settle))
}
if opts.FullPage { if opts.FullPage {
tasks = append(tasks, chromedp.FullScreenshot(&buf, 90)) tasks = append(tasks, chromedp.FullScreenshot(&buf, 90))
} else { } else {

View File

@ -3,16 +3,16 @@ package shot
import "testing" import "testing"
func TestPreviewURLComposesQuery(t *testing.T) { func TestPreviewURLComposesQuery(t *testing.T) {
got := PreviewURL("https://showcase.localdev.blockninjacms.com", "/", "gotham") got := PreviewURL("https://showcase.blockninja.dev", "/", "gotham")
want := "https://showcase.localdev.blockninjacms.com/?preview_template=gotham" want := "https://showcase.blockninja.dev/?preview_template=gotham"
if got != want { if got != want {
t.Fatalf("PreviewURL = %q, want %q", got, want) t.Fatalf("PreviewURL = %q, want %q", got, want)
} }
} }
func TestPreviewURLTrimsTrailingSlashHost(t *testing.T) { func TestPreviewURLTrimsTrailingSlashHost(t *testing.T) {
got := PreviewURL("https://showcase.localdev.blockninjacms.com/", "/", "noir") got := PreviewURL("https://showcase.blockninja.dev/", "/", "noir")
want := "https://showcase.localdev.blockninjacms.com/?preview_template=noir" want := "https://showcase.blockninja.dev/?preview_template=noir"
if got != want { if got != want {
t.Fatalf("PreviewURL = %q, want %q", got, want) t.Fatalf("PreviewURL = %q, want %q", got, want)
} }