diff --git a/apps/web/src/components/admin/settings/appearance/use-design-draft.ts b/apps/web/src/components/admin/settings/appearance/use-design-draft.ts
index 190bcff52f..dfb24e3c26 100644
--- a/apps/web/src/components/admin/settings/appearance/use-design-draft.ts
+++ b/apps/web/src/components/admin/settings/appearance/use-design-draft.ts
@@ -151,6 +151,7 @@ export function useDesignDraft() {
copy: view.feedback.copy,
logo: view.logo,
statusColours: view.statusColours,
+ bandImage: view.bandImage,
}
}, [design])
diff --git a/apps/web/src/components/public/__tests__/portal-band.test.tsx b/apps/web/src/components/public/__tests__/portal-band.test.tsx
index 8ab6d43dd4..770684149c 100644
--- a/apps/web/src/components/public/__tests__/portal-band.test.tsx
+++ b/apps/web/src/components/public/__tests__/portal-band.test.tsx
@@ -120,4 +120,38 @@ describe('PortalBand', () => {
expect(band.className).toContain('[background:var(--qb-band-bg-narrow,var(--qb-band-bg))]')
expect(band.className).toContain('sm:[background:var(--qb-band-bg)]')
})
+
+ it('sets a side image beside the words', () => {
+ root.portalDesign = {
+ ...view({ title: { en: 'Ideas' }, subtitle: {} }),
+ bandImage: { url: '/api/storage/portal-images/room.jpg', place: 'side' },
+ }
+ mount()
+ const band = document.querySelector('[data-qb-part=band]')!
+ const image = band.querySelector('img[data-qb-part=band-image]')!
+ expect(image.getAttribute('src')).toBe('/api/storage/portal-images/room.jpg')
+ expect(band.hasAttribute('data-qb-band-image')).toBe(false)
+ })
+
+ it('puts a cover or pattern image behind the words, from storage only', () => {
+ root.portalDesign = {
+ ...view({ title: { en: 'Ideas' }, subtitle: {} }),
+ bandImage: { url: '/api/storage/portal-images/leaves.png', place: 'pattern' },
+ }
+ mount()
+ let band = document.querySelector('[data-qb-part=band]') as HTMLElement
+ expect(band.getAttribute('data-qb-band-image')).toBe('pattern')
+ expect(band.style.getPropertyValue('--qb-band-image')).toBe(
+ 'url("/api/storage/portal-images/leaves.png")'
+ )
+ cleanup()
+ root.portalDesign = {
+ ...view({ title: { en: 'Ideas' }, subtitle: {} }),
+ bandImage: { url: 'https://example.com/x.png");}body{', place: 'cover' },
+ }
+ mount()
+ band = document.querySelector('[data-qb-part=band]') as HTMLElement
+ expect(band.hasAttribute('data-qb-band-image')).toBe(false)
+ expect(band.getAttribute('style')).toBeNull()
+ })
})
diff --git a/apps/web/src/components/public/__tests__/portal-preview-design.test.ts b/apps/web/src/components/public/__tests__/portal-preview-design.test.ts
index 806dd4a89d..a5b3b9376b 100644
--- a/apps/web/src/components/public/__tests__/portal-preview-design.test.ts
+++ b/apps/web/src/components/public/__tests__/portal-preview-design.test.ts
@@ -29,6 +29,18 @@ describe('a draft design read from the preview message', () => {
expect(message({ statusColours: ['red'] }).statusColours).toBeNull()
})
+ it('keeps a band image only from storage, in a place it knows', () => {
+ expect(
+ message({ bandImage: { url: '/api/storage/portal-images/a.jpg', place: 'edge' } }).bandImage
+ ).toEqual({ url: '/api/storage/portal-images/a.jpg', place: 'edge' })
+ expect(
+ message({ bandImage: { url: 'https://x.example/a.jpg', place: 'edge' } }).bandImage
+ ).toBeNull()
+ expect(
+ message({ bandImage: { url: '/api/storage/a.jpg', place: 'float' } }).bandImage
+ ).toBeNull()
+ })
+
it('keeps a headline’s accent only in a shape it knows', () => {
const title = { en: 'Make it better' }
expect(
diff --git a/apps/web/src/components/public/portal-band.tsx b/apps/web/src/components/public/portal-band.tsx
index 907a4c495b..a953ad6acb 100644
--- a/apps/web/src/components/public/portal-band.tsx
+++ b/apps/web/src/components/public/portal-band.tsx
@@ -1,8 +1,8 @@
import { useIntl } from 'react-intl'
import { useRouteContext } from '@tanstack/react-router'
-import type { ReactNode } from 'react'
+import type { CSSProperties, ReactNode } from 'react'
import { cn } from '@/lib/shared/utils'
-import { usePreviewHeadline, usePreviewHome } from './preview-draft-context'
+import { usePreviewBandImage, usePreviewHeadline, usePreviewHome } from './preview-draft-context'
/** Text in the visitor's language, else its base language, else English, else any. */
export function textForLocale(
@@ -14,6 +14,20 @@ export function textForLocale(
return text[locale] || text[base] || text.en || Object.values(text).find(Boolean)
}
+/** A band image comes from the workspace's own storage. */
+const STORED_FILE = /^\/api\/storage\/[\w./-]+$/
+
+/**
+ * The band's image behind its words: along the bottom (on wider screens),
+ * veiled by the band's colour so the words keep their contrast, or tiled
+ * under a lighter veil.
+ */
+const IMAGE_FILL = [
+ 'sm:[&[data-qb-band-image=edge]]:[background:var(--qb-band-image)_center_bottom/100%_auto_no-repeat,var(--qb-band-bg)] sm:[&[data-qb-band-image=edge]]:pb-44',
+ '[&[data-qb-band-image=cover]]:[background:linear-gradient(var(--qb-band-scrim),var(--qb-band-scrim)),var(--qb-band-image)_center/cover_no-repeat,var(--qb-band-bg)]',
+ '[&[data-qb-band-image=pattern]]:[background:linear-gradient(var(--qb-band-scrim-soft),var(--qb-band-scrim-soft)),var(--qb-band-image)_0_0/280px_auto_repeat,var(--qb-band-bg)]',
+].join(' ')
+
/** The band's colour: plain on phones where the design asks, as a mesh crosses the words there. */
const BAND_FILL =
'[background:var(--qb-band-bg-narrow,var(--qb-band-bg))] sm:[background:var(--qb-band-bg)]'
@@ -64,6 +78,7 @@ export function PortalBand({
})
const draftHeadline = usePreviewHeadline()
const draftHome = usePreviewHome()
+ const draftImage = usePreviewBandImage()
if (design?.engine !== 'v2') return null
const headline =
page === 'home'
@@ -75,13 +90,21 @@ export function PortalBand({
const subtitle = textForLocale(headline?.subtitle, intl.locale)
const phrase = textForLocale(headline?.accent?.text, intl.locale)
if (!title && !subtitle && !children) return null
+ const shown = draftImage !== undefined ? draftImage : design.bandImage
+ const image = shown && STORED_FILE.test(shown.url) ? shown : null
+ const side = image?.place === 'side'
return (
{/* Panel: the words sit on a block of the second colour, in its own text colours. */}
@@ -115,6 +140,14 @@ export function PortalBand({
)}
+ {side && image && (
+
+ )}
)
diff --git a/apps/web/src/components/public/portal-preview-design.ts b/apps/web/src/components/public/portal-preview-design.ts
index 2d571d54ad..6bdbce37d2 100644
--- a/apps/web/src/components/public/portal-preview-design.ts
+++ b/apps/web/src/components/public/portal-preview-design.ts
@@ -6,7 +6,7 @@
* shared code into a chunk of its own.
*/
-import type { DesignLogo, PortalWords } from '@/lib/shared/design/view'
+import type { BandImage, DesignLogo, PortalWords } from '@/lib/shared/design/view'
type TextByLocale = Record
@@ -41,6 +41,17 @@ export interface DesignPreview {
logo: DesignLogo | null
/** The logo's colours status markers take, when the draft asks for them. */
statusColours: string[] | null
+ bandImage: BandImage | null
+}
+
+const IMAGE_PLACES = new Set(['side', 'edge', 'cover', 'pattern'])
+
+function readBandImage(value: unknown): BandImage | null {
+ if (!value || typeof value !== 'object') return null
+ const { url, place } = value as Record
+ return typeof url === 'string' && STORED_FILE.test(url) && IMAGE_PLACES.has(place as string)
+ ? { url, place: place as BandImage['place'] }
+ : null
}
/** A colour as a design stores it: hex, or oklch(). */
@@ -157,8 +168,19 @@ const DESIGN_VALUE = /^[a-z_]+$/
/** The message's design, keeping only the structural attributes a design sets. */
export function readDesignPreview(value: unknown): DesignPreview | null {
if (!value || typeof value !== 'object') return null
- const { css, attributes, fontIds, headline, home, nav, vocabulary, copy, logo, statusColours } =
- value as Record
+ const {
+ css,
+ attributes,
+ fontIds,
+ headline,
+ home,
+ nav,
+ vocabulary,
+ copy,
+ logo,
+ statusColours,
+ bandImage,
+ } = value as Record
if (typeof css !== 'string') return null
const safe = Object.fromEntries(
Object.entries((attributes as Record) ?? {}).filter(
@@ -177,6 +199,7 @@ export function readDesignPreview(value: unknown): DesignPreview | null {
copy: readCopy(copy),
logo: readLogo(logo),
statusColours: readStatusColours(statusColours),
+ bandImage: readBandImage(bandImage),
}
}
diff --git a/apps/web/src/components/public/portal-preview-listener.tsx b/apps/web/src/components/public/portal-preview-listener.tsx
index 4990f84863..c4da4df06e 100644
--- a/apps/web/src/components/public/portal-preview-listener.tsx
+++ b/apps/web/src/components/public/portal-preview-listener.tsx
@@ -6,6 +6,7 @@ import {
PreviewDraftProvider,
PreviewAttributesContext,
PreviewLogoContext,
+ PreviewBandImageContext,
PreviewHeadlineContext,
PreviewPagesContext,
FeedCopyContext,
@@ -153,13 +154,15 @@ export function PortalPreviewProvider({
-
- {design?.words ? (
- {children}
- ) : (
- children
- )}
-
+
+
+ {design?.words ? (
+ {children}
+ ) : (
+ children
+ )}
+
+
diff --git a/apps/web/src/components/public/preview-draft-context.tsx b/apps/web/src/components/public/preview-draft-context.tsx
index 00f47cf1fb..8d9f147479 100644
--- a/apps/web/src/components/public/preview-draft-context.tsx
+++ b/apps/web/src/components/public/preview-draft-context.tsx
@@ -1,7 +1,7 @@
import { createContext, useContext, useMemo, type ReactNode } from 'react'
import { useIntl } from 'react-intl'
import type { PortalDesign } from '@/lib/shared/design/schema'
-import type { DesignLogo } from '@/lib/shared/design/view'
+import type { BandImage, DesignLogo } from '@/lib/shared/design/view'
import type { PortalNavConfig, PortalWelcomeCard } from '@/lib/shared/types/settings'
/**
@@ -138,6 +138,13 @@ export function useFeedCopy(): { prompt?: string; submit?: string; emptyTitle?:
)
}
+export const PreviewBandImageContext = createContext(undefined)
+
+/** A draft design's band image: undefined outside a design preview, null when it has none. */
+export function usePreviewBandImage(): BandImage | null | undefined {
+ return useContext(PreviewBandImageContext)
+}
+
export const PreviewLogoContext = createContext(undefined)
/** A draft design's logo, or undefined (always, outside a design preview). */
diff --git a/apps/web/src/lib/server/functions/__tests__/design-image-uploads.test.ts b/apps/web/src/lib/server/functions/__tests__/design-image-uploads.test.ts
new file mode 100644
index 0000000000..3e18951b8d
--- /dev/null
+++ b/apps/web/src/lib/server/functions/__tests__/design-image-uploads.test.ts
@@ -0,0 +1,76 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@tanstack/react-start', () => ({
+ createServerFn: () => {
+ let handler: ((args: { data: unknown }) => Promise) | null = null
+ const fn = (args: { data: unknown }) => handler!(args)
+ fn.validator = () => fn
+ fn.handler = (h: typeof handler) => {
+ handler = h
+ return fn
+ }
+ return fn
+ },
+}))
+
+vi.mock('../../storage/s3', () => ({
+ isS3Usable: vi.fn(() => true),
+ generatePresignedUploadUrl: vi.fn(async (key: string, contentType: string) => ({
+ uploadUrl: `https://s3.example.com/${key}?presigned`,
+ key,
+ contentType,
+ })),
+ generateStorageKey: vi.fn((prefix: string, filename: string) => `${prefix}/${filename}`),
+ isAllowedImageType: vi.fn((type: string) =>
+ ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(type)
+ ),
+ MAX_FILE_SIZE: 10 * 1024 * 1024,
+}))
+
+const requireAuth = vi.hoisted(() => vi.fn())
+vi.mock('../auth-helpers', () => ({ requireAuth }))
+vi.mock('../widget-auth', () => ({ getWidgetSession: vi.fn() }))
+
+import { PERMISSIONS } from '@/lib/shared/permissions'
+import { getBandImageUploadUrlFn, getLogoUploadUrlFn, getWordmarkUploadUrlFn } from '../uploads'
+
+const file = (filename: string, contentType: string) => ({
+ data: { filename, contentType, fileSize: 2000 },
+})
+
+beforeEach(() => vi.clearAllMocks())
+
+describe('a design’s wordmark upload', () => {
+ it('takes an SVG, as most logos are, beside the usual images', async () => {
+ const svg = await getWordmarkUploadUrlFn(file('wordmark.svg', 'image/svg+xml'))
+ expect(svg).toMatchObject({ key: 'header-logos/wordmark.svg', contentType: 'image/svg+xml' })
+ await expect(getWordmarkUploadUrlFn(file('w.png', 'image/png'))).resolves.toMatchObject({
+ key: 'header-logos/w.png',
+ })
+ expect(requireAuth).toHaveBeenCalledWith({ permission: PERMISSIONS.SETTINGS_MANAGE })
+ })
+
+ it('still refuses what is not an image', async () => {
+ await expect(getWordmarkUploadUrlFn(file('page.html', 'text/html'))).rejects.toThrow(
+ 'Invalid image type'
+ )
+ })
+})
+
+describe('other image uploads', () => {
+ it('keep refusing SVG: the workspace logo also goes in emails', async () => {
+ await expect(getLogoUploadUrlFn(file('logo.svg', 'image/svg+xml'))).rejects.toThrow(
+ 'Invalid image type'
+ )
+ })
+
+ it('take a band image as a photo, never an SVG', async () => {
+ await expect(getBandImageUploadUrlFn(file('room.jpg', 'image/jpeg'))).resolves.toMatchObject({
+ key: 'portal-images/room.jpg',
+ })
+ await expect(getBandImageUploadUrlFn(file('x.svg', 'image/svg+xml'))).rejects.toThrow(
+ 'Invalid image type'
+ )
+ expect(requireAuth).toHaveBeenCalledWith({ permission: PERMISSIONS.SETTINGS_MANAGE })
+ })
+})
diff --git a/apps/web/src/lib/server/functions/uploads.ts b/apps/web/src/lib/server/functions/uploads.ts
index ce2627dd2b..17ce4656fc 100644
--- a/apps/web/src/lib/server/functions/uploads.ts
+++ b/apps/web/src/lib/server/functions/uploads.ts
@@ -55,7 +55,15 @@ const imageUploadSchema = z.object({
*/
async function presignedImageUpload(
data: z.infer,
- opts: { label: string; prefix: string }
+ opts: {
+ label: string
+ prefix: string
+ /**
+ * Also SVG, for a wordmark. Storage serves SVG as a sandboxed download,
+ * which an
still shows, so it never runs as a page.
+ */
+ svg?: boolean
+ }
) {
log.debug(
{ content_type: data.contentType, file_size: data.fileSize },
@@ -66,8 +74,11 @@ async function presignedImageUpload(
throw new Error('File storage is not configured. Contact your administrator.')
}
- if (!isAllowedImageType(data.contentType)) {
- throw new Error(`Invalid image type: ${data.contentType}. Allowed types: JPEG, PNG, GIF, WebP.`)
+ const svg = opts.svg === true && data.contentType === 'image/svg+xml'
+ if (!svg && !isAllowedImageType(data.contentType)) {
+ throw new Error(
+ `Invalid image type: ${data.contentType}. Allowed types: JPEG, PNG, GIF, WebP${opts.svg ? ', SVG' : ''}.`
+ )
}
const key = generateStorageKey(opts.prefix, data.filename)
@@ -217,6 +228,27 @@ export const getHeaderLogoUploadUrlFn = createServerFn({ method: 'POST' })
return presignedImageUpload(data, { label: 'header logo', prefix: 'header-logos' })
})
+/**
+ * Get a presigned URL for uploading a portal design's wordmark, light or dark.
+ * Unlike the workspace logo, which emails carry, it may be an SVG.
+ */
+export const getWordmarkUploadUrlFn = createServerFn({ method: 'POST' })
+ .validator(imageUploadSchema)
+ .handler(async ({ data }) => {
+ await requireAuth({ permission: PERMISSIONS.SETTINGS_MANAGE })
+ return presignedImageUpload(data, { label: 'wordmark', prefix: 'header-logos', svg: true })
+ })
+
+/**
+ * Get a presigned URL for uploading the image a portal design shows in its band.
+ */
+export const getBandImageUploadUrlFn = createServerFn({ method: 'POST' })
+ .validator(imageUploadSchema)
+ .handler(async ({ data }) => {
+ await requireAuth({ permission: PERMISSIONS.SETTINGS_MANAGE })
+ return presignedImageUpload(data, { label: 'band image', prefix: 'portal-images' })
+ })
+
/**
* Get a presigned URL for uploading a custom OIDC identity provider's logo.
*/
diff --git a/apps/web/src/lib/server/policy/authz-matrix/MATRIX.md b/apps/web/src/lib/server/policy/authz-matrix/MATRIX.md
index fb7d3f5680..343bf2fa17 100644
--- a/apps/web/src/lib/server/policy/authz-matrix/MATRIX.md
+++ b/apps/web/src/lib/server/policy/authz-matrix/MATRIX.md
@@ -101,7 +101,7 @@ Profiles: **Owner** = admin class + an admin-owned full API key (scoped keys hol
## 2. Surfaces and their enforced authorization
-### Server functions (`requireAuth`) — 768 surfaces
+### Server functions (`requireAuth`) — 770 surfaces
| Surface | Enforces |
| --- | --- |
@@ -808,6 +808,8 @@ Profiles: **Owner** = admin class + an admin-owned full API key (scoped keys hol
| `lib/server/functions/uploads.ts`::getLogoUploadUrlFn | settings.manage |
| `lib/server/functions/uploads.ts`::getFaviconUploadUrlFn | settings.manage |
| `lib/server/functions/uploads.ts`::getHeaderLogoUploadUrlFn | settings.manage |
+| `lib/server/functions/uploads.ts`::getWordmarkUploadUrlFn | settings.manage |
+| `lib/server/functions/uploads.ts`::getBandImageUploadUrlFn | settings.manage |
| `lib/server/functions/uploads.ts`::getIdentityProviderLogoUploadUrlFn | auth.manage |
| `lib/server/functions/uploads.ts`::getWidgetHeroUploadUrlFn | settings.manage |
| `lib/server/functions/uploads.ts`::getAvatarUploadUrlFn | END_USER (any authenticated) |
@@ -1103,7 +1105,7 @@ Key scopes are enforced: an API key holds exactly its stored scopes (owner permi
## 4. Entry points without a requireAuth/key gate
-228 of 1118 entry points hold no `requireAuth` / `withApiKeyAuth` / `requireTeamAuth` gate.
+228 of 1120 entry points hold no `requireAuth` / `withApiKeyAuth` / `requireTeamAuth` gate.
Each is expected to be intentionally public, a pre-auth flow, a signature-verified webhook, or a handler that delegates auth (e.g. the MCP route).
**Adding a row here is an access-control change** — confirm the new entry point is meant to be reachable without a gate.
diff --git a/apps/web/src/lib/shared/design/__tests__/design-view.test.ts b/apps/web/src/lib/shared/design/__tests__/design-view.test.ts
index de29311cee..b3d594df82 100644
--- a/apps/web/src/lib/shared/design/__tests__/design-view.test.ts
+++ b/apps/web/src/lib/shared/design/__tests__/design-view.test.ts
@@ -59,6 +59,18 @@ describe('designView', () => {
expect(designView(doc, 1).statusColours).toBeNull()
})
+ it('carries the band image and where it sits, beside the words unless it says', () => {
+ const doc = defaultDesign()
+ expect(designView(doc, 1).bandImage).toBeNull()
+ doc.brand.bandImage = { key: 'portal-images/room.jpg' }
+ expect(designView(doc, 1).bandImage).toEqual({
+ url: '/api/storage/portal-images/room.jpg',
+ place: 'side',
+ })
+ doc.layout.imagePlace = 'pattern'
+ expect(designView(doc, 1).bandImage?.place).toBe('pattern')
+ })
+
it('carries a name the header shows in place of the workspace’s', () => {
const doc = defaultDesign()
doc.brand.logo = { ...doc.brand.logo, display: 'name', name: 'Harbor & Pine' }
diff --git a/apps/web/src/lib/shared/design/__tests__/fixtures/bramble.json b/apps/web/src/lib/shared/design/__tests__/fixtures/bramble.json
index d25f63e0ff..35e5e8ed1c 100644
--- a/apps/web/src/lib/shared/design/__tests__/fixtures/bramble.json
+++ b/apps/web/src/lib/shared/design/__tests__/fixtures/bramble.json
@@ -38,7 +38,10 @@
"favicon": {
"key": "fixtures/bramble/favicon.png"
},
- "shareImage": null
+ "shareImage": null,
+ "bandImage": {
+ "key": "portal-images/bramble-kitchen.jpg"
+ }
},
"shape": {
"corners": "pill"
diff --git a/apps/web/src/lib/shared/design/__tests__/fixtures/fieldnote.json b/apps/web/src/lib/shared/design/__tests__/fixtures/fieldnote.json
index 809019afcc..3ff49df928 100644
--- a/apps/web/src/lib/shared/design/__tests__/fixtures/fieldnote.json
+++ b/apps/web/src/lib/shared/design/__tests__/fixtures/fieldnote.json
@@ -37,7 +37,10 @@
"favicon": {
"key": "fixtures/fieldnote/favicon.png"
},
- "shareImage": null
+ "shareImage": null,
+ "bandImage": {
+ "key": "portal-images/fieldnote-grid.png"
+ }
},
"shape": {
"corners": "subtle"
@@ -49,7 +52,8 @@
"cards": "list",
"density": "compact",
"width": "default",
- "filters": "end"
+ "filters": "end",
+ "imagePlace": "pattern"
},
"pages": {
"home": {
diff --git a/apps/web/src/lib/shared/design/__tests__/fixtures/ledger.json b/apps/web/src/lib/shared/design/__tests__/fixtures/ledger.json
index 986dbb268c..7c5f3a2cd0 100644
--- a/apps/web/src/lib/shared/design/__tests__/fixtures/ledger.json
+++ b/apps/web/src/lib/shared/design/__tests__/fixtures/ledger.json
@@ -41,7 +41,10 @@
"favicon": {
"key": "fixtures/ledger/favicon.png"
},
- "shareImage": null
+ "shareImage": null,
+ "bandImage": {
+ "key": "portal-images/ledger-skyline.png"
+ }
},
"shape": {
"corners": "sharp"
@@ -53,7 +56,8 @@
"cards": "bordered",
"density": "comfortable",
"width": "narrow",
- "filters": "end"
+ "filters": "end",
+ "imagePlace": "edge"
},
"pages": {
"home": {
diff --git a/apps/web/src/lib/shared/design/__tests__/fixtures/plume.json b/apps/web/src/lib/shared/design/__tests__/fixtures/plume.json
index c5b1f2d512..e0a79f962e 100644
--- a/apps/web/src/lib/shared/design/__tests__/fixtures/plume.json
+++ b/apps/web/src/lib/shared/design/__tests__/fixtures/plume.json
@@ -43,7 +43,10 @@
"favicon": {
"key": "fixtures/plume/favicon.png"
},
- "shareImage": null
+ "shareImage": null,
+ "bandImage": {
+ "key": "portal-images/plume-desk.jpg"
+ }
},
"shape": {
"corners": "rounded"
@@ -55,7 +58,8 @@
"cards": "list",
"density": "spacious",
"width": "default",
- "filters": "start"
+ "filters": "start",
+ "imagePlace": "cover"
},
"pages": {
"home": {
diff --git a/apps/web/src/lib/shared/design/__tests__/reference-brands.test.ts b/apps/web/src/lib/shared/design/__tests__/reference-brands.test.ts
index 2ffb964194..82696a12ae 100644
--- a/apps/web/src/lib/shared/design/__tests__/reference-brands.test.ts
+++ b/apps/web/src/lib/shared/design/__tests__/reference-brands.test.ts
@@ -26,6 +26,7 @@ interface Fixture {
labelCase?: string
}
logo: { display: string }
+ bandImage?: { key: string }
}
shape: { corners: string; buttons?: string }
layout: Record<'header' | 'nav' | 'band' | 'cards' | 'density' | 'width' | 'filters', string> & {
@@ -33,6 +34,7 @@ interface Fixture {
bandAlign?: string
bandShape?: string
status?: string
+ imagePlace?: string
}
pages: {
home: {
@@ -94,6 +96,7 @@ function used(): Record> {
add('bandShape', doc.layout.bandShape ?? 'full')
add('buttons', doc.shape.buttons ?? 'match')
add('status', doc.layout.status ?? 'colour')
+ if (doc.brand.bandImage) add('imagePlace', doc.layout.imagePlace ?? 'side')
if (doc.pages.home.blocks.some((block) => block.type === 'composer'))
add('composerPlace', doc.pages.home.composer ?? 'block')
add('sort', doc.pages.feedback.defaultSort)
diff --git a/apps/web/src/lib/shared/design/__tests__/view.test.ts b/apps/web/src/lib/shared/design/__tests__/view.test.ts
index eb0635ba03..8f9a80f0df 100644
--- a/apps/web/src/lib/shared/design/__tests__/view.test.ts
+++ b/apps/web/src/lib/shared/design/__tests__/view.test.ts
@@ -55,6 +55,7 @@ describe('portalTheme', () => {
wordmark: { light: null, dark: null },
},
statusColours: null,
+ bandImage: null,
}
expect(portalTheme({ brandingConfig, customCss: '.x {}', portalDesign })).toEqual({
themeMode: 'user',
@@ -87,6 +88,7 @@ describe('portalFontIds', () => {
wordmark: { light: null, dark: null },
},
statusColours: null,
+ bandImage: null,
}
expect(portalFontIds({ portalDesign })).toEqual(['inter', 'fraunces'])
})
@@ -124,6 +126,7 @@ describe('portalFontLinks', () => {
wordmark: { light: null, dark: null },
},
statusColours: null,
+ bandImage: null,
}
expect(portalFontLinks({ portalDesign })).toEqual([
{
@@ -163,6 +166,7 @@ describe('portalThemeMode', () => {
wordmark: { light: null, dark: null },
},
statusColours: null,
+ bandImage: null,
},
})
expect(portalThemeMode(v2('auto'))).toBe('user')
@@ -222,6 +226,7 @@ describe('portalDefaultSort', () => {
wordmark: { light: null, dark: null },
},
statusColours: null,
+ bandImage: null,
}
expect(portalDefaultSort({ portalDesign })).toBe('new')
})
diff --git a/apps/web/src/lib/shared/design/components.ts b/apps/web/src/lib/shared/design/components.ts
index 8902b5c48a..689b17358a 100644
--- a/apps/web/src/lib/shared/design/components.ts
+++ b/apps/web/src/lib/shared/design/components.ts
@@ -206,6 +206,9 @@ function bandTokens(doc: PortalDesign, t: Required, mode: Mode):
// A headline's accent phrase: the brand colour, moved until it reads at
// 3:1 as large text wherever the headline sits, or the band's ink.
'--qb-band-accent': solveContrast(t.primary, surface.behind, 3) ?? surface.ink,
+ // Over an image, the band's colour veils it so the words keep their contrast.
+ '--qb-band-scrim': `color-mix(in oklch, ${surface.behind[0]} 85%, transparent)`,
+ '--qb-band-scrim-soft': `color-mix(in oklch, ${surface.behind[0]} 60%, transparent)`,
'--qb-band-highlight': mixColor(
support ?? t.primary,
surface.behind[0]!,
diff --git a/apps/web/src/lib/shared/design/design-view.ts b/apps/web/src/lib/shared/design/design-view.ts
index 84bedb6e57..c16193cfae 100644
--- a/apps/web/src/lib/shared/design/design-view.ts
+++ b/apps/web/src/lib/shared/design/design-view.ts
@@ -12,7 +12,7 @@ import { rewritePartAliases } from './parts'
import { designFontIds, fontStack, uploadedFontFaces } from './fonts'
import { renderDesignCss, resolveDesign } from './resolve'
import type { PortalDesign } from './schema'
-import type { DesignLogo, PortalDesignView } from './view'
+import type { BandImage, DesignLogo, PortalDesignView } from './view'
import { isEmptyTiptapDoc } from '@/lib/shared/utils/is-empty-tiptap-doc'
import type { TiptapContent } from '@/lib/shared/db-types'
@@ -41,6 +41,7 @@ export function designView(
nav: doc.nav,
vocabulary: doc.vocabulary,
logo: designLogo(doc),
+ bandImage: bandImage(doc),
statusColours:
doc.layout.status === 'brand' && doc.brand.logoColours?.length ? doc.brand.logoColours : null,
}
@@ -49,6 +50,11 @@ export function designView(
/** Where the portal loads a stored brand file from: its own origin. */
export const designAssetUrl = (key: string) => `/api/storage/${key}`
+function bandImage(doc: PortalDesign): BandImage | null {
+ const image = doc.brand.bandImage
+ return image ? { url: designAssetUrl(image.key), place: doc.layout.imagePlace ?? 'side' } : null
+}
+
function designLogo(doc: PortalDesign): DesignLogo {
const { display, name, wordmark } = doc.brand.logo
return {
diff --git a/apps/web/src/lib/shared/design/options.ts b/apps/web/src/lib/shared/design/options.ts
index 854ba78bea..d62d873331 100644
--- a/apps/web/src/lib/shared/design/options.ts
+++ b/apps/web/src/lib/shared/design/options.ts
@@ -33,6 +33,8 @@ export const DESIGN_OPTIONS = {
bandAlign: ['left', 'centre'],
/** The band across the page, as a rounded panel within it, or with its words on a panel. */
bandShape: ['full', 'inset', 'panel'],
+ /** Where the band's image sits: beside the words, along its bottom, behind them, or tiled. */
+ imagePlace: ['side', 'edge', 'cover', 'pattern'],
/** Home's composer: a block on the grid, or in the band under the headline. */
composerPlace: ['block', 'band'],
cards: ['bordered', 'raised', 'list'],
diff --git a/apps/web/src/lib/shared/design/parts.ts b/apps/web/src/lib/shared/design/parts.ts
index 077ab88427..1b92cac116 100644
--- a/apps/web/src/lib/shared/design/parts.ts
+++ b/apps/web/src/lib/shared/design/parts.ts
@@ -51,6 +51,13 @@ export const PORTAL_PARTS = [
tokens: ['--qb-band-bg', '--qb-band-ink', '--qb-band-muted'],
files: ['components/public/portal-band.tsx'],
},
+ {
+ name: 'band-image',
+ description: "The band's image, when it sits beside the words.",
+ states: [],
+ tokens: ['--qb-radius-panel'],
+ files: ['components/public/portal-band.tsx'],
+ },
{
name: 'headline',
description: "A page's headline, in the band.",
diff --git a/apps/web/src/lib/shared/design/schema.ts b/apps/web/src/lib/shared/design/schema.ts
index c5bba47625..17a92d7eab 100644
--- a/apps/web/src/lib/shared/design/schema.ts
+++ b/apps/web/src/lib/shared/design/schema.ts
@@ -210,6 +210,8 @@ export const portalDesignV2 = z
name: z.string().trim().max(60).optional(),
})
.strict(),
+ /** An image for the band, such as a photo or a pattern. */
+ bandImage: asset.optional(),
/** The logo's own colours, for status markers in the brand's colours. */
logoColours: z.array(color).max(6).optional(),
favicon: asset.nullable(),
@@ -238,6 +240,8 @@ export const portalDesignV2 = z
headerOnBand: z.boolean().optional(),
/** Absent is coloured dots. */
status: z.enum(DESIGN_OPTIONS.status).optional(),
+ /** Where the band's image sits; absent is beside the words. */
+ imagePlace: z.enum(DESIGN_OPTIONS.imagePlace).optional(),
cards: z.enum(DESIGN_OPTIONS.cards),
density: z.enum(DESIGN_OPTIONS.density),
width: z.enum(DESIGN_OPTIONS.width),
diff --git a/apps/web/src/lib/shared/design/view.ts b/apps/web/src/lib/shared/design/view.ts
index 3371c47708..3cba199c3f 100644
--- a/apps/web/src/lib/shared/design/view.ts
+++ b/apps/web/src/lib/shared/design/view.ts
@@ -54,8 +54,15 @@ export type PortalDesignView =
logo: DesignLogo
/** The logo's colours that status markers take, when the design asks for them. */
statusColours: string[] | null
+ /** The band's image and where it sits, or null. */
+ bandImage: BandImage | null
}
+export interface BandImage {
+ url: string
+ place: NonNullable
+}
+
/**
* The header's logo: the display, the name it shows when not the workspace's,
* and the wordmark files' addresses, for light and dark.
diff --git a/apps/web/src/routes/_portal/__tests__/feedback.viewer-change.test.tsx b/apps/web/src/routes/_portal/__tests__/feedback.viewer-change.test.tsx
index abe1e06cbf..419842707b 100644
--- a/apps/web/src/routes/_portal/__tests__/feedback.viewer-change.test.tsx
+++ b/apps/web/src/routes/_portal/__tests__/feedback.viewer-change.test.tsx
@@ -70,6 +70,7 @@ vi.mock('@/components/public/preview-draft-context', () => ({
usePreviewWelcomeCard: () => null,
usePreviewHeadline: () => undefined,
usePreviewHome: () => undefined,
+ usePreviewBandImage: () => undefined,
}))
vi.mock('@/components/public/feedback/portal-welcome-card', () => ({
PortalWelcomeCard: () => null,