feat: add theme typography spacing and color controls

This commit is contained in:
Admin 2026-09-23 07:00:03 +00:00
parent fc77267987
commit a331c2bb56
6 changed files with 325 additions and 4 deletions

View File

@ -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"`. 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.

View File

@ -65,6 +65,9 @@ function resolveDensity(template, mode) {
function themeOf(template) { function themeOf(template) {
const theme = template?.theme || {} const theme = template?.theme || {}
const reportDensityMode = safeDensityMode(theme.reportDensity) 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 { return {
backgroundColor: safeHex(theme.backgroundColor, '#f8fafc'), backgroundColor: safeHex(theme.backgroundColor, '#f8fafc'),
surfaceColor: safeHex(theme.surfaceColor, '#ffffff'), surfaceColor: safeHex(theme.surfaceColor, '#ffffff'),
@ -74,6 +77,14 @@ function themeOf(template) {
buttonTextColor: safeHex(theme.buttonTextColor, '#ffffff'), buttonTextColor: safeHex(theme.buttonTextColor, '#ffffff'),
borderColor: safeHex(theme.borderColor, '#dbe2ea'), borderColor: safeHex(theme.borderColor, '#dbe2ea'),
borderRadius: safeNumber(theme.borderRadius, 12, 0, 32), 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), pagePadding: safeNumber(theme.pagePadding, 18, 0, 80),
pageMarginTop: safeNumber(theme.pageMarginTop, 12, 0, 40), pageMarginTop: safeNumber(theme.pageMarginTop, 12, 0, 40),
pageMarginRight: safeNumber(theme.pageMarginRight, 12, 0, 40), pageMarginRight: safeNumber(theme.pageMarginRight, 12, 0, 40),
@ -86,7 +97,7 @@ function themeOf(template) {
decorativeBorder: theme.decorativeBorder === true, decorativeBorder: theme.decorativeBorder === true,
borderImage: safeUrl(theme.borderImage, {}), borderImage: safeUrl(theme.borderImage, {}),
reportDensityMode, reportDensityMode,
reportDensity: resolveDensity(template, reportDensityMode) reportDensity
} }
} }
@ -232,7 +243,7 @@ export function renderReportHtml(template, data = {}) {
return `<!doctype html> return `<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<style> <style>
@page{size:A4;margin:${theme.pageMarginTop}mm ${theme.pageMarginRight}mm ${theme.pageMarginBottom}mm ${theme.pageMarginLeft}mm}*{box-sizing:border-box}html,body{min-height:100%}body{font:${theme.reportDensity === 'compact' ? 13 : 15}px/${theme.reportDensity === 'compact' ? 1.35 : 1.45} ${theme.fontFamily},sans-serif;color:${theme.textColor};margin:0;background:${theme.backgroundColor};-webkit-print-color-adjust:exact;print-color-adjust:exact}.document{position:relative;isolation:isolate;overflow:hidden;min-height:calc(297mm - ${theme.pageMarginTop + theme.pageMarginBottom}mm);background:${theme.surfaceColor};padding:${theme.pagePadding}px;border:${borderEnabled ? 10 : 0}px solid transparent;border-image:${borderImage};border-radius:${theme.borderRadius}px}.document::before{content:"";position:absolute;z-index:-1;inset:0;background-image:${backgroundImage};background-size:${backgroundSize};background-position:center top;background-repeat:no-repeat;opacity:${hasBackground ? theme.pageBackgroundOpacity : 0};pointer-events:none}.block{margin:0 0 ${theme.reportDensity === 'compact' ? 6 : 10}px;break-inside:avoid-page;page-break-inside:avoid}.block:last-child{margin-bottom:0}.image-logo{margin-bottom:6px}.image-hero{margin-top:2px;margin-bottom:10px}h2{font-size:${theme.reportDensity === 'compact' ? 19 : 23}px;line-height:1.2;margin:0;color:inherit}p{margin:0}.kpi-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin:0 0 6px;break-inside:avoid-page;page-break-inside:avoid}.kpi-grid>.block{margin:0;min-width:0}.kpi{border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px;padding:${theme.reportDensity === 'compact' ? '8px 10px' : '12px 14px'};display:flex;justify-content:space-between;gap:10px;background:${theme.surfaceColor}}.kpi-grid .kpi{height:100%;display:grid;gap:3px;align-content:center}.kpi span{color:${theme.mutedColor}}.kpi strong{font-size:${theme.reportDensity === 'compact' ? 16 : 18}px}.button{display:inline-block;background:${theme.accentColor};color:${theme.buttonTextColor};text-decoration:none;padding:10px 16px;border-radius:${theme.borderRadius}px}.image{height:auto;vertical-align:middle}hr{border:0;border-top:1px solid ${theme.borderColor};margin:${theme.reportDensity === 'compact' ? 6 : 10}px 0}.table-wrap{overflow:hidden;border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px;break-inside:avoid-page;page-break-inside:avoid}.table-wrap.allow-break{overflow:visible;break-inside:auto;page-break-inside:auto}.table-wrap.allow-break table{border-collapse:collapse}.compact-table{border-radius:${Math.min(theme.borderRadius, 6)}px}table{border-collapse:collapse;width:100%;font-size:${theme.reportDensity === 'compact' ? 11.5 : 13}px}thead{display:table-header-group}tfoot{display:table-footer-group}tr{break-inside:avoid-page;page-break-inside:avoid}th,td{padding:${theme.reportDensity === 'compact' ? '5px 6px' : '8px 10px'};text-align:left;border-bottom:1px solid ${theme.borderColor};vertical-align:top}th{background:${theme.backgroundColor};font-weight:700}.compact-table th,.compact-table td{padding:5px 6px;font-size:11.5px} @page{size:A4;margin:${theme.pageMarginTop}mm ${theme.pageMarginRight}mm ${theme.pageMarginBottom}mm ${theme.pageMarginLeft}mm}*{box-sizing:border-box}html,body{min-height:100%}body{font:${theme.bodyFontSize}px/${theme.lineHeight} ${theme.fontFamily},sans-serif;color:${theme.textColor};margin:0;background:${theme.backgroundColor};-webkit-print-color-adjust:exact;print-color-adjust:exact}.document{position:relative;isolation:isolate;overflow:hidden;min-height:calc(297mm - ${theme.pageMarginTop + theme.pageMarginBottom}mm);background:${theme.surfaceColor};padding:${theme.pagePadding}px;border:${borderEnabled ? 10 : 0}px solid transparent;border-image:${borderImage};border-radius:${theme.borderRadius}px}.document::before{content:"";position:absolute;z-index:-1;inset:0;background-image:${backgroundImage};background-size:${backgroundSize};background-position:center top;background-repeat:no-repeat;opacity:${hasBackground ? theme.pageBackgroundOpacity : 0};pointer-events:none}.block{margin:0 0 ${theme.reportDensity === 'compact' ? Math.max(2, theme.blockGap - 4) : theme.blockGap}px;break-inside:avoid-page;page-break-inside:avoid}.block:last-child{margin-bottom:0}.image-logo{margin-bottom:6px}.image-hero{margin-top:2px;margin-bottom:10px}h2{font-size:${theme.renderedHeadingSize}px;line-height:1.2;margin:0;color:inherit}p{margin:0}.kpi-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin:0 0 6px;break-inside:avoid-page;page-break-inside:avoid}.kpi-grid>.block{margin:0;min-width:0}.kpi{border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px;padding:${theme.reportDensity === 'compact' ? `${Math.max(4, theme.cardPadding - 4)}px ${Math.max(6, theme.cardPadding - 2)}px` : `${theme.cardPadding}px ${theme.cardPadding + 2}px`};display:flex;justify-content:space-between;gap:10px;background:${theme.surfaceColor}}.kpi-grid .kpi{height:100%;display:grid;gap:3px;align-content:center}.kpi span{color:${theme.mutedColor}}.kpi strong{font-size:${theme.reportDensity === 'compact' ? 16 : 18}px}.button{display:inline-block;background:${theme.accentColor};color:${theme.buttonTextColor};text-decoration:none;padding:10px 16px;border-radius:${theme.borderRadius}px}.image{height:auto;vertical-align:middle}hr{border:0;border-top:1px solid ${theme.borderColor};margin:${theme.reportDensity === 'compact' ? 6 : 10}px 0}.table-wrap{overflow:hidden;border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px;break-inside:avoid-page;page-break-inside:avoid}.table-wrap.allow-break{overflow:visible;break-inside:auto;page-break-inside:auto}.table-wrap.allow-break table{border-collapse:collapse}.compact-table{border-radius:${Math.min(theme.borderRadius, 6)}px}table{border-collapse:collapse;width:100%;font-size:${Math.max(9.5, theme.bodyFontSize - 2)}px}thead{display:table-header-group}tfoot{display:table-footer-group}tr{break-inside:avoid-page;page-break-inside:avoid}th,td{padding:${theme.reportDensity === 'compact' ? `${Math.max(2, theme.tableCellPadding - 3)}px ${Math.max(3, theme.tableCellPadding - 2)}px` : `${theme.tableCellPadding}px ${theme.tableCellPadding + 2}px`};text-align:left;border-bottom:1px solid ${theme.borderColor};vertical-align:top}th{background:${theme.backgroundColor};font-weight:700}.compact-table th,.compact-table td{padding:${Math.max(2, theme.tableCellPadding - 3)}px ${Math.max(3, theme.tableCellPadding - 2)}px;font-size:${Math.max(9.5, theme.bodyFontSize - 2)}px}
</style></head><body><main class="document" data-density="${theme.reportDensity}">${renderReportBlocks(blocks, data, theme)}</main></body></html>` </style></head><body><main class="document" data-density="${theme.reportDensity}">${renderReportBlocks(blocks, data, theme)}</main></body></html>`
} }
@ -250,7 +261,7 @@ function emailBlock(block, data, theme) {
switch (block.type) { switch (block.type) {
case 'heading': case 'heading':
return `<mj-text ${common} font-size="24px" font-weight="700" container-background-color="${backgroundColor}">${escapeHtml(interpolate(p.text, data))}</mj-text>` return `<mj-text ${common} font-size="${theme.headingSize}px" line-height="${theme.lineHeight}" font-weight="700" container-background-color="${backgroundColor}">${escapeHtml(interpolate(p.text, data))}</mj-text>`
case 'text': case 'text':
return `<mj-text ${common} container-background-color="${backgroundColor}">${escapeHtml(interpolate(p.text, data))}</mj-text>` return `<mj-text ${common} container-background-color="${backgroundColor}">${escapeHtml(interpolate(p.text, data))}</mj-text>`
case 'kpi': case 'kpi':
@ -270,7 +281,7 @@ export function renderEmailHtml(template, data = {}) {
const theme = themeOf(template) const theme = themeOf(template)
const body = (template?.blocks || []).map(block => emailBlock(block, data, theme)).join('\n') 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 backgroundUrl = theme.pageBackgroundImage === '#' ? '' : ` background-url="${escapeHtml(theme.pageBackgroundImage)}" background-size="cover" background-repeat="no-repeat"`
const mjml = `<mjml><mj-head><mj-attributes><mj-all font-family="${theme.fontFamily}, sans-serif" /><mj-text font-size="15px" line-height="1.5" color="${theme.textColor}" /></mj-attributes></mj-head><mj-body background-color="${theme.backgroundColor}"><mj-section background-color="${theme.surfaceColor}" padding="${theme.pagePadding}px"${backgroundUrl}><mj-column>${body}</mj-column></mj-section></mj-body></mjml>` const mjml = `<mjml><mj-head><mj-attributes><mj-all font-family="${theme.fontFamily}, sans-serif" /><mj-text font-size="${theme.baseFontSize}px" line-height="${theme.lineHeight}" color="${theme.textColor}" /></mj-attributes></mj-head><mj-body background-color="${theme.backgroundColor}"><mj-section background-color="${theme.surfaceColor}" padding="${theme.pagePadding}px"${backgroundUrl}><mj-column>${body}</mj-column></mj-section></mj-body></mjml>`
const result = mjml2html(mjml, { validationLevel: 'soft' }) const result = mjml2html(mjml, { validationLevel: 'soft' })
if (result.errors?.length) console.warn('MJML warnings', result.errors) if (result.errors?.length) console.warn('MJML warnings', result.errors)
return result.html return result.html

View File

@ -161,3 +161,50 @@ test('comfortable density preserves explicit section page breaks', () => {
assert.match(html, /data-density="comfortable"/) assert.match(html, /data-density="comfortable"/)
assert.match(html, /break-before:page/) 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)
})

View File

@ -197,6 +197,12 @@ export const themePackSpec = {
borderColor: '#d8e1e8', borderColor: '#d8e1e8',
borderRadius: 8, borderRadius: 8,
fontFamily: 'Arial', fontFamily: 'Arial',
baseFontSize: 15,
headingSize: 24,
lineHeight: 1.45,
blockGap: 10,
cardPadding: 12,
tableCellPadding: 8,
reportDensity: 'auto', reportDensity: 'auto',
pageMarginTop: 9, pageMarginTop: 9,
pageMarginRight: 9, pageMarginRight: 9,

View File

@ -107,6 +107,7 @@ export function App({ user }: Props) {
const loadedId = useRef<string | null>(null) const loadedId = useRef<string | null>(null)
const timer = useRef<number | undefined>(undefined) const timer = useRef<number | undefined>(undefined)
const previewTimer = useRef<number | undefined>(undefined)
const selected = templateDoc.blocks.find(block => block.id === selectedId) || null const selected = templateDoc.blocks.find(block => block.id === selectedId) || null
const paths = useMemo(() => flatten(sampleData), [sampleData]) const paths = useMemo(() => flatten(sampleData), [sampleData])
@ -167,6 +168,15 @@ export function App({ user }: Props) {
return () => window.clearTimeout(timer.current) return () => window.clearTimeout(timer.current)
}, [templateDoc, sampleData, active?.id]) }, [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) { async function openTemplate(id: string) {
setError('') setError('')
const remote = await api<TemplateRecord>(`/api/templates/${id}`) const remote = await api<TemplateRecord>(`/api/templates/${id}`)
@ -663,6 +673,114 @@ export function App({ user }: Props) {
</div> </div>
</section> </section>
<section className="theme-controls-card">
<div className="theme-controls-heading">
<div>
<span className="eyebrow">THEME TOKENS</span>
<h3>Typography, spacing & colors</h3>
</div>
<span>Live</span>
</div>
<div className="theme-control-section">
<span className="control-label">Font</span>
<div className="theme-font-row">
<select
value={themeString('fontFamily', 'Arial')}
onChange={event => updateTheme({ fontFamily: event.target.value })}
>
{['Arial', 'Helvetica', 'Verdana', 'Georgia', 'Times New Roman', 'system-ui'].map(font => (
<option key={font} value={font}>{font}</option>
))}
</select>
<label>
Body
<input
type="number"
min="10"
max="22"
step="1"
value={themeNumber('baseFontSize', 15)}
onChange={event => updateTheme({ baseFontSize: Number(event.target.value) })}
/>
</label>
<label>
Heading
<input
type="number"
min="14"
max="42"
step="1"
value={themeNumber('headingSize', 23)}
onChange={event => updateTheme({ headingSize: Number(event.target.value) })}
/>
</label>
</div>
<label className="token-slider">
<span>Line height</span>
<input
type="range"
min="1.1"
max="1.9"
step="0.05"
value={themeNumber('lineHeight', 1.45)}
onChange={event => updateTheme({ lineHeight: Number(event.target.value) })}
/>
<strong>{themeNumber('lineHeight', 1.45).toFixed(2)}</strong>
</label>
</div>
<div className="theme-control-section">
<span className="control-label">Spacing</span>
{[
['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]) => (
<label className="token-slider" key={String(key)}>
<span>{label}</span>
<input
type="range"
min={Number(min)}
max={Number(max)}
step="1"
value={themeNumber(String(key), Number(fallback))}
onChange={event => updateTheme({ [String(key)]: Number(event.target.value) })}
/>
<strong>{themeNumber(String(key), Number(fallback))} px</strong>
</label>
))}
</div>
<div className="theme-control-section">
<span className="control-label">Colors</span>
<div className="theme-color-grid">
{[
['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]) => (
<label className="color-token" key={String(key)}>
<span>{label}</span>
<span className="color-input-wrap">
<input
type="color"
value={themeString(String(key), String(fallback))}
onChange={event => updateTheme({ [String(key)]: event.target.value })}
/>
<code>{themeString(String(key), String(fallback)).toUpperCase()}</code>
</span>
</label>
))}
</div>
</div>
</section>
{active?.type === 'report' && ( {active?.type === 'report' && (
<section className="page-setup-card"> <section className="page-setup-card">
<div className="page-setup-heading"> <div className="page-setup-heading">

View File

@ -951,3 +951,130 @@ body{
font-weight:800; font-weight:800;
text-decoration:none; 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));
}
}