Prototype code
The seven before and after pairs live in this file.
"use client";
import { usePrefersReducedMotion } from "@/components/use-reduced-motion";
import { type ReactNode, useEffect, useRef, useState } from "react";
const SHELL = "granola-shell";
function BeforeAfter({
title,
now,
improved,
caption,
}: {
title: string;
now: ReactNode;
improved: ReactNode;
caption?: string;
}) {
return (
<section className="mt-14">
<h3 className="display text-lg text-ink">{title}</h3>
{caption ? (
<p className="mt-2 max-w-[52ch] text-base leading-relaxed text-ink-dim">
{caption}
</p>
) : null}
<div className="mt-5 grid gap-4 md:grid-cols-2">
<figure>
<p className="mb-2 text-base text-ink-dim">Now</p>
{now}
</figure>
<figure>
<p className="mb-2 text-base text-ink-dim">Improved</p>
{improved}
</figure>
</div>
</section>
);
}
function CopyNextToShare() {
const [copied, setCopied] = useState(false);
return (
<div className={SHELL}>
<div className="flex justify-end gap-2">
<button
type="button"
className="granola-btn"
onClick={() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
}}
>
{copied ? "Copied" : "Copy"}
</button>
<button type="button" className="granola-btn">
Share
</button>
</div>
</div>
);
}
function ComingUpFilters() {
const [tab, setTab] = useState<"all" | "today" | "week">("all");
const rows = [
{ id: "1", when: "Today", label: "Weekly sync" },
{ id: "2", when: "Thursday", label: "Design critique" },
{ id: "3", when: "Next week", label: "Office hours" },
];
const visible =
tab === "all"
? rows
: tab === "today"
? rows.filter((r) => r.when === "Today")
: rows.filter((r) => r.when !== "Today");
return (
<div className={SHELL}>
<div className="flex flex-wrap gap-2">
{(
[
["all", "All"],
["today", "Today"],
["week", "This week"],
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
aria-pressed={tab === id}
onClick={() => setTab(id)}
className={tab === id ? "granola-chip is-on" : "granola-chip"}
>
{label}
</button>
))}
</div>
<ul className="mt-4 space-y-2">
{visible.map((r) => (
<li key={r.id} className="rounded-lg bg-white/5 px-3 py-2">
<p className="text-base text-white/50">{r.when}</p>
<p className="text-base">{r.label}</p>
</li>
))}
</ul>
</div>
);
}
function SidebarSlide() {
const [open, setOpen] = useState(true);
const reduce = usePrefersReducedMotion();
return (
<div className={`${SHELL} overflow-hidden`}>
<div className="flex min-h-[16rem]">
<aside
className="granola-sidebar"
style={{
width: open ? "var(--granola-rail)" : "0",
transition: reduce ? "none" : "width 0.22s ease-out",
}}
>
<p className="truncate px-3 pt-3 text-base">Home</p>
<p className="truncate px-3 pt-2 text-base text-white/50">Calendar</p>
<p className="truncate px-3 pt-2 text-base text-white/50">Notes</p>
</aside>
<div className="min-w-0 flex-1 p-3">
<button
type="button"
className="granola-btn max-w-full min-h-14"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
{open ? "Close sidebar" : "Open sidebar"}
</button>
<p className="mt-4 text-base text-white/70">
The page stays put. The list reflows with the sidebar.
</p>
</div>
</div>
</div>
);
}
function TopBarWhenOpen() {
const [open, setOpen] = useState(true);
const [menu, setMenu] = useState(false);
return (
<div className={SHELL}>
<div className="flex items-center gap-2">
{open ? (
<span className="px-3 text-base">Home</span>
) : (
<>
<button type="button" className="granola-btn" onClick={() => setOpen(true)}>
Open sidebar
</button>
<span className="px-3 text-base">Home</span>
</>
)}
<button
type="button"
className="granola-btn ml-auto"
aria-expanded={menu}
aria-haspopup="true"
onClick={() => setMenu((v) => !v)}
>
Menu
</button>
</div>
{menu ? (
<div className="mt-2 rounded-lg border border-white/10 bg-[#1c1c1c] p-2">
{open ? (
<button
type="button"
className="granola-menu-item"
onClick={() => {
setOpen(false);
setMenu(false);
}}
>
Close sidebar
</button>
) : null}
<button type="button" className="granola-menu-item">
Settings
</button>
</div>
) : null}
</div>
);
}
function PointerButtons() {
const [on, setOn] = useState("Copy");
return (
<div className={SHELL}>
<div className="flex flex-wrap gap-2">
{["Copy", "Share", "Star"].map((label) => (
<button
key={label}
type="button"
className={on === label ? "granola-btn is-on" : "granola-btn"}
onClick={() => setOn(label)}
>
{label}
</button>
))}
</div>
</div>
);
}
function SearchBlur() {
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
function closeSearch() {
setOpen(false);
triggerRef.current?.focus();
}
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
return (
<div
className={`${SHELL} relative ${open ? "min-h-[32rem] overflow-visible" : "min-h-[14rem]"}`}
onKeyDown={(event) => {
if (event.key === "Escape") closeSearch();
}}
>
<p className="text-base">Notifications</p>
<p className="mt-2 text-base text-white/45">Meeting notifications</p>
<button
ref={triggerRef}
type="button"
className="granola-btn relative z-10 mt-4 w-fit shrink-0 self-start whitespace-nowrap"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
{open ? "Hide search" : "Open search"}
</button>
{open ? (
<div
className="granola-backdrop"
onPointerDown={(event) => {
if (event.target === event.currentTarget) closeSearch();
}}
onPointerUp={(event) => {
if (event.target === event.currentTarget) closeSearch();
}}
>
<div
className="granola-palette"
onPointerDown={(event) => event.stopPropagation()}
>
<input
ref={inputRef}
className="granola-search"
placeholder="Search"
aria-label="Search"
/>
<button type="button" className="granola-menu-item mt-2">
Preferences
</button>
<button type="button" className="granola-menu-item">
Calendar
</button>
</div>
</div>
) : null}
</div>
);
}
function HighlightNotes() {
const [starred, setStarred] = useState(true);
return (
<div className={SHELL}>
<article className={starred ? "granola-note is-hot" : "granola-note"}>
<div className="flex items-start justify-between gap-3">
<p className="text-base leading-relaxed text-white/55">Redacted line</p>
<button
type="button"
className="granola-btn"
aria-pressed={starred}
onClick={() => setStarred((v) => !v)}
>
{starred ? "Starred" : "Star"}
</button>
</div>
</article>
</div>
);
}
const SHOT_SIZE: Record<string, { width: number; height: number }> = {
"/granola/menu-copy-buried.webp": { width: 690, height: 394 },
"/granola/coming-up.webp": { width: 1406, height: 598 },
"/granola/sidebar-open-buttons.webp": { width: 1406, height: 1654 },
"/granola/notifications-dropdowns.webp": { width: 418, height: 502 },
"/granola/search-palette-no-blur.webp": { width: 1412, height: 1664 },
};
function Shot({ src, alt }: { src: string; alt: string }) {
const size = SHOT_SIZE[src];
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt}
width={size?.width}
height={size?.height}
loading="lazy"
decoding="async"
className="h-auto w-full rounded-xl border border-line"
/>
);
}
export function GranolaCritique() {
return (
<div>
<BeforeAfter
title="Copy sits next to Share"
caption="Copy used to live inside the overflow menu. I want it next to Share, where my hand already is."
now={<Shot src="/granola/menu-copy-buried.webp" alt="Copy buried inside the overflow menu" />}
improved={<CopyNextToShare />}
/>
<BeforeAfter
title="Coming up filters stay on this list"
caption="The current Coming up screen jumps you away when you change a filter. The list should stay and just change."
now={<Shot src="/granola/coming-up.webp" alt="Coming up screen with no in-place filters" />}
improved={<ComingUpFilters />}
/>
<BeforeAfter
title="Sidebar slides, it does not snap"
caption="Closing the sidebar today is a cut. I want a short slide, about 220 milliseconds, and the content should ease with it."
now={
<video
className="h-auto w-full rounded-xl border border-line"
poster="/granola/sidebar-snap-poster.webp"
width={1000}
height={1148}
preload="none"
muted
playsInline
controls
>
<source src="/granola/sidebar-snap.webm" type="video/webm" />
<source src="/granola/sidebar-snap.mp4" type="video/mp4" />
</video>
}
improved={<SidebarSlide />}
/>
<BeforeAfter
title="Only Home stays in the top bar"
caption="When the sidebar is open, the extra top buttons fight it. Keep Home. Put the sidebar toggle in the menu."
now={<Shot src="/granola/sidebar-open-buttons.webp" alt="Top bar still showing sidebar controls while the sidebar is open" />}
improved={<TopBarWhenOpen />}
/>
<BeforeAfter
title="A pointer on every button"
caption="Clickable things need a hand cursor and a hover I can see. The notifications dropdowns made me guess."
now={<Shot src="/granola/notifications-dropdowns.webp" alt="Notification dropdowns without a clear hover" />}
improved={<PointerButtons />}
/>
<BeforeAfter
title="Blur behind the search palette"
caption="The palette sits on live settings copy and I lose the edge. A backdrop is enough."
now={<Shot src="/granola/search-palette-no-blur.webp" alt="Search palette with no backdrop blur" />}
improved={<SearchBlur />}
/>
<BeforeAfter
title="A way to highlight a note"
caption="Some lines matter more than the rest of the meeting. I want a star, and a look that holds."
now={
<div className="rounded-xl border border-line bg-bg-soft px-4 py-8 text-base text-ink-dim">
There is no highlight action in the current note chrome I captured.
</div>
}
improved={<HighlightNotes />}
/>
</div>
);
}