import { useEffect, useMemo, useRef, useState } from 'react';
import Select, { components as SelectComponents } from 'react-select';
import { FONTS } from '../../constants/fonts';
import { getActiveProduct } from '../../constants/products';
import { placeTextCentered } from '../../utils/textGeometry';
import { darkenHexColor, getContrastRatio } from '../../utils/colorUtils';
import '../../styles/TextTab.css';

// Curated palette shown beneath the recent-colors row. Eight items used
// to live here; we trimmed to seven so the row (7 swatches + custom
// picker + eyedropper = 9 items) fits on a single horizontal line
// inside the right rail. The removed entry was `#db2777` (pink-strong) —
// it was the most visually adjacent to `#ec4899` (brand pink) in the
// palette, so dropping it didn't meaningfully reduce the colour gamut
// the user can reach without the custom picker. Anyone who specifically
// wants the deeper magenta can pick it via the + swatch and it'll land
// in their recent-colors row for one-tap reuse afterwards.
const QUICK_COLORS = [
  '#1f1d23', // ink
  '#ffffff', // white (for dark shirts)
  '#ec4899', // brand pink
  '#c4b5e5', // lavender
  '#f5a3b8', // shirt pink
  '#fcd7b6', // peach
  '#22c55e', // green
];

const DEFAULT_DRAFT = {
  text: 'Your text here',
  fontFamily: 'Pacifico',
  fontSize: 48,
  fill: '#1f1d23',
  // Stroke (S8). Default off (strokeWidth 0). When the user enables
  // outline, strokeWidth jumps to 1 (was 2 — see Change 7 in
  // [[Refinements_2026-05-20_Part2]]) and stroke defaults to a
  // darker shade of the fill rather than hard-coded white, so the
  // outline reads as a deliberate companion to the chosen colour.
  // The actual default stroke colour is computed at enable-time
  // from the current fill via darkenHexColor — the literal here is
  // only used when the fill is unknown / blank.
  stroke: '#000000',
  strokeWidth: 0,
  arc: 0,
};

// Default outline width when the user first enables the outline
// toggle. Was 2 originally; dropped to 1 so the default reads as a
// subtle accent rather than a heavy stroke that competes with the
// glyph itself.
const DEFAULT_OUTLINE_WIDTH = 1;
// How much darker the auto-derived default outline colour is than
// the fill. 20% lands deep enough to read as a contour without
// jumping into a different visual band — "this is a shadow of the
// fill colour, not a wholly new colour". See Change 7 in
// [[Refinements_2026-05-20_Part2]] for the reasoning.
const DEFAULT_OUTLINE_DARKEN = 0.2;

// Stroke-width slider bounds. 8px reads as a chunky outline at typical
// fontSize values; beyond that, glyphs collapse into pure stroke shapes
// that don't read as text. Stop the slider at 8 to keep it useful.
const STROKE_WIDTH_MAX = 8;

// Preview surface contrast handling.
//
// The preview always renders against `--brand-pink-wash` (#fdf2f4) —
// matches the brand look and gives black / dark inks excellent
// contrast (~15:1). For fills that fail against the wash though
// (white at 1.1:1, peach at 1.2:1, pale pinks at ~1.7:1), the text
// is effectively invisible.
//
// A previous iteration switched the BACKGROUND to a darker brand
// pink (#db2777) in those cases. It was readable but the saturated
// pink drew too much attention to the preview section — the
// surface stopped being "a quiet swatch confirming the styling"
// and started competing with the surrounding controls for the eye.
//
// Current approach: keep the muted wash for ALL fills, and instead
// add a soft dark `text-shadow` to the preview text when contrast
// is low. The shadow creates a visible silhouette around each
// glyph — light text gets a dark halo, the eye picks out the
// outline of the letters, and the panel's overall colour stays
// quiet. The shadow doesn't try to match what'll render on the
// canvas (it won't); it's a UI affordance to keep the preview
// readable, since the preview's job is "confirm font / size /
// outline are set the way I want" rather than "pixel-match the
// final shirt render".
//
// Threshold of 2.0:1 is unchanged from the previous bg-swap
// approach — same set of curated palette colours trigger the
// fallback treatment:
//   • ink (#1f1d23) on wash:  ~15:1  → no shadow
//   • brand pink (#ec4899):   ~2.9:1 → no shadow (visible enough)
//   • green (#22c55e):        ~2.1:1 → no shadow (just over)
//   • lavender / shirt pink:  ~1.7:1 → shadow on
//   • peach (#fcd7b6):        ~1.2:1 → shadow on
//   • white:                  ~1.1:1 → shadow on
const PREVIEW_BG = '#fdf2f4';
const LOW_PREVIEW_CONTRAST_THRESHOLD = 2.0;
// Two-layer shadow: a tight 2px inner blur at higher opacity gives
// each glyph a defined dark edge (the "outline silhouette" the
// user is meant to see), and a softer 6px outer blur at lower
// opacity feathers it into a halo so the effect doesn't read as a
// hard stroke. Pure black rather than the brand ink colour because
// a slight cool cast under warm fills (peach, light pink) reads as
// more neutral than a brand-coloured shadow would.
const LOW_CONTRAST_TEXT_SHADOW =
  '0 0 2px rgba(0, 0, 0, 0.7), 0 0 6px rgba(0, 0, 0, 0.4)';

/**
 * Text panel.
 *
 * Two modes, same form:
 *
 *   • DRAFT MODE  — no text element selected. Form values come from local
 *     state. The "Add text to canvas" button creates a new element from
 *     those values.
 *
 *   • EDIT MODE   — a text element is selected. Form values are pulled
 *     from `selectedTextElement` and edits are pushed back via
 *     `onUpdateSelected`. The bottom button changes to "+ Add new text"
 *     and creates a fresh draft element so the user can compose a new
 *     piece without losing the one they were just editing.
 *
 * Same visual surface; switching modes is automatic when the user clicks
 * a text element on the canvas (or deselects). This is the bidirectional
 * editing the user asked for: selecting text on canvas populates the
 * form, and form changes update the canvas live.
 */
export function TextTab({
  onAddText,
  selectedTextElement,
  onUpdateSelected,
  onCommit,
  // Recently-used colors — lifted to App so the list survives tab
  // switches and selection changes. `onPickColor` is invoked whenever the
  // user selects ANY color (swatch or custom picker) so the parent can
  // dedupe + cap the list.
  recentColors = [],
  onPickColor,
  // Recently-used fonts. Surfaced at the top of the dropdown so common
  // picks are one tap away. Capped + deduped by App. (Fix for #30.)
  recentFonts = [],
  onPickFont,
  // Forwarded textarea ref (Change 7 follow-up). Sidebar owns this
  // ref because Sidebar is the persistent layer that survives tab
  // switches; the focus-token-watching effect lives up there and
  // calls `.focus()` on this ref when the user clicks the pencil.
  // Optional — when omitted, the textarea is uncontrolled-ref and
  // nothing else touches it. See Sidebar.jsx for the full rationale.
  textareaRef,
  // Full canvas elements list (bug 3 fix). Used to compute a shared
  // style — if every text element on the canvas (or the single text
  // element, when there's only one) has the same font / colour /
  // outline, that style becomes the default for the draft form. Lets
  // the user add a series of consistently-styled text pieces without
  // re-picking the same settings every time. Optional; when omitted
  // the draft falls back to DEFAULT_DRAFT.
  elements = [],
}) {
  // Draft state — user-set OVERRIDES only, not a full DEFAULT_DRAFT
  // snapshot. Each field is undefined until the user explicitly
  // changes it; reads cascade through `draft.X ?? sharedStyle?.X ??
  // DEFAULT_DRAFT.X`. Storing only overrides means a change to the
  // shared canvas style propagates into the draft fields the user
  // HASN'T touched while leaving fields the user has explicitly set
  // alone. (Bug 3 fix.)
  const [draft, setDraft] = useState({});

  // Shared style across all text elements on the canvas. Returns null
  // when there's no text, or when the elements disagree on any of
  // fontFamily / fill / stroke / strokeWidth. fontSize / arc / text
  // are deliberately excluded — those are per-piece properties, not
  // styling. (Bug 3 fix.)
  //
  // Equality uses `??`-coalesced canonical values so an element
  // missing `stroke` (treated as null) matches another missing
  // `stroke`. Without this an old element saved before the outline
  // feature would never compare equal to a fresh one.
  const sharedStyle = useMemo(() => {
    const textEls = elements.filter((e) => e.type === 'text');
    if (textEls.length === 0) return null;
    const canon = (e) => ({
      fontFamily: e.fontFamily ?? DEFAULT_DRAFT.fontFamily,
      fill: e.fill ?? DEFAULT_DRAFT.fill,
      stroke: (e.strokeWidth ?? 0) > 0 ? (e.stroke ?? DEFAULT_DRAFT.stroke) : null,
      strokeWidth: e.strokeWidth ?? 0,
    });
    const first = canon(textEls[0]);
    for (let i = 1; i < textEls.length; i++) {
      const c = canon(textEls[i]);
      if (
        c.fontFamily !== first.fontFamily
        || c.fill !== first.fill
        || c.stroke !== first.stroke
        || c.strokeWidth !== first.strokeWidth
      ) return null;
    }
    // Re-expand the null-stroke case into a usable fallback so the
    // draft accessors below can `?? DEFAULT_DRAFT.stroke` without
    // synthesising white-on-white.
    return {
      fontFamily: first.fontFamily,
      fill: first.fill,
      stroke: first.stroke ?? DEFAULT_DRAFT.stroke,
      strokeWidth: first.strokeWidth,
    };
  }, [elements]);

  const isEditing = !!selectedTextElement;

  // Effective values for the form fields. Three sources cascade per
  // field:
  //
  //   1. Edit mode wins outright — if a text element is selected for
  //      editing, the form mirrors that element's attrs.
  //   2. Draft mode: user override (`draft.X`, undefined until they
  //      explicitly touch field X).
  //   3. Shared canvas style (`sharedStyle?.X`, null when there's no
  //      consensus across the canvas's text elements). Only the
  //      style-y fields cascade here: fontFamily, fill, stroke,
  //      strokeWidth. The content-y fields (text, fontSize, arc)
  //      don't — they're per-piece, not styling.
  //   4. DEFAULT_DRAFT as the final fallback.
  //
  // This makes the form a single source of truth for what the user
  // sees, regardless of mode — and additionally inherits the
  // canvas's shared style when applicable (bug 3 fix).
  const text = isEditing ? (selectedTextElement.text ?? '') : (draft.text ?? DEFAULT_DRAFT.text);
  const fontFamily = isEditing
    ? (selectedTextElement.fontFamily ?? DEFAULT_DRAFT.fontFamily)
    : (draft.fontFamily ?? sharedStyle?.fontFamily ?? DEFAULT_DRAFT.fontFamily);
  const fontSize = isEditing
    ? (selectedTextElement.fontSize ?? DEFAULT_DRAFT.fontSize)
    : (draft.fontSize ?? DEFAULT_DRAFT.fontSize);
  const fill = isEditing
    ? (selectedTextElement.fill ?? DEFAULT_DRAFT.fill)
    : (draft.fill ?? sharedStyle?.fill ?? DEFAULT_DRAFT.fill);
  const arc = isEditing ? (selectedTextElement.arc ?? 0) : (draft.arc ?? DEFAULT_DRAFT.arc);
  // Stroke / strokeWidth (S8). Reading via ?? against DEFAULT_DRAFT means
  // existing text elements without these fields render as un-outlined
  // (strokeWidth 0), which Konva treats as fill-only.
  const stroke = isEditing
    ? (selectedTextElement.stroke ?? DEFAULT_DRAFT.stroke)
    : (draft.stroke ?? sharedStyle?.stroke ?? DEFAULT_DRAFT.stroke);
  const strokeWidth = isEditing
    ? (selectedTextElement.strokeWidth ?? DEFAULT_DRAFT.strokeWidth)
    : (draft.strokeWidth ?? sharedStyle?.strokeWidth ?? DEFAULT_DRAFT.strokeWidth);
  const outlineEnabled = strokeWidth > 0;

  // Font dropdown options + helpers (Change 1).
  //
  // Each row in the dropdown is rendered using the user's CURRENT text
  // in the option's own fontFamily, so the dropdown is a live preview
  // grid rather than a list of font names. The preview text is
  // truncated to 24 chars per row so long captions don't blow out the
  // dropdown width; for an empty text field we fall back to "Aa" so
  // each row still has visible ink.
  //
  // Recent fonts (when present) are hoisted into a labelled group at
  // the top of the option list — same affordance as the previous
  // native <optgroup>, preserved here via react-select's group syntax.
  const previewText = useMemo(() => {
    const raw = (text || '').trim();
    if (!raw) return 'Aa';
    return raw.length > 24 ? raw.slice(0, 24) + '…' : raw;
  }, [text]);

  const fontSelectOptions = useMemo(() => {
    const allOpts = FONTS.map((f) => ({ value: f.family, label: f.name }));
    if (!recentFonts || recentFonts.length === 0) return allOpts;
    const recentSet = new Set(recentFonts);
    const recentOpts = recentFonts
      .filter((family) => FONTS.some((f) => f.family === family))
      .map((family) => {
        const f = FONTS.find((x) => x.family === family);
        return { value: family, label: f?.name ?? family };
      });
    // De-dupe — fonts in the Recent group are also still listed in
    // "All fonts" so the user can find them either way.
    return [
      { label: 'Recent', options: recentOpts },
      { label: 'All fonts', options: allOpts.filter((o) => !recentSet.has(o.value)) },
    ];
  }, [recentFonts]);

  // The currently-selected value object. We synthesize it from
  // fontFamily rather than searching options each render — the
  // user can have a fontFamily that isn't in FONTS (e.g. legacy
  // saved designs) and we still want the dropdown to show its
  // current preview rather than appearing empty.
  const fontSelectValue = useMemo(() => {
    const known = FONTS.find((f) => f.family === fontFamily);
    return { value: fontFamily, label: known?.name ?? fontFamily };
  }, [fontFamily]);

  // Custom row renderer. Receives the option plus react-select's
  // `context` ("menu" while the dropdown is open, "value" for the
  // closed-state display). We render the preview text in the
  // option's font for both contexts so the closed dropdown shows
  // exactly what the open dropdown's selected row showed.
  const renderFontOption = (opt) => (
    <span
      style={{ fontFamily: opt.value, fontSize: 18, lineHeight: 1.1 }}
      aria-label={opt.label}
    >
      {previewText}
    </span>
  );

  // SingleValue replacement: same preview rendering as the menu rows,
  // applied to the closed-state display. react-select's default
  // SingleValue uses `aria-live` and CSS overflow that fight with the
  // font preview's intrinsic width — swapping in a minimal wrapper
  // gives us full control over typography there.
  const FontSingleValue = (props) => (
    <SelectComponents.SingleValue {...props}>
      <span
        style={{ fontFamily: props.data.value, fontSize: 18, lineHeight: 1.1 }}
        aria-label={props.data.label}
      >
        {previewText}
      </span>
    </SelectComponents.SingleValue>
  );

  // Styles — mirror the brand-pink focus ring and rounded corners used
  // by the other inputs in this tab. The defaults from react-select
  // are a generic blue ring; without an override the font dropdown
  // would visually stand out as the only non-pink-themed control.
  const fontSelectStyles = useMemo(() => ({
    control: (base, state) => ({
      ...base,
      minHeight: 48,
      borderRadius: 12,
      borderWidth: '1.5px',
      borderColor: state.isFocused ? '#ec4899' : '#d8d2d8',
      boxShadow: state.isFocused ? '0 0 0 3px rgba(236, 72, 153, 0.18)' : 'none',
      ':hover': { borderColor: '#ec4899' },
    }),
    valueContainer: (base) => ({ ...base, padding: '4px 12px' }),
    indicatorSeparator: () => ({ display: 'none' }),
    option: (base, state) => ({
      ...base,
      padding: '10px 12px',
      backgroundColor: state.isSelected
        ? 'rgba(236, 72, 153, 0.18)'
        : state.isFocused
          ? 'rgba(236, 72, 153, 0.08)'
          : '#fff',
      color: '#1f1d23',
      cursor: 'pointer',
    }),
    groupHeading: (base) => ({
      ...base,
      fontSize: 10,
      fontWeight: 700,
      letterSpacing: '0.5px',
      textTransform: 'uppercase',
      color: '#7a6f7a',
      padding: '6px 12px 4px',
    }),
    menu: (base) => ({ ...base, borderRadius: 12, overflow: 'hidden', zIndex: 20 }),
  }), []);

  // Setters dispatch to the right destination depending on mode. In edit
  // mode we push to the selected element via onUpdateSelected; in draft
  // mode we update local state.
  const setField = (field, value) => {
    if (isEditing) onUpdateSelected?.({ [field]: value });
    else setDraft((d) => ({ ...d, [field]: value }));
  };

  // We commit history at "natural pause" points: blur, mouseup on sliders,
  // discrete control changes (font dropdown, color swatch). Continuous-input
  // controls call setField on every change but batch the history entry to
  // a single commit at the end. The shared updateElement debounce in
  // useDesignEditor also captures any in-flight edits.
  const commit = () => { if (isEditing) onCommit?.(); };

  // Focus is intentionally NOT auto-grabbed when an existing text
  // element is selected (Change 7 follow-up in
  // [[Refinements_2026-05-20_Part2]]). Auto-focus on selection meant
  // the user's next keystroke after clicking a text element on the
  // canvas would immediately rewrite its text content — the exact
  // "enter edit mode without asking" behaviour we'd already removed
  // for the inline overlay.
  //
  // The desktop pencil DOES focus the textarea, but that logic
  // lives in Sidebar (the persistent parent) — see the long comment
  // there. TextTab just exposes its textarea via the forwarded
  // `textareaRef` prop.

  // Color setter — unified path used by both swatches and the custom
  // picker. Curated and recent-swatch clicks use the defaults (commit
  // history + push to recents immediately, since each click IS the
  // commit). The custom picker passes `commitNow: false` AND
  // `addToRecents: false` because its onChange fires continuously
  // during a slider drag inside the native picker; see
  // `scheduleRecentsPush` below for how the recents push is
  // debounced into one entry per session, and the picker's onBlur
  // (or the unmount cleanup) for how history commits land.
  const setColor = (c, { commitNow = true, addToRecents = true } = {}) => {
    setField('fill', c);
    if (commitNow) commit();
    if (addToRecents) onPickColor?.(c);
  };

  // Debounced recents push for the custom-picker drag case.
  //
  // React's onChange for <input type="color"> fires continuously while
  // the user drags inside the native picker (React maps onChange to
  // the DOM `input` event for form inputs, not the `change` event the
  // HTML spec describes for color inputs). Pushing every intermediate
  // value into recents would pollute the row with every shade the
  // user briefly passes through.
  //
  // Each onChange schedules a push 150ms later. A subsequent onChange
  // cancels the previous timer and starts a new one, so:
  //
  //   • A slider drag (60Hz, events every ~16ms) collapses into a
  //     SINGLE push 150ms after the user stops dragging. The final
  //     colour lands in recents once, intermediates are discarded.
  //
  //   • Discrete clicks separated by pauses longer than 150ms each
  //     get their own push, because each click's debounce window
  //     completes before the next click arrives.
  //
  // 150ms is a deliberate compromise: long enough to outlast 60Hz
  // drag flickers (16ms apart), short enough that the user doesn't
  // perceive a lag between picking and seeing the colour appear in
  // the recents row. If discrete picks land less than 150ms apart
  // (rare — requires the picker to stay open across rapid-fire
  // clicks) only the later one survives; that's an acceptable edge
  // case for the simpler implementation.
  //
  // The picker's onBlur deliberately does NOT push to recents — the
  // debounced timer handles that. Pushing from onBlur too would
  // either double-push (if blur fires reliably) or do nothing useful
  // (if blur doesn't fire because the picker's modal-overlay nature
  // keeps the input "focused" in the browser's model).
  const recentsTimerRef = useRef(null);
  const RECENTS_DEBOUNCE_MS = 150;

  // Cleanup on unmount — if the user navigates away while a push is
  // pending, fire-and-forget would call onPickColor after the parent
  // has stopped caring. Clearing the timeout avoids the
  // setState-after-unmount warning that would produce.
  useEffect(() => () => {
    if (recentsTimerRef.current) clearTimeout(recentsTimerRef.current);
  }, []);

  const scheduleRecentsPush = (color) => {
    if (recentsTimerRef.current) clearTimeout(recentsTimerRef.current);
    recentsTimerRef.current = setTimeout(() => {
      onPickColor?.(color);
      recentsTimerRef.current = null;
    }, RECENTS_DEBOUNCE_MS);
  };

  // Font setter — unified path so swapping fonts via the dropdown also
  // populates the recent-fonts list. The fontSize re-derive from
  // App.updateSelectedElement still happens inside that wrapper; we just
  // pass the family.
  const setFont = (family) => {
    setField('fontFamily', family);
    commit();
    onPickFont?.(family);
  };

  // Eyedropper handler — calls the EyeDropper API directly. Clicking
  // the dropper button triggers `dropper.open()` IMMEDIATELY (no
  // intermediate popup or color-picker dialog). Chrome then shows its
  // magnifier overlay following the cursor; the user's next click
  // anywhere on screen samples that pixel, and the resulting hex flows
  // back through the same `setColor` path the swatches use (so it lands
  // in recents like any other colour choice). Escape cancels.
  //
  // API is Chromium-only (Chrome, Edge, Opera, Brave). The button is
  // hidden in browsers without `window.EyeDropper`, so this handler is
  // only reachable where the API will actually work — no need to
  // fall back to a different picker.
  const eyedropperSupported = typeof window !== 'undefined' && typeof window.EyeDropper === 'function';
  const handleEyedropper = async () => {
    if (!eyedropperSupported) return;
    try {
      const dropper = new window.EyeDropper();
      const result = await dropper.open();
      if (result?.sRGBHex) setColor(result.sRGBHex);
    } catch {
      // User cancelled (Escape / clicked outside) — no-op.
    }
  };

  const handleAdd = () => {
    // Source values: in edit mode, "Add new text" creates a fresh
    // default-styled element so the user has a clean starting point. In
    // draft mode, use the form's current values.
    const src = isEditing
      ? { ...DEFAULT_DRAFT }
      : { text, fontFamily, fontSize, fill, stroke, strokeWidth, arc };

    // Center the new text inside the canvas's working area, auto-shrinking
    // its fontSize if it would otherwise overflow. This guarantees newly-
    // added text is always fully visible — no more text appearing
    // half-off-canvas because the default size is too big for the working
    // area.
    const { canvasSize } = getActiveProduct();
    const placement = placeTextCentered(
      { text: src.text, fontFamily: src.fontFamily, fontSize: src.fontSize },
      canvasSize,
    );

    onAddText({
      type: 'text',
      x: placement.x,
      y: placement.y,
      text: src.text,
      fontFamily: src.fontFamily,
      fontSize: placement.fontSize,
      fill: src.fill,
      stroke: src.stroke,
      strokeWidth: src.strokeWidth,
      arc: src.arc,
      rotation: 0,
    });
  };

  // Curve preset magnitude. Using a moderate value so each direction
  // produces a visible curve without going all the way to circle-shape
  // territory. Sign convention matches TextElement.jsx:
  //   arc > 0   → upward curve (rainbow shape)
  //   arc < 0   → downward curve (smile shape)
  // So "Curve Up" sets +CURVE_PRESET, "Curve Down" sets −CURVE_PRESET.
  // The underlying state field is still called `arc` for backward
  // compatibility with saved designs; only the user-facing copy has
  // moved to "Curve".
  const CURVE_PRESET = 50;
  // Selected-button detection. We check direction (sign) rather than
  // exact equality so an element loaded with a legacy non-preset value
  // (e.g. arc = 35 from the prior slider era) still highlights the
  // correct direction button.
  const curveDirection = arc > 0 ? 'up' : arc < 0 ? 'down' : 'straight';

  return (
    <div className="tt">
      {isEditing && (
        <div className="tt__mode-banner" role="status">
          <span className="tt__mode-dot" aria-hidden="true" />
          <span>Editing selected text</span>
        </div>
      )}

      <div className="tt__field">
        <label className="tt__label" htmlFor="tt-text-input">Your message</label>
        <textarea
          id="tt-text-input"
          ref={textareaRef}
          value={text}
          onChange={(e) => setField('text', e.target.value)}
          onBlur={commit}
          rows={2}
          className="tt__textarea"
          placeholder="e.g. Best Pup Ever"
        />
      </div>

      <div className="tt__field">
        <label className="tt__label" htmlFor="tt-font-select">Style</label>
        {/* Custom dropdown (react-select). Every option is rendered with
            its own `fontFamily` so each row in the dropdown is a live
            preview — the user picks the font that LOOKS the way they
            want, instead of trying to recognise typeface names. The
            preview text is the user's actual draft text (or the
            selected text element's text) so they see exactly how their
            content will look. When the input is empty we fall back to
            "Aa" so the dropdown always has something to render.

            Font names are intentionally absent from the visible UI per
            the design goal — only the rendered glyphs communicate the
            choice. They're still passed to `aria-label` on each option
            so screen readers can announce the family name. */}
        <Select
          inputId="tt-font-select"
          classNamePrefix="tt-fontselect"
          isSearchable={false}
          value={fontSelectValue}
          onChange={(opt) => opt && setFont(opt.value)}
          options={fontSelectOptions}
          formatOptionLabel={renderFontOption}
          components={{ SingleValue: FontSingleValue }}
          styles={fontSelectStyles}
          /* Bug 4 — surface label is "Style" rather than "Font". The
           * dropdown still picks a font family under the hood; the
           * user-facing label just reframes that as picking a STYLE,
           * since the live-preview rows are what the user is actually
           * choosing between. */
          aria-label="Style"
        />
      </div>

      <div className="tt__field tt__field--size">
        <div className="tt__label-row">
          <label className="tt__label">Size</label>
          {/* Display always rounds to a whole integer. The underlying
              fontSize value can be fractional after a corner-drag resize
              on the canvas (Konva multiplies the node's fontSize by the
              transformer's continuous scale factor); rounding here keeps
              the displayed number from showing decimals like "47.32px"
              while the slider stays bound to the actual value so it tracks
              the canvas faithfully. */}
          <span className="tt__value">{Math.round(fontSize)}px</span>
        </div>
        <input
          type="range"
          min="12"
          max="120"
          value={fontSize}
          onChange={(e) => setField('fontSize', parseInt(e.target.value, 10))}
          onMouseUp={commit}
          onTouchEnd={commit}
        />
      </div>

      <div className="tt__field">
        {/* Color label with a current-color indicator on the left.
         *
         * Why a separate indicator rather than a coloured ring on
         * the matching palette swatch:
         *
         *   • The user's chosen fill might not be in the curated
         *     palette at all (custom picker, eyedropper, or any
         *     previously-used colour from recents). An is-selected
         *     ring only works when the active colour exists in the
         *     palette below; for everything else there's nothing to
         *     ring, so the user can't tell what colour they have
         *     active from the row of swatches alone.
         *
         *   • With the indicator anchored to the label, the
         *     answer to "what colour is selected?" is always in one
         *     place regardless of how the user picked it. Palette
         *     swatches go back to being pure "click to set" actions
         *     with no selection state — simpler to scan, and they
         *     stop pretending to communicate state they can't always
         *     represent accurately.
         *
         * The swatch carries a thin border so a white fill stays
         * visible against the white sidebar surface (#fff on #fff
         * would be invisible without it). */}
        <div className="tt__label-row">
          <div className="tt__label-with-swatch">
            <span
              className="tt__current-swatch"
              style={{ backgroundColor: fill }}
              aria-hidden="true"
            />
            <label className="tt__label">Color</label>
          </div>
        </div>

        <div className="tt__colors">
          {QUICK_COLORS.map((c) => (
            <button
              key={c}
              type="button"
              className="tt__color"
              style={{ backgroundColor: c }}
              onClick={() => setColor(c)}
              aria-label={`Color ${c}`}
            />
          ))}
          <label className="tt__color tt__color--custom" aria-label="Custom color">
            <input
              type="color"
              value={fill}
              onChange={(e) => {
                // Live-update the fill for an immediate preview, but
                // defer the recents push to the debounce timer below.
                // React's onChange for <input type="color"> fires
                // continuously during slider drag inside the native
                // picker — see `scheduleRecentsPush` above for the
                // full rationale on why we don't push per-event.
                setColor(e.target.value, { commitNow: false, addToRecents: false });
                scheduleRecentsPush(e.target.value);
                // Best-effort picker close. In browsers where blur()
                // on a color input dismisses the popup, this gives
                // the user a "picker closes when I'm done" feel. In
                // browsers where the picker's modal-overlay keeps
                // ignoring blur, the picker stays open until the
                // user clicks outside / hits Escape — the recents
                // debounce timer still fires either way, so the
                // recents row converges to the right state
                // regardless of which path the browser takes.
                e.target.blur();
              }}
              onBlur={commit}
            />
            <span aria-hidden="true">+</span>
          </label>
          {/* Eyedropper — sample a color from anywhere on the page (or
              other monitors). Hidden in browsers without the API since
              the user can't be expected to know it's missing. (Fix for #32.)
              The icon is a stylized rubber-bulb dropper: rounded
              reservoir at top, tapering tube, droplet at the tip. The
              previous shape read as a fountain pen at small sizes;
              this redraw uses the distinctive bulb+drop silhouette
              that most users recognize from photo-editing tools.

              The whole icon is rotated 45° clockwise (via an SVG
              group transform around the viewBox centre) so the
              dropper looks tilted rather than perfectly vertical —
              matches the angled pose used across design tools and
              reads more clearly as "sampling tool" than "pen / pipette".
              Rotation is done in-SVG (not via CSS transform) so the
              parent button can still own its own hover-scale transform
              without compounding. */}
          {eyedropperSupported && (
            <button
              type="button"
              className="tt__color tt__color--eyedropper"
              onClick={handleEyedropper}
              aria-label="Pick color with eyedropper"
              title="Pick a color from anywhere on screen"
            >
              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <g transform="rotate(45 12 12)">
                  {/* Rubber bulb — a rounded rectangle at the top of the
                      dropper, where you'd squeeze in a real glass
                      pipette. Stroked, not filled, so it picks up the
                      button's currentColor on hover/selected. */}
                  <rect x="8" y="2" width="8" height="5" rx="1.5" />
                  {/* Glass tube — narrower vertical section below the
                      bulb. Two parallel strokes (the rect's sides). */}
                  <path d="M10 7 v8" />
                  <path d="M14 7 v8" />
                  {/* Tapered tip narrowing to a point. */}
                  <path d="M10 15 L 12 19 L 14 15" />
                  {/* Droplet about to fall from the tip — the visual
                      cue that this tool samples color. */}
                  <path d="M12 22 a 1.4 1.4 0 1 1 0 -0.01" />
                </g>
              </svg>
            </button>
          )}
        </div>

        {/* Recent colors row — sits BELOW the curated palette now (it
            previously sat above). The reorder follows the user's
            mental model: the curated palette is the primary picker,
            and recents are a smaller follow-up surface of "colours
            you've already used" for one-tap reuse. Putting them
            second matches that hierarchy.
            Only renders when the user has picked at least one color
            in this session. The parent caps the list so we don't
            need to slice here. */}
        {recentColors && recentColors.length > 0 && (
          <div className="tt__recent-row">
            <span className="tt__recent-caption">Recent</span>
            <div className="tt__colors tt__colors--inline">
              {recentColors.map((c) => (
                <button
                  key={c}
                  type="button"
                  className="tt__color tt__color--small"
                  style={{ backgroundColor: c }}
                  onClick={() => setColor(c)}
                  aria-label={`Recent color ${c}`}
                />
              ))}
            </div>
          </div>
        )}
      </div>

      {/* Outline (stroke) controls (S8). The toggle gates the
          color/width inputs and updates strokeWidth between 0 (off) and
          a sensible default (2). When already non-zero, the toggle
          preserves the user's chosen width. */}
      <div className="tt__field">
        <div className="tt__label-row">
          <label className="tt__label">Outline</label>
          <label className="tt__outline-toggle">
            {/* Outline toggle. When enabled, strokeWidth jumps from 0 to
              * DEFAULT_OUTLINE_WIDTH (1) — quieter than the previous default
              * of 2 — and the stroke colour is auto-derived as 20% darker
              * than the current fill via darkenHexColor. The user can
              * override either via the controls below; we only synthesize
              * defaults at the enable transition. */}
            <input
              type="checkbox"
              checked={outlineEnabled}
              onChange={(e) => {
                if (e.target.checked) {
                  // Enable: jump to a visible default if currently off.
                  if (!outlineEnabled) {
                    setField('strokeWidth', DEFAULT_OUTLINE_WIDTH);
                    // Auto-derive a contour colour from the fill so the
                    // outline reads as a companion to the chosen colour
                    // rather than an unrelated stroke. If the fill is
                    // unknown / blank, fall back to the DEFAULT_DRAFT
                    // value (now black instead of white — easier to see
                    // against the white default canvas).
                    const base = fill || DEFAULT_DRAFT.fill;
                    const derived = darkenHexColor(base, DEFAULT_OUTLINE_DARKEN);
                    setField('stroke', derived || DEFAULT_DRAFT.stroke);
                  }
                } else {
                  setField('strokeWidth', 0);
                }
                commit();
              }}
            />
            <span className="tt__outline-toggle-track" aria-hidden="true">
              <span className="tt__outline-toggle-thumb" />
            </span>
          </label>
        </div>

        {outlineEnabled && (
          <>
            <div className="tt__outline-controls">
              <label className="tt__color tt__color--custom tt__outline-swatch" aria-label="Outline color">
                <input
                  type="color"
                  value={stroke || '#ffffff'}
                  onChange={(e) => {
                    setField('stroke', e.target.value);
                    // Close the native picker after the choice. Same
                    // rationale as the main fill input above — see
                    // that comment for the full reasoning. Both color
                    // pickers in this tab share the behaviour for
                    // consistency: pick a colour, picker closes,
                    // result is visible immediately on the preview
                    // and canvas.
                    e.target.blur();
                  }}
                  onBlur={commit}
                />
                <span className="tt__outline-swatch-fill" style={{ backgroundColor: stroke || '#ffffff' }} aria-hidden="true" />
              </label>
              <input
                type="range"
                min={1}
                max={STROKE_WIDTH_MAX}
                step={1}
                value={Math.max(1, Math.min(STROKE_WIDTH_MAX, strokeWidth))}
                onChange={(e) => setField('strokeWidth', parseInt(e.target.value, 10))}
                onMouseUp={commit}
                onTouchEnd={commit}
                className="tt__outline-slider"
                aria-label="Outline width"
              />
              <span className="tt__value tt__outline-width">{strokeWidth}px</span>
            </div>
          </>
        )}
      </div>

      {/* Curve — three-button picker for direction. The user picks
          shape (up / straight / down); intensity is fixed at the
          CURVE_PRESET magnitude defined above. Replaced an earlier
          bidirectional slider; per the research, horizontal sliders
          don't have an innate up/down semantic, so explicit buttons
          with icons + text labels are easier to understand at first
          glance. The selected button highlights based on the sign of
          the underlying arc value, so legacy elements with any
          non-zero arc still show the correct direction.

          The user-facing copy says "Curve" rather than "Arc" — less
          technical, more familiar to non-designer users. The internal
          state field is still `arc` for backward compat with saved
          designs. */}
      <div className="tt__field">
        <label className="tt__label">Curve</label>
        <div className="tt__arc-buttons" role="radiogroup" aria-label="Text curve direction">
          <button
            type="button"
            role="radio"
            aria-checked={curveDirection === 'up'}
            className={`tt__arc-btn${curveDirection === 'up' ? ' is-selected' : ''}`}
            onClick={() => { setField('arc', CURVE_PRESET); commit(); }}
          >
            <svg className="tt__arc-btn-icon" viewBox="0 0 32 18" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden="true">
              {/* Quadratic curve bulging UPWARD — mirrors what a
                  positive arc value looks like on the canvas. */}
              <path d="M3 15 Q 16 1 29 15" />
            </svg>
            <span className="tt__arc-btn-label">Curve Up</span>
          </button>
          <button
            type="button"
            role="radio"
            aria-checked={curveDirection === 'straight'}
            className={`tt__arc-btn${curveDirection === 'straight' ? ' is-selected' : ''}`}
            onClick={() => { setField('arc', 0); commit(); }}
          >
            <svg className="tt__arc-btn-icon" viewBox="0 0 32 18" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden="true">
              <path d="M3 9 L 29 9" />
            </svg>
            <span className="tt__arc-btn-label">Straight</span>
          </button>
          <button
            type="button"
            role="radio"
            aria-checked={curveDirection === 'down'}
            className={`tt__arc-btn${curveDirection === 'down' ? ' is-selected' : ''}`}
            onClick={() => { setField('arc', -CURVE_PRESET); commit(); }}
          >
            <svg className="tt__arc-btn-icon" viewBox="0 0 32 18" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden="true">
              {/* Quadratic curve bulging DOWNWARD — mirrors what a
                  negative arc value looks like on the canvas. */}
              <path d="M3 3 Q 16 17 29 3" />
            </svg>
            <span className="tt__arc-btn-label">Curve Down</span>
          </button>
        </div>
      </div>

      <div
        className="tt__preview"
        aria-label="Preview"
      >
        {/* Preview swatch. Renders the user's draft text in their
         * chosen style so they can see the result before adding it
         * to the canvas (or while editing a selected element). Mirrors
         * the on-canvas rendering as closely as the DOM allows:
         *
         *  • fontFamily + color + size match the canvas.
         *
         *  • Outline is rendered via `-webkit-text-stroke` with
         *    `paint-order: stroke fill` — the CSS equivalent of Konva's
         *    `fillAfterStrokeEnabled=true`. Paint order draws the stroke
         *    first then the fill on top, so the visible outline lives
         *    only on the OUTER half of the stroke (the inner half is
         *    covered by the fill). To make the visible outer half
         *    match the user's configured strokeWidth, we double the
         *    stroke-width passed to CSS — same trick used in
         *    TextElement.jsx for the Konva render.
         *
         *  • The stroke is also scaled by the same ratio the preview
         *    font uses (preview is `min(36, fontSize × 0.6)` while the
         *    canvas renders at `fontSize`), so a 2px outline on a
         *    fontSize=48 element looks like a 1.2px outline at the
         *    preview's 28.8px. Without that scaling, large fontSize
         *    elements would show preview text shrunken but the outline
         *    at full width, reading as a much chunkier outline than
         *    the canvas actually has.
         *
         * `paint-order` for HTML text is supported in modern Chrome,
         * Firefox, and Safari. Older browsers fall back to the default
         * `fill stroke` order — in that case the preview shows the
         * same overlap-outline artefact that the canvas used to have,
         * which is a strictly cosmetic degradation. */}
        <span style={(() => {
          const previewFontSize = Math.min(36, fontSize * 0.6);
          const base = {
            fontFamily,
            color: fill,
            fontSize: `${previewFontSize}px`,
          };

          // Low-contrast guard: if the fill is too close in luminance
          // to the pink wash to be readable on its own, apply a soft
          // dark text-shadow that gives every glyph a visible
          // silhouette. See the PREVIEW_BG / LOW_CONTRAST_TEXT_SHADOW
          // constants at the top of this file for the rationale. The
          // shadow merges with the base style here so both the
          // outline-on and outline-off return branches below pick it
          // up via the `...base` spread.
          const contrast = getContrastRatio(fill, PREVIEW_BG);
          if (!Number.isNaN(contrast) && contrast < LOW_PREVIEW_CONTRAST_THRESHOLD) {
            base.textShadow = LOW_CONTRAST_TEXT_SHADOW;
          }

          if (outlineEnabled && stroke && strokeWidth > 0) {
            // Visible outline width — canvas-accurate is
            // (strokeWidth × ratio), but at small strokeWidths and
            // capped preview font sizes that scales down to sub-pixel
            // values (e.g. strokeWidth=1 at fontSize=48 → 0.6px),
            // which the browser antialiases into a translucent ghost
            // that can read as "no outline rendered". Clamping the
            // visible width to a 1px floor keeps the preview honest
            // about WHETHER an outline exists, at the cost of being
            // slightly less accurate about its proportional thickness
            // when the canvas-faithful width would be < 1px. The
            // alternative — staying faithful but invisible — makes the
            // preview useless as a verification surface.
            const ratio = previewFontSize / fontSize;
            const visibleWidth = Math.max(1, strokeWidth * ratio);
            // Multiply by 2 because paint-order eats the inner half
            // of the stroke (same trick as TextElement.jsx). The CSS
            // stroke we write is 2× the visible outer half.
            return {
              ...base,
              WebkitTextStroke: `${visibleWidth * 2}px ${stroke}`,
              paintOrder: 'stroke fill',
            };
          }
          return base;
        })()}>
          {text || 'Preview'}
        </span>
      </div>

      {/* In draft mode the bottom button creates a new text element from the
          form values. In edit mode it's hidden — the form is wired straight
          to the selected element, so changes apply live and there's no need
          for a submit action. To compose a brand-new piece of text the user
          deselects (click empty canvas) which flips the form back to draft
          mode and the button reappears. */}
      {!isEditing && (
        <button type="button" className="tt__submit" onClick={handleAdd}>
          Add text to canvas
        </button>
      )}
    </div>
  );
}
