{
  "id": "seam-transitions",
  "kind": "element",
  "title": "Seam transitions between sections",
  "summary": "The edge between a hero and the page below it comes apart as you scroll: tiles crumble off, threads fray loose, the hero cracks into plates, or ink bleeds down. Four seams on one canvas, switched by buttons.",
  "url": "https://enrichr.dev/elements/seam-transitions.html",
  "section": "page",
  "techniques": [
    "scroll-driven",
    "reveal"
  ],
  "stack": [
    "Vanilla JS",
    "CSS",
    "Canvas 2D"
  ],
  "frameworks": [
    "React",
    "Next.js",
    "Vanilla"
  ],
  "mood": [
    "cinematic",
    "bold",
    "polished"
  ],
  "intents": [
    "section transition on scroll",
    "torn edge between hero and page",
    "crumbling tiles scroll effect",
    "ink bleed section divider",
    "cracked hero edge animation",
    "frayed edge scroll transition"
  ],
  "status": "tested",
  "params": [
    {
      "key": "seam",
      "label": "Seam",
      "type": "select",
      "options": [
        [
          "crumble",
          "Crumble"
        ],
        [
          "fray",
          "Fray"
        ],
        [
          "craquelure",
          "Craquelure"
        ],
        [
          "ink",
          "Ink"
        ]
      ],
      "value": "crumble"
    },
    {
      "key": "accent",
      "label": "Accent colour",
      "type": "color",
      "value": "#e6ff55"
    },
    {
      "key": "size",
      "label": "Piece size (px)",
      "type": "range",
      "min": 14,
      "max": 60,
      "step": 2,
      "value": 28
    },
    {
      "key": "reach",
      "label": "Seam height (% of hero)",
      "type": "range",
      "min": 16,
      "max": 42,
      "step": 2,
      "value": 30
    },
    {
      "key": "title",
      "label": "Hero title",
      "type": "text",
      "value": "Where sections meet"
    }
  ],
  "license": "Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.",
  "contract": "function mount(root, opts) -> { update(partialOpts), destroy() }; root must contain the element markup",
  "exports": {
    "claude": "Add the \"Seam transitions between sections\" element to this project.\n\nThis is the \"Seam transitions between sections\" element from the Enrichr Library: The edge between a hero and the page below it comes apart as you scroll: tiles crumble off, threads fray loose, the hero cracks into plates, or ink bleeds down. Four seams on one canvas, switched by buttons.\nPage: https://enrichr.dev/elements/seam-transitions.html\nMachine-readable spec: https://enrichr.dev/elements/seam-transitions.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nSteps:\n1. Look at the project first to see its framework and where components, styles and static files live. Follow its conventions.\n2. Fetch https://enrichr.dev/elements/seam-transitions.json if you can read URLs (fields: source.markup, source.css, source.js, params); otherwise use the source at the end of this message.\n3. Add the files, mount it where I tell you (default: the top of the home page), and pass the settings below as the opts.\n4. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n5. Run the dev server and check it. with reduced motion on, nothing moves; it works at 375px wide and with touch only; there are no console errors; scrolling stays smooth; destroy() is called when it unmounts.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "chatgpt": "I want the \"Seam transitions between sections\" element on my website. Below is its complete source. Please:\n1. Build one self-contained index.html that I can open locally to see it working (put the CSS in a <style> tag and the JavaScript in a <script> tag, with the markup in the body).\n2. Then explain, step by step, which parts go where in my own site, and how to mount it. My stack is: [tell me yours, or say \"plain HTML\"].\n3. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nThis is the \"Seam transitions between sections\" element from the Enrichr Library: The edge between a hero and the page below it comes apart as you scroll: tiles crumble off, threads fray loose, the hero cracks into plates, or ink bleeds down. Four seams on one canvas, switched by buttons.\nPage: https://enrichr.dev/elements/seam-transitions.html\nMachine-readable spec: https://enrichr.dev/elements/seam-transitions.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}\n\nVerify: with reduced motion on, nothing moves; it works at 375px wide and with touch only; there are no console errors; scrolling stays smooth; destroy() is called when it unmounts.",
    "cursor": "Add the \"Seam transitions between sections\" element to this codebase.\n\nThis is the \"Seam transitions between sections\" element from the Enrichr Library: The edge between a hero and the page below it comes apart as you scroll: tiles crumble off, threads fray loose, the hero cracks into plates, or ink bleeds down. Four seams on one canvas, switched by buttons.\nPage: https://enrichr.dev/elements/seam-transitions.html\nMachine-readable spec: https://enrichr.dev/elements/seam-transitions.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nUse Composer / agent mode. First search the repo for how components, styles and scripts are organised, then add the files in the matching places, mount it on the page I name, and run the app to confirm it works. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}\n\nVerify: with reduced motion on, nothing moves; it works at 375px wide and with touch only; there are no console errors; scrolling stays smooth; destroy() is called when it unmounts.\n\nOptional: save this as .cursor/rules/enrichr-seam-transitions.mdc so the rule stays with the project:\n---\ndescription: How to use the Enrichr Seam transitions between sections element\nalwaysApply: false\n---\nmount(root, opts) expects the element markup inside root and returns { update, destroy }. Always call destroy() on unmount and never edit the ef- class names.",
    "v0": "Create a Next.js (App Router) client component for the \"Seam transitions between sections\" element. Tailwind is available but this component needs no utility classes.\n\nThis is the \"Seam transitions between sections\" element from the Enrichr Library: The edge between a hero and the page below it comes apart as you scroll: tiles crumble off, threads fray loose, the hero cracks into plates, or ink bleeds down. Four seams on one canvas, switched by buttons.\nPage: https://enrichr.dev/elements/seam-transitions.html\nMachine-readable spec: https://enrichr.dev/elements/seam-transitions.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nFiles to create:\n- seam-transitions.js : the JavaScript below, with one line appended at the end: export { mount };  (and the markup exported as: export const markup = \"<the markup string>\";). If the project uses TypeScript and does not allow JS imports, put // @ts-nocheck on the first line.\n- the CSS below goes in your global stylesheet (or a plain .css file imported once at the app root). The class names are global and start with ef-.\n\nUse this component as the wrapper (adjust the import paths):\n```tsx\n\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport { markup, mount } from \"./seam-transitions\"; // markup is the HTML string; mount is the JavaScript's mount function\n\nexport default function SeamTransitions(props: Record<string, unknown>) {\n  const ref = useRef<HTMLDivElement>(null);\n  const fx = useRef<{ update: (o: object) => void; destroy: () => void } | null>(null);\n  useEffect(() => {\n    const root = ref.current;\n    if (!root) return;\n    root.innerHTML = markup;\n    fx.current = mount(root, props);\n    return () => { fx.current?.destroy(); fx.current = null; root.innerHTML = \"\"; };\n  }, []);\n  useEffect(() => { fx.current?.update(props); }, [JSON.stringify(props)]);\n  return <div ref={ref} />;\n}\n```\n\nRender it on the home page so I can see it. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "lovable": "Add the \"Seam transitions between sections\" element to this React + Vite + TypeScript + Tailwind project. Do not install any packages.\n\nThis is the \"Seam transitions between sections\" element from the Enrichr Library: The edge between a hero and the page below it comes apart as you scroll: tiles crumble off, threads fray loose, the hero cracks into plates, or ink bleeds down. Four seams on one canvas, switched by buttons.\nPage: https://enrichr.dev/elements/seam-transitions.html\nMachine-readable spec: https://enrichr.dev/elements/seam-transitions.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nFiles to create:\n- seam-transitions.js : the JavaScript below, with one line appended at the end: export { mount };  (and the markup exported as: export const markup = \"<the markup string>\";). If the project uses TypeScript and does not allow JS imports, put // @ts-nocheck on the first line.\n- the CSS below goes in your global stylesheet (or a plain .css file imported once at the app root). The class names are global and start with ef-.\n\nWrapper component (src/components/SeamTransitions.tsx):\n```tsx\n\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport { markup, mount } from \"./seam-transitions\"; // markup is the HTML string; mount is the JavaScript's mount function\n\nexport default function SeamTransitions(props: Record<string, unknown>) {\n  const ref = useRef<HTMLDivElement>(null);\n  const fx = useRef<{ update: (o: object) => void; destroy: () => void } | null>(null);\n  useEffect(() => {\n    const root = ref.current;\n    if (!root) return;\n    root.innerHTML = markup;\n    fx.current = mount(root, props);\n    return () => { fx.current?.destroy(); fx.current = null; root.innerHTML = \"\"; };\n  }, []);\n  useEffect(() => { fx.current?.update(props); }, [JSON.stringify(props)]);\n  return <div ref={ref} />;\n}\n```\n\nRemove the \"use client\" line if it is not needed. Show it on the home page. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "bolt": "Create a small Vite project (vanilla JavaScript, no framework unless I already have one) that shows the \"Seam transitions between sections\" element.\n\nThis is the \"Seam transitions between sections\" element from the Enrichr Library: The edge between a hero and the page below it comes apart as you scroll: tiles crumble off, threads fray loose, the hero cracks into plates, or ink bleeds down. Four seams on one canvas, switched by buttons.\nPage: https://enrichr.dev/elements/seam-transitions.html\nMachine-readable spec: https://enrichr.dev/elements/seam-transitions.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nFiles: index.html (a container element, then a script tag), style.css (the CSS below), seam-transitions.js (the JavaScript below) and main.js that sets the container's innerHTML to the markup and calls mount(container, opts) with the settings below. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}\n\nVerify: with reduced motion on, nothing moves; it works at 375px wide and with touch only; there are no console errors; scrolling stays smooth; destroy() is called when it unmounts.",
    "replit": "Create a new HTML, CSS and JavaScript Repl that shows the \"Seam transitions between sections\" element. No packages, no server code.\n\nThis is the \"Seam transitions between sections\" element from the Enrichr Library: The edge between a hero and the page below it comes apart as you scroll: tiles crumble off, threads fray loose, the hero cracks into plates, or ink bleeds down. Four seams on one canvas, switched by buttons.\nPage: https://enrichr.dev/elements/seam-transitions.html\nMachine-readable spec: https://enrichr.dev/elements/seam-transitions.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nFiles: index.html (a container element, link style.css, load seam-transitions.js and then main.js), style.css (the CSS below), seam-transitions.js (the JavaScript below), main.js (sets the container's innerHTML to the markup, then calls mount(container, opts) with the settings below). Press Run to preview. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "figma": "Designer handoff notes for the \"Seam transitions between sections\" element (Enrichr Library). Figma cannot run this JavaScript inside a design file, so use it as a motion reference and hand the code to a developer.\n\nThis is the \"Seam transitions between sections\" element from the Enrichr Library: The edge between a hero and the page below it comes apart as you scroll: tiles crumble off, threads fray loose, the hero cracks into plates, or ink bleeds down. Four seams on one canvas, switched by buttons.\nPage: https://enrichr.dev/elements/seam-transitions.html\nMachine-readable spec: https://enrichr.dev/elements/seam-transitions.json\n\nWhat it does: A vanilla port of Codrops' Seam Transitions with no GSAP, ScrollTrigger or ScrollSmoother. The demo scrolls inside its own container. One transparent canvas straddles the hero's bottom edge, and on every scroll event the script reads scrollTop, divides it by the hero height and redraws the whole seam as a pure function of that number, so scrolling back puts everything back exactly. Crumble breaks the lowest rows of the hero into square tiles that drop away on a simple falling curve, each one a flat patch of the hero's own gradient, with paper-coloured holes left behind. Fray cuts the hem into thin columns, pulls a thread loose from each and lets a small spring sway them when you scroll fast. Craquelure builds a jittered grid of plates, shrinks and turns them as they dry, and traces each with an accent seam. Ink grows rounded drips with fillets and droplets from the hem. At progress zero every seam draws nothing, so the plain edge shows until you scroll.\nAccessibility: The scrolling frame is a focusable region, so the arrow keys, Page Up and Page Down, Home and End all drive the seam. The four seam buttons are real buttons with aria-pressed. The canvas is decorative and hidden from assistive tech. With reduced motion, tiles fade out where they stand instead of falling and plates do not turn or the threads sway; the seam still follows scroll position. Page text always sits below the seam zone, so it is never drawn over.\n\nTunable settings:\n- Seam: select (Crumble / Fray / Craquelure / Ink), default crumble\n- Accent colour: color, default #e6ff55\n- Piece size (px): range 14 to 60, step 2, default 28\n- Seam height (% of hero): range 16 to 42, step 2, default 30\n- Hero title: text, default Where sections meet\n\nCurrent settings:\n{{SETTINGS}}\n\nFor the developer: Element contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nIn Figma:\n- Draw the resting state as a frame and the end state as a second frame; link them with Smart Animate to show the motion in a prototype.\n- Put the settings above in the frame's notes or Dev Mode annotations so the developer matches them.\n- Link https://enrichr.dev/elements/seam-transitions.html in the handoff: the live demo and the copy-ready code are there. Developers can paste the prompts for Claude, Cursor or v0 from the Export menu.\nNothing is copied from another designer's file; this is original code from Enrichr.",
    "framer": "Add the \"Seam transitions between sections\" element to Framer as a code component. Framer code components are React, so wrap the vanilla code like this. Check it in the Framer editor, as Framer's component API can change.\n\nThis is the \"Seam transitions between sections\" element from the Enrichr Library: The edge between a hero and the page below it comes apart as you scroll: tiles crumble off, threads fray loose, the hero cracks into plates, or ink bleeds down. Four seams on one canvas, switched by buttons.\nPage: https://enrichr.dev/elements/seam-transitions.html\nMachine-readable spec: https://enrichr.dev/elements/seam-transitions.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\n1. In Framer: Assets > Code > New component. Name it SeamTransitions.\n2. Paste the JavaScript below into a separate code file named seam-transitions.js in the same project, with  export { mount };  and  export const markup = \"<the markup string>\";  added. Put the CSS into the component as a <style> tag.\n3. Component:\n```tsx\nimport { addPropertyControls, ControlType } from \"framer\"\nimport { useEffect, useRef } from \"react\";\nimport { markup, mount } from \"./seam-transitions\"; // markup is the HTML string; mount is the JavaScript's mount function\n\nexport default function SeamTransitions(props: any) {\n  const ref = useRef<HTMLDivElement>(null);\n  const fx = useRef<{ update: (o: object) => void; destroy: () => void } | null>(null);\n  useEffect(() => {\n    const root = ref.current;\n    if (!root) return;\n    root.innerHTML = markup;\n    fx.current = mount(root, props);\n    return () => { fx.current?.destroy(); fx.current = null; root.innerHTML = \"\"; };\n  }, []);\n  useEffect(() => { fx.current?.update(props); }, [JSON.stringify(props)]);\n  return <div ref={ref} />;\n}\n\naddPropertyControls(SeamTransitions, {\n  seam: { type: ControlType.Enum, title: \"Seam\", options: [\"crumble\",\"fray\",\"craquelure\",\"ink\"], optionTitles: [\"Crumble\",\"Fray\",\"Craquelure\",\"Ink\"], defaultValue: \"crumble\" },\n  accent: { type: ControlType.Color, title: \"Accent colour\", defaultValue: \"#e6ff55\" },\n  size: { type: ControlType.Number, title: \"Piece size\", min: 14, max: 60, step: 2, defaultValue: 28 },\n  reach: { type: ControlType.Number, title: \"Seam height\", min: 16, max: 42, step: 2, defaultValue: 30 },\n  title: { type: ControlType.String, title: \"Hero title\", defaultValue: \"Where sections meet\" },\n})\n```\n4. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "webflow": "Add the \"Seam transitions between sections\" element to a Webflow site with an Embed element. Test it on the published or preview site: scripts in embeds may not run inside the Designer canvas.\n\nThis is the \"Seam transitions between sections\" element from the Enrichr Library: The edge between a hero and the page below it comes apart as you scroll: tiles crumble off, threads fray loose, the hero cracks into plates, or ink bleeds down. Four seams on one canvas, switched by buttons.\nPage: https://enrichr.dev/elements/seam-transitions.html\nMachine-readable spec: https://enrichr.dev/elements/seam-transitions.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\n1. Add an Embed element where it should appear. Paste a <style> block with the CSS, one container <div id=\"enrichr-seam-transitions\"></div>, and a <script> block with the JavaScript followed by:\n   mount(document.getElementById(\"enrichr-seam-transitions\"), { ...your settings... });\n   (set the container's innerHTML to the markup first, as in the contract).\n2. Embed elements have a size limit (check Webflow's current number). If the code is longer, host the JS and CSS as files and link them from Custom code in the page settings, then keep only the container and the mount call in the Embed.\n3. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}"
  },
  "exportTools": [
    "claude",
    "chatgpt",
    "cursor",
    "v0",
    "lovable",
    "bolt",
    "replit",
    "figma",
    "framer",
    "webflow"
  ],
  "accessibility": "The scrolling frame is a focusable region, so the arrow keys, Page Up and Page Down, Home and End all drive the seam. The four seam buttons are real buttons with aria-pressed. The canvas is decorative and hidden from assistive tech. With reduced motion, tiles fade out where they stand instead of falling and plates do not turn or the threads sway; the seam still follows scroll position. Page text always sits below the seam zone, so it is never drawn over.",
  "performance": "One 2D canvas, capped at 2x pixel ratio, sized to the seam zone only (about a third of the hero plus the same again below the edge), redrawn at most once per frame and only while that zone is on screen. Geometry is built once per width and setting. There is no rAF loop at rest; Fray runs one only while its sway settles.",
  "dependencies": [],
  "codepen": "https://codepen.io/Enrichr-Dev/pen/wBJRxVL",
  "source": {
    "markup": "<div class=\"ef-sm\">\n  <svg class=\"ef-sm__defs\" width=\"0\" height=\"0\" aria-hidden=\"true\" focusable=\"false\">\n    <symbol id=\"efsm-pp1\" viewBox=\"1513.7 1453.9 322.3 322.3\"><path fill=\"currentColor\" d=\"M1821 1761.2h-292.3v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5.1 7.8 8.6 19.7 13.6 32.7 11.9 16.8-2.3 30.1-16 32-32.7 2.5-22.6-15.1-41.6-37.1-41.6-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.4-14.5-14.4v-111.7h292.3v111.7c0 8-6.4 14.4-14.5 14.4-4.2 0-8.1-1.8-10.9-5-7.9-8.6-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.6 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5z\"/></symbol>\n    <symbol id=\"efsm-pp4\" viewBox=\"1423.36 860.8 503.07 322.3\"><path fill=\"currentColor\" d=\"M1821 875.8v111.6c0 8 6.5 14.5 14.5 14.5 4.2 0 8.1-1.9 10.9-5 6.8-7.5 16.7-12.3 27.7-12.3 22 0 39.6 19 37.1 41.6-1.9 16.7-15.2 30.5-32 32.7-13 1.7-24.8-3.3-32.7-11.9-2.8-3.2-6.8-5-11-5-8 0-14.5 6.4-14.5 14.4v111.7h-111.6c-8 0-14.5-6.5-14.5-14.5 0-4.2 1.9-8.2 5-11 8.7-7.9 13.7-19.7 12-32.7-2.3-16.8-16-30.1-32.8-32-22.5-2.5-41.5 15.1-41.5 37.1 0 11 4.7 20.9 12.3 27.7 3.1 2.8 4.9 6.7 4.9 10.8v0.1c0 8-6.4 14.5-14.5 14.5h-111.6v-111.7c0-8-6.4-14.4-14.5-14.4-4.1 0-8 1.8-10.8 4.9-6.9 7.6-16.7 12.3-27.7 12.3-22 0-39.6-19-37.1-41.6 1.9-16.7 15.2-30.4 32-32.7 13-1.7 24.8 3.3 32.7 11.9 2.8 3.2 6.7 5.1 10.9 5.1 8.1 0 14.5-6.5 14.5-14.5v-111.6z\"/></symbol>\n    <symbol id=\"efsm-pp8\" viewBox=\"1513.7 166.86 322.3 503.06\"><path fill=\"currentColor\" d=\"M1528.7 564.5v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5 7.8 8.7 19.7 13.7 32.7 11.9 16.8-2.2 30.1-15.9 32-32.7 2.5-22.5-15.1-41.5-37.1-41.5-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.5-14.5-14.5v-111.6h111.6c8.1 0 14.5-6.5 14.5-14.5 0-4.1-1.8-8.1-4.9-10.9-7.6-6.8-12.3-16.6-12.3-27.6 0-22 19-39.6 41.5-37.1 16.8 1.8 30.5 15.2 32.8 32 1.7 12.9-3.3 24.8-12 32.7-3.1 2.8-5 6.7-5 10.9 0 8 6.5 14.5 14.5 14.5h111.6v111.6c0 8-6.4 14.5-14.5 14.5-4.2 0-8.1-1.9-10.9-5-7.9-8.7-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.5 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5v111.6h-111.6c-8 0-14.5 6.5-14.5 14.5 0 4.2 1.9 8.1 5 10.9 7.5 6.8 12.3 16.7 12.3 27.7 0 22-19 39.5-41.6 37.1-16.8-1.9-30.5-15.3-32.7-32-1.7-13 3.3-24.9 11.9-32.7 3.1-2.9 5-6.8 5-11 0-8-6.4-14.5-14.5-14.5z\"/></symbol>\n  </svg>\n  <div class=\"ef-sm__wrap\">\n    <div class=\"ef-sm__bar\" role=\"group\" aria-label=\"Seam transition\">\n      <button type=\"button\" class=\"ef-sm__tab\" data-seam=\"crumble\" aria-pressed=\"true\">Crumble</button>\n      <button type=\"button\" class=\"ef-sm__tab\" data-seam=\"fray\" aria-pressed=\"false\">Fray</button>\n      <button type=\"button\" class=\"ef-sm__tab\" data-seam=\"craquelure\" aria-pressed=\"false\">Craquelure</button>\n      <button type=\"button\" class=\"ef-sm__tab\" data-seam=\"ink\" aria-pressed=\"false\">Ink</button>\n    </div>\n    <div class=\"ef-sm__scroll\" tabindex=\"0\" role=\"region\" aria-label=\"Scrollable demo. Scroll to play the seam between the two sections.\">\n      <div class=\"ef-sm__doc\">\n        <section class=\"ef-sm__hero\">\n          <svg class=\"ef-sm__art ef-sm__art--a\" viewBox=\"0 0 322.3 322.3\" aria-hidden=\"true\"><use href=\"#efsm-pp1\" width=\"322.3\" height=\"322.3\"/></svg>\n          <svg class=\"ef-sm__art ef-sm__art--b\" viewBox=\"0 0 503.07 322.3\" aria-hidden=\"true\"><use href=\"#efsm-pp4\" width=\"503.07\" height=\"322.3\"/></svg>\n          <svg class=\"ef-sm__art ef-sm__art--c\" viewBox=\"0 0 322.3 503.06\" aria-hidden=\"true\"><use href=\"#efsm-pp8\" width=\"322.3\" height=\"503.06\"/></svg>\n          <div class=\"ef-sm__copy\">\n            <span class=\"ef-sm__kick\">Enrichr Library</span>\n            <h3 class=\"ef-sm__title\">Where sections meet</h3>\n            <span class=\"ef-sm__cue\">Scroll</span>\n          </div>\n        </section>\n        <canvas class=\"ef-sm__seam\" aria-hidden=\"true\"></canvas>\n        <section class=\"ef-sm__page\">\n          <h4 class=\"ef-sm__h\">The page underneath</h4>\n          <p class=\"ef-sm__p\">The edge between two sections does not have to be a straight line. Scroll up and the hero gives way to the page.</p>\n          <div class=\"ef-sm__cards\">\n            <div class=\"ef-sm__card\"><svg viewBox=\"0 0 322.3 322.3\" aria-hidden=\"true\"><use href=\"#efsm-pp1\" width=\"322.3\" height=\"322.3\"/></svg><span>Piece one</span></div>\n            <div class=\"ef-sm__card\"><svg viewBox=\"0 0 503.07 322.3\" aria-hidden=\"true\"><use href=\"#efsm-pp4\" width=\"503.07\" height=\"322.3\"/></svg><span>Piece two</span></div>\n            <div class=\"ef-sm__card\"><svg viewBox=\"0 0 322.3 503.06\" aria-hidden=\"true\"><use href=\"#efsm-pp8\" width=\"322.3\" height=\"503.06\"/></svg><span>Piece three</span></div>\n          </div>\n          <p class=\"ef-sm__p ef-sm__p--end\">Everything below the seam is ordinary page content.</p>\n        </section>\n      </div>\n    </div>\n  </div>\n</div>",
    "css": "/* Enrichr Library · https://enrichr.dev */\n/* Adapted from https://github.com/codrops/SeamTransitions (MIT) — Copyright (c) 2009 - 2026 [Codrops](https://codrops.com) */\n.ef-sm { --ef-h: 24rem; --ef-acc: #e6ff55; --ef-above: 110px; --ef-below: 95px; width: 100%; }\n.ef-sm__defs { position: absolute; width: 0; height: 0; overflow: hidden; }\n.ef-sm__wrap { box-sizing: border-box; width: 100%; padding: 0.7rem; border-radius: 1rem; background: #0c1e18; font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif; }\n.ef-sm__bar { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.6rem; }\n.ef-sm__tab { appearance: none; border: 1px solid rgba(251, 248, 241, 0.28); background: transparent; color: #fbf8f1; font: inherit; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.04em; padding: 0.4rem 0.85rem; border-radius: 999px; cursor: pointer; transition: background 200ms, color 200ms, border-color 200ms; }\n.ef-sm__tab:hover { border-color: var(--ef-acc); }\n.ef-sm__tab[aria-pressed=\"true\"] { background: var(--ef-acc); border-color: var(--ef-acc); color: #0c1e18; }\n.ef-sm__tab:focus-visible { outline: 2px solid var(--ef-acc); outline-offset: 2px; }\n.ef-sm__scroll { position: relative; height: var(--ef-h); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; border-radius: 0.7rem; background: #fbf8f1; scrollbar-width: thin; scrollbar-color: #2f7d5b rgba(0, 0, 0, 0.08); outline: none; }\n.ef-sm__scroll:focus-visible { box-shadow: 0 0 0 2px var(--ef-acc); }\n.ef-sm__doc { position: relative; width: 100%; }\n.ef-sm__hero { position: relative; box-sizing: border-box; height: var(--ef-h); overflow: hidden; background: radial-gradient(ellipse 60% 34% at 78% 14%, rgba(230, 255, 85, 0.16), transparent 70%), linear-gradient(180deg, #0c1e18, #0a6b46); color: #fbf8f1; }\n.ef-sm__copy { position: relative; z-index: 1; padding: 1.3rem 1.4rem 0; max-width: 78%; }\n.ef-sm__kick { display: block; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: var(--ef-acc); }\n.ef-sm__title { margin: 0.5rem 0 0; font-size: clamp(1.7rem, 7vw, 2.7rem); line-height: 1; letter-spacing: -0.02em; font-weight: 800; }\n.ef-sm__cue { display: inline-block; margin-top: 0.9rem; font-size: 0.7rem; letter-spacing: 0.16em; text-transform: uppercase; opacity: 0.7; }\n.ef-sm__art { position: absolute; color: #fbf8f1; opacity: 0.16; pointer-events: none; }\n.ef-sm__art--a { width: 22%; right: 6%; top: 8%; transform: rotate(14deg); color: var(--ef-acc); opacity: 0.4; }\n.ef-sm__art--b { width: 30%; right: -4%; top: 30%; transform: rotate(-8deg); }\n.ef-sm__art--c { width: 14%; right: 36%; top: 18%; transform: rotate(10deg); opacity: 0.1; }\n.ef-sm__seam { position: absolute; left: 0; top: 0; width: 100%; height: 0; pointer-events: none; display: block; }\n.ef-sm__page { box-sizing: border-box; min-height: calc(var(--ef-h) * 1.35); padding: calc(var(--ef-below) + 1.6rem) 1.4rem 2rem; color: #0c1e18; background: #fbf8f1; }\n.ef-sm__h { margin: 0; font-size: 1.45rem; letter-spacing: -0.01em; font-weight: 800; }\n.ef-sm__p { margin: 0.6rem 0 0; max-width: 34ch; font-size: 0.92rem; line-height: 1.5; color: #005236; }\n.ef-sm__p--end { margin-top: 1.4rem; }\n.ef-sm__cards { display: flex; gap: 0.7rem; margin-top: 1.2rem; }\n.ef-sm__card { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 0.9rem 0.5rem 0.7rem; border-radius: 0.7rem; background: #fff; box-shadow: 0 6px 14px -10px rgba(12, 30, 24, 0.5); font-size: 0.7rem; font-weight: 700; letter-spacing: 0.06em; color: #0c1e18; }\n.ef-sm__card svg { height: 3rem; max-width: 80%; color: #2f7d5b; }\n@media (prefers-reduced-motion: reduce) { .ef-sm__tab { transition: none; } }",
    "js": "// Enrichr Library · https://enrichr.dev\n// Adapted from https://github.com/codrops/SeamTransitions (MIT) — Copyright (c) 2009 - 2026 [Codrops](https://codrops.com)\n// Seam transitions: the edge between a hero and the page below it comes apart as you scroll. Four seams on one 2D canvas that\n// straddles the edge: Crumble (square tiles break off and fall), Fray (threads pulled loose from the hem), Craquelure (the hero\n// cracks into plates, with gold-lime seams) and Ink (the hero bleeds down onto the page). A vanilla port: no GSAP or ScrollSmoother.\n// The demo scrolls inside its own container; every frame is a pure function of scrollTop, so scrolling back puts everything back.\n// mount(root, { seam, accent, size (px), reach (% of height), title })\nfunction mount(root, o) {\n  var reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  var scroller = root.querySelector(\".ef-sm__scroll\");\n  var doc = root.querySelector(\".ef-sm__doc\");\n  var canvas = root.querySelector(\".ef-sm__seam\");\n  var titleEl = root.querySelector(\".ef-sm__title\");\n  var tabs = [].slice.call(root.querySelectorAll(\".ef-sm__tab\"));\n  var host = root.querySelector(\".ef-sm\");\n  var ctx = canvas.getContext(\"2d\");\n  var opts = {}, raf = 0, ro = null, offs = [];\n  var W = 0, H = 0, above = 0, below = 0, dpr = 1, geo = null, geoKey = \"\";\n  var sway = 0, swayV = 0, lastTop = 0, started = false;\n  var PAPER = \"#fbf8f1\", TOP = [12, 30, 24], BOT = [10, 107, 70], DEEP = \"#005236\";\n\n  function rng(seed) { var a = seed >>> 0; return function () { a = (a + 0x6D2B79F5) >>> 0; var t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }\n  function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }\n  function ease(t) { return t * t * (3 - 2 * t); }\n  function rgb(c, k) { k = k || 1; return \"rgb(\" + Math.round(c[0] * k) + \",\" + Math.round(c[1] * k) + \",\" + Math.round(c[2] * k) + \")\"; }\n  function heroCol(y, E, k) { var t = clamp((y - (E - H)) / H, 0, 1); return [TOP[0] + (BOT[0] - TOP[0]) * t, TOP[1] + (BOT[1] - TOP[1]) * t, TOP[2] + (BOT[2] - TOP[2]) * t].map(function (v) { return v * (k || 1); }); }\n  function heroGrad(E) { var g = ctx.createLinearGradient(0, E - H, 0, E); g.addColorStop(0, rgb(TOP)); g.addColorStop(1, rgb(BOT)); return g; }\n\n  // ---- geometry (seeded, rebuilt when the size, width or reach change) ----\n  function build() {\n    var key = [opts.seam, W, above, below, opts.size].join(\"|\");\n    if (key === geoKey) return;\n    geoKey = key;\n    var r = rng(7), i, j, g = {};\n    var S = opts.size;\n    if (opts.seam === \"crumble\") {\n      var cols = Math.max(2, Math.round(W / S)), cs = W / cols, rows = Math.ceil(above / cs);\n      g.cs = cs; g.cols = cols; g.rows = rows; g.tiles = [];\n      for (j = 0; j < rows; j++) for (i = 0; i < cols; i++) {\n        g.tiles.push({ x: i * cs, y: -(j + 1) * cs, j: j, at: 0.03 + 0.62 * ((j + r() * 2.2) / (rows + 2.2)), dx: r() - 0.5, rot: r() - 0.5, shade: 0.88 + r() * 0.22, acc: r() < 0.09 });\n      }\n    } else if (opts.seam === \"fray\") {\n      var pitch = clamp(S / 3, 5, 14), n = Math.ceil(W / pitch);\n      g.pitch = pitch; g.threads = [];\n      var p1 = r() * 6, p2 = r() * 6;\n      for (i = 0; i < n; i++) g.threads.push({ x: (i + 0.5) * pitch, len: 0.5 * (0.5 + 0.5 * Math.sin(i * pitch * 0.021 + p1) * Math.sin(i * pitch * 0.047 + p2)) + 0.5 * r(), at: r() * 0.2, ph: r() * 6.28, acc: r() < 0.14, w: 0.8 + r() * 0.5 });\n    } else if (opts.seam === \"craquelure\") {\n      var C = S * 1.8, cc = Math.max(2, Math.round(W / C)), cw = W / cc;\n      var up = Math.ceil(above / cw), dn = Math.ceil(below / cw);\n      g.cw = cw; g.cc = cc; g.up = up; g.dn = dn;\n      // vertex grid rows -dn..up, row 0 sits exactly on the edge; vertices are jittered, the edge row only sideways\n      g.v = [];\n      for (j = -dn; j <= up; j++) { var row = []; for (i = 0; i <= cc; i++) row.push({ x: i * cw + (i === 0 || i === cc ? 0 : (r() - 0.5) * cw * 0.55), y: -j * cw + (j === 0 ? 0 : (r() - 0.5) * cw * 0.55) }); g.v.push(row); }\n      g.plates = [];\n      for (j = 0; j < up + dn; j++) for (i = 0; i < cc; i++) {\n        var jj = j - dn; // vertex row index of the plate's lower edge\n        var side = jj >= 0 ? \"hero\" : \"page\";\n        var lvl = side === \"hero\" ? jj : -jj - 1;\n        var lim = side === \"hero\" ? up : dn;\n        g.plates.push({ i: i, j: j, side: side, at: 0.04 + 0.6 * ((lvl + r() * 1.5) / (lim + 1.5)), rot: r() - 0.5 });\n      }\n    } else {\n      var nd = Math.max(3, Math.round(W / (S * 1.15)));\n      g.drips = [];\n      for (i = 0; i < nd; i++) g.drips.push({ x: (i + 0.15 + r() * 0.7) * (W / nd), w: S * (0.22 + r() * 0.3), at: 0.03 + r() * 0.5, max: below * (0.42 + r() * 0.56), drop: r() < 0.55, hl: r() });\n    }\n    geo = g;\n  }\n\n  // ---- the four seams. E is the edge's y inside the canvas; s is scrollTop / hero height ----\n  function crumble(E, s) {\n    var cs = geo.cs, i, t, f, hs = [], dy, dx, rot, a, space;\n    ctx.fillStyle = PAPER;\n    for (i = 0; i < geo.tiles.length; i++) {\n      t = geo.tiles[i]; f = s - t.at;\n      if (f <= 0) continue;\n      ctx.fillRect(t.x - 0.5, E + t.y - 0.5, cs + 1, cs + 1 + (t.j === 0 ? 2 : 0));\n      hs.push(t);\n    }\n    for (i = 0; i < hs.length; i++) {\n      t = hs[i]; f = s - t.at;\n      var cx = t.x + cs / 2, cy = E + t.y + cs / 2;\n      if (reduce) { dx = 0; dy = 0; rot = 0; a = 1 - clamp(f / 0.12, 0, 1); }\n      else {\n        dy = f * H * 0.5 + f * f * H * 1.5; dx = t.dx * f * H * 0.7; rot = t.rot * f * 7;\n        space = (E + below) - cy;\n        a = 1 - clamp((dy - space * 0.55) / (space * 0.45 + 1), 0, 1);\n      }\n      if (a <= 0.01) continue;\n      ctx.save();\n      ctx.globalAlpha = a;\n      ctx.translate(cx + dx, cy + dy);\n      ctx.rotate(rot);\n      ctx.fillStyle = t.acc ? opts.accent : rgb(heroCol(cy, E, t.shade));\n      ctx.fillRect(-cs / 2, -cs / 2, cs, cs);\n      ctx.restore();\n    }\n  }\n\n  function fray(E, s) {\n    var th = geo.threads, pitch = geo.pitch, UP = above * 0.92, pe = ease(clamp(s / 0.9, 0, 1)), i, t, L;\n    if (pe <= 0) return;\n    var Ls = [];\n    ctx.fillStyle = PAPER;\n    for (i = 0; i < th.length; i++) {\n      t = th[i]; L = clamp((pe - t.at) / (1 - t.at), 0, 1) * UP * (0.22 + 0.78 * t.len); Ls.push(L);\n      if (L > 0.4) ctx.fillRect(t.x - pitch / 2 - 0.5, E - L, pitch + 1, L + 2);\n    }\n    ctx.lineCap = \"round\";\n    for (i = 0; i < th.length; i++) {\n      t = th[i]; L = Ls[i];\n      if (L <= pitch * 0.5) continue;\n      var yt = E - L, tail = L * 0.8, len = L + tail, steps = 9, k, px = t.x, py = yt, nx, ny, u;\n      var col = t.acc ? opts.accent : rgb(heroCol(yt, E, 0.92));\n      ctx.strokeStyle = col;\n      for (k = 1; k <= steps; k++) {\n        u = k / steps; ny = yt + len * u;\n        var hang = u * u;\n        nx = t.x + Math.sin(ny * 0.13 + t.ph) * pitch * 0.16 * u + sway * (reduce ? 0 : 1) * pitch * 1.6 * hang * t.w * (0.6 + 0.4 * Math.sin(t.ph));\n        ctx.lineWidth = Math.max(0.5, pitch * 0.4 * (1 - u * 0.85));\n        ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(nx, ny); ctx.stroke();\n        px = nx; py = ny;\n      }\n    }\n  }\n\n  function plateRect(a, p) {\n    var v = geo.v, jr = p.j, i = p.i;\n    // plate between vertex rows jr (lower on screen, further below) and jr+1\n    return [v[jr][i], v[jr][i + 1], v[jr + 1][i + 1], v[jr + 1][i]].map(function (q) { return { x: q.x, y: E_ + q.y }; });\n  }\n  var E_ = 0;\n  function craquelure(E, s) {\n    var P = geo.plates, i, k, p, d, q, c, pts, sc, ang, cs, sn;\n    E_ = E;\n    var grad = heroGrad(E);\n    // page side first: hairline cracks on the cream\n    ctx.lineJoin = \"round\";\n    for (i = 0; i < P.length; i++) {\n      p = P[i]; if (p.side !== \"page\") continue;\n      d = ease(clamp((s - p.at) / 0.3, 0, 1)); if (d <= 0.01) continue;\n      pts = plateRect(null, p);\n      var depth = clamp((pts[0].y - E) / Math.max(1, below), 0, 1);\n      ctx.strokeStyle = \"rgba(10,107,70,\" + (0.55 * d * (1 - depth * 0.7)).toFixed(3) + \")\";\n      ctx.lineWidth = 0.6 + 1.8 * d;\n      ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); for (k = 1; k < 4; k++) ctx.lineTo(pts[k].x, pts[k].y); ctx.closePath(); ctx.stroke();\n    }\n    // hero plates: open gap of paper, then the shrunk and turned plate in the hero's own gradient\n    var live = [];\n    for (i = 0; i < P.length; i++) {\n      p = P[i]; if (p.side !== \"hero\") continue;\n      d = ease(clamp((s - p.at) / 0.3, 0, 1)); if (d <= 0.004) continue;\n      pts = plateRect(null, p);\n      ctx.fillStyle = PAPER;\n      ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y + (p.j === geo.dn ? 2 : 0)); for (k = 1; k < 4; k++) ctx.lineTo(pts[k].x, pts[k].y); ctx.closePath(); ctx.fill();\n      live.push({ p: p, d: d, pts: pts });\n    }\n    for (i = 0; i < live.length; i++) {\n      var L = live[i]; pts = L.pts; d = L.d;\n      c = { x: (pts[0].x + pts[1].x + pts[2].x + pts[3].x) / 4, y: (pts[0].y + pts[1].y + pts[2].y + pts[3].y) / 4 };\n      sc = 1 - 0.2 * d; ang = reduce ? 0 : L.p.rot * 0.14 * d; cs = Math.cos(ang); sn = Math.sin(ang);\n      ctx.beginPath();\n      for (k = 0; k < 4; k++) {\n        var dx = (pts[k].x - c.x) * sc, dy = (pts[k].y - c.y) * sc;\n        var X = c.x + dx * cs - dy * sn, Y = c.y + dx * sn + dy * cs;\n        if (k === 0) ctx.moveTo(X, Y); else ctx.lineTo(X, Y);\n      }\n      ctx.closePath();\n      ctx.fillStyle = grad; ctx.fill();\n      ctx.strokeStyle = opts.accent; ctx.globalAlpha = clamp(d * 1.2, 0, 1); ctx.lineWidth = 1.3; ctx.stroke(); ctx.globalAlpha = 1;\n    }\n  }\n\n  function ink(E, s) {\n    var D = geo.drips, i, d, u, len, nw, br, fil, yb, pe = ease(clamp(s / 0.7, 0, 1));\n    if (s <= 0.005) return;\n    var g = ctx.createLinearGradient(0, E, 0, E + below);\n    g.addColorStop(0, rgb(BOT)); g.addColorStop(1, DEEP);\n    ctx.fillStyle = g;\n    // a wavering band of ink along the whole hem\n    var bh = pe * below * 0.09;\n    ctx.beginPath(); ctx.moveTo(0, E - 1);\n    for (i = 0; i <= 48; i++) { var x = (W * i) / 48; ctx.lineTo(x, E + bh * (0.55 + 0.45 * Math.sin(x * 0.045 + 1.3) * Math.sin(x * 0.017))); }\n    ctx.lineTo(W, E - 1); ctx.closePath(); ctx.fill();\n    for (i = 0; i < D.length; i++) {\n      d = D[i]; u = clamp((s - d.at) / 0.6, 0, 1); if (u <= 0) continue;\n      len = (1 - Math.pow(1 - u, 3)) * d.max;\n      nw = d.w * 0.5; br = d.w * 0.8; fil = nw * 2.4; yb = E + Math.max(len - br, fil);\n      ctx.fillStyle = g;\n      ctx.beginPath();\n      ctx.moveTo(d.x - nw - fil, E - 1);\n      ctx.quadraticCurveTo(d.x - nw, E + 1, d.x - nw, E + fil);\n      ctx.lineTo(d.x - nw, yb); ctx.lineTo(d.x + nw, yb); ctx.lineTo(d.x + nw, E + fil);\n      ctx.quadraticCurveTo(d.x + nw, E + 1, d.x + nw + fil, E - 1);\n      ctx.closePath(); ctx.fill();\n      ctx.beginPath(); ctx.arc(d.x, yb, br * (0.55 + 0.45 * u), 0, 6.2832); ctx.fill();\n      ctx.strokeStyle = opts.accent; ctx.globalAlpha = 0.55; ctx.lineWidth = 1.2; ctx.lineCap = \"round\";\n      ctx.beginPath(); ctx.moveTo(d.x - nw * 0.4, E + fil * 0.9); ctx.lineTo(d.x - nw * 0.4, yb); ctx.stroke(); ctx.globalAlpha = 1;\n      if (d.drop && u > 0.7) {\n        var v = (u - 0.7) / 0.3, dy = E + len + br * 0.8 + v * Math.min(below * 0.2, 40), r2 = br * 0.42 * (1 - v * 0.35);\n        if (dy + r2 < E + below) { ctx.fillStyle = DEEP; ctx.beginPath(); ctx.arc(d.x, dy, r2, 0, 6.2832); ctx.fill(); }\n      }\n    }\n  }\n\n  var SEAMS = { crumble: crumble, fray: fray, craquelure: craquelure, ink: ink };\n\n  function draw() {\n    if (!W) return;\n    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    ctx.clearRect(0, 0, W, above + below);\n    var s = scroller.scrollTop / H;\n    if (s <= 0.0005 || s > 1 + below / H + 0.05) return;\n    build();\n    ctx.save();\n    SEAMS[opts.seam](above, s);\n    ctx.restore();\n  }\n  function frame() {\n    raf = 0;\n    if (opts.seam === \"fray\" && !reduce) {\n      swayV += -sway * 0.07; swayV *= 0.9; sway = clamp(sway + swayV, -2, 2);\n      if (Math.abs(sway) < 0.003 && Math.abs(swayV) < 0.003) { sway = 0; swayV = 0; }\n    }\n    draw();\n    if (opts.seam === \"fray\" && !reduce && (sway !== 0 || swayV !== 0)) need();\n  }\n  function need() { if (!raf) raf = requestAnimationFrame(frame); }\n\n  function layout() {\n    H = scroller.clientHeight;\n    W = Math.round(doc.clientWidth);\n    if (!H || !W) return;\n    above = Math.round(H * opts.reach / 100);\n    below = Math.round(above * 0.85);\n    dpr = Math.min(window.devicePixelRatio || 1, 2);\n    host.style.setProperty(\"--ef-above\", above + \"px\");\n    host.style.setProperty(\"--ef-below\", below + \"px\");\n    canvas.style.top = (H - above) + \"px\";\n    canvas.style.height = (above + below) + \"px\";\n    canvas.width = Math.round(W * dpr);\n    canvas.height = Math.round((above + below) * dpr);\n    geoKey = \"\";\n    if (!started) { started = true; scroller.scrollTop = Math.round(H * 0.5); lastTop = scroller.scrollTop; }\n    need();\n  }\n\n  function setSeam(name, jump) {\n    if (!SEAMS[name]) name = \"crumble\";\n    opts.seam = name;\n    tabs.forEach(function (b) { b.setAttribute(\"aria-pressed\", b.getAttribute(\"data-seam\") === name ? \"true\" : \"false\"); });\n    sway = 0; swayV = 0;\n    if (jump && H) scroller.scrollTo({ top: Math.round(H * 0.5), behavior: reduce ? \"auto\" : \"smooth\" });\n    need();\n  }\n\n  function onScroll() {\n    var top = scroller.scrollTop;\n    swayV = clamp(swayV + (top - lastTop) * 0.006, -0.6, 0.6);\n    lastTop = top;\n    need();\n  }\n  scroller.addEventListener(\"scroll\", onScroll, { passive: true });\n  offs.push(function () { scroller.removeEventListener(\"scroll\", onScroll); });\n  tabs.forEach(function (b) {\n    function ck() { setSeam(b.getAttribute(\"data-seam\"), true); }\n    b.addEventListener(\"click\", ck);\n    offs.push(function () { b.removeEventListener(\"click\", ck); });\n  });\n  if (window.ResizeObserver) { ro = new ResizeObserver(function () { layout(); }); ro.observe(scroller); }\n  else { window.addEventListener(\"resize\", layout); offs.push(function () { window.removeEventListener(\"resize\", layout); }); }\n\n  function update(next) {\n    var prev = opts;\n    opts = Object.assign({ seam: \"crumble\", accent: \"#e6ff55\", size: 28, reach: 30, title: \"Where sections meet\" }, opts, next || {});\n    opts.size = clamp(Math.round(opts.size), 14, 60);\n    opts.reach = clamp(Math.round(opts.reach), 16, 42);\n    host.style.setProperty(\"--ef-acc\", opts.accent);\n    titleEl.textContent = opts.title;\n    if (prev.seam !== opts.seam && prev.seam) setSeam(opts.seam, false); else setSeam(opts.seam, false);\n    if (prev.reach !== opts.reach || !W) layout(); else { geoKey = \"\"; need(); }\n  }\n  update(o);\n  need();\n  return {\n    update: update,\n    destroy: function () {\n      if (raf) cancelAnimationFrame(raf);\n      raf = 0;\n      if (ro) ro.disconnect();\n      offs.forEach(function (f) { f(); });\n      offs = [];\n      ctx.clearRect(0, 0, canvas.width, canvas.height);\n    }\n  };\n}"
  }
}