/* creative-exploration.jsx — "Creative Story" (A Creative Life, Vol. II).
 *
 * The volume's LANDING: the side projects, laid out as a timeline you read by
 * scrolling. It had a page of its own at /creative-exploration for a while,
 * under a bar carrying it and a greyed-out Photography; with the volume down to
 * one page that bar was two buttons where one was the page you were on and the
 * other went nowhere, so the timeline moved up to /acreativelife and the bar
 * came off. The old path 301s here (vercel.json). The file keeps its name — it
 * is the renderer, not the route. Uses the SHARED masthead (<MastheadNav>) and footer
 * (<Footer>), and Vol. I's own type — the page-header pattern, the eyebrow,
 * the body serif — so the two volumes read as one publication printed on two
 * different papers.
 *
 * THE PAPER is only this section's. The pink from the accent strip, laid over
 * the site's tan at part opacity so it reads as a wash rather than a block, and
 * stopping at the colophon: the footer keeps A Working Life's tan, because it
 * is the same footer. The ink specks and blooms over it are the same <Splatter>
 * and <Bloom> primitives A Working Life prints its pages with, so the two
 * volumes are the same press running different paper. Trim marks sit inside
 * three of the wash's corners: this section IS a printed page, and that is the
 * quietest way to say so.
 *
 * THE SPINE. One rule down the left, starting under the masthead and running to
 * the footer, set on the page's content edge — the same pixel the VASTOLA
 * wordmark starts on directly above it, so the line reads as dropping out of
 * the letter rather than as a second margin. Each project is a stop on it: a
 * marker, and the year beside the marker. A second rule scales over the first as
 * you scroll, the markers fill as they pass, and the one card the read line is
 * currently inside lifts a little off its pink offset and settles back as you
 * leave it — a sheet being picked up and put down, not a slide coming in.
 * Nothing moves sideways, nothing parallaxes.
 *
 * EACH PROJECT is one card of a FIXED height: pictures left, writing right,
 * lifted off the wash by a hard 4px offset in the strip's full-strength pink.
 * All four are the same size, which is the point — a column of cards that each
 * sized to their own copy read as four different components. The writing scrolls
 * inside its half when there is more of it than the card holds, so a project
 * with three paragraphs and one with two make the same object.
 *
 * The plate fills the left half and carries one picture at a time; three dots
 * across the bottom of it say how many there are and which one you are on. The
 * dots are the whole set, always, in the order the files are named — the row
 * never rearranges, so nothing moves under the cursor between clicks.
 *
 * The projects reveal on entry through the site's shared `data-reveal` observer
 * (art.jsx), which honours prefers-reduced-motion by never starting. The scroll
 * handler below honours it too: under that setting the spine renders full and
 * still.
 */
/* global React, ReactDOM, MastheadNav, Footer, AboutZineModal, AboutZineTab, TrimMarks, RegistrationMark, Splatter, Bloom, CREATIVE_SECTIONS */

const SECTIONS = (typeof CREATIVE_SECTIONS !== "undefined" ? CREATIVE_SECTIONS : []);

/* How many pictures a project carries. A project with no files yet prints this
   many empty squares, so the layout is the same before and after the pictures
   arrive. */
const SHOT_COUNT = 3;

/* Each `body` is an array of paragraphs — add or cut one and the card scrolls
   further or less far; the card itself does not change size. `images` takes up
   to four { src, alt } objects out of /assets/creative/; leave it empty and the
   project prints empty plates rather than broken pictures.

   Oldest first: the line reads downward from 1994, which is what makes it a
   timeline rather than a feed.

   `subtitle` is optional — a project with one prints it under the title in the
   deck's voice; a project without one prints nothing. So is `pos` on a picture:
   the plate crops to a near-square, and a photograph whose subject sits away
   from the middle can say where the crop should hold (any CSS
   object-position). Without one the crop is centred.

   All four are written. */
const PROJECTS = [
  {
    id: "whitmans",
    year: "1994",
    title: "Whitman’s",
    medium: "Mixed media relief",
    /* Terms in this project's prose that are links. Kept out of `body` so the
       writing stays plain strings a copy edit can rewrite without touching
       markup; the renderer splits each paragraph on `text` (see withLinks). */
    links: [
      { text: "Vermont Arts Exchange", href: "https://vtartxchange.org/about-us/" },
    ],
    body: [
      "Growing up in Vermont, encouraged by my parents, I took an afterschool class at the Vermont Arts Exchange, one built around repurposing what businesses and households threw away. At the student art show, the store I featured bought my piece, my first and only sale.",
      "I kept the photo and the note tucked away in my files for years. They finally have a home here, a reminder of an early creative chapter and the people and moments that encouraged it.",
    ],
    images: [
      { src: "/assets/creative/w1.webp",
        alt: "The Whitman’s relief pinned to a board above a handwritten note from the Vermont Arts Exchange at the Sage Street Mill: “Annie, congratulations! We sold ‘Whitmans’ for $75.00, at 60% going to the artist and 40% going to VAE.”" },
      { src: "/assets/creative/w2.webp",
        alt: "The rest of the note: “Hope your happy, healthy and making art or at least being creative — (am sure you are). Bye — Matthew.”" },
      { src: "/assets/creative/w3.webp",
        alt: "The finished relief: the barn-red feed store under a green roof with its Blue Seal sign, a chicken in the yard, two cars parked out front and cotton clouds glued to a painted sky." },
    ],
  },
  {
    id: "studio-ruse",
    year: "2008",
    title: "Studio Ruse",
    medium: "Collage notebooks",
    body: [
      "Amy and I met early in our careers at a branding firm with a comping room that never stopped overflowing with paper samples and scraps. It was hard to watch all that beautiful paper be tossed out. So we started a side project to give it a second life. Amy taught me to hand-bind books, and we collaged the covers. We sold at craft fairs for a while, but what kept us going was the process itself: spending time together making something beautiful with our hands. Almost twenty years later, we still meet up regularly to make “art” in one form or another.",
    ],
    images: [
      { src: "/assets/creative/nb0.webp",
        alt: "Hand-bound notebooks on a table, their covers collaged from paper samples — a poppy set into a block of type on one, a bird over blue sky lettered “Hello Good Morning” on another, a third lying open." },
      { src: "/assets/creative/nb1.webp",
        alt: "A notebook stood open on a garden table, its cover cut from a pink “Support Locally Raised” carton, a teal patterned spread and a blank leaf inside." },
      { src: "/assets/creative/nb2.webp",
        alt: "Two of the notebooks stood on a table outdoors, one showing the pink carton cover, the other a spread with a pig cut from the same packaging." },
    ],
  },
  {
    id: "v-in-the-d",
    year: "2022",
    title: "@V-in-the-D",
    medium: "Photography, Instagram",
    /* The handle in the copy is the link; the one in the title is the name. */
    links: [
      { text: "V-in-the-D", href: "https://www.instagram.com/v_in_the_d/" },
    ],
    /* A mark in the corner of the plate, for a project that lives somewhere
       else as well as here. Optional: a project without one prints nothing. */
    mark: { href: "https://www.instagram.com/v_in_the_d/", label: "@v_in_the_d on Instagram" },
    body: [
      "I believe a city is best explored through a camera’s lens. I created V-in-the-D to reignite my love for photography, share what I find, and see Detroit in a way I would otherwise miss. Detroit is a city that is once again shapeshifting, and it has been wild to watch the transformation underway.",
    ],
    images: [
      { src: "/assets/creative/vind1.webp",
        alt: "A grid of photographs from @V-in-the-D: Detroit storefronts, murals, food, interiors and street scenes." },
      { src: "/assets/creative/vind2.webp",
        alt: "A second grid from the same feed — more of the city, from signage and shopfronts to the river and the skyline." },
    ],
  },
  {
    id: "snack-clips",
    year: "2025",
    title: "Snack Clips",
    medium: "Collage",
    body: [
      "I make these snack clips by hand from cut paper, mostly old issues of Bon Appétit and the other magazines I still love subscribing to. Cutting the paper and gluing them down is slow, quiet, meditative work. The result is a playful kitchen tool that’s useful and pretty.",
    ],
    images: [
      { src: "/assets/creative/sc1.webp",
        alt: "Four collaged clothespins stood in a row along a wooden edge, their faces cut from photographs of a passionflower, a grapefruit, a pink bloom and a sunflower." },
      { src: "/assets/creative/sc2.webp",
        alt: "A handful of collaged clothespins scattered on a wooden table." },
      { src: "/assets/creative/sc3.webp",
        /* Portrait, and the clip is at the very top: a centred crop takes the
           head off it. 20% keeps the whole clip with a little air above. */
        pos: "center 20%",
        alt: "A collaged clothespin doing its job, holding a bag of Better Made potato chips shut." },
    ],
  },
  {
    id: "birdie-and-pip",
    year: "2026",
    title: "“I’m Bored”",
    subtitle: "Creative adventures of Birdie and Pip",
    medium: "Children’s Book, Hand Illustrated",
    body: [
      "Creativity is a skill, and skills need practice. Screens are eating up downtime in a way that has made boredom unbearable for kids. We need to hold onto imagination and teach kids how.",
      "In “I’m Bored,” Birdie learns to appreciate quiet time and, instead of reaching for her screen, picks up a paintbrush. That one act opens up a whole new world of creativity that she and her sister Pip explore together.",
    ],
    images: [],
  },
];

/* The read line: how far down the viewport a stop has to travel before the
   timeline counts it as reached. Just above centre, so a marker fills at about
   the moment its project is the thing you are actually looking at. */
const READ_LINE = 0.42;

/* Splits a paragraph on each of a project's linked terms and returns the mix of
   strings and anchors React should render. The prose stays plain text in the
   data and the links stay a list of terms, so neither has to know how the other
   is written. Every occurrence of a term is linked; a term that does not appear
   in a paragraph costs one split that finds nothing. */
function withLinks(text, links) {
  if (!links || !links.length) return text;
  let parts = [text];
  links.forEach((link, li) => {
    const next = [];
    parts.forEach((part) => {
      /* Already an anchor from an earlier term — leave it alone. */
      if (typeof part !== "string") { next.push(part); return; }
      part.split(link.text).forEach((bit, i) => {
        if (i > 0) {
          next.push(
            <a className="cx__link" key={li + "-" + next.length}
               href={link.href} target="_blank" rel="noopener noreferrer">
              {link.text}
            </a>
          );
        }
        if (bit) next.push(bit);
      });
    });
    parts = next;
  });
  return parts;
}

/* The Instagram glyph the footer and About me already use, at the size the
   plate wants it. */
const IG_GLYPH = (
  <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
       strokeWidth="1.9" aria-hidden="true" focusable="false">
    <rect x="2" y="2" width="20" height="20" rx="5.5" />
    <circle cx="12" cy="12" r="4.2" />
    <circle cx="17.5" cy="6.5" r="1.1" fill="currentColor" stroke="none" />
  </svg>
);

/* The writing, and whether there is more of it than the card holds.

   The card is a fixed frame, so some projects scroll and some do not — but the
   fade at the foot of the card was painted on all of them, which told a reader
   there was more under a card that had already said everything. It is drawn now
   only when the box actually overflows, measured after layout and again
   whenever the box changes size: the same copy fits at one window width and
   runs over at another, so this cannot be decided once. */
function Prose({ paragraphs, links }) {
  const ref = React.useRef(null);
  const [more, setMore] = React.useState(false);

  React.useEffect(() => {
    const el = ref.current;
    if (!el) return undefined;
    /* A pixel of slack: sub-pixel line heights leave scrollHeight a hair over
       clientHeight on boxes that visibly do not scroll. */
    const check = () => setMore(el.scrollHeight - el.clientHeight > 1);
    check();
    if (typeof ResizeObserver === "undefined") {
      window.addEventListener("resize", check);
      return () => window.removeEventListener("resize", check);
    }
    const ro = new ResizeObserver(check);
    ro.observe(el);
    return () => ro.disconnect();
  }, [paragraphs]);

  return (
    <div
      className="cx__prose"
      ref={ref}
      data-more={more ? "true" : "false"}
      /* Only focusable when it is a scroll region. A box with nothing to scroll
         is a tab stop that does nothing. */
      tabIndex={more ? 0 : -1}
      role={more ? "region" : undefined}>
      {paragraphs.map((para, n) => (
        <p className="cx__para" key={n}>{withLinks(para, links)}</p>
      ))}
    </div>
  );
}

/* A medium that names two things sets them on two lines, breaking after the
   comma: "Children's Book, Hand Illustrated" is a book and a technique, and run
   together against the right edge it reads as one long label rather than two
   facts. A medium with no comma is one thing and stays on one line. */
function mediumLines(medium) {
  const i = medium.indexOf(",");
  if (i < 0) return medium;
  return [medium.slice(0, i + 1), <br key="brk" />, medium.slice(i + 1).trim()];
}

/* An empty plate — a printer's registration mark and, on the large one, the
   medium. It occupies exactly the frame the picture will, so nothing on the
   page moves when the file lands. */
function EmptyPlate({ label }) {
  return (
    <>
      {typeof RegistrationMark !== "undefined" && (
        <RegistrationMark size={label ? 30 : 14} color="var(--ink)" style={{ opacity: 0.4 }} />
      )}
      {label && <span className="cx-plate__wait">{label}</span>}
    </>
  );
}

/* The pictures. `active` is an index into the project's set; the dots render the
   WHOLE set in file order and mark the one in the plate. They were thumbnails
   at first — three small copies of the pictures — which meant the corner of
   every plate carried a second, competing set of images. Dots say the same two
   things (how many, which one) and say nothing else, which is what a page this
   quiet wants.

   With no files yet the set is three inert dots — nothing pretends to be
   clickable when there is nothing to click to. */
function Gallery({ project }) {
  const shots = project.images || [];
  const live = shots.length > 0;
  const slots = live ? shots : new Array(SHOT_COUNT).fill(null);
  const [active, setActive] = React.useState(0);

  const current = slots[active] || null;

  /* SWIPE. The dots are a small target on a phone and they sit in one corner
     of a picture that fills the column — so the picture itself takes the
     gesture too. Same reading as the career story's chapter stage
     (onStageTouchStart there): a swipe has to be 55px and clearly more
     horizontal than vertical, or scrolling the page through a tall plate would
     keep changing the picture under the reader's thumb.

     Wraps at both ends, the way the dots let you jump anywhere: a set of three
     is short enough that running off the end and stopping feels broken.
     Inert with one picture or none — there is nowhere to go. Touch handlers
     only, so nothing about the mouse or the dots changes. */
  const swipeRef = React.useRef(null);
  function onPlateTouchStart(e) {
    const t = e.changedTouches[0];
    swipeRef.current = { x: t.clientX, y: t.clientY };
  }
  function onPlateTouchEnd(e) {
    const start = swipeRef.current;
    swipeRef.current = null;
    if (!start || slots.length < 2) return;
    const t = e.changedTouches[0];
    const dx = t.clientX - start.x;
    const dy = t.clientY - start.y;
    if (Math.abs(dx) < 55 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
    const step = dx < 0 ? 1 : -1;
    setActive((i) => (i + step + slots.length) % slots.length);
  }

  /* Keyed on the FILE, not on its position. Choosing a picture reshuffles which
     slots the strip is showing, and a key that is the position would have React
     keep the same <img> node and swap its src underneath — a node that is
     already mid-load, or that a browser has parked because it was marked lazy,
     does not always repaint when you do that, and the square goes blank. Keyed
     on the src, a picture that moves keeps its own node and one that arrives
     gets a new one; no src is ever mutated in place.

     Nothing in the strip is lazy either. They are three 54px squares already on
     screen beside a picture that has loaded — there is nothing to defer, and
     deferring is the other half of what makes a swapped-in square blank. */
  return (
    <div className="cx__media">
      <figure
        className="cx-plate"
        onTouchStart={live ? onPlateTouchStart : undefined}
        onTouchEnd={live ? onPlateTouchEnd : undefined}>
        {current
          ? <img key={current.src} src={current.src} alt={current.alt || project.title}
                 style={current.pos ? { objectPosition: current.pos } : undefined} />
          : <EmptyPlate label={project.medium} />}
      </figure>

      {/* Bottom left, opposite the dots, so the two never meet. It is a link
          off the site, so it says so rather than relying on the glyph. */}
      {project.mark && (
        <a className="cx__badge" href={project.mark.href}
           target="_blank" rel="noopener noreferrer" aria-label={project.mark.label}>
          {IG_GLYPH}
        </a>
      )}

      <div className="cx__dots" role="group" aria-label={project.title + " — pictures"}>
        {slots.map((shot, index) =>
          shot ? (
            <button
              type="button"
              className="cx__dot"
              key={shot.src}
              data-current={index === active ? "true" : "false"}
              aria-pressed={index === active}
              onClick={() => setActive(index)}
              aria-label={"Picture " + (index + 1) + " of " + slots.length}
            />
          ) : (
            <span className="cx__dot cx__dot--empty" key={"empty-" + index} aria-hidden="true" />
          )
        )}
      </div>
    </div>
  );
}

/* The timeline. The spine and its fill are absolutely positioned against the
   .page wrapper and pulled out past its padding, so the line spans the whole
   section; the markers are positioned against each project and land on the same
   x, which is what keeps them on the line at any width. */
function Timeline({ projects }) {
  const spineRef = React.useRef(null);
  const itemRefs = React.useRef([]);
  const [progress, setProgress] = React.useState(0);
  const [reached, setReached] = React.useState(0);
  const [focus, setFocus] = React.useState(-1);

  React.useEffect(() => {
    const reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    /* Under reduced motion the line is simply drawn, whole and still, and no
       card is ever singled out. */
    if (reduce) { setProgress(1); setReached(projects.length); return undefined; }

    let frame = 0;
    function measure() {
      frame = 0;
      /* Measured off the rule itself, not off the column of projects: the rule
         runs past both ends of that column (up to the masthead, down to the
         colophon), and progress has to be a fraction of the line the reader can
         actually see filling. */
      const spine = spineRef.current;
      if (!spine) return;
      const box = spine.getBoundingClientRect();
      const line = window.innerHeight * READ_LINE;
      const span = box.height || 1;
      const p = (line - box.top) / span;
      setProgress(p < 0 ? 0 : p > 1 ? 1 : p);

      /* How many stops the read line has passed. Counting rather than flagging
         each one keeps a project lit once it has been reached, which is what
         makes the column read as a progress track and not as a spotlight
         following the cursor back up the page. */
      let n = 0;
      /* And which one the line is inside right now. Nothing is focused in the
         gaps between projects, so a card settles back on its way out rather
         than handing the lift straight to the next one. */
      let f = -1;
      for (let i = 0; i < itemRefs.current.length; i++) {
        const el = itemRefs.current[i];
        if (!el) continue;
        const box = el.getBoundingClientRect();
        if (box.top <= line) n = i + 1;
        if (box.top <= line && box.bottom >= line) f = i;
      }
      setReached(n);
      setFocus(f);
    }
    function onScroll() { if (!frame) frame = window.requestAnimationFrame(measure); }

    measure();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      if (frame) window.cancelAnimationFrame(frame);
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
    };
  }, [projects.length]);

  return (
    <div className="page cx__page">
      {/* The rule, and the rule that fills. aria-hidden: it is the shape of the
          list, and the list itself is already an <ol>. */}
      <span className="cx__spine" aria-hidden="true" ref={spineRef}>
        <span className="cx__spine-fill" style={{ transform: "scaleY(" + progress + ")" }} />
      </span>

      <header className="svc__hero cx__hero" data-reveal>
        <div className="eyebrow"><span>A Creative Life &middot; Vol. II</span></div>
        <h1 className="svc__header">Creative <em>Story</em></h1>
        <p className="svc__deck">
          Side projects, classes, and other forms of creative expression. Here is
          a small snapshot of my creative story.
        </p>
      </header>

      <ol className="cx__list">
        {projects.map((p, i) => (
          <li
            className="cx__entry"
            key={p.id}
            id={p.id}
            ref={(el) => { itemRefs.current[i] = el; }}
            data-live={i < reached ? "true" : "false"}
            data-focus={i === focus ? "true" : "false"}
            data-reveal>
            {/* The stop: the marker on the line, and the year beside it. */}
            <span className="cx__mark" aria-hidden="true" />
            <time className="cx__year" dateTime={p.year}>{p.year}</time>

            <article className="cx__card">
              <Gallery project={p} />

              <div className="cx__body">
                {/* The medium, named and set to the right; the title under it,
                    ranged left as the card's heading. The title carries no
                    label of its own — a heading that has to announce itself as
                    a heading is not one. */}
                <div className="cx__head">
                  {/* One line: the title ranged left, the medium ranged right,
                      sitting on the same baseline. The medium leads in the DOM
                      so a screen reader hears what the thing is before its
                      name, and grid placement puts the title first on the page
                      — the order that reads and the order that looks right are
                      not the same order here. */}
                  <div className="cx__namerow">
                    <p className="cx__medium">
                      <span className="cx__field-key">Medium</span> {mediumLines(p.medium)}
                    </p>
                    <h2 className="cx__title">{p.title}</h2>
                  </div>
                  {p.subtitle && <p className="cx__subtitle">{p.subtitle}</p>}
                </div>

                {/* The one part of the card that is allowed to be a different
                    length. It scrolls; the card does not grow. */}
                <Prose paragraphs={p.body} links={p.links} />
              </div>
            </article>
          </li>
        ))}
      </ol>
    </div>
  );
}

function CreativeExploration() {
  const year = new Date().getFullYear();
  const edition = (
    <>
      <strong>VOL. II</strong>
      <span className="masthead-nav__edition-rest">
        &nbsp;&nbsp;&middot;&nbsp;&nbsp;{year} EDITION&nbsp;&nbsp;&middot;&nbsp;&nbsp;ISS. 01
      </span>
    </>
  );

  return (
    <>
      {/* No sections: the volume is one page (see CREATIVE_SECTIONS). The
          masthead still carries the wordmark, the volume line and the way
          across to Vol. I — only the section bar is gone. */}
      <MastheadNav current="creative" edition={edition} sections={SECTIONS} />

      <div className="page-host">
        {/* Trim marks on the host, not on the wash. On <main> the bottom pair
            landed where the wash stops — above the colophon, inside the pink —
            which reads as a box drawn round the projects rather than as the
            sheet's own trim. Three corners, the same shared <TrimMarks /> A
            Working Life uses, so both volumes are cut identically. The top
            left stays out: it is where the rule comes down from the masthead
            here, and where the copy starts over there. */}
        {typeof TrimMarks !== "undefined" && <TrimMarks corners={["tr", "br", "bl"]} />}
        <main className="cx" data-screen-label="01 Creative Story">
          {/* The page's own ink. Same primitives A Working Life uses (art.jsx),
              seeded differently so the marks are this page's and not a copy of
              another one's. Held back in opacity — they sit under four cards
              and a rule, and this page is meant to stay calm. */}
          {typeof Bloom !== "undefined" && (
            <>
              <Bloom color="var(--rose)" size={520} top={-140} left={"66%"} seed={12} opacity={0.42} />
              <Bloom color="var(--bloom)" size={420} top={"58%"} left={-160} seed={4} opacity={0.2} />
            </>
          )}
          {typeof Splatter !== "undefined" && (
            <>
              <Splatter color="var(--ink)" size={190} seed={31}
                        style={{ top: "13%", right: "5%", opacity: 0.4 }} />
              <Splatter color="var(--ink)" size={150} seed={7}
                        style={{ top: "47%", left: "2%", opacity: 0.3 }} />
              <Splatter color="var(--ink)" size={210} seed={23}
                        style={{ bottom: "9%", right: "11%", opacity: 0.34 }} />
            </>
          )}

          <Timeline projects={PROJECTS} />
        </main>

        {typeof Footer !== "undefined" && <Footer />}
      </div>

      {typeof AboutZineTab !== "undefined" && <AboutZineTab tint="azure" />}
      {typeof AboutZineModal !== "undefined" && <AboutZineModal />}
    </>
  );
}

const cxRoot = ReactDOM.createRoot(document.getElementById("root"));
cxRoot.render(<CreativeExploration />);
