feat(picture): M3 — boards adopt the unified visibility system (soft)

Second consumer of @mana/shared-privacy. Picture boards now carry a
VisibilityLevel the owner flips from the board detail page via
<VisibilityPicker>; the website embed resolver gates hard on
canEmbedOnWebsite. This unblocks the picture.board embed — it had been
effectively dead because the legacy `isPublic` bool had no UI toggle
and thus stayed false for every row in practice.

Soft migration (per the repo's soft-first/hard-follow-up rule). The
legacy `isPublic` field is marked @deprecated on both LocalBoard and
LocalImage but kept on the record so any reader that slipped through
the grep still sees sane data. Converters fall back to
`isPublic === true ? 'public' : 'private'` when visibility is missing,
so legacy rows (pre-M3) route through the new gate with the same
intent. Hard follow-up drops the field in a later PR once callers are
clean.

Changes:
- picture/types: visibility + unlistedToken + visibilityChangedAt +
  visibilityChangedBy on LocalImage and LocalBoard; Image and Board
  (plaintext UI types) expose `visibility: VisibilityLevel` as a
  required field
- picture/queries: toImage + toBoard forward visibility with the
  legacy-isPublic fallback described above
- picture/stores/boards: createBoard stamps
  defaultVisibilityFor(activeSpace.type) instead of isPublic: false;
  duplicateBoard resets the clone to the space default (a copy of a
  public board does NOT auto-publish); new setVisibility(id, level)
  mints/clears the unlisted token on the transition boundary and
  emits the cross-module VisibilityChanged event
- picture/collections: PICTURE_GUEST_SEED demo board starts with
  visibility: 'private'
- picture/ListView + routes/picture/generate + wardrobe/try-on:
  constructed LocalImage seeds set `visibility: 'private'` instead
  of `isPublic: false`
- website/embeds: resolvePictureBoard replaces the hard-coded
  isPublic check with canEmbedOnWebsite, reading visibility with the
  legacy fallback. Error message points users at the picture module's
  new picker
- routes/picture/board/[id]: VisibilityPicker mounted in the header
  toolbar, left of the edit/delete buttons, wired through
  handleVisibilityChange → boardsStore.setVisibility

Not in this PR:
- Image-level visibility picker UI (record field is ready; no UI
  control yet — boards currently govern public exposure, per-image
  visibility is a later refinement if anyone asks)
- Hard drop of the legacy isPublic column (M3.1 follow-up once a
  soak confirms nothing reads the old field)

Verified:
- pnpm check (web): 7450 files, 0 errors, 0 warnings
- pnpm test picture + website + library: 23/23
- pnpm run validate:all: theme-tokens, theme-parity, crypto-registry,
  encrypted-tools all green

Next: M4 — Calendar + Todo + Goals. New embed resolvers + new
moduleEmbed source values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 02:23:56 +02:00
parent d5ae2f19b4
commit 0e9f574dfb
9 changed files with 100 additions and 17 deletions

View file

@ -194,7 +194,7 @@
fileSize: uf.file.size, fileSize: uf.file.size,
width: dims?.width ?? null, width: dims?.width ?? null,
height: dims?.height ?? null, height: dims?.height ?? null,
isPublic: false, visibility: 'private',
isFavorite: false, isFavorite: false,
downloadCount: 0, downloadCount: 0,
createdAt: nowIso, createdAt: nowIso,

View file

@ -27,7 +27,7 @@ export const PICTURE_GUEST_SEED = {
canvasWidth: 2000, canvasWidth: 2000,
canvasHeight: 1500, canvasHeight: 1500,
backgroundColor: '#1e1e2e', backgroundColor: '#1e1e2e',
isPublic: false, visibility: 'private',
}, },
] satisfies LocalBoard[], ] satisfies LocalBoard[],
boardItems: [ boardItems: [

View file

@ -38,7 +38,9 @@ export function toImage(local: LocalImage): Image {
height: local.height ?? undefined, height: local.height ?? undefined,
fileSize: local.fileSize ?? undefined, fileSize: local.fileSize ?? undefined,
blurhash: local.blurhash ?? undefined, blurhash: local.blurhash ?? undefined,
isPublic: local.isPublic, // Soft-migration fallback: rows written before M3 only have the
// legacy `isPublic` flag; map it to the nearest visibility level.
visibility: local.visibility ?? (local.isPublic === true ? 'public' : 'private'),
isFavorite: local.isFavorite, isFavorite: local.isFavorite,
downloadCount: local.downloadCount, downloadCount: local.downloadCount,
rating: local.rating ?? undefined, rating: local.rating ?? undefined,
@ -62,7 +64,7 @@ export function toBoard(local: LocalBoard): Board {
canvasWidth: local.canvasWidth, canvasWidth: local.canvasWidth,
canvasHeight: local.canvasHeight, canvasHeight: local.canvasHeight,
backgroundColor: local.backgroundColor, backgroundColor: local.backgroundColor,
isPublic: local.isPublic, visibility: local.visibility ?? (local.isPublic === true ? 'public' : 'private'),
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: local.updatedAt ?? new Date().toISOString(),
}; };

View file

@ -8,6 +8,14 @@
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { encryptRecord, decryptRecord } from '$lib/data/crypto'; import { encryptRecord, decryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { getActiveSpace } from '$lib/data/scope';
import { getEffectiveUserId } from '$lib/data/current-user';
import {
defaultVisibilityFor,
generateUnlistedToken,
type VisibilityLevel,
} from '@mana/shared-privacy';
import type { LocalBoard, LocalBoardItem } from '../types'; import type { LocalBoard, LocalBoardItem } from '../types';
import { toBoard } from '../queries'; import { toBoard } from '../queries';
@ -39,7 +47,7 @@ export const boardsStore = {
canvasWidth: 2000, canvasWidth: 2000,
canvasHeight: 1500, canvasHeight: 1500,
backgroundColor: input.backgroundColor || '#ffffff', backgroundColor: input.backgroundColor || '#ffffff',
isPublic: false, visibility: defaultVisibilityFor(getActiveSpace()?.type),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
@ -125,6 +133,9 @@ export const boardsStore = {
const newId = crypto.randomUUID(); const newId = crypto.randomUUID();
const now = new Date().toISOString(); const now = new Date().toISOString();
// Duplicate inherits the same default-for-space visibility, not
// the original's. A copy of a public board should NOT auto-
// publish — the user has to explicitly flip visibility again.
const duplicated: LocalBoard = { const duplicated: LocalBoard = {
id: newId, id: newId,
name: `${original.name} (Kopie)`, name: `${original.name} (Kopie)`,
@ -132,7 +143,7 @@ export const boardsStore = {
canvasWidth: original.canvasWidth, canvasWidth: original.canvasWidth,
canvasHeight: original.canvasHeight, canvasHeight: original.canvasHeight,
backgroundColor: original.backgroundColor, backgroundColor: original.backgroundColor,
isPublic: false, visibility: defaultVisibilityFor(getActiveSpace()?.type),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@ -168,4 +179,45 @@ export const boardsStore = {
return { success: false, error }; return { success: false, error };
} }
}, },
/**
* Flip the board's visibility. Mints/clears an unlisted token on the
* transition boundary and emits the cross-module VisibilityChanged
* event. Caller passes the raw level; no-op if it already matches.
*/
async setVisibility(id: string, next: VisibilityLevel) {
error = null;
try {
const existing = await db.table<LocalBoard>('boards').get(id);
if (!existing) return { success: false, error: 'Board not found' };
const before: VisibilityLevel =
existing.visibility ?? (existing.isPublic === true ? 'public' : 'private');
if (before === next) return { success: true };
const now = new Date().toISOString();
const patch: Partial<LocalBoard> = {
visibility: next,
visibilityChangedAt: now,
visibilityChangedBy: getEffectiveUserId(),
updatedAt: now,
};
if (next === 'unlisted' && !existing.unlistedToken) {
patch.unlistedToken = generateUnlistedToken();
} else if (next !== 'unlisted' && existing.unlistedToken) {
patch.unlistedToken = undefined;
}
await db.table('boards').update(id, patch);
emitDomainEvent('VisibilityChanged', 'picture', 'boards', id, {
recordId: id,
collection: 'boards',
before,
after: next,
});
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to set visibility';
return { success: false, error };
}
},
}; };

View file

@ -3,6 +3,7 @@
*/ */
import type { BaseRecord } from '@mana/local-store'; import type { BaseRecord } from '@mana/local-store';
import type { VisibilityLevel } from '@mana/shared-privacy';
/** /**
* How the image was created. 'text' is the classic prompt-only * How the image was created. 'text' is the classic prompt-only
@ -25,7 +26,16 @@ export interface LocalImage extends BaseRecord {
height?: number | null; height?: number | null;
fileSize?: number | null; fileSize?: number | null;
blurhash?: string | null; blurhash?: string | null;
isPublic: boolean; /**
* @deprecated Use `visibility` instead. Kept for the soft-migration
* window will be dropped in the hard follow-up once no reader
* references it. See docs/plans/visibility-system.md §M3.
*/
isPublic?: boolean;
visibility?: VisibilityLevel;
visibilityChangedAt?: string;
visibilityChangedBy?: string;
unlistedToken?: string;
isFavorite: boolean; isFavorite: boolean;
downloadCount: number; downloadCount: number;
rating?: number | null; rating?: number | null;
@ -51,7 +61,15 @@ export interface LocalBoard extends BaseRecord {
canvasWidth: number; canvasWidth: number;
canvasHeight: number; canvasHeight: number;
backgroundColor: string; backgroundColor: string;
isPublic: boolean; /**
* @deprecated Use `visibility` instead. Kept during the M3 soft
* migration dropped in the hard follow-up.
*/
isPublic?: boolean;
visibility?: VisibilityLevel;
visibilityChangedAt?: string;
visibilityChangedBy?: string;
unlistedToken?: string;
} }
export interface LocalBoardItem extends BaseRecord { export interface LocalBoardItem extends BaseRecord {
@ -94,7 +112,7 @@ export interface Image {
height?: number; height?: number;
fileSize?: number; fileSize?: number;
blurhash?: string; blurhash?: string;
isPublic: boolean; visibility: VisibilityLevel;
isFavorite: boolean; isFavorite: boolean;
downloadCount: number; downloadCount: number;
rating?: number; rating?: number;
@ -116,7 +134,7 @@ export interface Board {
canvasWidth: number; canvasWidth: number;
canvasHeight: number; canvasHeight: number;
backgroundColor: string; backgroundColor: string;
isPublic: boolean; visibility: VisibilityLevel;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

View file

@ -188,7 +188,7 @@ export async function runOutfitTryOn(params: RunOutfitTryOnParams): Promise<RunT
format: 'png', format: 'png',
width: dims.width, width: dims.width,
height: dims.height, height: dims.height,
isPublic: false, visibility: 'private',
isFavorite: false, isFavorite: false,
downloadCount: 0, downloadCount: 0,
generationMode: 'reference', generationMode: 'reference',
@ -289,7 +289,7 @@ export async function runGarmentTryOn(params: RunGarmentTryOnParams): Promise<Ru
format: 'png', format: 'png',
width: dims.width, width: dims.width,
height: dims.height, height: dims.height,
isPublic: false, visibility: 'private',
isFavorite: false, isFavorite: false,
downloadCount: 0, downloadCount: 0,
generationMode: 'reference', generationMode: 'reference',

View file

@ -55,8 +55,10 @@ export async function resolveEmbed(props: ModuleEmbedProps): Promise<ResolvedEmb
} }
/** /**
* Picture-board: returns image items for a board that the owner marked * Picture-board: returns image items for a board whose owner flipped
* `isPublic=true`. Private boards return an error. * its visibility to 'public' via the VisibilityPicker. `canEmbedOnWebsite`
* is the hard gate; the soft-migration fallback maps legacy `isPublic`
* rows (pre-M3) to the right level.
*/ */
async function resolvePictureBoard(props: ModuleEmbedProps): Promise<EmbedItem[]> { async function resolvePictureBoard(props: ModuleEmbedProps): Promise<EmbedItem[]> {
if (!props.sourceId) { if (!props.sourceId) {
@ -72,8 +74,10 @@ async function resolvePictureBoard(props: ModuleEmbedProps): Promise<EmbedItem[]
if (!rawBoard || rawBoard.deletedAt) { if (!rawBoard || rawBoard.deletedAt) {
throw new Error('Board nicht gefunden'); throw new Error('Board nicht gefunden');
} }
if (!rawBoard.isPublic) { const boardVisibility =
throw new Error('Board ist nicht öffentlich — setze "Öffentlich" im Picture-Modul'); rawBoard.visibility ?? (rawBoard.isPublic === true ? 'public' : 'private');
if (!canEmbedOnWebsite(boardVisibility)) {
throw new Error('Board ist nicht öffentlich — setze es im Picture-Modul auf "Öffentlich"');
} }
const items = await db const items = await db

View file

@ -8,6 +8,7 @@
import type { BoardWithCount } from '$lib/modules/picture/types'; import type { BoardWithCount } from '$lib/modules/picture/types';
import { CaretLeft, Trash, PencilSimple, Image } from '@mana/shared-icons'; import { CaretLeft, Trash, PencilSimple, Image } from '@mana/shared-icons';
import { RoutePage } from '$lib/components/shell'; import { RoutePage } from '$lib/components/shell';
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
const allBoards: { value: BoardWithCount[] } = getContext('allBoards'); const allBoards: { value: BoardWithCount[] } = getContext('allBoards');
@ -40,6 +41,11 @@
await boardsStore.deleteBoard(board.id); await boardsStore.deleteBoard(board.id);
goto('/picture/board'); goto('/picture/board');
} }
async function handleVisibilityChange(next: VisibilityLevel) {
if (!board) return;
await boardsStore.setVisibility(board.id, next);
}
</script> </script>
<svelte:head> <svelte:head>
@ -74,6 +80,7 @@
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<VisibilityPicker level={board.visibility} onChange={handleVisibilityChange} />
<button <button
onclick={startEditing} onclick={startEditing}
class="rounded-lg p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" class="rounded-lg p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"

View file

@ -157,7 +157,7 @@
format: 'png', format: 'png',
width: currentAspect.width, width: currentAspect.width,
height: currentAspect.height, height: currentAspect.height,
isPublic: false, visibility: 'private',
isFavorite: false, isFavorite: false,
downloadCount: 0, downloadCount: 0,
generationMode: isReferenceMode ? 'reference' : 'text', generationMode: isReferenceMode ? 'reference' : 'text',