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.
(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 calls dump_failure (the url, 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).
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:
print(f"[{now()}] FAIL {t.__name__} ({time.time()-t0:.1f}s): {e}", flush=True); failed += 1
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" ↳ url: {page.url}", flush=True)
for ev in events:
print(f" ↳ {ev}", flush=True)
if not events:
print(" ↳ (no console errors — a timing/state flake; the screenshot shows the page)", 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
Notes linking here
- Netto to Mieuxa adapter
- Playwright tests
- scrutin de Condorcet randomisé entre amis
- simple pwa karate beep trainer
- simple pwa organiser
- simple pwa piano hero
Permalink
-
“Browser switches from
mousemoveto drag events during operation […] custom implementations continue to emitmousemoveevents throughout the operation.” — Playwright PR #34882 (fix(drag-n-drop): send two mousemove events to target to make Angular CDK work), closed in favour of astepsparameter. ↩︎ -
“we have two sources of truth: Vue and SortableJS […] unpredictable behaviour.” — VueUse #3727 (
useSortableclashes with Vue reactivity). The proposed workaround (bumping akey) proved insufficient in practice: tried in the Condorcet app, the bug still reproduced. ↩︎ -
Input.dispatchDragEventis marked Experimental in the CDP spec and only fires drag events afterInput.setInterceptDragsenables interception — it injects drag events into the page without putting the browser into its real drag mode. — CDPInput.dispatchDragEvent. ↩︎ -
“The
dragToaction doesn’t work [with] html5sortable [on] Chromium.” — Playwright #38796, regression 1.56 → 1.57, closed not planned. ↩︎ -
“If your page relies on the
dragoverevent being dispatched, you need at least two mouse moves to trigger it in all browsers.” — Playwright docs, Actions / Input. ↩︎ -
“Powering some of the biggest products on the web, including Trello, Jira and Confluence […] enables safe and successful usage of the browsers built in drag and drop functionality.” — atlassian/pragmatic-drag-and-drop. ↩︎
-
“leverages the native HTML Drag and Drop API to create seamless drag-and-drop experiences across any tech stack.” — LogRocket, Implement the Pragmatic drag and drop library. Same
dragstart/dragover/droppath as raw HTML5 drag, so the same CDP-synthesis gap applies. ↩︎ -
Playwright’s
page.mouse.*routes to CDPInput.dispatchMouseEvent; the browser then firespointerdown/pointermove/pointerupon the same DOM path as for OS-level input (every mouse interaction has an associated pointer event per the spec). No “drag mode” gate, no trusted-event gate. — CDPInput.dispatchMouseEvent, W3C Pointer Events 3..reorder-list{display:flex;flex-direction:column;gap:6px;margin-bottom:8px;position:relative} .reorder-item{ display:flex;align-items:center;gap:10px; padding:10px 14px;background:rgba(255,255,255,.05);border-radius:12px; user-select:none; } .reorder-item.drag-placeholder{ opacity:.3;border:2px dashed rgba(255,255,255,.25);background:transparent; } .drag-clone{ position:fixed;z-index:9999;pointer-events:none; display:flex;align-items:center;gap:10px; padding:10px 14px;border-radius:12px; background:var(--accent);color:#fff; box-shadow:0 8px 24px rgba(0,0,0,.4); transform:scale(1.04);opacity:.95; font-family:inherit;font-size:inherit; } .drag-clone .reorder-grip{color:#fff} .reorder-grip{ display:grid;place-items:center;width:36px;height:36px; cursor:grab;touch-action:none;color:var(--muted);font-size:1.2rem; flex-shrink:0;border-radius:8px; } .reorder-grip:active{cursor:grabbing;background:rgba(255,255,255,.1)} .reorder-rank{font-weight:800;font-size:1.1rem;min-width:1.4em;text-align:center} .reorder-name{flex:1;font-weight:600}When the list overflows the viewport, the user would otherwise have to release the drag, scroll, and re-grip. The engine watches the cursor: once it enters an 80-pixel margin at the top or bottom edge,
window.scrollByruns every animation frame at depth/6 pixels (so up to about 13 px/frame at the very edge), until the cursor leaves the margin or the drag ends. The cursor’s last known viewport position is re-applied each tick so the floating clone and the slot detection stay in sync as the page scrolls underneath.On touch, the hold that begins a drag is also the long-press that opens the OS menu —
Save image,Copy,Open in new tab— popping mid-gesture, masking the list, and aborting the drag. We suppress that menu on reorder rows so the gesture stays clean; the rest of the page keeps its normal contextmenu.↩︎// ── Drag-and-drop reorder engine (framework-agnostic) ── // Binds once on document. Handles any =.reorder-list= with =.reorder-item= // children and a =.reorder-grip= handle on each item. Fires // =reorder:move= on the list (bubbling CustomEvent with // =detail = {from, to}=) when the cursor crosses into a new slot during // drag. Consumers listen and update their own state; the engine never // mutates the consumer's store. (function initReorderEngine() { var dragging = null; function fireMove(list, fromIdx, toIdx) { list.dispatchEvent(new CustomEvent('reorder:move', { detail: { from: fromIdx, to: toIdx }, bubbles: true, })); } function markPlaceholder() { var items = dragging.list.querySelectorAll('.reorder-item'); items.forEach(function(el) { el.classList.toggle('drag-placeholder', el === dragging.itemEl); }); } function stripFrameworkAttrs(root) { root.querySelectorAll('*').forEach(function(el) { Array.from(el.attributes).forEach(function(a) { if (a.name.indexOf('x-') === 0 || a.name.indexOf('v-') === 0 || a.name.indexOf(':') === 0 || a.name.indexOf('@') === 0) { el.removeAttribute(a.name); } }); }); } function onStart(e) { var grip = e.target.closest('.reorder-grip'); if (!grip) return; var list = grip.closest('.reorder-list'); var item = grip.closest('.reorder-item'); if (!list || !item) return; var idx = parseInt(item.dataset.idx, 10); var rect = item.getBoundingClientRect(); var clientX = e.clientX; var clientY = e.clientY; var clone = document.createElement('div'); clone.className = 'drag-clone'; clone.style.width = rect.width + 'px'; clone.innerHTML = item.innerHTML; stripFrameworkAttrs(clone); clone.style.left = rect.left + 'px'; clone.style.top = rect.top + 'px'; document.body.appendChild(clone); dragging = { list: list, curIdx: idx, itemEl: item, clone: clone, offsetX: clientX - rect.left, offsetY: clientY - rect.top, clientX: clientX, clientY: clientY, scrollRaf: 0, }; markPlaceholder(); e.preventDefault(); } function applyPointer(clientX, clientY) { dragging.clone.style.left = (clientX - dragging.offsetX) + 'px'; dragging.clone.style.top = (clientY - dragging.offsetY) + 'px'; var items = dragging.list.querySelectorAll('.reorder-item'); for (var i = 0; i < items.length; i++) { if (i === dragging.curIdx) continue; var rect = items[i].getBoundingClientRect(); if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) { fireMove(dragging.list, dragging.curIdx, i); dragging.curIdx = i; markPlaceholder(); var rank = dragging.clone.querySelector('.reorder-rank'); if (rank) rank.textContent = i + 1; break; } } } function autoScrollTick() { if (!dragging) return; var margin = 80; var y = dragging.clientY; var dy = 0; if (y < margin) dy = -Math.ceil((margin - y) / 6); else if (y > window.innerHeight - margin) dy = Math.ceil((y - (window.innerHeight - margin)) / 6); if (dy) { window.scrollBy(0, dy); applyPointer(dragging.clientX, y); } dragging.scrollRaf = requestAnimationFrame(autoScrollTick); } function onMove(e) { if (!dragging) return; e.preventDefault(); dragging.clientX = e.clientX; dragging.clientY = e.clientY; applyPointer(e.clientX, e.clientY); if (!dragging.scrollRaf) dragging.scrollRaf = requestAnimationFrame(autoScrollTick); } function onEnd() { if (!dragging) return; if (dragging.scrollRaf) cancelAnimationFrame(dragging.scrollRaf); dragging.clone.remove(); dragging.list.querySelectorAll('.drag-placeholder').forEach(function(el) { el.classList.remove('drag-placeholder'); }); dragging = null; } document.addEventListener('pointerdown', function(e) { if (e.target.closest('.reorder-grip')) onStart(e); }); document.addEventListener('pointermove', onMove); document.addEventListener('pointerup', onEnd); document.addEventListener('pointercancel', onEnd); document.addEventListener('contextmenu', function(e) { if (e.target.closest('.reorder-item')) e.preventDefault(); }); })();