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.
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()]])
Notes linking here
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.↩︎// ── 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); })();