Directory board: JSON/CSV import + logo-replaces-title + fix images on player (#195)

* feat(widgets): bulk import for the directory board (JSON / CSV / TSV / text)

Adds an "Import from JSON / CSV" button to the directory-board editor. Paste JSON
(the { company, tenantsByFloor, advertisements, backgroundImages } shape plus
categories[]/floors[]/flat-array/bare-floor-map variants), a CSV/TSV/pipe/semicolon
table (with or without a header — vacant/yes/1 => available, quoted fields), or a
sectioned "room name" text list, and it auto-fills title, footer, floors->categories,
rooms/names/details/availability, and background-image URLs. "Replace / append" toggle.

Tolerant key matching (room/suite/unit/id, name/tenant/company, details/subtitle, …);
warns on things it can't use (bare-filename background images, headerless columns).
parseDirectoryImport is pure and was unit-tested in node across every format.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(widgets): directory board — logo replaces title, and images load on the player

Two on-screen bugs on the directory board:

1. A logo did not remove the title text — both rendered, stacking the wordmark over
   the name. renderDirectoryBoard (and the directory-search header) now gate the title
   h1 behind !logoSrc, so a logo replaces the title. New render test guards it.

2. Logo + background images did not show on the player (NS_ERROR_DOM_CORP_FAILED,
   0 bytes). The player embeds widgets in a sandbox="allow-scripts" (opaque-origin)
   iframe, so /api/content image requests are cross-origin, and the helmet default
   Cross-Origin-Resource-Policy: same-origin blocks them. Set CORP: cross-origin (+
   ACAO:*) on the content file + thumbnail routes, matching the existing /uploads/content
   static route. Content already serves publicly, so no new exposure. Verified in a real
   sandboxed iframe: same-origin blocks, cross-origin loads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
screentinker 2026-07-16 13:53:34 -05:00 committed by GitHub
parent 61c1246b5b
commit 178af029a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 301 additions and 2 deletions

View file

@ -773,6 +773,19 @@ export default {
'widget.dir.columns_auto': 'Auto',
'widget.dir.categories': 'Categories',
'widget.dir.add_category': '+ Add Category',
'widget.dir.import_btn': 'Import from JSON / CSV',
'widget.dir.import_hint': 'Paste JSON, CSV/TSV, or a simple tenant list to auto-fill the fields below.',
'widget.dir.import_title': 'Import Directory Data',
'widget.dir.import_desc': 'Paste JSON, a CSV/TSV table, or a "room name" list. Floors or sections become categories; rooms, names, details and availability are detected automatically.',
'widget.dir.import_placeholder': 'Paste here — e.g.\n{ "company": "…", "tenantsByFloor": { "First Floor": [ { "room": "110", "name": "…" } ] } }\n\n…or CSV:\nFloor,Room,Name,Details,Available\nFirst Floor,110,Bodied By Rico,,no',
'widget.dir.import_replace': 'Replace existing categories (uncheck to append)',
'widget.dir.import_populate': 'Populate Form',
'widget.dir.import_done': 'Imported {cats} categories and {entries} entries.',
'widget.dir.import_warn_bg': '{n} background image name(s) were skipped — they are bare filenames, not URLs. Add them via "Add Background Image".',
'widget.dir.import_warn_noheader': 'No header row detected — columns were guessed by position. Add a header (e.g. Floor,Room,Name,Details) for exact mapping.',
'widget.dir.import_warn_nosections': 'No sections detected — everything was placed in one category. Use headings like "Second Floor:" to split by floor.',
'widget.dir.import_err_empty': 'Nothing to import — paste JSON, CSV, or a tenant list.',
'widget.dir.import_err_norows': 'No rows found. Check the format — JSON, CSV/TSV, or a "room name" list.',
'widget.dir.add_entry': '+ Add Entry',
'widget.dir.empty_categories': 'Add your first floor or department to get started',
'widget.dir.no_entries': 'No entries yet',

View file

@ -24,6 +24,175 @@ function escAttr(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// --- Directory-board bulk import: tolerant parser for JSON, CSV/TSV/pipe/semicolon
// tables, or a sectioned "room name" text list. Returns { meta, categories,
// background_images, warnings, stats }. Pure (no DOM) — unit-tested in node. ---
function _diPick(o, keys) {
if (!o || typeof o !== 'object') return undefined;
const low = {};
for (const k of Object.keys(o)) low[k.toLowerCase()] = o[k];
for (const k of keys) { const v = low[k]; if (v != null && v !== '') return v; }
return undefined;
}
function _diBool(v) {
if (v === true) return true;
const s = String(v == null ? '' : v).trim().toLowerCase();
return s === 'true' || s === 'yes' || s === 'y' || s === '1' || s === 'available' || s === 'open' || s === 'vacant';
}
function _diEntry(e) {
if (e == null) return null;
if (typeof e === 'string' || typeof e === 'number') return { identifier: '', name: String(e).trim(), subtitle: '', available: false };
const id = _diPick(e, ['room', 'identifier', 'id', 'number', 'no', 'suite', 'unit', 'office', 'space']);
const name = _diPick(e, ['name', 'title', 'company', 'tenant', 'business', 'label', 'text']);
const sub = _diPick(e, ['subtitle', 'details', 'detail', 'description', 'desc', 'note', 'notes', 'info']);
const avail = _diPick(e, ['available', 'vacant', 'is_available', 'open', 'status']);
return {
identifier: id == null ? '' : String(id).trim(),
name: name == null ? '' : String(name).trim(),
subtitle: sub == null ? '' : String(sub).trim(),
available: avail == null ? false : _diBool(avail),
};
}
const _DI_META_ARRAY_KEYS = new Set(['advertisements', 'ads', 'announcements', 'backgroundimages', 'background_images', 'backgrounds']);
function _diNormalizeJson(data) {
const meta = {}; const warnings = [];
const title = _diPick(data, ['company', 'title', 'name', 'building', 'property']);
if (title != null) meta.title = String(title).trim();
let footer = _diPick(data, ['footer_text', 'footer', 'footertext', 'leasing', 'contact']);
const ads = data.advertisements || data.ads || data.announcements;
if (footer == null && Array.isArray(ads)) footer = ads.map(a => (typeof a === 'string' ? a : _diPick(a, ['text', 'message', 'content']))).filter(Boolean).join(' • ');
if (footer) meta.footer_text = String(footer).trim();
const theme = _diPick(data, ['theme']); if (theme) meta.theme = String(theme).toLowerCase();
const speed = _diPick(data, ['scroll_speed', 'scrollspeed', 'speed']); if (speed) meta.scroll_speed = String(speed).toLowerCase();
const cols = _diPick(data, ['columns', 'cols']); if (cols != null) meta.columns = String(cols).toLowerCase();
const logo = _diPick(data, ['logo_url', 'logo', 'logourl']); if (logo) meta.logo_url = String(logo);
const bgSrc = data.background_images || data.backgroundImages || data.backgrounds || [];
const background_images = []; let skippedBg = 0;
if (Array.isArray(bgSrc)) for (const b of bgSrc) {
const s = typeof b === 'string' ? b : _diPick(b, ['url', 'src', 'path']);
if (!s) continue;
if (/^(https?:)?\/\//i.test(s) || String(s).startsWith('/')) background_images.push(String(s)); else skippedBg++;
}
if (skippedBg) warnings.push(t('widget.dir.import_warn_bg', { n: skippedBg }));
let categories = [];
const mapCat = (c) => ({ name: String(_diPick(c, ['name', 'title', 'floor', 'category', 'section', 'label']) || '').trim(), entries: (c.entries || c.tenants || c.items || c.rooms || []).map(_diEntry).filter(Boolean) });
const tbf = data.tenantsByFloor || data.byfloor || data.tenantsbyfloor || data.sections;
if (tbf && typeof tbf === 'object' && !Array.isArray(tbf)) categories = Object.keys(tbf).map(fn => ({ name: fn, entries: (Array.isArray(tbf[fn]) ? tbf[fn] : []).map(_diEntry).filter(Boolean) }));
else if (Array.isArray(data.categories)) categories = data.categories.map(mapCat);
else if (Array.isArray(data.floors)) categories = data.floors.map(mapCat);
else if (Array.isArray(data)) {
if (data.some(x => x && typeof x === 'object' && (x.entries || x.tenants || x.items || x.rooms))) categories = data.map(mapCat);
else categories = [{ name: '', entries: data.map(_diEntry).filter(Boolean) }];
} else {
const floorKeys = Object.keys(data).filter(k => Array.isArray(data[k]) && !_DI_META_ARRAY_KEYS.has(k.toLowerCase()));
if (floorKeys.length) categories = floorKeys.map(fn => ({ name: fn, entries: data[fn].map(_diEntry).filter(Boolean) }));
}
return { meta, categories, background_images, warnings };
}
function _diSplit(line, delim) {
if (delim !== ',' && delim !== ';') return line.split(delim);
const out = []; let cur = ''; let q = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (q) { if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else q = false; } else cur += ch; }
else if (ch === '"') q = true;
else if (ch === delim) { out.push(cur); cur = ''; }
else cur += ch;
}
out.push(cur); return out;
}
const _DI_HEAD = {
floor: ['floor', 'category', 'section', 'level', 'wing', 'building', 'group'],
id: ['room', 'suite', 'unit', 'id', 'number', 'no', 'no.', 'office', 'space', '#'],
name: ['name', 'tenant', 'company', 'business', 'title', 'occupant'],
sub: ['subtitle', 'details', 'detail', 'description', 'desc', 'note', 'notes', 'info'],
avail: ['available', 'vacant', 'status', 'open'],
};
function _diHeaderRole(cell) {
const c = cell.trim().toLowerCase();
for (const role of Object.keys(_DI_HEAD)) if (_DI_HEAD[role].includes(c)) return role;
return null;
}
function _diParseTable(lines, delim, warnings) {
const rows = lines.map(l => _diSplit(l, delim).map(c => c.trim()));
const first = rows[0] || [];
const roles = first.map(_diHeaderRole);
const hasHeader = roles.filter(Boolean).length >= 2 || (roles.includes('name') && rows.length > 1);
const idx = { floor: -1, id: -1, name: -1, sub: -1, avail: -1 };
let body = rows;
if (hasHeader) { roles.forEach((r, i) => { if (r && idx[r] === -1) idx[r] = i; }); body = rows.slice(1); }
else {
const n = Math.max(...rows.map(r => r.length));
if (n <= 2) { idx.id = 0; idx.name = 1; }
else if (n === 3) { idx.id = 0; idx.name = 1; idx.sub = 2; }
else { idx.floor = 0; idx.id = 1; idx.name = 2; idx.sub = 3; }
warnings.push(t('widget.dir.import_warn_noheader'));
}
if (idx.name === -1 && idx.id === -1) { idx.id = 0; idx.name = 1; }
const cats = new Map();
const getCat = (nm) => { const k = nm || ''; if (!cats.has(k)) cats.set(k, { name: k, entries: [] }); return cats.get(k); };
for (const r of body) {
if (!r.length || r.every(c => c === '')) continue;
const cell = (i) => (i >= 0 && i < r.length ? r[i] : '');
const e = { identifier: cell(idx.id), name: idx.name >= 0 ? cell(idx.name) : '', subtitle: idx.sub >= 0 ? cell(idx.sub) : '', available: idx.avail >= 0 ? _diBool(cell(idx.avail)) : false };
if (!e.identifier && !e.name) continue;
getCat(idx.floor >= 0 ? cell(idx.floor) : '').entries.push(e);
}
return { meta: {}, categories: [...cats.values()], background_images: [], warnings };
}
function _diHeading(line) {
const l = line.trim();
if (/:$/.test(l)) return l.replace(/:$/, '').trim();
if (/^#{1,6}\s+/.test(l)) return l.replace(/^#{1,6}\s+/, '').trim();
if (/^\[.+\]$/.test(l)) return l.slice(1, -1).trim();
if (/^=+\s*(.+?)\s*=+$/.test(l)) return l.replace(/^=+\s*/, '').replace(/\s*=+$/, '').trim();
if (/^-{3,}\s*(.+?)\s*-{3,}$/.test(l)) return l.replace(/^-+\s*/, '').replace(/\s*-+$/, '').trim();
if (!/^\W*\d/.test(l) && /\b(floor|level|suite|section|wing|building)\b/i.test(l) && l.split(/\s+/).length <= 4) return l;
return null;
}
function _diParseSectioned(lines, warnings) {
const cats = []; let cur = null;
const ensure = () => { if (!cur) { cur = { name: '', entries: [] }; cats.push(cur); } return cur; };
for (const raw of lines) {
const heading = _diHeading(raw);
if (heading != null) { cur = { name: heading, entries: [] }; cats.push(cur); continue; }
const l = raw.trim();
const m = l.match(/^(#?\d+[A-Za-z]?)[\s.)\-:–—|]+(.+)$/);
const e = m ? { identifier: m[1].replace(/^#/, ''), name: m[2].trim(), subtitle: '', available: false }
: { identifier: '', name: l, subtitle: '', available: false };
if (e.identifier || e.name) ensure().entries.push(e);
}
if (!(cats.length > 1 || cats.some(c => c.name))) warnings.push(t('widget.dir.import_warn_nosections'));
return { meta: {}, categories: cats, background_images: [], warnings };
}
function _diParseDelimited(raw) {
const warnings = [];
const lines = raw.split(/\r?\n/).map(l => l.replace(/\s+$/, '')).filter(l => l.trim() !== '');
if (!lines.length) throw new Error(t('widget.dir.import_err_empty'));
const sample = lines.slice(0, 12);
let delim = null, best = 1;
for (const d of ['\t', ',', ';', '|']) {
const counts = sample.map(l => _diSplit(l, d).length);
const withCols = counts.filter(c => c >= 2).length;
const avg = counts.reduce((a, b) => a + b, 0) / counts.length;
if (withCols >= Math.ceil(sample.length * 0.6) && avg > best) { best = avg; delim = d; }
}
return delim ? _diParseTable(lines, delim, warnings) : _diParseSectioned(lines, warnings);
}
function parseDirectoryImport(text) {
const raw = String(text == null ? '' : text).trim();
if (!raw) throw new Error(t('widget.dir.import_err_empty'));
let json = null;
if (/^[[{]/.test(raw)) { try { json = JSON.parse(raw); } catch (e) { /* not JSON */ } }
const res = (json && typeof json === 'object') ? _diNormalizeJson(json) : _diParseDelimited(raw);
res.categories = (res.categories || [])
.map(c => ({ name: String(c.name || '').trim(), entries: (c.entries || []).filter(e => (e.identifier || '').trim() || (e.name || '').trim()) }))
.filter(c => c.name || c.entries.length);
if (!res.categories.length) throw new Error(t('widget.dir.import_err_norows'));
res.stats = { categories: res.categories.length, entries: res.categories.reduce((n, c) => n + c.entries.length, 0) };
return res;
}
function openContentPicker({ multiple = false, title } = {}) {
return new Promise(async (resolve) => {
const overlay = document.createElement('div');
@ -250,6 +419,10 @@ export async function render(container) {
break;
case 'directory-board':
html += `
<div class="form-group" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:10px;border:1px dashed var(--border);border-radius:6px;background:var(--bg-input)">
<button type="button" class="btn btn-secondary btn-sm" id="dbImportData">${t('widget.dir.import_btn')}</button>
<span style="font-size:11px;color:var(--text-muted);flex:1;min-width:160px">${t('widget.dir.import_hint')}</span>
</div>
<div class="form-group"><label>${t('widget.dir.title_label')}</label><input type="text" id="wTitle" class="input" value="${escAttr(config.title)}" placeholder="${t('widget.dir.title_placeholder')}"></div>
<div class="form-group"><label>${t('widget.dir.logo_label')}</label><div id="wLogoBox"></div></div>
<div class="form-group"><label>${t('widget.dir.footer_text_label')}</label><input type="text" id="wFooter" class="input" value="${escAttr(config.footer_text)}" placeholder="${t('widget.dir.footer_placeholder')}"></div>
@ -331,6 +504,7 @@ export async function render(container) {
renderDirCategories({ focusCatName: dirState.categories.length - 1 });
};
document.getElementById('wBgAdd').onclick = pickBgImages;
document.getElementById('dbImportData').onclick = openDirImport;
}
if (type === 'directory-search') {
@ -473,6 +647,64 @@ export async function render(container) {
}
}
function openDirImport() {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:10001;padding:16px';
const hasExisting = dirState.categories.length > 0;
overlay.innerHTML = `
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;width:100%;max-width:660px;max-height:90vh;display:flex;flex-direction:column;gap:12px">
<h3 style="font-size:16px;font-weight:600">${t('widget.dir.import_title')}</h3>
<div style="font-size:12px;color:var(--text-muted)">${t('widget.dir.import_desc')}</div>
<textarea id="diText" class="input" style="flex:1;min-height:220px;font-family:monospace;font-size:12px;white-space:pre;overflow:auto" placeholder="${escAttr(t('widget.dir.import_placeholder'))}"></textarea>
<div id="diError" style="display:none;font-size:12px;color:#ff6b6b;white-space:pre-wrap"></div>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer"><input type="checkbox" id="diReplace" ${hasExisting ? 'checked' : ''}> ${t('widget.dir.import_replace')}</label>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button type="button" class="btn btn-secondary" id="diCancel">${t('common.cancel')}</button>
<button type="button" class="btn btn-primary" id="diGo">${t('widget.dir.import_populate')}</button>
</div>
</div>`;
document.body.appendChild(overlay);
const ta = overlay.querySelector('#diText');
ta.focus();
const cleanup = () => overlay.remove();
overlay.querySelector('#diCancel').onclick = cleanup;
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
overlay.querySelector('#diGo').onclick = () => {
const errBox = overlay.querySelector('#diError');
let result;
try { result = parseDirectoryImport(ta.value); }
catch (e) { errBox.textContent = e.message || t('widget.dir.import_err_norows'); errBox.style.display = 'block'; return; }
applyDirImport(result, overlay.querySelector('#diReplace').checked);
cleanup();
};
}
function applyDirImport(result, replace) {
const m = result.meta || {};
const setVal = (id, v) => { const el = document.getElementById(id); if (el && v != null && v !== '') el.value = v; };
setVal('wTitle', m.title);
setVal('wFooter', m.footer_text);
const setSel = (id, v, allowed) => { if (v == null) return; const el = document.getElementById(id); if (el && allowed.includes(String(v))) el.value = String(v); };
setSel('wTheme', m.theme, ['dark', 'light']);
setSel('wSpeed', m.scroll_speed, ['slow', 'medium', 'fast']);
setSel('wCols', m.columns, ['auto', '1', '2', '3', '4']);
if (m.logo_url && (/^(https?:)?\/\//i.test(m.logo_url) || m.logo_url.startsWith('/'))) { dirState.logo_url = m.logo_url; renderLogoPicker(); }
if (Array.isArray(result.background_images) && result.background_images.length) {
const seen = new Set(dirState.background_images);
for (const u of result.background_images) if (!seen.has(u)) { dirState.background_images.push(u); seen.add(u); }
renderBgList();
}
const mapped = result.categories.map(c => ({
name: c.name || '', _expanded: false,
entries: c.entries.map(e => ({ identifier: e.identifier || '', name: e.name || '', subtitle: e.subtitle || '', available: !!e.available })),
}));
dirState.categories = replace ? mapped : dirState.categories.concat(mapped);
renderDirCategories();
const warnings = result.warnings || [];
const msg = [t('widget.dir.import_done', { cats: result.stats.categories, entries: result.stats.entries })].concat(warnings).join(' ');
showToast(msg, warnings.length ? 'info' : 'success');
}
function renderLogoPicker() {
const box = document.getElementById('wLogoBox');
if (!box) return;

View file

@ -347,6 +347,12 @@ router.get('/:id/file', (req, res) => {
// Prevent path traversal
const safePath = path.resolve(config.contentDir, path.basename(content.filepath));
if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' });
// Widget boards (logo/background images) render inside the player's sandboxed
// (opaque-origin) widget iframe, so these image requests are cross-origin. Without
// CORP: cross-origin the helmet default (same-origin) blocks them (NS_ERROR_DOM_CORP_FAILED,
// 0 bytes). Matches the /uploads/content static route. Content already serves publicly.
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.sendFile(safePath);
});
@ -357,6 +363,9 @@ router.get('/:id/thumbnail', (req, res) => {
if (!content.thumbnail_path) return res.status(404).json({ error: 'Thumbnail not found' });
const safePath = path.resolve(config.contentDir, path.basename(content.thumbnail_path));
if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' });
// See /:id/file — cross-origin so sandboxed widget iframes can load it.
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.sendFile(safePath);
});

View file

@ -528,7 +528,8 @@ function renderDirectoryBoard(c) {
img.alt = '';
header.appendChild(img);
}
if (cfg.title) {
// A logo replaces the title text — showing both stacks the wordmark over the name.
if (cfg.title && !logoSrc) {
var h1 = document.createElement('h1');
h1.textContent = cfg.title;
header.appendChild(h1);
@ -824,7 +825,8 @@ function renderDirectorySearch(c) {
img.className = 'logo'; img.src = logoSrc; img.alt = '';
header.appendChild(img);
}
if (cfg.title) {
// A logo replaces the title text — showing both stacks the wordmark over the name.
if (cfg.title && !logoSrc) {
var h1 = document.createElement('h1');
h1.textContent = cfg.title;
header.appendChild(h1);

View file

@ -0,0 +1,43 @@
'use strict';
// Guards the directory-board header behaviour: a logo REPLACES the title text
// (showing both stacked the wordmark over the name). Renders the public widget
// endpoint and inspects the emitted board script. Mirrors widget-render-xss.test.js.
const test = require('node:test');
const assert = require('node:assert/strict');
const Database = require('better-sqlite3');
process.env.JWT_SECRET = 'test-secret-dir-board';
const db = new Database(':memory:');
db.exec(`CREATE TABLE widgets (id TEXT PRIMARY KEY, widget_type TEXT, config TEXT, workspace_id TEXT);`);
const dbModulePath = require.resolve('../db/database');
require.cache[dbModulePath] = { id: dbModulePath, filename: dbModulePath, loaded: true, exports: { db } };
const express = require('express');
const widgetsRouter = require('../routes/widgets');
const app = express();
app.use('/api/widgets', widgetsRouter);
const server = app.listen(0);
let base;
test.before(async () => { await new Promise(r => server.listening ? r() : server.once('listening', r)); base = `http://127.0.0.1:${server.address().port}`; });
test.after(() => { server.close(); db.close(); });
const seed = (id, config) => db.prepare('INSERT INTO widgets (id, widget_type, config, workspace_id) VALUES (?,?,?,?)').run(id, 'directory-board', JSON.stringify(config), 'ws1');
const render = async (id) => (await fetch(`${base}/api/widgets/${id}/render`)).text();
test('directory board: title text is gated behind !logoSrc (logo replaces title)', async () => {
seed('b1', { title: 'LINNcinnati', logo_url: '/api/content/abc/file', categories: [] });
const html = await render('b1');
// The title h1 must only be appended when there is no logo.
assert.match(html, /if \(cfg\.title && !logoSrc\)/, 'title render must be guarded by !logoSrc');
assert.doesNotMatch(html, /if \(cfg\.title\) \{\s*\n\s*var h1/, 'title must not be rendered unconditionally');
});
test('directory board: still embeds title + logo config for the client', async () => {
seed('b2', { title: 'Lincoln Warehouse', logo_url: '/api/content/xyz/file', categories: [] });
const html = await render('b2');
assert.match(html, /Lincoln Warehouse/, 'title present in embedded config');
assert.match(html, /\/api\/content\/xyz\/file/, 'logo url present in embedded config');
});