Fix stepper default: show full "the joy of sx" on load

Root cause: default step-idx was 9, but the expression has 16 steps.
At step 9, only "the joy" + empty emerald span renders. Changed default
to 16 so all four words display after hydration.

Reverted mutable-list changes — (list) already creates ListRef in the
OCaml kernel, so append! works correctly with plain (list).

Added spec/tests/test-stepper.sx (7 tests) proving the split-tag +
steps-to-preview pipeline works correctly at each step boundary.

Updated Playwright stepper.spec.js with four tests:
- no raw SX visible after hydration
- default view shows all four words
- all spans inside h1
- stepping forward renders styled text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 13:55:40 +00:00
parent 1dd7c22201
commit 9a64f13dc6
4 changed files with 212 additions and 23 deletions

139
spec/tests/test-stepper.sx Normal file
View File

@@ -0,0 +1,139 @@
(define
is-html-tag?
(fn
(name)
(contains?
(list "div" "span" "h1" "h2" "h3" "p" "a" "button" "section" "nav")
name)))
(define
split-tag
(fn
(expr result)
(cond
(not (list? expr))
(append! result {:expr expr :type "leaf"})
(empty? expr)
nil
(not (= (type-of (first expr)) "symbol"))
(append! result {:expr expr :type "leaf"})
(is-html-tag? (symbol-name (first expr)))
(let
((ctag (symbol-name (first expr)))
(cargs (rest expr))
(cch (list))
(cat (list))
(spreads (list))
(ckw false))
(for-each
(fn
(a)
(cond
(= (type-of a) "keyword")
(do (set! ckw true) (append! cat a))
ckw
(do (set! ckw false) (append! cat a))
(and
(list? a)
(not (empty? a))
(= (type-of (first a)) "symbol")
(starts-with? (symbol-name (first a)) "~"))
(do (set! ckw false) (append! spreads a))
:else (do (set! ckw false) (append! cch a))))
cargs)
(append! result {:spreads spreads :tag ctag :type "open" :attrs cat})
(for-each (fn (c) (split-tag c result)) cch)
(append! result {:tag ctag :type "close"}))
:else (append! result {:expr expr :type "expr"}))))
(define
steps-to-preview
(fn
(all-steps target)
(if
(or (empty? all-steps) (<= target 0))
nil
(let
((pos (dict "i" 0)) (max-i (min target (len all-steps))))
(letrec
((bc-loop (fn (children) (if (>= (get pos "i") max-i) children (let ((step (nth all-steps (get pos "i"))) (stype (get step "type"))) (cond (= stype "open") (do (dict-set! pos "i" (+ (get pos "i") 1)) (let ((tag (get step "tag")) (inner (bc-loop (list)))) (append! children (concat (list (make-symbol tag)) inner))) (bc-loop children)) (= stype "close") (do (dict-set! pos "i" (+ (get pos "i") 1)) children) (= stype "leaf") (do (dict-set! pos "i" (+ (get pos "i") 1)) (append! children (get step "expr")) (bc-loop children)) :else (do (dict-set! pos "i" (+ (get pos "i") 1)) (bc-loop children))))))))
(let
((root (bc-loop (list))))
(cond
(= (len root) 1)
(first root)
(empty? root)
nil
:else (concat (list (make-symbol "<>")) root))))))))
(define
test-src
(quote (div (h1 (span "the ") (span "joy ") (span "of ") (span "sx")))))
(defsuite
"stepper-split-tag"
(deftest
"produces 16 steps for nested 4-span expression"
(let
((result (list)))
(split-tag test-src result)
(assert-equal 16 (len result))))
(deftest
"step sequence is open/open/.../close/close"
(let
((result (list)))
(split-tag test-src result)
(assert-equal "open" (get (first result) "type"))
(assert-equal "div" (get (first result) "tag"))
(assert-equal "leaf" (get (nth result 3) "type"))
(assert-equal "the " (get (nth result 3) "expr"))
(assert-equal "close" (get (last result) "type"))
(assert-equal "div" (get (last result) "tag"))))
(deftest
"children are correctly nested"
(let
((result (list)))
(split-tag test-src result)
(assert-equal "span" (get (nth result 2) "tag"))
(assert-equal "the " (get (nth result 3) "expr"))
(assert-equal "span" (get (nth result 4) "tag"))
(assert-equal "span" (get (nth result 8) "tag"))
(assert-equal "of " (get (nth result 9) "expr"))
(assert-equal "span" (get (nth result 10) "tag")))))
(defsuite
"stepper-preview"
(deftest
"full preview at step 16 equals source"
(let
((result (list)))
(split-tag test-src result)
(let
((expr (steps-to-preview result 16)))
(assert-equal test-src expr))))
(deftest
"step 10 includes of"
(let
((result (list)))
(split-tag test-src result)
(let
((expr (steps-to-preview result 10)))
(assert-true (string-contains? (str expr) "of ")))))
(deftest
"step 9 does NOT include of (leaf not yet processed)"
(let
((result (list)))
(split-tag test-src result)
(let
((expr (steps-to-preview result 9)))
(assert-false (string-contains? (str expr) "of ")))))
(deftest
"step 8 shows the and joy only"
(let
((result (list)))
(split-tag test-src result)
(let
((expr (steps-to-preview result 8)))
(assert-true (string-contains? (str expr) "the "))
(assert-true (string-contains? (str expr) "joy "))
(assert-false (string-contains? (str expr) "of "))))))

View File

@@ -5,12 +5,12 @@
((source "(div (~tw :tokens \"text-center\")\n (h1 (~tw :tokens \"text-3xl font-bold mb-2\")\n (span (~tw :tokens \"text-rose-500\") \"the \")\n (span (~tw :tokens \"text-amber-500\") \"joy \")\n (span (~tw :tokens \"text-emerald-500\") \"of \")\n (span (~tw :tokens \"text-violet-600 text-4xl\") \"sx\")))")
(steps (signal (list)))
(store
(if (client?) (def-store "home-stepper" (fn () {:step-idx (signal 9)})) nil))
(step-idx (if store (get store "step-idx") (signal 9)))
(if (client?) (def-store "home-stepper" (fn () {:step-idx (signal 16)})) nil))
(step-idx (if store (get store "step-idx") (signal 16)))
(dom-stack-sig (signal (list)))
(code-tokens (signal (list))))
(letrec
((split-tag (fn (expr result) (cond (not (list? expr)) (append! result {:expr expr :type "leaf"}) (empty? expr) nil (not (= (type-of (first expr)) "symbol")) (append! result {:expr expr :type "leaf"}) (is-html-tag? (symbol-name (first expr))) (let ((ctag (symbol-name (first expr))) (cargs (rest expr)) (cch (mutable-list)) (cat (mutable-list)) (spreads (mutable-list)) (ckw false)) (for-each (fn (a) (cond (= (type-of a) "keyword") (do (set! ckw true) (append! cat a)) ckw (do (set! ckw false) (append! cat a)) (and (list? a) (not (empty? a)) (= (type-of (first a)) "symbol") (starts-with? (symbol-name (first a)) "~")) (do (set! ckw false) (append! spreads a)) :else (do (set! ckw false) (append! cch a)))) cargs) (append! result {:spreads spreads :tag ctag :type "open" :attrs cat}) (for-each (fn (c) (split-tag c result)) cch) (append! result {:open-attrs cat :open-spreads spreads :tag ctag :type "close"})) :else (append! result {:expr expr :type "expr"}))))
((split-tag (fn (expr result) (cond (not (list? expr)) (append! result {:expr expr :type "leaf"}) (empty? expr) nil (not (= (type-of (first expr)) "symbol")) (append! result {:expr expr :type "leaf"}) (is-html-tag? (symbol-name (first expr))) (let ((ctag (symbol-name (first expr))) (cargs (rest expr)) (cch (list)) (cat (list)) (spreads (list)) (ckw false)) (for-each (fn (a) (cond (= (type-of a) "keyword") (do (set! ckw true) (append! cat a)) ckw (do (set! ckw false) (append! cat a)) (and (list? a) (not (empty? a)) (= (type-of (first a)) "symbol") (starts-with? (symbol-name (first a)) "~")) (do (set! ckw false) (append! spreads a)) :else (do (set! ckw false) (append! cch a)))) cargs) (append! result {:spreads spreads :tag ctag :type "open" :attrs cat}) (for-each (fn (c) (split-tag c result)) cch) (append! result {:open-attrs cat :open-spreads spreads :tag ctag :type "close"})) :else (append! result {:expr expr :type "expr"}))))
(build-code-tokens
(fn
(expr tokens step-ref indent)
@@ -99,9 +99,9 @@
(let
((pos (dict "i" 0)) (max-i (min target (len all-steps))))
(letrec
((bc-loop (fn (children) (if (>= (get pos "i") max-i) children (let ((step (nth all-steps (get pos "i"))) (stype (get step "type"))) (cond (= stype "open") (do (dict-set! pos "i" (+ (get pos "i") 1)) (let ((tag (get step "tag")) (attrs (or (get step "attrs") (list))) (spreads (or (get step "spreads") (list))) (inner (bc-loop (mutable-list)))) (append! children (concat (list (make-symbol tag)) spreads attrs inner))) (bc-loop children)) (= stype "close") (do (dict-set! pos "i" (+ (get pos "i") 1)) children) (= stype "leaf") (do (dict-set! pos "i" (+ (get pos "i") 1)) (append! children (get step "expr")) (bc-loop children)) (= stype "expr") (do (dict-set! pos "i" (+ (get pos "i") 1)) (append! children (get step "expr")) (bc-loop children)) :else (do (dict-set! pos "i" (+ (get pos "i") 1)) (bc-loop children))))))))
((bc-loop (fn (children) (if (>= (get pos "i") max-i) children (let ((step (nth all-steps (get pos "i"))) (stype (get step "type"))) (cond (= stype "open") (do (dict-set! pos "i" (+ (get pos "i") 1)) (let ((tag (get step "tag")) (attrs (or (get step "attrs") (list))) (spreads (or (get step "spreads") (list))) (inner (bc-loop (list)))) (append! children (concat (list (make-symbol tag)) spreads attrs inner))) (bc-loop children)) (= stype "close") (do (dict-set! pos "i" (+ (get pos "i") 1)) children) (= stype "leaf") (do (dict-set! pos "i" (+ (get pos "i") 1)) (append! children (get step "expr")) (bc-loop children)) (= stype "expr") (do (dict-set! pos "i" (+ (get pos "i") 1)) (append! children (get step "expr")) (bc-loop children)) :else (do (dict-set! pos "i" (+ (get pos "i") 1)) (bc-loop children))))))))
(let
((root (bc-loop (mutable-list))))
((root (bc-loop (list))))
(cond
(= (len root) 1)
(first root)
@@ -295,11 +295,11 @@
(when
(not (empty? parsed))
(let
((result (mutable-list)) (step-ref (dict "v" 0)))
((result (list)) (step-ref (dict "v" 0)))
(split-tag (first parsed) result)
(reset! steps result)
(let
((tokens (mutable-list)))
((tokens (list)))
(dict-set! step-ref "v" 0)
(build-code-tokens (first parsed) tokens step-ref 0)
(reset! code-tokens tokens)))))

View File

@@ -1,31 +1,81 @@
const { test, expect } = require('playwright/test');
const { loadPage, trackErrors } = require('./helpers');
test('home page stepper: no raw SX component calls visible', async ({ page }) => {
test('stepper: no raw SX component calls visible after hydration', async ({ page }) => {
const t = trackErrors(page);
await loadPage(page, '');
const stepper = page.locator('[data-sx-island="home/stepper"]');
await expect(stepper).toBeVisible({ timeout: 10000 });
// The lake (rendered preview) should NOT show raw component calls
const lake = stepper.locator('[data-sx-lake]');
await expect(lake).toBeVisible({ timeout: 5000 });
const lakeText = await lake.textContent();
expect(lakeText).not.toContain('~cssx/tw');
expect(lakeText).not.toContain('~tw');
expect(lakeText).not.toContain(':tokens');
// Should show rendered content (colored text)
expect(lakeText.length).toBeGreaterThan(5);
// Stepper navigation should work
const buttons = stepper.locator('button');
await expect(buttons).toHaveCount(2);
const textBefore = await stepper.textContent();
await buttons.last().click();
await page.waitForTimeout(300);
const textAfter = await stepper.textContent();
expect(textAfter).not.toBe(textBefore);
expect(t.errors()).toEqual([]);
});
test('stepper: default view shows all four words', async ({ page }) => {
// Clear stepper cookie
await page.context().clearCookies();
await loadPage(page, '');
const lake = page.locator('[data-sx-lake="home-preview"]');
await expect(lake).toBeVisible({ timeout: 10000 });
// Wait for hydration
await page.waitForTimeout(2000);
const text = await lake.textContent();
// All four words should be present after hydration
expect(text).toContain('the');
expect(text).toContain('joy');
expect(text).toContain('of');
expect(text).toContain('sx');
});
test('stepper: all spans inside h1 with correct structure', async ({ page }) => {
await page.context().clearCookies();
await loadPage(page, '');
await page.waitForTimeout(3000);
const lake = page.locator('[data-sx-lake="home-preview"]');
const h1 = lake.locator('h1');
await expect(h1).toBeVisible({ timeout: 5000 });
// All colored spans should be inside the h1
const spans = h1.locator('span');
const count = await spans.count();
expect(count).toBeGreaterThanOrEqual(4);
});
test('stepper: stepping forward renders styled text', async ({ page }) => {
// Start from step 0
await page.context().addCookies([{
name: 'sx-home-stepper',
value: '0',
url: 'http://localhost:8013'
}]);
await loadPage(page, '');
await page.waitForTimeout(3000);
const fwdBtn = page.locator('button:has-text("▶")');
await expect(fwdBtn).toBeVisible({ timeout: 5000 });
// Step forward 10 times to get through "of"
for (let i = 0; i < 10; i++) {
await fwdBtn.click();
await page.waitForTimeout(300);
}
const lake = page.locator('[data-sx-lake="home-preview"]');
const text = await lake.textContent();
expect(text).toContain('of');
// The "of" text should be in a styled span (with sx- or data-tw class)
const styledSpan = lake.locator('span[data-tw]').filter({ hasText: 'of' });
const count = await styledSpan.count();
expect(count).toBeGreaterThan(0);
});

View File

@@ -49,7 +49,7 @@
"Other")))
(when
(not (has-key? categories category))
(dict-set! categories category (mutable-list)))
(dict-set! categories category (list)))
(append! (get categories category) {:doc (or (get kwargs "doc") "") :example (or (get kwargs "example") "") :tail-position (or (get kwargs "tail-position") "") :syntax (or (get kwargs "syntax") "") :name name}))))
parsed-exprs)
categories)))
@@ -200,7 +200,7 @@
(fn
((pages-raw :as list))
(let
((pages-data (mutable-list)) (client-count 0) (server-count 0))
((pages-data (list)) (client-count 0) (server-count 0))
(for-each
(fn
((page :as dict))