diff --git a/data-browser-panes.css b/data-browser-panes.css
new file mode 100644
index 0000000..9a731c4
--- /dev/null
+++ b/data-browser-panes.css
@@ -0,0 +1,5 @@
+/* Intentionally empty. Styles live inside data-browser-panes.js so the
+ browser is a single file. JSS auto-fetches this sibling URL
+ (javascript-solid-server/src/mashlib/index.js:307); shipping an empty
+ 200 keeps the console warning-free until JSS makes the .css fetch
+ optional. */
diff --git a/data-browser-panes.js b/data-browser-panes.js
new file mode 100644
index 0000000..e0a654e
--- /dev/null
+++ b/data-browser-panes.js
@@ -0,0 +1,288 @@
+// jspod pane-aware data browser. Sibling of data-browser.js (json) and
+// data-browser-folder.js (folder); selected with `--browser panes`. JSS
+// embeds the resource as JSON-LD in #dataisland; this script:
+// 1. If the resource is an LDP container, render breadcrumb + table
+// (NAME / TYPE / SIZE / MODIFIED) — identical to the folder browser.
+// 2. If it's a single resource, load the pod's own panes from
+// /public/panes/ and render the first whose canHandle(@type) matches,
+// with a collapsible Source. Panes are NOT shipped here — they live on
+// the pod and are augmented locally (drop a module in /public/panes/).
+// 3. No matching pane → pretty-printed JSON-LD (same as data-browser.js).
+//
+// Single file, no build, no framework. Local pane contract (ES module):
+// export default { canHandle(node, h) -> boolean, render(node, h) -> htmlString }
+// where h = { escape, prop, propAll, first, idOf, types, host, fmtDate, localName }.
+// (prop may return an array for multi-valued JSON-LD; use h.first for single values.)
+
+document.head.insertAdjacentHTML('beforeend', ``);
+
+(function () {
+ main();
+
+ async function main() {
+ const here = window.location.pathname;
+ const up = (here === '/' || !here)
+ ? null
+ : (s => { const i = s.lastIndexOf('/'); return i === 0 ? '/' : s.slice(0, i) + '/'; })(here.replace(/\/$/, ''));
+ const navHTML = ``;
+
+ let doc = null;
+ try { doc = JSON.parse(document.getElementById('dataisland').textContent); } catch (e) {}
+
+ const items = doc ? parseContainer(doc, window.location.href) : null;
+ const isContainer = Array.isArray(items);
+
+ const target = document.getElementById('mashlib');
+ if (isContainer) {
+ target.innerHTML = `
${navHTML}
${renderFolder(items, here)}
`;
+ return;
+ }
+
+ // Single resource: try a pod-local pane, else fall back to the JSON dump.
+ const node = primaryNode(doc);
+ const paneHTML = node ? await renderLocalPane(node) : null;
+ if (paneHTML) {
+ target.innerHTML = `
${navHTML}
${paneHTML}
` +
+ `Source${renderJson(doc)}
`;
+ } else {
+ target.innerHTML = `
${navHTML}${renderJson(doc)}
`;
+ }
+ }
+
+ // ---- local panes: discovered from /public/panes/, augmentable per-pod ----
+
+ async function renderLocalPane(node) {
+ const panes = await loadLocalPanes();
+ if (!panes.length) return null;
+ const h = { escape, prop, propAll, first: firstVal, idOf, types: typesOf, host: hostOf, fmtDate: fmtDay, localName: localType };
+ for (const p of panes) {
+ try { if (p.canHandle(node, h)) return p.render(node, h); } catch (e) { /* skip */ }
+ }
+ return null;
+ }
+
+ async function loadLocalPanes() {
+ try {
+ const base = new URL('/public/panes/', window.location.origin).href;
+ const r = await fetch(base, { headers: { Accept: 'application/ld+json' } });
+ if (!r.ok) return [];
+ const items = parseContainer(await r.json(), base) || [];
+ const urls = items
+ .filter(it => it.type !== 'container' && it.url.endsWith('.js') && !nameOf(it.url).startsWith('.'))
+ .map(it => it.url);
+ const mods = await Promise.all(urls.map(u => import(u).then(m => m.default).catch(() => null)));
+ return mods.filter(p => p && typeof p.canHandle === 'function' && typeof p.render === 'function');
+ } catch (e) {
+ return [];
+ }
+ }
+
+ // The primary subject of a single-resource doc: #this, else the @graph node
+ // carrying an @type, else the doc itself.
+ function primaryNode(d) {
+ if (!d) return null;
+ const graph = Array.isArray(d['@graph']) ? d['@graph'] : null;
+ if (graph) {
+ return graph.find(n => /(^|#)this$/.test(n['@id'] || '')) ||
+ graph.find(n => n['@type']) || graph[0] || null;
+ }
+ return d;
+ }
+ function localType(t) { return String(t).split(/[#/:]/).pop(); }
+ function typesOf(node) {
+ const t = node && node['@type'];
+ return (Array.isArray(t) ? t : (t == null ? [] : [t])).map(localType);
+ }
+ // Read a property tolerant of prefixed / aliased / expanded keys (pass short key).
+ function prop(node, key) {
+ if (!node) return undefined;
+ for (const k of Object.keys(node)) {
+ if (k.startsWith('@')) continue;
+ if (k === key || k.endsWith(':' + key) || k.endsWith('/' + key) || k.endsWith('#' + key)) return node[k];
+ }
+ return undefined;
+ }
+ function propAll(node, key) { const v = prop(node, key); return v == null ? [] : (Array.isArray(v) ? v : [v]); }
+ // First value of a possibly-multi-valued JSON-LD property.
+ function firstVal(v) { return Array.isArray(v) ? v[0] : v; }
+ function idOf(v) { if (Array.isArray(v)) v = v[0]; return v == null ? '' : (typeof v === 'string' ? v : (v['@id'] || v.id || '')); }
+ function hostOf(u) { try { return new URL(u).host; } catch (e) { return u; } }
+ function fmtDay(iso) {
+ if (!iso) return '';
+ const d = new Date(iso);
+ return isNaN(d) ? '' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
+ }
+
+ function renderFolder(items, path) {
+ const folders = items.filter(i => i.type === 'container').length;
+ const files = items.length - folders;
+ items.sort((a, b) => {
+ if (a.type !== b.type) return a.type === 'container' ? -1 : 1;
+ return nameOf(a.url).localeCompare(nameOf(b.url));
+ });
+
+ const segments = path.replace(/\/$/, '').split('/').filter(Boolean);
+ const here = segments[segments.length - 1] || window.location.host;
+ let acc = '/';
+ const crumbs = [`${escape(window.location.host)}`].concat(
+ segments.map((s, i) => {
+ acc += s + '/';
+ return i === segments.length - 1
+ ? `/${escape(decodeURIComponent(s))}`
+ : `/${escape(decodeURIComponent(s))}`;
+ })
+ ).join('');
+
+ const rows = items.length
+ ? `