+ {/* Settings gear — left side, home screen only */}
+ {isHome && (
+
setViewState('pour-over')}
onDialIn={() => setViewState('dial-in')}
onShotAnalysis={() => setViewState('shot-analysis')}
- onSettings={() => setViewState('settings')}
aiConfigured={aiAvailable}
hideAiWhenUnavailable={hideAiWhenUnavailable}
controlCenter={
diff --git a/apps/web/src/views/StartView.tsx b/apps/web/src/views/StartView.tsx
index d643b1aa..a786f70c 100644
--- a/apps/web/src/views/StartView.tsx
+++ b/apps/web/src/views/StartView.tsx
@@ -1,48 +1,10 @@
-import { useState, useEffect, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { scaleIn, gentleSpring } from '@/lib/animations'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
-import { Plus, Coffee, Play, Gear, Drop, ChartLine, Crosshair } from '@phosphor-icons/react'
-import { getServerUrl } from '@/lib/config'
-import { isDirectMode } from '@/lib/machineMode'
-
-const IGNORED_NAMES = ['meticai', 'metic ai', 'gemini', 'admin', 'user', 'default']
-
-function isValidAuthorName(name: string | undefined): name is string {
- if (!name) return false
- const trimmed = name.trim()
- if (!trimmed) return false
- return !IGNORED_NAMES.some(ignored => trimmed.toLowerCase().includes(ignored))
-}
-
-function pickGreeting(
- t: ReturnType
['t'],
-): string {
- const hour = new Date().getHours()
- let period: string
-
- if (hour >= 5 && hour < 12) {
- period = 'morning'
- } else if (hour >= 12 && hour < 17) {
- period = 'afternoon'
- } else {
- period = 'evening'
- }
-
- const result = t(`greetings.${period}`, { returnObjects: true })
- const greetings = Array.isArray(result) ? result as string[] : null
- if (!greetings || greetings.length === 0) {
- return 'Hello!'
- }
- return greetings[Math.floor(Math.random() * greetings.length)]
-}
-
-function applyName(greeting: string, firstName?: string): string {
- if (!firstName) return greeting
- return greeting.replace(/!$/, `, ${firstName}!`)
-}
+import { Plus, Coffee, Play, Drop, ChartLine, Crosshair } from '@phosphor-icons/react'
+import { useIsMobile } from '@/hooks/use-mobile'
interface StartViewProps {
profileCount: number | null
@@ -53,7 +15,6 @@ interface StartViewProps {
onDialIn: () => void
onPourOver: () => void
onShotAnalysis: () => void
- onSettings: () => void
aiConfigured?: boolean
hideAiWhenUnavailable?: boolean
controlCenter?: React.ReactNode
@@ -61,7 +22,6 @@ interface StartViewProps {
}
export function StartView({
- profileCount,
onGenerateNew,
onViewHistory,
onProfileCatalogue,
@@ -69,39 +29,78 @@ export function StartView({
onDialIn,
onPourOver,
onShotAnalysis,
- onSettings,
aiConfigured = true,
hideAiWhenUnavailable = false,
controlCenter,
lastShotBanner,
}: StartViewProps) {
const { t } = useTranslation()
- const [firstName, setFirstName] = useState(undefined)
-
- // Pick greeting once per language — stable across re-renders, updates on locale change
- const greetingBase = useMemo(() => pickGreeting(t), [t])
-
- useEffect(() => {
- const fetchAuthorName = async () => {
- if (isDirectMode()) return // No MeticAI backend
- try {
- const serverUrl = await getServerUrl()
- const response = await fetch(`${serverUrl}/api/settings`)
- if (response.ok) {
- const data = await response.json()
- const name = data.authorName?.trim()
- if (isValidAuthorName(name)) {
- setFirstName(name.split(/\s+/)[0])
- }
- }
- } catch {
- // Silently ignore — greeting will just omit the name
- }
- }
- fetchAuthorName()
- }, [])
-
- const greeting = applyName(greetingBase, firstName)
+ const isMobile = useIsMobile()
+
+ // Action buttons content (shared between mobile and desktop layouts)
+ const actionButtons = (
+
+ {/* Core actions — 2×3 grid on mobile, stacked on desktop */}
+
+
+
+ {(!hideAiWhenUnavailable || aiConfigured) && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ )
return (
-
-
-
{greeting}
-
- {profileCount && profileCount > 0
- ? t('profileGeneration.youHaveProfiles', { count: profileCount })
- : t('profileGeneration.getStarted')}
-
-
-
- {/* Control Center — machine status (mobile only, passed from App) */}
- {controlCenter}
-
- {/* Last-shot analysis prompt */}
-
- {lastShotBanner}
-
-
-
- {/* Generate New — primary action, always full width */}
- {(!hideAiWhenUnavailable || aiConfigured) && (
-
- )}
-
- {!aiConfigured && !hideAiWhenUnavailable && (
-
- {t('navigation.aiUnavailable')}
-
- )}
-
- {/* Core actions — 2-col grid on mobile, stacked on desktop */}
-
-
-
-
-
-
-
-
-
-
-
- {/* Settings — ember accent, completes the 2×3 grid */}
-
-
+ {isMobile ? (
+ // Mobile: no wrapping card — control centre and buttons as separate cards
+
+ {controlCenter}
+
+ {/* Last-shot analysis prompt */}
+
+ {lastShotBanner}
+
+
+
+ {actionButtons}
+
-
+ ) : (
+ // Desktop: single card with all content
+
+ {controlCenter}
+
+
+ {lastShotBanner}
+
+
+ {actionButtons}
+
+ )}
)
}