From a7533261dccc8a2e0b819781a41f27cdb76a97e7 Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Sun, 3 May 2026 10:01:22 +0900 Subject: [PATCH 01/52] =?UTF-8?q?=E2=9C=A8=20feat(user-style-management):?= =?UTF-8?q?=20gallery=20pin=20UI=20with=20section=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC: 20260503-0854_user-style-management Progress: Phase 1a complete - StyleCard: ★ pin button (hover fade-in, bounce animation, stopPropagation) - Pinned section + All Styles collapsible when pins exist - Flat grid when no pins (backward compatible) - Preview header: ★ pin toggle next to style name - Custom badge for user styles - Scroll position preserved on pin toggle - StyleEntry type: added pinned/source fields with backfill defaults Next: Phase 1b — Engine + API layer --- web-ui/src/components/deck/SpecStepNav.tsx | 102 +++++++++++++++++++-- web-ui/src/services/deckService.ts | 9 +- 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/web-ui/src/components/deck/SpecStepNav.tsx b/web-ui/src/components/deck/SpecStepNav.tsx index df5e328d..7396ae24 100644 --- a/web-ui/src/components/deck/SpecStepNav.tsx +++ b/web-ui/src/components/deck/SpecStepNav.tsx @@ -16,7 +16,7 @@ "use client" import { useCallback, useEffect, useRef, useState } from "react" -import { Layers, FileText, Palette, ArrowLeft, Check } from "lucide-react" +import { Layers, FileText, Palette, ArrowLeft, Check, Star } from "lucide-react" import Markdown from "react-markdown" import type { Components } from "react-markdown" import remarkGfm from "remark-gfm" @@ -220,6 +220,17 @@ export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect, const [previewLoading, setPreviewLoading] = useState(false) const galleryScrollRef = useRef(0) const galleryContainerRef = useRef(null) + const [allStylesOpen, setAllStylesOpen] = useState(false) + + // Pin toggle — optimistic local state (Phase 1a: local only, API in 1c) + // Preserve scroll position across re-renders caused by section layout changes + const handlePinToggle = useCallback((name: string) => { + const scrollTop = galleryContainerRef.current?.scrollTop ?? 0 + setStyles(prev => prev.map(s => s.name === name ? { ...s, pinned: !s.pinned } : s)) + requestAnimationFrame(() => { + if (galleryContainerRef.current) galleryContainerRef.current.scrollTop = scrollTop + }) + }, []) // Sync mode when content appears externally (e.g. polling updates art-direction) const userRequestedGallery = useRef(false) @@ -316,13 +327,17 @@ export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect, setPreviewLoading(false) } + const pinnedStyles = styles.filter(s => s.pinned) + const hasPins = pinnedStyles.length > 0 + const unpinnedStyles = styles.filter(s => !s.pinned) + return (
{/* Header */}

Choose a Style

-

Click a style to preview

+

Click to preview · ★ to pin favorites

{content && (
+ ) : hasPins ? ( + /* Sectioned layout: Pinned + All Styles collapsible */ +
+ {/* Pinned section */} +
+
+ +

Pinned

+
+
+ {pinnedStyles.map((style, i) => ( + + ))} +
+
+ {/* All Styles collapsible */} +
+ + {allStylesOpen && ( +
+ {unpinnedStyles.map((style, i) => ( + + ))} +
+ )} +
+
) : ( + /* Flat layout: no pins */
{styles.map((style, i) => ( - + ))}
)} @@ -356,6 +406,9 @@ export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect, // PREVIEW state if (adMode === "preview" && preview) { + const previewStyle = styles.find(s => s.name === preview.name) + const previewPinned = previewStyle?.pinned ?? false + const handleSelect = () => { if (onStyleSelect) onStyleSelect(preview.name) if (content) setAdMode("result") @@ -374,10 +427,17 @@ export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect, > -
+

{preview.name}

-

Preview all slides — select to apply

+
+

Preview all slides — select to apply

+ )}
-

{style.name}

+
+

{style.name}

+ {style.source === "user" && ( + Custom + )} +
{style.description && (

{style.description}

)} diff --git a/web-ui/src/services/deckService.ts b/web-ui/src/services/deckService.ts index dedb5e37..ee5f0de0 100644 --- a/web-ui/src/services/deckService.ts +++ b/web-ui/src/services/deckService.ts @@ -432,6 +432,8 @@ export interface StyleEntry { name: string description: string coverHtml: string + pinned: boolean + source: "builtin" | "user" } /** @@ -447,7 +449,12 @@ export async function fetchStyles(idToken: string): Promise { }) if (!res.ok) return [] const data = await res.json() - return data.styles || [] + // Backfill defaults for APIs that don't yet return pinned/source + return (data.styles || []).map((s: Partial) => ({ + ...s, + pinned: s.pinned ?? false, + source: s.source ?? "builtin", + })) as StyleEntry[] } /** From fa2ea33bc3d781ad29503b5de38ea015fc7857ab Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Sun, 3 May 2026 10:03:33 +0900 Subject: [PATCH 02/52] =?UTF-8?q?=E2=9C=A8=20feat(user-style-management):?= =?UTF-8?q?=20Engine=20+=20MCP=20Local=20pin/filter=20infrastructure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC: 20260503-0854_user-style-management Progress: Phase 1b complete - config.py: get_state() / update_state() for state.json - reference/__init__.py: filter_styles() pure function (I/O-free) - api.py: list_styles_filtered() filesystem entry point - tools.py: list_styles with include_all, browser open removed - server.py: include_all parameter on MCP tool - server_acp.py: fixed to use tools.py (user-local styles now visible) - 21 tests passing (9 new) Next: Phase 1c — Local API Routes + frontend API connection --- mcp-local/server.py | 8 +-- mcp-local/server_acp.py | 11 +-- mcp-local/tools.py | 15 ++-- skill/sdpm/api.py | 35 ++++++++++ skill/sdpm/config.py | 33 +++++++++ skill/sdpm/reference/__init__.py | 34 ++++++++++ tests/test_styles_resolution.py | 113 +++++++++++++++++++++++++++++++ 7 files changed, 234 insertions(+), 15 deletions(-) diff --git a/mcp-local/server.py b/mcp-local/server.py index 6859355c..d467c8d2 100644 --- a/mcp-local/server.py +++ b/mcp-local/server.py @@ -245,15 +245,15 @@ def list_asset_sources() -> str: @mcp.tool() -def list_styles() -> str: +def list_styles(include_all: bool = False) -> str: """List available design styles for presentations. - Workflow equivalent: ``examples styles`` + Default returns pinned + user styles only. Pass include_all=True for all. Returns: - JSON with list of styles (name + description). + JSON with list of styles (name, description, pinned, source). """ - return json.dumps(_list_styles(skill_dir=_SKILL_DIR), ensure_ascii=False) + return json.dumps(_list_styles(skill_dir=_SKILL_DIR, include_all=include_all), ensure_ascii=False) @mcp.tool() diff --git a/mcp-local/server_acp.py b/mcp-local/server_acp.py index aba97f6e..dd53dbff 100644 --- a/mcp-local/server_acp.py +++ b/mcp-local/server_acp.py @@ -41,15 +41,16 @@ @mcp.tool() -def list_styles() -> str: +def list_styles(include_all: bool = False) -> str: """List available design styles for presentations. + Default returns pinned + user styles only. Pass include_all=True for all. + Returns: - JSON with list of styles (name + description). + JSON with list of styles (name, description, pinned, source). """ - from sdpm.reference import list_styles as _list_styles - styles_dir = _SKILL_DIR / "references" / "examples" / "styles" - return json.dumps({"styles": _list_styles(styles_dir)}, ensure_ascii=False) + from tools import list_styles as _list_styles + return json.dumps(_list_styles(skill_dir=_SKILL_DIR, include_all=include_all), ensure_ascii=False) # --------------------------------------------------------------------------- diff --git a/mcp-local/tools.py b/mcp-local/tools.py index 6f153d30..98821a33 100644 --- a/mcp-local/tools.py +++ b/mcp-local/tools.py @@ -100,18 +100,21 @@ def list_asset_sources(skill_dir: Path) -> dict[str, Any]: return {"sources": list_sources()} -def list_styles(skill_dir: Path) -> dict[str, Any]: - """List available design styles and open gallery in browser. +def list_styles(skill_dir: Path, include_all: bool = False) -> dict[str, Any]: + """List available design styles with pin/source metadata. Searches user-local styles directory (``~/.config/sdpm/styles/``) in addition to the package-bundled styles. User-local entries shadow bundled ones with the same name. + + Default returns pinned + user styles only (or all if no pins). + Pass include_all=True to return everything. """ - from sdpm.api import get_styles_dirs - from sdpm.reference import list_styles_merged, open_styles_gallery + from sdpm.api import get_styles_dirs, list_styles_filtered + from sdpm.config import get_state styles_dirs = get_styles_dirs() - open_styles_gallery(styles_dirs) - return {"styles": list_styles_merged(styles_dirs)} + pinned = get_state().get("pinned_styles", []) + return {"styles": list_styles_filtered(styles_dirs, pinned, include_all)} def read_examples(names: list[str], skill_dir: Path) -> dict[str, Any]: diff --git a/skill/sdpm/api.py b/skill/sdpm/api.py index 4ef76d5a..073fc2a5 100644 --- a/skill/sdpm/api.py +++ b/skill/sdpm/api.py @@ -41,6 +41,41 @@ def get_styles_dirs() -> list[Path]: return _get_resource_dirs("SDPM_STYLES_DIR", "styles", BUNDLED_STYLES_DIR) +def list_styles_filtered( + styles_dirs: list[Path], + pinned_names: list[str], + include_all: bool = False, +) -> list[dict]: + """List styles with pin/source metadata, optionally filtered. + + Filesystem-based entry point for MCP Local / CLI. + Determines source ("user" vs "builtin") by checking whether each style + lives in the user-local directory. + + Args: + styles_dirs: Ordered directories from get_styles_dirs(). + pinned_names: Pinned style names from state.json. + include_all: Pass through to filter_styles(). + + Returns: + Filtered list with pinned/source metadata. + """ + from sdpm.config import get_user_config_dir + from sdpm.reference import filter_styles, list_styles_merged + + user_dir = get_user_config_dir() / "styles" + raw = list_styles_merged(styles_dirs) + + # Tag source based on whether the style file exists in user dir + for s in raw: + if (user_dir / f"{s['name']}.html").exists(): + s["source"] = "user" + else: + s["source"] = "builtin" + + return filter_styles(raw, pinned_names, include_all) + + def _find_style_in_dirs(name: str, styles_dirs: list[Path]) -> Path | None: """Search for a style HTML by name across the given directories. diff --git a/skill/sdpm/config.py b/skill/sdpm/config.py index 0e81eb2b..c43ca767 100644 --- a/skill/sdpm/config.py +++ b/skill/sdpm/config.py @@ -104,3 +104,36 @@ def get_output_dir() -> Path: def get_extra_sources() -> list[dict]: """Extra asset sources list.""" return get_config().get("extra_sources", []) + + +# ── State (app-managed, separate from user-editable config) ── + + +def get_state() -> dict: + """Load app state from state.json. Returns empty dict if missing. + + state.json stores app-managed data (pinned styles, etc.) separately + from config.json which is user-editable settings. + """ + state_path = get_user_config_dir() / "state.json" + if state_path.exists(): + with open(state_path) as f: + return json.load(f) + return {} + + +def update_state(key: str, value: object) -> None: + """Update a single key in state.json (read-modify-write). + + Creates the file and parent directory if they don't exist. + """ + config_dir = get_user_config_dir() + config_dir.mkdir(parents=True, exist_ok=True) + state_path = config_dir / "state.json" + state = {} + if state_path.exists(): + with open(state_path) as f: + state = json.load(f) + state[key] = value + with open(state_path, "w") as f: + json.dump(state, f, ensure_ascii=False, indent=2) diff --git a/skill/sdpm/reference/__init__.py b/skill/sdpm/reference/__init__.py index 7f277c8d..967c8901 100644 --- a/skill/sdpm/reference/__init__.py +++ b/skill/sdpm/reference/__init__.py @@ -394,3 +394,37 @@ def list_styles_merged(styles_dirs: list[Path]) -> list[dict[str, str]]: seen.add(item["name"]) result.append(item) return result + + +def filter_styles( + styles: list[dict], + pinned_names: list[str], + include_all: bool = False, +) -> list[dict]: + """Add pinned/source metadata and optionally filter styles. + + Pure function — no I/O. Usable by both MCP Local (filesystem) and + MCP Server (S3) since style retrieval is the caller's responsibility. + + Args: + styles: List of style dicts. Each must have ``name``. May already + have ``source``; defaults to ``"builtin"`` if absent. + pinned_names: List of pinned style names. + include_all: If True, return all styles. If False, return only + pinned + user styles (or all if no pins exist). + + Returns: + Styles with ``pinned: bool`` and ``source: "builtin"|"user"`` added. + """ + pin_set = set(pinned_names) + result = [] + for s in styles: + enriched = {**s, "pinned": s["name"] in pin_set} + if "source" not in enriched: + enriched["source"] = "builtin" + result.append(enriched) + + if include_all or not pin_set: + return result + + return [s for s in result if s["pinned"] or s["source"] == "user"] diff --git a/tests/test_styles_resolution.py b/tests/test_styles_resolution.py index 07341cde..a6205e3a 100644 --- a/tests/test_styles_resolution.py +++ b/tests/test_styles_resolution.py @@ -149,3 +149,116 @@ def test_list_styles_single_dir_still_works(temp_styles_dir: Path) -> None: names = [s["name"] for s in result] assert "elegant-dark" in names assert "custom-brand" in names + + +# --------------------------------------------------------------------------- +# filter_styles +# --------------------------------------------------------------------------- + + +from sdpm.reference import filter_styles + + +def test_filter_styles_adds_pinned_metadata() -> None: + styles = [{"name": "a", "description": ""}, {"name": "b", "description": ""}] + result = filter_styles(styles, pinned_names=["a"], include_all=True) + a = next(s for s in result if s["name"] == "a") + b = next(s for s in result if s["name"] == "b") + assert a["pinned"] is True + assert b["pinned"] is False + + +def test_filter_styles_defaults_source_to_builtin() -> None: + styles = [{"name": "a", "description": ""}] + result = filter_styles(styles, pinned_names=[]) + assert result[0]["source"] == "builtin" + + +def test_filter_styles_preserves_existing_source() -> None: + styles = [{"name": "a", "description": "", "source": "user"}] + result = filter_styles(styles, pinned_names=[]) + assert result[0]["source"] == "user" + + +def test_filter_styles_include_all_returns_everything() -> None: + styles = [ + {"name": "a", "description": "", "source": "builtin"}, + {"name": "b", "description": "", "source": "user"}, + ] + result = filter_styles(styles, pinned_names=["a"], include_all=True) + assert len(result) == 2 + + +def test_filter_styles_no_pins_returns_all() -> None: + styles = [ + {"name": "a", "description": ""}, + {"name": "b", "description": ""}, + ] + result = filter_styles(styles, pinned_names=[], include_all=False) + assert len(result) == 2 + + +def test_filter_styles_with_pins_filters_to_pinned_and_user() -> None: + styles = [ + {"name": "a", "description": "", "source": "builtin"}, + {"name": "b", "description": "", "source": "user"}, + {"name": "c", "description": "", "source": "builtin"}, + ] + result = filter_styles(styles, pinned_names=["a"], include_all=False) + names = [s["name"] for s in result] + assert "a" in names # pinned + assert "b" in names # user + assert "c" not in names # neither pinned nor user + + +# --------------------------------------------------------------------------- +# list_styles_filtered (filesystem integration) +# --------------------------------------------------------------------------- + + +from sdpm.api import list_styles_filtered + + +def test_list_styles_filtered_tags_user_source(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setenv("APPDATA", str(tmp_path)) + + user_dir = tmp_path / "sdpm" / "styles" + user_dir.mkdir(parents=True) + (user_dir / "my-style.html").write_text("My Style") + + bundled = tmp_path / "bundled" + bundled.mkdir() + (bundled / "default.html").write_text("Default") + + result = list_styles_filtered([user_dir, bundled], pinned_names=[], include_all=True) + my = next(s for s in result if s["name"] == "my-style") + default = next(s for s in result if s["name"] == "default") + assert my["source"] == "user" + assert default["source"] == "builtin" + + +# --------------------------------------------------------------------------- +# get_state / update_state +# --------------------------------------------------------------------------- + + +from sdpm.config import get_state, update_state + + +def test_get_state_returns_empty_when_no_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setenv("APPDATA", str(tmp_path)) + assert get_state() == {} + + +def test_update_state_creates_and_updates(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setenv("APPDATA", str(tmp_path)) + update_state("pinned_styles", ["a", "b"]) + state = get_state() + assert state["pinned_styles"] == ["a", "b"] + + update_state("pinned_styles", ["a"]) + state = get_state() + assert state["pinned_styles"] == ["a"] From db08f2d6d56b1df2611ea29982f290f0cebda1d3 Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Sun, 3 May 2026 10:05:58 +0900 Subject: [PATCH 03/52] =?UTF-8?q?=E2=9C=A8=20feat(user-style-management):?= =?UTF-8?q?=20Local=20API=20routes=20+=20frontend=20API=20connection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC: 20260503-0854_user-style-management Progress: Phase 1c (Local portion) complete - sdpmPaths.ts: shared helpers for config dir, state.json, style listing - GET /api/styles: user-local + bundled with pinned/source metadata - GET /api/styles/[name]: search user-local first, then bundled - POST /api/styles/pin: pin toggle persisted to state.json - deckService.ts: pinStyle() API function - SpecStepNav: optimistic pin toggle connected to API Next: Phase 1c — Cloud layer (DynamoDB pins, MCP Server) --- web-ui/src/app/api/styles/[name]/route.ts | 20 ++++--- web-ui/src/app/api/styles/pin/route.ts | 19 +++++++ web-ui/src/app/api/styles/route.ts | 36 +++++++------ web-ui/src/components/deck/SpecStepNav.tsx | 13 +++-- web-ui/src/lib/local/sdpmPaths.ts | 61 ++++++++++++++++++++++ web-ui/src/services/deckService.ts | 17 ++++++ 6 files changed, 139 insertions(+), 27 deletions(-) create mode 100644 web-ui/src/app/api/styles/pin/route.ts create mode 100644 web-ui/src/lib/local/sdpmPaths.ts diff --git a/web-ui/src/app/api/styles/[name]/route.ts b/web-ui/src/app/api/styles/[name]/route.ts index 822b71f3..7e776667 100644 --- a/web-ui/src/app/api/styles/[name]/route.ts +++ b/web-ui/src/app/api/styles/[name]/route.ts @@ -1,15 +1,21 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: MIT-0 -/** Local Style Detail API — returns full HTML for a single style. */ +/** Local Style Detail API — returns full HTML for a single style (user-local first, then bundled). */ import fs from "fs" import path from "path" - -const STYLES_DIR = path.resolve(process.cwd(), "..", "skill", "references", "examples", "styles") +import { BUNDLED_STYLES_DIR, getUserStylesDir } from "@/lib/local/sdpmPaths" export async function GET(_req: Request, { params }: { params: Promise<{ name: string }> }) { const { name } = await params - const filePath = path.join(STYLES_DIR, `${name}.html`) - if (!fs.existsSync(filePath)) return Response.json({ fullHtml: "" }, { status: 404 }) - const fullHtml = fs.readFileSync(filePath, "utf-8") - return Response.json({ fullHtml }) + if (!/^[a-zA-Z0-9_-]+$/.test(name)) return Response.json({ fullHtml: "" }, { status: 400 }) + + // Search user-local first, then bundled + for (const dir of [getUserStylesDir(), BUNDLED_STYLES_DIR]) { + const filePath = path.join(dir, `${name}.html`) + if (fs.existsSync(filePath)) { + const fullHtml = fs.readFileSync(filePath, "utf-8") + return Response.json({ fullHtml }) + } + } + return Response.json({ fullHtml: "" }, { status: 404 }) } diff --git a/web-ui/src/app/api/styles/pin/route.ts b/web-ui/src/app/api/styles/pin/route.ts new file mode 100644 index 00000000..0a026228 --- /dev/null +++ b/web-ui/src/app/api/styles/pin/route.ts @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 +/** Local Pin API — toggle pin state for a style in state.json. */ +import { getState, updateState } from "@/lib/local/sdpmPaths" + +export async function POST(req: Request) { + const { name, pinned } = await req.json() as { name: string; pinned: boolean } + if (!name || typeof pinned !== "boolean") { + return Response.json({ error: "name and pinned required" }, { status: 400 }) + } + + const current: string[] = (getState().pinned_styles as string[]) || [] + const updated = pinned + ? [...new Set([...current, name])] + : current.filter(n => n !== name) + + updateState("pinned_styles", updated) + return Response.json({ pinned_styles: updated }) +} diff --git a/web-ui/src/app/api/styles/route.ts b/web-ui/src/app/api/styles/route.ts index 8234b8a5..1743e658 100644 --- a/web-ui/src/app/api/styles/route.ts +++ b/web-ui/src/app/api/styles/route.ts @@ -1,21 +1,25 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: MIT-0 -/** Local Styles API — lists available style HTML files from skill/references/examples/styles/. */ -import fs from "fs" -import path from "path" - -const STYLES_DIR = path.resolve(process.cwd(), "..", "skill", "references", "examples", "styles") +/** Local Styles API — lists styles from bundled + user-local directories with pin/source metadata. */ +import { BUNDLED_STYLES_DIR, getUserStylesDir, getState, listStylesFromDir } from "@/lib/local/sdpmPaths" export async function GET() { - if (!fs.existsSync(STYLES_DIR)) return Response.json({ styles: [] }) - const files = fs.readdirSync(STYLES_DIR).filter(f => f.endsWith(".html")) - const styles = files.map(f => { - const name = f.replace(/\.html$/, "") - const html = fs.readFileSync(path.join(STYLES_DIR, f), "utf-8") - // Extract first slide as cover preview (content before second
or full) - const coverEnd = html.indexOf(" 0 ? html.slice(0, coverEnd) + "
" : html - return { name, coverHtml } - }) - return Response.json({ styles }) + const userDir = getUserStylesDir() + const pinnedNames: string[] = (getState().pinned_styles as string[]) || [] + const pinSet = new Set(pinnedNames) + + // Merge: user-local first (shadows bundled with same name) + const seen = new Set() + const merged: Array<{ name: string; description: string; coverHtml: string; pinned: boolean; source: "builtin" | "user" }> = [] + + for (const s of listStylesFromDir(userDir)) { + seen.add(s.name) + merged.push({ ...s, pinned: pinSet.has(s.name), source: "user" }) + } + for (const s of listStylesFromDir(BUNDLED_STYLES_DIR)) { + if (seen.has(s.name)) continue + merged.push({ ...s, pinned: pinSet.has(s.name), source: "builtin" }) + } + + return Response.json({ styles: merged }) } diff --git a/web-ui/src/components/deck/SpecStepNav.tsx b/web-ui/src/components/deck/SpecStepNav.tsx index 7396ae24..e39ed0a3 100644 --- a/web-ui/src/components/deck/SpecStepNav.tsx +++ b/web-ui/src/components/deck/SpecStepNav.tsx @@ -20,7 +20,7 @@ import { Layers, FileText, Palette, ArrowLeft, Check, Star } from "lucide-react" import Markdown from "react-markdown" import type { Components } from "react-markdown" import remarkGfm from "remark-gfm" -import { fetchStyles, fetchStyleHtml, type StyleEntry, type SpecFiles } from "@/services/deckService" +import { fetchStyles, fetchStyleHtml, pinStyle, type StyleEntry, type SpecFiles } from "@/services/deckService" import { OutlineView } from "./OutlineView" /** Tab key union type for spec viewer navigation. */ @@ -222,15 +222,20 @@ export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect, const galleryContainerRef = useRef(null) const [allStylesOpen, setAllStylesOpen] = useState(false) - // Pin toggle — optimistic local state (Phase 1a: local only, API in 1c) + // Pin toggle — optimistic UI with API persistence // Preserve scroll position across re-renders caused by section layout changes const handlePinToggle = useCallback((name: string) => { const scrollTop = galleryContainerRef.current?.scrollTop ?? 0 - setStyles(prev => prev.map(s => s.name === name ? { ...s, pinned: !s.pinned } : s)) + setStyles(prev => { + const style = prev.find(s => s.name === name) + const newPinned = !style?.pinned + if (idToken) pinStyle(name, newPinned, idToken) + return prev.map(s => s.name === name ? { ...s, pinned: newPinned } : s) + }) requestAnimationFrame(() => { if (galleryContainerRef.current) galleryContainerRef.current.scrollTop = scrollTop }) - }, []) + }, [idToken]) // Sync mode when content appears externally (e.g. polling updates art-direction) const userRequestedGallery = useRef(false) diff --git a/web-ui/src/lib/local/sdpmPaths.ts b/web-ui/src/lib/local/sdpmPaths.ts new file mode 100644 index 00000000..61fd93f7 --- /dev/null +++ b/web-ui/src/lib/local/sdpmPaths.ts @@ -0,0 +1,61 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 +/** Shared helpers for local sdpm config/state paths. */ +import fs from "fs" +import path from "path" +import os from "os" + +/** Bundled styles directory (skill/references/examples/styles/). */ +export const BUNDLED_STYLES_DIR = path.resolve(process.cwd(), "..", "skill", "references", "examples", "styles") + +/** User config directory (~/.config/sdpm on macOS/Linux, %APPDATA%/sdpm on Windows). */ +export function getUserConfigDir(): string { + const base = process.platform === "win32" + ? process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming") + : process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config") + return path.join(base, "sdpm") +} + +/** User-local styles directory. */ +export function getUserStylesDir(): string { + return path.join(getUserConfigDir(), "styles") +} + +/** State file path (~/.config/sdpm/state.json). */ +function getStatePath(): string { + return path.join(getUserConfigDir(), "state.json") +} + +/** Read app state. Returns empty object if file missing. */ +export function getState(): Record { + const p = getStatePath() + if (!fs.existsSync(p)) return {} + return JSON.parse(fs.readFileSync(p, "utf-8")) +} + +/** Update a single key in state.json (read-modify-write). */ +export function updateState(key: string, value: unknown): void { + const dir = getUserConfigDir() + fs.mkdirSync(dir, { recursive: true }) + const p = getStatePath() + const state = fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf-8")) : {} + state[key] = value + fs.writeFileSync(p, JSON.stringify(state, null, 2)) +} + +/** List style HTML files from a directory. Returns [{name, description, coverHtml}]. */ +export function listStylesFromDir(dir: string): Array<{ name: string; description: string; coverHtml: string }> { + if (!fs.existsSync(dir)) return [] + return fs.readdirSync(dir) + .filter(f => f.endsWith(".html") && !f.startsWith(".")) + .sort() + .map(f => { + const name = f.replace(/\.html$/, "") + const html = fs.readFileSync(path.join(dir, f), "utf-8") + const titleMatch = html.match(/(.*?)<\/title>/i) + const description = titleMatch ? titleMatch[1].trim() : "" + const coverEnd = html.indexOf("<section", html.indexOf("<section") + 1) + const coverHtml = coverEnd > 0 ? html.slice(0, coverEnd) + "</section>" : html + return { name, description, coverHtml } + }) +} diff --git a/web-ui/src/services/deckService.ts b/web-ui/src/services/deckService.ts index ee5f0de0..1db16202 100644 --- a/web-ui/src/services/deckService.ts +++ b/web-ui/src/services/deckService.ts @@ -473,3 +473,20 @@ export async function fetchStyleHtml(name: string, idToken: string): Promise<str const data = await res.json() return data.fullHtml || "" } + + +/** + * Toggle pin state for a style. + * + * @param name - Style name + * @param pinned - New pin state + * @param idToken - Cognito ID token for API Gateway authorization + */ +export async function pinStyle(name: string, pinned: boolean, idToken: string): Promise<void> { + const base = await getApiBaseUrl() + await fetch(`${base}styles/pin`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${idToken}` }, + body: JSON.stringify({ name, pinned }), + }) +} From 645ebcbbb7896b831bf360924309dff1bb102519 Mon Sep 17 00:00:00 2001 From: ShotaroKataoka <shotaro.kata@gmail.com> Date: Sun, 3 May 2026 10:18:25 +0900 Subject: [PATCH 04/52] =?UTF-8?q?=E2=9C=A8=20feat(user-style-management):?= =?UTF-8?q?=20/styles=20page=20+=20header=20tab=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC: 20260503-0854_user-style-management Progress: Phase 2 — /styles page + AppShell tabs - AppShell: Decks | Styles tab navigation (active state, hidden in workspace) - /styles page: user styles (top, with delete) + built-in styles grid - Style preview with iframe scaling (1920×1080) - Pin toggle on cards and preview - Custom badge for user styles Next: Phase 2 — Engine save/delete + API routes --- .../src/app/(authenticated)/styles/page.tsx | 223 ++++++++++++++++++ web-ui/src/components/AppShell.tsx | 35 ++- 2 files changed, 250 insertions(+), 8 deletions(-) create mode 100644 web-ui/src/app/(authenticated)/styles/page.tsx diff --git a/web-ui/src/app/(authenticated)/styles/page.tsx b/web-ui/src/app/(authenticated)/styles/page.tsx new file mode 100644 index 00000000..7c7e104f --- /dev/null +++ b/web-ui/src/app/(authenticated)/styles/page.tsx @@ -0,0 +1,223 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 +/** + * Styles page — Browse, manage, and create presentation styles. + * + * Lists user styles (with delete) and built-in styles. + * Phase 3 will add style creation via agent chat. + */ + +"use client" + +import { useState, useEffect, useRef } from "react" +import { useAuth } from "@/hooks/useAuth" +import { AppShell } from "@/components/AppShell" +import { fetchStyles, fetchStyleHtml, pinStyle, type StyleEntry } from "@/services/deckService" +import { Star, Trash2, Download, Palette } from "lucide-react" + +export default function StylesPage() { + const auth = useAuth() + const idToken = auth.user?.id_token + const [styles, setStyles] = useState<StyleEntry[]>([]) + const [loading, setLoading] = useState(true) + const [preview, setPreview] = useState<{ name: string; html: string } | null>(null) + const [previewLoading, setPreviewLoading] = useState(false) + const containerRef = useRef<HTMLDivElement>(null) + const [containerWidth, setContainerWidth] = useState(0) + + useEffect(() => { + if (!idToken) return + fetchStyles(idToken).then(s => { setStyles(s); setLoading(false) }) + }, [idToken]) + + // Measure container for iframe scaling + useEffect(() => { + if (!containerRef.current) return + const ro = new ResizeObserver(([e]) => setContainerWidth(e.contentRect.width)) + ro.observe(containerRef.current) + return () => ro.disconnect() + }, [preview]) + + const handlePin = async (name: string) => { + const style = styles.find(s => s.name === name) + const newPinned = !style?.pinned + setStyles(prev => prev.map(s => s.name === name ? { ...s, pinned: newPinned } : s)) + if (idToken) pinStyle(name, newPinned, idToken) + } + + const handlePreview = async (name: string) => { + setPreviewLoading(true) + setPreview({ name, html: "" }) + if (idToken) { + const html = await fetchStyleHtml(name, idToken) + setPreview({ name, html }) + } + setPreviewLoading(false) + } + + const userStyles = styles.filter(s => s.source === "user") + const builtinStyles = styles.filter(s => s.source === "builtin") + const scale = containerWidth > 0 ? (containerWidth) / 1920 : 0.4 + + return ( + <AppShell> + <div className="flex-1 overflow-y-auto"> + <div className="max-w-5xl mx-auto px-5 sm:px-8 py-8 sm:py-12"> + {/* Page header */} + <div className="flex items-center justify-between mb-8"> + <div> + <h1 className="text-xl font-semibold tracking-[-0.02em]">Styles</h1> + <p className="text-sm text-foreground-muted mt-1">Manage and preview presentation styles</p> + </div> + </div> + + {loading ? ( + <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4"> + {[...Array(8)].map((_, i) => ( + <div key={i} className="aspect-[16/10] rounded-xl bg-white/[0.03] animate-pulse" /> + ))} + </div> + ) : preview ? ( + /* ── Preview ── */ + <div> + <div className="flex items-center justify-between mb-6"> + <div className="flex items-center gap-3"> + <button + onClick={() => setPreview(null)} + className="text-sm text-foreground-muted hover:text-foreground transition-colors" + > + ← Back + </button> + <h2 className="text-sm font-semibold">{preview.name}</h2> + <button + onClick={() => handlePin(preview.name)} + className={`p-1 rounded transition-colors ${ + styles.find(s => s.name === preview.name)?.pinned + ? "text-brand-teal" : "text-foreground-muted hover:text-foreground" + }`} + > + <Star className="h-3.5 w-3.5" fill={styles.find(s => s.name === preview.name)?.pinned ? "currentColor" : "none"} /> + </button> + </div> + </div> + <div ref={containerRef} className="w-full"> + {previewLoading ? ( + <div className="aspect-[16/9] rounded-xl bg-white/[0.03] animate-pulse" /> + ) : preview.html ? ( + <div className="rounded-xl overflow-hidden border border-white/[0.06]" style={{ height: `${1080 * scale}px` }}> + <iframe + srcDoc={preview.html} + className="pointer-events-none" + style={{ width: 1920, height: 1080, transform: `scale(${scale})`, transformOrigin: "top left" }} + sandbox="allow-same-origin" + title={`Preview: ${preview.name}`} + /> + </div> + ) : null} + </div> + </div> + ) : ( + /* ── Style grid ── */ + <div className="flex flex-col gap-10"> + {/* User styles */} + {userStyles.length > 0 && ( + <section> + <h2 className="text-xs font-semibold text-foreground-muted uppercase tracking-wider mb-4">My Styles</h2> + <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4"> + {userStyles.map(style => ( + <StyleListCard + key={style.name} + style={style} + onPreview={handlePreview} + onPin={handlePin} + showDelete + /> + ))} + </div> + </section> + )} + + {/* Built-in styles */} + <section> + <h2 className="text-xs font-semibold text-foreground-muted uppercase tracking-wider mb-4">Built-in Styles</h2> + <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4"> + {builtinStyles.map(style => ( + <StyleListCard + key={style.name} + style={style} + onPreview={handlePreview} + onPin={handlePin} + /> + ))} + </div> + </section> + </div> + )} + </div> + </div> + </AppShell> + ) +} + +/** Style card for the /styles list page. */ +function StyleListCard({ style, onPreview, onPin, showDelete }: { + style: StyleEntry + onPreview: (name: string) => void + onPin: (name: string) => void + showDelete?: boolean +}) { + return ( + <div + className="group relative rounded-xl overflow-hidden border border-white/[0.06] bg-white/[0.02] hover:border-white/[0.12] transition-all duration-200 cursor-pointer" + onClick={() => onPreview(style.name)} + > + {/* Cover preview */} + <div className="aspect-[16/10] overflow-hidden"> + {style.coverHtml ? ( + <iframe + srcDoc={style.coverHtml} + className="w-[1920px] h-[1080px] pointer-events-none" + style={{ transform: "scale(0.15)", transformOrigin: "top left" }} + tabIndex={-1} + sandbox="allow-same-origin" + title={style.name} + /> + ) : ( + <div className="w-full h-full flex items-center justify-center"> + <Palette className="h-8 w-8 text-foreground/10" /> + </div> + )} + </div> + + {/* Info bar */} + <div className="px-3 py-2.5 border-t border-white/[0.06]"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium truncate">{style.name}</span> + <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-150"> + <button + onClick={e => { e.stopPropagation(); onPin(style.name) }} + className={`p-1 rounded transition-colors ${ + style.pinned ? "text-brand-teal opacity-100" : "text-foreground-muted hover:text-foreground" + }`} + aria-label={style.pinned ? `Unpin ${style.name}` : `Pin ${style.name}`} + > + <Star className="h-3.5 w-3.5" fill={style.pinned ? "currentColor" : "none"} /> + </button> + {showDelete && ( + <button + onClick={e => { e.stopPropagation() }} + className="p-1 rounded text-foreground-muted hover:text-red-400 transition-colors" + aria-label={`Delete ${style.name}`} + > + <Trash2 className="h-3.5 w-3.5" /> + </button> + )} + </div> + </div> + {style.source === "user" && ( + <span className="text-[11px] text-brand-teal/70 font-medium mt-0.5 block">Custom</span> + )} + </div> + </div> + ) +} diff --git a/web-ui/src/components/AppShell.tsx b/web-ui/src/components/AppShell.tsx index 48dc23cb..95041c68 100644 --- a/web-ui/src/components/AppShell.tsx +++ b/web-ui/src/components/AppShell.tsx @@ -18,8 +18,9 @@ "use client" import { ReactNode, useState, useRef, useEffect, useCallback } from "react" +import { usePathname } from "next/navigation" import { useAuth } from "@/hooks/useAuth" -import { Layers, ChevronLeft, MessageSquare, CircleUser, LogOut, Bot, Settings as SettingsIcon } from "lucide-react" +import { Layers, ChevronLeft, MessageSquare, CircleUser, LogOut, Bot, Settings as SettingsIcon, Palette } from "lucide-react" import { AgentSettingsDialog } from "@/components/chat/AgentSettingsDialog" import { Settings } from "@/components/Settings" import { CloudOnly, LocalOnly } from "@/lib/mode" @@ -34,6 +35,7 @@ interface AppShellProps { export function AppShell({ children, deckName, onBack, chatOpen = false, onChatToggle }: AppShellProps) { const { user, signOut } = useAuth() + const pathname = usePathname() const profile = user?.profile as Record<string, unknown> | undefined const alias = (profile?.preferred_username as string) || (profile?.email as string)?.split("@")[0] || "" const email = (profile?.email as string) || "" @@ -124,14 +126,31 @@ export function AppShell({ children, deckName, onBack, chatOpen = false, onChatT </span> </button> ) : ( - <a href="/decks/" className="flex items-center gap-2.5 no-underline"> - <div className="w-6 h-6 rounded-md flex items-center justify-center bg-brand-teal-soft"> - <Layers className="h-3 w-3 text-brand-teal" /> + <> + <a href="/decks/" className="flex items-center gap-2.5 no-underline"> + <div className="w-6 h-6 rounded-md flex items-center justify-center bg-brand-teal-soft"> + <Layers className="h-3 w-3 text-brand-teal" /> + </div> + </a> + <div className="flex items-center gap-0.5 ml-1"> + <a + href="/decks/" + className={`px-2.5 py-1 rounded-md text-sm font-medium no-underline transition-colors ${ + pathname?.startsWith("/decks") ? "text-foreground" : "text-foreground/40 hover:text-foreground/70" + }`} + > + Decks + </a> + <a + href="/styles/" + className={`px-2.5 py-1 rounded-md text-sm font-medium no-underline transition-colors ${ + pathname?.startsWith("/styles") ? "text-foreground" : "text-foreground/40 hover:text-foreground/70" + }`} + > + Styles + </a> </div> - <span className="text-sm font-semibold tracking-[-0.02em] text-foreground"> - spec-driven-presentation-maker - </span> - </a> + </> )} </nav> From 5d1e54bd9d6803a84b0574c679809e5514202840 Mon Sep 17 00:00:00 2001 From: ShotaroKataoka <shotaro.kata@gmail.com> Date: Sun, 3 May 2026 10:49:59 +0900 Subject: [PATCH 05/52] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(user-style-?= =?UTF-8?q?management):=20shared=20StyleSlidePreview=20+=20/styles=20page?= =?UTF-8?q?=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC: 20260503-0854_user-style-management - StyleSlidePreview: shared component for style/art-direction rendering - Resets body zoom/padding/margin, preserves slide border/outline - 2200px iframe width to accommodate decorative borders - 8px gap between slides - /styles page: full-width preview, dynamic card scaling - SpecStepNav: art-direction result now uses StyleSlidePreview - sdpmPaths: cover extraction uses div.slide, resets body zoom - Removed duplicate StylePreviewInline and inline iframe code --- .../src/app/(authenticated)/styles/page.tsx | 170 +++++++++--------- web-ui/src/components/StyleSlidePreview.tsx | 91 ++++++++++ web-ui/src/components/deck/SpecStepNav.tsx | 98 +--------- web-ui/src/lib/local/sdpmPaths.ts | 12 +- 4 files changed, 191 insertions(+), 180 deletions(-) create mode 100644 web-ui/src/components/StyleSlidePreview.tsx diff --git a/web-ui/src/app/(authenticated)/styles/page.tsx b/web-ui/src/app/(authenticated)/styles/page.tsx index 7c7e104f..53c0956f 100644 --- a/web-ui/src/app/(authenticated)/styles/page.tsx +++ b/web-ui/src/app/(authenticated)/styles/page.tsx @@ -13,7 +13,8 @@ import { useState, useEffect, useRef } from "react" import { useAuth } from "@/hooks/useAuth" import { AppShell } from "@/components/AppShell" import { fetchStyles, fetchStyleHtml, pinStyle, type StyleEntry } from "@/services/deckService" -import { Star, Trash2, Download, Palette } from "lucide-react" +import { StyleSlidePreview } from "@/components/StyleSlidePreview" +import { Star, Trash2, Palette, Plus } from "lucide-react" export default function StylesPage() { const auth = useAuth() @@ -22,22 +23,12 @@ export default function StylesPage() { const [loading, setLoading] = useState(true) const [preview, setPreview] = useState<{ name: string; html: string } | null>(null) const [previewLoading, setPreviewLoading] = useState(false) - const containerRef = useRef<HTMLDivElement>(null) - const [containerWidth, setContainerWidth] = useState(0) useEffect(() => { if (!idToken) return fetchStyles(idToken).then(s => { setStyles(s); setLoading(false) }) }, [idToken]) - // Measure container for iframe scaling - useEffect(() => { - if (!containerRef.current) return - const ro = new ResizeObserver(([e]) => setContainerWidth(e.contentRect.width)) - ro.observe(containerRef.current) - return () => ro.disconnect() - }, [preview]) - const handlePin = async (name: string) => { const style = styles.find(s => s.name === name) const newPinned = !style?.pinned @@ -57,85 +48,82 @@ export default function StylesPage() { const userStyles = styles.filter(s => s.source === "user") const builtinStyles = styles.filter(s => s.source === "builtin") - const scale = containerWidth > 0 ? (containerWidth) / 1920 : 0.4 return ( <AppShell> <div className="flex-1 overflow-y-auto"> - <div className="max-w-5xl mx-auto px-5 sm:px-8 py-8 sm:py-12"> - {/* Page header */} - <div className="flex items-center justify-between mb-8"> - <div> - <h1 className="text-xl font-semibold tracking-[-0.02em]">Styles</h1> - <p className="text-sm text-foreground-muted mt-1">Manage and preview presentation styles</p> + {loading ? ( + <div className="max-w-5xl mx-auto px-5 sm:px-8 py-8 sm:py-12"> + <div className="flex items-center justify-between mb-8"> + <div> + <h1 className="text-xl font-semibold tracking-[-0.02em]">Styles</h1> + <p className="text-sm text-foreground-muted mt-1">Manage and preview presentation styles</p> + </div> </div> - </div> - - {loading ? ( <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4"> {[...Array(8)].map((_, i) => ( <div key={i} className="aspect-[16/10] rounded-xl bg-white/[0.03] animate-pulse" /> ))} </div> - ) : preview ? ( - /* ── Preview ── */ - <div> - <div className="flex items-center justify-between mb-6"> - <div className="flex items-center gap-3"> - <button - onClick={() => setPreview(null)} - className="text-sm text-foreground-muted hover:text-foreground transition-colors" - > - ← Back - </button> - <h2 className="text-sm font-semibold">{preview.name}</h2> - <button - onClick={() => handlePin(preview.name)} - className={`p-1 rounded transition-colors ${ - styles.find(s => s.name === preview.name)?.pinned - ? "text-brand-teal" : "text-foreground-muted hover:text-foreground" - }`} - > - <Star className="h-3.5 w-3.5" fill={styles.find(s => s.name === preview.name)?.pinned ? "currentColor" : "none"} /> - </button> - </div> - </div> - <div ref={containerRef} className="w-full"> - {previewLoading ? ( - <div className="aspect-[16/9] rounded-xl bg-white/[0.03] animate-pulse" /> - ) : preview.html ? ( - <div className="rounded-xl overflow-hidden border border-white/[0.06]" style={{ height: `${1080 * scale}px` }}> - <iframe - srcDoc={preview.html} - className="pointer-events-none" - style={{ width: 1920, height: 1080, transform: `scale(${scale})`, transformOrigin: "top left" }} - sandbox="allow-same-origin" - title={`Preview: ${preview.name}`} - /> - </div> - ) : null} + </div> + ) : preview ? ( + /* ── Full-width preview ── */ + <div> + <div className="flex items-center gap-3 px-5 sm:px-8 py-3"> + <button + onClick={() => setPreview(null)} + className="text-sm text-foreground-muted hover:text-foreground transition-colors" + > + ← Back + </button> + <h2 className="text-sm font-semibold">{preview.name}</h2> + <button + onClick={() => handlePin(preview.name)} + className={`p-1 rounded transition-colors ${ + styles.find(s => s.name === preview.name)?.pinned + ? "text-brand-teal" : "text-foreground-muted hover:text-foreground" + }`} + > + <Star className="h-3.5 w-3.5" fill={styles.find(s => s.name === preview.name)?.pinned ? "currentColor" : "none"} /> + </button> + </div> + <StyleSlidePreview html={preview.html} loading={previewLoading} /> + </div> + ) : ( + /* ── Style grid ── */ + <div className="max-w-5xl mx-auto px-5 sm:px-8 py-8 sm:py-12"> + <div className="flex items-center justify-between mb-8"> + <div> + <h1 className="text-xl font-semibold tracking-[-0.02em]">Styles</h1> + <p className="text-sm text-foreground-muted mt-1">Manage and preview presentation styles</p> </div> </div> - ) : ( - /* ── Style grid ── */ <div className="flex flex-col gap-10"> {/* User styles */} - {userStyles.length > 0 && ( - <section> - <h2 className="text-xs font-semibold text-foreground-muted uppercase tracking-wider mb-4">My Styles</h2> - <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4"> - {userStyles.map(style => ( - <StyleListCard - key={style.name} - style={style} - onPreview={handlePreview} - onPin={handlePin} - showDelete - /> - ))} - </div> - </section> - )} + <section> + <h2 className="text-xs font-semibold text-foreground-muted uppercase tracking-wider mb-4">My Styles</h2> + <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4"> + {userStyles.map(style => ( + <StyleListCard + key={style.name} + style={style} + onPreview={handlePreview} + onPin={handlePin} + showDelete + /> + ))} + {/* New Style card */} + <button + className="aspect-[16/10] rounded-xl border-2 border-dashed border-white/[0.08] hover:border-white/[0.2] bg-transparent hover:bg-white/[0.02] flex flex-col items-center justify-center gap-2 transition-all duration-200 cursor-pointer group" + onClick={() => {/* Phase 3: navigate to style creator */}} + > + <div className="w-10 h-10 rounded-full border-2 border-dashed border-white/[0.1] group-hover:border-white/[0.25] flex items-center justify-center transition-colors duration-200"> + <Plus className="h-5 w-5 text-foreground/20 group-hover:text-foreground/50 transition-colors duration-200" /> + </div> + <span className="text-xs text-foreground/30 group-hover:text-foreground/60 font-medium transition-colors duration-200">New Style</span> + </button> + </div> + </section> {/* Built-in styles */} <section> @@ -152,8 +140,8 @@ export default function StylesPage() { </div> </section> </div> - )} - </div> + </div> + )} </div> </AppShell> ) @@ -166,24 +154,36 @@ function StyleListCard({ style, onPreview, onPin, showDelete }: { onPin: (name: string) => void showDelete?: boolean }) { + const cardRef = useRef<HTMLDivElement>(null) + const [scale, setScale] = useState(0.15) + + useEffect(() => { + const el = cardRef.current + if (!el) return + const ro = new ResizeObserver(([entry]) => setScale(entry.contentRect.width / 1920)) + ro.observe(el) + return () => ro.disconnect() + }, []) + return ( <div + ref={cardRef} className="group relative rounded-xl overflow-hidden border border-white/[0.06] bg-white/[0.02] hover:border-white/[0.12] transition-all duration-200 cursor-pointer" onClick={() => onPreview(style.name)} > {/* Cover preview */} - <div className="aspect-[16/10] overflow-hidden"> + <div className="relative overflow-hidden bg-black/20" style={{ height: 1080 * scale }}> {style.coverHtml ? ( <iframe srcDoc={style.coverHtml} - className="w-[1920px] h-[1080px] pointer-events-none" - style={{ transform: "scale(0.15)", transformOrigin: "top left" }} + className="pointer-events-none" + style={{ width: 1920, height: 1080, transform: `scale(${scale})`, transformOrigin: "top left", border: "none" }} tabIndex={-1} sandbox="allow-same-origin" title={style.name} /> ) : ( - <div className="w-full h-full flex items-center justify-center"> + <div className="absolute inset-0 flex items-center justify-center"> <Palette className="h-8 w-8 text-foreground/10" /> </div> )} @@ -193,11 +193,11 @@ function StyleListCard({ style, onPreview, onPin, showDelete }: { <div className="px-3 py-2.5 border-t border-white/[0.06]"> <div className="flex items-center justify-between"> <span className="text-sm font-medium truncate">{style.name}</span> - <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-150"> + <div className="flex items-center gap-1"> <button onClick={e => { e.stopPropagation(); onPin(style.name) }} className={`p-1 rounded transition-colors ${ - style.pinned ? "text-brand-teal opacity-100" : "text-foreground-muted hover:text-foreground" + style.pinned ? "text-brand-teal" : "text-foreground-muted hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity duration-150" }`} aria-label={style.pinned ? `Unpin ${style.name}` : `Pin ${style.name}`} > @@ -206,7 +206,7 @@ function StyleListCard({ style, onPreview, onPin, showDelete }: { {showDelete && ( <button onClick={e => { e.stopPropagation() }} - className="p-1 rounded text-foreground-muted hover:text-red-400 transition-colors" + className="p-1 rounded text-foreground-muted hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100 transition-opacity duration-150" aria-label={`Delete ${style.name}`} > <Trash2 className="h-3.5 w-3.5" /> diff --git a/web-ui/src/components/StyleSlidePreview.tsx b/web-ui/src/components/StyleSlidePreview.tsx new file mode 100644 index 00000000..8704fec1 --- /dev/null +++ b/web-ui/src/components/StyleSlidePreview.tsx @@ -0,0 +1,91 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 +/** + * StyleSlidePreview — Renders full style HTML scaled to container width. + * + * Style HTMLs have `body { zoom: 0.7; padding: 40px }` for standalone browser + * viewing. We reset only zoom/padding/margin/background on body so the slides + * render at their natural 1920×1080 size. Border/outline on .slide is preserved + * as it's part of the design. + * + * The iframe is sized wider than 1920 to accommodate border/outline overflow, + * and CSS transform scales it to fit the container. + */ + +"use client" + +import { useCallback, useRef, useState } from "react" + +// Extra width to accommodate border + outline on .slide (e.g. border 56px + outline 40px each side) +const IFRAME_WIDTH = 2200 +const SLIDE_HEIGHT = 1080 + +/** Inject minimal reset into style HTML. Only neutralize body-level layout, not slide decoration. */ +function prepareHtml(html: string): string { + const reset = `<style data-preview-reset> +html,body{margin:0!important;padding:0!important;background:transparent!important;zoom:1!important;overflow:visible!important} +.slide{margin:0 auto 8px!important} +</style>` + if (html.includes("</head>")) { + return html.replace("</head>", `${reset}</head>`) + } + return reset + html +} + +function countSlides(html: string): number { + const matches = html.match(/class="slide"/g) + return matches ? matches.length : 1 +} + +export function StyleSlidePreview({ html, loading }: { html: string; loading: boolean }) { + const [containerWidth, setContainerWidth] = useState(0) + const roRef = useRef<ResizeObserver | null>(null) + const measuredRef = useCallback((node: HTMLDivElement | null) => { + if (roRef.current) { roRef.current.disconnect(); roRef.current = null } + if (node) { + const w = node.getBoundingClientRect().width + if (w > 0) setContainerWidth(w) + roRef.current = new ResizeObserver(([entry]) => setContainerWidth(entry.contentRect.width)) + roRef.current.observe(node) + } + }, []) + + if (loading || !html) { + return ( + <div className="flex items-center justify-center py-20"> + <div className="w-6 h-6 border-2 border-brand-teal/30 border-t-brand-teal rounded-full animate-spin" /> + </div> + ) + } + + const scale = containerWidth > 0 ? containerWidth / IFRAME_WIDTH : 0 + const slideCount = countSlides(html) + const slideWithGap = SLIDE_HEIGHT + 8 + const totalHeight = (slideWithGap * slideCount) * scale + + return ( + <div ref={measuredRef} className="w-full"> + {scale > 0 ? ( + <div style={{ width: "100%", height: totalHeight, overflow: "hidden" }}> + <iframe + srcDoc={prepareHtml(html)} + className="pointer-events-none" + style={{ + width: IFRAME_WIDTH, + height: slideWithGap * slideCount, + transform: `scale(${scale})`, + transformOrigin: "top left", + border: "none", + }} + sandbox="allow-same-origin" + title="Style Preview" + /> + </div> + ) : ( + <div className="flex items-center justify-center py-20"> + <div className="w-6 h-6 border-2 border-brand-teal/30 border-t-brand-teal rounded-full animate-spin" /> + </div> + )} + </div> + ) +} diff --git a/web-ui/src/components/deck/SpecStepNav.tsx b/web-ui/src/components/deck/SpecStepNav.tsx index e39ed0a3..5eee6605 100644 --- a/web-ui/src/components/deck/SpecStepNav.tsx +++ b/web-ui/src/components/deck/SpecStepNav.tsx @@ -22,6 +22,7 @@ import type { Components } from "react-markdown" import remarkGfm from "remark-gfm" import { fetchStyles, fetchStyleHtml, pinStyle, type StyleEntry, type SpecFiles } from "@/services/deckService" import { OutlineView } from "./OutlineView" +import { StyleSlidePreview } from "@/components/StyleSlidePreview" /** Tab key union type for spec viewer navigation. */ export type SpecTab = "brief" | "outline" | "artDirection" | "slides" @@ -208,7 +209,6 @@ const specComponents = { */ export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect, idToken }: { content: string | null; specName: string; specKey?: string; onStyleSelect?: (name: string) => void; idToken?: string }) { // Hooks must be called unconditionally — before any early returns. - const [containerWidth, setContainerWidth] = useState(0) // Art Direction inline gallery state type ArtDirectionMode = "gallery" | "preview" | "result" @@ -277,23 +277,6 @@ export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect, return () => document.removeEventListener("keydown", handleKeyDown) }, [handleKeyDown]) - // Art Direction result iframe: callback ref for ResizeObserver (handles mount/unmount across states) - const resultRoRef = useRef<ResizeObserver | null>(null) - const resultMeasuredRef = useCallback((node: HTMLDivElement | null) => { - if (resultRoRef.current) { - resultRoRef.current.disconnect() - resultRoRef.current = null - } - if (node) { - const w = node.getBoundingClientRect().width - if (w > 0) setContainerWidth(w) - resultRoRef.current = new ResizeObserver(([entry]) => { - setContainerWidth(entry.contentRect.width) - }) - resultRoRef.current.observe(node) - } - }, []) - // Outline tab: show waiting animation when no content, timeline when content exists. if (specKey === "outline" && !content) { return ( @@ -454,16 +437,15 @@ export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect, </div> {/* Preview content */} <div className="p-6"> - <StylePreviewInline html={preview.html} loading={previewLoading} /> + <StyleSlidePreview html={preview.html} loading={previewLoading} /> </div> </div> ) } // RESULT state (default when content exists) - const ratio = containerWidth > 0 ? containerWidth / 1920 : 1 return ( - <div ref={resultMeasuredRef} className="flex-1 overflow-y-auto overflow-x-hidden"> + <div className="flex-1 overflow-y-auto overflow-x-hidden"> {onStyleSelect && ( <div className="flex justify-end px-4 py-2"> <button @@ -475,20 +457,7 @@ export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect, </button> </div> )} - <div style={{ width: containerWidth, height: 1080 * ratio * 10, overflow: "hidden" }}> - <iframe - srcDoc={content!} - sandbox="allow-same-origin" - title="Art Direction" - style={{ - width: 1920, - height: 10800, - border: "none", - transformOrigin: "top left", - transform: `scale(${ratio})`, - }} - /> - </div> + <StyleSlidePreview html={content!} loading={false} /> </div> ) } @@ -613,64 +582,7 @@ function StyleCard({ style, index, onClick, onPin }: { style: StyleEntry; index: } /** Full style preview rendered via scaled iframe. */ -function StylePreviewInline({ html, loading }: { html: string; loading: boolean }) { - const [containerWidth, setContainerWidth] = useState(0) - - // Callback ref to handle DOM element changes (e.g. after loading→content transition) - const roRef = useRef<ResizeObserver | null>(null) - const measuredRef = useCallback((node: HTMLDivElement | null) => { - if (roRef.current) { - roRef.current.disconnect() - roRef.current = null - } - if (node) { - const w = node.getBoundingClientRect().width - if (w > 0) setContainerWidth(w) - roRef.current = new ResizeObserver(([entry]) => { - setContainerWidth(entry.contentRect.width) - }) - roRef.current.observe(node) - } - }, []) - - if (loading || !html) { - return ( - <div className="flex items-center justify-center py-20"> - <div className="w-6 h-6 border-2 border-brand-teal/30 border-t-brand-teal rounded-full animate-spin" /> - </div> - ) - } - - const ratio = containerWidth > 0 ? containerWidth / 1920 : 0 - const slideCount = (html.match(/class="slide"/g) || []).length || 5 - - return ( - <div ref={measuredRef} className="w-full overflow-x-hidden"> - <div style={{ width: "100%", height: ratio > 0 ? 1080 * ratio * slideCount : 400, overflow: "hidden" }}> - {ratio > 0 ? ( - <iframe - srcDoc={html} - sandbox="allow-same-origin" - title="Style Preview" - style={{ - width: 1920, - height: 1080 * slideCount, - border: "none", - transformOrigin: "top left", - transform: `scale(${ratio})`, - }} - /> - ) : ( - <div className="flex items-center justify-center h-full"> - <div className="w-6 h-6 border-2 border-brand-teal/30 border-t-brand-teal rounded-full animate-spin" /> - </div> - )} - </div> - </div> - ) -} - -/* ── Spec waiting animations ── */ +export /* ── Spec waiting animations ── */ const WAIT_COLORS = [ { css: "var(--wait-teal)", raw: "oklch(0.75 0.14 185)" }, diff --git a/web-ui/src/lib/local/sdpmPaths.ts b/web-ui/src/lib/local/sdpmPaths.ts index 61fd93f7..8321fb90 100644 --- a/web-ui/src/lib/local/sdpmPaths.ts +++ b/web-ui/src/lib/local/sdpmPaths.ts @@ -54,8 +54,16 @@ export function listStylesFromDir(dir: string): Array<{ name: string; descriptio const html = fs.readFileSync(path.join(dir, f), "utf-8") const titleMatch = html.match(/<title>(.*?)<\/title>/i) const description = titleMatch ? titleMatch[1].trim() : "" - const coverEnd = html.indexOf("<section", html.indexOf("<section") + 1) - const coverHtml = coverEnd > 0 ? html.slice(0, coverEnd) + "</section>" : html + // Extract first slide as cover: find first <div class="slide"> to second + const marker = '<div class="slide"' + const first = html.indexOf(marker) + if (first === -1) return { name, description, coverHtml: html } + const second = html.indexOf(marker, first + marker.length) + const slideHtml = second > 0 ? html.slice(first, second) : html.slice(first, html.indexOf("</body", first) || undefined) + // Build standalone doc with head styles + body padding reset + const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i) + const head = headMatch ? headMatch[1] : "" + const coverHtml = `<!DOCTYPE html><html><head>${head}<style>body{margin:0!important;padding:0!important;background:transparent!important;overflow:hidden!important;zoom:1!important}.slide{margin:0 auto!important}</style></head><body>${slideHtml}</body></html>` return { name, description, coverHtml } }) } From dc317a0e1ff3adb05fd802b9c3422cfb5f81ca4f Mon Sep 17 00:00:00 2001 From: ShotaroKataoka <shotaro.kata@gmail.com> Date: Sun, 3 May 2026 10:51:07 +0900 Subject: [PATCH 06/52] =?UTF-8?q?=F0=9F=90=9B=20fix(user-style-management)?= =?UTF-8?q?:=20nested=20button=20hydration=20error=20in=20StyleCard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change StyleCard from <button> to <div role=button> to allow nested pin <button> without violating HTML spec. --- web-ui/src/components/deck/SpecStepNav.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/web-ui/src/components/deck/SpecStepNav.tsx b/web-ui/src/components/deck/SpecStepNav.tsx index 5eee6605..584bc87d 100644 --- a/web-ui/src/components/deck/SpecStepNav.tsx +++ b/web-ui/src/components/deck/SpecStepNav.tsx @@ -499,7 +499,7 @@ export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect, function StyleCard({ style, index, onClick, onPin }: { style: StyleEntry; index: number; onClick: (name: string) => void; onPin?: (name: string) => void }) { const iframeWidth = 1920 const iframeHeight = 1080 - const cardRef = useRef<HTMLButtonElement>(null) + const cardRef = useRef<HTMLDivElement>(null) const [scale, setScale] = useState(0.2) const [bouncing, setBouncing] = useState(false) @@ -521,10 +521,13 @@ function StyleCard({ style, index, onClick, onPin }: { style: StyleEntry; index: } return ( - <button + <div ref={cardRef} + role="button" + tabIndex={0} onClick={() => onClick(style.name)} - className="group text-left rounded-xl border border-white/[0.06] overflow-hidden transition-all duration-300 hover:border-brand-teal/30 hover:shadow-[0_0_24px_oklch(0.75_0.14_185/10%)] focus:outline-none focus:ring-2 focus:ring-brand-teal/40 animate-[card-in_0.5s_ease_both]" + onKeyDown={e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(style.name) } }} + className="group text-left rounded-xl border border-white/[0.06] overflow-hidden transition-all duration-300 hover:border-brand-teal/30 hover:shadow-[0_0_24px_oklch(0.75_0.14_185/10%)] focus:outline-none focus:ring-2 focus:ring-brand-teal/40 animate-[card-in_0.5s_ease_both] cursor-pointer" style={{ animationDelay: `${index * 60}ms` }} aria-label={`Preview ${style.name} style`} > @@ -577,7 +580,7 @@ function StyleCard({ style, index, onClick, onPin }: { style: StyleEntry; index: <p className="text-xs text-foreground-muted mt-0.5 line-clamp-1">{style.description}</p> )} </div> - </button> + </div> ) } From a91e62cd6d6d5e1445747bc267dae0a770a22963 Mon Sep 17 00:00:00 2001 From: ShotaroKataoka <shotaro.kata@gmail.com> Date: Sun, 3 May 2026 14:55:01 +0900 Subject: [PATCH 07/52] =?UTF-8?q?=E2=9C=A8=20feat(user-style-management):?= =?UTF-8?q?=20Phase=202=20=E2=80=94=20save/delete/import/export=20+=20UX?= =?UTF-8?q?=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC: 20260503-0854_user-style-management Phase 2 + Phase 4 (pulled forward): API Routes: - POST /api/styles/user — save user style (title tag validation) - DELETE /api/styles/user/[name] — delete user style Service layer: - saveUserStyle(), deleteUserStyle() in deckService.ts /styles page: - Create with AI card (primary) + Import Style link (secondary) - Delete with confirmation dialog (Esc, aria-modal, autoFocus) - Export (HTML download) on hover + preview header - Toast notifications replacing alert() for import/delete feedback - Touch target fix on Import Style link Bug fix: - Slide class regex: class="slide" missed variants like slide--dark, slide-alt. Changed to class="slide[\s"] Fixes corporate-executive (4→6) and cute-playful (2→4) --- .../src/app/(authenticated)/styles/page.tsx | 162 ++++++++++++++++-- .../src/app/api/styles/user/[name]/route.ts | 21 +++ web-ui/src/app/api/styles/user/route.ts | 25 +++ web-ui/src/components/StyleSlidePreview.tsx | 2 +- web-ui/src/lib/local/sdpmPaths.ts | 13 +- web-ui/src/services/deckService.ts | 33 ++++ 6 files changed, 232 insertions(+), 24 deletions(-) create mode 100644 web-ui/src/app/api/styles/user/[name]/route.ts create mode 100644 web-ui/src/app/api/styles/user/route.ts diff --git a/web-ui/src/app/(authenticated)/styles/page.tsx b/web-ui/src/app/(authenticated)/styles/page.tsx index 53c0956f..79b1af2b 100644 --- a/web-ui/src/app/(authenticated)/styles/page.tsx +++ b/web-ui/src/app/(authenticated)/styles/page.tsx @@ -12,9 +12,9 @@ import { useState, useEffect, useRef } from "react" import { useAuth } from "@/hooks/useAuth" import { AppShell } from "@/components/AppShell" -import { fetchStyles, fetchStyleHtml, pinStyle, type StyleEntry } from "@/services/deckService" +import { fetchStyles, fetchStyleHtml, pinStyle, saveUserStyle, deleteUserStyle, type StyleEntry } from "@/services/deckService" import { StyleSlidePreview } from "@/components/StyleSlidePreview" -import { Star, Trash2, Palette, Plus } from "lucide-react" +import { Star, Trash2, Palette, Download, Sparkles } from "lucide-react" export default function StylesPage() { const auth = useAuth() @@ -23,6 +23,14 @@ export default function StylesPage() { const [loading, setLoading] = useState(true) const [preview, setPreview] = useState<{ name: string; html: string } | null>(null) const [previewLoading, setPreviewLoading] = useState(false) + const fileInputRef = useRef<HTMLInputElement>(null) + const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null) + const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null) + + const showToast = (message: string, type: "success" | "error" = "success") => { + setToast({ message, type }) + setTimeout(() => setToast(null), 3000) + } useEffect(() => { if (!idToken) return @@ -46,6 +54,44 @@ export default function StylesPage() { setPreviewLoading(false) } + const handleImport = async (file: File) => { + if (!idToken) return + const html = await file.text() + if (!/<title>.*?<\/title>/i.test(html)) { + showToast("Invalid style: HTML must contain a <title> tag.", "error") + return + } + const name = file.name.replace(/\.html?$/i, "").replace(/[^a-zA-Z0-9_-]/g, "-") + const result = await saveUserStyle(name, html, idToken) + if (result.error) { showToast(result.error, "error"); return } + const updated = await fetchStyles(idToken) + setStyles(updated) + showToast(`Imported "${name}"`) + } + + const handleDelete = async (name: string) => { + if (!idToken) return + const result = await deleteUserStyle(name, idToken) + if (result.error) { showToast(result.error, "error"); return } + setStyles(prev => prev.filter(s => s.name !== name)) + if (preview?.name === name) setPreview(null) + setDeleteConfirm(null) + showToast(`Deleted "${name}"`) + } + + const handleExport = async (name: string) => { + if (!idToken) return + const html = await fetchStyleHtml(name, idToken) + if (!html) return + const blob = new Blob([html], { type: "text/html" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `${name}.html` + a.click() + URL.revokeObjectURL(url) + } + const userStyles = styles.filter(s => s.source === "user") const builtinStyles = styles.filter(s => s.source === "builtin") @@ -86,6 +132,24 @@ export default function StylesPage() { > <Star className="h-3.5 w-3.5" fill={styles.find(s => s.name === preview.name)?.pinned ? "currentColor" : "none"} /> </button> + {styles.find(s => s.name === preview.name)?.source === "user" && ( + <> + <button + onClick={() => handleExport(preview.name)} + className="p-1 rounded text-foreground-muted hover:text-foreground transition-colors" + aria-label={`Export ${preview.name}`} + > + <Download className="h-3.5 w-3.5" /> + </button> + <button + onClick={() => setDeleteConfirm(preview.name)} + className="p-1 rounded text-foreground-muted hover:text-red-400 transition-colors" + aria-label={`Delete ${preview.name}`} + > + <Trash2 className="h-3.5 w-3.5" /> + </button> + </> + )} </div> <StyleSlidePreview html={preview.html} loading={previewLoading} /> </div> @@ -109,19 +173,37 @@ export default function StylesPage() { style={style} onPreview={handlePreview} onPin={handlePin} - showDelete + onDelete={name => setDeleteConfirm(name)} + onExport={handleExport} /> ))} - {/* New Style card */} - <button - className="aspect-[16/10] rounded-xl border-2 border-dashed border-white/[0.08] hover:border-white/[0.2] bg-transparent hover:bg-white/[0.02] flex flex-col items-center justify-center gap-2 transition-all duration-200 cursor-pointer group" - onClick={() => {/* Phase 3: navigate to style creator */}} - > - <div className="w-10 h-10 rounded-full border-2 border-dashed border-white/[0.1] group-hover:border-white/[0.25] flex items-center justify-center transition-colors duration-200"> - <Plus className="h-5 w-5 text-foreground/20 group-hover:text-foreground/50 transition-colors duration-200" /> - </div> - <span className="text-xs text-foreground/30 group-hover:text-foreground/60 font-medium transition-colors duration-200">New Style</span> - </button> + {/* Create with AI card + Import link */} + <div className="flex flex-col"> + <button + className="aspect-[16/10] rounded-xl border-2 border-dashed border-white/[0.08] hover:border-brand-teal/30 bg-transparent hover:bg-brand-teal/[0.03] flex flex-col items-center justify-center gap-2 transition-all duration-200 cursor-pointer group" + onClick={() => {/* Phase 3: navigate to style creator */}} + > + <Sparkles className="h-6 w-6 text-brand-teal/30 group-hover:text-brand-teal/60 transition-colors duration-200" /> + <span className="text-xs text-foreground/30 group-hover:text-foreground/60 font-medium transition-colors duration-200">Create with AI</span> + </button> + <button + className="mt-2 py-1.5 text-xs text-foreground/25 hover:text-foreground/50 transition-colors text-center" + onClick={() => fileInputRef.current?.click()} + > + Import Style + </button> + </div> + <input + ref={fileInputRef} + type="file" + accept=".html,.htm" + className="hidden" + onChange={e => { + const file = e.target.files?.[0] + if (file) handleImport(file) + e.target.value = "" + }} + /> </div> </section> @@ -143,16 +225,53 @@ export default function StylesPage() { </div> )} </div> + + {/* Delete confirmation dialog */} + {deleteConfirm && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setDeleteConfirm(null)} onKeyDown={e => { if (e.key === "Escape") setDeleteConfirm(null) }}> + <div className="bg-surface-secondary border border-white/[0.08] rounded-xl p-6 max-w-sm mx-4 shadow-2xl" role="alertdialog" aria-modal="true" onClick={e => e.stopPropagation()}> + <h3 className="text-sm font-semibold mb-2">Delete style</h3> + <p className="text-sm text-foreground-muted mb-5"> + Are you sure you want to delete <span className="font-medium text-foreground">{deleteConfirm}</span>? This cannot be undone. + </p> + <div className="flex justify-end gap-2"> + <button + onClick={() => setDeleteConfirm(null)} + className="px-3 py-1.5 text-sm rounded-lg border border-white/[0.08] hover:bg-white/[0.04] transition-colors" + > + Cancel + </button> + <button + autoFocus + onClick={() => handleDelete(deleteConfirm)} + className="px-3 py-1.5 text-sm rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors" + > + Delete + </button> + </div> + </div> + </div> + )} + + {/* Toast notification */} + {toast && ( + <div className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-4 py-2.5 rounded-lg text-sm font-medium shadow-lg animate-in fade-in slide-in-from-bottom-2 duration-200 ${ + toast.type === "error" ? "bg-red-500/20 text-red-300 border border-red-500/20" : "bg-brand-teal/20 text-brand-teal border border-brand-teal/20" + }`}> + {toast.message} + </div> + )} </AppShell> ) } /** Style card for the /styles list page. */ -function StyleListCard({ style, onPreview, onPin, showDelete }: { +function StyleListCard({ style, onPreview, onPin, onDelete, onExport }: { style: StyleEntry onPreview: (name: string) => void onPin: (name: string) => void - showDelete?: boolean + onDelete?: (name: string) => void + onExport?: (name: string) => void }) { const cardRef = useRef<HTMLDivElement>(null) const [scale, setScale] = useState(0.15) @@ -203,9 +322,18 @@ function StyleListCard({ style, onPreview, onPin, showDelete }: { > <Star className="h-3.5 w-3.5" fill={style.pinned ? "currentColor" : "none"} /> </button> - {showDelete && ( + {onExport && ( + <button + onClick={e => { e.stopPropagation(); onExport(style.name) }} + className="p-1 rounded text-foreground-muted hover:text-foreground transition-colors opacity-0 group-hover:opacity-100 transition-opacity duration-150" + aria-label={`Export ${style.name}`} + > + <Download className="h-3.5 w-3.5" /> + </button> + )} + {onDelete && ( <button - onClick={e => { e.stopPropagation() }} + onClick={e => { e.stopPropagation(); onDelete(style.name) }} className="p-1 rounded text-foreground-muted hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100 transition-opacity duration-150" aria-label={`Delete ${style.name}`} > diff --git a/web-ui/src/app/api/styles/user/[name]/route.ts b/web-ui/src/app/api/styles/user/[name]/route.ts new file mode 100644 index 00000000..a4f97d96 --- /dev/null +++ b/web-ui/src/app/api/styles/user/[name]/route.ts @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 +/** Local User Style Delete API — delete a user style HTML file. */ +import fs from "fs" +import path from "path" +import { getUserStylesDir } from "@/lib/local/sdpmPaths" + +export async function DELETE(_req: Request, { params }: { params: Promise<{ name: string }> }) { + const { name } = await params + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + return Response.json({ error: "invalid style name" }, { status: 400 }) + } + + const filePath = path.join(getUserStylesDir(), `${name}.html`) + if (!fs.existsSync(filePath)) { + return Response.json({ error: "style not found" }, { status: 404 }) + } + + fs.unlinkSync(filePath) + return Response.json({ deleted: name }) +} diff --git a/web-ui/src/app/api/styles/user/route.ts b/web-ui/src/app/api/styles/user/route.ts new file mode 100644 index 00000000..181d733f --- /dev/null +++ b/web-ui/src/app/api/styles/user/route.ts @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 +/** Local User Style API — save a user style HTML file. */ +import fs from "fs" +import path from "path" +import { getUserStylesDir } from "@/lib/local/sdpmPaths" + +export async function POST(req: Request) { + const { name, html } = await req.json() as { name: string; html: string } + if (!name || !html) { + return Response.json({ error: "name and html required" }, { status: 400 }) + } + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + return Response.json({ error: "invalid style name" }, { status: 400 }) + } + // Validate <title> tag exists + if (!/<title>.*?<\/title>/i.test(html)) { + return Response.json({ error: "HTML must contain a <title> tag" }, { status: 400 }) + } + + const dir = getUserStylesDir() + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, `${name}.html`), html, "utf-8") + return Response.json({ saved: name }) +} diff --git a/web-ui/src/components/StyleSlidePreview.tsx b/web-ui/src/components/StyleSlidePreview.tsx index 8704fec1..182c87d9 100644 --- a/web-ui/src/components/StyleSlidePreview.tsx +++ b/web-ui/src/components/StyleSlidePreview.tsx @@ -33,7 +33,7 @@ html,body{margin:0!important;padding:0!important;background:transparent!importan } function countSlides(html: string): number { - const matches = html.match(/class="slide"/g) + const matches = html.match(/class="slide[\s"]/g) return matches ? matches.length : 1 } diff --git a/web-ui/src/lib/local/sdpmPaths.ts b/web-ui/src/lib/local/sdpmPaths.ts index 8321fb90..03f080e9 100644 --- a/web-ui/src/lib/local/sdpmPaths.ts +++ b/web-ui/src/lib/local/sdpmPaths.ts @@ -54,12 +54,13 @@ export function listStylesFromDir(dir: string): Array<{ name: string; descriptio const html = fs.readFileSync(path.join(dir, f), "utf-8") const titleMatch = html.match(/<title>(.*?)<\/title>/i) const description = titleMatch ? titleMatch[1].trim() : "" - // Extract first slide as cover: find first <div class="slide"> to second - const marker = '<div class="slide"' - const first = html.indexOf(marker) - if (first === -1) return { name, description, coverHtml: html } - const second = html.indexOf(marker, first + marker.length) - const slideHtml = second > 0 ? html.slice(first, second) : html.slice(first, html.indexOf("</body", first) || undefined) + // Extract first slide as cover: find first <div class="slide..."> to second + const slideRegex = /<div class="slide[\s"]/g + const firstMatch = slideRegex.exec(html) + if (!firstMatch) return { name, description, coverHtml: html } + const first = firstMatch.index + const secondMatch = slideRegex.exec(html) + const slideHtml = secondMatch ? html.slice(first, secondMatch.index) : html.slice(first, html.indexOf("</body", first) || undefined) // Build standalone doc with head styles + body padding reset const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i) const head = headMatch ? headMatch[1] : "" diff --git a/web-ui/src/services/deckService.ts b/web-ui/src/services/deckService.ts index 1db16202..e1146144 100644 --- a/web-ui/src/services/deckService.ts +++ b/web-ui/src/services/deckService.ts @@ -490,3 +490,36 @@ export async function pinStyle(name: string, pinned: boolean, idToken: string): body: JSON.stringify({ name, pinned }), }) } + + +/** + * Save a user style (import). + * + * @param name - Style name (alphanumeric, hyphens, underscores) + * @param html - Full HTML content + * @param idToken - Cognito ID token for API Gateway authorization + */ +export async function saveUserStyle(name: string, html: string, idToken: string): Promise<{ saved?: string; error?: string }> { + const base = await getApiBaseUrl() + const res = await fetch(`${base}styles/user`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${idToken}` }, + body: JSON.stringify({ name, html }), + }) + return res.json() +} + +/** + * Delete a user style. + * + * @param name - Style name + * @param idToken - Cognito ID token for API Gateway authorization + */ +export async function deleteUserStyle(name: string, idToken: string): Promise<{ deleted?: string; error?: string }> { + const base = await getApiBaseUrl() + const res = await fetch(`${base}styles/user/${encodeURIComponent(name)}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${idToken}` }, + }) + return res.json() +} From 8c6a84ba38fcbb55276f94fe52776123ed62872d Mon Sep 17 00:00:00 2001 From: ShotaroKataoka <shotaro.kata@gmail.com> Date: Tue, 5 May 2026 11:56:56 +0900 Subject: [PATCH 08/52] =?UTF-8?q?=E2=9C=A8=20feat(user-style-management):?= =?UTF-8?q?=20hash=20routing,=20Copy=20to=20My=20Styles,=20All=20Styles=20?= =?UTF-8?q?default=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC: 20260503-0854_user-style-management Progress: Phase 2.5 + Phase 2.7 complete - apply_style: use _find_style_in_dirs for user style support - /styles page: migrate to useStyleWorkspace hash routing - Add Copy to My Styles for builtin styles - All Styles collapsible default open (UX: Anticipatory Design) --- mcp-local/server_acp.py | 9 +- .../src/app/(authenticated)/styles/page.tsx | 118 ++++++++++++------ web-ui/src/components/deck/SpecStepNav.tsx | 2 +- 3 files changed, 86 insertions(+), 43 deletions(-) diff --git a/mcp-local/server_acp.py b/mcp-local/server_acp.py index dd53dbff..9fdcd4ec 100644 --- a/mcp-local/server_acp.py +++ b/mcp-local/server_acp.py @@ -329,10 +329,11 @@ def apply_style(deck_id: str, style: str) -> str: JSON with status and the copied file path. """ import shutil - styles_dir = _SKILL_DIR / "references" / "examples" / "styles" - src = styles_dir / f"{style}.html" - if not src.exists(): - return json.dumps({"error": f"Style not found: {style}. Available: {[s.stem for s in styles_dir.glob('*.html')]}"}) + from sdpm.api import get_styles_dirs, _find_style_in_dirs + src = _find_style_in_dirs(style, get_styles_dirs()) + if src is None: + available = [p.stem for d in get_styles_dirs() if d.is_dir() for p in d.glob("*.html")] + return json.dumps({"error": f"Style not found: {style}. Available: {sorted(set(available))}"}) deck_path = Path(deck_id) if not deck_path.is_dir(): return json.dumps({"error": f"Deck directory not found: {deck_id}"}) diff --git a/web-ui/src/app/(authenticated)/styles/page.tsx b/web-ui/src/app/(authenticated)/styles/page.tsx index 79b1af2b..84aa5588 100644 --- a/web-ui/src/app/(authenticated)/styles/page.tsx +++ b/web-ui/src/app/(authenticated)/styles/page.tsx @@ -3,26 +3,29 @@ /** * Styles page — Browse, manage, and create presentation styles. * - * Lists user styles (with delete) and built-in styles. - * Phase 3 will add style creation via agent chat. + * Uses URL hash routing (useStyleWorkspace): + * - No hash: style list grid + * - #create: new style creation (Phase 3 agent chat) + * - #{name}: style preview (user styles get chat panel in Phase 3) */ "use client" -import { useState, useEffect, useRef } from "react" +import { useState, useEffect, useRef, useCallback } from "react" import { useAuth } from "@/hooks/useAuth" +import { useStyleWorkspace } from "@/hooks/useStyleWorkspace" import { AppShell } from "@/components/AppShell" import { fetchStyles, fetchStyleHtml, pinStyle, saveUserStyle, deleteUserStyle, type StyleEntry } from "@/services/deckService" import { StyleSlidePreview } from "@/components/StyleSlidePreview" -import { Star, Trash2, Palette, Download, Sparkles } from "lucide-react" +import { Star, Trash2, Palette, Download, Sparkles, Copy } from "lucide-react" export default function StylesPage() { const auth = useAuth() const idToken = auth.user?.id_token + const ws = useStyleWorkspace(idToken) + const [styles, setStyles] = useState<StyleEntry[]>([]) const [loading, setLoading] = useState(true) - const [preview, setPreview] = useState<{ name: string; html: string } | null>(null) - const [previewLoading, setPreviewLoading] = useState(false) const fileInputRef = useRef<HTMLInputElement>(null) const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null) const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null) @@ -32,11 +35,15 @@ export default function StylesPage() { setTimeout(() => setToast(null), 3000) } - useEffect(() => { + const refreshStyles = useCallback(async () => { if (!idToken) return - fetchStyles(idToken).then(s => { setStyles(s); setLoading(false) }) + const s = await fetchStyles(idToken) + setStyles(s) + setLoading(false) }, [idToken]) + useEffect(() => { refreshStyles() }, [refreshStyles]) + const handlePin = async (name: string) => { const style = styles.find(s => s.name === name) const newPinned = !style?.pinned @@ -44,16 +51,6 @@ export default function StylesPage() { if (idToken) pinStyle(name, newPinned, idToken) } - const handlePreview = async (name: string) => { - setPreviewLoading(true) - setPreview({ name, html: "" }) - if (idToken) { - const html = await fetchStyleHtml(name, idToken) - setPreview({ name, html }) - } - setPreviewLoading(false) - } - const handleImport = async (file: File) => { if (!idToken) return const html = await file.text() @@ -64,8 +61,7 @@ export default function StylesPage() { const name = file.name.replace(/\.html?$/i, "").replace(/[^a-zA-Z0-9_-]/g, "-") const result = await saveUserStyle(name, html, idToken) if (result.error) { showToast(result.error, "error"); return } - const updated = await fetchStyles(idToken) - setStyles(updated) + await refreshStyles() showToast(`Imported "${name}"`) } @@ -74,7 +70,7 @@ export default function StylesPage() { const result = await deleteUserStyle(name, idToken) if (result.error) { showToast(result.error, "error"); return } setStyles(prev => prev.filter(s => s.name !== name)) - if (preview?.name === name) setPreview(null) + if (ws.styleName === name) ws.navigateToList() setDeleteConfirm(null) showToast(`Deleted "${name}"`) } @@ -92,13 +88,35 @@ export default function StylesPage() { URL.revokeObjectURL(url) } + const handleCopyToMyStyles = async (name: string) => { + if (!idToken) return + const html = await fetchStyleHtml(name, idToken) + if (!html) return + // Replace <title> with "Copy of {original}" + const originalTitle = html.match(/<title>(.*?)<\/title>/i)?.[1] || name + const newHtml = html.replace(/<title>.*?<\/title>/i, `<title>Copy of ${originalTitle}`) + const now = new Date() + const pad = (n: number) => String(n).padStart(2, "0") + const filename = `style-${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}` + const result = await saveUserStyle(filename, newHtml, idToken) + if (result.error) { showToast(result.error, "error"); return } + await refreshStyles() + showToast(`Copied as "Copy of ${originalTitle}"`) + } + + const currentStyle = ws.styleName ? styles.find(s => s.name === ws.styleName) : null + const userStyles = styles.filter(s => s.source === "user") const builtinStyles = styles.filter(s => s.source === "builtin") return ( - +
{loading ? ( + /* ── Loading skeleton ── */
@@ -112,46 +130,70 @@ export default function StylesPage() { ))}
- ) : preview ? ( - /* ── Full-width preview ── */ + ) : ws.view.mode === "preview" && ws.styleName ? ( + /* ── Style preview ── */
-

{preview.name}

+

{ws.styleName}

- {styles.find(s => s.name === preview.name)?.source === "user" && ( + {currentStyle?.source === "user" && ( <> )} + {currentStyle?.source === "builtin" && ( + + )} +
+ +
+ ) : ws.view.mode === "create" ? ( + /* ── Create new style (Phase 3: agent chat) ── */ +
+
+ +

Create with AI

+

Style creation will be available in Phase 3.

+
-
) : ( /* ── Style grid ── */ @@ -171,7 +213,7 @@ export default function StylesPage() { setDeleteConfirm(name)} onExport={handleExport} @@ -181,7 +223,7 @@ export default function StylesPage() {
{currentStyle?.source === "user" && ( <> + )}
- +
) : ws.view.mode === "create" ? ( - /* ── Create new style (Phase 3: agent chat) ── */ + /* ── Create new style → creates Untitled Style and navigates ── */

Create with AI

-

Style creation will be available in Phase 3.

+

Creating your new style…

)} +
+ + {/* Style Chat Panel (side panel) */} + {ws.view.mode === "preview" && currentStyle?.source === "user" && ( + setChatOpen(false)} + styleId={ws.styleName!} + styleName={ws.styleName!} + onStyleHtmlUpdate={handleStyleHtmlUpdate} + /> + )}
{/* Delete confirmation dialog */} diff --git a/web-ui/src/app/api/agent/invoke/route.ts b/web-ui/src/app/api/agent/invoke/route.ts index 0f362aaa..c8c5b5b8 100644 --- a/web-ui/src/app/api/agent/invoke/route.ts +++ b/web-ui/src/app/api/agent/invoke/route.ts @@ -11,6 +11,7 @@ const MODE_TO_AGENT: Record = { spec: "sdpm-spec", separated: "sdpm-spec", single: "sdpm-single", + style: "sdpm-style", } export const dynamic = 'force-dynamic' diff --git a/web-ui/src/components/chat/ChatInput.tsx b/web-ui/src/components/chat/ChatInput.tsx new file mode 100644 index 00000000..68fb377a --- /dev/null +++ b/web-ui/src/components/chat/ChatInput.tsx @@ -0,0 +1,259 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 +/** + * ChatInput — Reusable chat input area with textarea, Send/Stop, PlusMenu, attachments. + * + * Extracted from ChatPanel.tsx. Handles: + * - Textarea with auto-resize and IME-safe composition + * - Send/Stop button toggle + * - PlusMenu (file attach + snippet) + * - AttachmentPreview + SnippetInput + * - FileDropZone wrapper + * - ⌘+Enter / Enter send preference + * + * Does NOT handle: @mentions, Options panel (deck-specific). Use `children` slot for those. + */ + +"use client" + +import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle, FormEvent, KeyboardEvent, ReactNode } from "react" +import { useCompositionSafe } from "@/hooks/useCompositionSafe" +import { useIsMobile } from "@/hooks/UseMobile" +import { uploadFile, validateFile, canAddMoreFiles, UploadedFile } from "@/services/uploadService" +import { PlusMenu } from "./PlusMenu" +import { AttachmentPreview, Attachment, SnippetAttachment } from "./AttachmentPreview" +import { FileDropZone } from "./FileDropZone" +import { SnippetInput } from "./SnippetInput" +import { Send, Square } from "lucide-react" +import { toast } from "sonner" + +export interface ChatInputProps { + /** Called with the final message text, uploaded files, snippets, and attachment metadata. */ + onSend: (text: string, uploadedFiles: UploadedFile[], snippets: { label: string; text: string }[], attachments: { fileName: string; fileType: string }[]) => void + isLoading: boolean + onStop: () => void + disabled?: boolean + placeholder?: string + /** idToken for file upload. */ + idToken?: string + /** Session ID for file upload context. */ + sessionId?: string + /** Deck ID for file upload context. */ + deckId?: string + /** Slot for additional UI above the textarea row (e.g., Options panel, @mentions overlay). */ + children?: ReactNode + /** Stop button tooltip override. */ + stopTitle?: string +} + +export interface ChatInputHandle { + insertAtCursor: (text: string) => void + focus: () => void +} + +export const ChatInput = forwardRef(function ChatInput( + { onSend, isLoading, onStop, disabled, placeholder, idToken, sessionId, deckId, children, stopTitle }, + ref, +) { + const [input, setInput] = useState("") + const [attachments, setAttachments] = useState([]) + const [snippetOpen, setSnippetOpen] = useState(false) + const [snippets, setSnippets] = useState([]) + const [editingSnippetId, setEditingSnippetId] = useState(null) + const textareaRef = useRef(null) + const { onCompositionStart, onCompositionEnd, getIsComposing } = useCompositionSafe() + const isMobile = useIsMobile() + + useImperativeHandle(ref, () => ({ + insertAtCursor(text: string) { + const ta = textareaRef.current + if (!ta) return + const start = ta.selectionStart + const end = ta.selectionEnd + const before = input.slice(0, start) + const after = input.slice(end) + setInput(before + text + after) + requestAnimationFrame(() => { + ta.focus() + const pos = start + text.length + ta.setSelectionRange(pos, pos) + }) + }, + focus() { + textareaRef.current?.focus() + }, + }), [input]) + + // Auto-resize textarea + useEffect(() => { + const ta = textareaRef.current + if (ta) { + ta.style.height = "0px" + ta.style.height = ta.scrollHeight + "px" + } + }, [input]) + + const handleFiles = useCallback((files: FileList) => { + const currentCount = attachments.length + for (const file of Array.from(files)) { + if (!canAddMoreFiles(currentCount + attachments.length)) { + toast.error("Maximum 5 files can be attached at once.") + break + } + const error = validateFile(file) + if (error) { toast.error(error); continue } + const id = crypto.randomUUID() + setAttachments((prev) => [...prev, { id, file, status: "pending" }]) + } + }, [attachments.length]) + + const removeAttachment = useCallback((id: string) => { + setAttachments((prev) => prev.filter((a) => a.id !== id)) + }, []) + + const handleSnippetRequest = () => setSnippetOpen(true) + + const handleSnippetConfirm = (text: string) => { + if (editingSnippetId) { + setSnippets((prev) => prev.map((s) => s.id === editingSnippetId ? { ...s, text } : s)) + setEditingSnippetId(null) + } else { + setSnippets((prev) => [...prev, { id: crypto.randomUUID(), text }]) + } + } + + const editSnippet = useCallback((id: string) => { + setEditingSnippetId(id) + setSnippetOpen(true) + }, []) + + const removeSnippet = useCallback((id: string) => { + setSnippets((prev) => prev.filter((s) => s.id !== id)) + }, []) + + const handleSend = async () => { + if ((!input.trim() && attachments.length === 0 && snippets.length === 0) || isLoading || disabled) return + + // Upload pending attachments + const uploadedFiles: UploadedFile[] = [] + for (const att of attachments) { + if (att.status === "pending") { + try { + setAttachments((prev) => prev.map((a) => (a.id === att.id ? { ...a, status: "uploading" as const } : a))) + const result = await uploadFile(att.file, idToken ?? "", sessionId ?? "", deckId !== "new" ? deckId : undefined) + uploadedFiles.push(result) + setAttachments((prev) => prev.map((a) => (a.id === att.id ? { ...a, status: "completed" as const, uploadId: result.uploadId } : a))) + } catch { + setAttachments((prev) => prev.map((a) => (a.id === att.id ? { ...a, status: "failed" as const, error: "Upload failed" } : a))) + } + } + } + + const sentSnippets = snippets.map((s) => ({ label: "Text snippet", text: s.text })) + const sentAttachments = uploadedFiles.map((f) => ({ fileName: f.fileName, fileType: f.fileType })) + + onSend(input, uploadedFiles, sentSnippets, sentAttachments) + setInput("") + setAttachments([]) + setSnippets([]) + requestAnimationFrame(() => textareaRef.current?.focus()) + } + + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + handleSend() + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (getIsComposing(e)) return + const canSendNow = input.trim() || attachments.length > 0 || snippets.length > 0 + const sendWithEnter = (() => { try { return JSON.parse(localStorage.getItem("sdpm-prefs") || "{}").sendWithEnter ?? false } catch { return false } })() + + if (sendWithEnter) { + if (e.key === "Enter" && !e.shiftKey && !e.metaKey && !e.ctrlKey && canSendNow) { + e.preventDefault() + handleSend() + } + } else { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && canSendNow) { + e.preventDefault() + handleSend() + } + } + } + + const canSend = input.trim() || attachments.length > 0 || snippets.length > 0 + + return ( + + { setSnippetOpen(false); setEditingSnippetId(null) }} + onConfirm={handleSnippetConfirm} + initialText={editingSnippetId ? snippets.find((s) => s.id === editingSnippetId)?.text : undefined} + /> +
+
+ + + {children} + +
+ + +
+