17 KiB
Subtitle Preset Grid Redesign Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Redesign preset preview cards to match uploaded video aspect ratio with modern visual refresh and style characteristics display
Architecture: Fetch video metadata to calculate aspect ratio, pass to preset cards via props. Update StylePreview for dynamic sizing. Add loading skeleton state. Use Catppuccin Mocha color palette matching the project theme.
Tech Stack: React, TypeScript, SCSS Modules, TanStack Query, openapi-react-query
Design Spec: docs/superpowers/specs/2026-04-06-subtitle-preset-grid-redesign.md
File Structure
| File | Purpose |
|---|---|
src/features/project/CaptionSettingsStep/useVideoMetadata.ts |
New hook to fetch video metadata and calculate aspect ratio |
src/features/project/CaptionSettingsStep/PresetGrid.tsx |
Modified - adds aspect ratio fetching, loading state, passes ratio to cards |
src/features/project/CaptionSettingsStep/PresetGrid.module.scss |
Modified - grid layout, responsive styles |
src/features/project/CaptionSettingsStep/PresetCard.tsx |
Modified - adds style characteristics display, checkmark indicator, updated styling |
src/features/project/CaptionSettingsStep/PresetCard.module.scss |
Modified - new card design with Catppuccin Mocha colors |
src/features/project/CaptionSettingsStep/StylePreview.tsx |
Modified - accepts aspectRatio prop for dynamic sizing |
src/features/project/CaptionSettingsStep/StylePreview.module.scss |
Modified - dynamic aspect-ratio container |
src/features/project/CaptionSettingsStep/PresetCardSkeleton.tsx |
New - skeleton loading component for preset cards |
src/features/project/CaptionSettingsStep/PresetCardSkeleton.module.scss |
New - skeleton styles with shimmer animation |
Task 1: Create useVideoMetadata Hook
Files:
- Create:
src/features/project/CaptionSettingsStep/useVideoMetadata.ts
Context: This hook fetches video metadata from the API and calculates the aspect ratio. It uses the existing api from @shared/api which is openapi-react-query.
- Step 1: Write the hook implementation
import { useMemo } from "react"
import api from "@shared/api"
interface UseVideoMetadataResult {
aspectRatio: number
isLoading: boolean
isError: boolean
}
const DEFAULT_ASPECT_RATIO = 16 / 9
export function useVideoMetadata(fileId: string | null): UseVideoMetadataResult {
const { data: mediaFile, isLoading, isError } = api.useQuery(
"get",
"/api/media/mediafiles/{media_file_id}/",
{
params: {
path: {
media_file_id: fileId ?? "",
},
},
},
{
enabled: !!fileId,
}
)
const aspectRatio = useMemo(() => {
if (!mediaFile?.width || !mediaFile?.height) {
return DEFAULT_ASPECT_RATIO
}
return mediaFile.width / mediaFile.height
}, [mediaFile])
return {
aspectRatio,
isLoading,
isError,
}
}
- Step 2: Commit
git add src/features/project/CaptionSettingsStep/useVideoMetadata.ts
git commit -m "feat: add useVideoMetadata hook for aspect ratio calculation"
Task 2: Create PresetCardSkeleton Component
Files:
-
Create:
src/features/project/CaptionSettingsStep/PresetCardSkeleton.tsx -
Create:
src/features/project/CaptionSettingsStep/PresetCardSkeleton.module.scss -
Step 1: Write the SCSS module
// PresetCardSkeleton.module.scss
.skeletonCard {
border-radius: 12px;
overflow: hidden;
background: var(--bg-default);
border: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
}
.skeletonPreview {
aspect-ratio: 16 / 9;
background: var(--bg-surface);
position: relative;
overflow: hidden;
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(203, 166, 247, 0.08) 50%,
transparent 100%
);
animation: shimmer 1.5s infinite;
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.skeletonFooter {
padding: 14px 16px;
background: linear-gradient(to top, var(--bg-surface), var(--bg-default));
border-top: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
gap: 10px;
}
.skeletonLine {
height: 14px;
background: var(--bg-hover);
border-radius: 4px;
width: 60%;
position: relative;
overflow: hidden;
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(203, 166, 247, 0.06) 50%,
transparent 100%
);
animation: shimmer 1.5s infinite;
}
}
.skeletonLineShort {
composes: skeletonLine;
width: 40%;
height: 10px;
}
- Step 2: Write the component
// PresetCardSkeleton.tsx
import type { FunctionComponent } from "react"
import type { JSX } from "react"
import styles from "./PresetCardSkeleton.module.scss"
interface IPresetCardSkeletonProps {
aspectRatio?: number
}
export const PresetCardSkeleton: FunctionComponent<IPresetCardSkeletonProps> = ({
aspectRatio = 16 / 9,
}): JSX.Element => {
return (
<div className={styles.skeletonCard}>
<div
className={styles.skeletonPreview}
style={{ aspectRatio }}
/>
<div className={styles.skeletonFooter}>
<div className={styles.skeletonLine} />
<div className={styles.skeletonLineShort} />
</div>
</div>
)
}
- Step 3: Add barrel export
Add to src/features/project/CaptionSettingsStep/index.ts:
export { PresetCardSkeleton } from "./PresetCardSkeleton"
- Step 4: Commit
git add src/features/project/CaptionSettingsStep/PresetCardSkeleton.tsx
git add src/features/project/CaptionSettingsStep/PresetCardSkeleton.module.scss
git add src/features/project/CaptionSettingsStep/index.ts
git commit -m "feat: add PresetCardSkeleton component with shimmer animation"
Task 3: Update StylePreview for Dynamic Aspect Ratio
Files:
-
Modify:
src/features/project/CaptionSettingsStep/StylePreview.tsx -
Modify:
src/features/project/CaptionSettingsStep/StylePreview.module.scss -
Step 1: Read existing StylePreview files
cat src/features/project/CaptionSettingsStep/StylePreview.tsx
cat src/features/project/CaptionSettingsStep/StylePreview.module.scss
- Step 2: Update StylePreview.module.scss
Add or modify the preview container to accept dynamic aspect-ratio:
// Add to existing StylePreview.module.scss
.previewContainer {
position: relative;
width: 100%;
overflow: hidden;
background: #0c0a1a;
display: flex;
align-items: center;
justify-content: center;
}
- Step 3: Update StylePreview.tsx
Add aspectRatio prop and apply it to the container:
// Add to existing imports
import type { CSSProperties } from "react"
// Update interface to include aspectRatio
interface IStylePreviewProps {
preset: CaptionPresetRead
aspectRatio?: number
className?: string
}
// In component, apply aspect ratio
export const StylePreview: FunctionComponent<IStylePreviewProps> = ({
preset,
aspectRatio = 9 / 16, // Default to vertical (original behavior)
className,
}): JSX.Element => {
// ... existing logic ...
const containerStyle: CSSProperties = {
aspectRatio: String(aspectRatio),
}
return (
<div
className={cs(styles.previewContainer, className)}
style={containerStyle}
>
{/* ... existing preview content ... */}
</div>
)
}
- Step 4: Commit
git add src/features/project/CaptionSettingsStep/StylePreview.tsx
git add src/features/project/CaptionSettingsStep/StylePreview.module.scss
git commit -m "feat: add aspectRatio prop to StylePreview for dynamic sizing"
Task 4: Update PresetCard with New Design
Files:
-
Modify:
src/features/project/CaptionSettingsStep/PresetCard.tsx -
Modify:
src/features/project/CaptionSettingsStep/PresetCard.module.scss -
Step 1: Read existing PresetCard files
cat src/features/project/CaptionSettingsStep/PresetCard.tsx
cat src/features/project/CaptionSettingsStep/PresetCard.module.scss
- Step 2: Rewrite PresetCard.module.scss with new design
// PresetCard.module.scss
.presetCard {
position: relative;
border-radius: 12px;
overflow: hidden;
background: var(--bg-default);
border: 1px solid var(--border-subtle);
cursor: pointer;
transition: all 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
display: flex;
flex-direction: column;
&:hover {
border-color: var(--purple-400);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
}
.selected {
border-color: var(--purple-400);
box-shadow:
0 0 0 1px var(--purple-400),
0 0 20px rgba(203, 166, 247, 0.25),
var(--shadow-lg);
&::before {
content: "";
position: absolute;
inset: -1px;
border-radius: 12px;
padding: 1px;
background: linear-gradient(135deg, var(--purple-400), var(--purple-600));
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
}
.previewArea {
position: relative;
overflow: hidden;
}
.selectedIndicator {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
background: var(--purple-400);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(203, 166, 247, 0.4);
svg {
width: 12px;
height: 12px;
color: var(--bg-canvas);
}
}
.cardFooter {
padding: 14px 16px;
background: linear-gradient(to top, var(--bg-surface), var(--bg-default));
border-top: 1px solid var(--border-subtle);
}
.presetName {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.systemBadge {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 8px;
background: var(--purple-100);
color: var(--purple-400);
border-radius: 4px;
}
.styleChars {
font-size: 12px;
color: var(--text-tertiary);
display: flex;
align-items: center;
gap: 8px;
}
.colorDot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.divider {
color: var(--border-default);
}
- Step 3: Update PresetCard.tsx with style characteristics
// Add helper to extract style characteristics
function getStyleCharacteristics(preset: CaptionPresetRead): {
fontFamily: string
accentColor: string | null
accentName: string | null
} {
const style = preset.style_config
const fontFamily = style?.text?.font_family ?? "Inter"
// Extract accent color from highlight or text color
const highlightColor = style?.highlight?.color
const textColor = style?.text?.color
// Simple color name mapping (expand as needed)
const colorMap: Record<string, string> = {
"#FFD700": "Золотой",
"#00ffff": "Неоновый",
"#ffffff": "Белый",
"#ff006e": "Розовый",
"#cba6f7": "Пурпурный",
"#f38ba8": "Розовый",
"#a6e3a1": "Зеленый",
"#f9e2af": "Желтый",
"#89dceb": "Голубой",
}
const accentColor = highlightColor || textColor
const accentName = accentColor ? (colorMap[accentColor] ?? null) : null
return {
fontFamily,
accentColor,
accentName,
}
}
// Update component to render characteristics
export const PresetCard: FunctionComponent<IPresetCardProps> = ({
preset,
isSelected,
aspectRatio,
onSelect,
onEdit,
onDelete,
}): JSX.Element => {
const { fontFamily, accentColor, accentName } = getStyleCharacteristics(preset)
return (
<div
className={cs(styles.presetCard, { [styles.selected]: isSelected })}
onClick={onSelect}
>
<div className={styles.previewArea}>
<StylePreview preset={preset} aspectRatio={aspectRatio} />
{isSelected && (
<div className={styles.selectedIndicator}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
)}
</div>
<div className={styles.cardFooter}>
<div className={styles.presetName}>
{preset.name}
{preset.is_system && <span className={styles.systemBadge}>Системный</span>}
</div>
<div className={styles.styleChars}>
{fontFamily}
{accentColor && accentName && (
<>
<span className={styles.divider}>·</span>
<span
className={styles.colorDot}
style={{ background: accentColor }}
/>
<span style={{ color: accentColor }}>{accentName}</span>
</>
)}
</div>
</div>
{/* Context menu for edit/delete - preserve existing */}
</div>
)
}
- Step 4: Commit
git add src/features/project/CaptionSettingsStep/PresetCard.tsx
git add src/features/project/CaptionSettingsStep/PresetCard.module.scss
git commit -m "feat: redesign PresetCard with style characteristics and checkmark indicator"
Task 5: Update PresetGrid with Aspect Ratio and Loading State
Files:
-
Modify:
src/features/project/CaptionSettingsStep/PresetGrid.tsx -
Modify:
src/features/project/CaptionSettingsStep/PresetGrid.module.scss -
Step 1: Read existing PresetGrid files
cat src/features/project/CaptionSettingsStep/PresetGrid.tsx
cat src/features/project/CaptionSettingsStep/PresetGrid.module.scss
- Step 2: Update PresetGrid.module.scss
// PresetGrid.module.scss
.presetGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
@media (max-width: 768px) {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
}
// Optional: Add fade-in animation for cards
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.cardWrapper {
animation: fadeInUp 0.3s ease forwards;
// Staggered animation delay
@for $i from 1 through 10 {
&:nth-child(#{$i}) {
animation-delay: #{$i * 50}ms;
}
}
}
- Step 3: Update PresetGrid.tsx
// Add imports
import { useVideoMetadata } from "./useVideoMetadata"
import { PresetCardSkeleton } from "./PresetCardSkeleton"
import { useWizard } from "../WizardContext"
// In component
export const PresetGrid: FunctionComponent<IPresetGridProps> = ({
presets,
selectedPresetId,
onSelect,
onEdit,
onDelete,
onCreate,
}): JSX.Element => {
const { primaryFileId } = useWizard()
const { aspectRatio, isLoading: isLoadingMetadata } = useVideoMetadata(primaryFileId)
if (isLoadingMetadata) {
return (
<div className={styles.presetGrid}>
{Array.from({ length: 4 }).map((_, i) => (
<PresetCardSkeleton key={i} aspectRatio={aspectRatio} />
))}
</div>
)
}
return (
<div className={styles.presetGrid}>
{presets.map((preset, index) => (
<div
key={preset.id}
className={styles.cardWrapper}
style={{ animationDelay: `${index * 50}ms` }}
>
<PresetCard
preset={preset}
isSelected={preset.id === selectedPresetId}
aspectRatio={aspectRatio}
onSelect={() => onSelect(preset.id)}
onEdit={() => onEdit(preset.id)}
onDelete={() => onDelete(preset.id)}
/>
</div>
))}
{/* Create new card - preserve existing */}
</div>
)
}
- Step 4: Commit
git add src/features/project/CaptionSettingsStep/PresetGrid.tsx
git add src/features/project/CaptionSettingsStep/PresetGrid.module.scss
git commit -m "feat: add aspect ratio and loading state to PresetGrid"
Task 6: Type Check and Verify
- Step 1: Run type check
cd cofee_frontend && bunx tsc --noEmit
Expected: No TypeScript errors
- Step 2: Run lint check
cd cofee_frontend && bun run lint 2>/dev/null || echo "Lint not configured, using type check only"
- Step 3: Final commit
git add .
git commit -m "feat: complete subtitle preset grid redesign with dynamic aspect ratio"
Verification Checklist
- Preset cards display with correct aspect ratio based on uploaded video
- Loading state shows skeleton cards with shimmer animation
- Style characteristics (font, color) visible on card footers
- Selected card shows checkmark indicator and purple glow border
- Grid is responsive (2 columns on mobile, more on desktop)
- Hover effects work smoothly
- Falls back to 16:9 when no video is available
- All existing functionality preserved (select, edit, delete, create)