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
+
+
+
+
+
+ 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));
+ }
+}