Shared Blocks
Shared noweb blocks for braindump and blog notes.
Consumer files use cross-file references like <<shared_blocks.org:deploy_iframe-ex()>>.
Deploy iframe
Embeds the deployed app in an iframe on the braindump/blog page. The caller
sets the shell variable deploy_url to the path prefix (e.g. /tally/ or
/p/bafyxyz) before expanding the block.
Deploy
Publishes the calling file’s [[root]] block to IPFS via ipfa, then
embeds it in an iframe. Use with #+call: shared_blocks.org:deploy().
PWA meta tags
Generic PWA meta tags: charset, purge escape hatch, viewport, and manifest
link. Callers add their own <title> and <meta name“theme-color”>=.
Service worker (cache-first + write-through)
Cache strategy that lets a returning visitor open the app without re-downloading assets, and keeps it usable offline. The handlers assume two consumer-defined constants:
CACHES(array of{name, pattern?}) — one or more cache storage buckets, evaluated in order: the first whosepatternmatches the request URL wins. The last entry should omitpattern— it is the default that catches everything else. Splitting into multiple buckets lets the consumer invalidate them independently: bump the app cache on every deploy via a source-hash, and the vendor cache only when dependency versions change. With a single entry the SW behaves like a one-cache setup.ASSETS(array of URLs) — the shell precached at install into the default (last) cache. Keep it minimal: anything the app fetches at runtime is cached on the way through (write-through on the first miss), so precaching only matters for offline cold-start.
Three event listeners:
- install opens the default cache, precaches
ASSETS, thenskipWaiting()so the new SW activates immediately rather than waiting for every tab to close. - activate drops any cache whose name is not in
CACHESand callsclients.claim()so the SW takes control on first install instead of waiting for the next navigation. Bumping a cache name inCACHEStriggers eviction of the old one on next activate. - fetch splits two regimes. Navigations (HTML) are network-first with cache fallback: a live visit gets the latest HTML, an offline visit gets the cached shell. Everything else (JS, WASM, images, …) is cache-first with write-through, scoped to the cache picked by the consumer’s pattern: the first miss fills the bucket, the next request short-circuits the network. Offline use after the first online visit just works.
Consumers tangle a sw.js block that declares CACHES and
ASSETS then noweb-includes sw-handlers-ex, and pair it with
sw-register-ex in the HTML <head> to register the worker.
The register block skips localhost and 127.0.0.1 because in
dev/test rebuilds happen often and a stale cache on bundle.js
or a WASM blob would mask any change ; the ?purge escape
hatch from PWA meta tags remains the manual override.
function cacheNameFor(request){
const url = new URL(request.url);
for(const c of CACHES){
if(!c.pattern || c.pattern.test(url.pathname)) return c.name;
}
return CACHES[CACHES.length - 1].name;
}
self.addEventListener('install', e => {
const defaultCache = CACHES[CACHES.length - 1].name;
e.waitUntil(caches.open(defaultCache).then(c => c.addAll(ASSETS)));
self.skipWaiting();
});
self.addEventListener('activate', e => {
const valid = new Set(CACHES.map(c => c.name));
e.waitUntil(caches.keys().then(keys =>
Promise.all(keys.filter(k => !valid.has(k)).map(k => caches.delete(k)))
));
self.clients.claim();
});
self.addEventListener('fetch', e => {
const name = cacheNameFor(e.request);
if(e.request.mode === 'navigate'){
e.respondWith(
fetch(e.request)
.then(r => caches.open(name).then(c => { c.put(e.request, r.clone()); return r; }))
.catch(() => caches.match(e.request))
);
} else {
e.respondWith(caches.open(name).then(c =>
c.match(e.request).then(cached => {
if(cached) return cached;
return fetch(e.request).then(net => {
if(net.ok) c.put(e.request, net.clone());
return net;
});
})
));
}
});
<script>
if('serviceWorker' in navigator
&& location.hostname !== 'localhost'
&& location.hostname !== '127.0.0.1'){
navigator.serviceWorker.register('sw.js', {updateViaCache: 'none'});
}
</script>
Sync infrastructure
Shared Yjs WebSocket connection, remote-change rendering, and init logic.
Each app calls setupSync(persistence, ydoc, onReady) where onReady is
the app-specific first-render function.
// ── Shared sync infrastructure ──
var wsProvider;
var _rendering = false;
var _scheduleRender;
function setupSync(persistence, ydoc, onReady, scheduleRenderFn) {
_scheduleRender = scheduleRenderFn || function() {
if (_rendering) return;
_rendering = true;
requestAnimationFrame(function() { _rendering = false; onReady(); });
};
// Re-render on any non-local Yjs update (including IndexedDB sync)
ydoc.on('update', function(update, origin) {
if (origin === null) return;
_scheduleRender();
});
// WebSocket — connect only after IndexedDB synced, retry on denial
var _wsRetryTimer = null;
function connectWs() {
try {
if (wsProvider) return;
var params = new URLSearchParams(location.search);
var paramWsUrl = params.get('websocket_url');
if (paramWsUrl) {
localStorage.setItem('websocket_url', paramWsUrl);
var cleanUrl = new URL(location);
cleanUrl.searchParams.delete('websocket_url');
history.replaceState(null, '', cleanUrl);
}
var wsUrl = paramWsUrl || localStorage.getItem('websocket_url');
if (!wsUrl) return;
var wsEverConnected = false;
wsProvider = new WebsocketProvider(wsUrl, _roomName, ydoc);
var syncBtn = document.getElementById('syncDot');
wsProvider.on('status', function(e) {
if (e.status === 'connected') {
wsEverConnected = true;
if (_wsRetryTimer) { clearInterval(_wsRetryTimer); _wsRetryTimer = null; }
}
var state = e.status === 'connected' ? 'connected' : 'offline';
document.body.setAttribute('data-ws', state);
if (syncBtn) syncBtn.title = state === 'connected' ? 'Synced' : 'Offline';
});
wsProvider.on('connection-close', function(e) {
if (!wsEverConnected) {
document.body.setAttribute('data-ws', 'denied');
if (syncBtn) syncBtn.title = 'Not authorized';
wsProvider.destroy();
wsProvider = null;
if (!_wsRetryTimer) {
_wsRetryTimer = setInterval(connectWs, 10000);
}
}
});
} catch(e) { console.warn('WebSocket setup failed:', e); }
}
function init() {
onReady();
document.body.setAttribute('data-ready', 'true');
}
persistence.once('synced', connectWs);
persistence.once('synced', function() { setTimeout(init, 0); });
if (persistence.synced) { connectWs(); setTimeout(init, 0); }
if ('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' });
}
Reorder engine (drag-and-drop)
Framework-agnostic pointer-based drag-and-drop for reorderable lists.
Each .reorder-list[data-reorder“key”]= is a list of .reorder-item[data-idx]
items with a .reorder-grip handle. The engine handles the drag gesture,
floating clone, placeholder marking, and edge-of-viewport auto-scroll.
Slot detection is 2D — the cursor’s (clientX, clientY) must fall
inside an item’s bounding box — so the same engine drives a single
column or a wrapping flex grid (intra-row drags pick the right column
by X, not just any item on the same Y). It does not touch the
consumer’s state: when the cursor crosses into a new slot it fires a
reorder:move CustomEvent on the list with detail = {from, to}.
The consumer listens and mutates its own store (Alpine, Vue, vanilla).
Because that re-render is asynchronous, the engine identifies the
dragged element by the reference captured at pointerdown rather
than by DOM index, so the placeholder slot stays on it regardless of
where the re-render lands it in the list.
Decoupled like that, the same engine drives the player reorder in
score_counter_tally (Alpine) and the ballot ranking in the
Condorcet app (petite-vue), without either framework leaking into
the other.
Why custom (vs SortableJS, vuedraggable, pragmatic-drag-and-drop):
those libs rely on the browser’s native HTML5 drag, which is not
reliably reproducible by Playwright/CDP synthesis1 —
the browser only enters “drag mode” for real human pointer input.
Observed directly in the Condorcet app under SortableJS: a human drag
reproduced a DOM desync2 (stale <li> left
behind after submit) while page.mouse.* and
Input.dispatchDragEvent3 never triggered it. Playwright maintainers
have themselves closed similar dragTo-vs-sortable bugs as “not
planned”4. The community-recommended workaround
(double-hover5 or manual DataTransfer dispatch)
is fragile with libs that run their own state machines. Atlassian’s
pragmatic-drag-and-drop6 is the cleanest “proper”
option but still rides on HTML5 native drag under the hood7, so the
test-synthesis gap persists. Pointer events, by contrast, take the
same path in tests and in prod; no gap8. The consumer keeps full
control of state (no DOM mutation by the lib), so Vue/Alpine
reactivity stays the single source of truth.
Drop-zone engine (drag between containers)
Sister to the reorder engine, for the other half of the drag-and-drop
problem space: moving an item between named containers, not within
one. Each draggable carries [data-draggable]; each container that
accepts drops carries [data-drop-target]. On pointerup over a
target, the engine fires a dropzone:drop CustomEvent on the
target with detail = { sourceEl }, where sourceEl is the original
draggable element. The consumer reads whatever it needs off
sourceEl.dataset and mutates its own store; the engine never
touches the consumer’s state.
Same rationale for going pointer-event-based over HTML5 native drag (cf. reorder engine’s footnotes): the OS-level “drag mode” gate doesn’t fire under Playwright/CDP synthesis, so a test-green native drag can be a real-world red. Pointer events take the same path in tests and in prod — and they work on touch, where HTML5 DnD does not, which is the actual dealbreaker for a phone-targeted PWA.
A small movement threshold (THRESHOLD_PX) keeps a tap-and-release
from accidentally initiating a drag — useful for future click-to-edit
affordances on the same items, and harmless for the test which moves
the cursor across the screen in one go.
[data-draggable]{touch-action:none;user-select:none;cursor:grab}
[data-draggable]:active{cursor:grabbing}
.drag-source{opacity:.35}
.drop-hover{outline:2px dashed var(--accent,#f9a826);outline-offset:2px}
.drag-clone{
position:fixed;z-index:9999;pointer-events:none;
padding:10px 14px;border-radius:8px;
background:var(--accent,#f9a826);color:#111;
box-shadow:0 8px 24px rgba(0,0,0,.4);
transform:scale(1.04);opacity:.95;
font-family:inherit;font-size:inherit;
}
// ── Drop-zone drag engine (framework-agnostic) ──
// Bound once on document. Anything matching =[data-draggable]= can be
// grabbed; anything matching =[data-drop-target]= accepts drops. On
// pointerup over a target, fires =dropzone:drop= on it as a bubbling
// CustomEvent with =detail = { sourceEl }=. Consumers listen on the
// target (or any ancestor — document works) and mutate their store.
(function initDropZoneEngine() {
var THRESHOLD_PX = 6;
var drag = null;
function activate() {
var rect = drag.item.getBoundingClientRect();
var clone = drag.item.cloneNode(true);
clone.classList.add('drag-clone');
clone.style.left = rect.left + 'px';
clone.style.top = rect.top + 'px';
clone.style.width = rect.width + 'px';
document.body.appendChild(clone);
drag.clone = clone;
drag.offsetX = drag.startX - rect.left;
drag.offsetY = drag.startY - rect.top;
drag.item.classList.add('drag-source');
drag.active = true;
}
function setHover(el) {
if (drag.lastTarget === el) return;
if (drag.lastTarget) drag.lastTarget.classList.remove('drop-hover');
drag.lastTarget = el;
if (el) el.classList.add('drop-hover');
}
document.addEventListener('pointerdown', function(e) {
if (drag) return;
var item = e.target.closest('[data-draggable]');
if (!item) return;
drag = {
item: item,
pointerId: e.pointerId,
startX: e.clientX, startY: e.clientY,
active: false,
clone: null,
lastTarget: null,
};
});
document.addEventListener('pointermove', function(e) {
if (!drag || drag.pointerId !== e.pointerId) return;
if (!drag.active) {
var dx = e.clientX - drag.startX;
var dy = e.clientY - drag.startY;
if (Math.hypot(dx, dy) < THRESHOLD_PX) return;
activate();
}
e.preventDefault();
drag.clone.style.left = (e.clientX - drag.offsetX) + 'px';
drag.clone.style.top = (e.clientY - drag.offsetY) + 'px';
drag.clone.style.display = 'none';
var under = document.elementFromPoint(e.clientX, e.clientY);
drag.clone.style.display = '';
var tgt = under ? under.closest('[data-drop-target]') : null;
// Don't highlight the item's own current container as a drop target
// when the cursor hasn't yet left it — feels like nothing happens
// on drop. Simpler: still highlight; consumers can ignore same-container drops.
setHover(tgt);
});
function onEnd(e) {
if (!drag || drag.pointerId !== e.pointerId) return;
if (drag.active && drag.lastTarget) {
drag.lastTarget.dispatchEvent(new CustomEvent('dropzone:drop', {
detail: { sourceEl: drag.item },
bubbles: true,
}));
}
if (drag.lastTarget) drag.lastTarget.classList.remove('drop-hover');
if (drag.item) drag.item.classList.remove('drag-source');
if (drag.clone) drag.clone.remove();
drag = null;
}
document.addEventListener('pointerup', onEnd);
document.addEventListener('pointercancel', onEnd);
document.addEventListener('contextmenu', function(e) {
if (e.target.closest('[data-draggable]')) e.preventDefault();
});
})();
Modal shell
Centred fixed-position overlay with a translucent backdrop, for
modal dialogs and full-screen sheets. .modal-backdrop darkens
the page beneath the modal; .modal-shell is the card that
hosts the dialog content, sized to the viewport with a 16px
horizontal gutter and 32px vertical gutter so it doesn’t touch
the edges on any screen. Consumers add their own inner
structure inside the shell.
Two CSS variables can be overridden by the caller before this block is included, when the defaults don’t match the host theme:
--modal-card— the shell’s background colour (default inherits--cardif set, else a dark slate).--modal-max-width— the shell’s max width (default 420px).
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:99}
.modal-shell{
position:fixed;z-index:100;
top:50%;left:50%;transform:translate(-50%,-50%);
width:min(var(--modal-max-width, 420px), calc(100vw - 16px));
max-height:calc(100vh - 32px);
background:var(--modal-card, var(--card, #1f2235));
border-radius:12px;
box-shadow:0 12px 32px rgba(0,0,0,.5);
overflow-y:auto;
}
Spotlight pulse
Brief halo pulse used to flag a freshly-scrolled-to element so
the user’s eye lands on the right card. Adding the
.spotlight class to any element runs a short animated
box-shadow pulse; removing it on animationend lets the next
trigger restart the animation cleanly. Imperative use is the
intended one — the class is a transient visual marker, not
state worth keeping in the store.
Two CSS variables parameterise the effect, both with sensible defaults so the block works without any caller setup:
--spotlight-color— the halo colour at peak (defaultrgba(249,168,38,.85)— the warm orange accent).--spotlight-duration— how long the pulse lasts (default1.2s).
@keyframes spotlight-pulse{
0% {box-shadow:0 0 0 0 transparent}
20% {box-shadow:0 0 0 6px var(--spotlight-color, rgba(249,168,38,.85))}
100% {box-shadow:0 0 0 0 transparent}
}
.spotlight{animation:spotlight-pulse var(--spotlight-duration, 1.2s) ease-out}
Build hash
Short SHA-256 tag for the caller’s [[root]] block, shown in the UI
so you can tell which build is loaded on a given device (PWAs cache
aggressively). Each consumer defines a one-line build-hash
delegator whose body noweb-splices build-hash-body below; the elisp
then runs in the consumer’s buffer and finds its local root. The
raw block source contains a constant noweb marker, so the hash is
stable without any self-reference stripping.
By default the hash covers a single block named root. When the
build pulls from several blocks (HTML root + service worker +
manifest, say), a consumer can wrap the splice in a let that binds
build-hash-blocks to a list of block names, and the bodies are
concatenated before hashing.
An app also loads shared ES modules through import '../shared/*.js'.
Those are fetched at runtime, not noweb-spliced, and are tangled from a
sibling note, so expanding the caller’s blocks never sees their text —
yet an edit to one must still change the build so a device refetches.
The body therefore reads its own expansion for ../shared/ imports,
follows them (and any they import in turn), and folds each file into the
hash. The files are looked up under the common tangle root
/var/run/user/1000/shared/, overridable with build-hash-shared-dir.
(let ((build-hash-blocks '("root" "manifest" "sw")))
[[shared_blocks.org:build-hash-body-ex()]])
CadQuery pipe and sleeve
Five helpers for building hollow stacked STLs in CadQuery.
pipe(workplane, height, inner_diameter, outer_diameter) is the
cylindrical primitive: a hollow cylinder sitting on the input
workplane (not centered on it). cone(workplane, height, inner_top, inner_bottom, outer_top, outer_bottom) is the conical
primitive: a hollow frustum whose bore and outer each interpolate
linearly from the bottom values to the top values over height.
concat_pipe(workplane, **sections) is the generic cylinder
stacker — each kwarg’s value is a (height, inner_diameter, outer_diameter) tuple, and the helper unions them bottom-to-top
so each pipe rises from the top face of the previous one.
sleeve(workplane, *, female, junction_length, male) is the
sleeve-specific abstraction for adapters that bridge a male
shaft (entered through the female end of the sleeve) and a
female socket (entered by the male end). It builds three
sections — male pipe at the bottom, female pipe at the top, and a
conical junction between them whose outer and bore each
interpolate linearly from the male’s dimensions at the bottom to
the female’s at the top — so all four faces of the junction
connect seamlessly to its neighbours, with no step. The smooth
taper makes the assembly printable without supports.
sleeve_tapered(workplane, *, female, junction_length, male, tolerance) is the same shape but with the male and female
sections turned into tapered cones rather than straight
cylinders, to absorb 3D print imperfections. The cone narrows
toward the entry end (so insertion starts loose) and widens
toward the junction (so the fit gets snug as the pieces seat).
Concretely, on each tapered end the dimension that does the
fitting — the male’s outer, the female’s bore — equals the
requested value ± tolerance: bigger than requested at the
junction-side face, smaller than requested at the entry-side
face. The non-fitting dimension (the male’s bore, the female’s
outer) stays constant, and the central junction is rebuilt to
match the new male-top and female-bottom values so the four
interfaces still meet without a step. tolerance can also be
a (female, male) tuple to set each end’s tolerance
independently; passing 0 for one end collapses its cone back
to a straight cylinder while leaving the other end tapered.
def pipe(workplane, height, inner_diameter, outer_diameter):
return (
workplane.cylinder(height=height, radius=outer_diameter / 2,
centered=(True, True, False))
.cut(workplane.cylinder(height=height, radius=inner_diameter / 2,
centered=(True, True, False)))
)
def cone(workplane, height, inner_top, inner_bottom, outer_top, outer_bottom):
outer = (
workplane.circle(outer_bottom / 2)
.workplane(offset=height)
.circle(outer_top / 2)
.loft(combine=False)
)
inner = (
workplane.circle(inner_bottom / 2)
.workplane(offset=height)
.circle(inner_top / 2)
.loft(combine=False)
)
return outer.cut(inner)
def concat_pipe(workplane, **sections):
current_workplane = workplane
parts = []
for height, inner_diameter, outer_diameter in sections.values():
part = pipe(current_workplane, height, inner_diameter, outer_diameter)
parts.append(part)
current_workplane = part.faces('+Z').workplane()
combined = parts[0]
for part in parts[1:]:
combined = combined.union(part)
return combined
def sleeve(workplane, *, female, junction_length, male):
female_length, female_bore, female_outer = female
male_length, male_bore, male_outer = male
male_pipe = pipe(workplane, male_length, male_bore, male_outer)
junction = cone(
male_pipe.faces('+Z').workplane(),
junction_length,
inner_top=female_bore, inner_bottom=male_bore,
outer_top=female_outer, outer_bottom=male_outer,
)
female_pipe = pipe(
junction.faces('+Z').workplane(),
female_length, female_bore, female_outer,
)
return male_pipe.union(junction).union(female_pipe)
def sleeve_tapered(workplane, *, female, junction_length, male, tolerance):
female_length, female_bore, female_outer = female
male_length, male_bore, male_outer = male
female_tol, male_tol = tolerance if isinstance(tolerance, tuple) else (tolerance, tolerance)
male_cone = cone(
workplane, male_length,
inner_top=male_bore, inner_bottom=male_bore,
outer_top=male_outer + male_tol, outer_bottom=male_outer - male_tol,
)
junction = cone(
male_cone.faces('+Z').workplane(),
junction_length,
inner_top=female_bore - female_tol, inner_bottom=male_bore,
outer_top=female_outer, outer_bottom=male_outer + male_tol,
)
female_cone = cone(
junction.faces('+Z').workplane(),
female_length,
inner_top=female_bore + female_tol, inner_bottom=female_bore - female_tol,
outer_top=female_outer, outer_bottom=female_outer,
)
return male_cone.union(junction).union(female_cone)
Playwright test harness
Shared scaffolding for the no-build PWAs’ Playwright suites (each served by the dev
lighttpd at /debug/<app>/). The caller’s own preamble keeps what differs — its
imports, its BASE_URL, its viewport — and splices these for the parts that don’t.
pw-run-simple expects TESTS, PHONE_VIEWPORT, sync_playwright, time, BASE_URL
and dump_failure in scope. It prints timestamped per-test progress, collects console
and page errors on the shared page, forwards unhandled promise rejections to the console
so they surface too, and on a failure prints the full Python traceback — so the exact
failing line and exception type are always in the log — then calls dump_failure (the
runner page’s url and readiness, the events, and a screenshot named from APP or
BASE_URL’s last path segment). It also runs an optional warmup() before the loop and
setup_context(ctx) on the context when the caller defines them (for a suite that must
boot a fixture or seed every context).
The console/page-error hooks bind to the runner’s own shared page. A test that opens its own context — two devices syncing, a second viewport — sees none of them captured, so on such a failure the traceback is the signal: it names the line and the exception, while the dumped url and body describe the runner page, which is not the page that failed. The dump labels itself runner page for that reason, and points back to the traceback.
if "PLAYWRIGHT_BROWSERS_PATH" not in os.environ:
for p in os.environ.get("buildInputs", "").split():
if "playwright-browsers" in p:
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = p
break
TESTS = []
def testcase(fn):
TESTS.append(fn)
return fn
def main():
argv = sys.argv[1:]
headed = "--headed" in argv
stop_on_fail = "-x" in argv or "--exitfirst" in argv
patterns = [a for a in argv
if a not in ("--headed", "-x", "--exitfirst")]
selected = [t for t in TESTS
if not patterns or any(p in t.__name__ for p in patterns)]
if not selected:
print(f"no test matches {patterns}")
sys.exit(2)
warmup, setup_context = globals().get("warmup"), globals().get("setup_context")
app = globals().get("APP") or BASE_URL.rstrip("/").rsplit("/", 1)[-1].split(".")[0]
now = lambda: time.strftime("%H:%M:%S")
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=not headed)
if warmup: warmup()
ctx = browser.new_context(viewport=PHONE_VIEWPORT)
ctx.add_init_script("addEventListener('unhandledrejection', e =>"
" console.error('unhandledrejection: ' + (e.reason?.message || e.reason)))")
if setup_context: setup_context(ctx)
page = ctx.new_page()
page.set_default_timeout(5000)
events = []
page.on("console", lambda m: m.type in ("error", "warning") and events.append(f"console.{m.type}: {m.text}"))
page.on("pageerror", lambda e: events.append(f"pageerror: {e}"))
page.on("requestfailed", lambda r: events.append(f"requestfailed: {r.method} {r.url}"))
passed = failed = 0
for t in selected:
events.clear()
t0 = time.time()
print(f"[{now()}] TRYING {t.__name__}", flush=True)
try:
t(page); passed += 1
print(f"[{now()}] PASS {t.__name__} ({time.time()-t0:.1f}s)", flush=True)
except Exception as e:
import traceback
print(f"[{now()}] FAIL {t.__name__} ({time.time()-t0:.1f}s): {type(e).__name__}: {e}", flush=True); failed += 1
print(traceback.format_exc(), flush=True)
dump_failure(page, t.__name__, app, events)
if stop_on_fail: break
browser.close()
print(f"\n{passed} passed, {failed} failed out of {len(selected)}")
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()
def dump_failure(page, name, app, events):
print(f" ↳ runner page url: {page.url}", flush=True)
for ev in events:
print(f" ↳ {ev}", flush=True)
if not events:
print(" ↳ (no console/network events on the runner page — a test using"
" its own context captures none; read the traceback above)", flush=True)
try:
state = page.evaluate(
"() => ({ready: document.readyState,"
" appRendered: !!((document.getElementById('app') || {}).childElementCount"
" || document.querySelector('h1, main, [role=main]')),"
" landmark: ((document.querySelector('h1, main, [role=main]') || {}).textContent || '').slice(0, 60),"
" body: (document.body ? document.body.innerText : '').slice(0, 200)})")
print(f" ↳ runner page: readyState={state['ready']} appRendered={state['appRendered']} landmark={state['landmark']!r}", flush=True)
print(f" ↳ runner page body: {state['body']!r}", flush=True)
except Exception as pe:
print(f" ↳ (runner page state unavailable: {pe})", flush=True)
try:
shot = f"/tmp/{app}-fail-{name}.png"; page.screenshot(path=shot)
print(f" ↳ screenshot: {shot}", flush=True)
except Exception as se:
print(f" ↳ (screenshot failed: {se})", flush=True)
A GraphQL failure is the one error the handlers above garble: urql (and the raw
fetch) log only Failed to load resource: 400, while the reason a query was rejected —
an unknown argument, a misspelt field, a type mismatch — sits in the response body the
console never prints. So a wrong query reads as an opaque status code, undebuggable
without a throwaway probe. watch_graphql_errors closes that gap: it reads the body of any
failing /graphql response into the same events the dump prints, so the real reason
surfaces in the failure itself. A runner registers it beside the console/pageerror hooks.
def watch_graphql_errors(page, events):
def on_response(r):
if "/graphql" not in r.url:
return
try:
status = r.status
except Exception:
return
if status < 400:
return
# always record the status; the body carries the real reason (bad arg, unknown
# field), so try hard for it but never let a body we can't read hide the failure.
try:
body = r.text()[:600]
except Exception as e:
try:
body = r.body().decode("utf-8", "replace")[:600]
except Exception:
body = f"(body unavailable: {e})"
events.append(f"graphql {status}: {body}")
page.on("response", on_response)
The CRDT apps’ sync tests boot a websocket server in docker. _free_port claims a
free TCP port for it, and _wait_ws_handshake waits until the server actually speaks
the protocol: the listener binds the port a beat before the upgrade-handler chain is
wired, so “port open” isn’t “ready”, and a test firing a WebSocket too early would
hang. The probe drives a real upgrade from Python and only declares ready on an HTTP 101.
def _free_port():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _wait_ws_handshake(port, timeout=30.0):
deadline = time.monotonic() + timeout
probe_path = f"/__probe_{uuid.uuid4().hex[:8]}"
last_status = b""
while time.monotonic() < deadline:
try:
sock = socket.create_connection(("localhost", port), timeout=1)
sock.settimeout(2)
key = base64.b64encode(os.urandom(16)).decode()
sock.sendall((
f"GET {probe_path} HTTP/1.1\r\n"
f"Host: localhost:{port}\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n"
"\r\n"
).encode())
buf = b""
while b"\r\n" not in buf and len(buf) < 256:
chunk = sock.recv(64)
if not chunk:
break
buf += chunk
sock.close()
if buf.startswith(b"HTTP/1.1 101"):
return True
last_status = buf.split(b"\r\n", 1)[0]
except OSError:
pass
time.sleep(0.2)
return last_status
Checkbox list control
For a note that is a checkbox list — a trigger list of things to take, a
procedure to follow step by step. Exported org checkboxes are inert HTML: a tick
is gone on the next reload, which is no use for a list meant to be run again and
again. So each tick is kept in the device’s own localStorage, and the whole list can
be swung in one gesture: all off clears it for the next run, all on fills it in for
the times when only a couple of things are left to think about.
There is nothing to key that tick by: the published page carries no identifier per item, only its text. So an item is recognised by its place in the list and a hash of what it says — which means a reworded item comes back unticked, the safe way round, rather than inheriting a tick that was about something else.
Anything the setup throws is shown rather than swallowed, so the initial pass is
fired at the element as an event instead of running inline: that way it lands in the
same on exception handler as every later change.
A consumer note pulls it in with
#+transclude: [[file:shared_blocks.org::checkbox-control]].
In practice all of it stays one block, long as it is, rather than being cut along those
three ideas: transclusion copies the named element verbatim and never runs babel, so a
composition would reach its consumers as literal <<…>> and the checkboxes would stop
working.