Files
backnotprop__plannotator/packages/guide-viewer/GuideSectionCard.tsx
Michael Ramos 94f8d45daa guide-viewer: readable on phones and tablets, desktop untouched (#1329)
* fix(guide-viewer): readable on phones and tablets, desktop untouched

Every change is behind a breakpoint; 1440px and 1024px renders of the same
guide are byte-identical before and after (screenshot MD5s match).

- Split diffs below lg (1024px) are forced unified in the portable viewer's
  diff renderer (matchMedia; the setting is untouched, so a wider window
  gets split back). A phone has ~350px of pane and a portrait tablet ~430px,
  so two columns were under 220px each.
- Padding scales: page px-3/sm:px-6/lg:px-10, chapter column px-4/md:px-6,
  diff column px-1.5/md:px-4. Code pane on a 390px phone: 276px → 352px.
- Tablets: the chapter column is proportional (minmax(260px,36%)) from md
  and the fixed 440px only from lg. Pane at 768px: 214px → 426px.
- Header actions (Download, theme) sit in a right-aligned row above the
  title below md instead of floating into it.

Viewer rebuilt and published (viewer.dWt7KCum.js), manifest synced.

* fix(guide-viewer): touch targets, labels, and no tap delay on coarse pointers

Only under `pointer: coarse` (Tailwind's `pointer-coarse:` variant), so mouse
layouts are unchanged:
- Reviewed checkbox and the collapse chevron get an invisible ::before hit
  area (visual 15–17px, hit ≥ 44px); the "Reviewed" text button and file
  chips get taller padding; the theme toggle and hosted Download button grow
  to a 44px hit box.
- `touch-action: manipulation` on controls in the portable viewer and the
  landing page (no double-tap-to-zoom delay; the page still pinch-zooms).
- `aria-label` on the two icon-only buttons (theme toggle, collapse chevron).
- Landing page: the GitHub link and the Copy button are 44px tall on touch.

Tailwind v4 already gates `hover:` behind `@media (hover: hover)`, so no
false hover states on tap. Viewer rebuilt and published, manifest synced.

* guide-viewer: manifest for the combined build (labels + mobile), viewer.sFtOnb1i.js published
2026-08-16 13:30:58 -07:00

243 lines
9.7 KiB
TypeScript

import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import type { GuideSection } from '@plannotator/core/guide';
import type { DiffFile } from './types';
import { renderMarkdownProse } from './renderMarkdownProse';
import { useGuideHost } from './host';
import { GuideFileCard } from './GuideFileCard';
function Checkbox({ checked }: { checked: boolean }) {
return (
<span
className={`flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded-[4px] border transition-colors ${
checked ? 'border-primary bg-primary' : 'border-border bg-transparent'
}`}
>
{checked && (
<svg width="9" height="9" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6.25L4.75 8.5L9.5 3.5" stroke="white" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
);
}
function FileChip({
filePath,
summary,
file,
active,
onClick,
}: {
filePath: string;
summary?: string;
file: DiffFile | undefined;
active: boolean;
onClick: () => void;
}) {
const slash = filePath.lastIndexOf('/');
const name = slash >= 0 ? filePath.slice(slash + 1) : filePath;
const dir = slash >= 0 ? filePath.slice(0, slash) : '';
return (
<button
type="button"
onClick={onClick}
className={`flex w-full items-center gap-2 rounded-md border px-2.5 py-1.5 text-left transition-colors pointer-coarse:py-2.5 ${
active ? 'border-primary/40 bg-primary/10' : 'border-border/50 bg-background hover:border-border'
}`}
title={summary ? `${filePath}\n\n${summary}` : filePath}
>
<span className="truncate font-mono text-[11px] font-medium text-foreground">{name}</span>
{dir && <span className="min-w-0 flex-1 truncate font-mono text-[10px] text-muted-foreground/60">{dir}</span>}
{!file ? (
<span className="flex-shrink-0 font-mono text-[9px] uppercase tracking-wider text-muted-foreground/50">outdated</span>
) : (
<span className="ml-auto flex-shrink-0 font-mono text-[10px]">
{file.additions > 0 && <span className="text-emerald-600 dark:text-emerald-400">+{file.additions}</span>}
{file.deletions > 0 && <span className="ml-1 text-red-600/80 dark:text-red-400/80">-{file.deletions}</span>}
</span>
)}
</button>
);
}
interface GuideSectionCardProps {
section: GuideSection;
files: DiffFile[];
index: number;
total: number;
reviewed?: boolean;
showReviewed?: boolean;
onToggleReviewed?: () => void;
focusedFile: string | null;
revealTarget: { filePath: string; token: number } | null;
onActivate: (filePath: string) => void;
onRequestReveal: (filePath: string) => void;
}
/**
* One original Guided Review chapter. Every file keeps its natural description
* and bounded card in document flow; GuideFileCard decides whether that shell's
* one-file Pierre CodeView is inside the shared outer mount window.
*/
export const GuideSectionCard: React.FC<GuideSectionCardProps> = ({
section,
files,
index,
total,
reviewed = false,
showReviewed = true,
onToggleReviewed,
focusedFile,
revealTarget,
onActivate,
onRequestReveal,
}) => {
const [collapsedOverride, setCollapsedOverride] = useState<boolean | null>(null);
const host = useGuideHost();
const cardRef = useRef<HTMLDivElement>(null);
const position = `${String(index + 1).padStart(2, '0')} / ${String(total).padStart(2, '0')}`;
const isCollapsed = showReviewed ? collapsedOverride ?? reviewed : collapsedOverride === true;
const filesByPath = useMemo(() => new Map(host.files.map((file) => [file.path, file])), [host.files]);
const summaryByPath = useMemo(() => {
const summaries = new Map<string, string>();
for (const ref of section.diffs) {
if (ref.summary) summaries.set(ref.file, ref.summary);
}
return summaries;
}, [section.diffs]);
const targetBelongsHere = revealTarget && section.diffs.some((ref) => ref.file === revealTarget.filePath)
? revealTarget
: null;
// Reopen a reviewed chapter before its target file shell force-mounts and
// scrolls itself into view. Reviewed persistence remains unchanged because
// this is only the local visual override.
useEffect(() => {
if (!targetBelongsHere) return;
setCollapsedOverride(false);
onActivate(targetBelongsHere.filePath);
// Token identifies the reveal event; callback identity must not replay it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [targetBelongsHere?.token]);
const handleToggleReviewed = () => {
setCollapsedOverride(null);
onToggleReviewed?.();
};
if (isCollapsed) {
return (
<div ref={cardRef} className="flex w-full items-center gap-3 rounded-lg border border-border/50 bg-muted/10 px-4 py-3">
{showReviewed && (
<button
type="button"
onClick={handleToggleReviewed}
aria-label={reviewed ? 'Un-mark as reviewed' : 'Mark as reviewed'}
className="flex-shrink-0 rounded relative pointer-coarse:before:absolute pointer-coarse:before:-inset-3.5 pointer-coarse:before:content-['']"
>
<Checkbox checked={reviewed} />
</button>
)}
<button
type="button"
onClick={() => setCollapsedOverride(false)}
className="group flex min-w-0 flex-1 items-center gap-3 text-left"
title="Expand"
>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground/70">{section.title}</span>
<span className="flex-shrink-0 text-[11px] text-muted-foreground/60">
{section.diffs.length} diff{section.diffs.length !== 1 ? 's' : ''}
{reviewed ? ' · reviewed' : ''}
</span>
<span className="flex-shrink-0 font-mono text-[10px] text-muted-foreground/40">{position}</span>
<ChevronDown className="flex-shrink-0 -rotate-90 text-muted-foreground/40 transition-transform group-hover:text-muted-foreground" size={13} />
</button>
</div>
);
}
return (
<div ref={cardRef} className="scroll-mt-4 overflow-clip rounded-lg border border-border/50 bg-card">
{/* Stacked below md; a proportional chapter column on tablets; the fixed 440px column from lg up (desktop unchanged). */}
<div className="md:grid md:grid-cols-[minmax(260px,36%)_minmax(0,1fr)] lg:grid-cols-[440px_minmax(0,1fr)]">
<div className="border-b border-border/40 md:border-b-0 md:border-r">
<div className="px-4 py-4 md:sticky md:top-0 md:flex md:max-h-[calc(100dvh-48px)] md:flex-col md:overflow-y-auto md:overflow-x-hidden md:px-6 md:py-5">
<div className="flex items-start gap-2 md:flex-none">
<h3 className="flex-1 text-[15px] font-semibold leading-snug text-foreground [text-wrap:balance]">
{section.title}
</h3>
<button
type="button"
onClick={() => setCollapsedOverride(true)}
className="mt-0.5 flex-shrink-0 rounded p-0.5 text-muted-foreground/40 transition-colors hover:text-foreground relative pointer-coarse:before:absolute pointer-coarse:before:-inset-3.5 pointer-coarse:before:content-['']"
title="Collapse section"
aria-label="Collapse section"
>
<ChevronDown className="rotate-180" size={13} />
</button>
</div>
<div className="mt-2 flex items-center gap-3 md:flex-none">
<span className="font-mono text-[11px] text-muted-foreground/60">{position}</span>
{showReviewed && (
<button
type="button"
onClick={handleToggleReviewed}
className="flex items-center gap-1.5 text-[11.5px] text-muted-foreground transition-colors hover:text-foreground pointer-coarse:-my-3 pointer-coarse:py-3"
title={reviewed ? 'Un-mark as reviewed' : 'Mark as reviewed'}
>
<Checkbox checked={reviewed} />
Reviewed
</button>
)}
</div>
{section.overview && (
<div className="mt-3.5 space-y-2.5 md:flex-none">{renderMarkdownProse(section.overview, { tone: 'muted' })}</div>
)}
{section.diffs.length > 0 && (
<div className="mt-5 space-y-1.5 md:min-h-[84px] md:overflow-y-auto md:overflow-x-hidden">
{section.diffs.map((ref) => (
<FileChip
key={ref.file}
filePath={ref.file}
summary={ref.summary}
file={filesByPath.get(ref.file)}
active={focusedFile === ref.file}
onClick={() => onRequestReveal(ref.file)}
/>
))}
</div>
)}
</div>
</div>
<div className="min-w-0 space-y-4 bg-muted/[0.07] px-1.5 py-3 md:px-4 md:py-4">
{files.length > 0 ? (
files.map((file) => (
<GuideFileCard
key={file.path}
file={file}
summary={summaryByPath.get(file.path)}
focused={focusedFile === file.path}
revealTarget={targetBelongsHere}
onActivate={onActivate}
/>
))
) : (
<div className="flex min-h-[150px] items-center justify-center px-6 text-center">
<div>
<p className="text-xs font-medium text-foreground">No chapter files in the current diff</p>
<p className="mt-1 text-[11px] text-muted-foreground">The generated references may be out of date.</p>
</div>
</div>
)}
</div>
</div>
</div>
);
};