Files
remotion_service/docs/superpowers/plans/2026-04-06-subtitle-preset-grid-redesign.md
T
2026-04-06 01:44:58 +03:00

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)