From e92b72d406fd7cbe49b6e88c77958d92bdea3246 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 14:48:45 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20preview=20a=20resource,=20or=20a=20them?= =?UTF-8?q?e's=20sample=20deck,=20as=20pages=20=E2=80=94=20without=20downl?= =?UTF-8?q?oading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PowerPoint or Word file cannot be shown in a browser and a phone has nowhere to open one. Preview renders the resource the way its download would be built, turns it into one PNG per page (Gotenberg to PDF, pdftoppm to pages), and shows the pages in an overlay that scrolls and pinch-zooms like anything else. Rendered once per version — updated_at and theme are in the key — and served from disk afterwards. The theme picker's sample deck has the same Preview beside its download. Every presentation now shows the theme picker in the library, since markdown slides take a theme too. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- public/css/styles.css | 11 ++++ public/js/myResources.js | 86 ++++++++++++++++++++++++++- src/routes/myResources.js | 97 +++++++++++++++++++++++++++++++ src/utils/openapiRoutes.js | 4 ++ src/utils/previewPages.js | Bin 0 -> 4221 bytes test/deck-themes.test.js | 2 +- test/my-resources-refine.test.js | 1 + test/resource-previews.test.js | 48 +++++++++++++++ 8 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 src/utils/previewPages.js create mode 100644 test/resource-previews.test.js diff --git a/public/css/styles.css b/public/css/styles.css index 24d7f98e..4e4e879f 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -1301,6 +1301,17 @@ textarea.full-input{resize:vertical;} the card ends, then sits where it always did. The negative margins let the bar span the card's padding rather than floating in a gutter; the background is opaque because content scrolls under it. */ +/* The document preview: pages as pictures, edge to edge on a phone, a strip + down the middle on a desktop; the bar stays put, the pages scroll. */ +.mr-preview { position:fixed; inset:0; z-index:9000; background:rgba(15,23,42,.85); display:flex; flex-direction:column; } +body.mr-preview-open { overflow:hidden; } +.mr-preview-bar { display:flex; align-items:center; gap:10px; padding:8px 12px; background:var(--g900,#0f172a); color:#fff; flex:0 0 auto; } +.mr-preview-title { font-weight:600; font-size:14px; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.mr-preview-count { font-size:12px; color:#cbd5e1; } +.mr-preview-close { color:#fff; } +.mr-preview-pages { flex:1; overflow:auto; -webkit-overflow-scrolling:touch; padding:10px; display:flex; flex-direction:column; align-items:center; gap:10px; } +.mr-preview-page { width:100%; max-width:960px; height:auto; background:#fff; box-shadow:0 2px 12px rgba(0,0,0,.35); border-radius:4px; min-height:120px; } +.mr-preview-status { color:#e2e8f0; font-size:14px; margin:24px 0; } .admin-save-row { border-top:1px solid var(--g100); display:flex; align-items:center; gap:8px; flex-wrap:wrap; position:sticky; bottom:0; z-index:2; diff --git a/public/js/myResources.js b/public/js/myResources.js index 337e38d3..60a954c0 100644 --- a/public/js/myResources.js +++ b/public/js/myResources.js @@ -229,6 +229,14 @@ link.title = 'Every slide layout, with placeholder text, as a PowerPoint file'; link.style.cssText = 'font-size:12px;color:var(--blue);text-decoration:none;'; if (!link.parentNode) box.appendChild(link); + // And the same sample as pages, here, for a phone with no PowerPoint. + var look = box.querySelector('button') || document.createElement('button'); + look.type = 'button'; + look.className = 'btn-sm btn-ghost'; + look.style.cssText = 'margin-left:10px;font-size:12px;'; + look.innerHTML = ' Preview'; + look.onclick = function () { openPreview('/api/my-resources/theme-sample/' + encodeURIComponent(select.value) + '/preview', 'Sample deck: ' + name); }; + if (!look.parentNode) box.appendChild(look); box.hidden = false; } @@ -784,6 +792,16 @@ // An article has no slides, so offering PowerPoint would produce a deck of // paragraphs. A presentation as Word is fine — prose absorbs slide content // without overflowing anything. + // Look first, download after. The preview is the file's own pages as + // pictures, so it shows exactly what the download would. + var look = document.createElement('button'); + look.className = 'btn-sm btn-ghost'; + look.type = 'button'; + look.dataset.preview = String(row.id); + look.innerHTML = ' Preview'; + look.title = 'See every page here, without downloading'; + wrap.appendChild(look); + var formats = row.kind === 'article' ? ['docx', 'pdf'] : ['pptx', 'docx', 'pdf']; formats.forEach(function (format) { var btn = document.createElement('button'); @@ -799,7 +817,8 @@ // Re-skinning is a column write, not a regeneration: the next download // renders from the same deck in different colours. Only for a row that has // a deck — flat markdown has no palette to change. - if (row.kind !== 'article' && row.has_deck !== false && themeCatalogue.length > 1) { + // Every presentation takes a theme now — markdown slides too. + if (row.kind !== 'article' && themeCatalogue.length > 1) { var theme = document.createElement('select'); theme.className = 'btn-sm'; theme.dataset.theme = String(row.id); @@ -899,6 +918,8 @@ var cloud = event.target.closest && event.target.closest('[data-nextcloud]'); if (cloud) return sendToNextcloud(cloud.dataset.nextcloud, cloud.dataset.format, cloud); + var preview = event.target.closest && event.target.closest('[data-preview]'); + if (preview) { openPreview('/api/my-resources/' + encodeURIComponent(preview.dataset.preview) + '/preview', preview.dataset.previewTitle || 'Preview'); return; } var download = event.target.closest && event.target.closest('[data-download]'); if (download) return downloadResource(download.dataset.download, download.dataset.format, download); @@ -939,6 +960,69 @@ .finally(function () { btn.disabled = false; btn.textContent = original; }); } + // ── The preview gallery ────────────────────────────────── + // One overlay: the pages of a document, top to bottom, at the width of the + // screen. Each page is fetched with the auth header (an cannot + // carry one) and shown as it arrives, so the first page is up before the + // last is rendered. Pinch-zoom is the browser's own. + function openPreview(base, title) { + var old = document.getElementById('mr-preview'); + if (old) old.remove(); + var overlay = document.createElement('div'); + overlay.id = 'mr-preview'; + overlay.className = 'mr-preview'; + overlay.innerHTML = '
' + + '' + + '
' + + '

Rendering pages…

'; + overlay.querySelector('.mr-preview-title').textContent = title || 'Preview'; + document.body.appendChild(overlay); + document.body.classList.add('mr-preview-open'); + var urls = []; + function close() { + overlay.remove(); + document.body.classList.remove('mr-preview-open'); + urls.forEach(function (u) { URL.revokeObjectURL(u); }); + document.removeEventListener('keydown', onKey); + } + function onKey(e) { if (e.key === 'Escape') close(); } + document.addEventListener('keydown', onKey); + overlay.querySelector('.mr-preview-close').addEventListener('click', close); + overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); }); + + var pagesEl = overlay.querySelector('.mr-preview-pages'); + var countEl = overlay.querySelector('.mr-preview-count'); + fetch(base, { headers: getAuthHeaders() }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (!d.success) throw new Error(d.error || 'Could not render a preview'); + pagesEl.innerHTML = ''; + countEl.textContent = d.pages + (d.pages === 1 ? ' page' : ' pages'); + var i = 1; + function next() { + if (i > d.pages || !overlay.isConnected) return; + var n = i++; + var img = document.createElement('img'); + img.className = 'mr-preview-page'; + img.alt = 'Page ' + n; + pagesEl.appendChild(img); + fetch(base + '/' + n, { headers: getAuthHeaders() }) + .then(function (r) { if (!r.ok) throw new Error('page ' + n); return r.blob(); }) + .then(function (blob) { var u = URL.createObjectURL(blob); urls.push(u); img.src = u; }) + .catch(function () { img.alt = 'Page ' + n + ' could not be shown'; }) + .finally(next); + } + next(); next(); + }) + .catch(function (err) { + pagesEl.innerHTML = ''; + var p = document.createElement('p'); + p.className = 'mr-preview-status'; + p.textContent = err.message; + pagesEl.appendChild(p); + }); + } + function saveBlob(blob, name) { var url = URL.createObjectURL(blob); var link = document.createElement('a'); diff --git a/src/routes/myResources.js b/src/routes/myResources.js index 20f5090f..58e6d964 100644 --- a/src/routes/myResources.js +++ b/src/routes/myResources.js @@ -38,6 +38,7 @@ var logger = require('../utils/logger'); var webSearch = require('../utils/webSearch'); var pubmedSearch = require('../utils/pubmedSearch'); var documentExport = require('../utils/documentExport'); +var previewPages = require('../utils/previewPages'); // Scoped to this router's own prefix. Mounted on /api, a bare // router.use(authMiddleware) would gate every /api path below it in server.js. @@ -586,6 +587,102 @@ router.post('/my-resources/:id/to-nextcloud', async function (req, res) { } }); +// ── Previews: pages as pictures ───────────────────────────── +// Looking without downloading, on a phone as much as anywhere. A resource is +// rendered the way its download would be (PowerPoint for a presentation, +// Word for an article), turned into one PNG per page, and shown in the page +// itself. Rendered once per version — the key carries updated_at and the +// theme — and served from disk afterwards. +function previewKeyFor(row) { + return previewPages.cacheKey(['resource', row.id, row.updated_at, row.theme || '', row.kind]); +} + +async function resourceOfficeBytes(row, user) { + var format = row.kind === 'article' ? 'docx' : 'pptx'; + var scratch = await require('fs/promises').mkdtemp(require('path').join(require('os').tmpdir(), 'figs-')); + try { + var figures = await collectFigures(row.image_ids, user, scratch); + var bytes = await documentExport.render(row.markdown, row.kind, format, + { images: figures, deck: row.deck, theme: row.theme, figureIds: figureIdList(row.image_ids) }); + return { bytes: bytes, mime: documentExport.mimeFor(format), extension: format }; + } finally { + await require('fs/promises').rm(scratch, { recursive: true, force: true }).catch(function () {}); + } +} + +router.get('/my-resources/:id/preview', async function (req, res) { + try { + var row = await db.get( + 'SELECT id, kind, markdown, image_ids, deck, theme, updated_at FROM user_resources WHERE id = ? AND user_id = ?', + [parseInt(req.params.id, 10), req.user.id] + ); + if (!row) return res.status(404).json({ error: 'Not found' }); + var key = previewKeyFor(row); + var pages = await previewPages.ensure(key, function () { return resourceOfficeBytes(row, req.user); }); + res.json({ success: true, pages: pages, key: key }); + } catch (err) { + logger.warn('[my-resources] preview', { id: req.params.id, error: err.message }); + res.status(503).json({ error: 'Could not render a preview. Download the file instead.' }); + } +}); + +router.get('/my-resources/:id/preview/:page', async function (req, res) { + try { + var row = await db.get( + 'SELECT id, kind, theme, updated_at FROM user_resources WHERE id = ? AND user_id = ?', + [parseInt(req.params.id, 10), req.user.id] + ); + if (!row) return res.status(404).json({ error: 'Not found' }); + var png = await previewPages.page(previewKeyFor(row), parseInt(req.params.page, 10)); + if (!png) return res.status(404).json({ error: 'No such page' }); + res.setHeader('Content-Type', 'image/png'); + res.setHeader('Cache-Control', 'private, max-age=3600'); + res.send(png); + } catch (err) { + res.status(500).json({ error: 'Could not read the preview' }); + } +}); + +// The sample deck of a theme, as pages, so a theme can be judged on a phone. +function themeSampleKey(id, theme) { + // The catalogue entry itself is part of the key, so an edited palette + // renders afresh rather than serving yesterday's colours. + return previewPages.cacheKey(['theme-sample', id, JSON.stringify(theme || {})]); +} + +router.get('/my-resources/theme-sample/:id/preview', async function (req, res) { + try { + var id = deckSchema.themeId(req.params.id); + if (!id) return res.status(404).json({ error: 'No such theme' }); + var theme = deckSchema.themes().filter(function (t) { return t.id === id; })[0]; + var pages = await previewPages.ensure(themeSampleKey(id, theme), async function () { + var deck = deckSample.build(theme); + deck.theme = id; + var pptx = await documentExport.renderDeck(deck, [deckSample.FIGURE], [deckSample.FIGURE_JOB]); + return { bytes: pptx, mime: documentExport.FORMATS.pptx.mime, extension: 'pptx' }; + }); + res.json({ success: true, pages: pages }); + } catch (err) { + logger.warn('[my-resources] theme sample preview', { theme: req.params.id, error: err.message }); + res.status(503).json({ error: 'Could not render the sample.' }); + } +}); + +router.get('/my-resources/theme-sample/:id/preview/:page', async function (req, res) { + try { + var id = deckSchema.themeId(req.params.id); + if (!id) return res.status(404).json({ error: 'No such theme' }); + var theme = deckSchema.themes().filter(function (t) { return t.id === id; })[0]; + var png = await previewPages.page(themeSampleKey(id, theme), parseInt(req.params.page, 10)); + if (!png) return res.status(404).json({ error: 'No such page' }); + res.setHeader('Content-Type', 'image/png'); + res.setHeader('Cache-Control', 'private, max-age=86400'); + res.send(png); + } catch (err) { + res.status(500).json({ error: 'Could not read the preview' }); + } +}); + // ── Theme previews ────────────────────────────────────────── // A sample deck per theme: every layout the renderer can draw, filler text // throughout, downloaded and opened in PowerPoint. diff --git a/src/utils/openapiRoutes.js b/src/utils/openapiRoutes.js index 5d017635..99533c03 100644 --- a/src/utils/openapiRoutes.js +++ b/src/utils/openapiRoutes.js @@ -25,6 +25,10 @@ var parameters = { var operations = { // ── Speech ────────────────────────────────────────────────────────── + 'GET /api/my-resources/:id/preview': { summary: 'Render a resource as pages for viewing in place', description: 'Returns the page count and a key; pages are served as PNG by the sibling route. Rendered once per version (updated_at and theme).' }, + 'GET /api/my-resources/:id/preview/:page': { summary: 'One page of a resource preview, as PNG' }, + 'GET /api/my-resources/theme-sample/:id/preview': { summary: 'Render a theme\'s sample deck as pages' }, + 'GET /api/my-resources/theme-sample/:id/preview/:page': { summary: 'One page of a theme sample, as PNG' }, 'GET /api/nextcloud/config': { summary: 'The site\'s Nextcloud address, if one is set', description: 'nextcloud.url (or NEXTCLOUD_URL). When present the settings page hides the address field and the sign-in and app-password routes use it when none is given.' diff --git a/src/utils/previewPages.js b/src/utils/previewPages.js new file mode 100644 index 0000000000000000000000000000000000000000..a39397dfaf5522d1701fd8766add1c4f9461ac01 GIT binary patch literal 4221 zcmbtXZExE~628y+6%*i4l8Hpwz2b0C>MQc%_<}UCV7u)BNzYx8OOZ9X%kC~EtEr3o z5%&x4m)tYEBqjTjPZeO%+MS)9muH?C&gXRU2R@VeoR$}--@iWnftJtCPA}=d|NajN z%5~N?QaKWq+A4FMk#irxN88Gkx^ocTs#=R2%dGDYm~+DCpV3lprCI6< z_v6MNw8^QcYDt+;N;_IfvL)`MO2O2X(OWAGIkB3OP&t;`QY%TNu&hq$Z7Gc;r%AU` z5w=pg)DVMDx-*%i$`U+OGKWSM!{Oz-Gisr=6>I5Z;!4TdbWv0|;mvWS(cQYFGwq~W zNwbbDeOs-JJP$%pwS4(%N;s;h%?qd7wg~}-nY}`P0lNbmN^RwoY-V&_BbZZ!US;L% zSFIbdsD)v$|A-biCBjnLqd8`(PQSc31P^GkBFX(_La%$zp6 zgG|YvB-nmTP3>179H(Fd-98 z)Oy{?1g>$|8=XTZmf1+;m$l6-K!qn;YDpAGO&OA@*s=~`5NZuCbD7<6zL4<^eilYI z@W$SxbOj4Dk>MNLf-e7Qz|QMlsxqk+8zq%alI9;e5>=rOCAY&BFPE=z=J@#NDc%1dfVLE-1=bMZP-WnOqN{6=LGH>a{eJw_5xsfN+o&9G z!`o;7ybj!QLVrC9!Fh51?)%e=tLv-RZ%@y^V-t_RJi;gJo}FKvzI%RpamJ9~IV-Nm=`+iw(=&b5pAd_7d);xAAVJ)KMnK*j^ibSlnt(;%(LtyzP{vTdHw#|a^Q zT=vNHmX;xV@pvv~cBnT1kC8v4Si%V!qyJFpM^?MwFBE*$ND$+K9=JIL)NY@5MIp_C zzM{u;eDvk`xSPTv{_IVV#-L_g4qg6<--8(}SQQlerQVNVXw2@M8l%l%B0ZsKK@lD? zl;H%24v(|}RFI`4b^6j7FixDLwg$h&M^i#1lLT1ioA}emgH^zuapTVa$KQ98$^7wS z&^M|IY_%sDQlbH6qSTpL2NuzV|2cG;lpfFbS@VVbM(*Mk1#0hQE0jZ;8423?hp=TF z0hGs4k_ILUN76>Lad0XLu#f%}B}tlBYgBz4mGYM14x7htGhn%{V$dO|mjiQ%l6y78 zbu|UMLNH><8Aq4@;aI1wTKCF@-KlJ!QI>e2JLUL}pxv3BzlDft#WCrekl2a}b!FQW zUgqAY&t9LgzKBb?bH(5IUd|RPQ(Qj6Sos;J5b7s4%_u= z=g`P{dan$<1^1BKwUV!#PadR7DvP0~$3n0sB#a|lij5r8bU&#aE-p+KNd{~92XF9M z5US5VDTAX&Uf7h7RqeY*9SK~fC`GU z!9G*REByNsJ$u;ZtyHuaopSU!)Q64GP+5>`)mn*qLC;p&xJ&;d?P)rGz|x6V7kDk?^3h;os&5j&8xyCGKw#Mj zLtYp+!rWB|1SY4L12G;1C%VqEE6Grz`y$^O)ZYJBIfI}NDyBy+@z6U`o_gYNe7pd& zo7jc@c;xL~1}!ie=1;`seKcc+g6f&UcTfH3`b;9TdcQPwZrG!BlYpK?*{6zklz5Gud=smH%;TXb@4V-Gdp`qD??5!RpB_f{V%7W7 z6aC>9J9!6(P?VtcP{I2fP2&;siTL=J&}<*9)HkLBKp-R`ghOxgF9!TN#}VZLggu|~ zi-+=aD|eK!1?3tD=C=iTTUqC4k_UZ(Plv96UdeWIj(iaI{0fm5W0z-7xgk6XLKwXs z#b~gYctflG`Xn4r#%{rO@hQ*X0Eiz_4}!BF2@X@JkiJhhYOgQ!YP@@OwFEb}C?l>_ ijII36^oA#y(DDK_!ngNgN}z@*jbtz-|5kDLbn-vu{YZ2G literal 0 HcmV?d00001 diff --git a/test/deck-themes.test.js b/test/deck-themes.test.js index c2f48798..e30e7c21 100644 --- a/test/deck-themes.test.js +++ b/test/deck-themes.test.js @@ -81,7 +81,7 @@ test('the theme is shown by a deck the renderer built, not a mocked-up swatch', test('the library offers a theme only where there is a deck to re-skin', () => { const ui = read('public/js/myResources.js'); - assert.match(ui, /row\.kind !== 'article' && row\.has_deck !== false/); + assert.match(ui, /row\.kind !== 'article' && themeCatalogue\.length > 1/); assert.match(ui, /data-theme|dataset\.theme/); // A failed change puts the control back rather than showing a theme the // resource does not have. diff --git a/test/my-resources-refine.test.js b/test/my-resources-refine.test.js index ef4dcb51..b94448df 100644 --- a/test/my-resources-refine.test.js +++ b/test/my-resources-refine.test.js @@ -80,6 +80,7 @@ function router(t, overrides = {}) { '../utils/learningRetrieval': { retrieve: async () => ({ context: '', sources: [], reason: null }) }, '../utils/logger': quiet, '../utils/nextcloudFiles': { send: async () => '/PediatricScribe/2026-01-01/x.pptx' }, + '../utils/previewPages': { cacheKey: () => 'key', ensure: async () => 1, page: async () => null }, '../utils/metrics': { resourceRefines: { inc() {} }, resourceVocabularyGaps: { inc() {} } }, '../utils/pubmedSearch': { isAvailable: async () => false, search: async () => ({ results: [] }), formatForPrompt: () => '' }, '../utils/webSearch': { isAvailable: async () => false, search: async () => ({ results: [] }), formatForPrompt: () => '' }, diff --git a/test/resource-previews.test.js b/test/resource-previews.test.js new file mode 100644 index 00000000..e49b4da7 --- /dev/null +++ b/test/resource-previews.test.js @@ -0,0 +1,48 @@ +// A resource, or a theme's sample deck, shown as pages in the page itself — +// on a phone as much as anywhere — rendered once per version. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8'); + +test('the cache key follows the version, so a modification or a re-skin renders afresh', () => { + const previews = require('../src/utils/previewPages'); + const a = previews.cacheKey(['resource', 7, '2026-09-13T10:00:00Z', 'ward-teal', 'presentation']); + const same = previews.cacheKey(['resource', 7, '2026-09-13T10:00:00Z', 'ward-teal', 'presentation']); + const edited = previews.cacheKey(['resource', 7, '2026-09-13T11:00:00Z', 'ward-teal', 'presentation']); + const reskinned = previews.cacheKey(['resource', 7, '2026-09-13T10:00:00Z', 'slate', 'presentation']); + assert.equal(a, same); + assert.notEqual(a, edited); + assert.notEqual(a, reskinned); + assert.match(a, /^[0-9a-f]{40}$/, 'a directory name, nothing a caller typed'); +}); + +test('previews are served for a resource and for a theme sample, and only to the owner', () => { + const route = read('src/routes/myResources.js'); + assert.match(route, /router\.get\('\/my-resources\/:id\/preview', async/); + assert.match(route, /router\.get\('\/my-resources\/:id\/preview\/:page', async/); + assert.match(route, /router\.get\('\/my-resources\/theme-sample\/:id\/preview', async/); + assert.match(route, /router\.get\('\/my-resources\/theme-sample\/:id\/preview\/:page', async/); + // The resource routes read with the owner in the WHERE, like every other route here. + const preview = route.slice(route.indexOf("router.get('/my-resources/:id/preview'"), route.indexOf("// The sample deck of a theme")); + assert.equal((preview.match(/AND user_id = \?/g) || []).length, 2); + // Rendered the way the download is: the same exporter, the same theme. + assert.match(route, /documentExport\.render\(row\.markdown, row\.kind, format,\s*\{ images: figures, deck: row\.deck, theme: row\.theme, figureIds: figureIdList\(row\.image_ids\) \}\)/); + assert.match(route, /previewPages\.cacheKey\(\['resource', row\.id, row\.updated_at, row\.theme \|\| '', row\.kind\]\)/); + assert.match(route, /previewPages\.cacheKey\(\['theme-sample', id, JSON\.stringify\(theme \|\| \{\}\)\]\)/); +}); + +test('the page offers Preview beside the downloads and beside the theme sample, and the gallery scrolls', () => { + const js = read('public/js/myResources.js'); + assert.match(js, /look\.dataset\.preview = String\(row\.id\)/); + assert.match(js, /openPreview\('\/api\/my-resources\/theme-sample\/' \+ encodeURIComponent\(select\.value\) \+ '\/preview'/); + // Fetched with the auth header — an cannot carry one. + assert.match(js, /fetch\(base \+ '\/' \+ n, \{ headers: getAuthHeaders\(\) \}\)/); + assert.match(js, /URL\.revokeObjectURL\(u\)/, 'blob URLs are released when the preview closes'); + // Every presentation takes a theme now, markdown slides included. + assert.doesNotMatch(js, /row\.has_deck !== false && themeCatalogue\.length > 1/); + const css = read('public/css/styles.css'); + assert.match(css, /\.mr-preview-pages \{[^}]*overflow:auto/); + assert.match(css, /\.mr-preview-page \{[^}]*width:100%/); +});