family-dashboard-code ▸ docs/plans/2026-04-03-kiosk-v5-focus.md
updated 2026-04-03

Kiosk v5 “Focus” 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: Overhaul the kiosk dashboard to prioritize calendar visibility, photo prominence, and readability — remove the rotating stage, expand calendar to 10 days, switch to always-dark theme, enlarge UI elements, upgrade photo frame styling, and fix travel auto-detection for same-timezone cities.

Architecture: The kiosk view (?view=kiosk) is a single-column CSS grid in KioskDashboard.tsx. Changes are kiosk-only — the default mobile dashboard is untouched. The grid loses its stage row, gains more calendar days, and the photo row becomes the sole flex element absorbing remaining space.

Tech Stack: React 19, TypeScript, Vite 7, Tailwind 4, CSS custom properties, Open-Meteo API, date-fns

Spec: docs/2026-04-02-kiosk-v5-focus-design.md


Task 1: Always-Dark Theme

Files: - Modify: src/index.css:19-77 (theme token definitions)

In src/index.css, replace the :root default block (lines 19-36) with:

:root {
  --fd-bg-1: #141210;
  --fd-bg-2: #1C1816;
  --fd-card-bg: #1C1816;
  --fd-card-border: rgba(255, 255, 255, 0.06);
  --fd-accent: #D4813A;
  --fd-accent-teal: #56BDB0;
  --fd-text-1: #F5F0E8;
  --fd-text-2: #C4B8AC;
  --fd-photo-frame: #1C1816;
  --fd-compact-bg: #2A2420;
  --fd-ticker-bg: #2A2420;
  --fd-divider: #2A2420;
  --fd-shimmer-1: #E8835A;
  --fd-shimmer-2: #D4A43A;
  --fd-shimmer-3: #56BDB0;
  --fd-shimmer-4: #7BA4D9;
}

Replace the :root[data-phase="night"], :root[data-phase="dawn"], :root[data-phase="dusk"] block (lines 38-57) with:

:root[data-phase="night"],
:root[data-phase="dawn"],
:root[data-phase="dusk"] {
  --fd-bg-1: #141210;
  --fd-bg-2: #1C1816;
  --fd-card-bg: #1C1816;
  --fd-card-border: rgba(255, 255, 255, 0.06);
  --fd-accent: #D4813A;
  --fd-accent-teal: #56BDB0;
  --fd-text-1: #F5F0E8;
  --fd-text-2: #C4B8AC;
  --fd-photo-frame: #1C1816;
  --fd-compact-bg: #2A2420;
  --fd-ticker-bg: #2A2420;
  --fd-divider: #2A2420;
  --fd-shimmer-1: #E8835A;
  --fd-shimmer-2: #D4A43A;
  --fd-shimmer-3: #56BDB0;
  --fd-shimmer-4: #7BA4D9;
}

Replace the :root[data-phase="day"], :root[data-phase="golden"] block (lines 59-77) with:

:root[data-phase="day"],
:root[data-phase="golden"] {
  --fd-bg-1: #1E1A16;
  --fd-bg-2: #2A2420;
  --fd-card-bg: #2A2420;
  --fd-card-border: rgba(255, 255, 255, 0.08);
  --fd-accent: #D4813A;
  --fd-accent-teal: #56BDB0;
  --fd-text-1: #F5F0E8;
  --fd-text-2: #C4B8AC;
  --fd-photo-frame: #2A2420;
  --fd-compact-bg: #3D3530;
  --fd-ticker-bg: #3D3530;
  --fd-divider: #3D3530;
  --fd-shimmer-1: #E8835A;
  --fd-shimmer-2: #D4A43A;
  --fd-shimmer-3: #56BDB0;
  --fd-shimmer-4: #7BA4D9;
}

Run: cd family-dashboard-code && npm run build Expected: Build succeeds with no errors.

git add src/index.css
git commit -m "feat(kiosk): always-dark theme with subtle day/night variation"

Task 2: Remove Rotating Stage from Kiosk Grid

Files: - Modify: src/components/kiosk/KioskDashboard.tsx (remove stage imports/render) - Modify: src/index.css:124-137 (grid template)

In src/index.css, replace the .kiosk-grid block (lines 124-137) with:

.kiosk-grid {
  display: grid;
  grid-template-areas:
    'header'
    'today'
    'next'
    'compact'
    'photo'
    'ticker'
    'status';
  grid-template-columns: 1fr;
  grid-template-rows: auto auto auto auto minmax(200px, 1fr) auto auto;
}

In src/components/kiosk/KioskDashboard.tsx:

Remove these imports (lines 8-12 selectively):

import { KioskRotatingStage } from './KioskRotatingStage';
import { KioskPageA } from './KioskPageA';
import { KioskPageB } from './KioskPageB';

Remove the StageSlot type import:

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

Remove the prevSlotRef and handleSlotChange code (lines 36-39):

  const prevSlotRef = useRef<StageSlot>('life');
  const handleSlotChange = useCallback((slot: StageSlot) => {
    prevSlotRef.current = slot;
  }, []);

Remove the useRef and useCallback from the React import if no longer used:

// Change from:
import { useCallback, useRef } from 'react';
// To (if neither is used elsewhere):
import React from 'react';

Remove the entire stage ErrorBoundary block (lines 81-96):

        <ErrorBoundary FallbackComponent={PanelFallback} onError={logError}>
          <KioskRotatingStage
            cadenceMs={60000}
            onSlotChange={handleSlotChange}
            lifeSlot={...}
            worldSlot={...}
          />
        </ErrorBoundary>

Run: cd family-dashboard-code && npm run build Expected: Build succeeds. No references to KioskRotatingStage, KioskPageA, or KioskPageB remain in KioskDashboard.

git add src/components/kiosk/KioskDashboard.tsx src/index.css
git commit -m "feat(kiosk): remove rotating stage, simplify grid layout"

Task 3: Extend Calendar to 10 Days

Files: - Modify: src/hooks/useCalendar.ts:49-68 (change 7 to 10) - Modify: src/lib/api/openMeteo.ts:40 (change forecast_days to 10) - Modify: src/components/kiosk/KioskDashboard.tsx:33 (update slice)

In src/hooks/useCalendar.ts, change both instances of length: 7 to length: 10:

Line 51 (empty days fallback):

      const emptyDays = Array.from({ length: 10 }, (_, i) => {

Line 68 (day schedules):

    const daySchedules: DaySchedule[] = Array.from({ length: 10 }, (_, i) => {

In src/lib/api/openMeteo.ts, line 40, change:

    forecast_days: '7',

to:

    forecast_days: '10',

In src/components/kiosk/KioskDashboard.tsx, line 33, change:

  const compactDays = days.slice(3, 7);

to:

  const compactDays = days.slice(3, 10);

And update the weather mapping on line 34 to match:

  const compactWeather = compactDays.map((_, i) => getDailyWeather(weather, i + 3));

(This line stays the same — the i + 3 offset correctly maps compact day index to weather day index.)

Run: cd family-dashboard-code && npm run build Expected: Build succeeds.

git add src/hooks/useCalendar.ts src/lib/api/openMeteo.ts src/components/kiosk/KioskDashboard.tsx
git commit -m "feat(kiosk): extend calendar to 10 days, request 10-day weather forecast"

Task 4: Expand Compact Row to Show Event Names

Files: - Modify: src/components/kiosk/KioskCompactRow.tsx (full rewrite of render) - Modify: src/index.css (update .kiosk-compact-grid for 7 columns)

In src/index.css, replace the .kiosk-compact-grid rule (lines 386-390):

.kiosk-compact-grid {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  gap: clamp(6px, 0.6vw, 10px);
  min-height: 0;
}

Update .kiosk-compact-cell (lines 392-398):

.kiosk-compact-cell {
  display: flex;
  flex-direction: column;
  gap: clamp(2px, 0.2vw, 3px);
  padding: clamp(6px, 0.5vw, 8px);
  background: var(--fd-compact-bg);
  border-radius: 0.65rem;
  overflow: hidden;
  min-width: 0;
}

Replace the full content of src/components/kiosk/KioskCompactRow.tsx:

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

const MAX_EVENTS = 3;

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

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

function CompactEvent({ event }: { event: CalendarEvent }) {
  const emoji = event.persons[0]
    ? undefined // person emoji is already in the summary for kiosk
    : undefined;
  const personEmojis = event.persons
    .filter((p) => p !== 'family')
    .slice(0, 2);

  return (
    <div
      className="truncate"
      style={{
        fontSize: 'clamp(0.65rem, 0.8vw, 0.85rem)',
        color: 'var(--fd-text-2)',
        lineHeight: 1.3,
      }}
      title={event.summary}
    >
      {event.summary}
    </div>
  );
}

export function KioskCompactRow({ days, dailyWeather }: KioskCompactRowProps) {
  return (
    <div className="kiosk-compact-grid">
      {days.map((day, i) => {
        const dayAbbrev = format(day.date, 'EEE');
        const dateNum = format(day.date, 'd');
        const weather = dailyWeather[i];
        const visibleEvents = day.events.slice(0, MAX_EVENTS);
        const extraCount = day.events.length - MAX_EVENTS;

        return (
          <div key={day.dateStr} className="kiosk-compact-cell">
            {/* Day header: abbrev + date */}
            <div className="flex items-baseline justify-between">
              <span
                style={{
                  fontSize: 'clamp(0.9rem, 1.1vw, 1.15rem)',
                  fontWeight: 600,
                  color: 'var(--fd-text-1)',
                }}
              >
                {dayAbbrev}
              </span>
              <span
                style={{
                  fontSize: 'clamp(0.75rem, 0.9vw, 0.95rem)',
                  color: 'var(--fd-text-2)',
                  opacity: 0.7,
                }}
              >
                {dateNum}
              </span>
            </div>

            {/* Weather */}
            {weather && (
              <div
                className="flex items-center gap-0.5"
                style={{
                  fontSize: 'clamp(0.7rem, 0.85vw, 0.9rem)',
                  color: 'var(--fd-accent)',
                }}
              >
                <WeatherIcon
                  code={weather.weatherCode}
                  size="clamp(0.9rem, 1vw, 1.1rem)"
                />
                <span className="tabular-nums font-medium">
                  {Math.round(weather.high)}&deg;
                </span>
                <span
                  className="tabular-nums"
                  style={{ color: 'var(--fd-text-2)', opacity: 0.6 }}
                >
                  {Math.round(weather.low)}&deg;
                </span>
              </div>
            )}

            {/* Events */}
            {visibleEvents.length > 0 ? (
              <div className="flex flex-col gap-px mt-0.5">
                {visibleEvents.map((event) => (
                  <CompactEvent key={event.id} event={event} />
                ))}
                {extraCount > 0 && (
                  <span
                    style={{
                      fontSize: 'clamp(0.6rem, 0.7vw, 0.75rem)',
                      color: 'var(--fd-text-2)',
                      opacity: 0.5,
                    }}
                  >
                    +{extraCount} more
                  </span>
                )}
              </div>
            ) : (
              <span
                style={{
                  fontSize: 'clamp(0.65rem, 0.8vw, 0.85rem)',
                  color: 'var(--fd-text-2)',
                  opacity: 0.35,
                  fontStyle: 'italic',
                }}
              >
                Free
              </span>
            )}
          </div>
        );
      })}
    </div>
  );
}

Run: cd family-dashboard-code && npm run build Expected: Build succeeds.

git add src/components/kiosk/KioskCompactRow.tsx src/index.css
git commit -m "feat(kiosk): compact row shows 7 days with event names"

Task 5: Header Size Bump (~30-40%)

Files: - Modify: src/components/clock/Clock.tsx (scale kiosk font sizes) - Modify: src/components/clock/DateDisplay.tsx (scale kiosk font size) - Modify: src/components/weather/CurrentWeather.tsx (scale kiosk font sizes) - Modify: src/components/weather/SunTimes.tsx (scale kiosk font sizes)

In src/components/clock/Clock.tsx:

Main clock (line 35-36), change:

          fontSize: isKiosk
            ? 'clamp(4rem, 12vw, 11rem)'

to:

          fontSize: isKiosk
            ? 'clamp(5.5rem, 15vw, 14rem)'

Travel location label (line 49-50), change:

              fontSize: isKiosk
                ? 'clamp(0.7rem,1vw,0.95rem)'

to:

              fontSize: isKiosk
                ? 'clamp(0.95rem,1.35vw,1.3rem)'

Travel time (line 59-60), change:

              fontSize: isKiosk
                ? 'clamp(0.9rem,1.3vw,1.2rem)'

to:

              fontSize: isKiosk
                ? 'clamp(1.2rem,1.7vw,1.6rem)'

In src/components/clock/DateDisplay.tsx, line 20-21, change:

        fontSize: isKiosk
          ? 'clamp(1.2rem, 1.8vw, 1.8rem)'

to:

        fontSize: isKiosk
          ? 'clamp(1.6rem, 2.4vw, 2.4rem)'

In src/components/weather/CurrentWeather.tsx:

Weather icon (lines 73-74), change:

              ? 'clamp(2.2rem, 3.6vw, 4rem)'

to:

              ? 'clamp(3rem, 4.8vw, 5.5rem)'

Temperature (lines 83-84), change:

              fontSize: isKiosk
                ? 'clamp(2.3rem, 3.8vw, 4.2rem)'

to:

              fontSize: isKiosk
                ? 'clamp(3rem, 5vw, 5.5rem)'

Weather description (lines 93-94), change:

              fontSize: isKiosk
                ? 'clamp(0.86rem,1.25vw,1.1rem)'

to:

              fontSize: isKiosk
                ? 'clamp(1.15rem,1.7vw,1.5rem)'

Travel weather icon (lines 110-111), change:

              ? 'clamp(1rem, 1.4vw, 1.4rem)'

to:

              ? 'clamp(1.3rem, 1.9vw, 1.9rem)'

Travel temp (lines 119-120), change:

              fontSize: isKiosk
                ? 'clamp(0.8rem, 1.1vw, 1rem)'

to:

              fontSize: isKiosk
                ? 'clamp(1.05rem, 1.5vw, 1.35rem)'

Travel location name (lines 129-130), change:

              fontSize: isKiosk
                ? 'clamp(0.65rem, 0.9vw, 0.85rem)'

to:

              fontSize: isKiosk
                ? 'clamp(0.85rem, 1.2vw, 1.15rem)'

In src/components/weather/SunTimes.tsx:

Container font size (lines 38-39), change:

        fontSize: isKiosk
          ? 'clamp(0.82rem,1.18vw,1.02rem)'

to:

        fontSize: isKiosk
          ? 'clamp(1.1rem,1.6vw,1.4rem)'

Sunrise/sunset icon sizes (line 46 and similar), change both:

          size={isKiosk ? 'clamp(1.2rem,1.8vw,1.9rem)' : 'clamp(1rem, 1.5vw, 1.5rem)'}

to:

          size={isKiosk ? 'clamp(1.6rem,2.4vw,2.5rem)' : 'clamp(1rem, 1.5vw, 1.5rem)'}

(Apply to both the WiSunrise and WiSunset icon size props.)

Run: cd family-dashboard-code && npm run build Expected: Build succeeds.

git add src/components/clock/Clock.tsx src/components/clock/DateDisplay.tsx src/components/weather/CurrentWeather.tsx src/components/weather/SunTimes.tsx
git commit -m "feat(kiosk): scale up header elements ~35% for distance readability"

Task 6: News Ticker Size Bump

Files: - Modify: src/index.css (ticker CSS rules)

In src/index.css, update the ticker CSS rules:

Replace .ticker-container (lines 288-293):

.ticker-container {
  width: 100%;
  overflow: hidden;
  position: relative;
  background: var(--fd-ticker-bg);
  border-radius: 0.75rem;
  padding: 0.85rem 1.2rem;
}

Replace .ticker-carousel (lines 295-298):

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

Replace .ticker-slide (lines 300-312):

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

Replace .ticker-accent-pip (lines 320-328):

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

Run: cd family-dashboard-code && npm run build Expected: Build succeeds.

git add src/index.css
git commit -m "feat(kiosk): scale up news ticker ~35% for distance readability"

Task 7: Photo Frame — Framed Print Treatment

Files: - Modify: src/index.css (photo frame CSS rules)

In src/index.css, replace .kiosk-gallery-frame (lines 206-212):

.kiosk-gallery-frame {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100%;
  padding: clamp(8px, 1vw, 16px);
}

Replace .kiosk-gallery-photo (lines 214-223):

.kiosk-gallery-photo {
  position: relative;
  width: 100%;
  flex: 1;
  min-height: 0;
  overflow: hidden;
  border-radius: 0.75rem;
  border: 1px solid rgba(255, 255, 255, 0.12);
  box-shadow:
    0 4px 24px rgba(0, 0, 0, 0.5),
    0 1px 4px rgba(0, 0, 0, 0.3);
  background: transparent;
  padding: 0;
}

Replace .kiosk-gallery-photo .photo-frame (lines 225-227):

.kiosk-gallery-photo .photo-frame {
  border-radius: 0.5rem;
  box-shadow: inset 0 0 12px rgba(0, 0, 0, 0.2);
}

Run: cd family-dashboard-code && npm run build Expected: Build succeeds.

git add src/index.css
git commit -m "feat(kiosk): framed print photo treatment — shadow + thin border"

Task 8: Fix Travel Auto-Detection for Same-Timezone Cities

Files: - Modify: src/hooks/useAutoTravelTarget.ts:121-149 (resolveTravelTarget function)

In src/hooks/useAutoTravelTarget.ts, replace the resolveTravelTarget function (lines 121-150):

async function resolveTravelTarget(candidates: TravelCandidate[]): Promise<TravelTarget | null> {
  for (const candidate of candidates.slice(0, 12)) {
    const results = await geocodeLocation(candidate.query);
    if (results.length === 0) continue;

    const selected = results[0];
    if (!selected) continue;

    // Skip if the location is in the home area (Berlin/Germany)
    if (isHomeArea(candidate.query) || isHomeArea(selected.name)) {
      continue;
    }

    const person = candidate.personId
      ? CALENDAR_FEEDS.find((feed) => feed.id === candidate.personId)
      : null;
    const who = person ? `${person.emoji} ${person.name}` : 'Travel';
    const where = selected.country ? `${selected.name}, ${selected.country}` : selected.name;

    return {
      id: `auto-${candidate.personId ?? 'unknown'}-${candidate.query.toLowerCase()}`,
      label: `${who} (${where})`,
      timezone: selected.timezone,
      latitude: selected.latitude,
      longitude: selected.longitude,
    };
  }

  return null;
}

Key changes: - Removed results.find((result) => result.timezone !== HOME_TIMEZONE) ?? results[0] — now always uses results[0] - Removed the selected.timezone === HOME_TIMEZONE && isHomeArea(candidate.query) check — replaced with a simpler isHomeArea guard on both the candidate query and the geocoded name - Paris, Amsterdam, Rome, Warsaw etc. will now correctly trigger travel weather display

Run: cd family-dashboard-code && npm run build Expected: Build succeeds.

git add src/hooks/useAutoTravelTarget.ts
git commit -m "fix(travel): detect same-timezone travel (Paris, Amsterdam, etc.)"

Task 9: Final Build Verification

Run: cd family-dashboard-code && npm run build Expected: Build succeeds with no TypeScript errors.

Open the dev server and verify at ?view=kiosk:

Run: cd family-dashboard-code && npm run dev

Check: - [ ] Dark theme active at all times of day (no bright cream) - [ ] No rotating stage visible - [ ] Today hero card shows full events - [ ] Days 2-3 show medium cards with events - [ ] Days 4-10 show in compact grid with event names - [ ] Photo frame fills remaining vertical space - [ ] Photo has subtle shadow + thin border (no colored mat) - [ ] Clock, date, weather, sun times are visibly larger - [ ] News ticker text is readable - [ ] If a travel event exists, travel weather appears for same-timezone cities