Skip to content

feat: Desktop two-column layout for Profile Detail, Shot Analysis, and Run Shot views#171

Open
hessius wants to merge 14 commits intoversion/2.0.0from
feat/desktop-two-column
Open

feat: Desktop two-column layout for Profile Detail, Shot Analysis, and Run Shot views#171
hessius wants to merge 14 commits intoversion/2.0.0from
feat/desktop-two-column

Conversation

@hessius
Copy link
Owner

@hessius hessius commented Feb 11, 2026

Desktop Two-Column Layout

Summary

Adds a responsive two-column layout for desktop viewports (≥1024px). Mobile layout remains completely unchanged — the single-column experience stays exactly the same below 1024px.

Changes

Container (App.tsx)

  • Widened from max-w-md (448px) to max-w-md lg:max-w-5xl — desktop gets the full width, mobile stays narrow
  • Added lg:px-8 horizontal padding for desktop breathing room

Layout System (index.css)

  • .desktop-two-col — CSS grid, 2 equal columns, 1.5rem gap (activates at min-width: 1024px)
  • .desktop-full-width — spans both columns for headers/full-width content
  • .desktop-panel-left — elevated z-index, appears in front
  • .desktop-panel-right — subtle 3D perspective transform (rotateY(-1.5deg) translateZ(-8px)), left-edge depth shadow, smooth hover transition to flat

View-Specific Layouts

ProfileDetailView (HistoryView.tsx)

  • Left: Coffee Analysis + Profile Sections + ProfileBreakdown
  • Right: Action buttons (Shot History, Run/Schedule, Image Upload/Generate, Export)

Shot Detail (ShotHistoryView.tsx)

  • Left: Summary Stats (3-col grid) + Extraction Graph + Stage Legend
  • Right: Tabs (Replay / Compare / Analyze)

RunShotView (RunShotView.tsx)

  • Left: Profile Selection + Options + Action Button + Help Text
  • Right: Scheduled Shots + Recurring Schedules

Visual Effect

The right panel has a subtle 3D "leaning back" effect via CSS perspective transform, with a soft shadow along the left edge. On hover, the panel smoothly transitions to flat. This creates a layered, magazine-style layout on desktop without any JavaScript.

Testing

  • ✅ TypeScript type-check passes (tsc --noEmit)
  • ✅ Vite production build succeeds
  • ✅ Mobile layout unchanged (CSS only activates at min-width: 1024px)

…d Run Shot views

- Widen container from max-w-md to lg:max-w-5xl for desktop breakpoint
- Add CSS grid-based .desktop-two-col layout system (1024px+ breakpoint)
- 3D layered panel effect: left panel elevated, right panel with subtle
  perspective transform (rotateY -1.5deg) and depth shadow
- ProfileDetailView: analysis+breakdown left, actions right
- ShotHistoryView: stats+chart left, replay/compare/analyze tabs right
- RunShotView: profile+options+action left, schedules right
- Mobile layout completely unchanged (single column below 1024px)
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a desktop-only (≥1024px) two-column layout system to improve information density for profile details, shot details/analysis, and run/scheduling flows, while aiming to keep mobile behavior unchanged.

Changes:

  • Introduces reusable desktop grid/panel classes (.desktop-two-col, panel left/right effects) in global CSS.
  • Refactors ProfileDetailView, ShotHistoryView shot details, and RunShotView to render into left/right columns on desktop.
  • Expands the app container max width on desktop to allow the new layout to breathe.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
apps/web/src/index.css Adds desktop grid + 3D right-panel styling helpers.
apps/web/src/components/HistoryView.tsx Wraps profile detail content/actions into desktop left/right panels.
apps/web/src/components/ShotHistoryView.tsx Splits shot detail view into stats/chart (left) and tabs (right) on desktop.
apps/web/src/components/RunShotView.tsx Splits run/schedule flow into controls (left) and schedules (right) on desktop.
apps/web/src/App.tsx Widens main container on desktop (lg:max-w-5xl) and increases desktop padding.

hessius and others added 5 commits February 12, 2026 12:50
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Replace the condensed 25-line PROFILING_KNOWLEDGE with the full Advanced
Espresso Profiling Guide (~250 lines) covering puck dynamics, phase
blueprints, troubleshooting, flow vs pressure strategy, exit trigger
best practices, and anti-patterns.

Also embed the guide directly in the Gemini CLI profile creation prompt
alongside the OEPF RFC, eliminating the need for the CLI to call
get_profiling_knowledge MCP tool at runtime.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 4 comments.

Comment on lines 2677 to 2744
{activeAction === 'replay' && (() => {
const chartData = getChartData(shotData)
const stageRanges = getStageRanges(chartData)
const hasGravFlow = chartData.some(d => d.gravimetricFlow !== undefined && d.gravimetricFlow > 0)
const dataMaxTime = chartData.length > 0 ? chartData[chartData.length - 1].time : 0
const mergedData = mergeWithTargetCurves(chartData, analysisResult?.profile_target_curves)
const maxPressure = Math.max(...chartData.map(d => d.pressure || 0), ...(analysisResult?.profile_target_curves?.map(d => d.target_pressure || 0) || []), 12)
const maxFlow = Math.max(...chartData.map(d => Math.max(d.flow || 0, d.gravimetricFlow || 0)), ...(analysisResult?.profile_target_curves?.map(d => d.target_flow || 0) || []), 8)
const maxLeftAxis = Math.ceil(Math.max(maxPressure, maxFlow) * 1.1)
const maxWeight = Math.max(...chartData.map(d => d.weight || 0), 50)
const maxRightAxis = Math.ceil(maxWeight * 1.1)
const isShowingReplay = currentTime > 0 && currentTime < dataMaxTime
const displayData = isShowingReplay ? mergedData.filter(d => d.time <= currentTime) : mergedData
const displayStageRanges = isShowingReplay
? stageRanges.filter(s => s.startTime <= currentTime).map(s => ({ ...s, endTime: Math.min(s.endTime, currentTime) }))
: stageRanges
return (
<>
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold tracking-wide text-primary flex items-center gap-2">
<ChartLine size={16} weight="bold" />
Extraction Graph
</Label>
{isPlaying && (
<Badge variant="secondary" className="animate-pulse">
<Play size={10} weight="fill" className="mr-1" />
Replaying {playbackSpeed}x
</Badge>
)}
</div>
<div className="bg-secondary/40 rounded-xl border border-border/20 p-2">
<div className="h-[60vh] min-h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={displayData} margin={{ top: 5, right: 5, left: -5, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" opacity={0.3} />
{displayStageRanges.map((stage, idx) => (
<ReferenceArea key={idx} yAxisId="left" x1={stage.startTime} x2={stage.endTime} fill={STAGE_COLORS[stage.colorIndex]} fillOpacity={1} stroke={STAGE_BORDER_COLORS[stage.colorIndex]} strokeWidth={0} ifOverflow="extendDomain" />
))}
{isShowingReplay && <ReferenceLine yAxisId="left" x={currentTime} stroke="#fff" strokeWidth={2} strokeDasharray="4 2" />}
<XAxis dataKey="time" stroke="#666" fontSize={10} tickFormatter={(v) => `${Math.round(v)}s`} axisLine={{ stroke: '#444' }} tickLine={{ stroke: '#444' }} domain={[0, dataMaxTime]} type="number" allowDataOverflow={false} />
<YAxis yAxisId="left" stroke="#666" fontSize={10} domain={[0, maxLeftAxis]} axisLine={{ stroke: '#444' }} tickLine={{ stroke: '#444' }} width={35} allowDataOverflow={false} />
<YAxis yAxisId="right" orientation="right" stroke="#666" fontSize={10} domain={[0, maxRightAxis]} axisLine={{ stroke: '#444' }} tickLine={{ stroke: '#444' }} width={35} allowDataOverflow={false} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: '10px', paddingTop: '8px' }} iconType="circle" iconSize={8} />
<Line yAxisId="left" type="monotone" dataKey="pressure" stroke={CHART_COLORS.pressure} strokeWidth={2} dot={false} name="Pressure (bar)" isAnimationActive={false} />
<Line yAxisId="left" type="monotone" dataKey="flow" stroke={CHART_COLORS.flow} strokeWidth={2} dot={false} name="Flow (ml/s)" isAnimationActive={false} />
<Line yAxisId="right" type="monotone" dataKey="weight" stroke={CHART_COLORS.weight} strokeWidth={2} dot={false} name="Weight (g)" isAnimationActive={false} />
{hasGravFlow && <Line yAxisId="left" type="monotone" dataKey="gravimetricFlow" stroke={CHART_COLORS.gravimetricFlow} strokeWidth={1.5} dot={false} strokeDasharray="4 2" name="Grav. Flow (g/s)" isAnimationActive={false} />}
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* Stage Legend */}
{(() => {
if (stageRanges.length === 0) return null
return (
<div className="flex flex-wrap gap-1.5 pt-1">
{stageRanges.map((stage, idx) => (
<Badge key={idx} variant="outline" className="text-[10px] px-2 py-0.5 font-medium" style={{ backgroundColor: STAGE_COLORS[stage.colorIndex], borderColor: STAGE_BORDER_COLORS[stage.colorIndex], color: isDark ? STAGE_TEXT_COLORS_DARK[stage.colorIndex] : STAGE_TEXT_COLORS_LIGHT[stage.colorIndex] }}>
{typeof stage.name === 'string' ? stage.name : String(stage.name || '')}
</Badge>
))}
</div>
)
})()}
</>
)
})()}
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Significant code duplication detected: The chart rendering logic for the Replay tab is duplicated between mobile (lines 1476-1545) and desktop (lines 2677-2744) versions. This creates a maintainability issue where bug fixes or feature updates need to be applied in two places. Consider extracting the chart rendering logic into a reusable component that accepts viewport-specific props (height, responsive container settings, etc.).

Copilot uses AI. Check for mistakes.
Comment on lines 2746 to 2798
{/* Compare tab graph */}
{activeAction === 'compare' && comparisonShotData && (() => {
const combinedData = getCombinedChartData()
const dataMaxTime = combinedData.length > 0 ? combinedData[combinedData.length - 1].time : 0
const maxPressure = Math.max(...combinedData.map(d => Math.max(d.pressureA || 0, d.pressureB || 0)), 12)
const maxFlow = Math.max(...combinedData.map(d => Math.max(d.flowA || 0, d.flowB || 0)), 8)
const maxWeight = Math.max(...combinedData.map(d => Math.max(d.weightA || 0, d.weightB || 0)), 50)
const leftDomain = Math.ceil(Math.max(maxPressure, maxFlow) * 1.1)
const rightDomain = Math.ceil(maxWeight * 1.1)
const isShowingReplay = comparisonCurrentTime > 0 && comparisonCurrentTime < dataMaxTime
const displayData = isShowingReplay ? combinedData.filter(d => d.time <= comparisonCurrentTime) : combinedData
return (
<>
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold text-primary flex items-center gap-1.5">
<ChartLine size={16} weight="bold" />
Extraction Comparison
</Label>
{comparisonIsPlaying && (
<Badge variant="secondary" className="animate-pulse text-[10px]">
<Play size={8} weight="fill" className="mr-1" />
{comparisonPlaybackSpeed}x
</Badge>
)}
</div>
<div className="bg-secondary/40 rounded-xl border border-border/20 p-2">
<div className="h-[60vh] min-h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={displayData} margin={{ top: 5, right: 5, left: -5, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" opacity={0.3} />
{isShowingReplay && <ReferenceLine yAxisId="left" x={comparisonCurrentTime} stroke="#fff" strokeWidth={2} strokeDasharray="4 2" />}
<XAxis dataKey="time" stroke="#666" fontSize={10} tickFormatter={(v) => `${Math.round(v)}s`} domain={[0, dataMaxTime]} type="number" allowDataOverflow={false} />
<YAxis yAxisId="left" stroke="#666" fontSize={10} domain={[0, leftDomain]} width={30} allowDataOverflow={false} />
<YAxis yAxisId="right" orientation="right" stroke="#666" fontSize={10} domain={[0, rightDomain]} width={30} allowDataOverflow={false} />
<Tooltip contentStyle={{ backgroundColor: 'rgba(0,0,0,0.9)', border: '1px solid #333', borderRadius: '8px', fontSize: '10px' }} />
<Legend wrapperStyle={{ fontSize: '9px', paddingTop: '4px' }} iconSize={7} />
<Line yAxisId="left" type="monotone" dataKey="pressureA" stroke={COMPARISON_COLORS.pressure} strokeWidth={2} dot={false} name="Pressure A" isAnimationActive={false} />
<Line yAxisId="left" type="monotone" dataKey="flowA" stroke={COMPARISON_COLORS.flow} strokeWidth={2} dot={false} name="Flow A" isAnimationActive={false} />
<Line yAxisId="right" type="monotone" dataKey="weightA" stroke={COMPARISON_COLORS.weight} strokeWidth={2} dot={false} name="Weight A" isAnimationActive={false} />
<Line yAxisId="left" type="monotone" dataKey="pressureB" stroke={COMPARISON_COLORS.pressure} strokeWidth={1.5} strokeDasharray="4 3" dot={false} name="Pressure B" opacity={0.6} isAnimationActive={false} />
<Line yAxisId="left" type="monotone" dataKey="flowB" stroke={COMPARISON_COLORS.flow} strokeWidth={1.5} strokeDasharray="4 3" dot={false} name="Flow B" opacity={0.6} isAnimationActive={false} />
<Line yAxisId="right" type="monotone" dataKey="weightB" stroke={COMPARISON_COLORS.weight} strokeWidth={1.5} strokeDasharray="4 3" dot={false} name="Weight B" opacity={0.6} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="flex items-center justify-center gap-4 text-[10px] text-muted-foreground">
<span className="flex items-center gap-1"><div className="w-4 h-0.5 bg-primary rounded" /> Shot A (solid)</span>
<span className="flex items-center gap-1"><div className="w-4 h-0.5 bg-primary/50 rounded border-dashed" /> Shot B (dashed)</span>
</div>
</>
)
})()}
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Compare tab chart rendering is also duplicated (similar logic appears both for mobile/tablet and desktop views). This compounds the maintainability issue. Consider creating a shared chart component that can be configured for different viewports.

Copilot uses AI. Check for mistakes.
Comment on lines 2809 to 2899
{activeAction === 'analyze' && analysisResult && (() => {
const chartData = getChartData(shotData)
const stageRanges = getStageRanges(chartData)
const hasTargetCurves = analysisResult.profile_target_curves && analysisResult.profile_target_curves.length > 0
const dataMaxTime = chartData.length > 0 ? chartData[chartData.length - 1].time : 0
const maxPressure = Math.max(...chartData.map(d => d.pressure || 0), ...(analysisResult.profile_target_curves?.map(d => d.target_pressure || 0) || []), 10)
const maxFlow = Math.max(...chartData.map(d => d.flow || 0), ...(analysisResult.profile_target_curves?.map(d => d.target_flow || 0) || []), 5)
const maxLeftAxis = Math.ceil(Math.max(maxPressure, maxFlow) * 1.1)
return (
<>
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold text-primary flex items-center gap-1.5">
<ChartLine size={16} weight="bold" />
Shot vs Profile Target
</Label>
{hasTargetCurves && (
<Badge variant="outline" className="text-xs bg-primary/10 border-primary/20">Target overlay</Badge>
)}
</div>
<div className="bg-secondary/40 rounded-xl border border-border/20 p-2">
<div className="h-[60vh] min-h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 5, right: 5, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
{stageRanges.map((stage, idx) => (
<ReferenceArea key={idx} yAxisId="left" x1={stage.startTime} x2={stage.endTime} fill={STAGE_COLORS[stage.colorIndex]} fillOpacity={1} stroke={STAGE_BORDER_COLORS[stage.colorIndex]} strokeWidth={0} ifOverflow="extendDomain" />
))}
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} tickFormatter={(v) => `${Math.round(v)}s`} axisLine={{ stroke: '#444' }} type="number" domain={[0, dataMaxTime]} />
<YAxis yAxisId="left" domain={[0, maxLeftAxis]} tick={{ fontSize: 10, fill: '#888' }} axisLine={{ stroke: '#444' }} width={25} />
<YAxis yAxisId="right" orientation="right" domain={[0, Math.ceil(maxFlow * 1.1)]} hide width={0} />
<Tooltip contentStyle={{ backgroundColor: 'rgba(0,0,0,0.85)', border: '1px solid #333', borderRadius: '8px', fontSize: '11px' }} formatter={(value: number, name: string) => [`${value?.toFixed(1) || '-'}`, name]} labelFormatter={(label) => `${Number(label).toFixed(1)}s`} />
<Line yAxisId="left" type="monotone" dataKey="pressure" stroke={CHART_COLORS.pressure} strokeWidth={2} dot={false} name="Pressure (bar)" isAnimationActive={false} />
<Line yAxisId="left" type="monotone" dataKey="flow" stroke={CHART_COLORS.flow} strokeWidth={2} dot={false} name="Flow (ml/s)" isAnimationActive={false} />
{hasTargetCurves && analysisResult.profile_target_curves && (
<Customized
component={({ xAxisMap, yAxisMap }: { xAxisMap?: Record<string, { scale: (v: number) => number }>; yAxisMap?: Record<string, { scale: (v: number) => number }> }) => {
if (!xAxisMap || !yAxisMap) return null
const xAxis = Object.values(xAxisMap)[0]
const yAxis = yAxisMap['left']
if (!xAxis?.scale || !yAxis?.scale) return null
const curves = analysisResult.profile_target_curves!
const pressurePoints = curves.filter(p => p.target_pressure !== undefined).sort((a, b) => a.time - b.time)
const flowPoints = curves.filter(p => p.target_flow !== undefined).sort((a, b) => a.time - b.time)
let pressurePath = ''
if (pressurePoints.length >= 2) pressurePath = pressurePoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xAxis.scale(p.time)} ${yAxis.scale(p.target_pressure!)}`).join(' ')
let flowPath = ''
if (flowPoints.length >= 2) flowPath = flowPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xAxis.scale(p.time)} ${yAxis.scale(p.target_flow!)}`).join(' ')
return (
<g className="target-curves">
{pressurePath && <>
<path d={pressurePath} fill="none" stroke={CHART_COLORS.targetPressure} strokeWidth={2.5} strokeDasharray="8 4" strokeLinecap="round" />
{pressurePoints.map((p, i) => <circle key={`tp-${i}`} cx={xAxis.scale(p.time)} cy={yAxis.scale(p.target_pressure!)} r={4} fill={CHART_COLORS.targetPressure} />)}
</>}
{flowPath && <>
<path d={flowPath} fill="none" stroke={CHART_COLORS.targetFlow} strokeWidth={2.5} strokeDasharray="8 4" strokeLinecap="round" />
{flowPoints.map((p, i) => <circle key={`tf-${i}`} cx={xAxis.scale(p.time)} cy={yAxis.scale(p.target_flow!)} r={4} fill={CHART_COLORS.targetFlow} />)}
</>}
</g>
)
}}
/>
)}
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1"><div className="w-3 h-0.5 rounded" style={{ backgroundColor: CHART_COLORS.pressure }} /><span>Pressure</span></div>
<div className="flex items-center gap-1"><div className="w-3 h-0.5 rounded" style={{ backgroundColor: CHART_COLORS.flow }} /><span>Flow</span></div>
{hasTargetCurves && <>
<div className="flex items-center gap-1"><div className="w-3 h-0.5 rounded" style={{ backgroundColor: CHART_COLORS.targetPressure, borderStyle: 'dashed' }} /><span>Target Pressure</span></div>
<div className="flex items-center gap-1"><div className="w-3 h-0.5 rounded" style={{ backgroundColor: CHART_COLORS.targetFlow, borderStyle: 'dashed' }} /><span>Target Flow</span></div>
</>}
</div>
{/* Stage Legend */}
{(() => {
if (stageRanges.length === 0) return null
return (
<div className="flex flex-wrap gap-1.5">
{stageRanges.map((stage, idx) => (
<Badge key={idx} variant="outline" className="text-[10px] px-1.5 py-0.5 font-medium" style={{ backgroundColor: STAGE_COLORS[stage.colorIndex], borderColor: STAGE_BORDER_COLORS[stage.colorIndex], color: isDark ? STAGE_TEXT_COLORS_DARK[stage.colorIndex] : STAGE_TEXT_COLORS_LIGHT[stage.colorIndex] }}>
{typeof stage.name === 'string' ? stage.name : String(stage.name || '')}
</Badge>
))}
</div>
)
})()}
</>
)
})()}
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Analyze tab chart is also duplicated for mobile and desktop rendering. This is the third instance of chart duplication in this file. Consolidating these into reusable components would significantly improve maintainability and reduce the risk of inconsistencies.

Copilot uses AI. Check for mistakes.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@hessius
Copy link
Owner Author

hessius commented Feb 14, 2026

@copilot open a new pull request to apply changes based on the comments in this thread

Copy link
Contributor

Copilot AI commented Feb 14, 2026

@hessius I've opened a new pull request, #174, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI and others added 6 commits February 14, 2026 09:51
Co-authored-by: hessius <1499030+hessius@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
- Extract chart constants to shared chartStyles module to avoid duplication
- Fix ReplayChart to use fragment for desktop to preserve parent spacing
- Replace mobile comparison inline chart with CompareChart component
- Remove duplicated chart color/stage constants from ShotHistoryView and ShotCharts

Co-authored-by: hessius <1499030+hessius@users.noreply.github.com>
refactor: consolidate duplicated chart rendering into reusable components
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants