#!/usr/bin/env node
// Generates the Euro Office UI asset contact sheet: every icon and piece of interface
// artwork that ships in the editors, on one browsable page. Built for non-engineers
// (marketing, design) who need to see the icon set without a checkout or a build.
//
// Reads the SVG sources, inlines them with namespaced ids and scoped stylesheets, and
// writes a single self-contained HTML file - no external requests, opens offline.
//
// node build/scripts/contact-sheet.mjs [output.html]
//
// Env:
// WEB_APPS_ROOT override the repo root (default: two levels up from this script)
// GH_BRANCH branch the "source on GitHub" links point at (default: main)
//
// Notes for anyone editing this:
// - An inlined SVG's <style> is scoped to the HOST DOCUMENT, not the icon. loading.svg
// ships "g{display:none}", which blanks every grouped icon on the page. Every rule is
// rewritten to ".<uniquePrefix> <selector>" here; do not remove that.
// - Ids are namespaced per file, and url(#..)/href="#.." rewritten to match, or mask,
// clipPath and gradient references resolve to the wrong element once files share a
// document.
// - Fill conventions differ per set: toolbar and doc-format icons declare fill="none"
// and paint via stroke + the icon-stroke-*/icon-fill-* classes from common.less;
// mobile icons declare nothing and inherit. The root attribute is honoured per file
// rather than forced in CSS, because one blanket rule breaks one group or the other.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = process.env.WEB_APPS_ROOT
? path.resolve(process.env.WEB_APPS_ROOT)
: path.resolve(HERE, '..', '..');
const OUT = path.resolve(process.argv[2] || path.join(process.cwd(), 'eo-ui-assets.html'));
const GH = `https://github.com/Euro-Office/web-apps/tree/${process.env.GH_BRANCH || 'main'}`;
if (!fs.existsSync(path.join(ROOT, 'apps', 'common'))) {
console.error(`contact-sheet: no apps/common under ${ROOT}`);
console.error('Run from a web-apps checkout, or set WEB_APPS_ROOT.');
process.exit(1);
}
// ---------------------------------------------------------------- svg loading
let uid = 0;
function listSvgs(rel, { recursive = false } = {}) {
const dir = path.join(ROOT, rel);
if (!fs.existsSync(dir)) return [];
const out = [];
for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
if (e.isDirectory()) {
if (recursive) out.push(...listSvgs(path.join(rel, e.name), { recursive }));
continue;
}
if (!e.name.endsWith('.svg') || e.name.startsWith('.')) continue;
if (e.name === 'formats@2.5x.svg') continue; // generated sprite, not a source
out.push(path.join(rel, e.name));
}
return out;
}
const rxEsc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Remove clipPath defs whose only child is a rect covering the whole viewBox,
// along with the clip-path attributes pointing at them.
function stripNoopClips(body, viewBox) {
if (!viewBox) return body;
const [vx, vy, vw, vh] = viewBox.trim().split(/[\s,]+/).map(Number);
if (![vx, vy, vw, vh].every(Number.isFinite)) return body;
const noop = new Set();
for (const d of body.matchAll(/<clipPath\s+([^>]*?)>([\s\S]*?)<\/clipPath>/g)) {
const idm = d[1].match(/\bid\s*=\s*"([^"]+)"/);
if (!idm || /transform/.test(d[1])) continue;
const inner = d[2].trim();
const r = inner.match(/^<rect\b([^>]*?)\/?>$/);
if (!r) continue;
const a = {};
for (const m of r[1].matchAll(/([\w:-]+)\s*=\s*"([^"]*)"/g)) a[m[1]] = m[2];
if (a.transform || a.rx || a.ry) continue;
const x = parseFloat(a.x || 0), y = parseFloat(a.y || 0);
const w = parseFloat(a.width), h = parseFloat(a.height);
if (!Number.isFinite(w) || !Number.isFinite(h)) continue;
if (x <= vx && y <= vy && w >= vw && h >= vh) noop.add(idm[1]);
}
for (const id of noop) {
const e = rxEsc(id);
body = body
.replace(new RegExp(`\\s*clip-path\\s*=\\s*"url\\(#${e}\\)"`, 'g'), '')
.replace(new RegExp(`<clipPath\\s+[^>]*id="${e}"[^>]*>[\\s\\S]*?<\\/clipPath>`, 'g'), '');
}
return body.replace(/<defs>\s*<\/defs>/g, '');
}
// Inline one SVG: strip width/height (CSS controls size), guarantee a viewBox,
// namespace every internal id so 1000+ inlined files can share one document.
function inlineSvg(rel) {
const abs = path.join(ROOT, rel);
let src = fs.readFileSync(abs, 'utf8');
src = src.replace(/<\?xml[\s\S]*?\?>/g, '').replace(/<!--[\s\S]*?-->/g, '').trim();
const open = src.match(/<svg([\s\S]*?)>/);
if (!open) return null;
const attrs = {};
const re = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
let m;
while ((m = re.exec(open[1])) !== null) attrs[m[1]] = m[2] !== undefined ? m[2] : m[3];
let w = parseFloat(attrs.width), h = parseFloat(attrs.height);
if (!attrs.viewBox && Number.isFinite(w) && Number.isFinite(h)) attrs.viewBox = `0 0 ${w} ${h}`;
if (attrs.viewBox && (!Number.isFinite(w) || !Number.isFinite(h))) {
const p = attrs.viewBox.trim().split(/[\s,]+/).map(Number);
if (p.length === 4) { w = p[2]; h = p[3]; }
}
if (!Number.isFinite(w) || w <= 0) w = 24;
if (!Number.isFinite(h) || h <= 0) h = 24;
let body = src.replace(/<svg[\s\S]*?>/, '').replace(/<\/svg>\s*$/, '');
// Drop no-op clip paths. Every clipPath in this asset set is a rect covering the
// full viewBox, so it clips nothing - but 40+ live resource containers on a page
// holding 1100 inlined SVGs is enough to make Chrome give up painting them.
body = stripNoopClips(body, attrs.viewBox);
// Namespace ids: collect declarations, then rewrite decls + every reference.
const ns = `a${(uid++).toString(36)}`;
// A <style> inside inline SVG is scoped to the whole HTML document, not the icon.
// loading.svg ships "g{display:none}" and warnings_s.svg ships "use{display:none}",
// which blank every grouped icon on the page. Scope each rule to its own svg.
body = body.replace(/<style\b[^>]*>([\s\S]*?)<\/style>/g, (_m, css) => {
const scoped = css.replace(/(^|\})([^{}@]+)\{/g, (_mm, brace, sel) =>
brace + sel.split(',').map(s => `.${ns} ${s.trim()}`).join(',') + '{');
return `<style>${scoped}</style>`;
});
const ids = new Set();
for (const mm of body.matchAll(/\sid\s*=\s*"([^"]+)"/g)) ids.add(mm[1]);
for (const id of ids) {
const esc = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
body = body
.replace(new RegExp(`(\\sid\\s*=\\s*")${esc}(")`, 'g'), `$1${ns}-${id}$2`)
.replace(new RegExp(`url\\(#${esc}\\)`, 'g'), `url(#${ns}-${id})`)
.replace(new RegExp(`((?:xlink:)?href\\s*=\\s*")#${esc}(")`, 'g'), `$1#${ns}-${id}$2`);
}
// Sources fall into two conventions: toolbar/format icons declare fill="none" at the
// root and paint via stroke or explicit child fills; mobile icons declare nothing and
// rely on the app's inherited colour. Honour the first, and give the second
// currentColor so it tracks the theme instead of defaulting to black.
if (attrs.fill === undefined) attrs.fill = 'currentColor';
const keep = ['viewBox', 'fill', 'stroke', 'stroke-width', 'fill-rule', 'clip-rule',
'stroke-linecap', 'stroke-linejoin', 'opacity'];
const kept = keep.filter(k => attrs[k] !== undefined).map(k => `${k}="${attrs[k]}"`).join(' ');
// Some files legitimately draw nothing on their own: sprite sheets are a bag of
// <symbol>s only visible via <use>, and a couple are driven by :target state.
const symbols = (src.match(/<symbol\b/g) || []).length;
const badge = symbols ? `sprite · ${symbols} symbols`
: /:target/.test(src) ? 'shown on :target'
: /^<svg width="0"/.test(src) ? 'placeholder'
: '';
const name = path.basename(rel);
return {
name,
rel,
w, h,
badge,
// White artwork is invisible on a white stage - it is drawn for dark chrome.
onDark: /-white\.svg$/i.test(name) || /white_s\.svg$/i.test(name),
markup: `<svg class="ico ${ns}" ${kept} xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">${body}</svg>`,
};
}
// ------------------------------------------------------------------- sections
// accent keys map to the product's own editor brand tokens (see theme LESS / mobile CSS)
const SECTIONS = [
{
id: 'brand', title: 'Brand & logos', accent: 'eo',
note: 'Euro Office branding, applied over the stock assets at deploy time by <code>deploy-theme-images.js</code>. The only Euro Office–authored artwork in the set.',
groups: [{ label: 'theme/euro-office/assets/img', dir: 'theme/euro-office/assets/img', recursive: true }],
links: ['theme/euro-office/assets/img'],
},
{
id: 'formats', title: 'Document format icons', accent: 'eo',
note: 'File-type icons. The small set is compiled into a sprite; the large set is used directly in the “Download as” and “Save copy as” dialogs.',
groups: [
{ label: 'Small — sprite source', dir: 'apps/common/main/resources/img/doc-formats' },
{ label: 'Large — dialog artwork', dir: 'apps/common/main/resources/img/doc-formats/large' },
],
links: ['apps/common/main/resources/img/doc-formats', 'apps/common/main/resources/img/doc-formats/large'],
},
{
id: 'tb-common', title: 'Toolbar — shared', accent: 'eo',
note: 'Used by every editor. Compiled into <code>icons.svg</code> sprites by <code>deploy-sprites.js</code>; the source files never ship.',
groups: [
{ label: '24 px', dir: 'apps/common/main/resources/img/toolbar/v2/2.5x' },
{ label: '28 px', dir: 'apps/common/main/resources/img/toolbar/v2/2.5x/big' },
{ label: 'huge', dir: 'apps/common/main/resources/img/toolbar/v2/2.5x/huge' },
],
links: ['apps/common/main/resources/img/toolbar/v2/2.5x'],
},
...[
['tb-word', 'Toolbar — Documents', 'word', 'documenteditor'],
['tb-cell', 'Toolbar — Spreadsheets', 'cell', 'spreadsheeteditor'],
['tb-slide', 'Toolbar — Presentations', 'slide', 'presentationeditor'],
['tb-pdf', 'Toolbar — PDF', 'pdf', 'pdfeditor'],
['tb-visio', 'Toolbar — Diagrams', 'visio', 'visioeditor'],
].map(([id, title, accent, ed]) => ({
id, title, accent,
note: `Specific to the ${ed.replace('editor', '')} editor, on top of the shared set.`,
groups: [
{ label: '24 px', dir: `apps/${ed}/main/resources/img/toolbar/v2/2.5x` },
{ label: '28 px', dir: `apps/${ed}/main/resources/img/toolbar/v2/2.5x/big` },
{ label: 'huge', dir: `apps/${ed}/main/resources/img/toolbar/v2/2.5x/huge` },
],
links: [`apps/${ed}/main/resources/img/toolbar/v2/2.5x`],
})),
{
id: 'mobile-common', title: 'Mobile — shared', accent: 'eo',
note: 'Bundled by the Framework7 build through the <code>@common-icons</code> / <code>@common-ios-icons</code> / <code>@common-android-icons</code> aliases. iOS and Android variants of the same action differ by platform convention.',
groups: [
{ label: 'common', dir: 'apps/common/mobile/resources/icons/common' },
{ label: 'iOS', dir: 'apps/common/mobile/resources/icons/ios' },
{ label: 'Android', dir: 'apps/common/mobile/resources/icons/android' },
{ label: 'formats', dir: 'apps/common/mobile/resources/icons/common/formats' },
{ label: 'loose', dir: 'apps/common/mobile/resources/icons' },
],
links: ['apps/common/mobile/resources/icons'],
},
...[
['mob-word', 'Mobile — Documents', 'word', 'documenteditor'],
['mob-cell', 'Mobile — Spreadsheets', 'cell', 'spreadsheeteditor'],
['mob-slide', 'Mobile — Presentations', 'slide', 'presentationeditor'],
].map(([id, title, accent, ed]) => ({
id, title, accent,
note: 'Editor-specific mobile icons. Diagrams has no mobile assets of its own — it draws entirely on the shared set.',
groups: [
{ label: 'common', dir: `apps/${ed}/mobile/resources/icons/common` },
{ label: 'iOS', dir: `apps/${ed}/mobile/resources/icons/ios` },
{ label: 'Android', dir: `apps/${ed}/mobile/resources/icons/android` },
{ label: 'formats', dir: `apps/${ed}/mobile/resources/icons/common/formats` },
],
links: [`apps/${ed}/mobile/resources/icons`],
})),
{
id: 'chrome', title: 'Interface furniture', accent: 'eo',
note: 'Everything outside the toolbar: header logos, the About dialog, loading state, panel chrome, and the per-editor start-screen artwork.',
groups: [
{ label: 'header', dir: 'apps/common/main/resources/img/header' },
{ label: 'about', dir: 'apps/common/main/resources/img/about' },
{ label: 'load-mask', dir: 'apps/common/main/resources/img/load-mask' },
{ label: 'right-panels', dir: 'apps/common/main/resources/img/right-panels' },
{ label: 'combo-border-size', dir: 'apps/common/main/resources/img/combo-border-size' },
{ label: 'controls', dir: 'apps/common/main/resources/img/controls' },
{ label: 'forms', dir: 'apps/common/forms/resources/img' },
{ label: 'embed', dir: 'apps/common/embed/resources/img' },
{ label: 'Documents', dir: 'apps/documenteditor/main/resources/img' },
{ label: 'Spreadsheets', dir: 'apps/spreadsheeteditor/main/resources/img' },
{ label: 'Presentations', dir: 'apps/presentationeditor/main/resources/img' },
{ label: 'PDF', dir: 'apps/pdfeditor/main/resources/img' },
{ label: 'Diagrams', dir: 'apps/visioeditor/main/resources/img' },
],
links: [
'apps/common/main/resources/img/header',
'apps/common/main/resources/img/about',
'apps/common/main/resources/img/load-mask',
'apps/common/main/resources/img/right-panels',
'apps/common/main/resources/img/combo-border-size',
'apps/common/main/resources/img/controls',
'apps/common/forms/resources/img',
'apps/common/embed/resources/img',
'apps/documenteditor/main/resources/img',
'apps/spreadsheeteditor/main/resources/img',
'apps/presentationeditor/main/resources/img',
'apps/pdfeditor/main/resources/img',
'apps/visioeditor/main/resources/img',
],
},
];
// ---------------------------------------------------------------- build model
let total = 0;
const built = [];
for (const sec of SECTIONS) {
const groups = [];
for (const g of sec.groups) {
const files = listSvgs(g.dir, { recursive: !!g.recursive })
.map(inlineSvg)
.filter(Boolean);
if (!files.length) continue;
groups.push({ ...g, files });
total += files.length;
}
if (groups.length) built.push({ ...sec, groups, count: groups.reduce((n, g) => n + g.files.length, 0) });
}
// --------------------------------------------------------------------- markup
const esc = s => s.replace(/&(?![a-z#0-9]+;)/g, '&').replace(/</g, '<').replace(/>/g, '>');
const tile = f => `<button class="tile${f.onDark ? ' on-dark' : ''}" data-n="${esc(f.name.toLowerCase())}" data-name="${esc(f.name)}" data-path="${esc(f.rel)}" style="--w:${f.w};--h:${f.h}" type="button" title="${esc(f.rel)}">
<span class="stage">${f.markup}${f.badge ? `<span class="badge">${f.badge}</span>` : ''}</span><span class="fname">${esc(f.name.replace(/\.svg$/, ''))}</span><span class="dim">${f.w}×${f.h}</span></button>`;
const sections = built.map(sec => `
<section class="sec" id="${sec.id}" data-accent="${sec.accent}">
<header class="sec-h">
<div class="sec-id">
<h2>${sec.title}</h2>
<p class="note">${sec.note}</p>
</div>
<div class="sec-meta">
<span class="count"><b>${sec.count}</b> files</span>
${sec.links.map(l => `<a class="gh" href="${GH}/${l}" target="_blank" rel="noopener">${l.split('/').slice(-2).join('/')}<span class="arr" aria-hidden="true">↗</span></a>`).join('')}
</div>
</header>
${sec.groups.map(g => `<div class="grp">
<h3 class="grp-h">${g.label}<span class="grp-n">${g.files.length}</span></h3>
<div class="grid">${g.files.map(tile).join('')}</div>
</div>`).join('')}
</section>`).join('');
const nav = built.map(s => `<a href="#${s.id}">${s.title}<span>${s.count}</span></a>`).join('');
const allLinks = [...new Set(built.flatMap(s => s.links))];
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Euro Office — UI asset contact sheet</title>
<style>
:root{
--ground:#f4f6f9; --panel:#fff; --panel-2:#eef1f6; --line:#dde3ec;
--ink:#171a21; --ink-2:#5a6478; --ink-3:#8a94a6;
--eo:#0082c9; --eo-soft:#e3f1fa;
--word:#446995; --cell:#40865c; --slide:#be664f; --pdf:#aa5252; --visio:#444796;
/* the app's own icon tokens - light theme (colors-table-white.less) */
--icon-gray-primary:#383838; --icon-blue-primary:#446eca; --icon-gray-secondary:#969696;
--icon-blue-secondary:#dce7fa; --icon-red:#ef4444; --icon-success:#2e8b57;
--stage:#fff; --shadow:0 1px 2px rgba(23,26,33,.06),0 0 0 1px rgba(23,26,33,.05);
--sans:ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
}
@media (prefers-color-scheme:dark){:root{
--ground:#12141a; --panel:#191c23; --panel-2:#20242d; --line:#2a2f3a;
--ink:#e7eaf0; --ink-2:#98a2b5; --ink-3:#6b7688;
--eo:#3aa8e8; --eo-soft:#132735;
--word:#208bff; --cell:#34c759; --slide:#fe8c33; --pdf:#d25a3c; --visio:#6065ff;
/* colors-table-night.less */
--icon-gray-primary:#eaeaea; --icon-blue-primary:#92b7f0; --icon-gray-secondary:#f3f3f3;
--icon-blue-secondary:#b7cff5; --icon-red:#fca5a5; --icon-success:#78b588;
--stage:#20242d; --shadow:0 1px 2px rgba(0,0,0,.3),0 0 0 1px rgba(255,255,255,.05);
}}
:root[data-theme="dark"]{
--ground:#12141a; --panel:#191c23; --panel-2:#20242d; --line:#2a2f3a;
--ink:#e7eaf0; --ink-2:#98a2b5; --ink-3:#6b7688;
--eo:#3aa8e8; --eo-soft:#132735;
--word:#208bff; --cell:#34c759; --slide:#fe8c33; --pdf:#d25a3c; --visio:#6065ff;
--icon-gray-primary:#eaeaea; --icon-blue-primary:#92b7f0; --icon-gray-secondary:#f3f3f3;
--icon-blue-secondary:#b7cff5; --icon-red:#fca5a5; --icon-success:#78b588;
--stage:#20242d; --shadow:0 1px 2px rgba(0,0,0,.3),0 0 0 1px rgba(255,255,255,.05);
}
:root[data-theme="light"]{
--ground:#f4f6f9; --panel:#fff; --panel-2:#eef1f6; --line:#dde3ec;
--ink:#171a21; --ink-2:#5a6478; --ink-3:#8a94a6;
--eo:#0082c9; --eo-soft:#e3f1fa;
--word:#446995; --cell:#40865c; --slide:#be664f; --pdf:#aa5252; --visio:#444796;
--icon-gray-primary:#383838; --icon-blue-primary:#446eca; --icon-gray-secondary:#969696;
--icon-blue-secondary:#dce7fa; --icon-red:#ef4444; --icon-success:#2e8b57;
--stage:#fff; --shadow:0 1px 2px rgba(23,26,33,.06),0 0 0 1px rgba(23,26,33,.05);
}
*{box-sizing:border-box}
body{margin:0;background:var(--ground);color:var(--ink);font-family:var(--sans);
font-size:15px;line-height:1.55;-webkit-font-smoothing:antialiased}
.wrap{max-width:1400px;margin:0 auto;padding:0 24px 96px}
/* ---- masthead ---- */
.mast{padding:56px 0 28px;border-bottom:1px solid var(--line)}
.eyebrow{font-size:11px;font-weight:650;letter-spacing:.14em;text-transform:uppercase;color:var(--eo);margin:0 0 10px}
.mast h1{font-size:clamp(28px,4vw,42px);line-height:1.1;letter-spacing:-.02em;margin:0 0 14px;text-wrap:balance;font-weight:680}
.lede{max-width:64ch;color:var(--ink-2);margin:0 0 24px;font-size:16px}
.stats{display:flex;flex-wrap:wrap;gap:10px 28px;font-size:13px;color:var(--ink-2)}
.stats b{color:var(--ink);font-variant-numeric:tabular-nums;font-weight:650}
/* ---- controls ---- */
.bar{position:sticky;top:0;z-index:20;background:color-mix(in srgb,var(--ground) 88%,transparent);
backdrop-filter:blur(12px);border-bottom:1px solid var(--line);margin-bottom:8px}
.bar-in{max-width:1400px;margin:0 auto;padding:11px 24px;display:flex;gap:12px;align-items:center;flex-wrap:wrap}
.search{flex:1 1 240px;min-width:180px;position:relative}
.search input{width:100%;padding:9px 12px 9px 34px;border-radius:8px;border:1px solid var(--line);
background:var(--panel);color:var(--ink);font:inherit;font-size:14px}
.search input:focus-visible{outline:2px solid var(--eo);outline-offset:1px;border-color:transparent}
.search svg{position:absolute;left:11px;top:50%;transform:translateY(-50%);width:15px;height:15px;
stroke:var(--ink-3);fill:none;stroke-width:2}
.seg{display:flex;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel)}
.seg button{border:0;background:transparent;color:var(--ink-2);font:inherit;font-size:13px;
padding:8px 13px;cursor:pointer}
.seg button+button{border-left:1px solid var(--line)}
.seg button[aria-pressed="true"]{background:var(--eo);color:#fff}
.seg button:focus-visible{outline:2px solid var(--eo);outline-offset:-2px}
.hits{font-size:13px;color:var(--ink-3);font-variant-numeric:tabular-nums;white-space:nowrap}
/* ---- jump nav ---- */
.nav{display:flex;flex-wrap:wrap;gap:6px;padding:18px 0 4px}
.nav a{display:inline-flex;align-items:center;gap:7px;font-size:12.5px;color:var(--ink-2);
text-decoration:none;padding:5px 10px;border-radius:99px;border:1px solid var(--line);background:var(--panel)}
.nav a:hover{color:var(--ink);border-color:var(--eo)}
.nav a:focus-visible{outline:2px solid var(--eo);outline-offset:1px}
.nav a span{font-variant-numeric:tabular-nums;color:var(--ink-3);font-size:11px}
/* ---- sections ---- */
.sec{padding-top:44px;scroll-margin-top:66px}
.sec[hidden]{display:none}
.sec-h{display:flex;gap:24px;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;
padding-left:14px;border-left:3px solid var(--ac);margin-bottom:22px}
.sec[data-accent="eo"]{--ac:var(--eo)} .sec[data-accent="word"]{--ac:var(--word)}
.sec[data-accent="cell"]{--ac:var(--cell)} .sec[data-accent="slide"]{--ac:var(--slide)}
.sec[data-accent="pdf"]{--ac:var(--pdf)} .sec[data-accent="visio"]{--ac:var(--visio)}
.sec-h h2{font-size:21px;letter-spacing:-.01em;margin:0 0 5px;font-weight:660}
.note{margin:0;color:var(--ink-2);font-size:13.5px;max-width:62ch}
.note code{font-family:var(--mono);font-size:12px;background:var(--panel-2);padding:1px 5px;border-radius:4px}
.sec-meta{display:flex;flex-direction:column;align-items:flex-end;gap:6px}
.count{font-size:12px;color:var(--ink-3);font-variant-numeric:tabular-nums}
.count b{color:var(--ac);font-weight:660}
.gh{font-family:var(--mono);font-size:11.5px;color:var(--ink-2);text-decoration:none;
border:1px solid var(--line);background:var(--panel);border-radius:6px;padding:4px 9px;white-space:nowrap}
.gh:hover{border-color:var(--ac);color:var(--ink)}
.gh:focus-visible{outline:2px solid var(--eo);outline-offset:1px}
.arr{opacity:.5;margin-left:5px}
.grp{margin-bottom:26px}
.grp[hidden]{display:none}
.grp-h{display:flex;align-items:center;gap:9px;font-size:10.5px;font-weight:650;letter-spacing:.1em;
text-transform:uppercase;color:var(--ink-3);margin:0 0 11px}
.grp-h::after{content:"";flex:1;height:1px;background:var(--line)}
.grp-n{font-variant-numeric:tabular-nums;letter-spacing:0;order:3}
/* ---- tiles ---- */
.grid{display:grid;gap:8px;grid-template-columns:repeat(auto-fill,minmax(104px,1fr))}
.tile{display:flex;flex-direction:column;align-items:center;gap:7px;padding:12px 7px 9px;
background:var(--panel);border:1px solid var(--line);border-radius:10px;cursor:pointer;
font:inherit;color:inherit;text-align:center;transition:border-color .12s,transform .12s}
.tile:hover{border-color:var(--ac,var(--eo));transform:translateY(-1px)}
.tile:focus-visible{outline:2px solid var(--eo);outline-offset:2px}
.tile[hidden]{display:none}
.tile.copied{border-color:var(--eo);background:var(--eo-soft)}
.stage{display:flex;align-items:center;justify-content:center;width:100%;height:56px;
background:var(--stage);border-radius:7px;box-shadow:var(--shadow);overflow:hidden}
.badge{position:absolute;font-family:var(--mono);font-size:8.5px;letter-spacing:.02em;
color:var(--ink-3);background:var(--panel-2);border-radius:4px;padding:2px 5px;line-height:1.2}
.stage{position:relative}
.fname{font-family:var(--mono);font-size:10.5px;line-height:1.3;color:var(--ink-2);
word-break:break-word;max-width:100%}
.dim{font-family:var(--mono);font-size:9.5px;color:var(--ink-3);font-variant-numeric:tabular-nums}
/* icon rendering - mirrors apps/common/main/resources/less/common.less.
Fill is carried on each root <svg> attribute, not forced here, so the two
source conventions (fill="none"+stroke vs inherited colour) both survive. */
.ico{color:var(--icon-gray-primary);display:block}
[data-size="fit"] .ico{max-width:calc(100% - 8px);max-height:48px;width:auto;height:auto}
[data-size="native"] .ico{width:calc(var(--w)*1px);height:calc(var(--h)*1px);
max-width:calc(100% - 8px);max-height:48px}
[data-size="native"] .stage{height:56px}
.icon-stroke-blue-primary{fill:transparent;stroke:var(--icon-blue-primary)}
.icon-fill-blue-primary{fill:var(--icon-blue-primary);stroke:transparent}
.icon-stroke-gray-primary{fill:transparent;stroke:var(--icon-gray-primary)}
.icon-fill-gray-primary{fill:var(--icon-gray-primary);stroke:transparent}
.icon-stroke-gray-secondary{fill:transparent;stroke:var(--icon-gray-secondary)}
.icon-fill-gray-secondary{fill:var(--icon-gray-secondary);stroke:transparent}
.icon-stroke-blue-secondary{fill:transparent;stroke:var(--icon-blue-secondary)}
.icon-fill-blue-secondary{fill:var(--icon-blue-secondary);stroke:transparent}
.icon-stroke-red{fill:transparent;stroke:var(--icon-red)}
.icon-fill-red{fill:var(--icon-red);stroke:transparent}
.icon-stroke-success{fill:transparent;stroke:var(--icon-success)}
.icon-fill-success{fill:var(--icon-success);stroke:transparent}
/* White artwork is drawn for dark chrome - stage it accordingly, and let the
whole sheet flip so logos can be checked against a dark ground. */
.tile.on-dark .stage,[data-stage="dark"] .stage{background:#2b303b;box-shadow:none}
.tile.on-dark .ico,[data-stage="dark"] .ico{color:var(--icon-gray-primary)}
[data-stage="dark"]{--icon-gray-primary:#eaeaea}
/* ---- link appendix ---- */
.appendix{margin-top:64px;padding-top:34px;border-top:1px solid var(--line)}
.appendix h2{font-size:19px;margin:0 0 6px;font-weight:660}
.appendix p{color:var(--ink-2);font-size:13.5px;margin:0 0 18px;max-width:62ch}
.urls{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px 18px;
overflow-x:auto}
.urls ol{margin:0;padding-left:22px;display:flex;flex-direction:column;gap:5px}
.urls a{font-family:var(--mono);font-size:12px;color:var(--ink-2);text-decoration:none;white-space:nowrap}
.urls a:hover{color:var(--eo);text-decoration:underline}
.urls a:focus-visible{outline:2px solid var(--eo);outline-offset:1px}
.empty{display:none;padding:60px 0;text-align:center;color:var(--ink-3);font-size:14px}
.empty.on{display:block}
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%) translateY(12px);
background:var(--ink);color:var(--ground);padding:9px 16px;border-radius:8px;font-size:13px;
font-family:var(--mono);opacity:0;pointer-events:none;transition:opacity .16s,transform .16s;z-index:50}
.toast.on{opacity:1;transform:translateX(-50%) translateY(0)}
.foot{margin-top:52px;padding-top:22px;border-top:1px solid var(--line);color:var(--ink-3);font-size:12.5px}
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
</style>
</head>
<body>
<div class="bar">
<div class="bar-in">
<div class="search">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input id="q" type="search" placeholder="Filter ${total} icons by name…" autocomplete="off" aria-label="Filter icons by name">
</div>
<div class="seg" role="group" aria-label="Icon display size">
<button type="button" id="b-fit" aria-pressed="true">Uniform</button>
<button type="button" id="b-native" aria-pressed="false">Native size</button>
</div>
<div class="seg" role="group" aria-label="Tile background">
<button type="button" id="b-light" aria-pressed="true">Light tiles</button>
<button type="button" id="b-dark" aria-pressed="false">Dark tiles</button>
</div>
<span class="hits" id="hits">${total} shown</span>
</div>
</div>
<div class="wrap" data-size="fit" id="root">
<div class="mast">
<p class="eyebrow">Euro Office · web-apps</p>
<h1>UI asset contact sheet</h1>
<p class="lede">Every icon and piece of interface artwork that ships in the Euro Office editors, rendered from source at its real size and in the product's own colours. Click any tile to copy its filename.</p>
<div class="stats">
<span><b>${total}</b> SVG files</span>
<span><b>${built.length}</b> sets</span>
<span><b>${allLinks.length}</b> source folders</span>
<span>Light & dark shown per your system theme</span>
</div>
<nav class="nav">${nav}</nav>
</div>
${sections}
<p class="empty" id="empty">No icon matches that name.</p>
<div class="appendix">
<h2>Source folders on GitHub</h2>
<p>Every folder above, in <code>Euro-Office/web-apps</code> on <code>main</code>. Toolbar sources are compiled into sprites at build time, so they have no browsable rendered form outside this sheet.</p>
<div class="urls"><ol>${allLinks.map(l => `<li><a href="${GH}/${l}" target="_blank" rel="noopener">${GH}/${l}</a></li>`).join('')}</ol></div>
</div>
<p class="foot">Generated from the <code>main</code> branch. Brand artwork is Euro Office; the remaining icons are inherited from ONLYOFFICE DocumentServer and carry its AGPL licensing.</p>
</div>
<div class="toast" id="toast"></div>
<script>
(function(){
var root=document.getElementById('root'),q=document.getElementById('q'),
hits=document.getElementById('hits'),empty=document.getElementById('empty'),
toast=document.getElementById('toast'),
tiles=[].slice.call(document.querySelectorAll('.tile')),
grps=[].slice.call(document.querySelectorAll('.grp')),
secs=[].slice.call(document.querySelectorAll('.sec')),
total=tiles.length,tid;
function filter(){
var v=q.value.trim().toLowerCase(),n=0;
for(var i=0;i<tiles.length;i++){
var hit=!v||tiles[i].dataset.n.indexOf(v)>-1;
tiles[i].hidden=!hit; if(hit)n++;
}
grps.forEach(function(g){g.hidden=!g.querySelector('.tile:not([hidden])')});
secs.forEach(function(s){s.hidden=!s.querySelector('.tile:not([hidden])')});
hits.textContent=n===total?total+' shown':n+' of '+total;
empty.classList.toggle('on',n===0);
}
q.addEventListener('input',filter);
var bf=document.getElementById('b-fit'),bn=document.getElementById('b-native');
function size(mode){
root.dataset.size=mode;
bf.setAttribute('aria-pressed',String(mode==='fit'));
bn.setAttribute('aria-pressed',String(mode==='native'));
}
bf.addEventListener('click',function(){size('fit')});
bn.addEventListener('click',function(){size('native')});
var bl=document.getElementById('b-light'),bd=document.getElementById('b-dark');
function stage(mode){
if(mode==='dark')root.dataset.stage='dark'; else delete root.dataset.stage;
bl.setAttribute('aria-pressed',String(mode!=='dark'));
bd.setAttribute('aria-pressed',String(mode==='dark'));
}
bl.addEventListener('click',function(){stage('light')});
bd.addEventListener('click',function(){stage('dark')});
function flash(msg){
toast.textContent=msg;toast.classList.add('on');
clearTimeout(tid);tid=setTimeout(function(){toast.classList.remove('on')},1400);
}
document.addEventListener('click',function(e){
var t=e.target.closest&&e.target.closest('.tile'); if(!t)return;
var name=t.dataset.name;
var done=function(){t.classList.add('copied');flash(name+' copied');
setTimeout(function(){t.classList.remove('copied')},700)};
if(navigator.clipboard&&navigator.clipboard.writeText){
navigator.clipboard.writeText(name).then(done,function(){flash(name)});
} else {
var ta=document.createElement('textarea');ta.value=name;document.body.appendChild(ta);
ta.select();try{document.execCommand('copy');done()}catch(err){flash(name)}
document.body.removeChild(ta);
}
});
})();
</script>
</body>
</html>
`;
fs.writeFileSync(OUT, html);
console.log(`wrote ${OUT}`);
console.log(`sections=${built.length} icons=${total} bytes=${(fs.statSync(OUT).size / 1024).toFixed(0)}KB`);
for (const s of built) console.log(` ${String(s.count).padStart(4)} ${s.title.replace(/—/g, '-')}`);
Summary
A generator that renders every UI asset in the repo onto one browsable HTML page — icons at real size, in the product's own colours, with search, click-to-copy filenames, and a link to each source folder on GitHub.
It exists because there is currently no way to see the icon set without a checkout and a build. Toolbar icons in particular are compiled into
icons.svgsprites bydeploy-sprites.jsand never ship as individual files, so they cannot be browsed on GitHub either. This came out of a request from marketing for "the UI assets", which is otherwise unanswerable with a list of folder paths.Output is a single self-contained file — no external requests, no fonts, no CDN, opens offline by double-click. 1100 icons across 13 sets, ~1.5 MB, ~250 KB zipped.
Usage
WEB_APPS_ROOTGH_BRANCHmainDeliberately not wired into
build-pipeline.js— it is an on-demand documentation tool, not a build step, and nothing in the product consumes its output.Why it is worth committing rather than re-deriving
The script encodes three non-obvious constraints that each cost real debugging time. Anyone rewriting this from scratch will hit all three again:
An inlined SVG's
<style>is scoped to the host document, not the icon.apps/common/main/resources/img/load-mask/loading.svgshipsg { display: none; }(a:targetframe-switching trick, harmless when loaded via CSSurl()). Inline it alongside other icons and it blanks every grouped icon on the page. The symptom points nowhere near the cause: icons with a<g>wrapper vanish, icons with flat children render fine. The generator rewrites every embedded rule to.<uniquePrefix> <selector>.Worth noting beyond this script:
inline-svgs.jsdoes not currently inline any<style>-bearing SVG, so there is no live bug — but addingloading.svgorwarnings_s.svgto an<inline>tag would blank large parts of that editor's UI.Ids collide once files share a document.
mask,clipPathand gradient references silently resolve to the wrong element. Every id is namespaced per file, withurl(#…),href="#…"andxlink:href="#…"rewritten to match.Fill conventions differ per set. Toolbar and doc-format icons declare
fill="none"at the root and paint viastroke="currentColor"plus theicon-stroke-*/icon-fill-*classes fromcommon.less; mobile icons declare nothing and inherit colour from the app. A single blanket CSS fill rule breaks one group or the other, so the root attribute is honoured per file. Icon colours are read from the real tokens incolors-table-white.less/colors-table-night.less, so the sheet tracks the product rather than approximating it.Known-empty tiles
Ten of the 1100 tiles render nothing, correctly, and are labelled as such rather than left looking broken:
header/icons.svg,arrows.svg,BorderSize.svg,cf-icons.svg,form-points.svg) — bags of<symbol>s, invisible standalone by definitionloading.svgandwarnings_s.svg—:target-driven, empty until activatedemptyicon.svg— a genuine 0×0 placeholderProposed location
build/scripts/contact-sheet.mjsScript below — verified against
main: runs from a clean checkout, exits non-zero with a clear message if the root is wrong, and produces 1100 tiles with zero external resource loads.build/scripts/contact-sheet.mjs (click to expand)