Moodboard: webapp parity, lightbox redesign, editable notes & selection polish

Align the shared @storyteller/ui-moodboard surface with the webapp's design
language across both hosts (Tauri desktop + webapp), plus several UX fixes.

Theming & layout
- Use `var(--st-bg, hsl(0 0% 9%))` for the workspace/grid background so desktop
  follows the active theme while the webapp falls back to its --background token
  (replaces the desktop-only bg-ui-background class).
- Fix desktop overflow: the fixed TopBar is 56px, so the moodboard tab now uses
  h-screen + pt-[56px] + overflow-hidden, killing the stray vertical/horizontal
  scrollbars on an empty board. Grid view root clips too.

Empty state & toolbars
- Empty-state Upload / From-library CTAs now use the shared Button, matching the
  webapp create pages (h-12, pill, semibold; primary + secondary).
- Restyle SelectionBar to match the library multiselect bar (solid pill, filled
  bg-ui-controls buttons, red Delete, round clear); align the "Move to"
  menu/items/Kbd to the same idiom.
- Restyle SmartSearchBar (bg-ui-controls, rounded-xl) and tighten ToolbarShell
  padding.
- Add a tooltip to the toolbar's Add Color button to match the sibling buttons.

Notes
- Adding a note creates an empty, immediately-editable note (autofocusing inline
  textarea; commit on blur/Enter, Shift+Enter newline, Esc reverts); double-click
  re-enters edit mode — no more stuck "New note".
- Pad the note's top so the selection check no longer overlaps the first line.

Selection
- Clicks accumulate: clicking an item adds it to the selection (clicking again
  removes it), so multi-select builds up without a modifier; background click
  clears. "Use as reference" only shows when the selection has a
  reference-capable image.

Item inspector
- Redesign ItemInspector to mirror the shared LightboxModal: a bounded, centered,
  draggable/resizable Modal with media on a dark plate, hover nav arrows, and a
  fixed info/actions column using shared Buttons.

Webapp homepage
- Add a Moodboard card next to Edit Video on the webapp home page.
This commit is contained in:
bflatastic committed 2026-06-17 21:37:21 +07:00
1 parent 43de77f2a6
commit 8334e43886
15 files changed
+477 -207

No files matched your search

@@ -6,6 +6,7 @@ import {
faWandMagicSparkles,
faCube,
faFilm,
faObjectGroup,
faArrowRight,
} from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
@@ -69,6 +70,16 @@ const APPS: AppCard[] = [
iconColor: "text-rose-300",
badge: "BETA",
},
{
label: "Moodboard",
description: "Collect references and ideas to steer a generation.",
href: "/moodboard",
icon: faObjectGroup,
accent: "from-indigo-500/20 to-indigo-500/0",
iconBg: "bg-indigo-500/25 border-indigo-400/30",
iconColor: "text-indigo-300",
badge: "BETA",
},
];
export function Home() {
@@ -46,10 +46,7 @@ import {
} from "@storyteller/tauri-events";
import { SoundManager } from "@storyteller/soundboard";
import { useStoryboardPageEnabled } from "@storyteller/ui-settings-modal";
import {
DomLevels,
usePageSceneStore,
} from "@storyteller/ui-pagescene";
import { DomLevels, usePageSceneStore } from "@storyteller/ui-pagescene";
import { useActiveJobs } from "~/hooks/useActiveJobs";
import { useBackgroundLoadingMedia } from "~/hooks/useBackgroundLoadingMedia";
@@ -224,9 +221,17 @@ const TabBody = ({ sceneToken }: { sceneToken?: string }) => {
case "3D":
return <PageScene sceneToken={sceneToken} />;
case "APPS":
return <div><AppsIndexPage /></div>;
return (
<div>
<AppsIndexPage />
</div>
);
case "2D":
return <div><PageDrawWithGalleryDrop /></div>;
return (
<div>
<PageDrawWithGalleryDrop />
</div>
);
case "IMAGE":
return (
<div>
@@ -237,29 +242,83 @@ const TabBody = ({ sceneToken }: { sceneToken?: string }) => {
</div>
);
case "VIDEO":
return <div><ImageToVideo /></div>;
return (
<div>
<ImageToVideo />
</div>
);
case "VIDEO_FRAME_EXTRACTOR":
return <div><VideoFrameExtractor /></div>;
return (
<div>
<VideoFrameExtractor />
</div>
);
case "VIDEO_WATERMARK_REMOVAL":
return <div><VideoWatermarkRemover /></div>;
return (
<div>
<VideoWatermarkRemover />
</div>
);
case "IMAGE_WATERMARK_REMOVAL":
return <div><ImageWatermarkRemover /></div>;
return (
<div>
<ImageWatermarkRemover />
</div>
);
case "IMAGE_TO_3D_OBJECT":
return <div><ImageTo3DObject /></div>;
return (
<div>
<ImageTo3DObject />
</div>
);
case "IMAGE_TO_3D_WORLD":
return <div><ImageTo3DWorld /></div>;
return (
<div>
<ImageTo3DWorld />
</div>
);
case "REMOVE_BACKGROUND":
return <div><RemoveBackground /></div>;
return (
<div>
<RemoveBackground />
</div>
);
case "ANGLES":
return <div><Angles /></div>;
return (
<div>
<Angles />
</div>
);
case "STORYBOARD":
return storyboardPageEnabled ? <div><Storyboard /></div> : null;
return storyboardPageEnabled ? (
<div>
<Storyboard />
</div>
) : null;
case "BACKGROUND_CHANGE":
return <div><PageBackgroundChange /></div>;
return (
<div>
<PageBackgroundChange />
</div>
);
case "VIDEO_EDITOR":
return <div className="h-[calc(100vh-3rem)] w-full"><PageVideoEditor /></div>;
return (
<div className="h-[calc(100vh-3rem)] w-full">
<PageVideoEditor />
</div>
);
case "MOODBOARD":
return <div className="h-[calc(100vh-3rem)] w-full"><PageMoodboard /></div>;
// The TopBar is fixed and 56px tall, so the board sits below it and fills
// the rest of the viewport. h-screen + pt-[56px] (border-box) makes the
// board area exactly viewport-minus-topbar, and overflow-hidden keeps
// sub-pixel 100vh rounding from spawning page scrollbars (which w-screen
// ancestors would turn into a horizontal scrollbar via the Windows
// scrollbar gutter).
return (
<div className="h-[calc(100vh-56px)] w-full overflow-hidden">
<PageMoodboard />
</div>
);
default:
return null;
}
@@ -42,7 +42,11 @@ export const MoodboardWorkspace = ({ adapter, topBarEndSlot }: Props) => {
return (
<div
data-moodboard-root
className="relative h-full w-full overflow-hidden bg-ui-background"
className="relative h-full w-full overflow-hidden"
// Desktop themes set --st-bg (gray/light/black/aurora) so the board follows
// the active theme. The webapp never defines --st-bg, so it falls back to
// its own --background token (0 0% 9% / #171717), matching the rest of the app.
style={{ background: "var(--st-bg, hsl(0 0% 9%))" }}
>
{viewMode === "grid" ? (
<BoardGridView active={!presenting} adapter={adapter} />
@@ -77,7 +81,7 @@ const ViewSwitch = ({
mode: ViewMode;
onChange: (m: ViewMode) => void;
}) => (
<div className="glass absolute left-3 top-3 z-40 flex items-center gap-0.5 rounded-full border border-ui-divider p-0.5 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.45)]">
<div className="glass absolute left-3 top-3 z-40 flex items-center gap-0.5 rounded-2xl border border-ui-divider p-1 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.45)]">
{OPTIONS.map((opt) => {
const active = opt.mode === mode;
return (
@@ -86,7 +90,7 @@ const ViewSwitch = ({
type="button"
onClick={() => onChange(opt.mode)}
className={[
"flex items-center gap-2 rounded-full px-3.5 py-1.5 text-sm font-medium",
"flex items-center gap-2 rounded-xl px-3.5 py-1.5 text-sm font-medium",
"transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
active
? "bg-base-fg/15 text-base-fg"
@@ -112,13 +116,13 @@ const TopRightCluster = ({
endSlot?: ReactNode;
}) => (
<div className="pointer-events-none absolute right-3 top-3 z-40 flex items-center gap-2">
<div className="glass pointer-events-auto flex items-center rounded-full border border-ui-divider p-0.5 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.45)]">
<div className="glass pointer-events-auto flex items-center rounded-xl border border-ui-divider p-0.5 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.45)]">
<button
type="button"
onClick={onPresent}
title="Present"
aria-label="Present"
className="flex items-center gap-2 rounded-full px-3.5 py-1.5 text-sm font-medium text-base-fg/55 transition-colors duration-150 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
className="flex items-center gap-2 rounded-xl px-3.5 py-1.5 text-sm font-medium text-base-fg/55 transition-colors duration-150 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faPlay} className="h-3 w-3" />
Present
@@ -86,7 +86,9 @@ interface BoardLibraryState extends PersistedLibrary {
// Selection (operates on the active board)
setSelection: (ids: Iterable<string>) => void;
toggleSelection: (id: string, additive: boolean) => void;
// Clicks accumulate; `additive` is accepted for caller compatibility but no
// longer changes behavior (a plain click already toggles within the set).
toggleSelection: (id: string, additive?: boolean) => void;
clearSelection: () => void;
deleteSelected: () => void;
}
@@ -413,10 +415,15 @@ export const useBoardLibraryStore = create<BoardLibraryState>()(
set({ searchQuery: "", activeTagFilters: [], ratingFilter: 0 }),
setSelection: (ids) => set({ selectedItemIds: new Set(ids) }),
toggleSelection: (id, additive) =>
// Every click accumulates: a plain click adds the item to the current
// selection (or removes it if already selected), so clicking item after
// item builds up a multi-selection without needing a modifier. The
// `additive` flag (ctrl/cmd/shift-click) behaves identically here; the
// distinction is only meaningful for marquee/range selection.
toggleSelection: (id) =>
set((s) => {
const next = additive ? new Set(s.selectedItemIds) : new Set<string>();
if (additive && s.selectedItemIds.has(id)) next.delete(id);
const next = new Set(s.selectedItemIds);
if (next.has(id)) next.delete(id);
else next.add(id);
return { selectedItemIds: next };
}),
@@ -8,7 +8,8 @@ import type { MoodboardAdapter, MoodboardPickedItem } from "../adapter";
interface UseReturn {
triggerUpload: () => void;
triggerGallery: () => void;
addNote: () => void;
/** Adds an empty note and returns its id so the grid can open it for editing. */
addNote: () => string;
modals: ReactNode;
}
@@ -34,7 +35,11 @@ export const useBoardItemEntry = (
const addNote = useCallback(() => {
const store = useBoardLibraryStore.getState();
store.addTextItem(store.ensureActiveBoard(), "New note");
// Empty text → the card shows its "Note" placeholder and the grid drops
// straight into edit mode so the user types instead of clearing "New note".
const id = store.addTextItem(store.ensureActiveBoard(), "");
store.setSelection([id]);
return id;
}, []);
// Place the image immediately from a blob, then (if the platform supports it)
@@ -29,6 +29,12 @@ interface Props {
onOpen: (id: string) => void;
onDelete: (id: string) => void;
onUseReference: (id: string) => void;
/** This text card is in inline-edit mode (renders an editable textarea). */
editing?: boolean;
/** Enter inline edit for a text card (double-click). */
onEditText?: (id: string) => void;
/** Commit edited note text (blur / Enter). */
onCommitText?: (id: string, text: string) => void;
}
const KIND_NOUN: Record<BoardItem["kind"], string> = {
@@ -60,6 +66,9 @@ const BoardCardInner = ({
onOpen,
onDelete,
onUseReference,
editing = false,
onEditText,
onCommitText,
}: Props) => {
const rootRef = useRef<HTMLDivElement | null>(null);
const [entering, setEntering] = useState(
@@ -107,7 +116,11 @@ const BoardCardInner = ({
if (e.button !== 0) return;
onSelect(item.id, e.shiftKey || e.metaKey || e.ctrlKey);
}}
onDoubleClick={() => onOpen(item.id)}
onDoubleClick={() =>
item.kind === "text" && onEditText
? onEditText(item.id)
: onOpen(item.id)
}
onFocus={() => onFocusCard?.(item.id)}
onKeyDown={(e) => onKeyNav?.(item.id, e)}
>
@@ -129,7 +142,11 @@ const BoardCardInner = ({
>
{/* Inner core — concentric radius, holds the content. */}
<div className="relative h-full w-full overflow-hidden rounded-[14px] bg-ui-panel shadow-[inset_0_1px_1px_rgba(255,255,255,0.08)]">
<CardContent item={item} />
<CardContent
item={item}
editing={editing}
onCommitText={onCommitText}
/>
{/* Rating — persistent when set (Lightroom-style triage marker).
No backdrop-blur: this tile lives in a virtualized scroll
@@ -211,7 +228,15 @@ const RailButton = ({
</button>
);
const CardContent = ({ item }: { item: BoardItem }) => {
const CardContent = ({
item,
editing,
onCommitText,
}: {
item: BoardItem;
editing?: boolean;
onCommitText?: (id: string, text: string) => void;
}) => {
switch (item.kind) {
case "image":
return (
@@ -243,9 +268,22 @@ const CardContent = ({ item }: { item: BoardItem }) => {
</div>
);
case "text":
return (
<div className="h-full w-full overflow-hidden p-4">
<p className="whitespace-pre-wrap text-sm leading-relaxed text-base-fg/90">
return editing && onCommitText ? (
<NoteEditor
initial={item.text}
onCommit={(text) => onCommitText(item.id, text)}
/>
) : (
// Extra top padding clears the selection check (top-left) so it never
// overlaps the first line of the note.
<div className="h-full w-full overflow-hidden px-4 pb-4 pt-10">
<p
// Clicking the text itself shouldn't toggle the card's selection —
// stop the pointer-down from reaching the card root. Double-click
// (a separate event) still bubbles, so it opens the editor.
onPointerDown={(e) => e.stopPropagation()}
className="whitespace-pre-wrap text-sm leading-relaxed text-base-fg/90"
>
{item.text || "Note"}
</p>
</div>
@@ -287,4 +325,53 @@ const CardContent = ({ item }: { item: BoardItem }) => {
}
};
// Inline note editor. Autofocuses, commits on blur or Enter (Shift+Enter inserts
// a newline), reverts on Escape. Pointer events are stopped so typing/selecting
// inside the textarea never triggers card selection, drag, or marquee.
const NoteEditor = ({
initial,
onCommit,
}: {
initial: string;
onCommit: (text: string) => void;
}) => {
const ref = useRef<HTMLTextAreaElement | null>(null);
const [draft, setDraft] = useState(initial);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.focus();
el.select();
}, []);
return (
<div
// Matches the static note: top padding clears the selection check.
className="h-full w-full px-4 pb-4 pt-10"
onPointerDown={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
<textarea
ref={ref}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => onCommit(draft.trim())}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onCommit(draft.trim());
} else if (e.key === "Escape") {
e.preventDefault();
onCommit(initial.trim());
}
}}
placeholder="Write a note…"
className="h-full w-full resize-none bg-transparent text-sm leading-relaxed text-base-fg/90 placeholder:text-base-fg/35 focus:outline-none"
/>
</div>
);
};
export const BoardCard = memo(BoardCardInner);
@@ -1,14 +1,18 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowUpFromBracket,
faImages,
} from "@fortawesome/pro-regular-svg-icons";
import { Button } from "@storyteller/ui-button";
interface Props {
onUpload: () => void;
onLibrary: () => void;
}
// Shared sizing so both CTAs match the webapp create pages' empty-state buttons
// (h-12, pill, semibold). Centralized so the pair stays visually aligned.
const CTA_CLASS = "h-12 px-6 text-base font-semibold rounded-full";
// Editorial empty state — the board's first impression. Doubles as the
// drop / paste affordance (the whole grid accepts drops; this just says so).
export const BoardEmptyState = ({ onUpload, onLibrary }: Props) => {
@@ -27,27 +31,22 @@ export const BoardEmptyState = ({ onUpload, onLibrary }: Props) => {
</p>
<div className="mt-7 flex items-center gap-3">
<button
type="button"
<Button
variant="primary"
onClick={onUpload}
className="group flex items-center gap-2.5 rounded-full bg-primary py-2.5 pl-5 pr-2.5 text-sm font-medium text-white transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:bg-primary/90 active:scale-[0.98] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
icon={faArrowUpFromBracket}
className={CTA_CLASS}
>
Upload
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/15 transition-transform duration-200 group-hover:translate-x-0.5 group-hover:scale-105">
<FontAwesomeIcon
icon={faArrowUpFromBracket}
className="h-3.5 w-3.5"
/>
</span>
</button>
<button
type="button"
</Button>
<Button
variant="secondary"
onClick={onLibrary}
className="flex items-center gap-2 rounded-full border border-ui-divider px-5 py-2.5 text-sm font-medium text-base-fg/80 transition-colors duration-200 hover:bg-base-fg/5 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
icon={faImages}
className={CTA_CLASS}
>
<FontAwesomeIcon icon={faImages} className="h-3.5 w-3.5" />
From library
</button>
</Button>
</div>
</div>
</div>
@@ -30,6 +30,10 @@ interface Props {
onOpen: (id: string) => void;
onUseReference: (id: string) => void;
onDelete: (id: string) => void;
// Inline text editing: which text card is being edited, plus enter/commit.
editingTextId?: string | null;
onEditText?: (id: string) => void;
onCommitText?: (id: string, text: string) => void;
// When present (non-empty), the grid groups items into section lanes with
// header rows. Absent → flat masonry (the default).
sections?: BoardSection[];
@@ -62,6 +66,9 @@ export const BoardGrid = ({
onOpen,
onUseReference,
onDelete,
editingTextId,
onEditText,
onCommitText,
sections,
onRenameSection,
onDeleteSection,
@@ -298,6 +305,9 @@ export const BoardGrid = ({
onOpen={onOpen}
onUseReference={onUseReference}
onDelete={onDelete}
editing={editingTextId === pos.id}
onEditText={onEditText}
onCommitText={onCommitText}
/>
);
})}
@@ -1,3 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
@@ -18,6 +19,7 @@ import { ToolbarShell, ToolbarIconButton, ToolbarDivider } from "../ui/Toolbar";
interface Props {
boardName: string;
onRenameBoard: (name: string) => void;
itemCount: number;
density: GridDensity;
onDensityChange: (d: GridDensity) => void;
@@ -52,6 +54,7 @@ const DENSITY_LABEL: Record<GridDensity, string> = {
// its shell, buttons, and dividers with the Canvas toolbar (see ../ui/Toolbar).
export const BoardGridToolbar = ({
boardName,
onRenameBoard,
itemCount,
density,
onDensityChange,
@@ -73,10 +76,8 @@ export const BoardGridToolbar = ({
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex justify-center px-3 pt-3">
<ToolbarShell className="max-w-full">
<div className="flex min-w-0 flex-col pl-1 pr-2">
<span className="truncate text-sm font-semibold leading-tight text-base-fg">
{boardName}
</span>
<span className="text-[11px] leading-tight text-base-fg/45">
<BoardTitle name={boardName} onRename={onRenameBoard} />
<span className="pl-1.5 text-[11px] leading-tight text-base-fg/45">
{itemCount} {itemCount === 1 ? "item" : "items"}
</span>
</div>
@@ -179,3 +180,60 @@ export const BoardGridToolbar = ({
</div>
);
};
// Inline-editable board title — click to rename (mirrors SectionHeader). Commits
// on Enter / blur, Escape cancels; empty or unchanged input is ignored.
const BoardTitle = ({
name,
onRename,
}: {
name: string;
onRename: (name: string) => void;
}) => {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(name);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (editing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [editing]);
const commit = () => {
const next = draft.trim();
if (next && next !== name) onRename(next);
setEditing(false);
};
if (editing) {
return (
<input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
else if (e.key === "Escape") setEditing(false);
}}
onBlur={commit}
className="min-w-0 rounded-md bg-base-fg/5 px-1.5 py-0.5 text-sm font-semibold leading-tight text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
/>
);
}
return (
<button
type="button"
title="Rename board"
onClick={() => {
setDraft(name);
setEditing(true);
}}
className="truncate rounded-md px-1.5 py-0.5 text-left text-sm font-semibold leading-tight text-base-fg transition-colors hover:bg-base-fg/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{name}
</button>
);
};
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useBoardLibraryStore } from "../boards/BoardLibraryStore";
import {
@@ -51,6 +51,7 @@ export const BoardGridView = ({ active, adapter }: Props) => {
const removeItems = useBoardLibraryStore((s) => s.removeItems);
const updateItem = useBoardLibraryStore((s) => s.updateItem);
const addColorItem = useBoardLibraryStore((s) => s.addColorItem);
const renameBoard = useBoardLibraryStore((s) => s.renameBoard);
const createSection = useBoardLibraryStore((s) => s.createSection);
const renameSection = useBoardLibraryStore((s) => s.renameSection);
const deleteSection = useBoardLibraryStore((s) => s.deleteSection);
@@ -66,6 +67,20 @@ export const BoardGridView = ({ active, adapter }: Props) => {
const [dragOver, setDragOver] = useState(false);
const [openId, setOpenId] = useState<string | null>(null);
// Which text card is in inline-edit mode (e.g. a freshly-added note).
const [editingTextId, setEditingTextId] = useState<string | null>(null);
const handleAddNote = useCallback(
() => setEditingTextId(addNote()),
[addNote],
);
const commitText = useCallback(
(id: string, text: string) => {
if (board) updateItem(board.id, id, { text });
setEditingTextId((cur) => (cur === id ? null : cur));
},
[board, updateItem],
);
useEffect(() => {
if (active) ensureActiveBoard();
@@ -112,6 +127,18 @@ export const BoardGridView = ({ active, adapter }: Props) => {
.filter((it): it is BoardItem => Boolean(it)),
);
};
// "Use as reference" only applies to reference-capable items (images with a
// media token) — never notes/colors/links. Mirrors sendReference's filter.
const canUseReference = useMemo(
() =>
board
? Array.from(selectedItemIds).some((id) => {
const it = board.items[id];
return it?.kind === "image" && Boolean(it.mediaId);
})
: false,
[board, selectedItemIds],
);
const handleNewSection = () => {
if (board) createSection(board.id);
@@ -192,7 +219,11 @@ export const BoardGridView = ({ active, adapter }: Props) => {
return (
<div
className="relative h-full w-full bg-ui-background"
className="relative h-full w-full overflow-hidden"
// Matches MoodboardWorkspace: desktop themes drive --st-bg, the webapp
// falls back to its --background token (#171717). overflow-hidden keeps
// the view root from ever scrolling — the grid scrolls internally.
style={{ background: "var(--st-bg, hsl(0 0% 9%))" }}
onDragOver={(e) => {
if (e.dataTransfer.types.includes("Files")) {
e.preventDefault();
@@ -206,6 +237,7 @@ export const BoardGridView = ({ active, adapter }: Props) => {
>
<BoardGridToolbar
boardName={board?.name ?? "Moodboard"}
onRenameBoard={(name) => renameBoard(ensureActiveBoard(), name)}
itemCount={board?.itemOrder.length ?? 0}
density={density}
onDensityChange={setDensity}
@@ -221,7 +253,7 @@ export const BoardGridView = ({ active, adapter }: Props) => {
onUpload={triggerUpload}
onLibrary={triggerGallery}
canPickLibrary={Boolean(adapter.renderLibraryPicker)}
onAddNote={addNote}
onAddNote={handleAddNote}
onAddColor={(color) => addColorItem(ensureActiveBoard(), color)}
onNewSection={handleNewSection}
/>
@@ -257,6 +289,9 @@ export const BoardGridView = ({ active, adapter }: Props) => {
onOpen={(id) => setOpenId(id)}
onUseReference={referenceById}
onDelete={(id) => board && removeItems(board.id, [id])}
editingTextId={editingTextId}
onEditText={setEditingTextId}
onCommitText={commitText}
sections={board?.sections}
onRenameSection={(id, name) =>
board && renameSection(board.id, id, name)
@@ -275,6 +310,7 @@ export const BoardGridView = ({ active, adapter }: Props) => {
<SelectionBar
count={selectedItemIds.size}
canUseReference={canUseReference}
sections={board?.sections ?? []}
onUseReference={referenceSelected}
onAssignToSection={handleAssignToSection}
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPalette } from "@fortawesome/pro-regular-svg-icons";
import { PopoverMenu } from "@storyteller/ui-popover";
import { Button } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
const DEFAULT_COLOR = "#6384F4"; // character-selected — seeds the picker
@@ -15,23 +16,32 @@ interface Props {
// board until the explicit "Add color" button — so dragging through the spectrum
// no longer spams the board with a swatch on every tick.
export const ColorPickerPopover = ({ onAdd }: Props) => (
<PopoverMenu
mode="default"
position="bottom"
panelTitle="Add a color"
panelClassName="w-60"
triggerIcon={<FontAwesomeIcon icon={faPalette} className="h-4 w-4" />}
buttonClassName="h-9 w-9 rounded-[10px] border-transparent bg-transparent p-0 text-base-fg/80 shadow-none hover:bg-base-fg/10 hover:text-base-fg"
>
{(close) => (
<ColorPickerPanel
onAdd={(color) => {
onAdd(color);
close();
}}
/>
)}
</PopoverMenu>
// Tooltip matches the sibling ToolbarIconButtons (bottom, 300ms, close on click).
<Tooltip content="Add color" position="bottom" delay={300} closeOnClick>
<PopoverMenu
mode="default"
position="bottom"
panelTitle="Add a color"
panelClassName="w-60"
triggerIcon={
<FontAwesomeIcon
icon={faPalette}
className="h-4 w-4"
aria-label="Add color"
/>
}
buttonClassName="h-9 w-9 rounded-[10px] border-transparent bg-transparent p-0 text-base-fg/80 shadow-none hover:bg-base-fg/10 hover:text-base-fg"
>
{(close) => (
<ColorPickerPanel
onAdd={(color) => {
onAdd(color);
close();
}}
/>
)}
</PopoverMenu>
</Tooltip>
);
const ColorPickerPanel = ({ onAdd }: Props) => {
@@ -10,6 +10,8 @@ import {
faChevronRight,
} from "@fortawesome/pro-regular-svg-icons";
import { faStar } from "@fortawesome/pro-solid-svg-icons";
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { BoardItem } from "../boards/boardTypes";
import { hostnameOf } from "../boards/linkMeta";
import { extractPalette } from "../boards/palette";
@@ -38,8 +40,10 @@ const KIND_LABEL: Record<BoardItem["kind"], string> = {
color: "Color",
};
// Full-screen lightbox + inspector. Large media on the left, metadata / tags /
// palette / actions on the right. Keyboard: Esc closes, ←/→ navigate.
// Lightbox + inspector. Mirrors the shared LightboxModal design: a bounded,
// centered, draggable/resizable panel with the media on the left (object-contain
// on a dark plate) and a fixed-width info/actions column on the right. The shared
// Modal owns the backdrop, Esc, drag/resize and a11y; we own ←/→ navigation.
export const ItemInspector = ({
item,
hasPrev,
@@ -59,13 +63,12 @@ export const ItemInspector = ({
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
else if (e.key === "ArrowLeft" && hasPrev) onPrev();
if (e.key === "ArrowLeft" && hasPrev) onPrev();
else if (e.key === "ArrowRight" && hasNext) onNext();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose, onPrev, onNext, hasPrev, hasNext]);
}, [onPrev, onNext, hasPrev, hasNext]);
// Pull a palette for images, keyed on the image src — NOT the whole item, so
// editing tags/rating (which produce a new item object) doesn't re-decode the
@@ -83,15 +86,6 @@ export const ItemInspector = ({
};
}, [imageSrc]);
// Lock background scroll while the lightbox is open.
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, []);
const commitTag = () => {
const t = tagDraft.trim().toLowerCase();
if (t) onAddTag(t);
@@ -99,42 +93,39 @@ export const ItemInspector = ({
};
return (
<div
role="dialog"
aria-modal="true"
aria-label="Item details"
className="fixed inset-0 z-50 flex bg-black/80 backdrop-blur-md"
<Modal
isOpen
onClose={onClose}
accessibleTitle="Item details"
className="rounded-xl bg-ui-modal h-[760px] w-[1200px] max-w-screen min-w-[1000px] min-h-[600px] p-4"
draggable
resizable
expandable
showClose
>
{/* Backdrop click closes */}
<div className="absolute inset-0" onClick={onClose} />
{/* Invisible drag strip along the top, matching the shared lightbox. */}
<Modal.DragHandle>
<div className="absolute left-0 top-0 z-20 h-12 w-full cursor-move rounded-t-xl" />
</Modal.DragHandle>
<NavArrow side="left" show={hasPrev} onClick={onPrev} />
<NavArrow side="right" show={hasNext} onClick={onNext} />
<div className="relative z-10 flex w-full items-stretch gap-4 p-6">
{/* Media */}
<div className="flex min-w-0 flex-1 items-center justify-center">
<div className="flex h-full gap-4">
{/* Media panel — flexible width, dark plate, contained media. */}
<div className="group/nav relative flex h-full flex-1 items-center justify-center overflow-hidden rounded-l-xl bg-black/30">
<Media item={item} />
{hasPrev && <NavArrow side="left" onClick={onPrev} />}
{hasNext && <NavArrow side="right" onClick={onNext} />}
</div>
{/* Inspector panel — double-bezel */}
<div className="w-[320px] shrink-0 rounded-[20px] bg-[var(--mb-plane-1,rgba(127,127,127,0.05))] p-1.5 ring-1 ring-[var(--mb-hairline,rgba(127,127,127,0.14))]">
<div className="flex h-full flex-col rounded-[14px] bg-ui-panel p-4">
<div className="mb-4 flex items-center justify-between">
<span className="rounded-full border border-ui-divider px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.18em] text-base-fg/55">
{/* Info + actions — fixed width, scrollable body, buttons pinned bottom. */}
<div className="flex h-full w-[280px] shrink-0 flex-col">
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto pb-2 text-base-fg">
<div className="flex items-center gap-2.5 border-b border-white/5 pb-3 pr-10">
<span className="rounded-full border border-ui-panel-border px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.18em] text-base-fg/55">
{KIND_LABEL[item.kind]}
</span>
<button
type="button"
aria-label="Close"
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-full text-base-fg/60 transition-colors hover:bg-base-fg/10 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faXmark} className="h-4 w-4" />
</button>
</div>
{/* Rating */}
<Section title="Rating">
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((n) => (
@@ -158,7 +149,6 @@ export const ItemInspector = ({
</div>
</Section>
{/* Tags */}
<Section title="Tags">
<div className="flex flex-wrap gap-1.5">
{item.tags.map((tag) => (
@@ -166,7 +156,7 @@ export const ItemInspector = ({
key={tag}
type="button"
onClick={() => onRemoveTag(tag)}
className="group/tag flex items-center gap-1 rounded-full bg-base-fg/10 px-2.5 py-1 text-[11px] font-medium text-base-fg/80 transition-colors hover:bg-danger/15 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-danger"
className="group/tag flex items-center gap-1 rounded-full bg-base-fg/10 px-2.5 py-1 text-[11px] font-medium text-base-fg/80 transition-colors hover:bg-red/15 hover:text-red focus:outline-none focus-visible:ring-2 focus-visible:ring-red"
>
{tag}
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
@@ -185,7 +175,6 @@ export const ItemInspector = ({
</div>
</Section>
{/* Palette */}
{item.kind === "image" && palette.length > 0 && (
<Section title="Palette">
<div className="flex flex-wrap gap-1.5">
@@ -213,39 +202,32 @@ export const ItemInspector = ({
</button>
</Section>
)}
</div>
<div className="flex-1" />
{/* Actions */}
<div className="flex flex-col gap-2">
{item.kind === "image" && (
<button
type="button"
onClick={onUseReference}
className="group flex items-center justify-center gap-2.5 rounded-full bg-primary py-2 pl-5 pr-2 text-sm font-medium text-white transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:bg-primary/90 active:scale-[0.98] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
Use as reference
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/15 transition-transform duration-200 group-hover:translate-x-0.5 group-hover:scale-105">
<FontAwesomeIcon
icon={faWandMagicSparkles}
className="h-3.5 w-3.5"
/>
</span>
</button>
)}
<button
type="button"
onClick={onDelete}
className="flex items-center justify-center gap-2 rounded-full border border-ui-divider py-2.5 text-sm font-medium text-base-fg/70 transition-colors hover:bg-danger/10 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-danger"
{/* Actions — shared Button, lightbox-style 2-col grid. */}
<div className="mt-4 grid grid-cols-2 gap-1.5">
{item.kind === "image" && (
<Button
variant="primary"
icon={faWandMagicSparkles}
onClick={onUseReference}
className="w-full col-span-2 py-1.5 text-[13px]"
>
<FontAwesomeIcon icon={faTrashCan} className="h-3.5 w-3.5" />
Delete
</button>
</div>
Use as reference
</Button>
)}
<Button
variant="destructive"
icon={faTrashCan}
onClick={onDelete}
className="w-full col-span-2 py-1.5 text-[13px]"
>
Delete
</Button>
</div>
</div>
</div>
</div>
</Modal>
);
};
@@ -256,10 +238,8 @@ const Section = ({
title: string;
children: React.ReactNode;
}) => (
<div className="mb-4">
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-[0.18em] text-base-fg/45">
{title}
</h3>
<div className="space-y-1.5">
<div className="text-sm font-medium text-base-fg/90">{title}</div>
{children}
</div>
);
@@ -271,7 +251,7 @@ const Media = ({ item }: { item: BoardItem }) => {
<img
src={item.src}
alt={item.caption || ""}
className="max-h-full max-w-full rounded-xl object-contain shadow-2xl"
className="h-full w-full object-contain"
/>
);
case "video":
@@ -283,7 +263,7 @@ const Media = ({ item }: { item: BoardItem }) => {
loop
controls
playsInline
className="max-h-full max-w-full rounded-xl shadow-2xl"
className="h-full w-full object-contain"
/>
);
case "color":
@@ -334,31 +314,31 @@ const Media = ({ item }: { item: BoardItem }) => {
}
};
// Hover-reveal navigation arrow, styled to match the shared LightboxModal.
const NavArrow = ({
side,
show,
onClick,
}: {
side: "left" | "right";
show: boolean;
onClick: () => void;
}) => {
if (!show) return null;
return (
<button
type="button"
aria-label={side === "left" ? "Previous" : "Next"}
onClick={onClick}
className={[
"absolute top-1/2 z-20 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full",
"border border-white/15 bg-black/40 text-white/85 backdrop-blur-md transition-colors hover:bg-black/60 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70",
side === "left" ? "left-4" : "right-4",
].join(" ")}
>
<FontAwesomeIcon
icon={side === "left" ? faChevronLeft : faChevronRight}
className="h-4 w-4"
/>
</button>
);
};
}) => (
<button
type="button"
aria-label={side === "left" ? "Previous" : "Next"}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className={[
"absolute top-1/2 z-30 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full",
"bg-black/50 text-white/70 opacity-0 transition-opacity duration-200",
"hover:bg-black/70 hover:text-white group-hover/nav:opacity-100 focus:outline-none",
side === "left" ? "left-3" : "right-3",
].join(" ")}
>
<FontAwesomeIcon
icon={side === "left" ? faChevronLeft : faChevronRight}
className="text-lg"
/>
</button>
);
@@ -11,6 +11,9 @@ import { BoardSection } from "../boards/boardTypes";
interface Props {
count: number;
// True when the selection has at least one reference-capable item (an image
// with a media token). Notes/colors/links can't be used as a reference.
canUseReference: boolean;
sections: BoardSection[];
onUseReference: () => void;
onAssignToSection: (sectionId: string | null) => void;
@@ -23,6 +26,7 @@ interface Props {
// selection into a section lane; "Use as reference" is the board→generate loop.
export const SelectionBar = ({
count,
canUseReference,
sections,
onUseReference,
onAssignToSection,
@@ -33,44 +37,47 @@ export const SelectionBar = ({
if (count === 0) return null;
return (
<div className="pointer-events-none absolute inset-x-0 bottom-6 z-30 flex justify-center">
<div className="glass pointer-events-auto flex items-center gap-2 rounded-full border border-ui-divider py-1.5 pl-4 pr-1.5 shadow-[0_12px_32px_-12px_rgba(0,0,0,0.5)]">
<span className="text-sm font-medium text-base-fg">
{/* Styled to match the webapp library's multiselect bar: solid pill, filled
control buttons, red delete, round clear. */}
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-ui-panel-border bg-ui-panel/95 px-2.5 py-2 shadow-xl backdrop-blur">
<span className="px-1 text-sm font-medium text-white/80">
{count} selected
</span>
{/* Triage shortcut discoverability — these keys act on the selection. */}
<span className="hidden items-center gap-1 text-[11px] text-base-fg/45 sm:flex">
<span className="hidden items-center gap-1 text-[11px] text-white/40 sm:flex">
<Kbd>1–5</Kbd> rate
<Kbd>0</Kbd> clear
</span>
<div className="mx-1 h-5 w-px bg-ui-divider" />
<MoveToMenu
sections={sections}
onAssignToSection={onAssignToSection}
onCreateSectionWithSelection={onCreateSectionWithSelection}
/>
<button
type="button"
onClick={onUseReference}
className="flex items-center gap-2 rounded-full px-3 py-1.5 text-sm text-base-fg/80 transition-colors duration-150 hover:bg-primary/15 hover:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faWandMagicSparkles} className="h-3.5 w-3.5" />
Use as reference
</button>
{canUseReference && (
<button
type="button"
onClick={onUseReference}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faWandMagicSparkles} className="text-xs" />
Use as reference
</button>
)}
<button
type="button"
onClick={onDelete}
className="flex items-center gap-2 rounded-full px-3 py-1.5 text-sm text-base-fg/80 transition-colors duration-150 hover:bg-danger/15 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-danger"
className="flex items-center gap-2 rounded-full bg-red/90 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-red focus:outline-none focus-visible:ring-2 focus-visible:ring-red"
>
<FontAwesomeIcon icon={faTrashCan} className="h-3.5 w-3.5" />
<FontAwesomeIcon icon={faTrashCan} className="text-xs" />
Delete
</button>
<button
type="button"
aria-label="Clear selection"
onClick={onClear}
className="flex h-8 w-8 items-center justify-center rounded-full text-base-fg/60 transition-colors duration-150 hover:bg-base-fg/10 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
className="flex h-8 w-8 items-center justify-center rounded-full bg-ui-controls/60 text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faXmark} className="h-4 w-4" />
<FontAwesomeIcon icon={faXmark} />
</button>
</div>
</div>
@@ -117,16 +124,16 @@ const MoveToMenu = ({
onClick={() => setOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={open}
className="flex items-center gap-2 rounded-full px-3 py-1.5 text-sm text-base-fg/80 transition-colors duration-150 hover:bg-base-fg/10 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faLayerGroup} className="h-3.5 w-3.5" />
<FontAwesomeIcon icon={faLayerGroup} className="text-xs" />
Move to
</button>
{open && (
<div
role="menu"
className="absolute bottom-[calc(100%+8px)] left-1/2 z-40 max-h-72 w-52 -translate-x-1/2 overflow-y-auto rounded-2xl border border-ui-divider bg-ui-panel/95 p-1.5 shadow-[0_16px_40px_-12px_rgba(0,0,0,0.55)] backdrop-blur-xl"
className="absolute bottom-[calc(100%+8px)] left-1/2 z-40 max-h-72 w-52 -translate-x-1/2 overflow-y-auto rounded-lg border border-ui-panel-border bg-ui-panel p-2 shadow-xl"
>
<MenuItem
label="Ungrouped"
@@ -134,7 +141,7 @@ const MoveToMenu = ({
onClick={() => pick(() => onAssignToSection(null))}
/>
{sections.length > 0 && (
<div className="my-1 h-px bg-ui-divider/70" />
<div className="mx-1.5 my-1 border-t border-ui-panel-border" />
)}
{sections.map((s) => (
<MenuItem
@@ -143,7 +150,7 @@ const MoveToMenu = ({
onClick={() => pick(() => onAssignToSection(s.id))}
/>
))}
<div className="my-1 h-px bg-ui-divider/70" />
<div className="mx-1.5 my-1 border-t border-ui-panel-border" />
<MenuItem
label="New section"
icon={faPlus}
@@ -174,13 +181,13 @@ const MenuItem = ({
role="menuitem"
onClick={onClick}
className={[
"flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-left text-sm transition-colors duration-150",
"flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
accent
? "text-primary hover:bg-primary/10"
? "text-primary hover:bg-ui-controls/50"
: muted
? "text-base-fg/55 hover:bg-base-fg/10 hover:text-base-fg"
: "text-base-fg/85 hover:bg-base-fg/10",
? "text-white/50 hover:bg-ui-controls/50 hover:text-white"
: "text-white hover:bg-ui-controls/50",
].join(" ")}
>
{icon ? (
@@ -193,7 +200,7 @@ const MenuItem = ({
);
const Kbd = ({ children }: { children: ReactNode }) => (
<kbd className="rounded border border-ui-divider bg-base-fg/5 px-1 py-px font-sans text-[10px] text-base-fg/70">
<kbd className="rounded border border-ui-panel-border bg-white/5 px-1 py-px font-sans text-[10px] text-white/60">
{children}
</kbd>
);
@@ -1,8 +1,5 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faMagnifyingGlass,
faXmark,
} from "@fortawesome/pro-regular-svg-icons";
import { faMagnifyingGlass, faXmark } from "@fortawesome/pro-regular-svg-icons";
interface Props {
query: string;
@@ -23,7 +20,7 @@ export const SmartSearchBar = ({
}: Props) => {
return (
<div className="flex min-w-0 items-center gap-2">
<div className="glass flex h-9 min-w-[200px] items-center gap-2 rounded-full border border-ui-divider px-3 focus-within:ring-2 focus-within:ring-primary/50">
<div className="bg-ui-controls flex h-9 min-w-[200px] transition-all items-center gap-2 rounded-xl border border-ui-controls px-3 focus-within:ring-2 focus-within:ring-primary/50">
<FontAwesomeIcon
icon={faMagnifyingGlass}
className="h-3.5 w-3.5 text-base-fg/45"
@@ -17,7 +17,7 @@ interface ToolbarShellProps {
export const ToolbarShell = ({ children, className }: ToolbarShellProps) => (
<div
className={twMerge(
"glass pointer-events-auto flex items-center gap-2 rounded-2xl border border-ui-divider px-2 py-1.5 text-base-fg shadow-[0_8px_28px_-12px_rgba(0,0,0,0.45)]",
"glass pointer-events-auto flex items-center gap-2 rounded-2xl border border-ui-divider p-2 text-base-fg shadow-[0_8px_28px_-12px_rgba(0,0,0,0.45)]",
className,
)}
>