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(" 0 ? html.slice(0, coverEnd) + "" : 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 {
+ 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
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([])
+ const [loading, setLoading] = useState(true)
+ const [preview, setPreview] = useState<{ name: string; html: string } | null>(null)
+ const [previewLoading, setPreviewLoading] = useState(false)
+ const containerRef = useRef(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 (
+
+
+
+ {/* Page header */}
+
+
+
Styles
+
Manage and preview presentation styles
+
+
+
+ {loading ? (
+
+ {[...Array(8)].map((_, i) => (
+
+ ))}
+
+ ) : preview ? (
+ /* ── Preview ── */
+
+
+
+ setPreview(null)}
+ className="text-sm text-foreground-muted hover:text-foreground transition-colors"
+ >
+ ← Back
+
+
{preview.name}
+ 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"
+ }`}
+ >
+ s.name === preview.name)?.pinned ? "currentColor" : "none"} />
+
+
+
+
+ {previewLoading ? (
+
+ ) : preview.html ? (
+
+
+
+ ) : null}
+
+
+ ) : (
+ /* ── Style grid ── */
+
+ {/* User styles */}
+ {userStyles.length > 0 && (
+
+ My Styles
+
+ {userStyles.map(style => (
+
+ ))}
+
+
+ )}
+
+ {/* Built-in styles */}
+
+ Built-in Styles
+
+ {builtinStyles.map(style => (
+
+ ))}
+
+
+
+ )}
+
+
+
+ )
+}
+
+/** 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 (
+ onPreview(style.name)}
+ >
+ {/* Cover preview */}
+
+ {style.coverHtml ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Info bar */}
+
+
+
{style.name}
+
+ { 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}`}
+ >
+
+
+ {showDelete && (
+ { e.stopPropagation() }}
+ className="p-1 rounded text-foreground-muted hover:text-red-400 transition-colors"
+ aria-label={`Delete ${style.name}`}
+ >
+
+
+ )}
+
+
+ {style.source === "user" && (
+
Custom
+ )}
+
+
+ )
+}
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 | 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
) : (
-
-
-
+ <>
+
+
+
+
+
+
-
- spec-driven-presentation-maker
-
-
+ >
)}
From 5d1e54bd9d6803a84b0574c679809e5514202840 Mon Sep 17 00:00:00 2001
From: ShotaroKataoka
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(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 (
-
- {/* Page header */}
-
-
-
Styles
-
Manage and preview presentation styles
+ {loading ? (
+
+
+
+
Styles
+
Manage and preview presentation styles
+
-
-
- {loading ? (
{[...Array(8)].map((_, i) => (
))}
- ) : preview ? (
- /* ── Preview ── */
-
-
-
- setPreview(null)}
- className="text-sm text-foreground-muted hover:text-foreground transition-colors"
- >
- ← Back
-
-
{preview.name}
- 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"
- }`}
- >
- s.name === preview.name)?.pinned ? "currentColor" : "none"} />
-
-
-
-
- {previewLoading ? (
-
- ) : preview.html ? (
-
-
-
- ) : null}
+
+ ) : preview ? (
+ /* ── Full-width preview ── */
+
+
+ setPreview(null)}
+ className="text-sm text-foreground-muted hover:text-foreground transition-colors"
+ >
+ ← Back
+
+
{preview.name}
+ 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"
+ }`}
+ >
+ s.name === preview.name)?.pinned ? "currentColor" : "none"} />
+
+
+
+
+ ) : (
+ /* ── Style grid ── */
+
+
+
+
Styles
+
Manage and preview presentation styles
- ) : (
- /* ── Style grid ── */
{/* User styles */}
- {userStyles.length > 0 && (
-
- My Styles
-
- {userStyles.map(style => (
-
- ))}
-
-
- )}
+
+ My Styles
+
+ {userStyles.map(style => (
+
+ ))}
+ {/* New Style card */}
+
{/* Phase 3: navigate to style creator */}}
+ >
+
+ New Style
+
+
+
{/* Built-in styles */}
@@ -152,8 +140,8 @@ export default function StylesPage() {
- )}
-
+
+ )}
)
@@ -166,24 +154,36 @@ function StyleListCard({ style, onPreview, onPin, showDelete }: {
onPin: (name: string) => void
showDelete?: boolean
}) {
+ const cardRef = useRef
(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 (
onPreview(style.name)}
>
{/* Cover preview */}
-
+
{style.coverHtml ? (
) : (
-
+
)}
@@ -193,11 +193,11 @@ function StyleListCard({ style, onPreview, onPin, showDelete }: {
{style.name}
-
+
{ 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 && (
{ 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}`}
>
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 = ``
+ if (html.includes("")) {
+ return html.replace("", `${reset}`)
+ }
+ 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(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 (
+
+ )
+ }
+
+ const scale = containerWidth > 0 ? containerWidth / IFRAME_WIDTH : 0
+ const slideCount = countSlides(html)
+ const slideWithGap = SLIDE_HEIGHT + 8
+ const totalHeight = (slideWithGap * slideCount) * scale
+
+ return (
+
+ {scale > 0 ? (
+
+
+
+ ) : (
+
+ )}
+
+ )
+}
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(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,
{/* Preview content */}
-
+
)
}
// RESULT state (default when content exists)
- const ratio = containerWidth > 0 ? containerWidth / 1920 : 1
return (
-
+
{onStyleSelect && (
)}
-
-
-
+
)
}
@@ -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
(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 (
-
- )
- }
-
- const ratio = containerWidth > 0 ? containerWidth / 1920 : 0
- const slideCount = (html.match(/class="slide"/g) || []).length || 5
-
- return (
-
-
0 ? 1080 * ratio * slideCount : 400, overflow: "hidden" }}>
- {ratio > 0 ? (
-
- ) : (
-
- )}
-
-
- )
-}
-
-/* ── 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>/i)
const description = titleMatch ? titleMatch[1].trim() : ""
- const coverEnd = html.indexOf(" 0 ? html.slice(0, coverEnd) + "" : html
+ // Extract first slide as cover: find first to second
+ const marker = '
0 ? html.slice(first, second) : html.slice(first, html.indexOf("
${slideHtml}]*>([\s\S]*?)<\/head>/i)
+ const head = headMatch ? headMatch[1] : ""
+ const coverHtml = `