diff --git a/.changeset/shared-preview-storage.md b/.changeset/shared-preview-storage.md
new file mode 100644
index 00000000..01af4264
--- /dev/null
+++ b/.changeset/shared-preview-storage.md
@@ -0,0 +1,5 @@
+---
+"@luzzle/web": patch
+---
+
+Share worker-generated preview files with the web service through a read-only preview mount in the production Compose examples. Document the directory setup and mount update required for existing deployments.
diff --git a/.changeset/uniform-icon-viewport.md b/.changeset/uniform-icon-viewport.md
new file mode 100644
index 00000000..fac5f768
--- /dev/null
+++ b/.changeset/uniform-icon-viewport.md
@@ -0,0 +1,5 @@
+---
+"@luzzle/web": patch
+---
+
+Use the same responsive icon viewport for Preview and Published views, scaling each complete composition uniformly instead of shrinking its individual parts on narrow screens.
diff --git a/apps/web/docker-compose.yml b/apps/web/docker-compose.yml
index d83dcce9..2a400dad 100644
--- a/apps/web/docker-compose.yml
+++ b/apps/web/docker-compose.yml
@@ -12,6 +12,7 @@ services:
- "3000:3000"
volumes:
- ./demo/assets/pieces:/app/assets/pieces
+ - ./demo/assets/previews:/app/assets/previews:ro
- ./demo/config:/app/config:ro
- ./demo/data:/app/data
- ./demo/content:/app/content
diff --git a/apps/web/scripts/compose-mounts.test.ts b/apps/web/scripts/compose-mounts.test.ts
new file mode 100644
index 00000000..7499e86d
--- /dev/null
+++ b/apps/web/scripts/compose-mounts.test.ts
@@ -0,0 +1,14 @@
+import { readFileSync } from 'node:fs'
+import { parse } from 'yaml'
+import { expect, test } from 'vitest'
+
+// Deployment-contract coverage, not a running-container smoke test.
+test.each([
+ ['../docker-compose.yml', './demo/assets'],
+ ['../../../docs/web/docker-compose.yml', './assets']
+])('%s shares preview storage between worker and web', (file, assets) => {
+ const { services } = parse(readFileSync(new URL(file, import.meta.url), 'utf8'))
+ expect(services['luzzle-worker'].volumes).toContain(`${assets}:/app/assets`)
+ expect(services['luzzle-web'].volumes).toContain(`${assets}/previews:/app/assets/previews:ro`)
+ expect(services['luzzle-web'].volumes).toContain(`${assets}/pieces:/app/assets/pieces`)
+})
diff --git a/apps/web/src/lib/pieces/viewer/IconViewport.svelte b/apps/web/src/lib/pieces/viewer/IconViewport.svelte
new file mode 100644
index 00000000..1345d0ce
--- /dev/null
+++ b/apps/web/src/lib/pieces/viewer/IconViewport.svelte
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
diff --git a/apps/web/src/lib/pieces/viewer/IconViewport.test.ts b/apps/web/src/lib/pieces/viewer/IconViewport.test.ts
new file mode 100644
index 00000000..158c6921
--- /dev/null
+++ b/apps/web/src/lib/pieces/viewer/IconViewport.test.ts
@@ -0,0 +1,59 @@
+import { expect, test, vi } from 'vitest'
+import { render } from 'svelte/server'
+import IconViewport from './IconViewport.svelte'
+import { publishedPiece as rawPublishedPiece } from './fixtures/published'
+
+const piece = {
+ ...rawPublishedPiece,
+ templates: {
+ icon: { name: 'default', fields: { image: 'cover' } },
+ page: { name: 'default', fields: { image: 'cover' } },
+ opengraph: { name: 'default', fields: { image: 'cover' } }
+ }
+}
+
+const { page } = vi.hoisted(() => ({
+ page: {
+ data: {
+ preview: false,
+ job: 'preview-job',
+ config: { url: { app: '/garden', luzzle_assets: '/cdn' } }
+ }
+ }
+}))
+vi.mock('$app/state', () => ({ page }))
+vi.mock('$app/paths', () => ({
+ resolve: (route: string, params: Record) =>
+ route.replace(/\[(?:\.\.\.)?(\w+)\]/g, (_, name: string) => params[name])
+}))
+// The synchronous renderer stub checks the viewport's SSR contract and real asset helpers.
+// Node SSR cannot verify zoom layout, natural template heights, 3D paint/shadows,
+// resize/orientation updates, or ResizeObserver mounting and cleanup in a browser.
+vi.mock('#lib/pieces/components/icon.svelte', async () => ({
+ default: (await import('./fixtures/IconRenderer.svelte')).default
+}))
+
+test.each([
+ { preview: false, src: '/cdn/pieces/assets/published/cover.jpg' },
+ { preview: true, src: '/admin/preview/preview-job/asset/published/cover.jpg' }
+])('SSR keeps eager 500px rendering and resource URLs (preview=$preview)', (state) => {
+ page.data.preview = state.preview
+ const html = render(IconViewport, { props: { piece } }).body
+
+ expect(html.match(/]*class="icon-frame\b[^>]*>/)?.[0]
+ expect(frame).toBeDefined()
+ expect(frame).toContain('width: 500px; zoom: 1')
+ // Height belongs to the selected template, not a shared 750px canvas.
+ expect(frame).not.toContain('height:')
+})
diff --git a/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/icon/+page.svelte b/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/icon/+page.svelte
index 77943393..062b73de 100644
--- a/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/icon/+page.svelte
+++ b/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/icon/+page.svelte
@@ -1,27 +1,10 @@
{#if data.status === 'completed' && data.piece}
-
-
-
+
{/if}
-
-
diff --git a/apps/web/src/routes/(auth)/admin/piece/[...path]/published/icon/+page.svelte b/apps/web/src/routes/(auth)/admin/piece/[...path]/published/icon/+page.svelte
index 54a53678..249a5c6c 100644
--- a/apps/web/src/routes/(auth)/admin/piece/[...path]/published/icon/+page.svelte
+++ b/apps/web/src/routes/(auth)/admin/piece/[...path]/published/icon/+page.svelte
@@ -1,29 +1,7 @@
-
-
-
-
-
+
diff --git a/docs/web/deployment.md b/docs/web/deployment.md
index c4df9944..18d7dc0e 100644
--- a/docs/web/deployment.md
+++ b/docs/web/deployment.md
@@ -7,7 +7,7 @@ images. No checkout or custom build is needed.
```sh
mkdir luzzle && cd luzzle
-mkdir -p config archive assets/pieces data content rclone static nginx/cache
+mkdir -p config archive assets/pieces assets/previews data content rclone static nginx/cache
touch .env
```
@@ -55,7 +55,13 @@ Changes to `.env` or mounts require
To update: back up your data, update `LUZZLE_VERSION` if pinned, then pull and
start again.
-## Preview cleanup
+## Preview storage and cleanup
+
+The worker writes previews to `assets/previews`; the web service needs the same
+directory mounted read-only. For existing deployments, first create this directory
+with ownership and write permissions matching the worker's configured UID/GID.
+Then add the preview mount from the Compose example and recreate `luzzle-web`;
+updating the image alone does not change mounts.
The worker removes expired previews and app logs daily around 02:00 UTC.
Finished jobs are retained for seven days; workflow history stays.
diff --git a/docs/web/development.md b/docs/web/development.md
index fccd5f35..e334392a 100644
--- a/docs/web/development.md
+++ b/docs/web/development.md
@@ -22,7 +22,7 @@ Set `LUZZLE_AUTH_SECRET`, `LUZZLE_AUTH_PASSWORD` and, if using AI, `GOOGLE_API_K
Generate a secret with `openssl rand -hex 32`. Keep `.env` out of Git.
```sh
-mkdir -p demo/assets/pieces demo/data nginx/cache nginx/logs
+mkdir -p demo/assets/pieces demo/assets/previews demo/data nginx/cache nginx/logs
docker compose -f docker-compose.dev.yml -f docker-compose.dev.local.yml up --build
```
diff --git a/docs/web/docker-compose.yml b/docs/web/docker-compose.yml
index 8f97ed3b..b304a13c 100644
--- a/docs/web/docker-compose.yml
+++ b/docs/web/docker-compose.yml
@@ -17,6 +17,7 @@ services:
- ./config:/app/config:ro
- ./archive:/app/archive
- ./assets/pieces:/app/assets/pieces
+ - ./assets/previews:/app/assets/previews:ro
- ./data:/app/data
- ./content:/app/content:ro
healthcheck: