diff --git a/docs/template-pack.md b/docs/template-pack.md
index 3197ee3..11a2899 100644
--- a/docs/template-pack.md
+++ b/docs/template-pack.md
@@ -37,6 +37,9 @@ my-pack.zip
"pageMarginLeft": 12,
"pagePadding": 18,
"pageBackgroundImage": "asset://paper-bg",
+ "pageBackgroundMode": "none",
+ "pageBackgroundOpacity": 0.08,
+ "decorativeBorder": false,
"borderImage": "asset://page-border"
},
"blocks": [
@@ -118,6 +121,12 @@ Report themes can set A4 print margins independently in millimetres with `pageMa
`pagePadding` is separate: it controls the internal spacing inside the rendered document/frame in pixels and defaults to 18 px.
+### Page artwork
+
+Reports default to a clean page even if the ZIP includes background or border assets. Set `pageBackgroundMode` to `"watermark"` or `"full"` to opt in to the background image. `pageBackgroundOpacity` controls artwork strength from 0 to 0.35.
+
+Decorative border artwork is also opt-in with `decorativeBorder: true`. This prevents stationery-style page chrome from appearing automatically on operational reports.
+
### Styling
The constrained theme supports colors, border radius, background imagery and report border imagery. Blocks may also include a `style` object with background/text/border colors, border width/radius, padding and alignment. Arbitrary CSS/HTML/JavaScript is intentionally not supported.
diff --git a/server/templateEngine.js b/server/templateEngine.js
index d01c37e..033f63c 100644
--- a/server/templateEngine.js
+++ b/server/templateEngine.js
@@ -47,6 +47,10 @@ function safeFont(value) {
return allowed.includes(font) ? font : 'Arial'
}
+function safeBackgroundMode(value) {
+ return ['none', 'watermark', 'full'].includes(value) ? value : 'none'
+}
+
function themeOf(template) {
const theme = template?.theme || {}
return {
@@ -65,6 +69,9 @@ function themeOf(template) {
pageMarginLeft: safeNumber(theme.pageMarginLeft, 12, 0, 40),
fontFamily: safeFont(theme.fontFamily),
pageBackgroundImage: safeUrl(theme.pageBackgroundImage, {}),
+ pageBackgroundMode: safeBackgroundMode(theme.pageBackgroundMode),
+ pageBackgroundOpacity: safeNumber(theme.pageBackgroundOpacity, 0.08, 0, 0.35),
+ decorativeBorder: theme.decorativeBorder === true,
borderImage: safeUrl(theme.borderImage, {})
}
}
@@ -176,13 +183,16 @@ function blockHtml(block, data, theme) {
export function renderReportHtml(template, data = {}) {
const blocks = template?.blocks || []
const theme = themeOf(template)
- const backgroundImage = theme.pageBackgroundImage === '#' ? 'none' : `url("${escapeHtml(theme.pageBackgroundImage)}")`
- const borderImage = theme.borderImage === '#' ? 'none' : `url("${escapeHtml(theme.borderImage)}") 30 stretch`
+ const hasBackground = theme.pageBackgroundImage !== '#' && theme.pageBackgroundMode !== 'none'
+ const backgroundImage = hasBackground ? `url("${escapeHtml(theme.pageBackgroundImage)}")` : 'none'
+ const borderEnabled = theme.decorativeBorder && theme.borderImage !== '#'
+ const borderImage = borderEnabled ? `url("${escapeHtml(theme.borderImage)}") 30 stretch` : 'none'
+ const backgroundSize = theme.pageBackgroundMode === 'full' ? 'cover' : '100% auto'
return `
${blocks.map(block => blockHtml(block, data, theme)).join('\n')}`
}
diff --git a/server/templateEngine.test.js b/server/templateEngine.test.js
index 95e43df..1b5acd0 100644
--- a/server/templateEngine.test.js
+++ b/server/templateEngine.test.js
@@ -80,3 +80,31 @@ test('report renderer applies independent page margins in millimetres', () => {
assert.match(html, /min-height:calc\(297mm - 22mm\)/)
assert.match(html, /padding:22px/)
})
+
+test('report page chrome defaults to clean without stacked artwork', () => {
+ const html = renderReportHtml({
+ theme: {
+ pageBackgroundImage: 'https://example.com/paper.webp',
+ borderImage: 'https://example.com/border.png'
+ },
+ blocks: [{ type: 'text', props: { text: 'Clean report' } }]
+ }, {})
+ assert.match(html, /background-image:none/)
+ assert.match(html, /border:0px solid transparent/)
+})
+
+test('report page chrome can opt into subtle background and decorative border', () => {
+ const html = renderReportHtml({
+ theme: {
+ pageBackgroundImage: 'https://example.com/paper.webp',
+ pageBackgroundMode: 'watermark',
+ pageBackgroundOpacity: 0.1,
+ borderImage: 'https://example.com/border.png',
+ decorativeBorder: true
+ },
+ blocks: [{ type: 'text', props: { text: 'Styled report' } }]
+ }, {})
+ assert.match(html, /paper\.webp/)
+ assert.match(html, /opacity:0\.1/)
+ assert.match(html, /border:10px solid transparent/)
+})
diff --git a/server/templatePack.js b/server/templatePack.js
index 8d380b6..a9c4de8 100644
--- a/server/templatePack.js
+++ b/server/templatePack.js
@@ -236,6 +236,9 @@ export const templatePackSpec = {
pageMarginLeft: 12,
pagePadding: 18,
pageBackgroundImage: 'asset://paper-bg',
+ pageBackgroundMode: 'none',
+ pageBackgroundOpacity: 0.08,
+ decorativeBorder: false,
borderImage: 'asset://page-border'
},
blocks: [
diff --git a/src/App.tsx b/src/App.tsx
index 7932577..278f428 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -215,6 +215,16 @@ export function App({ user }: Props) {
return Number.isFinite(value) ? value : fallback
}
+ function themeString(key: string, fallback: string) {
+ const value = templateDoc.theme?.[key]
+ return typeof value === 'string' ? value : fallback
+ }
+
+ function themeBoolean(key: string, fallback = false) {
+ const value = templateDoc.theme?.[key]
+ return typeof value === 'boolean' ? value : fallback
+ }
+
function moveBlock(id: string, direction: -1 | 1) {
setTemplateDoc(current => {
const blocks = [...current.blocks]
@@ -567,12 +577,62 @@ export function App({ user }: Props) {
/>
{themeNumber('pagePadding', 18)} px
+
+
+
Page artwork
+
+ {[
+ ['none', 'Clean'],
+ ['watermark', 'Subtle'],
+ ['full', 'Full']
+ ].map(([value, label]) => (
+
+ ))}
+
+
+
+ {themeString('pageBackgroundMode', 'none') !== 'none' && (
+
+ )}
+
+
+
)}
diff --git a/src/refine.css b/src/refine.css
index 76996ce..5c457ce 100644
--- a/src/refine.css
+++ b/src/refine.css
@@ -788,3 +788,48 @@ body{
padding:0;
font-size:10px;
}
+
+.page-decoration-control{
+ display:grid;
+ gap:7px;
+ margin-top:12px;
+}
+.control-label{
+ color:#59677a;
+ font-size:10px;
+ font-weight:800;
+}
+.segmented.compact{
+ margin:0;
+}
+.segmented.compact button{
+ padding:8px 9px;
+ font-size:10px;
+}
+.toggle-row{
+ display:flex!important;
+ align-items:center;
+ justify-content:space-between;
+ gap:14px;
+ margin-top:12px;
+ padding:10px 0 0;
+ border-top:1px solid #edf1f5;
+}
+.toggle-row>span{
+ display:grid;
+ gap:2px;
+}
+.toggle-row strong{
+ color:#263449;
+ font-size:11px;
+}
+.toggle-row small{
+ color:#8994a4;
+ font-size:9px;
+ font-weight:500;
+}
+.toggle-row input{
+ width:18px;
+ height:18px;
+ flex:0 0 auto;
+}