Closed general

Readme sandbox: 4 fixes to make rich interactive readmes first-class (CSP connect-src, size cap, /blob content-types, html+md double-render)

John Lauer · 5d ago ·closed by Colby Knox

Rich interactive readmes are possible today but require extreme workarounds - 4 platform fixes would make them first-class

Proven live on https://wiki.adom.inc/adom/bq25792-charger (working exploded/assembled 3D toggle in readme.html, extending the wiki's own adom-3d-viewer-babylon9 bundle). Full recipe now in the skillpack (wiki-readme-3d, john/adom-wiki-skillpack 0.7.5). What we had to fight, with measured evidence:

1. /readme CSP: connect-src 'none' blocks ALL runtime data

The readme document cannot fetch anything - not the wiki API, not its own repo's /blob files, not even data: URIs. Workaround in production: GLB bytes packed into lossless repo PNGs (img-src https: is open, /blob serves image/png inline with CORS *) and decoded through a canvas. Ask: connect-src 'self'. The PNG hack becomes a plain fetch; readme.html for the BQ page is already only 13 KB.

2. /readme size cap ~4-5 MB, undocumented, opaque error

Probed: 4,000,000 bytes renders; 5,000,000 returns {"error":"README too large to render"}. Cost us three publish cycles. Ask: document it + include the limit in the error.

3. /blob content-type whitelist forces downloads

readme.html/csv/js from /blob: application/octet-stream + content-disposition: attachment + nosniff. GLB/JSON/PNG serve correctly. (Same family as SVG issue #38.) Ask: correct inline content-types for html/js/svg/csv, or a /raw route.

4. readme.html + README.md BOTH render on the Overview

The shadowing rule says readme.html replaces README.md, but the page currently renders the html iframe AND the old markdown below it (seen on adom/bq25792-charger through v0.1.23).

Bonus findings for the viewer team

  • Draco GLBs cannot decode in the readme sandbox (no wasm-unsafe-eval, no workers). quantize+KHR_mesh_quantization works everywhere, decoder-free; PNG-over-repo even recovers the compression (7.8 MB quantized GLB -> 2.1 MB PNG).
  • EXT_mesh_gpu_instancing (gltf-transform optimize default) renders as ONE giant mis-scaled instance in the babylon9 bundle - build viewer GLBs with --instance false.
  • Nesting /viewer/3d/... in an iframe inside the readme sandbox: scene runs at 60fps but composites BLACK on screen.
  • The bundle only starts its render loop / lights / camera framing inside its own loadModel() - external SceneLoader.AppendAsync users must do all three manually (details in the skill).

7 Replies

John Lauer · 5d ago

Follow-up findings from building the animated stackup viewer (adom/bq25792-charger, readme v0.1.30):

5. The readme iframe forces a fixed height (~600px). Any-height content should be possible - either auto-size the iframe to its content (postMessage height protocol) or allow the page to declare a height. A 3D viewer + article cramped into 600px with an inner scrollbar reads badly (John).

6. The readme iframe lacks allow="fullscreen". requestFullscreen from readme content is blocked in the embed; works only when the /readme URL is opened directly. Please add allowfullscreen + allow="fullscreen" to the Overview iframe.

7. Deployed viewer bundle: the SceneBuilder light rig + embedded studio env do not materialize under the readme sandbox even when driving the bundle's own loadModel via BABYLON.FilesInputStore + loadModel("file:..."). Source (adom-inc/adom-3d-viewer-babylon9) shows the env is STUDIO_ENV_BASE64 (no fetch) and lights are procedural, yet scene.lights stays empty and no viewcube renders in-sandbox, while the same bundle works on /viewer. Needs a debug build to root-cause; suspect an init path that bails silently under an opaque origin.

8. Feature ask for the viewer: a native "exploded stackup animation" mode. We built assembled->explode->reassemble looping per-layer animation page-side (per-layer groups + runtime z interpolation, order-preserving deltas incl. through-board pins needing extra clearance). This belongs in adom-3d-viewer-babylon9 as a first-class mode (input: layer list with assembled/exploded z per group) so every board page gets it with the full viewcube/env experience instead of page scripting.

John Lauer · 5d ago

FINAL FINDINGS UPDATE (2026-07-16) - includes the ROOT CAUSE fix for item 7

Everything below was proven live on https://wiki.adom.inc/adom/bq25792-charger (readme viewer v0.1.74, working: cascade explode animation, animated PCSS shadows, layers panel, ViewCube, all inside the readme iframe).

THE BIG ONE: item 7 is SOLVED - sandboxed docs THROW on localStorage, killing the viewer rig

The mystery of "the deployed bundle's lights/env/ViewCube never materialize in the sandbox" is root-caused: a sandboxed iframe WITHOUT allow-same-origin throws SecurityError on ANY localStorage/sessionStorage ACCESS (even a read). The viewer bundle touches storage during init, the exception silently aborts the whole SceneBuilder rig (hemispheric+bottom+spot lights, embedded studio env, ViewCube, shadow generator). Caught via a puppeteer console capture: "SecurityError: Failed to read the 'localStorage' property from 'Window': The document is sandboxed and lacks the 'allow-same-origin' flag."

Fix that makes the FULL native viewer work in any sandbox (12 lines, before the bundle script):

<script>
(function(){
  function mem(){ var st={}; return {getItem:k=>k in st?st[k]:null,setItem:(k,v)=>{st[k]=String(v)},removeItem:k=>{delete st[k]},clear:()=>{st={}},key:i=>Object.keys(st)[i]||null,get length(){return Object.keys(st).length}}; }
  try{ window.localStorage; }catch(e){ Object.defineProperty(window,"localStorage",{value:mem(),configurable:true}); }
  try{ window.sessionStorage; }catch(e){ Object.defineProperty(window,"sessionStorage",{value:mem(),configurable:true}); }
})();
</script>

Platform ask: either the readme pipeline injects this shim, or (better) the viewer guards its storage access with try/catch. Then ViewCube + env + lights + shadows all come alive in the readme sandbox with ZERO page hacks. Verified: scenes:2 (ViewCube scene!), the exact SceneBuilder light rig, env:true.

Complete workaround inventory (what a page must do TODAY)

  1. Models: connect-src 'none' blocks all fetch, so GLB bytes ride in the page repo as LOSSLESS PNGs (4-byte length header + bytes packed in RGB channels only - canvas 2D premultiplies alpha, so never store data under alpha<255), decoded via a CORS-clean canvas (img-src https: is the one open channel). Bonus: PNG deflate compresses quantized GLBs ~4x for free (7.8MB -> 2.1MB).
  2. No wasm ('wasm-unsafe-eval' absent) + no workers = Draco can NOT decode in the sandbox. Ship uncompressed or KHR_mesh_quantization (decoder-free). But NOTE: quantized bounds LIE, which breaks frameModel and any bounds-based framing - so for animated/mixed scenes ship unquantized and let PNG-deflate do the compressing.
  3. Textures: img-src has no blob:, and Babylon wraps glTF textures in blob object-URLs. Shim URL.createObjectURL to hand back data: URIs stamped on small Blobs, and/or convert the GLB's bufferView images to data: URIs.
  4. Loading bytes: BABYLON.FilesInputStore.FilesToLoad["m.glb"]=file + viewer.loadModel("file:m.glb") works and runs the bundle's own pipeline - BUT loadModel reparents/normalizes under a transformed root, which swallows external per-node animation offsets. For multi-part animated scenes use SceneLoader.AppendAsync(File) and wrap each group in a scene-root TransformNode (the glTF root RH->LH conversion transform otherwise amplifies/redirects local z-offsets).
  5. EXT_mesh_gpu_instancing (gltf-transform optimize default) renders as ONE giant mis-scaled instance in the babylon9 bundle - always --instance false.
  6. readme size cap ~4-5MB with an opaque "README too large to render" (probed: 4.0MB ok, 5.0MB fails).
  7. /blob serves html/js/csv as application/octet-stream + content-disposition: attachment + nosniff - cannot serve runtime assets; only glb/json/png come back inline.
  8. The readme iframe: fixed ~600px height (needs content-height autosizing) and no allowfullscreen (requestFullscreen only works when /readme is opened directly).

Shadows: what we learned about the bundle's rig

  • The SpotLight+PCF generator is tuned for mm-scale scenes (position 600,-300,1500, intensity 2e6) and is repositioned by frameModel. Pages that bypass loadModel must rescale it themselves for meter models (position ~(0.6,-0.3,1.5), intensity ~2).
  • The shadow map is FROZEN (refreshRate 0) by design for perf - confirmed live even while an animation group is playing, so baked animations play with a static shadow. There is no option to change it. FEATURE ASK: an animateShadows: true init option, or auto-unfreeze when the loaded GLB has animation groups. Page-side we proved animated shadows work great: refreshRate=ONEVERYFRAME + PCSS (useContactHardeningShadow, ratio ~0.08) + transparencyShadow=true so translucent layers cast their artwork instead of solid slabs + every layer mesh set to cast AND receive.

Baked glTF animations DO auto-play in the native viewer (nice!)

We baked the explode cascade as glTF keyframe channels (staggered per-layer z translation, 14s loop) and the /viewer route plays it beautifully. Key trick to keep it performant AND animated: gltf-transform optimize with --flatten false --join true (join merges within each parent, preserving animated group nodes) took the scene from ~24k draw calls to 80 meshes. Also: animation can only target TRS nodes, not matrix nodes.

Honest difficulty feedback on using the built-in viewer from a page

The bundle itself is excellent (env, ViewCube, shadows, framing). What made this hard:

  • The sandbox kills the bundle silently (the storage throw) - a one-line guard in the viewer erases the whole class of pain.
  • /viewer/3d// renders only page.json's model_3d - no query param to pick a different GLB, no way to pass options (camera, animation on/off), so a page cannot embed two variants side by side.
  • No documented path to feed the viewer bytes without network (FilesInputStore works but is undocumented and loadModel's normalization surprises).
  • frameModel trusts accessor bounds (quantized models mis-frame at radius ~2700).
  • getShadowGenerator() is exposed but the frozen map is not controllable via init options.

Happy to PR any of these against adom-inc/adom-3d-viewer-babylon9 - the storage guard + animateShadows option are both small.

John Lauer · 5d ago

Two more asks, per the chat thread with @kcknox — both parent-side, neither touches allow-same-origin:

5. Readme iframe is a fixed size — let the readme request a resize

The iframe doesn't fit its content. Sandbox doesn't block postMessage, so the standard embed pattern works: readme.html posts {type:"resize", height} to the parent, the wiki page listens and sets the iframe height, clamped to a max so a page can't grow itself unbounded. Parent only honors messages it chooses to, so the box stays a box (same pattern YouTube/Disqus embeds use).

6. Allow fullscreen from the readme iframe

Fullscreen video or a fullscreen 3D board view can't work today because the wiki's iframe tag doesn't delegate it. Ask: add allow="fullscreen" (allowfullscreen) on the readme iframe. Not a CSP change — it's an attribute on the tag the wiki renders, and the browser already gates it behind a real user click plus the "site is now fullscreen" overlay.

John Lauer · 5d ago

Addendum (shadow gotcha worth knowing for the viewer): in this scene, Babylon's PCSS (useContactHardeningShadow) combined with enableSoftTransparentShadow rendered NO shadows at all - silently. Plain PCF (usePercentageCloserFiltering, QUALITY_MEDIUM) renders perfect layer-on-layer shadows with the same casters/receivers. Ralph-tested with a frozen-scene shadows-ON/OFF pixel diff: PCSS = 0.0% pixel change, PCF = 26.7%. Also the general lesson for shadow work on PBR content: shadows only darken DIRECT light, so if the environment/IBL dominates the lighting, shadows on PBR surfaces wash out to invisible - the spot must be the dominant light (we run env at 0.35 as fill, spot ~6). And test protocol matters: pixel-diffing two captures of an ANIMATING scene gives false positives from motion - freeze the scene (verify twice), hide the ground, THEN diff.

Colby Knox · 5d ago

Platform items 1 through 6 are shipped to prod, verified against this page (adom/bq25792-charger) and the stm32 page:

  1. connect-src is now 'self' (+ data:) in the readme sandbox. Your PNG-packing hack retires: fetch() the GLB straight from your repo's /blob path. Why this is safe: the sandbox gives the doc an opaque origin, so every fetch is an uncredentialed CORS request (our CORS is wildcard, no credentials) — a readme reads exactly what an anonymous visitor reads, nothing viewer-authenticated. That boundary is documented as an invariant in docs/readme-sandbox.md.
  2. The size cap is documented and the error states it: 413 now says the file size, the 4 MiB (4,194,304 byte) limit, and carries code README_TOO_LARGE with limit_bytes in data. No more binary-searching publish cycles.
  3. /blob content types: .html serves inline as text/html under the SAME sandbox CSP as /readme (so iframing repo HTML works), .csv gets text/csv. One deliberate exception: .js stays a forced download — same-origin script MIME would hand a future sanitizer bypass a CSP-proof payload under the wiki pages' script-src 'self'. With connect-src open you can fetch() repo JS and inject it inline inside your own sandbox instead.
  4. The html+md double-render is fixed: readme.html now REPLACES README.md on the Overview, per the README_SHADOWED contract. Verified live on this page: the stale markdown below your viewer is gone.
  5. Resize: a postMessage height protocol already existed and is now documented; it accepts both {type: "readme-height", height} and your guessed {type: "resize", height}, parent-verified by event.source (origin is "null" for a sandboxed doc), clamped to 120..16000px.
  6. allowfullscreen + allow="fullscreen" are on the Overview iframe. Fullscreen 3D from the readme works behind a user gesture.

Full contract (including the storage-shim recipe from your root-cause, verbatim) now lives in docs/readme-sandbox.md in the git-wiki repo.

Leaving this open for the viewer-side asks, which belong to adom-3d-viewer-babylon9, not the wiki server: the storage-access guard (your 12-line shim upstreamed as a try/catch in the bundle — please do PR it, it erases the whole failure class), animateShadows / auto-unfreeze on animated GLBs, query params for /viewer/3d to pick a GLB + options, and the exploded-stackup mode. Items 7/8 from the follow-ups are covered by those.

Colby Knox · 4d ago

Viewer-side items are now shipped. Bundle 20260717a is live on prod (source: adom-inc/adom-3d-viewer-babylon9 0.3.0), and the wiki was quietly a month behind: it had been pinned to a June 11 build, so your July 10 merges (animated cast shadows, skybox fixes, bottom light) only reached prod today too.

Storage guard (item 7 root cause): fixed in the bundle itself. The camera read localStorage unguarded during init; it is now wrapped in try/catch, so the FULL rig (lights, env, ViewCube, shadows) comes alive in a sandboxed readme with zero page hacks. Verified against a storage-throwing sandbox iframe: scenes:2, 3 lights, env present. Your 12-line shim is no longer needed for the viewer (docs/readme-sandbox.md updated; the shim recipe stays for other libraries). Your offered PR never showed up on the repo so I implemented it directly, credited to your root-cause; if you had a branch going, sorry for the race.

animateShadows: already yours. PR #2's auto-unfreeze (shadow map goes ONEVERYFRAME when the GLB has animation groups) covers the ask, and is now actually deployed.

/viewer/3d query params: shipped. ?model= (same-repo, .glb only, read-gated), ?ground=0, ?axes=1, ?animate=0 (holds animated GLBs at frame one), ?bottomlight=1, ?camera=alpha,beta[,radiusScale]. Two variants of a part on one page works now.

EXT_mesh_gpu_instancing: detect + warn shipped. Loud console warning naming the instanced meshes and the --instance false rebuild flag. One data point: a synthetic instanced GLB loaded with plausible transforms on Babylon 9.5, so if you still have a GLB that renders as one giant instance I would like it attached here; the warn may be able to graduate into a real fix.

PCSS + enableSoftTransparentShadow: auto-corrected. The combination is detected every frame and flipped to PCF with a one-time console warning, so the silent no-shadows state cannot happen.

frameModel quantized bounds: cannot reproduce. gltf-transform v3 quantize (KHR_mesh_quantization) on Babylon 9.5 gives byte-identical bounds and identical framing radius to the unquantized model. If your radius ~2700 case came from a page-side SceneLoader.AppendAsync (roots never registered, so frameModel no-ops and the camera sits at its 2700 default clamp) that would explain it; addContentRoot() is the fix on that path. If you have the actual GLB, attach it and I will chase it.

Still queued: exploded-stackup as a first-class mode. The layers toolbar (#48) is next and the naming convention it needs is now written (docs/CONTENT-CONVENTIONS.md in the viewer repo, distilled from your findings); exploded mode rides on the same layer-group convention.

Colby Knox · 4d ago

Closing: every actionable item here is done. Platform items 1-6 shipped earlier (connect-src, size cap, blob types, double render, resize, fullscreen). Viewer-side items shipped across bundles 20260717a-d: storage guard (root cause of item 7), animateShadows auto-unfreeze, /viewer/3d query params, instancing warning, PCSS/soft-transparent auto-PCF. The two leftovers move elsewhere: the exploded-stackup mode feature ask rides the layer convention under #48, and the quantized frameModel report needs a repro GLB (could not reproduce on Babylon 9.5; see my earlier comment, happy to reopen a dedicated issue when the GLB shows up).

Log in to reply.