Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
122 commits
Select commit Hold shift + click to select a range
40ce51d
feat(tutorial): tailor leave-editor copy and route success modal to s…
Ethanlita Jun 23, 2026
8148073
feat(tutorial): skip leave confirmation for course navigation
Ethanlita Jun 23, 2026
6aa0a0f
fix(tutorial): time-bound the leave-confirm skip to avoid a stuck flag
Ethanlita Jun 23, 2026
dcd989b
refactor(tutorial): address review feedback on success modal
Ethanlita Jun 24, 2026
414696a
refactor(editor): introduce editor-owned leave-confirm extension point
Ethanlita Jun 25, 2026
e9eece9
refactor(tutorial): scope leave-confirm skip to success-modal navigation
Ethanlita Jul 6, 2026
c15609a
refactor(editor): unify leave-confirmation copy and drop message over…
Ethanlita Jul 7, 2026
a973138
feat(editor): let copilot narrow the API references panel during tuto…
Ethanlita Jul 6, 2026
a13c8ce
feat(editor): add Copilot in-editor code guides
Ethanlita Jun 26, 2026
16b2a2e
chore: remove accidentally committed node_modules artifact
Ethanlita Jul 9, 2026
fc89e0e
feat(tutorial): focused editor workspace with copilot-controlled hidi…
Ethanlita Jul 9, 2026
c86bb9e
feat(tutorial): show course title in the navbar course entry dropdown
Ethanlita Jul 9, 2026
a42d403
feat(tutorial): course restart action & story video URL override
Ethanlita Jul 9, 2026
2ea1f0b
feat(tutorial): course opening sequence, success evaluation & no copy…
Ethanlita Jul 9, 2026
4f39ade
feat(tutorial): capability-first copilot guidance blocks
Ethanlita Jul 9, 2026
97fe050
fix(copilot): keep the context message within the backend length limit
Ethanlita Jul 9, 2026
c52b640
feat(tutorial): constrain guide-modal to one short plain-text sentenc…
Ethanlita Jul 9, 2026
cb81c0c
feat(tutorial): gradual intervention ladder & knowledge-point videos …
Ethanlita Jul 9, 2026
8ce624d
feat(tutorial): silent course opening & silence-first example dialogue
Ethanlita Jul 9, 2026
3edb618
feat(tutorial): forbid setup narration in the course-opening reply
Ethanlita Jul 10, 2026
1d41528
feat(copilot): respect an explicit collapse — user events no longer a…
Ethanlita Jul 10, 2026
9490d6f
feat(tutorial): docked copilot at the bottom-right corner & auto-play…
Ethanlita Jul 10, 2026
6f87597
feat(tutorial): periodic auto perception while nothing of the copilot…
Ethanlita Jul 10, 2026
50a5809
fix(tutorial): auto perception treats invisible-element replies as no…
Ethanlita Jul 10, 2026
568cf6b
feat(tutorial): auto-play each API video once per session & block-loo…
Ethanlita Jul 10, 2026
1f4dc4c
feat(tutorial): typed user messages always deserve a response
Ethanlita Jul 10, 2026
2b39706
fix(copilot): a stay-silent decision hides the whole reply, even with…
Ethanlita Jul 10, 2026
aa76431
fix(tutorial): empty setup-element attributes are no-ops instead of r…
Ethanlita Jul 10, 2026
986d94a
fix(copilot): docked panel is never dimmed by the floating out-of-bou…
Ethanlita Jul 10, 2026
4842100
feat(tutorial): demo explainer video everywhere & API hover cards pla…
Ethanlita Jul 10, 2026
9f38d33
feat(tutorial): course authors declare the story video in the course …
Ethanlita Jul 10, 2026
5042c6a
feat(tutorial): explicit intervention levels & prompt completion chec…
Ethanlita Jul 10, 2026
ca8fe7d
feat(tutorial): remind the copilot of completion & setup rules every …
Ethanlita Jul 10, 2026
4a9e7e1
feat(tutorial): intervention level gates which guidance tools exist, …
Ethanlita Jul 13, 2026
bc072f3
feat(tutorial): course author declares workspace setup, not the copilot
Ethanlita Jul 13, 2026
6ea0dff
feat(tutorial): jsonc config hides panels, copilot narrows the API li…
Ethanlita Jul 13, 2026
e28ab11
feat(tutorial): perceive the user through real editor events, drop th…
Ethanlita Jul 13, 2026
383c48e
fix(tutorial): align protocol text with the intervention-level gating
Ethanlita Jul 16, 2026
4963445
feat(tutorial): drive the intervention level from model-reported prog…
Ethanlita Jul 16, 2026
0bbfc3f
fix(tutorial): reconcile stay-silent with the progress verdicts
Ethanlita Jul 16, 2026
b1ebe5c
feat(copilot): keep the user's message last, protect critical context…
Ethanlita Jul 16, 2026
ac9e9c0
feat(copilot): persist proactive spotlights, keep hidden copilot running
Ethanlita Jul 16, 2026
22a4354
feat(editor): hide the API-reference category sidebar when a filter i…
Ethanlita Jul 16, 2026
b7152f5
docs(tutorial): record the runtime course-verification recipe
Ethanlita Jul 16, 2026
eac9c54
fix(copilot): don't surface batched-away cancellations; show tutorial…
Ethanlita Jul 16, 2026
2b5ec4b
feat(copilot): scrollable history that hides silent rounds, auto-grow…
Ethanlita Jul 16, 2026
eab44b3
feat(copilot): show the user's own messages, keep the chat to the con…
Ethanlita Jul 16, 2026
20214d1
feat(tutorial): show the guidance level in the course menu; end the o…
Ethanlita Jul 16, 2026
2234dbc
fix(tutorial): wake the copilot on game output, not just errors; ters…
Ethanlita Jul 17, 2026
b5472e1
feat(tutorial): start the course copilot collapsed, running in the ba…
Ethanlita Jul 17, 2026
a88cb05
feat(tutorial): a ruler for measuring distances on the stage
Ethanlita Jul 17, 2026
cf9de22
feat(tutorial): the ruler reads turn angles
Ethanlita Jul 17, 2026
b7cdb7b
feat(tutorial): background-first topics; courses can start with the c…
Ethanlita Jul 17, 2026
c05827a
feat(tutorial): real hosted explainer video for step
Ethanlita Jul 17, 2026
fc6da6f
feat(tutorial): programmatic course-code runner for dev verification
Ethanlita Jul 17, 2026
ba0fb06
feat(tutorial): load local .xbp in the course runner, and drive it ac…
Ethanlita Jul 17, 2026
6456ac3
docs(tutorial): what the rebuild adds, and what the Code: Lita series…
Ethanlita Jul 20, 2026
d70a22f
docs(tutorial): 教程文档改用中文
Ethanlita Jul 20, 2026
d046d31
docs(tutorial): English version of the change-surface overview
Ethanlita Jul 20, 2026
95705f9
Refactor tutorial structure and intervention design
Ethanlita Jul 20, 2026
c6fdbfe
docs(tutorial): sync the English overview with the reworked Chinese one
Ethanlita Jul 20, 2026
61a74b3
fix: fill focused stage with project backdrop
qingqing-ux Jul 22, 2026
4e70b56
Revert "fix: fill focused stage with project backdrop"
qingqing-ux Jul 22, 2026
9e96e9a
feat(tutorial): simplify the focused-mode editor per the design
Ethanlita Jul 21, 2026
3f3b902
feat(tutorial): explanatory-only API hover card in focused mode
Ethanlita Jul 21, 2026
1d77878
feat(tutorial): focused-mode 1:2.5:3 columns and a single run/stop co…
Ethanlita Jul 21, 2026
68aa07e
feat(tutorial): a control center listing the whole series
Ethanlita Jul 21, 2026
dfeb035
feat(tutorial): instant completion via a runtime sentinel, comment async
Ethanlita Jul 21, 2026
d33b0f8
feat(tutorial): teach the copilot the two completion paths
Ethanlita Jul 21, 2026
386ea5c
fix(tutorial): anchor the sprite-name label to its transform box
Ethanlita Jul 22, 2026
44ed240
feat(tutorial): align the focused editor controls with the redesign
Ethanlita Jul 22, 2026
9915ac8
fix(tutorial): align control center, ruler, and copilot with the design
Ethanlita Jul 22, 2026
48ca44b
fix(tutorial): pill-style navbar entry and series-page return
Ethanlita Jul 22, 2026
079b1d4
refactor(copilot): editor-owned docked shell over a shared chat body
Ethanlita Jul 22, 2026
daf5055
perf(tutorial): apply course-start setup locally instead of via copilot
Ethanlita Jul 22, 2026
19722d8
feat(tutorial): configurable completion signal for code-judged courses
Ethanlita Jul 22, 2026
7938506
fix(tutorial): load tutorial videos in CORS mode for COEP pages
Ethanlita Jul 23, 2026
4f88f44
chore(tutorial): point the demo video fallback at the hosted step clip
Ethanlita Jul 23, 2026
ecec465
feat(tutorial): allow course story videos from the tutorial asset host
Ethanlita Jul 23, 2026
3d8fee3
feat(tutorial): gate the input helper by type in block style
Ethanlita Jul 23, 2026
009be0e
docs(tutorial): refresh what's-new for the latest branch state
Ethanlita Jul 23, 2026
6718f87
feat(tutorial): author-ordered course opening with spotlight steps
Ethanlita Jul 23, 2026
6d83711
fix(tutorial): deliver the completion comment despite event supersession
Ethanlita Jul 23, 2026
87f6708
fix(copilot): drop ambient events while a copilot artifact is on screen
Ethanlita Jul 24, 2026
3ecc38f
feat(editor): content-fitting, drag-resizable API panel in block style
Ethanlita Jul 24, 2026
86fc558
feat(editor): keep the ruler in place while running; align the run stage
Ethanlita Jul 24, 2026
6c82a51
feat(tutorial): direction slots keep their picker; terser prelude button
Ethanlita Jul 24, 2026
ce81f62
feat(tutorial): real turn video, and a ruler video the copilot can play
Ethanlita Jul 24, 2026
379b7ba
fix(tutorial): size video dialogs to the video instead of pinning 16:9
Ethanlita Jul 24, 2026
2f28378
feat(tooling): add xbcs-package skill for course-package surgery
Ethanlita Jul 30, 2026
6bd2067
fix(tutorial): keep the opening in the editor, and the progress a fra…
Ethanlita Jul 30, 2026
094a52a
feat(editor): click the selected sprite's name label to insert it in …
Ethanlita Jul 30, 2026
9f391a6
feat(tutorial): match the video modal's player to the hover-card style
Ethanlita Jul 30, 2026
ac26d8c
docs(tutorial): sync whats-new with the latest branch work
Ethanlita Jul 30, 2026
8f41a04
feat(tutorial): hover-card-style player for the story video too
Ethanlita Jul 31, 2026
b256d6b
docs(skill): teach xbcs-package to spot app-backend contract breaks
Ethanlita Jul 31, 2026
3749717
feat(skill): catch sprites whose animations consume every costume
Ethanlita Jul 31, 2026
6df416f
feat(tutorial): sprite spotlights, patient steps, sound in video dialogs
Ethanlita Aug 3, 2026
27f1bab
feat(tutorial): let the pickup finish before the success dialog opens
Ethanlita Aug 3, 2026
e59a679
docs(tutorial): withdraw the stage-size authoring rule for now
Ethanlita Aug 3, 2026
5939317
fix(skill): accept both course-package format versions, flag the mism…
Ethanlita Aug 4, 2026
8944eea
feat(tutorial): judge how a course goal was reached, not just that it…
Ethanlita Aug 4, 2026
7958280
docs(tutorial): re-stage the curriculum — loops before conditions, 53…
Ethanlita Aug 4, 2026
60eef26
feat(tutorial): render course dialogs as Markdown, show what is being…
Ethanlita Aug 4, 2026
791c514
docs(tutorial): re-stage 19-43, and harvest on ripening as well as on…
Ethanlita Aug 5, 2026
880e687
feat(tutorial): point the knowledge-point videos at the re-shot takes
Ethanlita Aug 5, 2026
127dd53
revert(tutorial): take the explainer video back out of the API refere…
Ethanlita Aug 5, 2026
a290ea3
fix(editor): stop the thumbnail's placement from restyling its tooltip
Ethanlita Aug 5, 2026
6e203ed
feat(tutorial): teach turn's two notations as two knowledge points
Ethanlita Aug 5, 2026
a433bf4
feat(tutorial): render preludes as Markdown, dress the badge as a doc…
Ethanlita Aug 5, 2026
921be7f
fix(tutorial): break the cache on the turn video replaced under its o…
Ethanlita Aug 5, 2026
e26cb2b
fix(editor): sit the thumbnail where the document tabs sit
Ethanlita Aug 5, 2026
7bd6b2e
feat(tutorial): give stepTo its own explainer, drop the video dialog'…
Ethanlita Aug 5, 2026
ec15958
fix(tutorial): write the success comment from the code, not from a sc…
Ethanlita Aug 5, 2026
5209303
fix(tutorial): let a course point at the construct the learner skipped
Ethanlita Aug 5, 2026
9aec341
feat(tutorial): stop skipping a knowledge point the user has already …
Ethanlita Aug 6, 2026
e5a6293
feat(editor): show a sprite's name label on hover, not only when sele…
Ethanlita Aug 6, 2026
c4a41a1
fix(editor): keep the sprite name label up while the pointer rests on it
Ethanlita Aug 6, 2026
9870aa1
fix(editor): hold the sprite name label against a stale hover hit-test
Ethanlita Aug 6, 2026
242727b
feat(tutorial): give repeat its own explainer video
Ethanlita Sep 2, 2026
f2613b0
feat(tutorial): format the learner's code once the goal lands
Ethanlita Sep 1, 2026
4090074
fix(skill): sync xbcs-package course-prompt limit to 12000
Ethanlita Sep 4, 2026
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
12 changes: 12 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "spx-gui-staging",
"cwd": "spx-gui",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173
}
]
}
254 changes: 254 additions & 0 deletions .claude/skills/xbcs-package/SKILL.md

Large diffs are not rendered by default.

54 changes: 54 additions & 0 deletions .claude/skills/xbcs-package/evals/evals.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"skill_name": "xbcs-package",
"notes": "Fixtures live in /Users/lita/lita-workdir/xbcs-package-workspace/fixtures. 'Code_ Lita (designer upload).xbcs.zip' is a 31-course package repacked the way a filesystem zip tool does it (directory entries in every .xbp, a stray .DS_Store) plus three hand-edit faults: course 3's thumbnail path points at a non-existent 'thumbnails/courses/2.PNG', course 5's entrypoint names a project absent from projects[], and course 7's jsonc config block has a syntax error. 'Code_ Lita.xbcs.zip' is the same series, clean.",
"evals": [
{
"id": 0,
"name": "diagnose-and-fix-failed-upload",
"prompt": "设计师改了课程以后上传失败了,包在 /Users/lita/lita-workdir/xbcs-package-workspace/fixtures/Code_ Lita (designer upload).xbcs.zip 。帮我看看问题出在哪里。能修的话就修好,给我一个能直接上传的包,放到 OUTPUT_DIR 。",
"expected_output": "A diagnosis naming directory entries inside the .xbp payloads as the reason the import breaks (each becomes a bogus zero-byte project file), plus the three manifest faults, and a repacked .xbcs.zip in OUTPUT_DIR with all of them fixed and the course content otherwise untouched.",
"files": ["fixtures/Code_ Lita (designer upload).xbcs.zip"],
"assertions": [
"Identifies directory entries inside the .xbp files as the cause of the failed upload",
"Output package exists in OUTPUT_DIR and its filename ends in .xbcs.zip",
"No .xbp in the output contains any directory entry",
"The stray projects/.DS_Store entry is gone from the output",
"Course 3's declared thumbnail path resolves to an entry that exists in the output",
"Course 5's entrypoint project appears in the manifest's projects[] list",
"Course 7's jsonc config block parses as JSON after comment stripping",
"The output still has 31 courses and 31 projects",
"Course prompts and titles are unchanged apart from course 7's syntax fix"
]
},
{
"id": 1,
"name": "edit-course-config-and-repack",
"prompt": "基于 /Users/lita/lita-workdir/xbcs-package-workspace/fixtures/Code_ Lita.xbcs.zip 改两处课程配置:第 13 课的 complete.count 改成 6;第 5 课的 apis 加上 turnTo(原来的 step 和 turn 要保留)。改完重新打包成 OUTPUT_DIR/Code_ Lita.xbcs.zip,我要上传。",
"expected_output": "A repacked archive where course 13's complete.count is 6 and course 5's apis contains step, turn and turnTo, with everything else — including all 31 project payloads — byte-for-byte unchanged and no directory entries introduced.",
"files": ["fixtures/Code_ Lita.xbcs.zip"],
"assertions": [
"Output archive exists at OUTPUT_DIR/Code_ Lita.xbcs.zip",
"Course 13's config block has complete.count == 6",
"Course 5's apis list contains turnTo and still contains step and turn",
"No other course's jsonc config block changed",
"No .xbp in the output contains any directory entry",
"Every project payload's file set and bytes match the input package"
]
},
{
"id": 2,
"name": "replace-thumbnail-and-repack",
"prompt": "设计师重做了第 3 课的封面,新图在 /Users/lita/lita-workdir/xbcs-package-workspace/fixtures/course-3-new-cover.png 。把它换进 /Users/lita/lita-workdir/xbcs-package-workspace/fixtures/Code_ Lita.xbcs.zip 的第 3 课缩略图,重新打包到 OUTPUT_DIR/ 。",
"expected_output": "A repacked archive whose course-3 thumbnail is the new PNG, with the manifest's declared path and the entry's extension both reflecting PNG (the extension is what decides the uploaded MIME type, so leaving it as .jpeg mislabels the file). No dangling old thumbnail entry, nothing else changed.",
"files": ["fixtures/Code_ Lita.xbcs.zip", "fixtures/course-3-new-cover.png"],
"assertions": [
"Output archive exists in OUTPUT_DIR with a .xbcs.zip filename",
"The entry at course 3's declared thumbnail path is byte-identical to course-3-new-cover.png",
"Course 3's declared thumbnail path ends in .png, matching the actual PNG bytes",
"The old thumbnails/courses/2.jpeg entry is not left in the archive unreferenced",
"No other course's thumbnail bytes changed",
"No .xbp in the output contains any directory entry"
]
}
]
}
197 changes: 197 additions & 0 deletions .claude/skills/xbcs-package/references/format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# `.xbcs.zip` / `.xbp` reference

Read this when an edit goes beyond swapping text or images — when you need to know exactly what a
field means, what the importer does with it, or how a project's assets are wired together.

- [Archive layout](#archive-layout)
- [The manifest](#the-manifest)
- [Inside a `.xbp`](#inside-a-xbp)
- [Asset configs](#asset-configs)
- [What import actually does](#what-import-actually-does)
- [What export actually does](#what-export-actually-does)

Source of truth, if this drifts:
[course-series-file.ts](../../../../spx-gui/src/components/course/management/course-series-file.ts) (import/export),
[xbp.ts](../../../../spx-gui/src/models/common/xbp.ts) (project payload),
[zip.ts](../../../../spx-gui/src/utils/zip.ts) (filename decoding).

## Archive layout

```
course-series.json # the manifest
thumbnails/course-series.<ext>
thumbnails/courses/<i>.<ext> # i = position in courses[]
projects/<j>.xbp # j = position in projects[]
```

Indices in the file names are only conventional — the manifest's `path` fields are what the importer
reads, verbatim and case-sensitively. Nothing outside those declared paths is looked at, so extra
entries are inert (and a wrapping top-level folder is fatal: the manifest is no longer at the root).

## The manifest

```jsonc
{
"format": "xbuilder-course-series", // both checked; the only friendly error the importer gives
"version": 2,
"courseSeries": {
"title": "Code: Lita", // <=200
"description": "…", // <=400
"thumbnail": { "path": "thumbnails/course-series.jpeg" }
},
"courses": [{
"title": "1. 你好,Lita", // <=200
"entrypoint": "/editor/curator/Lita-Course-01/sprites/Lita/code",
"prompt": "```jsonc\n…\n```\n\n## 目标\n…", // <=4000; see docs/product/course-authoring.md
"thumbnail": { "path": "thumbnails/courses/0.jpg" }
}],
"projects": [{
"fullName": "curator/Lita-Course-01", // the exported source, used as a rewrite key
"name": "Lita-Course-01", // ^[\w-]+$, <=100; the name created on import
"path": "projects/0.xbp"
}]
}
```

**The version is decided by the deployment, not by your checkout.** The importer compares it for
exact equality and rejects every mismatch with the same opaque *"Unsupported course series file
format"* — the message names no version and does not say which side is newer. v1 → v2 (upstream
#3381) only removed `courses[].references`, so a package can otherwise be converted by editing two
things. Before building a package for someone else to upload, **export the target series once and
read the `version` it writes**; a local branch can easily sit on the older number while the site
they import into has moved on.

`courseSeries.order` is deliberately not exported — sort order depends on the other series in the
target environment, so import keeps the local value.

`courses` and `projects` are independent lists joined only through `entrypoint` / `references`. There
is no requirement that they be the same length or in the same order, but every course's entrypoint
project must appear in `projects[]`, otherwise the importer can't rewrite the owner and the course
ends up pointing at the export's original owner. That failure is silent at import time and shows up
as a 404 when a learner opens the course.

`fullName` is a *lookup key*, not a destination: import maps `fullName` → whatever it actually created
(`<signed-in-user>/<name>`) and rewrites entrypoints and references through that map. Only exact
`owner/name` segment matches are rewritten, so `curator/Lita-Course-1` is not touched by a mapping
for `curator/Lita-Course-01`.

## Inside a `.xbp`

A `.xbp` is a zip whose every entry becomes a project file, with two names treated specially:

```
builder-meta.json # { type, displayName, description, instructions, extraSettings }
builder-thumbnail.<ext> # optional; recognized by basename, becomes the project thumbnail
main.spx # stage code
<Sprite>.spx # per-sprite code
assets/index.json # stage config (backdrops, map, …)
assets/<backdrop>.png
assets/sprites/<Name>/index.json
assets/sprites/<Name>/<costume>.png|svg
assets/sounds/<Name>/index.json
assets/sounds/<Name>/<sound>.wav|mp3
```

`type` must be `game`; a missing `type` defaults to `game` for backward compatibility, anything else
is a hard failure. `displayName` should equal the manifest's `projects[].name` — import uses
`serialized.metadata.displayName ?? project.name`, so a stale value survives and mislabels the
project in the editor.

Everything else in the zip is copied verbatim into the project's file map, keyed by its zip path.
This is why directory entries are destructive: `assets/sprites/Lita/` becomes a project file at that
path, zero bytes, with `File.name === ''` (the segment after the final slash). See SKILL.md.

## Asset configs

The `.spx` code addresses assets **by name**, so names in these configs are part of the program.
`setCostume "萝卜"` and `animate "行走"` break if you rename the costume or animation without editing
the code too.

**Sprite** — `assets/sprites/<Name>/index.json`:

```jsonc
{
"costumes": [
{ "name": "kiko", "path": "kiko.png",
"x": 75, "y": 72, // pivot, in RAW image pixels
"bitmapResolution": 4 } // displayed size = raw / bitmapResolution (1 for SVG)
],
"fAnimations": {
"行走": { "frameFrom": "…-1", "frameTo": "…-6", "frameFps": 10,
"onStart": { "play": "草地行走" } }
},
"animBindings": { "step": "行走" }, // which animation an action plays
"heading": 90,
"rotationStyle": "normal", // none | normal | left-right
"faceRight": 0
}
```

`frameFrom`/`frameTo` name a **contiguous range** of costumes in `costumes[]` order — the frames
between them are played in array order, so inserting a costume in the middle silently changes an
animation.

**Every sprite needs at least one costume no animation references.** The editor's sprite model
pulls animation-referenced costumes out of the wearable-costume list (frames are not costumes
there); a sprite whose animations consume every costume loads with an empty costume list and
renders **nothing in edit mode** — while the engine does no such extraction, so the game and the
course-runner harness look perfectly fine. That split (runtime OK, editor invisible) is the
signature. `validate` checks this.

`rotationStyle: "normal"` rotates the artwork with the heading, which is what top-down art drawn
**facing right** wants. Art drawn facing down looks wrong under any rotation style; the fix is the
artwork, not the config.

**Sound** — `assets/sounds/<Name>/index.json`: `{ "path": "水声.wav", … }`.

**Stage** — `assets/index.json`: backdrops and map config, with `backdrops[].path` relative to
`assets/`.

Paths in these configs resolve relative to the config's own directory (a leading `assets/` also
works). `xbcs.py validate` checks every one of them.

## What import actually does

Order matters because there is no transaction and no rollback:

1. **For each project, serially**: load the `.xbp`; look up `<signed-in-user>/<name>`; if it exists,
**overwrite it** (files, thumbnail, displayName) and force `visibility: public`; otherwise create
it. Then cut a project release.
2. **For each course, serially**: upload its thumbnail, rewrite `entrypoint` and `references` through
the project map, create the course.
3. Update the series to point at the new course IDs.
4. Delete the old courses.

Consequences worth stating out loud when you hand a package over:

- The importer must be **signed in as the account that owns the series and the projects**. A
non-owner silently creates a full parallel set of projects under their own account and then fails
on step 3 with a 403.
- Same-named projects are **overwritten and published**, and the confirm dialog is the only warning.
- A failure at any step leaves projects already overwritten and released, courses possibly created,
and the series still pointing at the old ones. Re-running is safe per project but the intermediate
state is real.
- It is N × ~40 serial file uploads. Rate limiting (`42900` / `42901`) is a plausible failure on a
large series and looks like a random mid-import death.

Server-side limits surface as bare API codes: `40001` invalid args (the length limits above, or a
project name outside `^[\w-]+$`), `40300` forbidden, `40301` quota, `41300` content too large,
`41500` unsupported media type.

The importer's only schema check is `format` + `version`. Everything else — missing `projects`,
`courses` not an array, a `thumbnail` that isn't an object — comes out as an unguarded `TypeError`
behind a generic "failed to import" toast. That is why `xbcs.py validate` checks these itself.

## What export actually does

Exports come from the **latest release** for public projects and the draft otherwise
(`preferPublishedContent = true`), so an exported archive is not necessarily what the curator last
saved. A referenced project that is private or belongs to someone else aborts the export with a
403/404.

Thumbnails are named from the stored file's own name, which for storage keys usually has no
extension — so real exports frequently emit `.jpg` for what is actually PNG or WebP bytes. That is
where "extension disagrees with the actual image data" warnings come from; they are inherited, not
something you introduced, and the app tolerates them.

Export requires every thumbnail to already be uploaded, and drops `courseSeries.order` by design.
Loading