From b8a474f33a32ead9376d9f3bd115184bc7aaaaa2 Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 13:10:06 +0000 Subject: [PATCH 1/2] feat(design): SVG wordmarks, and an image in the band A design's wordmark can now be uploaded as an SVG, the format most logos come in. Storage serves SVG as a sandboxed download, which an image tag still shows, so it never runs as a page; the workspace logo, which emails carry, keeps to raster images. A design can set an image in its band: beside the words (under them on phones), along the band's bottom on wider screens, behind the words under a veil of the band's colour, or tiled as a pattern under a lighter veil. Only images from the workspace's own storage are shown, and the admin preview follows the draft's image. --- .../settings/appearance/use-design-draft.ts | 1 + .../public/__tests__/portal-band.test.tsx | 34 +++++++++ .../__tests__/portal-preview-design.test.ts | 12 +++ .../web/src/components/public/portal-band.tsx | 39 +++++++++- .../public/portal-preview-design.ts | 29 ++++++- .../public/portal-preview-listener.tsx | 17 +++-- .../public/preview-draft-context.tsx | 9 ++- .../__tests__/design-image-uploads.test.ts | 76 +++++++++++++++++++ apps/web/src/lib/server/functions/uploads.ts | 38 +++++++++- .../lib/server/policy/authz-matrix/MATRIX.md | 6 +- .../design/__tests__/design-view.test.ts | 12 +++ .../design/__tests__/fixtures/bramble.json | 5 +- .../design/__tests__/fixtures/fieldnote.json | 8 +- .../design/__tests__/fixtures/ledger.json | 8 +- .../design/__tests__/fixtures/plume.json | 8 +- .../design/__tests__/reference-brands.test.ts | 3 + .../lib/shared/design/__tests__/view.test.ts | 5 ++ apps/web/src/lib/shared/design/components.ts | 3 + apps/web/src/lib/shared/design/design-view.ts | 8 +- apps/web/src/lib/shared/design/options.ts | 2 + apps/web/src/lib/shared/design/parts.ts | 7 ++ apps/web/src/lib/shared/design/schema.ts | 4 + apps/web/src/lib/shared/design/view.ts | 7 ++ 23 files changed, 314 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/lib/server/functions/__tests__/design-image-uploads.test.ts 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. From 3530012cd2f60509e213a3647ad8b6fe6dcda431 Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 14:25:30 +0000 Subject: [PATCH 2/2] test(portal): the feed test's preview mock knows the band image The band now reads the draft's band image, so the feedback page test's mock of the preview context provides it. --- .../src/routes/_portal/__tests__/feedback.viewer-change.test.tsx | 1 + 1 file changed, 1 insertion(+) 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,