diff --git a/docs/template-pack.md b/docs/template-pack.md index aa3547b..ec5507f 100644 --- a/docs/template-pack.md +++ b/docs/template-pack.md @@ -205,3 +205,15 @@ Minimal `theme-pack.json`: ``` For themes with no image assets, the same manifest can be uploaded directly as a `.json` file. `appliesTo` may be `"report"`, `"email"`, or `"all"`. + + +## Theme tokens: typography, spacing and color + +Reusable themes may also define these constrained tokens: + +- Typography: `fontFamily`, `baseFontSize`, `headingSize`, `lineHeight` +- Spacing: `blockGap`, `cardPadding`, `tableCellPadding`, `pagePadding` +- Shape: `borderRadius` +- Palette: `backgroundColor`, `surfaceColor`, `textColor`, `mutedColor`, `accentColor`, `buttonTextColor`, `borderColor` + +These tokens are editable from Preview and remain part of the draft/published theme. diff --git a/server/templateEngine.js b/server/templateEngine.js index 02b2044..8399fd1 100644 --- a/server/templateEngine.js +++ b/server/templateEngine.js @@ -65,6 +65,9 @@ function resolveDensity(template, mode) { function themeOf(template) { const theme = template?.theme || {} const reportDensityMode = safeDensityMode(theme.reportDensity) + const reportDensity = resolveDensity(template, reportDensityMode) + const baseFontSize = safeNumber(theme.baseFontSize, 15, 10, 22) + const headingSize = safeNumber(theme.headingSize, 23, 14, 42) return { backgroundColor: safeHex(theme.backgroundColor, '#f8fafc'), surfaceColor: safeHex(theme.surfaceColor, '#ffffff'), @@ -74,6 +77,14 @@ function themeOf(template) { buttonTextColor: safeHex(theme.buttonTextColor, '#ffffff'), borderColor: safeHex(theme.borderColor, '#dbe2ea'), borderRadius: safeNumber(theme.borderRadius, 12, 0, 32), + baseFontSize, + bodyFontSize: Math.max(10, baseFontSize - (reportDensity === 'compact' ? 2 : 0)), + headingSize, + renderedHeadingSize: Math.max(14, headingSize - (reportDensity === 'compact' ? 4 : 0)), + lineHeight: safeNumber(theme.lineHeight, 1.45, 1.1, 1.9), + blockGap: safeNumber(theme.blockGap, 10, 0, 32), + cardPadding: safeNumber(theme.cardPadding, 12, 4, 32), + tableCellPadding: safeNumber(theme.tableCellPadding, 8, 2, 20), pagePadding: safeNumber(theme.pagePadding, 18, 0, 80), pageMarginTop: safeNumber(theme.pageMarginTop, 12, 0, 40), pageMarginRight: safeNumber(theme.pageMarginRight, 12, 0, 40), @@ -86,7 +97,7 @@ function themeOf(template) { decorativeBorder: theme.decorativeBorder === true, borderImage: safeUrl(theme.borderImage, {}), reportDensityMode, - reportDensity: resolveDensity(template, reportDensityMode) + reportDensity } } @@ -232,7 +243,7 @@ export function renderReportHtml(template, data = {}) { return `
${renderReportBlocks(blocks, data, theme)}
` } @@ -250,7 +261,7 @@ function emailBlock(block, data, theme) { switch (block.type) { case 'heading': - return `${escapeHtml(interpolate(p.text, data))}` + return `${escapeHtml(interpolate(p.text, data))}` case 'text': return `${escapeHtml(interpolate(p.text, data))}` case 'kpi': @@ -270,7 +281,7 @@ export function renderEmailHtml(template, data = {}) { const theme = themeOf(template) const body = (template?.blocks || []).map(block => emailBlock(block, data, theme)).join('\n') const backgroundUrl = theme.pageBackgroundImage === '#' ? '' : ` background-url="${escapeHtml(theme.pageBackgroundImage)}" background-size="cover" background-repeat="no-repeat"` - const mjml = `${body}` + const mjml = `${body}` const result = mjml2html(mjml, { validationLevel: 'soft' }) if (result.errors?.length) console.warn('MJML warnings', result.errors) return result.html diff --git a/server/templateEngine.test.js b/server/templateEngine.test.js index 3421aae..52baa63 100644 --- a/server/templateEngine.test.js +++ b/server/templateEngine.test.js @@ -161,3 +161,50 @@ test('comfortable density preserves explicit section page breaks', () => { assert.match(html, /data-density="comfortable"/) assert.match(html, /break-before:page/) }) + + +test('theme typography and spacing tokens drive report output', () => { + const html = renderReportHtml({ + theme: { + fontFamily: 'Georgia', + baseFontSize: 17, + headingSize: 30, + lineHeight: 1.6, + blockGap: 18, + cardPadding: 20, + tableCellPadding: 12 + }, + blocks: [ + { type: 'heading', props: { text: 'Heading' } }, + { type: 'kpi', props: { label: 'A', value: '1' } }, + { type: 'table', props: { rowsPath: 'rows', columns: [{ label: 'Name', key: 'name' }] } } + ] + }, { rows: [{ name: 'One' }] }) + + assert.match(html, /font:17px\/1\.6 Georgia,sans-serif/) + assert.match(html, /h2\{font-size:30px/) + assert.match(html, /margin:0 0 18px/) + assert.match(html, /padding:20px 22px/) + assert.match(html, /padding:12px 14px/) +}) + +test('theme typography tokens drive email output', () => { + const html = renderEmailHtml({ + theme: { + fontFamily: 'Verdana', + baseFontSize: 18, + headingSize: 31, + lineHeight: 1.7, + accentColor: '#123456' + }, + blocks: [ + { type: 'heading', props: { text: 'Heading' } }, + { type: 'button', props: { text: 'Go', url: 'https://example.com' } } + ] + }, {}) + + assert.match(html, /font-family:Verdana/) + assert.match(html, /font-size:18px/) + assert.match(html, /font-size:31px/) + assert.match(html, /#123456/i) +}) diff --git a/server/themePack.js b/server/themePack.js index b61c467..a7734e3 100644 --- a/server/themePack.js +++ b/server/themePack.js @@ -197,6 +197,12 @@ export const themePackSpec = { borderColor: '#d8e1e8', borderRadius: 8, fontFamily: 'Arial', + baseFontSize: 15, + headingSize: 24, + lineHeight: 1.45, + blockGap: 10, + cardPadding: 12, + tableCellPadding: 8, reportDensity: 'auto', pageMarginTop: 9, pageMarginRight: 9, diff --git a/src/App.tsx b/src/App.tsx index 9cbb63a..46bc489 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -107,6 +107,7 @@ export function App({ user }: Props) { const loadedId = useRef(null) const timer = useRef(undefined) + const previewTimer = useRef(undefined) const selected = templateDoc.blocks.find(block => block.id === selectedId) || null const paths = useMemo(() => flatten(sampleData), [sampleData]) @@ -167,6 +168,15 @@ export function App({ user }: Props) { return () => window.clearTimeout(timer.current) }, [templateDoc, sampleData, active?.id]) + useEffect(() => { + if (screen !== 'preview' || !active || loadedId.current !== active.id) return + window.clearTimeout(previewTimer.current) + previewTimer.current = window.setTimeout(() => { + void refreshPreview().catch(err => setError(String(err))) + }, 450) + return () => window.clearTimeout(previewTimer.current) + }, [templateDoc, sampleData, screen, active?.id]) + async function openTemplate(id: string) { setError('') const remote = await api(`/api/templates/${id}`) @@ -663,6 +673,114 @@ export function App({ user }: Props) { +
+
+
+ THEME TOKENS +

Typography, spacing & colors

+
+ Live +
+ +
+ Font +
+ + + +
+ +
+ +
+ Spacing + {[ + ['Block gap', 'blockGap', 10, 0, 32], + ['Card padding', 'cardPadding', 12, 4, 32], + ['Table cells', 'tableCellPadding', 8, 2, 20], + ['Corner radius', 'borderRadius', 12, 0, 32] + ].map(([label, key, fallback, min, max]) => ( + + ))} +
+ +
+ Colors +
+ {[ + ['Page', 'backgroundColor', '#f8fafc'], + ['Surface', 'surfaceColor', '#ffffff'], + ['Text', 'textColor', '#172033'], + ['Muted', 'mutedColor', '#64748b'], + ['Accent', 'accentColor', '#111827'], + ['Border', 'borderColor', '#dbe2ea'], + ['Button text', 'buttonTextColor', '#ffffff'] + ].map(([label, key, fallback]) => ( + + ))} +
+
+
+ {active?.type === 'report' && (
diff --git a/src/refine.css b/src/refine.css index 25c39ed..00715f8 100644 --- a/src/refine.css +++ b/src/refine.css @@ -951,3 +951,130 @@ body{ font-weight:800; text-decoration:none; } + + +.theme-controls-card{ + margin:0 0 14px; + padding:14px; + border:1px solid var(--line); + border-radius:16px; + background:white; + box-shadow:0 3px 12px rgba(15,23,42,.025); +} +.theme-controls-heading{ + display:flex; + align-items:flex-start; + justify-content:space-between; + gap:12px; + margin-bottom:12px; +} +.theme-controls-heading h3{ + margin:3px 0 0; + font-size:15px; +} +.theme-controls-heading>span{ + color:#18794e; + background:#e7f6ed; + border-radius:999px; + padding:4px 7px; + font-size:8px; + font-weight:800; + text-transform:uppercase; +} +.theme-control-section{ + display:grid; + gap:9px; + padding:12px 0; + border-top:1px solid #edf1f5; +} +.theme-control-section:first-of-type{ + border-top:0; + padding-top:0; +} +.theme-font-row{ + display:grid; + grid-template-columns:minmax(0,1.6fr) repeat(2,minmax(64px,.7fr)); + gap:7px; +} +.theme-font-row select, +.theme-font-row input{ + width:100%; + min-width:0; + border:1px solid #d6dde6; + border-radius:9px; + background:#f9fafb; + color:#263449; + padding:9px 8px; + font-size:11px; +} +.theme-font-row label{ + display:grid; + gap:4px; + color:#7b8797; + font-size:8px; + font-weight:800; +} +.token-slider{ + display:grid; + grid-template-columns:90px minmax(0,1fr) 48px; + align-items:center; + gap:8px; + color:#59677a; + font-size:9px; + font-weight:800; +} +.token-slider input{ + width:100%; + min-width:0; +} +.token-slider strong{ + text-align:right; + color:#263449; + font-size:9px; + font-variant-numeric:tabular-nums; +} +.theme-color-grid{ + display:grid; + grid-template-columns:repeat(2,minmax(0,1fr)); + gap:7px; +} +.color-token{ + display:grid; + gap:5px; + padding:8px; + border:1px solid #e7ebf0; + border-radius:10px; + background:#fbfcfd; + color:#7b8797; + font-size:8px; + font-weight:800; +} +.color-input-wrap{ + display:flex; + align-items:center; + gap:7px; + min-width:0; +} +.color-input-wrap input[type="color"]{ + width:30px; + height:30px; + flex:0 0 auto; + padding:0; + border:0; + border-radius:7px; + overflow:hidden; + background:transparent; +} +.color-input-wrap code{ + min-width:0; + overflow:hidden; + white-space:nowrap; + text-overflow:ellipsis; + color:#344258; + font-size:9px; +} +@media(min-width:760px){ + .theme-color-grid{ + grid-template-columns:repeat(4,minmax(0,1fr)); + } +}