{"version":3,"file":"ProjectTile.DXCS7njH.mjs","names":["useRef","useState"],"sources":["https:/framerusercontent.com/modules/0d7Q93HBJhiIxrQSgmpU/PbmLdpWSPlQ2dcH8Ww9y/ProjectTile.js"],"sourcesContent":["import{jsx as _jsx,jsxs as _jsxs}from\"react/jsx-runtime\";import{addPropertyControls,ControlType}from\"framer\";import{useEffect,useRef,useState}from\"react\";// ---------------------------------------------------------------------------\n// adam.film — one project tile.\n//\n// TITLES READ ON BRIGHT PHOTOS, AND THE LABELS ARE BIGGER, 7 OCT, Adam's call.\n// The live QC found \"Life Size / Stills\" white on a white photo and the\n// Motion / Stills label lost on Lucky One, Clinique and others. Adam chose a\n// stronger text shadow (a soft dark halo hugging the letters), NOT a dark fade\n// across the tile foot. The same shadow is used on the related tiles\n// (ProjectPage) and the /two-campaigns proof cards. He also found the job\n// type (Commercial, Documentary...) too small: the work label went 10 to\n// 12.5px, the format line 10 to 11px, the role 10 to 11px.\n//\n// Place INSIDE a Collection List bound to `Projects`, then bind the controls\n// below to their CMS fields (click the dot left of the control name →\n// Set Variable → pick the field).\n//\n// EIGHT binds, not thirty. Every tag rides in on one string field,\n// `Tags (generated)`, which the tile splits and writes back out as data-*\n// attributes for FilterBar to read.\n//\n// Why one string: Framer has no API for binding property controls, so each\n// boolean would be a hand-click in the editor, twenty-two of them, with no way\n// to verify the result except by eye. One field is one click and it either\n// carries the tags or it doesn't.\n//\n// `Tags (generated)` is DERIVED from the boolean fields, which stay the CMS\n// source of truth. Change a boolean and the string goes stale — regenerate it\n// from the booleans and diff, exactly as the import does.\n//\n// Vocabulary, and it must match FilterBar's chip values exactly:\n//   motion stills\n//   commercial branded onset documentary narrative music series dance\n//   fashion beauty portrait product sport musicsubject movement lifestyle storysubject\n//   hasstory showingrid\n//\n// THE CAPTION SIZES ITSELF OFF THE TILE, NOT OFF THE VIEWPORT. `.aftile` is a\n// `container-type: inline-size` container and the caption rules below are\n// `@container` queries. That is deliberate and it is not interchangeable with\n// a media query: at 390px Home shows ONE tile across (346px wide) and /work\n// shows TWO (170px wide), so the same viewport needs two different caption\n// sizes. A viewport query set 17px for both and pushed \"Timberland · Nature\n// Needs Heroes\" clean out the top of its 128px tile on /work. Measured after\n// the change: caption block 66–81px inside a 128px tile, no overflow.\n//\n// THE SMALL-TILE LABELS ARE 10px, NOT 8.5, 23 SEP. Adam approved. Inside the\n// 270px container query the format line (\"Motion\", \"Stills\") and the Story\n// badge were 8.5px uppercase, which is below what reads on a phone. Both are\n// now 10px, the same size they are at desktop, with letter-spacing 1.3px\n// rather than the desktop 1.7 so the words stay compact in a 170px tile. The\n// title stays 12.5px; it is the part that has to fit. Measured on the\n// published /work grid at 390px with the new values injected: no format label\n// wraps or overflows on any of the 51 tiles, and the tallest caption still\n// clears the top of its tile by 30px (32 before).\n//\n// A TAPPED PHONE TILE SHOWS TITLE AND FORMAT ONLY, 4 OCT. Adam's call. On\n// /work at phone width the tile is 133–188px wide and the hover / tap reveal\n// (work label plus the full role in spaced capitals) grew the caption upward\n// until the title left the top of the tile: 16 of 53 tiles at 390, 34 at 360,\n// worst on Lily and Miami City Ballet. Under a 200px container the work label\n// and role are no longer revealed; the role is on the project page. Measured\n// with every reveal forced on: the title now clears the tile top by 28px or\n// more at 320–430. From 203px up (460 viewport, tablets, desktop) the full\n// reveal stays and still clears by 26px or more. `!important` is needed to\n// beat the `:hover` / `:focus-within` reveal rule above. The Story tile's\n// \"Read the story\" line is kept; it fits.\n//\n// NO GRADIENT OVER THE PHOTOGRAPH, 23 SEP. Adam's call: he does not want a\n// dark wash on top of his images. The caption block used to carry a\n// transparent-to-82%-black linear-gradient across the bottom of every tile;\n// it is gone. The caption keeps its legibility from a text-shadow on the\n// letters only, which darkens nothing but the glyph edges. If a caption ever\n// disappears into a bright frame, fix it with that tile's focal point or a\n// different tile image, not by putting the gradient back. The hover dim\n// (brightness .44) is unchanged; it only shows while the pointer is on a tile.\n//\n// ---------------------------------------------------------------------------\n// THE TILE IMAGE IS SIZED HERE, NOT BY FRAMER, 23 SEP. READ BEFORE TOUCHING.\n//\n// The problem, measured on the published /work grid at 1440 with eight tiles\n// carrying images: every 330px tile downloaded the 2048px or the full-size\n// original, 1.6 MB for eight tiles. At 51 tiles that is about 10 MB for one\n// page, and it was growing with every upload.\n//\n// Cause. Framer hands the tile a `srcSet` of 512 / 1024 / 2048 / original\n// with `w` descriptors, and writes its own `sizes` onto the <img>:\n// \"(min-width: 1200px) 100vw, (max-width: 1199.98px) 100vw\". 100vw is true of\n// a hero image and false of a grid tile, so the browser picks as if the tile\n// were the full width of the screen. The same rewriting broke the About\n// portrait on 30 Aug; AboutPage.tsx has the full account.\n//\n// Fix, the same one About uses: a <picture> with one <source> per layout band\n// and `x` descriptors, and NO `sizes` anywhere, so there is nothing for\n// Framer to rewrite. Each band asks the CDN for exactly the width the tile is\n// drawn at, times 1 and 2 for the screen's pixel density (3 until 7 Oct, see\n// \"IMAGES STOP AT 2x\" below).\n//\n// THE WIDTHS ARE MEASURED, NOT DERIVED. Tile width read off the published\n// grids at every band edge, 23 Sep, rounded up:\n//\n//     viewport     Home (Selected work grid)     Work (Content)\n//     ≤ 440        1 across, 392  → 400          2 across, 193  → 200\n//     ≤ 560        1 across, 512  → 520          2 across, 253  → 260\n//     ≤ 700        2 across, 321  → 330          2 across, 321  → 330\n//     ≤ 900        2 across, 415  → 420          3 across, 274  → 280\n//     ≤ 1150       3 across, 352  → 360          3 across, 352  → 360\n//     above        3 across, 473  → 480          4 across, 354  → 360\n//\n// They follow the column steps in GRID_CSS below. CHANGE A STEP THERE AND\n// THIS TABLE HAS TO CHANGE WITH IT, or the tiles go soft or heavy.\n//\n// WHICH GRID A TILE IS IN comes from the `Grid` control first, then from\n// the nearest named frame after mount. The /work tile instances have Grid\n// set to Work in the canvas (both breakpoints), so the server-rendered page\n// already asks for /work sizes and nothing is fetched twice. Home's instances\n// are left on Auto: Auto starts from Home's widths, which is right for Home.\n//\n// Before the control existed, 23 Sep, a /work tile on screen at load fetched\n// Home's size first and then its own after mount: about 220 KB extra at 1440\n// for the first eight. That is what Grid fixes. If a tile is ever placed in a\n// third grid, give it a column in TILE_WIDTHS and an option here.\n//\n// `data-grid` on the anchor prints which grid won.\n//\n// WHAT THE CDN IS ASKED FOR IS THE LONG EDGE, NOT THE WIDTH. `scale-down-to`\n// constrains the longest side (About header, point 3). The tile is 4:3 and\n// crops with object-fit:cover, so the image must cover both the tile's width\n// and its height: scale = max(w / pw, 0.75w / ph), long edge = that scale\n// times the source's long side. That is what makes a 3326 x 4096 portrait\n// (Life Size) ask for 1.23x the tile width, and a 16:9 frame 1.33x. Source\n// pixel size comes from `pixelWidth`/`pixelHeight` on Framer's image object;\n// if they are ever missing the tile asks for 1.34x, which covers both.\n//\n// If the long edge it would ask for is the source's own size or more, it asks\n// for the original instead: the CDN never upscales (About header, point 4).\n// Any URL not on framerusercontent.com gets Framer's own srcSet back, sizes\n// bug and all, so a hand-typed image still renders.\n//\n// Every arbitrary width was checked against the CDN before this shipped:\n// 360, 470, 520, 600, 800, 940, 1040, 1400 and 1560 on three tile images all\n// came back resized to the exact number. The \"800 returned the original\"\n// oddity recorded in About did not reproduce on these files.\n//\n// ---------------------------------------------------------------------------\n// THE TILE IS 4:3 LANDSCAPE AND THE SOURCE PHOTOGRAPHS ARE NOT. 14 SEPTEMBER.\n//\n// `.aftile` is `aspect-ratio:4/3` and that is set ONCE, here, with no\n// breakpoint, container query or media query overriding it anywhere in this\n// file. Measured on the published grid: 294 x 220 at a 1280 viewport, which\n// is 1.336. It is 4:3 on Home and on /work at every width.\n//\n// The first real set of tile images, Life Size, is 3326 x 4096 portrait —\n// ratio 0.812. `object-fit:cover` on a 4:3 box with a 0.812 source scales the\n// image to fill the box's WIDTH and throws away most of the height. Roughly\n// 61% of the source height survives; the rest is cropped off top and bottom.\n//\n// SO A CENTRED CROP CUTS THE HEAD OFF A FULL-FIGURE VERTICAL. Measured on the\n// published tile with no focus set: `object-position: 50% 50%` and the\n// dancer's head is gone.\n//\n// ---------------------------------------------------------------------------\n// FRAMER'S FOCAL POINT DOES REACH THIS COMPONENT. MEASURED 14 SEPTEMBER, AND\n// IT CORRECTS AN EARLIER NOTE IN THIS FILE THAT SAID THE OPPOSITE.\n//\n// Click a CMS image in Framer and it offers Crop and Focal Point. Adam found\n// the focal point button and set one, which is the right instinct — it is\n// visual, you drag a point onto the face, no numbers to type.\n//\n// THE FIRST MEASUREMENT SAID IT DID NOT WORK, AND THE FIRST MEASUREMENT WAS\n// TAKEN ON AN IMAGE THAT HAD NO FOCAL POINT SET. A `data-imgkeys` diagnostic\n// was shipped that prints the keys Framer puts on the image object. On a tile\n// whose image has never had a focal point applied it reads:\n//\n//     data-imgkeys=\"src,srcSet,pixelWidth,pixelHeight\"\n//\n// which looks exactly like proof that the focal point is unavailable. It is\n// not. On a tile whose image HAS one applied, the same diagnostic reads:\n//\n//     data-imgkeys=\"src,positionX,positionY,srcSet,pixelWidth,pixelHeight\"\n//\n// FRAMER OMITS THE KEYS UNTIL A FOCAL POINT EXISTS. `positionX`/`positionY`\n// come through as percentage strings and `framerFocus()` below turns them\n// straight into `object-position`. Verified on the published grid: a tile with\n// an empty `Tile focus` and a focal point set in the CMS rendered\n// `object-position: 62.4% 24.1%`, matching where the point was dragged.\n//\n// THE LESSON IS THE TRAP, NOT THE FEATURE. An absent key is evidence about\n// the state of that one record before it is evidence about the platform. The\n// null result was real and the inference drawn from it was wrong.\n//\n// TWO CONTROLS NOW, AND THE ORDER OF PRECEDENCE IS:\n//\n//   1. `Tile focus`, the CMS string, if it is filled in. It wins.\n//   2. Framer's focal point on the image, if one has been set.\n//   3. Centred, 50% 50%.\n//\n// USE THE FOCAL POINT BUTTON BY DEFAULT. It is visual and it lives next to the\n// image. `Tile focus` stays for the cases where a number is easier to reason\n// about than a dragged point, and for overriding one tile without touching\n// the image everyone else's tile also uses. Its values map straight to CSS:\n//\n//   \"50% 15%\"   band sits high — holds the head on a standing figure\n//   \"50% 4%\"    what Life Size uses, arrived at by eye on the live grid\n//   \"50% 50%\"   centred, the default, same as leaving it empty\n//   \"top\"       flush to the top of the frame\n//\n// A FILLED `Tile focus` SILENTLY BEATS THE FOCAL POINT BUTTON. If someone\n// drags the point and nothing moves, that field is not empty. `data-focus` on\n// the anchor prints the value that actually won, so read it off the published\n// page rather than guessing which of the two is in charge.\n//\n// `data-imgkeys` STAYS SHIPPED. It is the tripwire that tells you what Framer\n// handed over, and it is the only reason the mistake above was caught.\n//\n// ONLY THE SECOND NUMBER DOES ANYTHING ON A PORTRAIT SOURCE. Because cover\n// scales to the box width, the full width of a portrait image is always\n// visible and the horizontal value has nothing left to move. Keep writing\n// `50%` first for readability, but do not expect changing it to do anything\n// until a landscape source lands in a tile. THEY HAVE, 23 SEP: seven of the\n// first eight tile images are 3:2 to 2:1, so their sides are cropped and on\n// those it is the FIRST number that moves the crop. Dana Fuchs (2:1) loses a\n// third of its width.\n//\n// THE HOVER TIGHTENS THE CROP. `.aftile:hover img` applies `scale(1.04)`, so\n// about 2% comes off each edge when someone mouses over it. Leave a little\n// room when a subject sits near an edge.\n//\n// NO SECOND UPLOAD IS NEEDED FOR A TILE. A CMS image field accepts an\n// existing framerusercontent.com URL written straight into it, so a tile can\n// point at a file already uploaded as a gallery frame. One file, one upload,\n// two uses. Verified 14 September: Life Size's tile is frame 02.\n//\n// A FOCAL POINT BELONGS TO THE FIELD, NOT TO THE FILE. The same file sitting\n// in two different Tile image fields carries two independent focal points, so\n// setting one on a project does not disturb any other project using the same\n// photograph.\n// ---------------------------------------------------------------------------\n// CSS IS RENDERED INLINE, NOT INJECTED, 7 OCT. It used to be added to <head>\n// from a useEffect, and an effect only runs after the page has painted, so\n// every page load showed the bare HTML for a split second: blue underlined\n// links, grey default buttons, gallery frame numbers as bubbles. Adam saw it\n// on every page change. A <style> rendered as the first child of the root is\n// in Framer's server HTML, ahead of the markup it styles, so it is there on\n// the first paint. dangerouslySetInnerHTML, never {CSS} as a child: React\n// escapes text, which would turn every `>` combinator into &gt;.\nconst CSS=`\n.aftile{position:relative;display:block;width:100%;aspect-ratio:4/3;overflow:hidden;background:#1d1e1a;cursor:pointer;text-decoration:none;font-family:Poppins,-apple-system,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;container-type:inline-size}\n.aftile picture{display:block;width:100%;height:100%}\n.aftile img{display:block;width:100%;height:100%;object-fit:cover;transition:transform .6s cubic-bezier(.2,.7,.3,1),filter .35s}\n.aftile:hover img{transform:scale(1.04);filter:brightness(.44)}\n.aftile:focus-within img{transform:scale(1.04);filter:brightness(.38)}\n.aftile.noimg{background:repeating-linear-gradient(135deg,#1d1e1a,#1d1e1a 12px,#202117 12px,#202117 24px)}\n.aftile .meta{position:absolute;inset:auto 0 0 0;z-index:2;padding:38px 16px 13px;pointer-events:none;text-shadow:0 0 2px rgba(0,0,0,.9),0 1px 6px rgba(0,0,0,.75),0 0 18px rgba(0,0,0,.6),0 0 36px rgba(0,0,0,.4)}\n.aftile .c{display:block;font-size:16px;line-height:1.2;font-weight:600;color:#fff}\n.aftile .fm{display:block;margin-top:4px;font-size:11px;font-weight:600;letter-spacing:1.7px;text-transform:uppercase;color:#dfe6c3}\n.aftile .p{display:none;margin-top:3px;font-size:12.5px;color:#eef2dc}\n.aftile .r{display:none;margin-top:7px;font-size:11px;font-weight:600;letter-spacing:1.8px;text-transform:uppercase;color:#EEF7CA}\n.aftile .read{display:none;margin-top:9px;padding-top:9px;border-top:1px solid rgba(238,247,202,.3);font-size:10px;font-weight:700;letter-spacing:1.7px;text-transform:uppercase;color:#EEF7CA}\n.aftile:hover .p,.aftile:hover .r,.aftile:hover .read,\n.aftile:focus-within .p,.aftile:focus-within .r,.aftile:focus-within .read{display:block}\n.aftile .story{position:absolute;top:10px;right:10px;z-index:2;padding:4px 9px;border-radius:2px;background:#EEF7CA;color:#151516;font-size:10px;font-weight:700;letter-spacing:1.7px;text-transform:uppercase}\n.aftile:focus-visible{outline:none}\n.aftile:focus-visible::after{content:\"\";position:absolute;inset:0;z-index:6;border:3px solid #EEF7CA;pointer-events:none}\n@media (prefers-reduced-motion:reduce){.aftile img{transition:none}.aftile:hover img,.aftile:focus-within img{transform:none}}\n@container (max-width:270px){\n.aftile .c{font-size:12.5px}\n.aftile .fm{margin-top:3px;font-size:10.5px;letter-spacing:1.3px}\n.aftile .meta{padding:26px 11px 9px}\n.aftile .story{top:7px;right:7px;padding:3px 7px;font-size:10px;letter-spacing:1.3px}\n}\n@container (min-width:300px){\n.aftile .c{font-size:17px}\n.aftile .meta{padding:44px 18px 15px}\n}\n@container (max-width:200px){\n.aftile .p,.aftile .r{display:none!important}\n}\n`;// ---------------------------------------------------------------------------\n// THE RESPONSIVE GRID LIVES HERE, AND HERE IS WHY.\n//\n// Both project grids — Home's twelve and /work's fifty-one — are Framer canvas\n// grids bound to the Projects collection. A Framer canvas grid takes ONE fixed\n// integer column count. It has no media queries and no breakpoint of its own,\n// so until this stylesheet existed the grids never reflowed: Home stayed three\n// across at every width and /work stayed four, which at 390px produced 82px\n// and 60px thumbnails. Adam, 27 Aug: \"they always stay exactly perfectly three\n// rows of four as I shrink the page, which I don't think is good.\"\n//\n// TWO WAYS TO FIX IT, AND WHY THIS ONE.\n//   1. Framer breakpoint frames. The native answer. It also means duplicating\n//      every page's canvas two more times — eight pages, sixteen new frames —\n//      so that every future copy edit has to be made three times, all to give\n//      two grids a column count. The rest of this site is code components that\n//      are already fluid, so those extra frames would buy nothing else.\n//   2. This: a media-query stylesheet that overrides the two grids. One file,\n//      no canvas duplication, and it is the same CSS the prototype used\n//      (variants-v166.html: `.sb .grid{repeat(3,1fr)}`, `.wk .grid\n//      {repeat(4,1fr)}`, both `repeat(2,1fr)` under 900px).\n//\n// THE SELECTORS DEPEND ON TWO FRAME NAMES. Framer writes a frame's Layers name\n// into the DOM as `data-framer-name`, and that is the only stable hook the\n// canvas gives us — the class names are content hashes and change on any edit.\n// So:\n//\n//     Home  → the frame named  \"Selected work grid\"   (page /, id EXQB1r4zb)\n//     Work  → the frame named  \"Content\"              (page /work, id rmsqEGqHD)\n//\n// RENAME EITHER FRAME AND THE GRID STOPS REFLOWING. Nothing will error; it\n// will just quietly go back to a fixed column count. If you rename one, change\n// the matching selector below in the same edit. The image widths above also\n// read these two names to tell the grids apart.\n//\n// \"Content\" is a generic name, so it is qualified with `:has(a.aftile)` — it\n// only matches a frame named Content that actually contains project tiles.\n//\n// `div:has(> div > div > a.aftile)` is the grid element itself. The chain is\n//     grid  >  cell wrapper  >  component container  >  a.aftile\n// Verified on the published site 27 Aug: exactly one match per page, and\n// getComputedStyle(match).display === \"grid\" on both.\n//\n// THE GUTTERS. Both wrappers were on a fixed 64px horizontal padding while\n// every other block on the site uses clamp(22px,3.4vw,64px). Identical at\n// desktop, 42px out at phone — the grid sat further in than the text above it.\n// The clamp below is the same one in HomeTop, SiteHeader, SiteFooter and\n// ContactTop, so the tiles now line up with the text column at every width.\n// Measured live after the change: tile left edge === h2 left edge at 390, 500,\n// 800, 1000 and 1400. Framer frames only accept pixel padding, which is why\n// this is CSS and not a canvas value.\n//\n// THE /work GRID HAD NO FLOOR, 23 SEP. Home's grid frame carries its own\n// padding-bottom; /work's \"Content\" frame carried none, so the last row of\n// tiles sat 20px above the footer's hairline at every width, that 20px being\n// only the footer's own margin. Measured at 390, 768 and 1440. The comp gave\n// dark content 40–58px before the footer line. Content now carries\n// clamp(26px,2.6vw,38px), which with the footer's 20px lands on 46px at a\n// phone and 57px at 1440. No other page has a frame named Content holding\n// tiles; checked on Home, where the selector matches nothing.\n//\n// THE STEPS.\n//     Home   3 → 2 at 900 → 1 at 560     twelve tiles, presentation\n//     Work   4 → 3 at 1150 → 2 at 700    fifty-one tiles, archive\n// Home goes to one across on a phone on purpose: Home is the showcase and the\n// tiles should be big. /work stops at two because scrolling fifty-one tiles\n// one at a time is not an archive.\n//\n// `!important` is load-bearing. Framer writes its grid-template-columns and\n// padding inline on the element, so nothing without it wins.\n// ---------------------------------------------------------------------------\nconst GRID_CSS=`\n[data-framer-name=\"Selected work grid\"],\n[data-framer-name=\"Content\"]:has(a.aftile){padding-left:clamp(22px,3.4vw,64px)!important;padding-right:clamp(22px,3.4vw,64px)!important}\n[data-framer-name=\"Selected work grid\"]{padding-bottom:clamp(46px,6vw,84px)!important}\n[data-framer-name=\"Content\"]:has(a.aftile){padding-bottom:clamp(26px,2.6vw,38px)!important}\n@media (max-width:1150px){\n[data-framer-name=\"Content\"]:has(a.aftile) div:has(> div > div > a.aftile){grid-template-columns:repeat(3,minmax(0,1fr))!important}\n}\n@media (max-width:900px){\n[data-framer-name=\"Selected work grid\"] div:has(> div > div > a.aftile){grid-template-columns:repeat(2,minmax(0,1fr))!important}\n}\n@media (max-width:700px){\n[data-framer-name=\"Content\"]:has(a.aftile) div:has(> div > div > a.aftile){grid-template-columns:repeat(2,minmax(0,1fr))!important}\n}\n@media (max-width:560px){\n[data-framer-name=\"Selected work grid\"] div:has(> div > div > a.aftile){grid-template-columns:minmax(0,1fr)!important}\n[data-framer-name=\"Selected work grid\"] .aftile .r{display:block}\n}\n`;// Every tile carries both sheets, so /work with 37 tiles carries 37 copies.\n// That is about 3.5KB each before compression and almost nothing after it,\n// since the copies are identical. It is the price of every tile being styled\n// on its own first paint wherever it is placed; there is no reliable way for\n// one tile to know during the server render that another has already printed\n// it.\nconst TILE_CSS=CSS+GRID_CSS;// Tile width in CSS px per band. Read \"THE TILE IMAGE IS SIZED HERE\" above.\n// The last entry is the default, above 1150px.\nconst TILE_BANDS=[\"(max-width:440px)\",\"(max-width:560px)\",\"(max-width:700px)\",\"(max-width:900px)\",\"(max-width:1150px)\"];const TILE_WIDTHS={home:[400,520,330,420,360,480],work:[200,260,330,280,360,360]};const VOCAB=[\"motion\",\"stills\",\"commercial\",\"branded\",\"onset\",\"documentary\",\"narrative\",\"music\",\"series\",\"dance\",\"fashion\",\"beauty\",\"portrait\",\"product\",\"sport\",\"musicsubject\",\"movement\",\"lifestyle\",\"storysubject\"];// THE FIRST TILES LOAD AT ONCE, 7 OCT. Adam: yes to it. Every tile used to be\n// `loading=\"lazy\"`, which makes the browser hold back even the tiles on the\n// first screen until layout is done, and gives them low priority. The tiles a\n// visitor sees first now load straight away, and the very first one asks for\n// high priority.\n//\n// A TILE CANNOT KNOW ITS POSITION. Framer gives it no index, and working it\n// out after mount is too late for the server HTML. So the first tiles are\n// named here by slug, per grid, and the grid comes from the `Grid` control\n// (Work on /work, Auto on Home), which is the same on the server and the\n// client.\n//\n//     Home    the first 3 (one row at desktop, the first screen on a phone)\n//     /work   the first 4 (one row at desktop, two rows on a phone)\n//\n// UPDATE THESE WHEN THE ORDER OF HOME OR /work CHANGES. A stale list fails\n// safe: the named tiles still load early wherever they sit, and the new first\n// tiles simply load lazily, as every tile did before 7 Oct.\n// 8 OCT the first rows changed, and 9 OCT they went back to the launch\n// order (Adam's call), so this is the 7 Oct list again.\n// 12 OCT /work reordered (Adam approved the mockup): Maddman is 4th on\n// /work. Home's first 3 did not change.\nconst EAGER_FIRST={home:[\"victorias-secret\",\"bacardi-vmas\",\"the-shape-of-grace\"],work:[\"victorias-secret\",\"bacardi-vmas\",\"the-shape-of-grace\",\"maddman\"]};function slugOf(link){const m=String(link||\"\").match(/\\/work\\/([^/?#]+)/);return m?m[1]:\"\";}// The long edge to ask the CDN for, so the image covers a 4:3 tile `w` wide.\nfunction longEdge(w,pw,ph){if(!(pw>0)||!(ph>0))return Math.ceil(w*1.34);const s=Math.max(w/pw,.75*w/ph);return Math.ceil(Math.max(pw,ph)*s);}// IMAGES STOP AT 2x, 7 OCT. Adam: \"I like the site to be nice and nimble and\n// fast.\" A 3x file is more than twice the bytes of a 2x one and only 3x phones\n// ever asked for it, where a photograph at 2x is not visibly softer. Every\n// density set in this file now offers 1x and 2x only, and a 3x phone takes the\n// 2x file. Same rule as the gallery (ProjectGallery.tsx, \"PHONES GET\n// RIGHT-SIZED IMAGES\"). Measured on the published pages, the ≤440 band,\n// iPhone: the first six Home tiles 1163KB -> 571KB, the first eight /work\n// tiles 498KB -> 256KB.\n//\n// 1x / 2x for one tile width. Undefined for anything off Framer's CDN.\nfunction tileSet(src,w,pw,ph){const u=String(src||\"\");if(u.indexOf(\"framerusercontent.com\")<0)return undefined;const parts=u.split(\"?\");const base=parts[0];const q=(parts[1]||\"\").split(\"&\").filter(p=>p&&p.indexOf(\"scale-down-to=\")!==0).join(\"&\");const long=Math.max(pw||0,ph||0);return[1,2].map(x=>{const e=longEdge(w*x,pw,ph);if(long&&e>=long)return`${base}${q?\"?\"+q:\"\"} ${x}x`;return`${base}?scale-down-to=${e}${q?\"&\"+q:\"\"} ${x}x`;}).join(\", \");}// Empty, whitespace, or anything that is not a plain CSS object-position value\n// falls back to undefined, which lets the focal point take over. This is\n// deliberately permissive rather than a parser: the field is typed by hand in\n// the CMS and a typo should give a sensible tile, never a broken one.\nfunction focusValue(raw){const s=String(raw||\"\").trim();if(!s)return undefined;if(!/^[a-z0-9%.\\s-]+$/i.test(s))return undefined;return s;}// One axis of a focal point. Framer sends percentage strings today. The number\n// and keyword branches are tolerance, not speculation — they cost nothing and\n// they mean a format change does not silently centre every tile on the site.\nfunction axis(v){if(v===null||v===undefined||v===\"\")return undefined;if(typeof v===\"number\"&&isFinite(v)){if(v>=0&&v<=1)return(v*100).toFixed(1).replace(/\\.0$/,\"\")+\"%\";return undefined;}const s=String(v).trim();if(/^-?[\\d.]+%$/.test(s))return s;if(/^(left|right|top|bottom|center|centre)$/i.test(s))return s.toLowerCase();if(/^[\\d.]+$/.test(s)){const n=parseFloat(s);if(n>=0&&n<=1)return(n*100).toFixed(1).replace(/\\.0$/,\"\")+\"%\";}return undefined;}// Framer's focal point, read off the image object. Returns undefined when no\n// focal point has been set on that image, because Framer omits the keys\n// entirely in that case — see the header. `positionX`/`positionY` are the\n// live spellings; the other two are read in case they are renamed.\nfunction framerFocus(img){if(!img||typeof img!==\"object\")return undefined;const x=axis(img.positionX??img.focalPointX??img.objectPositionX);const y=axis(img.positionY??img.focalPointY??img.objectPositionY);if(!x&&!y)return undefined;return(x||\"50%\")+\" \"+(y||\"50%\");}/**\n * One project tile.\n *\n * @framerSupportedLayoutWidth any\n * @framerSupportedLayoutHeight any\n */export default function ProjectTile(props){// Which grid this tile sits in, read once after mount, for tiles whose\n// Grid control is left on Auto. Home until then, because Home's tiles are\n// the larger in every band.\nconst ref=useRef(null);const[grid,setGrid]=useState(\"\");useEffect(()=>{const el=ref.current;if(!el||!el.closest)return;if(el.closest('[data-framer-name=\"Selected work grid\"]'))setGrid(\"home\");else if(el.closest('[data-framer-name=\"Content\"]'))setGrid(\"work\");},[]);const set=new Set(String(props.tags||\"\").toLowerCase().split(/[\\s,]+/).filter(Boolean));const hasStory=set.has(\"hasstory\");const showInGrid=set.has(\"showingrid\");const data={\"data-tile\":\"1\"};for(const k of VOCAB)data[\"data-\"+k]=set.has(k)?\"1\":\"0\";data[\"data-showingrid\"]=showInGrid?\"1\":\"0\";data[\"data-hasstory\"]=hasStory?\"1\":\"0\";const img=props.image;const src=img?.src||null;const alt=props.alt||img?.alt||\"\";// Typed field wins, then Framer's focal point, then centred.\nconst focus=focusValue(props.focus)||framerFocus(img);// Tripwire. Prints the keys Framer actually put on the image object, so a\n// platform change can be read off the published page instead of guessed\n// at. With a focal point set it reads:\n//   src,positionX,positionY,srcSet,pixelWidth,pixelHeight\n// Without one, the two position keys are simply absent.\nif(img&&typeof img===\"object\"){data[\"data-imgkeys\"]=Object.keys(img).join(\",\");}// The value that actually won, whichever source it came from.\nif(focus)data[\"data-focus\"]=focus;const pw=Number(img?.pixelWidth)||0;const ph=Number(img?.pixelHeight)||0;const forced=props.grid===\"home\"||props.grid===\"work\"?props.grid:\"\";const which=forced||grid;const widths=TILE_WIDTHS[which]||TILE_WIDTHS.home;data[\"data-grid\"]=which||\"auto\";const defaultSet=src?tileSet(src,widths[TILE_BANDS.length],pw,ph):undefined;// Read \"THE FIRST TILES LOAD AT ONCE\". `forced`, not `grid`: the server\n// render has to make the same call as the client.\nconst firstList=EAGER_FIRST[forced===\"work\"?\"work\":\"home\"];const rank=firstList.indexOf(slugOf(props.link));const priority=rank===0?{fetchpriority:\"high\"}:{};return /*#__PURE__*/_jsxs(\"a\",{ref:ref,href:props.link||\"#\",className:\"aftile\"+(src?\"\":\" noimg\"),\"aria-label\":props.title,...data,children:[/*#__PURE__*/_jsx(\"style\",{dangerouslySetInnerHTML:{__html:TILE_CSS}}),src?/*#__PURE__*/_jsxs(\"picture\",{children:[defaultSet?TILE_BANDS.map((media,i)=>/*#__PURE__*/_jsx(\"source\",{media:media,srcSet:tileSet(src,widths[i],pw,ph)},media)):null,/*#__PURE__*/_jsx(\"img\",{src:src,srcSet:defaultSet||img?.srcSet,alt:alt,loading:rank>=0?\"eager\":\"lazy\",decoding:\"async\",...priority,style:focus?{objectPosition:focus}:undefined})]}):null,hasStory?/*#__PURE__*/_jsx(\"span\",{className:\"story\",children:\"Story\"}):null,/*#__PURE__*/_jsxs(\"div\",{className:\"meta\",children:[/*#__PURE__*/_jsx(\"span\",{className:\"c\",children:props.title}),props.formatLabel?/*#__PURE__*/_jsx(\"span\",{className:\"fm\",children:props.formatLabel}):null,props.workLabel?/*#__PURE__*/_jsx(\"span\",{className:\"p\",children:props.workLabel}):null,props.role?/*#__PURE__*/_jsx(\"span\",{className:\"r\",children:props.role}):null,hasStory?/*#__PURE__*/_jsx(\"span\",{className:\"read\",children:\"Read the story →\"}):null]})]});}ProjectTile.defaultProps={title:\"Project title\",role:\"\",formatLabel:\"Motion\",workLabel:\"Commercial\",tags:\"motion commercial showingrid\",alt:\"\",focus:\"\",grid:\"auto\"};addPropertyControls(ProjectTile,{title:{type:ControlType.String,title:\"Title\"},role:{type:ControlType.String,title:\"Role\"},formatLabel:{type:ControlType.String,title:\"Format label\"},workLabel:{type:ControlType.String,title:\"Work label\"},tags:{type:ControlType.String,title:\"Tags\",description:\"Bind to Tags (generated). Space separated.\"},image:{type:ControlType.ResponsiveImage,title:\"Image\"},alt:{type:ControlType.String,title:\"Alt text\"},focus:{type:ControlType.String,title:\"Tile focus\",description:\"Bind to Tile focus. Optional override. Leave it EMPTY to use the focal point set on the image in the CMS, which is the normal way to crop a tile. Fill it to force a CSS object-position, e.g. 50% 4%, and it beats the focal point.\"},link:{type:ControlType.Link,title:\"Link\"},grid:{type:ControlType.Enum,title:\"Grid\",options:[\"auto\",\"home\",\"work\"],optionTitles:[\"Auto\",\"Home\",\"Work\"],displaySegmentedControl:true,description:\"Which grid this tile sits in, so it downloads the right image size on first load. Work on the /work tiles, Auto everywhere else.\"}});\nexport const __FramerMetadata__ = {\"exports\":{\"default\":{\"type\":\"reactComponent\",\"name\":\"ProjectTile\",\"slots\":[],\"annotations\":{\"framerSupportedLayoutHeight\":\"any\",\"framerContractVersion\":\"1\",\"framerSupportedLayoutWidth\":\"any\"}},\"__FramerMetadata__\":{\"type\":\"variable\"}}}\n//# sourceMappingURL=./ProjectTile.map"],"mappings":"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"}