Granola

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>
  );
}