/* Ink Atelier — website (part 2): Start, Arbeiten, Artists.
   Artist texts, handles and tattoo photos are REAL (round 4). Page prose
   is still lorem ipsum, and every studio photo is still a placeholder.
   Every text lives in content.js (see <Txt> in parts.jsx), not here.
   See DESIGN-DIRECTION.md. */

/* Studio photography (everything directly in assets/photos/) is still
   placeholder — Tinett's shoot replaces it. Tattoo work (assets/photos/work/)
   is real. Photography is in colour; only the artist portrait inset is
   black and white. Do not redesign a layout that looks wrong until you have
   seen it with the right image in it. */
/* ?edit (edit/editor.js) swaps photos in place during review meetings: it
   puts replacement URLs in window.IA_EDITS, and this is the one place the
   site asks. Without ?edit, IA_EDITS never exists. */
const PHOTO = (n) => {
  const url = "./assets/photos/" + n;
  return (window.IA_EDITS && window.IA_EDITS[url]) || url;
};

/* Photo spots. Every photo outside the two strips has a named spot in
   content.js (`places`: start.hero, studio.photo, artists.<name>.work, …)
   with an optional crop (`crops`: pos = object-position, zoom ≥ 1). Two
   spots may show the same file and still change independently: ?edit puts
   its changes per spot in window.IA_PLACES (id → { src, pos, zoom }). */
const placeEdit = (id) => (window.IA_PLACES && window.IA_PLACES[id]) || null;
const placeSrc = (id) => { const e = placeEdit(id); return e && typeof e.src === "string" ? e.src : window.CONTENT.places[id]; };
const placeCrop = (id) => placeEdit(id) || (window.CONTENT.crops || {})[id] || {};

/* A crop as style. Zoom scales around the crop point; every frame a photo
   sits in clips it. */
const cropStyle = (c) => {
  const style = { objectPosition: (c && c.pos) || undefined };
  if (c && c.zoom > 1) { style.scale = String(c.zoom); style.transformOrigin = c.pos || "50% 50%"; }
  return style;
};

/* An `optional` spot may be empty ("" in content.js): the site then shows
   nothing there, and ?edit a "+" box to put a photo in. */
const EMPTY_SPOT = "data:image/svg+xml," + encodeURIComponent(
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" fill="#e6e4df"/>' +
  '<path d="M20 14v12M14 20h12" stroke="#9a978f" stroke-width="1.2"/></svg>');

function PlacePhoto(props) {
  const { place, optional, className = "ia-photo", style, alt = "", sizes } = props;
  const rest = omit(props, ["place", "optional", "className", "style", "alt", "sizes"]);
  const src = placeSrc(place);
  const edit = window.IA_EDIT_MODE ? { "data-place": place, "data-optional": optional ? "" : undefined } : null;
  if (!src) {
    return window.IA_EDIT_MODE
      ? <img key="" className={className + " ia-edit-only"} src={EMPTY_SPOT} alt="" style={style} {...edit} {...rest} />
      : null;
  }
  return (
    <img key={src} className={className} src={PHOTO(src)} srcSet={workSrcSet(src)} sizes={sizes} alt={alt}
         style={{ ...style, ...cropStyle(placeCrop(place)) }} {...edit} {...rest} />
  );
}

/* Tattoo work, as each artist delivered it (WhatsApp, 2026-09-07/08). The
   originals were 3–5k px; these are resized to 1600px on the long edge,
   and carry no metadata. Hanna's set had one photo twice at two sizes —
   the larger one is kept.

   Interleaved, so the reel alternates between artists rather than running
   through one portfolio at a time, and every item names its artist — the
   reel credits each photo (see Filmstrip). The order lives in content.js
   (`reels.arbeiten`), where ?edit can add, remove and reorder photos.

   The reel crops everything to 3:4 and six of Ming's seven photos are
   16:9 landscape, so a centred crop cuts the tattoo off in several of
   them: their hand-set crop points are on their entries in content.js.

   Celia's own work is not here yet: it has not been delivered.

   RESPONSIVE: each portrait-format work photo also exists as webp at
   480, 960 and 1440px wide (generated from the originals with cwebp,
   `name-<width>.webp`), served through srcset — a phone showing the reel
   at ~270px no longer downloads a 1600px JPEG. The JPEG stays as `src`.
   Ming's six landscape photos have no derivatives: they are only 1600px
   wide to begin with, and in a portrait crop they render far wider than
   their slot, which width-based srcset selection would get wrong. */
const WORK_WIDTHS = [480, 960, 1440];
const NO_DERIVATIVES = new Set(["ming-02", "ming-03", "ming-04", "ming-05", "ming-06", "ming-07"]);
/* Portraits (assets/photos/portraits/) get the same treatment at smaller
   widths — they mostly show as the 92px inset and the 54px Termin thumb. */
const PORTRAIT_WIDTHS = [240, 480, 960];
function workSrcSet(src) {
  const m = /^(work|portraits)\/(.+)\.jpg$/.exec(src || "");
  if (!m || NO_DERIVATIVES.has(m[2]) || PHOTO(src) !== "./assets/photos/" + src) return undefined;
  const widths = m[1] === "portraits" ? PORTRAIT_WIDTHS : WORK_WIDTHS;
  return widths.map((w) => `${PHOTO(`${m[1]}/${m[2]}-${w}.webp`)} ${w}w`).join(", ");
}
/* The two photo strips, from content.js — or, in ?edit mode, the strips
   as Celia rearranged them (window.IA_REELS, filled by edit/editor.js).
   `arbeiten` items are { src, artist }, `studio` items are file names. */
const reel = (name) => (window.IA_REELS && window.IA_REELS[name]) || window.CONTENT.reels[name];

/* ---- Start ----------------------------------------------------------
   Per Celia: "Als 1. Ansich kann man auch ein ganzes Bild machen" and
   "Die Front Page würde ich weniger mit Informationen haben wollen, also
   das man zu MANCHEN Bereichen nur über den Reiter kommt und nicht das
   man ALLES auf der 1. Page hat".

   Manchen, not allen — and "alles" is what she objected to. An earlier
   pass stripped this page to a poster with three doors, which put the
   opening hours two clicks deep. Adresse and Öffnungszeiten are the most
   looked-up thing on any local business site, so they sit here, quietly,
   below the fold. Studio text, reviews and the FAQ stay off it.

   THE TEXT on this page is Celia's own Google business description,
   used as-is "for now" (Johannes, 2026-09-14), split across the page's
   three text places in its own order: the first sentence is the hero
   headline, the next three are the intro, and the last three (small
   businesses, how to book, "Wir freuen uns auf Dich!") sit above the
   besuch block. Only its "Ink Atelier Potsdam- " prefix is dropped — the
   wordmark is right above it. The texts are start.* in content.js. */

function Start() {
  const tiles = [
    { n: "01", route: "arbeiten", label: "arbeiten", place: "start.tile1" },
    { n: "02", route: "artists", label: "artists", place: "start.tile2" },
    { n: "03", route: "studio", label: "studio", place: "start.tile3" },
  ];
  return (
    <div>
      <section className="ia-hero">
        <PlacePhoto place="start.hero" style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }} />
        {/* Two scrims: a light overall darken so white type holds anywhere,
            plus a stronger bottom gradient under the actual text. */}
        <div style={{ position: "absolute", inset: 0, background: "rgba(20,20,20,0.22)" }} />
        <div className="ia-scrim-top" />
        <div className="ia-scrim" style={{ height: "62%" }} />

        <div style={{ position: "relative", padding: "0 var(--gutter) clamp(3rem, 7vw, 5.5rem)" }}>
          <Reveal delay={200} y={16}>
            <Label tone="onPhoto" style={{ marginBottom: "1.4rem" }}><Txt k="start.tagline" /></Label>
            <h1 style={{
              fontFamily: "var(--font-display)", fontWeight: "var(--w-regular)",
              fontSize: "clamp(1.9rem, 5vw, 3.6rem)", lineHeight: "var(--leading-tight)",
              letterSpacing: "var(--tracking-tight)", color: "#fff",
              maxWidth: "18ch", margin: 0,
            }}>
              <Txt k="start.headline" />
            </h1>
            <div style={{ display: "flex", gap: "1rem", flexWrap: "wrap", marginTop: "clamp(2rem, 4vw, 3rem)" }}>
              <Btn variant="onPhoto" onClick={() => go("termin")}>termin anfragen</Btn>
              <Btn variant="onPhoto" onClick={() => go("arbeiten")}>arbeiten ansehen</Btn>
            </div>
          </Reveal>
        </div>
      </section>

      {/* Per Celia, 2026-08-23: "Kleiner Text bei der 1. Seite damit es
          nicht nur Bilder sind." Short, and the only prose on this page —
          she still wants the front page light. */}
      <Section narrow style={{ paddingBlock: "clamp(3.5rem, 8vw, 7rem)" }}>
        <Reveal>
          <p style={{
            fontFamily: "var(--font-display)", fontWeight: "var(--w-regular)",
            fontSize: "clamp(1.05rem, 2vw, 1.4rem)", lineHeight: "var(--leading-snug)",
            color: "var(--ink)", maxWidth: "44ch", margin: 0,
          }}>
            <Txt k="start.lead" />
          </p>
        </Reveal>
        <Reveal delay={80}>
          <p style={{
            fontSize: "var(--text-base)", lineHeight: "var(--leading-relaxed)",
            color: "var(--ink-soft)", maxWidth: "52ch", margin: "1.6rem 0 0",
          }}>
            <Txt k="start.intro" multi />
          </p>
        </Reveal>
      </Section>

      {/* The Coral teaser row: 01 / 02 / 03 centred above each image, an
          underlined caption centred below — laid out as in the reference
          she attached. */}
      <Section style={{ paddingTop: 0 }}>
        <div className="ia-cards" style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "clamp(1.5rem, 3.5vw, 3rem)" }}>
          {tiles.map((t, i) => (
            <Reveal key={t.n} delay={i * 90}>
              <div className="ia-tile" onClick={() => go(t.route)}>
                <Label style={{ display: "block", marginBottom: "1.2rem" }}>{t.n}</Label>
                <span className="ia-photo-zoom" style={{ display: "block" }}>
                  <PlacePhoto place={t.place} style={{ aspectRatio: "4 / 5" }} sizes="(max-width: 900px) 92vw, 30vw" />
                </span>
                <span className="ia-tile-caption">{t.label}</span>
              </div>
            </Reveal>
          ))}
        </div>
      </Section>

      {/* Per Celia, round 4: "Ich glaube ich brauche noch einen kleinen
          Text beim startbildschirm zwischen Studio Foto und Impressum" — her
          "Studio Foto" is the 03 tile, her "Impressum" the besuch block
          below. The closing part of her description (start.closing). */}
      <Section narrow style={{ paddingTop: 0 }}>
        <Reveal>
          <p style={{
            fontSize: "var(--text-base)", lineHeight: "var(--leading-relaxed)",
            color: "var(--ink-soft)", maxWidth: "52ch", margin: 0,
          }}>
            <Txt k="start.closing" multi />
          </p>
        </Reveal>
      </Section>

      <Besuchsinfo />
    </div>
  );
}

/* The one block of hard information on the front page: where we are and
   when we are open, so nobody has to click twice for it. Deliberately
   plain — no photo, no headline, no second call to action. The full
   version lives on Studio. */

function Besuchsinfo() {
  return (
    <Section tone="quiet" style={{ paddingBlock: "clamp(3rem, 6vw, 5rem)" }}>
      <Reveal><Label style={{ marginBottom: "clamp(1.8rem, 3.5vw, 2.6rem)" }}>besuch</Label></Reveal>
      <ContactRows columns="repeat(3, 1fr)" delay={80} />
      <Reveal delay={140} style={{ marginTop: "clamp(2rem, 4vw, 3rem)" }}>
        <ArrowLink onClick={() => go("studio")}>studio ansehen</ArrowLink>
      </Reveal>
    </Section>
  );
}

/* ---- Filmstrip -------------------------------------------------------
   Per Celia: "Ich kann mir so abwechselnde Bilder als Arbeiten/Portfolio
   gut vorstellen, ist das umsetzbar?" — with the Rue Studio reference,
   "Das man so durch swiped kann".

   Yes, and without a library: a horizontal flex row with CSS scroll-snap.
   Touch swipe is the browser's own. Desktop gets pointer-drag, arrow keys
   and buttons. The centre item scales up; the rest dim.

   Items are file names (studio photos) or { src, artist } (tattoo
   work). When the active item has an artist, the counter row credits it:
   per Johannes, round 4, the reel "should show whom that is from". */

function Filmstrip({ items: rawItems, reel: reelName }) {
  const items = rawItems.map((it) => (typeof it === "string" ? { src: it } : it));
  // ?edit adds, removes and reorders photos: measure again when they change.
  const itemsKey = items.map((it) => it.src).join("|");
  const scroller = React.useRef(null);
  const nodes = React.useRef([]);
  const [active, setActive] = React.useState(0);

  /* Geometry is measured once and cached. Reading offsetLeft/offsetWidth
     for nine nodes on every scroll frame forces a layout flush each time,
     which is what made the strip stutter. Item positions only change on
     resize, so that is the only time we pay for it. */
  const geom = React.useRef({ centers: [], widths: [], viewport: 0 });

  const remeasure = React.useCallback(() => {
    const el = scroller.current;
    if (!el) return;
    const centers = [], widths = [];
    nodes.current.forEach((n, i) => {
      if (!n) return;
      centers[i] = n.offsetLeft + n.offsetWidth / 2;
      widths[i] = n.offsetWidth;
    });
    geom.current = { centers, widths, viewport: el.clientWidth };
  }, []);

  // Nearest item to the centre, from cached values — no layout reads.
  const nearest = React.useCallback((scrollLeft) => {
    const { centers, viewport } = geom.current;
    const mid = scrollLeft + viewport / 2;
    let best = 0, bestDist = Infinity;
    for (let i = 0; i < centers.length; i++) {
      const dist = Math.abs(centers[i] - mid);
      if (dist < bestDist) { bestDist = dist; best = i; }
    }
    return best;
  }, []);

  React.useEffect(() => {
    const el = scroller.current;
    if (!el) return;
    remeasure();
    setActive(nearest(el.scrollLeft));

    let frame = 0;
    const onScroll = () => {
      if (frame) return;
      frame = requestAnimationFrame(() => {
        frame = 0;
        // setActive with an unchanged value is a no-op in React, so the
        // scroll path costs one scrollLeft read and a nine-element loop.
        setActive(nearest(el.scrollLeft));
      });
    };
    const onResize = () => { remeasure(); setActive(nearest(el.scrollLeft)); };

    el.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onResize);
    // Images arriving late change nothing about item geometry (the boxes
    // are sized by CSS), but fonts and late layout can — re-measure once.
    const settle = setTimeout(onResize, 600);
    return () => {
      el.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onResize);
      clearTimeout(settle);
      if (frame) cancelAnimationFrame(frame);
    };
  }, [remeasure, nearest]);
  React.useEffect(() => {
    const el = scroller.current;
    if (el) { remeasure(); setActive(nearest(el.scrollLeft)); }
  }, [itemsKey, remeasure, nearest]);

  /* Programmatic scrolling has to fight scroll-snap. With
     `scroll-snap-type: x mandatory` the snap engine re-snaps to the CURRENT
     snap point while a smooth scroll is still animating, so scrollTo() looks
     like it does nothing at all — the arrows and keyboard silently stop
     working while swiping still feels fine. Turning snapping off for the
     duration of the animation and restoring it on scrollend fixes it. */
  const snapTimer = React.useRef(0);

  const scrollToIndex = React.useCallback((i) => {
    const el = scroller.current;
    const { centers, viewport } = geom.current;
    if (!el || centers[i] === undefined) return;

    const reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    el.style.scrollSnapType = "none";
    el.scrollTo({ left: centers[i] - viewport / 2, behavior: reduced ? "auto" : "smooth" });

    const restore = () => {
      clearTimeout(snapTimer.current);
      el.removeEventListener("scrollend", restore);
      el.style.scrollSnapType = "";
    };
    // scrollend where it exists, a timer everywhere else. The timer also
    // covers the case where the scroll never starts (already in position).
    el.addEventListener("scrollend", restore);
    clearTimeout(snapTimer.current);
    snapTimer.current = setTimeout(restore, 1000);
  }, []);

  React.useEffect(() => () => clearTimeout(snapTimer.current), []);

  const step = (d) => scrollToIndex(Math.min(items.length - 1, Math.max(0, active + d)));

  /* Desktop drag. Touch is left to the browser's native momentum scroll —
     hijacking it here would make the swipe worse, not better. */
  const drag = React.useRef(null);
  // scrollToIndex after a drag needs the freshest index, not the one
  // captured when the handler was created.
  const activeRef = React.useRef(0);
  activeRef.current = active;

  const onPointerDown = (e) => {
    if (e.pointerType === "touch") return;
    const el = scroller.current;
    drag.current = { x: e.clientX, left: el.scrollLeft, moved: false };
    el.dataset.dragging = "true";
    el.setPointerCapture(e.pointerId);
  };
  const onPointerMove = (e) => {
    if (!drag.current) return;
    const dx = e.clientX - drag.current.x;
    if (Math.abs(dx) > 3) drag.current.moved = true;
    scroller.current.scrollLeft = drag.current.left - dx;
  };
  const endDrag = (e) => {
    const el = scroller.current;
    if (!drag.current || !el) return;
    el.dataset.dragging = "false";
    if (el.hasPointerCapture && el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
    // Snap to whatever ended up nearest the centre.
    const wasMoved = drag.current.moved;
    drag.current = null;
    if (wasMoved) requestAnimationFrame(() => scrollToIndex(activeRef.current));
  };

  return (
    <div>
      <div
        ref={scroller}
        className="ia-strip"
        tabIndex={0}
        role="group"
        aria-label="Arbeiten"
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
        onKeyDown={(e) => {
          if (e.key === "ArrowRight") { e.preventDefault(); step(1); }
          if (e.key === "ArrowLeft") { e.preventDefault(); step(-1); }
        }}
      >
        <div className="ia-strip-spacer" aria-hidden="true" />
        {items.map((it, i) => (
          <button
            key={it.src + i}
            ref={(n) => { nodes.current[i] = n; }}
            className="ia-strip-item"
            data-active={i === active}
            aria-current={i === active}
            aria-label={`Bild ${i + 1} von ${items.length}` + (it.artist ? `, von ${it.artist}` : "")}
            {...(window.IA_EDIT_MODE && reelName ? { "data-reel": reelName, "data-i": i } : null)}
            onClick={() => { if (!drag.current) scrollToIndex(i); }}
          >
            {/* Off-screen items stay unloaded until scrolled to, and decode
                happens off the main thread. No width/height attributes: the
                photos come in several ratios but the strip crops them all
                to 3:4, so the attributes would advertise the wrong ratio,
                and the CSS aspect-ratio already reserves the box. */}
            <img
              src={PHOTO(it.src)} alt={it.artist ? `Tattoo von ${it.artist}` : ""}
              srcSet={workSrcSet(it.src)} sizes="(max-width: 640px) 70vw, 360px"
              draggable="false" loading="lazy" decoding="async"
              style={cropStyle(it)}
            />
          </button>
        ))}
        <div className="ia-strip-spacer" aria-hidden="true" />
      </div>

      {/* Same padding-then-container structure as Section, so the counter
          lines up with every other left edge on the site. */}
      <div style={{ padding: "0 var(--gutter) clamp(2rem, 4vw, 3rem)" }}>
        <div style={{
          display: "flex", alignItems: "center", justifyContent: "space-between",
          gap: "1.5rem", maxWidth: "var(--container)", margin: "0 auto",
        }}>
          <span style={{ display: "flex", alignItems: "center", gap: "clamp(0.9rem, 2vw, 1.6rem)", flexWrap: "wrap", minWidth: 0 }}>
            <Label>{String(active + 1).padStart(2, "0")} / {String(items.length).padStart(2, "0")}</Label>
            <StripCredit artist={items[active] && items[active].artist} />
          </span>
          <span style={{ display: "flex", gap: "0.75rem" }}>
            <StripArrow onClick={() => step(-1)} disabled={active === 0} label="zurück">←</StripArrow>
            <StripArrow onClick={() => step(1)} disabled={active === items.length - 1} label="weiter">→</StripArrow>
          </span>
        </div>
      </div>
    </div>
  );
}

/* Whose tattoo the centred photo is: the handwritten name, as everywhere
   else an artist is named on the site, and the handle as a profile link.
   It changes with the active item and does not animate. */
function StripCredit({ artist: name }) {
  const artist = name && ARTISTS.find((a) => a.name === name);
  if (!artist) return null;
  const handle = handleOf(artist);
  return (
    <span className="ia-strip-credit" style={{ display: "inline-flex", alignItems: "baseline", gap: "0.8rem" }}>
      <Hand text={artist.name} style={{ fontSize: "1.55rem", lineHeight: 1, color: "var(--ink)" }} />
      {handle && (
        <a
          className="ia-link ia-link--quiet"
          href={instagramUrl(handle)}
          target="_blank" rel="noopener noreferrer"
          style={{
            fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)",
            letterSpacing: "var(--tracking-wider)", color: "var(--ink-mute)",
          }}
        >@{handle}</a>
      )}
    </span>
  );
}

function StripArrow({ children, onClick, disabled, label }) {
  const [hover, setHover] = React.useState(false);
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      aria-label={label}
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
      style={{
        width: "46px", height: "46px", borderRadius: 0, cursor: disabled ? "default" : "pointer",
        border: "1px solid var(--line-strong)",
        background: hover && !disabled ? "var(--ink)" : "transparent",
        color: hover && !disabled ? "var(--paper)" : "var(--ink)",
        opacity: disabled ? 0.25 : 1,
        display: "inline-grid", placeItems: "center", fontSize: "0.95rem",
        transition: "background var(--dur-base) var(--ease-calm), color var(--dur-base) var(--ease-calm), opacity var(--dur-base) var(--ease-calm)",
      }}
    >{children}</button>
  );
}

function Arbeiten() {
  return (
    <div>
      <Section first>
        <Head
          label="arbeiten"
          title={<Txt k="arbeiten.title" />}
          lead={<Txt k="arbeiten.lead" />}
        />
      </Section>

      {/* Full-bleed: the strip needs the whole viewport width to read as a
          strip, so it sits outside the Section container. */}
      <div style={{ borderBlock: "1px solid var(--line)" }}>
        <Filmstrip items={reel("arbeiten")} reel="arbeiten" />
      </div>

      <Section>
        <div className="ia-split" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "clamp(2rem, 5vw, 5rem)", alignItems: "start" }}>
          <Head label="stile" title={<Txt k="arbeiten.stile.title" />} max="18ch" />
          <Reveal delay={100}>
            <p style={{ fontSize: "var(--text-base)", lineHeight: "var(--leading-relaxed)", color: "var(--ink-soft)", maxWidth: "48ch", margin: 0 }}>
              <Txt k="arbeiten.stile.text" multi />
            </p>
            <div style={{ marginTop: "2.5rem" }}>
              <ArrowLink onClick={() => go("termin")}>termin anfragen</ArrowLink>
            </div>
          </Reveal>
        </div>
      </Section>
    </div>
  );
}

/* ---- Artists ---------------------------------------------------------
   Per Celia, on the inklabs.de screenshot: "fand ich sehr passend mit dem
   Stil der Person und oben dann das Bild des tätowierers und mit der
   handschriftlichen Schrift wo der Name drauf steht".

   So: the work photo is the card, the artist's portrait is a small inset
   at the top right, and the name is handwritten over the image. The one
   change from the reference is that the name sits directly on the photo
   instead of in a white pill — the pill was the playful part. */

/* Booking is not a platform — an enquiry goes straight to the artist.

   Two separate things, deliberately:
     · the handle   the public profile (artists.<name>.instagram in
                    content.js, editable in ?edit). Shown wherever it is set.
     · `contact`    where an ENQUIRY goes. Normally the handle's DMs; an
                    artist can instead take enquiries by mail with
                      contact: { kind: "email", address: "…" }
   An artist with no handle yet (Alessandra, until she sends hers) takes
   enquiries through the studio mail, and switches to DMs by herself the
   moment a handle is entered. Only the helpers below have to know.

   Round 4: the real team, as Celia listed it — residents Celia, Hanna,
   Amelie; guest artists Alessandra and Ming. Bios are VERBATIM as
   delivered (Johannes: leave the texts as they are) — including Hanna and
   Ming writing in the first person, Alessandra's text being in English,
   and one slip in Amelie's ("zurück nach in die"). The bios are in
   content.js as artists.<name>.bio: a blank line between paragraphs, a
   single line break where the artist wrote one.

   Portraits are real since 2026-09-23 (Johannes's crops, 4:5 — the
   inset's own format; Celia's is the first of her two). Each artist's
   work photo and portrait are spots in content.js: artists.<name>.work
   and artists.<name>.portrait.

   STILL PLACEHOLDER:
     · Celia's `work` and `bio` — not delivered yet;
     · Alessandra's handle — not delivered, so she has no profile link and
       her enquiries go to the studio mail until it arrives.
   Roles and handles are text in content.js (artists.<name>.role/.instagram). */
const ARTISTS = [
  {
    name: "celia",
    group: "resident",
  },
  {
    name: "hanna",
    group: "resident",
  },
  {
    name: "amelie",
    group: "resident",
  },
  {
    name: "alessandra",
    group: "guest",
    bioLang: "en",
  },
  {
    name: "ming",
    group: "guest",
  },
];

/* The studio's own channels. The Instagram handle is confirmed (its bio:
   "Tattoo& Creative Studio Neue Plantage 6, Potsdam lead by
   @tattoos.by.celia"). No TikTok — the studio has none as far as Johannes
   knows (2026-09-14); the footer link to a guessed handle is gone. */
const STUDIO = { instagram: "inkatelier.potsdam" };

/* Real, from Celia (round 4). No phone: "Beim Impressum kann Telefon
   raus". `maps` is her Google Maps business entry — the plain place link
   (by CID), not the share link, so it carries no share token. One copy of
   all of this, used on the front page, on Studio and by the reviews. */
const CONTACT = {
  street: "Neue Plantage 6",
  city: "14467 Potsdam",
  email: "inkatelier.potsdam@gmx.de",
  maps: "https://www.google.com/maps?cid=16957735778049536947",
};

/* Anfahrt / Öffnungszeiten / Email. The email opens a new message and the
   address opens her Maps entry — tap rather than select and copy. The
   opening hours are text (contact.* in content.js), so Celia can edit them. */
function ContactRows({ columns = "1fr 1fr", style, delay = 130 }) {
  const valueStyle = {
    fontSize: "var(--text-base)", color: "var(--ink)",
    lineHeight: "var(--leading-normal)", overflowWrap: "anywhere",
  };
  const small = {
    fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)",
    letterSpacing: "var(--tracking-wider)", color: "var(--ink-mute)",
  };
  const rows = [
    ["anfahrt", (
      <span>
        {CONTACT.street}<br />{CONTACT.city}
        {/* Which room — the building has three floors (Celia, 2026-10-05).
            Text, so she words it herself; empty hides it. */}
        {(T("contact.room").trim() || window.IA_EDIT_MODE) && (
          <Txt k="contact.room" style={{ display: "block" }} placeholder="z. b. raum 106" />
        )}
        <span style={{ display: "block", marginTop: "0.6rem" }}>
          <a className="ia-link ia-link--quiet" href={CONTACT.maps}
             target="_blank" rel="noopener noreferrer" style={small}>
            in google maps öffnen ↗
          </a>
        </span>
      </span>
    )],
    ["öffnungszeiten", (
      <span>
        <Txt k="contact.hours" />
        <Txt k="contact.hoursNote" style={{ ...small, display: "block", marginTop: "0.6rem" }} />
      </span>
    )],
    ["email", (
      <a className="ia-link ia-link--quiet" href={"mailto:" + CONTACT.email} style={valueStyle}>
        {CONTACT.email}
      </a>
    )],
  ];
  return (
    <Reveal delay={delay} className="ia-details" style={{
      display: "grid", gridTemplateColumns: columns, gap: "2rem 2.5rem", ...style,
    }}>
      {rows.map(([label, value]) => (
        <div key={label}>
          <Label style={{ marginBottom: "0.7rem" }}>{label}</Label>
          <div style={valueStyle}>{value}</div>
        </div>
      ))}
    </Reveal>
  );
}

const instagramUrl = (handle) => "https://instagram.com/" + handle;

/* An artist's Instagram handle, from content.js — "" when there is none. */
const handleOf = (artist) => T(`artists.${artist.name}.instagram`).trim().replace(/^@/, "");

/* Where an enquiry goes: an explicit contact, else the handle's DMs, else
   the studio mail. */
function contactHref(artist) {
  const c = artist.contact || {};
  if (c.kind === "email") return "mailto:" + c.address;
  const handle = handleOf(artist);
  return handle ? instagramUrl(handle) : "mailto:" + CONTACT.email;
}
const enquiresByMail = (artist) => /^mailto:/.test(contactHref(artist));

/* What the visitor sees they are writing to — always shown next to the
   action, so nobody sends an enquiry without knowing where it lands. */
function contactLabel(artist) {
  const href = contactHref(artist);
  return href.startsWith("mailto:") ? href.slice(7) : "@" + handleOf(artist);
}

function ArtistCard({ artist, delay }) {
  const [swapped, setSwapped] = React.useState(false);
  const swap = () => setSwapped((v) => !v);

  // Which spot is in which slot. The greyscale follows the PORTRAIT, not
  // the slot — tattoo work stays in colour wherever it sits.
  const work = `artists.${artist.name}.work`, portrait = `artists.${artist.name}.portrait`;
  const bigPlace = swapped ? portrait : work;
  const miniPlace = swapped ? work : portrait;

  return (
    <Reveal delay={delay}>
      <article>
        {/* .ia-polaroid is the hover target: the frame, the photo, the inset
            portrait and the name all breathe together as one object. */}
        <div className="ia-polaroid" style={{ position: "relative" }}>
          <button className="ia-artist-big" onClick={swap} aria-label={`Bilder von ${artist.name} tauschen`}>
            {/* .ia-clip keeps a zoomed photo inside the polaroid's frame. */}
            <span className="ia-clip">
              <PlacePhoto place={bigPlace} className={swapped ? "ia-photo ia-bw" : "ia-photo"} sizes="(max-width: 900px) 92vw, 45vw" />
            </span>
          </button>
          <button className="ia-artist-mini" onClick={swap} aria-label={`Bilder von ${artist.name} tauschen`}>
            <PlacePhoto place={miniPlace} className={swapped ? "ia-photo" : "ia-photo ia-bw"} alt={artist.name} sizes="92px" />
          </button>
          {/* Written on the polaroid's foot, in ink — which is where a
              polaroid is actually written on. No scrim needed now that the
              name is off the photograph. */}
          <Hand text={artist.name} style={{
            position: "absolute", left: "26px", bottom: "12px",
            fontSize: "clamp(1.9rem, 3.4vw, 2.5rem)", color: "var(--ink)", pointerEvents: "none",
          }} />
        </div>

        {/* This card is where an enquiry actually starts — the moment
            someone is looking at the work and deciding. The Termin page
            explains the process; it does not repeat this list. */}
        <div style={{
          display: "flex", justifyContent: "space-between", alignItems: "baseline",
          gap: "1rem", flexWrap: "wrap", marginTop: "1.2rem",
          paddingTop: "1.2rem", borderTop: "1px solid var(--line)",
        }}>
          <Label tone="ink"><Txt k={`artists.${artist.name}.role`} /></Label>
          <ArrowLink href={contactHref(artist)}>termin anfragen</ArrowLink>
        </div>
        {/* The artist's own text, as delivered. pre-line keeps the line
            breaks they wrote inside a paragraph. */}
        <Txt k={`artists.${artist.name}.bio`} as="div" paras lang={artist.bioLang}
             style={{ maxWidth: "50ch", marginTop: "1rem" }}
             pStyle={{ fontSize: "var(--text-sm)", lineHeight: "var(--leading-relaxed)", color: "var(--ink-soft)" }} />
        {/* The handle links to the profile — a different destination from
            "termin anfragen" whenever the enquiry channel is email. */}
        {(handleOf(artist) || window.IA_EDIT_MODE) && (
          <div style={{ marginTop: "1.1rem" }}>
            <a
              className="ia-link ia-link--quiet"
              href={handleOf(artist) ? instagramUrl(handleOf(artist)) : undefined}
              target="_blank" rel="noopener noreferrer"
              style={{
                fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)",
                letterSpacing: "var(--tracking-wider)", color: "var(--ink-mute)",
              }}
            >@<Txt k={`artists.${artist.name}.instagram`} kind="handle" placeholder="instagram-name eintragen" /></a>
          </div>
        )}
      </article>
    </Reveal>
  );
}

function Artists() {
  return (
    <div>
      <Section first>
        <Head
          label="artists"
          title={<Txt k="artists.title" />}
          lead={<Txt k="artists.lead" />}
        />
        {/* The team together, as an overview before the cards (Celia,
            2026-10-08: "ein Gruppenbild … so als Übersicht"). */}
        <Reveal delay={120} y={28} className="ia-photo-zoom" style={{ marginTop: "clamp(3rem, 6vw, 5rem)" }}>
          <PlacePhoto place="artists.group" alt="Das Team des Ink Atelier" style={{ aspectRatio: "16 / 9" }}
                      sizes="(max-width: 1280px) 92vw, 1200px" />
        </Reveal>
        {/* One grid, in Celia's order: "Residents: Celia, Hanna, Amelie /
            Guest Artists: Alessandra, Ming". Separate grids per group left
            an empty cell after the third resident; now the guests flow
            straight on, and each card's role label carries the split. */}
        <div className="ia-cards ia-artist-grid" style={{
          display: "grid", gridTemplateColumns: "1fr 1fr",
          gap: "clamp(2.5rem, 4.5vw, 4rem)", alignItems: "start",
          marginTop: "clamp(3rem, 6vw, 5rem)",
        }}>
          {[...ARTISTS.filter((a) => a.group === "resident"), ...ARTISTS.filter((a) => a.group === "guest")].map((a, i) => (
            <ArtistCard key={a.name} artist={a} delay={(i % 2) * 100} />
          ))}
        </div>
      </Section>

    </div>
  );
}

Object.assign(window, { PHOTO, placeSrc, placeCrop, cropStyle, PlacePhoto, reel, workSrcSet, ARTISTS, STUDIO, CONTACT, ContactRows, instagramUrl, handleOf, contactHref, enquiresByMail, contactLabel, Start, Besuchsinfo, Filmstrip, StripCredit, StripArrow, Arbeiten, ArtistCard, Artists });
