🦄 Unicorn.Land ▸ docs/plans/2026-03-06-kiosk-redesign-v3-implementation.md
updated 2026-03-06

Kiosk Redesign V3 “Gallery Wall” Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Replace the cramped 55/45 bottom section with a full-width rotating stage (photo > life > world on 60s cadence), simplify the compact calendar row, establish strong visual hierarchy, and make the news ticker pop.

Architecture: New useStageRotation hook manages a 3-slot rotation (0=photo, 1=life, 2=world). New KioskRotatingStage component replaces KioskBottomSection + KioskRotatingPanel, rendering all 3 slots at position: absolute with opacity crossfades. Photo rotation interval changes from internal 12-min timer to advancing once per stage cycle. CSS grid updated from 7 rows to a cleaner structure where the stage gets 1fr.

Tech Stack: React 19, TypeScript, Tailwind 4, CSS custom properties (existing --fd-* theme system)


Task 1: Create useStageRotation Hook

Files: - Create: family-dashboard-code/src/hooks/useStageRotation.ts

Step 1: Write the hook

import { useState, useCallback } from 'react';
import { useInterval } from './useInterval';

export type StageSlot = 'photo' | 'life' | 'world';

const STAGES: StageSlot[] = ['photo', 'life', 'world'];

/**
 * Cycles through 3 stage slots on a timer.
 * Returns the active slot and a transitioning flag for crossfade.
 */
export function useStageRotation(cadenceMs: number = 60000) {
  const [index, setIndex] = useState(0);
  const [isTransitioning, setIsTransitioning] = useState(false);

  const advance = useCallback(() => {
    setIsTransitioning(true);
    // After 800ms fade-out, swap content and fade in
    setTimeout(() => {
      setIndex((prev) => (prev + 1) % STAGES.length);
      setIsTransitioning(false);
    }, 800);
  }, []);

  useInterval(advance, cadenceMs);

  return {
    activeSlot: STAGES[index],
    stageIndex: index,
    isTransitioning,
  };
}

Step 2: Verify it compiles

Run: cd family-dashboard-code && npx tsc --noEmit Expected: No errors

Step 3: Commit

git add family-dashboard-code/src/hooks/useStageRotation.ts
git commit -m "feat(kiosk): add useStageRotation hook — 3-slot 60s rotation cycle"

Task 2: Create KioskRotatingStage Component

Files: - Create: family-dashboard-code/src/components/kiosk/KioskRotatingStage.tsx

This component renders 3 absolutely-positioned layers (photo, life, world) and crossfades between them using the useStageRotation hook.

Step 1: Write the component

import React from 'react';
import { useStageRotation } from '../../hooks/useStageRotation';
import type { StageSlot } from '../../hooks/useStageRotation';

interface KioskRotatingStageProps {
  photoSlot: React.ReactNode;
  lifeSlot: React.ReactNode;
  worldSlot: React.ReactNode;
  cadenceMs?: number;
  onSlotChange?: (slot: StageSlot) => void;
}

export function KioskRotatingStage({
  photoSlot,
  lifeSlot,
  worldSlot,
  cadenceMs = 60000,
  onSlotChange,
}: KioskRotatingStageProps) {
  const { activeSlot, isTransitioning } = useStageRotation(cadenceMs);

  // Notify parent (used by photo frame to advance photo index)
  React.useEffect(() => {
    onSlotChange?.(activeSlot);
  }, [activeSlot, onSlotChange]);

  const slots: { key: StageSlot; content: React.ReactNode }[] = [
    { key: 'photo', content: photoSlot },
    { key: 'life', content: lifeSlot },
    { key: 'world', content: worldSlot },
  ];

  return (
    <div className="kiosk-stage">
      {slots.map(({ key, content }) => {
        const isActive = activeSlot === key && !isTransitioning;
        return (
          <div
            key={key}
            className="kiosk-stage-slot"
            style={{
              opacity: isActive ? 1 : 0,
              transition: 'opacity 800ms ease-in-out',
              pointerEvents: isActive ? 'auto' : 'none',
            }}
          >
            {content}
          </div>
        );
      })}
    </div>
  );
}

Step 2: Verify it compiles

Run: cd family-dashboard-code && npx tsc --noEmit Expected: No errors

Step 3: Commit

git add family-dashboard-code/src/components/kiosk/KioskRotatingStage.tsx
git commit -m "feat(kiosk): add KioskRotatingStage — full-width 3-slot crossfade"

Task 3: Update CSS — New Grid, Stage Styles, Ticker, Compact Row

Files: - Modify: family-dashboard-code/src/index.css

This is the largest single change. Update the kiosk grid definition, add stage styles, restyle the ticker, simplify compact row, and add hero elevation.

Step 1: Replace kiosk grid and related CSS

In index.css, make these changes:

  1. Replace .kiosk-grid (lines 144-156) with:
.kiosk-grid {
  display: grid;
  grid-template-areas:
    'header'
    'today'
    'next'
    'compact'
    'stage'
    'ticker'
    'status';
  grid-template-columns: 1fr;
  grid-template-rows: auto auto auto auto 1fr auto auto;
}
  1. Remove .kiosk-grid > [style*="grid-area: today"] rule (lines 158-160) — no longer needed.

  2. Replace .kiosk-bottom-section (lines 179-186) with stage styles:

/* Rotating stage — full-width zone for photo/life/world rotation */
.kiosk-stage {
  grid-area: stage;
  position: relative;
  min-height: 0;
  overflow: hidden;
}

.kiosk-stage-slot {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
  1. Add gallery photo frame style (after the stage styles):
/* Gallery photo treatment inside rotating stage */
.kiosk-gallery-frame {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100%;
  padding: clamp(12px, 1.5vw, 24px);
}

.kiosk-gallery-photo {
  position: relative;
  max-width: 100%;
  max-height: 100%;
  overflow: hidden;
  border-radius: 0.75rem;
  background: var(--fd-card-bg);
  border: 1px solid color-mix(in srgb, var(--fd-accent) 15%, var(--fd-card-border));
  padding: clamp(6px, 0.8vw, 12px);
}

.kiosk-gallery-photo .photo-frame {
  border-radius: 0.5rem;
}
  1. Update .kiosk-today-card (lines 288-300) — elevate the hero:

Replace background: var(--fd-card-bg); with:

background: color-mix(in srgb, var(--fd-card-bg) 100%, rgba(255,255,255,0.04));

And change border-left: 4px to border-left: 6px.

  1. Update .kiosk-next-day-card (lines 317-327) — remove left accent:

Replace border-left: 3px solid color-mix(in srgb, var(--fd-accent) 40%, transparent); with:

border-left: 1px solid var(--fd-card-border);
  1. Replace .kiosk-compact-grid and .kiosk-compact-card (lines 347-368) with simplified versions:
.kiosk-compact-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: clamp(8px, 0.8vw, 12px);
  min-height: 0;
}

.kiosk-compact-cell {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(2px, 0.3vw, 4px);
  padding: clamp(6px, 0.6vw, 10px);
}
  1. Replace ticker styles (lines 245-282) with the new marquee treatment:
/* News Ticker — "The Marquee" */
.ticker-container {
  width: 100%;
  overflow: hidden;
  position: relative;
  background: rgba(255, 255, 255, 0.06);
  border-radius: 0.75rem;
  padding: 0.6rem 1rem;
}

:root[data-phase="day"] .ticker-container {
  background: rgba(0, 0, 0, 0.04);
}

.ticker-carousel {
  position: relative;
  height: 1.6em;
  overflow: hidden;
}

.ticker-slide {
  height: 1.6em;
  line-height: 1.6em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  font-size: clamp(1rem, 1.3vw, 1.2rem);
  color: var(--fd-text-1);
  font-family: 'Outfit', sans-serif;
  letter-spacing: 0.01em;
  will-change: transform;
}

.ticker-source-label {
  font-weight: 700;
  color: var(--fd-accent);
  margin-right: 0.35em;
}

.ticker-accent-pip {
  display: inline-block;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--fd-accent);
  margin-right: 0.5em;
  vertical-align: middle;
}

Step 2: Verify CSS is valid — build the project

Run: cd family-dashboard-code && npx vite build 2>&1 | head -20 Expected: Build succeeds (warnings OK, no errors)

Step 3: Commit

git add family-dashboard-code/src/index.css
git commit -m "feat(kiosk): CSS overhaul — stage grid, gallery frame, ticker marquee, hero elevation"

Task 4: Simplify KioskCompactRow

Files: - Modify: family-dashboard-code/src/components/kiosk/KioskCompactRow.tsx

Replace the current detailed event list with a minimal day name + weather icon + event count.

Step 1: Rewrite the component

Replace the entire file contents with:

import { format } from 'date-fns';
import type { CalendarEvent } from '../../lib/calendar/types';
import { WeatherIcon } from '../weather/WeatherIcon';

interface CompactDay {
  date: Date;
  dateStr: string;
  events: CalendarEvent[];
}

interface KioskCompactRowProps {
  days: CompactDay[];
  dailyWeather: Array<{
    high: number;
    low: number;
    weatherCode: number;
  } | null>;
}

export function KioskCompactRow({ days, dailyWeather }: KioskCompactRowProps) {
  return (
    <div className="kiosk-compact-grid">
      {days.map((day, i) => {
        const dayTitle = format(day.date, 'EEE');
        const weather = dailyWeather[i];
        const eventCount = day.events.length;

        return (
          <div key={day.dateStr} className="kiosk-compact-cell">
            <span
              style={{
                fontSize: 'clamp(1.2rem, 1.5vw, 1.5rem)',
                fontWeight: 600,
                color: 'var(--fd-text-1)',
              }}
            >
              {dayTitle}
            </span>
            {weather && (
              <div
                className="flex items-center gap-1"
                style={{
                  fontSize: 'clamp(0.85rem, 1vw, 1.1rem)',
                  color: 'var(--fd-accent)',
                }}
              >
                <WeatherIcon
                  code={weather.weatherCode}
                  size="clamp(1.1rem, 1.3vw, 1.5rem)"
                />
                <span className="tabular-nums font-medium">
                  {Math.round(weather.high)}&deg;
                </span>
              </div>
            )}
            <span
              style={{
                fontSize: 'clamp(0.8rem, 0.95vw, 1rem)',
                color: eventCount > 0 ? 'var(--fd-text-2)' : 'var(--fd-text-2)',
                opacity: eventCount > 0 ? 0.8 : 0.4,
              }}
            >
              {eventCount === 0
                ? 'Free'
                : eventCount === 1
                  ? '1 event'
                  : `${eventCount} events`}
            </span>
          </div>
        );
      })}
    </div>
  );
}

Step 2: Verify it compiles

Run: cd family-dashboard-code && npx tsc --noEmit Expected: No errors

Step 3: Commit

git add family-dashboard-code/src/components/kiosk/KioskCompactRow.tsx
git commit -m "feat(kiosk): simplify compact row — day name, weather icon, event count only"

Task 5: Update KioskTodayCard — Hero Elevation

Files: - Modify: family-dashboard-code/src/components/kiosk/KioskTodayCard.tsx

Only two small changes: bump the “Today” title size and ensure the elevated hero background comes from CSS (already handled in Task 3).

Step 1: Update the title font size

In KioskTodayCard.tsx, line 49, change:

fontSize: 'clamp(1.6rem, 2.5vw, 2.2rem)',

to:

fontSize: 'clamp(1.8rem, 3vw, 2.6rem)',

Step 2: Verify it compiles

Run: cd family-dashboard-code && npx tsc --noEmit Expected: No errors

Step 3: Commit

git add family-dashboard-code/src/components/kiosk/KioskTodayCard.tsx
git commit -m "feat(kiosk): bump Today title to hero size"

Task 6: Update KioskNextDayCard — Remove Accent Border, Bump Text

Files: - Modify: family-dashboard-code/src/components/kiosk/KioskNextDayCard.tsx

The left accent border removal is handled in CSS (Task 3). Here we bump text sizes ~10%.

Step 1: Bump font sizes

In KioskNextDayCard.tsx:

Step 2: Verify it compiles

Run: cd family-dashboard-code && npx tsc --noEmit Expected: No errors

Step 3: Commit

git add family-dashboard-code/src/components/kiosk/KioskNextDayCard.tsx
git commit -m "feat(kiosk): bump next-day card text sizes ~10%"

Task 7: Update KioskPageA and KioskPageB — Full-Width Sizing

Files: - Modify: family-dashboard-code/src/components/kiosk/KioskPageA.tsx - Modify: family-dashboard-code/src/components/kiosk/KioskPageB.tsx

Both panels need larger text now that they get full viewport width in the rotating stage.

Step 1: Update KioskPageA text sizes

In KioskPageA.tsx:

Step 2: Update KioskPageB text sizes

In KioskPageB.tsx:

Step 3: Verify it compiles

Run: cd family-dashboard-code && npx tsc --noEmit Expected: No errors

Step 4: Commit

git add family-dashboard-code/src/components/kiosk/KioskPageA.tsx family-dashboard-code/src/components/kiosk/KioskPageB.tsx
git commit -m "feat(kiosk): bump page A/B text sizes for full-width stage"

Task 8: Update KioskNewsTicker — Add Accent Pip

Files: - Modify: family-dashboard-code/src/components/kiosk/KioskNewsTicker.tsx

Add the accent pip before each headline’s source label. CSS changes already handled in Task 3.

Step 1: Add pip to ticker slides

In KioskNewsTicker.tsx, update both ticker-slide divs (lines 47-49 and 55-57).

Change:

<span className="ticker-source-label">{current.source}:</span>

to:

<span className="ticker-accent-pip" aria-hidden="true" />
<span className="ticker-source-label">{current.source}:</span>

Do the same for the next slide (replace {next.source} line similarly).

Step 2: Verify it compiles

Run: cd family-dashboard-code && npx tsc --noEmit Expected: No errors

Step 3: Commit

git add family-dashboard-code/src/components/kiosk/KioskNewsTicker.tsx
git commit -m "feat(kiosk): add accent pip to news ticker headlines"

Task 9: Update StatusBar — Font Size Bump

Files: - Modify: family-dashboard-code/src/components/layout/StatusBar.tsx

Step 1: Bump kiosk font size

In StatusBar.tsx, line 31, change:

? 'clamp(0.86rem,1.05vw,1rem)'

to:

? 'clamp(0.9rem,1.1vw,1.05rem)'

Step 2: Commit

git add family-dashboard-code/src/components/layout/StatusBar.tsx
git commit -m "feat(kiosk): nudge status bar font size up"

Task 10: Wire Everything Together — Update KioskDashboard

Files: - Modify: family-dashboard-code/src/components/kiosk/KioskDashboard.tsx

This is the integration task. Replace KioskBottomSection with KioskRotatingStage, update grid area naming, and connect the photo advance callback.

Step 1: Rewrite KioskDashboard

Replace the entire file with:

import { useCallback, useRef } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Header } from '../layout/Header';
import { StatusBar } from '../layout/StatusBar';
import { KioskTodayCard } from './KioskTodayCard';
import { KioskNextDayCard } from './KioskNextDayCard';
import { KioskCompactRow } from './KioskCompactRow';
import { KioskRotatingStage } from './KioskRotatingStage';
import { KioskPhotoFrame } from './KioskPhotoFrame';
import { KioskPageA } from './KioskPageA';
import { KioskPageB } from './KioskPageB';
import { KioskNewsTicker } from './KioskNewsTicker';
import { PanelFallback, GlobalFallback, logError } from '../ErrorFallback';
import { useCalendar } from '../../hooks/useCalendar';
import { useWeather } from '../../hooks/useWeather';
import type { StageSlot } from '../../hooks/useStageRotation';

function getDailyWeather(weather: ReturnType<typeof useWeather>['data'], dayIndex: number) {
  if (!weather?.daily || dayIndex >= weather.daily.time.length) return null;
  return {
    high: weather.daily.temperature_2m_max[dayIndex],
    low: weather.daily.temperature_2m_min[dayIndex],
    weatherCode: weather.daily.weather_code[dayIndex],
  };
}

export function KioskDashboard() {
  const { days } = useCalendar();
  const { data: weather } = useWeather();
  const todayDay = days[0];
  const tomorrowDay = days[1];
  const dayAfterDay = days[2];
  const compactDays = days.slice(3, 7);
  const compactWeather = compactDays.map((_, i) => getDailyWeather(weather, i + 3));

  // Track when stage rotates back to photo slot so we can trigger
  // the next photo. We store a ref to avoid re-renders.
  const prevSlotRef = useRef<StageSlot>('photo');
  const handleSlotChange = useCallback((slot: StageSlot) => {
    // When we just arrived at 'photo' from a non-photo slot,
    // the photo frame's own rotation timer handles advancement.
    // No extra wiring needed — the photo frame's useGooglePhotos
    // hook runs its own interval. The stage just shows/hides it.
    prevSlotRef.current = slot;
  }, []);

  return (
    <ErrorBoundary FallbackComponent={GlobalFallback} onError={logError}>
      <div className="kiosk-grid h-dvh w-screen overflow-hidden p-[clamp(14px,1.5vw,24px)] gap-[clamp(10px,1vw,18px)]">
        <ErrorBoundary FallbackComponent={PanelFallback} onError={logError}>
          <Header variant="kiosk" />
        </ErrorBoundary>

        {todayDay && (
          <ErrorBoundary FallbackComponent={PanelFallback} onError={logError}>
            <div style={{ gridArea: 'today', minHeight: 0, overflow: 'hidden' }}>
              <KioskTodayCard day={todayDay} weather={weather} />
            </div>
          </ErrorBoundary>
        )}

        <div className="kiosk-next-row">
          {tomorrowDay && (
            <ErrorBoundary FallbackComponent={PanelFallback} onError={logError}>
              <KioskNextDayCard
                day={tomorrowDay}
                dailyWeather={getDailyWeather(weather, 1)}
              />
            </ErrorBoundary>
          )}
          {dayAfterDay && (
            <ErrorBoundary FallbackComponent={PanelFallback} onError={logError}>
              <KioskNextDayCard
                day={dayAfterDay}
                dailyWeather={getDailyWeather(weather, 2)}
              />
            </ErrorBoundary>
          )}
        </div>

        <ErrorBoundary FallbackComponent={PanelFallback} onError={logError}>
          <div className="kiosk-compact-area">
            <KioskCompactRow days={compactDays} dailyWeather={compactWeather} />
          </div>
        </ErrorBoundary>

        <ErrorBoundary FallbackComponent={PanelFallback} onError={logError}>
          <KioskRotatingStage
            cadenceMs={60000}
            onSlotChange={handleSlotChange}
            photoSlot={
              <ErrorBoundary FallbackComponent={PanelFallback} onError={logError}>
                <div className="kiosk-gallery-frame">
                  <div className="kiosk-gallery-photo">
                    <KioskPhotoFrame />
                  </div>
                </div>
              </ErrorBoundary>
            }
            lifeSlot={
              <ErrorBoundary FallbackComponent={PanelFallback} onError={logError}>
                <KioskPageA />
              </ErrorBoundary>
            }
            worldSlot={
              <ErrorBoundary FallbackComponent={PanelFallback} onError={logError}>
                <KioskPageB />
              </ErrorBoundary>
            }
          />
        </ErrorBoundary>

        <div className="kiosk-ticker-area">
          <ErrorBoundary FallbackComponent={PanelFallback} onError={logError}>
            <KioskNewsTicker />
          </ErrorBoundary>
        </div>

        <StatusBar variant="kiosk" />
      </div>
    </ErrorBoundary>
  );
}

Step 2: Verify it compiles

Run: cd family-dashboard-code && npx tsc --noEmit Expected: No errors

Step 3: Verify it builds

Run: cd family-dashboard-code && npx vite build 2>&1 | tail -5 Expected: Build succeeds

Step 4: Commit

git add family-dashboard-code/src/components/kiosk/KioskDashboard.tsx
git commit -m "feat(kiosk): wire KioskRotatingStage into dashboard — gallery wall layout"

Task 11: Remove Dead Code

Files: - Delete: family-dashboard-code/src/components/kiosk/KioskBottomSection.tsx - Delete: family-dashboard-code/src/components/kiosk/KioskRotatingPanel.tsx - Delete: family-dashboard-code/src/hooks/usePageRotation.ts

Step 1: Verify no other imports reference these files

Search for imports of the deleted modules across the codebase. The only consumer was KioskDashboard.tsx (updated in Task 10) and KioskBottomSection.tsx itself.

Run: grep -r "KioskBottomSection\|KioskRotatingPanel\|usePageRotation" family-dashboard-code/src/ --include="*.ts" --include="*.tsx" -l

Expected: No results (or only the files being deleted)

Step 2: Delete the files

rm family-dashboard-code/src/components/kiosk/KioskBottomSection.tsx
rm family-dashboard-code/src/components/kiosk/KioskRotatingPanel.tsx
rm family-dashboard-code/src/hooks/usePageRotation.ts

Step 3: Verify it still compiles and builds

Run: cd family-dashboard-code && npx tsc --noEmit && npx vite build 2>&1 | tail -5 Expected: No errors

Step 4: Commit

git add -u family-dashboard-code/src/components/kiosk/KioskBottomSection.tsx family-dashboard-code/src/components/kiosk/KioskRotatingPanel.tsx family-dashboard-code/src/hooks/usePageRotation.ts
git commit -m "chore(kiosk): remove KioskBottomSection, KioskRotatingPanel, usePageRotation"

Task 12: Smoke Test & Final Build

Step 1: Full type check

Run: cd family-dashboard-code && npx tsc --noEmit Expected: No errors

Step 2: Full production build

Run: cd family-dashboard-code && npx vite build Expected: Build succeeds with no errors

Step 3: Manual verification

Run: cd family-dashboard-code && npx vite preview --port 4173

Open http://localhost:4173 in browser. Verify: - Kiosk grid shows header, today hero, tomorrow/day-after, compact row, rotating stage, ticker, status - Rotating stage cycles through photo > life > world every 60s with crossfade - Compact row shows only day name, weather icon, event count - Ticker has visible background, accent pip, larger text - Today card has prominent 6px left accent border

Step 4: Final commit (if any fixes needed)

git commit -m "fix(kiosk): address smoke test findings"