<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: yu chen</title>
    <description>The latest articles on DEV Community by yu chen (@yu_chen_883b789d9824afb83).</description>
    <link>https://dev.to/yu_chen_883b789d9824afb83</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4183426%2Ff41438d7-8414-470b-87b6-c12fdd53fa68.png</url>
      <title>DEV Community: yu chen</title>
      <link>https://dev.to/yu_chen_883b789d9824afb83</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yu_chen_883b789d9824afb83"/>
    <language>en</language>
    <item>
      <title>One Variant, One Image: Why Shopify's Image Table Binds Exactly One Photo per Variant</title>
      <dc:creator>yu chen</dc:creator>
      <pubDate>Mon, 09 Nov 2026 02:07:35 +0000</pubDate>
      <link>https://dev.to/yu_chen_883b789d9824afb83/one-variant-one-image-why-shopifys-image-table-binds-exactly-one-photo-per-variant-hlm</link>
      <guid>https://dev.to/yu_chen_883b789d9824afb83/one-variant-one-image-why-shopifys-image-table-binds-exactly-one-photo-per-variant-hlm</guid>
      <description>&lt;p&gt;&lt;em&gt;By EasyCatch Growth Desk — weekly technical deep-dive for the Shopify community. Written 2026-11-09.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;A merchant on the Tinker theme posted last week that his garments carry two or more photos per variant, yet the gallery &lt;a href="https://community.shopify.com/t/709119" rel="noopener noreferrer"&gt;keeps downloading 20+ images from variants the shopper never selected&lt;/a&gt; — "it's killing my performance." The first reply from Mustafa_Ali is the sentence this whole article unpacks: "Shopify only ties one featured image to each variant, so the 'hide other variants' media' toggle hides down to that one image and leaves the rest of the gallery alone. Showing a full set of images per variant isn't in the data model, so it comes down to the theme." A second, much busier thread — &lt;a href="https://community.shopify.com/t/622024" rel="noopener noreferrer"&gt;how long processing new product photos actually takes you&lt;/a&gt;, where an apparel merchant puts a 30-SKU drop at four to five hours and names the bottleneck precisely: "renaming 90 files by SKU, then uploading each one to the right variant … that part alone takes 2 hours per drop."&lt;/p&gt;

&lt;p&gt;Both threads describe the same constraint from opposite ends. The images table is not a photo album bolted onto a product; it is a join table whose binding column holds exactly &lt;strong&gt;one&lt;/strong&gt; value per variant row. That cardinality is the reason the toggle cannot do what merchants expect, the reason alt-text matching became a cottage industry, and the reason a "successful" import can still ship the wrong photograph. This is a schema fact, not a theme bug and not an operator error. Our earlier piece on &lt;a href="https://easycatch.org/guides/four-supplier-attributes-and-three-option-slots" rel="noopener noreferrer"&gt;collapsed option slots&lt;/a&gt; covered what happens when attributes outnumber the three option columns; this one starts one step later, at the moment a converted file has to become a specific variant's image.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The images table is a join table with a single slot
&lt;/h2&gt;

&lt;p&gt;A Shopify product CSV looks like one flat file. It is three tables interleaved, and each one answers a different question:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;products&lt;/strong&gt; — one row per product, keyed by &lt;code&gt;Handle&lt;/code&gt;. Carries &lt;code&gt;Title&lt;/code&gt;, &lt;code&gt;Body (HTML)&lt;/code&gt;, &lt;code&gt;Tags&lt;/code&gt;, and the option &lt;em&gt;declarations&lt;/em&gt; (&lt;code&gt;Option1 Name&lt;/code&gt;, &lt;code&gt;Option2 Name&lt;/code&gt;, &lt;code&gt;Option3 Name&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;variants&lt;/strong&gt; — one row per purchasable unit, repeating the same &lt;code&gt;Handle&lt;/code&gt;, supplying &lt;code&gt;Option1 Value&lt;/code&gt; … &lt;code&gt;Option3 Value&lt;/code&gt;, &lt;code&gt;Variant SKU&lt;/code&gt;, &lt;code&gt;Variant Price&lt;/code&gt;, &lt;code&gt;Variant Grams&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;images&lt;/strong&gt; — separate rows carrying &lt;code&gt;Image Src&lt;/code&gt;, &lt;code&gt;Image Position&lt;/code&gt;, &lt;code&gt;Alt Text&lt;/code&gt;, and one binding column: &lt;strong&gt;&lt;code&gt;Variant Image&lt;/code&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cardinality is asymmetric on purpose. A product can own an unlimited number of image rows. A variant row, however, exposes exactly one &lt;code&gt;Variant Image&lt;/code&gt; field — and that field does not take a list, a comma-separated string, or an index into a gallery. It takes a URL string that must match an &lt;code&gt;Image Src&lt;/code&gt; value elsewhere in the same file. When it matches, that media item becomes the variant's featured image: the one the product page swaps to when a shopper picks the colour.&lt;/p&gt;

&lt;p&gt;Do the arithmetic on the merchant's catalogue. Six colours, five sizes, three photos per colour: 30 variant rows, 18 image rows, and 30 bindings — of which each variant can point at only one of its colour's three photos. The other 12 photos are product-level media with no variant anchor, so they are eligible to appear in the gallery for every selection. Shopify's "hide other variants' media" setting hides precisely the images &lt;em&gt;bound&lt;/em&gt; to other variants; the unbound majority stays visible, and the browser downloads it. That is the thread's complaint in mechanical form, and it is also why the answer was "this comes down to the theme."&lt;/p&gt;

&lt;h2&gt;
  
  
  2. What the browser actually does to a WebP file
&lt;/h2&gt;

&lt;p&gt;Supplier catalogues from domestic wholesale marketplaces hand you WebP far more often than JPEG. The standard route is a canvas transcode inside the browser: draw the decoded image onto a canvas, then &lt;code&gt;canvas.toDataURL('image/jpeg', quality)&lt;/code&gt; and upload the resulting bytes. It works, and it is lossy in four ways that matter for apparel:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Alpha is not representable.&lt;/strong&gt; JPEG has no alpha channel. If the canvas is transparent when you draw a cut-out garment, the transparent pixels composite against black by default, and you get a garment with a charcoal halo. Filling the canvas with white (&lt;code&gt;fillStyle = '#fff'&lt;/code&gt;, &lt;code&gt;fillRect&lt;/code&gt; over the full area) before &lt;code&gt;drawImage&lt;/code&gt; is not cosmetic — it is the difference between a usable product photo and a rejected one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chroma subsampling and 8-bit rounding.&lt;/strong&gt; JPEG encoders typically subsample chroma at 4:2:0, which is invisible on fabric but very visible on thin print detail and on saturated reds and blues, where the hue shifts by a few degrees. At &lt;code&gt;quality = 0.92&lt;/code&gt; this is acceptable; at &lt;code&gt;0.7&lt;/code&gt; a printed pattern dissolves into mush.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Colour space is assumed, not measured.&lt;/strong&gt; Canvas composites in sRGB. A supplier file tagged Display P3 or Adobe RGB gets reinterpreted without conversion, and saturated colours come out flatter than the original. For solid colours this is tolerable; for brand-accurate textiles it is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata is dropped, including orientation.&lt;/strong&gt; EXIF disappears in the transcode. A WebP that relied on an orientation flag becomes a sideways JPEG — and it will look sideways on the product page because there is no longer a tag telling the browser to rotate it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is a fifth trap that is not about pixels at all. Wholesale CDNs frequently serve WebP behind a URL that ends in &lt;code&gt;.jpg&lt;/code&gt;, rewritten by a query string such as &lt;code&gt;?imageMogr2/format/webp&lt;/code&gt; or &lt;code&gt;?x-oss-process=image/format,webp&lt;/code&gt;. Any pipeline that routes on the file extension — "it ends in .jpg, skip conversion" — will upload WebP bytes under a JPEG name. Shopify's media pipeline sniffs the actual content, and when the sniffed type does not match what its media processor accepts, the row imports cleanly and the media slot stays empty. No error, no warning, upload success. This is the same silent-drop class that made a name for itself in our &lt;a href="https://easycatch.org/guides/shopify-cdn-image-auto-sniffing-guide" rel="noopener noreferrer"&gt;CDN image guide&lt;/a&gt;, and it is why a Local Canvas Transpiler that decodes and re-encodes locally, rather than trusting the extension, produces a materially different failure rate. The first bytes of a real JPEG are &lt;code&gt;FF D8 FF&lt;/code&gt;; if your "JPEG" does not start there, it is not one.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Cartesian expansion and the per-row repetition rule
&lt;/h2&gt;

&lt;p&gt;Colour times size is where the images table stops being abstract. Six colours and five sizes expand to 30 variant rows, each representing one purchasable unit, each repeating &lt;code&gt;Handle&lt;/code&gt; and the identical &lt;code&gt;Option1 Name&lt;/code&gt; / &lt;code&gt;Option2 Name&lt;/code&gt; declarations from the product row. Two rules govern the image columns during that expansion, and both are easy to get subtly wrong:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule one: repeat the binding, not the URL.&lt;/strong&gt; If each colour has its own photo, the &lt;code&gt;Image Src&lt;/code&gt; URL for that colour appears on &lt;strong&gt;one&lt;/strong&gt; image row, and the same URL string appears as &lt;code&gt;Variant Image&lt;/code&gt; on &lt;strong&gt;each of the five variant rows&lt;/strong&gt; for that colour. One media item, five bindings. The common mistake is the reverse: copying the image row five times, once per size. That creates five duplicate media items of the same photograph, which bloats the gallery to 30 items, gives the theme five candidates for "featured", and makes position ordering meaningless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule two: set the binding on every row, not on the first row of the group.&lt;/strong&gt; If you write &lt;code&gt;Variant Image&lt;/code&gt; only on the first variant row of each colour block, the remaining 24 rows in a 30-variant product carry an empty binding. The storefront does not error — it falls back to the product's primary image for those variants, so a shopper selecting "Navy, XL" sees the white version. This is the mechanism behind most "wrong colour shipped" complaints, and it survives every re-import, because the emptiness is preserved in the file.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Image Position&lt;/code&gt; is the third piece. It defines gallery order, and the lowest position of a product's images is what shoppers and themes treat as the primary. Positions must be unique within a product; duplicate or missing positions leave ordering to the importer's discretion, so the same file can produce a different gallery on two stores. And note the interaction with rule one: one bound image per variant plus a deterministic position order is the only combination that yields a gallery a theme can filter reliably — which is exactly the workaround the Tinker thread ended up recommending, via alt text, because the data model cannot express "three photos belong to the Navy variant."&lt;/p&gt;

&lt;p&gt;A Matrixify-compliant ZIP produced by an expansion engine solves this by construction rather than by discipline: the variant rows are generated from the colour-by-size matrix, every generated row inherits the colour's &lt;code&gt;Image Src&lt;/code&gt; as its &lt;code&gt;Variant Image&lt;/code&gt;, and positions are assigned sequentially per product, so the binding count equals the variant count by definition. That is checkable after the fact, which matters more than elegance.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Why alt-text matching is a workaround, not a fix
&lt;/h2&gt;

&lt;p&gt;The Tinker thread converged on tagging each image's alt text with the colour name, then filtering the gallery in Liquid. It works, and it is the cheapest path when a merchant has two hundred products live and no appetite for a re-import. Its costs are worth stating plainly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Alt Text&lt;/code&gt; lives on the &lt;strong&gt;image row&lt;/strong&gt;, so it is shared by every variant bound to that image. You cannot store three alt texts for one photo. If one photo serves two variants, the tagging scheme has to assume one photo belongs to one variant only — which is often false.&lt;/li&gt;
&lt;li&gt;Alt text is free-form. Any later re-import that carries an empty &lt;code&gt;Alt Text&lt;/code&gt; column overwrites the tags, and the gallery silently reverts to showing everything.&lt;/li&gt;
&lt;li&gt;Filename and slug matching break when a handle changes; handles change when a title is edited, and nothing warns you.&lt;/li&gt;
&lt;li&gt;CSS hiding does not stop downloads. The merchant's performance complaint is specifically about bytes over the wire, which is why the thread kept coming back to "the browser could still download them."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The durable fix is upstream: give each variant its own file per view, encode the view in the filename and the position, and let the theme map deterministically instead of guessing. That is a file-naming and CSV-generation problem, which is the layer a 100% Local-First exporter controls — not a Liquid problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A pre-flight you can count, not eyeball
&lt;/h2&gt;

&lt;p&gt;Before any import, these four counts answer whether the file is structurally sound. They are cheap, deterministic, and they catch every failure mode above:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Variant rows&lt;/strong&gt; should equal colours multiplied by sizes (plus any legitimate one-off rows). If the count is lower, part of the matrix collapsed during generation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distinct &lt;code&gt;Image Src&lt;/code&gt; values&lt;/strong&gt; should equal the number of gallery photos you intend, not the number of times a URL is mentioned. Duplicates inflate the gallery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-empty &lt;code&gt;Variant Image&lt;/code&gt; rows&lt;/strong&gt; should equal variant rows that have their own photo. Anything less means those variants will fall back to the product's primary image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transcode sanity&lt;/strong&gt;: output bytes at least 60 percent of the source, pixel dimensions identical to the source, alpha flattened against white, and the first three bytes equal to &lt;code&gt;FF D8 FF&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Run those four and the "import succeeded but the photo is wrong" class disappears. Skip them and the storefront becomes the test harness — with customers as the assertion library.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The Tinker merchant was not fighting a theme setting. He was fighting a join table that holds one binding per variant, and the "hide other variants' media" toggle simply cannot express what he wanted. The same asymmetry explains the four-hour photo drop: the renaming and matching loop exists because humans are manually creating the one-to-one binding that the schema requires.&lt;/p&gt;

&lt;p&gt;Five things to check, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Count variant rows against the colour-by-size matrix before importing.&lt;/li&gt;
&lt;li&gt;Count distinct &lt;code&gt;Image Src&lt;/code&gt; values against the photos you actually want in the gallery.&lt;/li&gt;
&lt;li&gt;Ensure every variant row that has its own photo carries a non-empty &lt;code&gt;Variant Image&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Give every image a unique &lt;code&gt;Image Position&lt;/code&gt; so the primary is deterministic.&lt;/li&gt;
&lt;li&gt;Verify each converted file is genuinely JPEG (&lt;code&gt;FF D8 FF&lt;/code&gt;), full-size, alpha-flattened, and white-matted.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do those five and the gallery stops being a lottery — on Tinker, on Dawn, and on whatever theme you migrate to next.&lt;/p&gt;

&lt;h2&gt;
  
  
  About EasyCatch
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://easycatch.org" rel="noopener noreferrer"&gt;EasyCatch&lt;/a&gt; is a 100% Local-First copilot for 1688-to-Shopify sourcing. Its &lt;strong&gt;Local Canvas Transpiler&lt;/strong&gt; converts supplier WebP images to JPEG entirely inside your browser — decoding first, re-encoding second, so a mislabeled CDN URL cannot slip a WebP past the import — and its CSV engine expands colour-by-size variants into &lt;strong&gt;Matrixify&lt;/strong&gt;-compliant ZIPs with the per-row &lt;code&gt;Variant Image&lt;/code&gt; binding already repeated on every generated row. No supplier link, cost price, or SKU structure ever leaves your machine. &lt;strong&gt;Zero-Server Privacy&lt;/strong&gt; by design.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>webdev</category>
    </item>
    <item>
      <title>An @e1 Is a Handle, Not a Position: How Operun's Snapshot Keeps a Ref Meaning the Same Thing</title>
      <dc:creator>yu chen</dc:creator>
      <pubDate>Mon, 02 Nov 2026 02:02:47 +0000</pubDate>
      <link>https://dev.to/yu_chen_883b789d9824afb83/an-e1-is-a-handle-not-a-position-how-operuns-snapshot-keeps-a-ref-meaning-the-same-thing-a6h</link>
      <guid>https://dev.to/yu_chen_883b789d9824afb83/an-e1-is-a-handle-not-a-position-how-operuns-snapshot-keeps-a-ref-meaning-the-same-thing-a6h</guid>
      <description>&lt;p&gt;&lt;em&gt;By Chen Yu — building Operun, a local-first browser kernel. Written 2026-11-02.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;The previous guides in this series were about what a snapshot &lt;em&gt;reads&lt;/em&gt;: the three walls that hid&lt;br&gt;
text from an a11y snapshot, the 40,000-token budget, and the size of a captured HAR body. This one&lt;br&gt;
is about the other half of the contract — the &lt;code&gt;@eN&lt;/code&gt; handle itself.&lt;/p&gt;

&lt;p&gt;A snapshot line like &lt;code&gt;@e7: [button 'Pay now']&lt;/code&gt; is a promise: act on &lt;code&gt;@e7&lt;/code&gt; and you touch that button,&lt;br&gt;
not "the seventh thing on the page". That promise is only credible if three things hold. The number&lt;br&gt;
must be an identity rather than a position, so a cookie banner appearing above it does not renumber&lt;br&gt;
the page under a workflow's feet. The handle must be able to fail honestly, so a stale ref returns&lt;br&gt;
nothing instead of a detached node that reports SUCCESS for an event no page ever saw. And the handle&lt;br&gt;
must know where it stops, because a ref names a node inside one frame and carries no frame identity&lt;br&gt;
of its own.&lt;/p&gt;

&lt;p&gt;None of that is free. It costs two maps, one pruning pass per snapshot, a structural type check&lt;br&gt;
across realms, an exclusion list of things the kernel cannot actually click, and a refusal to accept&lt;br&gt;
&lt;code&gt;@e1 &amp;gt;&amp;gt;&amp;gt; #inner&lt;/code&gt; at all.&lt;/p&gt;
&lt;h2&gt;
  
  
  The failure a position would cause
&lt;/h2&gt;

&lt;p&gt;Number the controls by their order in the walk and everything works until the page changes. Then it&lt;br&gt;
does not merely get slower — it gets &lt;em&gt;quietly wrong&lt;/em&gt;. A consent banner mounts above the form, one&lt;br&gt;
table row is inserted, a "you have unsaved changes" toast pushes the layout down by forty pixels, and&lt;br&gt;
every ref the agent is still holding now names a different control. Nothing throws. The next click&lt;br&gt;
lands on the wrong button and the trace looks like the agent made a bad decision, when in fact the&lt;br&gt;
address book was rewritten.&lt;/p&gt;

&lt;p&gt;So the scanner assigns numbers by node identity, not by index. The in-page function keeps a&lt;br&gt;
&lt;code&gt;WeakMap&amp;lt;Element, string&amp;gt;&lt;/code&gt; called &lt;code&gt;byNode&lt;/code&gt; — "which ref is this node" — and a &lt;code&gt;Map&amp;lt;string, Element&amp;gt;&lt;/code&gt;&lt;br&gt;
called &lt;code&gt;byRef&lt;/code&gt; — "which node is &lt;code&gt;@e1&lt;/code&gt;". When a control is handed a ref the code asks &lt;code&gt;byNode&lt;/code&gt; first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;refFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Element&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Slot&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;known&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byNode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;known&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Re-register: the index may have pruned this node while it was detached,&lt;/span&gt;
    &lt;span class="c1"&gt;// and `byNode` is the authority on which ref the node owns.&lt;/span&gt;
    &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;known&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;known&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@e&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;
  &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byNode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in those ten lines matter more than the rest of the file. First, a node that already has a&lt;br&gt;
number keeps it forever — across re-renders, across being pulled out of the DOM and put back. Second,&lt;br&gt;
&lt;code&gt;next&lt;/code&gt; only ever increases and is never reused. A number that has been spent cannot come back as a&lt;br&gt;
different control later, which is what makes "a stale ref never resolves" a property rather than a&lt;br&gt;
hope.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two maps, because there are two questions
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;byRef&lt;/code&gt; and &lt;code&gt;byNode&lt;/code&gt; look redundant until you ask what each one is for. &lt;code&gt;byRef&lt;/code&gt; answers &lt;em&gt;which node is&lt;br&gt;
&lt;code&gt;@e1&lt;/code&gt;&lt;/em&gt; — that is what an action needs. &lt;code&gt;byNode&lt;/code&gt; answers &lt;em&gt;which ref is this node&lt;/em&gt; — that is what&lt;br&gt;
stability needs, and it is a &lt;code&gt;WeakMap&lt;/code&gt; on purpose: a node the page throws away stops being remembered&lt;br&gt;
without anything having to sweep it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;byRef&lt;/code&gt; is the opposite: a plain &lt;code&gt;Map&lt;/code&gt; holds its keys strongly and would pin every removed subtree&lt;br&gt;
for the life of the document. On a dashboard that re-renders a table every few seconds, that is a&lt;br&gt;
leak measured in megabytes per minute. So every snapshot starts with one pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isConnected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pruning costs nothing in correctness because a node that comes back — a reparented row, a virtualised&lt;br&gt;
list item re-mounted — is re-registered from &lt;code&gt;byNode&lt;/code&gt; on the next walk, getting its original number&lt;br&gt;
back. The index is a cache of the truth; &lt;code&gt;byNode&lt;/code&gt; is the truth.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where the cache lives, and who may rewrite it
&lt;/h2&gt;

&lt;p&gt;The cache cannot live in the kernel. In an MV3 world the kernel's home is a service worker that&lt;br&gt;
shares no memory with any page and that Chrome is free to tear down between two steps of the &lt;em&gt;same&lt;/em&gt;&lt;br&gt;
task — state kept there would be lost exactly when it is needed.&lt;/p&gt;

&lt;p&gt;The cache also cannot live in the page's own JavaScript. The primitives run in the extension's&lt;br&gt;
isolated world, which page script cannot see or write to. That is not paranoia for its own sake: a&lt;br&gt;
cache a page could edit is a cache a hostile page could repoint, and an &lt;code&gt;@e1&lt;/code&gt; that silently becomes a&lt;br&gt;
different control is worse than a ref that fails.&lt;/p&gt;

&lt;p&gt;So the cache is an object on the isolated world's &lt;code&gt;globalThis&lt;/code&gt;, under &lt;code&gt;__operunRefCache__&lt;/code&gt;, written by&lt;br&gt;
the snapshot function and read by the primitives that act on a ref. That lifetime is exactly right: it&lt;br&gt;
outlives a single injected call, because Chrome re-evaluates each function but not the world it runs&lt;br&gt;
in, and it dies with the document — and a navigation &lt;em&gt;is&lt;/em&gt; a different document, so refs from the&lt;br&gt;
previous page do not survive into the next one.&lt;/p&gt;

&lt;p&gt;Two consequences are worth spelling out. &lt;code&gt;chrome.scripting.executeScript&lt;/code&gt; serialises a function with&lt;br&gt;
&lt;code&gt;toString()&lt;/code&gt; and re-evaluates the source in the frame, so an injected function cannot import the cache&lt;br&gt;
module — the key and the lookup exist twice, once for kernel-side use and once as a literal inside the&lt;br&gt;
function, pinned together by a unit test. And the "is this our cache" check is structural rather than&lt;br&gt;
&lt;code&gt;instanceof Map&lt;/code&gt;, because a &lt;code&gt;Map&lt;/code&gt; constructed in another realm is a perfectly healthy &lt;code&gt;Map&lt;/code&gt; that this&lt;br&gt;
realm's &lt;code&gt;instanceof&lt;/code&gt; would reject as foreign. A cross-realm identity check would have turned a working&lt;br&gt;
cache into a mysterious "ref not found".&lt;/p&gt;
&lt;h2&gt;
  
  
  A ref that resolves to nothing beats a ref that lies
&lt;/h2&gt;

&lt;p&gt;Resolution is one line of judgement: a node either &lt;code&gt;isConnected&lt;/code&gt; or the ref resolves to &lt;code&gt;null&lt;/code&gt;. The&lt;br&gt;
comment in the source is blunt about why &lt;code&gt;null&lt;/code&gt; is the only acceptable failure mode — a detached node&lt;br&gt;
still has &lt;code&gt;click()&lt;/code&gt; and &lt;code&gt;dispatchEvent()&lt;/code&gt;, its events fire into nothing, and the step would report&lt;br&gt;
SUCCESS for a page that never saw it. A workflow that fails loudly can be debugged. A workflow that&lt;br&gt;
succeeds silently writes the wrong price into the wrong product.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;null&lt;/code&gt; deliberately does not distinguish "this ref was never handed out" from "the node left the&lt;br&gt;
document". Both mean the caller can do nothing different, so inventing two error codes would only give&lt;br&gt;
the caller more ways to be wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  Handles the kernel cannot pull
&lt;/h2&gt;

&lt;p&gt;A ref is a promise that something can be done with it, so the walk skips nodes where nothing can be&lt;br&gt;
done:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;&amp;lt;option&amp;gt;&lt;/code&gt; inside a native &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt;.&lt;/strong&gt; No primitive in the kernel can pick one by ref — the
platform opens its own popup, which no synthetic click reaches. The parent &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; reports the
currently chosen value instead. A custom listbox built from &lt;code&gt;[role="option"]&lt;/code&gt; elements &lt;em&gt;is&lt;/em&gt;
reported, because a click does reach those.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;input[type="hidden"]&lt;/code&gt;.&lt;/strong&gt; Excluded by what it is, not by how it is styled. It usually computes to
&lt;code&gt;display: none&lt;/code&gt; and would fail the visibility test anyway, but a page is free to lay one out, and it
would still not be a control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything under &lt;code&gt;[hidden]&lt;/code&gt;, &lt;code&gt;[aria-hidden="true"]&lt;/code&gt;, or &lt;code&gt;[inert]&lt;/code&gt;&lt;/strong&gt; — a single &lt;code&gt;closest()&lt;/code&gt; test.
&lt;code&gt;inert&lt;/code&gt; exists precisely to make a subtree unreachable; handing out a ref into it would be handing
out a handle to a node the platform refuses to route events to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invisible elements&lt;/strong&gt;, tested by walking ancestors for &lt;code&gt;display: none&lt;/code&gt;, &lt;code&gt;visibility: hidden&lt;/code&gt; or
&lt;code&gt;visibility: collapse&lt;/code&gt;. Same rule as the Tab order, so what the agent reads and what the keyboard
can reach cannot disagree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Empty prose blocks&lt;/strong&gt;, in content mode only. &lt;code&gt;&amp;lt;p&amp;gt;&amp;lt;/p&amp;gt;&lt;/code&gt; is scaffolding; an &lt;code&gt;@eN&lt;/code&gt; naming it is a
handle to nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The prose path is worth a note because it is where &lt;code&gt;role === null&lt;/code&gt; is handled rather than skipped.&lt;br&gt;
A declared role that is not a control (&lt;code&gt;role="heading"&lt;/code&gt;, &lt;code&gt;role="presentation"&lt;/code&gt;) is a statement about&lt;br&gt;
&lt;em&gt;interaction&lt;/em&gt;, and content mode is not asking about interaction — so &lt;code&gt;&amp;lt;h1 role="heading"&amp;gt;&lt;/code&gt; still gets&lt;br&gt;
its text reported. That single branch is the difference between a content snapshot of a documentation&lt;br&gt;
page and a content snapshot with a hole in it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where a ref stops: the frame boundary
&lt;/h2&gt;

&lt;p&gt;Cross-origin iframes are the classic blind spot. A mailbox's login form lives in a frame the&lt;br&gt;
same-origin policy keeps a top-level &lt;code&gt;querySelector&lt;/code&gt; from entering, and no amount of &lt;code&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt; changes&lt;br&gt;
that: the piercing chain is a CSS selector per frame walking inwards, and the browser still enforces&lt;br&gt;
the origin boundary at every hop.&lt;/p&gt;

&lt;p&gt;Operun's answer is not to pierce harder but to ask every frame at once.&lt;br&gt;
&lt;code&gt;executeScript&lt;/code&gt; with &lt;code&gt;allFrames: true&lt;/code&gt; returns one result per frame, each carrying the &lt;code&gt;frameId&lt;/code&gt; the&lt;br&gt;
&lt;em&gt;next&lt;/em&gt; step must name to act inside it, and those results are merged into one text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== Frame: 0 (Top: https://mail.example.com) ===
@e1: [button 'Compose']
=== Frame: 849201 (https://login.example.net/signin) ===
@e1: [textbox 'Email'] [frame_id=849201]
@e2: [textbox 'Password'] [frame_id=849201]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that frame 849201 has its own &lt;code&gt;@e1&lt;/code&gt;. That is not a collision, because a ref's scope is the frame&lt;br&gt;
its snapshot ran in — the top frame is the default context, so only a frame an action &lt;em&gt;must&lt;/em&gt; name&lt;br&gt;
carries the &lt;code&gt;[frame_id=N]&lt;/code&gt; marker. This is also why &lt;code&gt;@e1 &amp;gt;&amp;gt;&amp;gt; #inner&lt;/code&gt; is rejected at the payload&lt;br&gt;
boundary rather than attempted: a reference already belongs to a frame and carries no frame identity of&lt;br&gt;
its own, so the chain has no meaning that resolves to one element. Refusing it keeps the ref cache's&lt;br&gt;
scope a single statable rule instead of one with a special case per position in the chain. Blank&lt;br&gt;
segments like &lt;code&gt;#frame &amp;gt;&amp;gt;&amp;gt;&lt;/code&gt; fail the same check — not a typo, an unanswerable question.&lt;/p&gt;

&lt;p&gt;Two smaller decisions in the merge are the kind that only show up in production. A frame that answered&lt;br&gt;
nothing — a blank ad iframe that navigated mid-injection, an origin the build is not granted — is&lt;br&gt;
skipped rather than thrown, because one mute frame must not cost you the other eleven. And the frames&lt;br&gt;
are sorted by id before merging, because &lt;code&gt;executeScript&lt;/code&gt; promises no ordering and the same page must&lt;br&gt;
read as the same text every time; an unstable snapshot is a cache-buster for every layer above it,&lt;br&gt;
including the token budget, where each line is charged &lt;code&gt;ceil(length / 3)&lt;/code&gt; against a 40,000-token&lt;br&gt;
allowance and the trailer &lt;code&gt;… N more elements omitted&lt;/code&gt; is the reader's only warning that what it holds&lt;br&gt;
is not the whole page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The interesting part of a browser kernel is not the click. It is everything that has to be true before&lt;br&gt;
the click is allowed to happen: a number that means a node and not a slot, an index that can be pruned&lt;br&gt;
without losing identity, a cache the page cannot rewrite, a failure mode that is a refusal instead of&lt;br&gt;
a lie, an exclusion list that admits what the platform will not let you touch, and a frame boundary&lt;br&gt;
that is respected rather than papered over.&lt;/p&gt;

&lt;p&gt;Each of those is a small piece of code. Together they are the difference between a workflow you can&lt;br&gt;
leave running and a workflow you have to watch.&lt;/p&gt;

&lt;h2&gt;
  
  
  About Operun
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://operun.app" rel="noopener noreferrer"&gt;Operun&lt;/a&gt; is a local-first browser kernel: a Chrome extension plus a runner that&lt;br&gt;
drives your own logged-in browser, with an a11y snapshot built for agents rather than for humans to&lt;br&gt;
read. Snapshots, refs and selectors are resolved in the page and in the isolated world, which is why&lt;br&gt;
the handle semantics above can be stated as rules instead of best-effort behaviour. The kernel keeps&lt;br&gt;
your session, your cookies and your pages on your machine; the cloud, when you use one, routes steps&lt;br&gt;
and never receives your page content by default. If you are building agents that touch real accounts,&lt;br&gt;
start with the a11y snapshot and the token budget, then read the selector contract — the handles are&lt;br&gt;
where reliability actually lives.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>automation</category>
      <category>ai</category>
      <category>chrome</category>
    </item>
    <item>
      <title>Four Supplier Attributes, Three Option Slots: How Collapsed Options Break Variant Images and Re-Imports</title>
      <dc:creator>yu chen</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:13:08 +0000</pubDate>
      <link>https://dev.to/yu_chen_883b789d9824afb83/four-supplier-attributes-three-option-slots-how-collapsed-options-break-variant-images-and-3883</link>
      <guid>https://dev.to/yu_chen_883b789d9824afb83/four-supplier-attributes-three-option-slots-how-collapsed-options-break-variant-images-and-3883</guid>
      <description>&lt;p&gt;&lt;em&gt;By EasyCatch Growth Desk — weekly technical deep-dive for the Shopify community. Written 2026-10-26.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Supplier catalogs are not built around Shopify's option model. A clothing SKU on a domestic wholesale marketplace routinely carries four attributes — colour, size, fabric weight, and print — while a product CSV exposes exactly three option slots. Two threads on this forum keep circling the same gap: a &lt;a href="https://community.shopify.com/t/683136" rel="noopener noreferrer"&gt;clothing-store thread asking how merchants actually check supplier files before importing&lt;/a&gt;, where a responder zeroes in on the real pain ("especially when supplier files have a lot of variants … whether the comparison can also show exactly which row or field changed"), and a much older &lt;a href="https://community.shopify.com/t/146857" rel="noopener noreferrer"&gt;import failure reported as &lt;code&gt;Option value provided for unknown options&lt;/code&gt;&lt;/a&gt;, where the accepted answer spells out that there are only "three option spaces: Option 1 Name/Value, Option 2 Name/Value, Option 3 Name/Value."&lt;/p&gt;

&lt;p&gt;That second answer is the whole article in one sentence. When a supplier has more attributes than you have slots, you do not get an error — you get a &lt;em&gt;decision&lt;/em&gt;. You collapse two attributes into a single option value ("Black / Heavyweight"), and from that moment the option &lt;em&gt;value string&lt;/em&gt; becomes the identity of the variant. Everything downstream keys off that string: the per-row &lt;code&gt;Variant Image&lt;/code&gt; binding, the swatch mapping, and every future re-import that tries to match existing rows. This is a data-model problem with a naming surface, not a technique problem — which is why the same store can import "successfully" for months and still ship the wrong photo to customers.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Three tables, one column that decides whether an image actually lands
&lt;/h2&gt;

&lt;p&gt;A Shopify product import is not one table. It is three overlapping ones, serialised into a single flat file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;products&lt;/strong&gt; — one row per product, keyed by &lt;code&gt;Handle&lt;/code&gt;. This row carries the product-level fields: &lt;code&gt;Title&lt;/code&gt;, &lt;code&gt;Body (HTML)&lt;/code&gt;, &lt;code&gt;Tags&lt;/code&gt;, and — critically — the &lt;strong&gt;&lt;code&gt;Option1 Name&lt;/code&gt;&lt;/strong&gt;, &lt;code&gt;Option2 Name&lt;/code&gt;, &lt;code&gt;Option3 Name&lt;/code&gt; declarations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;variants&lt;/strong&gt; — one row per purchasable SKU, repeating the same &lt;code&gt;Handle&lt;/code&gt; and supplying the &lt;em&gt;values&lt;/em&gt; (&lt;code&gt;Option1 Value&lt;/code&gt;, &lt;code&gt;Option2 Value&lt;/code&gt;, &lt;code&gt;Option3 Value&lt;/code&gt;), plus &lt;code&gt;Variant SKU&lt;/code&gt;, &lt;code&gt;Variant Price&lt;/code&gt;, &lt;code&gt;Variant Grams&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;images&lt;/strong&gt; — separate rows that carry &lt;code&gt;Image Src&lt;/code&gt; and &lt;code&gt;Image Position&lt;/code&gt;, usually with the variant-specific fields left empty, plus a per-variant binding column: &lt;strong&gt;&lt;code&gt;Variant Image&lt;/code&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The binding rule is the part people get wrong, and it is unforgiving in a specific way: &lt;code&gt;Variant Image&lt;/code&gt; is matched against the image URL &lt;strong&gt;as a string&lt;/strong&gt;. If the value on a variant row does not byte-for-byte match an &lt;code&gt;Image Src&lt;/code&gt; declared for that same product, the variant simply has no image. No error, no warning, no row rejection — the product imports, the variant exists, the front end falls back to the featured image, and the customer sees a colour they did not order. Note also that the option &lt;em&gt;names&lt;/em&gt; live on the product row while the option &lt;em&gt;values&lt;/em&gt; live on variant rows; mixing a name into a value column is exactly how you produce &lt;code&gt;Option value provided for unknown options&lt;/code&gt; rather than a useful message.&lt;/p&gt;

&lt;p&gt;This is why the clothing-thread responder's request — "show exactly which row or field changed" — is the correct instinct. Row-level diffing is the only diagnostic that catches a broken &lt;code&gt;Variant Image&lt;/code&gt; string, because the import report will never mention it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The three-slot ceiling and the error that is not the one you expect
&lt;/h2&gt;

&lt;p&gt;Shopify's variant model exposes three option dimensions. The CSV mirrors that with &lt;code&gt;Option1/2/3 Name&lt;/code&gt; and &lt;code&gt;Option1/2/3 Value&lt;/code&gt;, and the forum answer above states it plainly. There is no &lt;code&gt;Option4 Name&lt;/code&gt; column to add, and adding one does not extend the model — the column is ignored, and any value you expected it to carry has nowhere to go.&lt;/p&gt;

&lt;p&gt;Two failure signatures follow, and they look nothing alike:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Loud failure.&lt;/strong&gt; A variant row supplies a value whose option name was never declared on the product row — or the option &lt;em&gt;name&lt;/em&gt; is missing entirely while values are present. Shopify rejects the row with &lt;code&gt;Option value provided for unknown options&lt;/code&gt;. This is the good outcome: the import stops and tells you something.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent failure.&lt;/strong&gt; You keep three option columns but pack a fourth attribute &lt;em&gt;inside&lt;/em&gt; one of the values, e.g. &lt;code&gt;Option1 Value = Black / Heavyweight&lt;/code&gt;. Import succeeds. The product page now shows a single dropdown whose entries are compound strings. Every downstream consumer that assumed &lt;code&gt;Option1 Value&lt;/code&gt; is a clean colour token — swatch mapping, filter apps, feed syndication, your own re-import matcher — silently loses that assumption.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The asymmetry matters: the loud error gets fixed in ten minutes, the silent one survives for quarters. If your supplier file has a fourth attribute, assume you are in case 2 until proven otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Three ways to collapse a fourth attribute, and what each one costs
&lt;/h2&gt;

&lt;p&gt;Once you accept the ceiling, there are only three strategies. Each has a concrete, predictable breakage:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strategy A — concatenate into one option value.&lt;/strong&gt; &lt;code&gt;Black / Heavyweight&lt;/code&gt;, &lt;code&gt;Black / Lightweight&lt;/code&gt;, &lt;code&gt;White / Heavyweight&lt;/code&gt;. Clean to generate, brutal downstream: swatch logic matches exact strings, so colour swatches stop resolving; sorting is alphabetical on the compound string, so all &lt;code&gt;Black&lt;/code&gt; variants are not even adjacent unless your padding happens to agree; and any future supplier rename (&lt;code&gt;Heavy Weight&lt;/code&gt; vs &lt;code&gt;Heavyweight&lt;/code&gt;) creates a &lt;em&gt;new&lt;/em&gt; variant instead of updating the old one. This is the most common choice and the most common source of duplicate variants on re-import.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strategy B — promote one attribute to a second product.&lt;/strong&gt; Each fabric weight becomes its own product with its own &lt;code&gt;Handle&lt;/code&gt;, carrying only colour and size as options. The data model stays clean and swatches keep working. The cost moves to the catalog: N products instead of one, split reviews, split inventory reporting, and a collections/filter strategy you have to maintain. It is legitimate — but it is a &lt;em&gt;merchandising&lt;/em&gt; decision, and it should be made deliberately rather than discovered after import.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strategy C — drop the attribute from the variant model and move it to metafields or the description.&lt;/strong&gt; The attribute still exists for the customer, just not as a purchasable dimension. Correct when the fourth attribute does not change the price or the SKU — if it &lt;em&gt;does&lt;/em&gt; change either, you have silently made two SKUs into one and your inventory count is now fiction.&lt;/p&gt;

&lt;p&gt;The decision is not aesthetic. Ask one question: does the fourth attribute change price, SKU, or stock? If yes, you cannot take Strategy C. If no, Strategy A is usually fine — provided you own the string-normalisation step and never let a supplier's spelling variant create a second variant.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Cartesian expansion is where the images quietly die
&lt;/h2&gt;

&lt;p&gt;Whichever strategy you pick, the row count is a product, not a sum. Five colours × eight sizes is 40 rows; add a third dimension of three collapsed fabric values and you are at 120 rows for a single product. Every one of those rows is an independent variant, and &lt;strong&gt;every one of them needs its own &lt;code&gt;Variant Image&lt;/code&gt; value&lt;/strong&gt; — the same image URL repeated, once per row, for each row that shares that colour.&lt;/p&gt;

&lt;p&gt;The classic failure is writing the image only on the first row of each colour group. Rows are not grouped; the file is flat. Row 1 gets the image, rows 2-8 of the same colour get an empty &lt;code&gt;Variant Image&lt;/code&gt;, and the storefront shows eight sizes of the correct colour with no photo. Then someone "fixes" it by setting the product's featured image, which masks the problem on the default variant and hides it on all the others.&lt;/p&gt;

&lt;p&gt;A second, subtler failure comes from the URL itself. Supplier CDN URLs frequently carry query strings — resize parameters, region tags, signed expiries. &lt;code&gt;Image Src&lt;/code&gt; and &lt;code&gt;Variant Image&lt;/code&gt; must be the &lt;em&gt;same&lt;/em&gt; string, so the moment you normalise one and not the other (strip the query string in one column, keep it in the other), the binding breaks even though both URLs point at the same picture. Consistency between the two columns is worth more than prettiness in either.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. What the transcode actually does to a per-row image
&lt;/h2&gt;

&lt;p&gt;Supplier images are usually WebP, often wide-gamut, sometimes EXIF-rotated. If you convert them before upload — which you should, because a file whose bytes do not match its extension is the single most reliable way to get an image dropped at the CDN edge — the conversion itself introduces three specific losses that show up as colour complaints, not as errors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Alpha becomes black.&lt;/strong&gt; JPEG has no alpha channel. Drawing a transparent WebP onto an uninitialised canvas composites it against black; the correct order is fill the canvas white first, &lt;em&gt;then&lt;/em&gt; draw. Skipped, every transparent-background product shot gets a black rectangle behind it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Colour profile is clipped.&lt;/strong&gt; Canvas defaults to sRGB. A Display P3 or Adobe RGB source is clipped into sRGB on &lt;code&gt;drawImage&lt;/code&gt;, and &lt;code&gt;toDataURL('image/jpeg')&lt;/code&gt; writes no ICC profile — so the browser that rendered it "looks fine" while the phone that receives it shows a duller, shifted red. This is the mechanism behind "the photo looked right on my laptop."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EXIF orientation is discarded.&lt;/strong&gt; Canvas ignores the orientation tag. A portrait supplier photo with a rotation flag comes out sideways, and it comes out sideways &lt;em&gt;consistently&lt;/em&gt;, which makes it look like a supplier error rather than a pipeline step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these produce a failed row. They produce a batch that imports cleanly and generates returns — which is why the fix belongs upstream of the CSV, in the conversion step, not in a post-import cleanup pass.&lt;/p&gt;

&lt;p&gt;The white-fill ordering is the one part of this that is purely a code decision, and it is worth getting right once: a Local Canvas Transpiler that fills the canvas before calling &lt;code&gt;drawImage&lt;/code&gt; eliminates the black-backdrop class of defect entirely, because the composite has an opaque destination from the first pixel. The colour-profile and EXIF losses are physics — no converter recovers what the format discards — but they can at least be &lt;em&gt;known&lt;/em&gt; rather than discovered in a refund request. Running that conversion 100% Local-First also means the supplier photos never leave the machine they were downloaded to, which matters when the file set is the whole of next season's catalog.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The through-line across all five sections is the same: the CSV is a &lt;em&gt;serialisation&lt;/em&gt; of a model with three option dimensions, one string-keyed image binding, and no per-row grouping. Every failure above is that model asserting itself against a supplier file that never agreed to it. Before importing a batch, check the following — in this order, because each step invalidates the next if it fails:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Count attributes, not columns.&lt;/strong&gt; If the supplier spec has more than three purchasable dimensions, choose Strategy A, B, or C &lt;em&gt;before&lt;/em&gt; generating the file — and write down the choice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalise option values once, centrally.&lt;/strong&gt; One canonical token per colour, per size. Supplier spelling variants must never reach the CSV as distinct values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert row count = cartesian product.&lt;/strong&gt; 5 colours × 8 sizes must equal 40 rows; if it does not, something collapsed silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert &lt;code&gt;Variant Image&lt;/code&gt; is non-empty on every row&lt;/strong&gt;, and that each value exactly matches an &lt;code&gt;Image Src&lt;/code&gt; string for the same &lt;code&gt;Handle&lt;/code&gt; — query strings included. A round-trip through a Matrixify CSV export will surface a broken binding immediately, because the export reads back what the store actually stored rather than what you sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-encode images&lt;/strong&gt; to JPEG or PNG before upload, filling white behind transparency and accepting that the ICC profile will not survive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-import diff on &lt;code&gt;Handle&lt;/code&gt; + option values&lt;/strong&gt;, never on &lt;code&gt;Title&lt;/code&gt;. Title changes are cosmetic; option value changes create new variants.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  About EasyCatch
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://easycatch.org" rel="noopener noreferrer"&gt;EasyCatch&lt;/a&gt; is a 100% Local-First copilot for 1688-to-Shopify sourcing. Its &lt;strong&gt;Local Canvas Transpiler&lt;/strong&gt; converts supplier WebP images to JPG entirely inside your browser — filling behind transparency before the draw, so per-row images survive the transcode instead of arriving with black backdrops — and its CSV engine expands colour-by-size combinations into &lt;strong&gt;Matrixify&lt;/strong&gt;-compliant ZIPs with the image URL repeated on every variant row, not just the first. Option values are normalised once, at extraction, so a supplier's spelling variant cannot become a second variant. No supplier link, price, or product data ever leaves your machine: &lt;strong&gt;Zero-Server Privacy&lt;/strong&gt; by design.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A Snapshot That Stops at 4,000 Tokens Is Not Smaller, It Is Blind: Three Walls That Hide a Page From an Agent</title>
      <dc:creator>yu chen</dc:creator>
      <pubDate>Mon, 19 Oct 2026 02:04:50 +0000</pubDate>
      <link>https://dev.to/yu_chen_883b789d9824afb83/a-snapshot-that-stops-at-4000-tokens-is-not-smaller-it-is-blind-three-walls-that-hide-a-page-4ink</link>
      <guid>https://dev.to/yu_chen_883b789d9824afb83/a-snapshot-that-stops-at-4000-tokens-is-not-smaller-it-is-blind-three-walls-that-hide-a-page-4ink</guid>
      <description>&lt;p&gt;&lt;em&gt;By Chen Yu — building Operun, a local-first browser kernel. Written 2026-10-19.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Most browser-automation stacks treat "read the page" as a solved problem: dump the accessibility tree, hand it to the model, let the model pick a control. The failure mode that costs real days is not a wrong click. It is a &lt;strong&gt;snapshot that is quietly incomplete and looks complete&lt;/strong&gt;. In Operun's a11y snapshot we found three independent walls, each producing the same complaint from the operator ("the data is missing") from a completely different root cause: a per-name hard clip at 50 characters, a token budget that truncated by &lt;code&gt;break&lt;/code&gt; and said so only in a trailer, and a dispatch rule that never emitted nodes with no role at all. The fixes were not one constant: the name ceiling went 50 → 300 → 1,200 characters, the token budget went 4,000 → 40,000 tokens (roughly 120,000 characters of read surface), and the third wall could not be fixed by any constant — it needed a separate content-reading path. This post is the mechanism, the field evidence, and the design reasoning, including the reasons the numbers are ceilings rather than removals. Everything below comes from &lt;code&gt;src/core/snapshot/scanner.ts&lt;/code&gt; and from instrumented runs against a live listing page; nothing here is a feature summary.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The read surface is a budget, not a dump
&lt;/h2&gt;

&lt;p&gt;An a11y snapshot in Operun is produced by &lt;code&gt;takeSnapshotInPage()&lt;/code&gt; and returned as &lt;code&gt;{ text, count }&lt;/code&gt; — one newline-terminated line per control, plus the number of controls reported, which is the unambiguous signal for "this page is genuinely empty" as opposed to "I found nothing I could name". The function is injected into the page through &lt;code&gt;chrome.scripting.executeScript&lt;/code&gt;, which imposes a discipline that shapes the whole file: &lt;strong&gt;no imports, no module scope, every helper nested inside the function&lt;/strong&gt;. Anything closed over from module scope would simply not survive injection.&lt;/p&gt;

&lt;p&gt;That constraint is also why the budget exists at all. Actionbook, the earlier kernel, could afford a 100,000-token ceiling because it wrote the full tree to a file and only the &lt;em&gt;path&lt;/em&gt; traveled back. In Operun the text travels &lt;strong&gt;inline&lt;/strong&gt;, in a &lt;code&gt;STEP_RESULT&lt;/code&gt; frame over the cloud socket. So the token budget here bounds a &lt;strong&gt;message&lt;/strong&gt;, not a file. That distinction is the entire design pressure: a message has to be one frame's worth of payload, and the reader on the other end is a model deciding what to do next, not a human scrolling.&lt;/p&gt;

&lt;p&gt;The walk itself is &lt;code&gt;root.querySelectorAll('*')&lt;/code&gt; — every element, every time. The budget does not bound the walk; it bounds what leaves the page. That is worth stating plainly because it sets expectations: raising the ceiling tenfold did not make the scan cheaper, it made the answer complete. Cost moves to the message size, which is the cheaper of the two failures.&lt;/p&gt;

&lt;p&gt;Three exclusions happen before a node can ever become a line, and they are worth knowing because they explain absences that look like bugs: &lt;code&gt;&amp;lt;option&amp;gt;&lt;/code&gt; elements inside a native &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; (no action this kernel has can pick one, so a ref on an option would be a handle to nothing — the parent reports the currently chosen value instead); &lt;code&gt;input[type=hidden]&lt;/code&gt; (excluded by what it &lt;em&gt;is&lt;/em&gt;, not by how it is styled, since it computes to &lt;code&gt;display: none&lt;/code&gt; anyway); and anything inside &lt;code&gt;closest('[hidden], [aria-hidden="true"], [inert]')&lt;/code&gt;, which covers all three routes the platform offers for taking a subtree off the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Wall one: a name clipped at 50 characters
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;MAX_NAME_CHARS&lt;/code&gt; shipped at &lt;strong&gt;50&lt;/strong&gt;. Its only consumer was &lt;code&gt;clip()&lt;/code&gt;, applied to the accessible name of each control. On a dense admin console that is invisible — button labels are short. On a recruiting or e-commerce listing it is catastrophic, because a listing card typically &lt;strong&gt;composes the whole card's text into one accessible name&lt;/strong&gt;: company, school, and self-description concatenated. The field evidence was blunt: &lt;strong&gt;60 out of 60 cards on one BOSS listing had an accessible-name length of exactly 50&lt;/strong&gt;, and the cut landed mid-word. Not "around 50" — precisely 50, minimum equal to maximum, because every card exceeded the cap and every card was clipped to it.&lt;/p&gt;

&lt;p&gt;Why this is worse than a shorter answer: the name is how the agent decides &lt;em&gt;which&lt;/em&gt; control a line is. A name that stops mid-sentence is a line the agent cannot match against what it is looking for, so it confidently reports "not present" about a card that is sitting on screen. A wrong answer that looks like a whole one is the most expensive failure class in this whole system.&lt;/p&gt;

&lt;p&gt;The fix went in two steps, and the intermediate step is the interesting part. &lt;strong&gt;50 → 300&lt;/strong&gt; was not enough: with the cap at 300 the observed distribution became &lt;code&gt;{300: 32, 298: 1, 296: 1, …}&lt;/code&gt; — the mass of cards sitting &lt;em&gt;exactly at the new ceiling&lt;/em&gt;, which is the signature of a clip that is still binding. Only at &lt;strong&gt;1,200&lt;/strong&gt; did the distribution spread out naturally and the company name start surviving.&lt;/p&gt;

&lt;p&gt;1,200 is a &lt;strong&gt;ceiling, not a removal&lt;/strong&gt;, and that is deliberate. A page is free to put a 100 KB &lt;code&gt;aria-label&lt;/code&gt; on a single element, and one element spending the budget that every other control draws from is exactly how a snapshot stops describing the page. The same trade is made explicitly nearby: &lt;code&gt;MAX_VALUE_CHARS = 30&lt;/code&gt;, because a value is something you read once, not something you match on.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Wall two: the budget that truncated silently
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;MAX_TOKENS&lt;/code&gt; shipped at &lt;strong&gt;4,000&lt;/strong&gt;. The accounting loop walks the collected lines, estimates cost as &lt;code&gt;Math.ceil(line.length / 3)&lt;/code&gt; — a character is roughly a third of a token, the same estimate the earlier kernel truncates by — and accumulates. When &lt;code&gt;spent + cost &amp;gt; MAX_TOKENS&lt;/code&gt; it &lt;strong&gt;breaks&lt;/strong&gt;, and appends exactly one trailer: &lt;code&gt;… N more elements omitted&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The trailer is honest about &lt;em&gt;how many&lt;/em&gt; and silent about &lt;em&gt;which&lt;/em&gt;. That is the wall: the reader of a truncated a11y snapshot is an agent deciding what to do next, and it must know that what it holds is not the whole page — otherwise it concludes the page is short.&lt;/p&gt;

&lt;p&gt;The original 4,000 was argued from "a dense admin page is a few hundred controls". True of a console. False of a listing page that is thousands of cards and whose controls a workflow reaches by scrolling to the bottom. A ceiling that cuts that page is not a smaller answer, it is a blind one.&lt;/p&gt;

&lt;p&gt;Diagnosing this wall has a trap worth documenting, because it produced a false accusation in our own notes. Two frames from the same drawer: &lt;strong&gt;frame A measured 12,175 characters ≈ 4,058 tokens &amp;gt; 4,000&lt;/strong&gt; → trailer present; &lt;strong&gt;frame B measured 7,713 characters ≈ 2,571 tokens &amp;lt; 4,000&lt;/strong&gt; → no trailer, and no reason to suspect the budget. If you see an absent field in frame B and blame token truncation, you are wrong by arithmetic. Always run the sum before blaming the wall.&lt;/p&gt;

&lt;p&gt;The ceiling is now &lt;strong&gt;40,000 tokens&lt;/strong&gt;, which at &lt;code&gt;ceil(len/3)&lt;/code&gt; is roughly &lt;strong&gt;120,000 characters&lt;/strong&gt; of read surface — about 120 KB of text, one WebSocket frame, comfortably inside what the transport carries without configuration, and still a bound. The arithmetic that matters when sizing a page for this budget is one line: &lt;code&gt;cards × name ceiling ÷ 3 ≈ tokens&lt;/code&gt;, which must stay under the budget. Sixty cards at 1,200 characters is 72,000 characters ≈ 24,000 tokens — under 40,000, with room for the rest of the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Wall three: the nodes with no role at all
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;roleOf()&lt;/code&gt; returns one of three things, and the third is the wall. A &lt;strong&gt;declared&lt;/strong&gt; &lt;code&gt;role&lt;/code&gt; attribute is mapped through &lt;code&gt;INTERACTIVE_ROLES&lt;/code&gt;; anything declared but not interactive (&lt;code&gt;role="heading"&lt;/code&gt;, &lt;code&gt;role="presentation"&lt;/code&gt;) returns &lt;code&gt;null&lt;/code&gt; — a statement about &lt;em&gt;interaction&lt;/em&gt;, not about content. Tags map directly: &lt;code&gt;a[href]&lt;/code&gt; → link, &lt;code&gt;button&lt;/code&gt; → button, &lt;code&gt;select&lt;/code&gt; → combobox, and the &lt;code&gt;input&lt;/code&gt; type fan-out covers checkbox, radio, the button-ish types, slider, spinbutton, searchbox, textbox. A &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; with no &lt;code&gt;href&lt;/code&gt; returns the empty string. Everything else — a &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;, a &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt;, a &lt;code&gt;&amp;lt;section&amp;gt;&lt;/code&gt; — also returns the empty string, meaning &lt;em&gt;no declared role&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That empty string is where plain-div UIs live. A page whose controls are &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;s with a delegated click listener has no roles anywhere, so the only way it becomes visible to an agent is &lt;code&gt;pointerHint()&lt;/code&gt;: &lt;code&gt;cursor: pointer&lt;/code&gt;, &lt;code&gt;onclick&lt;/code&gt;, or &lt;code&gt;tabindex&lt;/code&gt;. That is the inherited cursor-detection pass, and it is why some pages work beautifully and others return &lt;strong&gt;zero nodes&lt;/strong&gt; despite a successful frame match.&lt;/p&gt;

&lt;p&gt;The three-layer measurement from a BOSS detail drawer is the clearest illustration I have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;__TOP__                28 → 36 nodes   (the 8 new ones are the action bar)
iframe (frame A)      255 → 267        (growth is wall one's fix: longer names)
iframe[search]        160 → 160        (unchanged — checking only this layer
                                        reports "the click did nothing")
iframe[src*=resume]         → 0 nodes  (the resume body lives here, plain text)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three layers, three different diagnoses, and a fourth that simply has no roles to report. &lt;strong&gt;No constant fixes this.&lt;/strong&gt; Raising &lt;code&gt;MAX_NAME_CHARS&lt;/code&gt; to a million changes nothing about a paragraph of &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; text with no role, no cursor hint, and no tabindex. The dispatch is explicit about it: &lt;code&gt;if (role === null) { pushContent(node, cache, lines); continue }&lt;/code&gt; — and since &lt;code&gt;pushContent()&lt;/code&gt; opens with &lt;code&gt;if (mode !== 'content') return&lt;/code&gt;, in interactive mode that branch is exactly &lt;code&gt;if (role === null) continue&lt;/code&gt;: the node is visited, then dropped without a line. This is why the content-reading path is a separate mode rather than a bigger cap: &lt;code&gt;mode === 'content'&lt;/code&gt; makes the walk call &lt;code&gt;pushContent()&lt;/code&gt;, which emits &lt;code&gt;@eN: [tag 'text']&lt;/code&gt; for content tags with non-empty flattened text — guarded by &lt;code&gt;if (text === '') return&lt;/code&gt;, because a block with no text is not a shorter answer, it is a line with nothing on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. What changing a constant actually costs
&lt;/h2&gt;

&lt;p&gt;Three engineering details separate "changed a number" from "shipped a fix".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build-time injection.&lt;/strong&gt; The constants live in &lt;code&gt;src/&lt;/code&gt;, and the snapshot is injected into the page at runtime from the built bundle. Editing &lt;code&gt;src&lt;/code&gt; alone changes nothing until &lt;code&gt;npm run build:operator&lt;/code&gt; runs and the unpacked extension is reloaded in &lt;code&gt;chrome://extensions&lt;/code&gt;. There is no hot path: a stale service worker keeps serving the old &lt;code&gt;background.js&lt;/code&gt; from memory, and a websocket reconnect does &lt;strong&gt;not&lt;/strong&gt; reload code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never grep the built artifact for the number.&lt;/strong&gt; Minification rewrites &lt;code&gt;40000&lt;/code&gt; as &lt;code&gt;4e4&lt;/code&gt; and &lt;code&gt;16000&lt;/code&gt; as &lt;code&gt;16e3&lt;/code&gt;. Verifying a build by grepping for &lt;code&gt;40000&lt;/code&gt; in &lt;code&gt;background.js&lt;/code&gt; reports a false negative on a perfectly good build. Compare byte size and behavior, not literals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The constant's own name is redacted.&lt;/strong&gt; &lt;code&gt;MAX_TOKENS&lt;/code&gt; contains &lt;code&gt;TOKEN&lt;/code&gt;, so reading that line through most tooling returns &lt;code&gt;const MAX_TOKENS=***&lt;/code&gt;. I hit this on this very article: the line reads &lt;code&gt;=***&lt;/code&gt; and &lt;code&gt;MAX_NAME_CHARS = 1200&lt;/code&gt; right below it reads fine. The workaround is mechanical — index by line number and extract digits with a regex, never paste the whole line into a command and trust the echo.&lt;/p&gt;

&lt;p&gt;One more design detail that shows the budget is not the only bound: the ref cache prunes itself. &lt;code&gt;byRef&lt;/code&gt; is a &lt;code&gt;Map&lt;/code&gt;, so it would pin every removed subtree for the life of the document; the walk deletes entries whose node is no longer &lt;code&gt;isConnected&lt;/code&gt;. &lt;code&gt;byNode&lt;/code&gt; is a &lt;code&gt;WeakMap&lt;/code&gt; and releases on its own. A reparented row is re-registered from &lt;code&gt;byNode&lt;/code&gt; on the next walk, so pruning costs nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;All three walls produce the same operator complaint — "the data is missing" — and each needs a different instrument. The checklist we now run before blaming the page:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Count the controls first.&lt;/strong&gt; &lt;code&gt;count === 0&lt;/code&gt; with a successful frame match is wall three, not a missing element. Check &lt;code&gt;pointerHint&lt;/code&gt; coverage on that layer before touching any constant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure name lengths, not names.&lt;/strong&gt; If the distribution is &lt;code&gt;{cap: N, cap-2: 1, …}&lt;/code&gt; with the mass sitting exactly on the ceiling, the clip is still binding. A spread distribution means the cap is finally above the data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the token arithmetic before blaming truncation.&lt;/strong&gt; &lt;code&gt;sum(ceil(len/3))&lt;/code&gt; against the budget, per frame. An untruncated frame has no trailer — do not invent a cause for an absence it cannot explain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat &lt;code&gt;… N more elements omitted&lt;/code&gt; as a page-level warning, not a footnote.&lt;/strong&gt; It means the answer is incomplete and you do not know what is missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the layer you did not check.&lt;/strong&gt; &lt;code&gt;iframe[search]&lt;/code&gt; stayed at 160 while the action bar grew and the resume frame went to zero nodes — one layer's stability says nothing about the other three.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reach for CSS only when perception genuinely cannot.&lt;/strong&gt; Some things — a download control that is a bare &lt;code&gt;&amp;lt;div&amp;gt;&amp;lt;svg&amp;gt;&amp;lt;use&amp;gt;&lt;/code&gt; with no accessible name — are addressable only by structural selector. That is a fallback, not a strategy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The general lesson is less about snapshots than about perception as an interface: a perception layer that fails by returning &lt;em&gt;less&lt;/em&gt; is safe, and one that fails by returning &lt;em&gt;a plausible subset&lt;/em&gt; is dangerous, because the agent downstream has no way to tell the difference. Every constant in that file is a decision about which of those two failures you are willing to ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  About Operun
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://operun.app" rel="noopener noreferrer"&gt;Operun&lt;/a&gt; is a local-first browser kernel: a cloud core on loopback plus an operator extension that drives your real, already-logged-in Chrome through CDP. Its a11y snapshot, token budget, and content mode are the perception layer an agent uses to decide what to click — which is why they are specified as constants with reasons attached rather than tuned per deployment. Local-first here is structural, not promotional: the browser session, the cookies, and the page text stay on your machine, and only the bounded snapshot crosses the socket.&lt;/p&gt;

</description>
      <category>browserautomation</category>
      <category>a11y</category>
      <category>aiagents</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Supplier Spreadsheets Are Not Shopify CSVs: Handle Keys, SKU Mapping, and the Image-Prep Pipeline That Keeps Bulk Updates Safe</title>
      <dc:creator>yu chen</dc:creator>
      <pubDate>Wed, 14 Oct 2026 15:29:34 +0000</pubDate>
      <link>https://dev.to/yu_chen_883b789d9824afb83/supplier-spreadsheets-are-not-shopify-csvs-handle-keys-sku-mapping-and-the-image-prep-pipeline-5ad</link>
      <guid>https://dev.to/yu_chen_883b789d9824afb83/supplier-spreadsheets-are-not-shopify-csvs-handle-keys-sku-mapping-and-the-image-prep-pipeline-5ad</guid>
      <description>&lt;p&gt;&lt;em&gt;By EasyCatch Growth Desk — weekly technical deep-dive for the Shopify community. Written 2026-09-07.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Most merchants treat "importing" as the hard part and "updating" as a smaller repeat of it. The two most active workflow threads on community.shopify.com this week show the opposite is true. In &lt;a href="https://community.shopify.com/t/676989" rel="noopener noreferrer"&gt;How do you handle supplier spreadsheets when updating your store?&lt;/a&gt; (Sep 5), a developer exploring this exact workflow is told by merchants that the import itself is rarely the failure point — it is everything &lt;em&gt;before&lt;/em&gt; it: supplier files are keyed on &lt;strong&gt;SKU&lt;/strong&gt; while Shopify's products CSV is keyed on &lt;strong&gt;Handle&lt;/strong&gt;, so a blank or drifted handle silently creates duplicate products instead of updating the one you meant. In &lt;a href="https://community.shopify.com/t/676739" rel="noopener noreferrer"&gt;What do you do with product images before they're ready for Shopify?&lt;/a&gt; (Sep 4), the consensus is that renaming, organizing, and matching images to variants is the "most repetitive and time-consuming" stage — and the filename is where variant-to-image binding is won or lost before the CSV is ever written. This guide explains why the update loop is a data-model problem, not a spreadsheet chore: handle-keyed upsert semantics, SKU-to-handle mapping, Excel's silent corruption of identifiers, and an image-prep naming discipline that makes the Variant Image column trivially correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Why Your Supplier File Is Not a Shopify CSV
&lt;/h2&gt;

&lt;p&gt;Open Shopify's product CSV template next to a supplier's price/stock sheet and the difference is not the columns — it is the &lt;strong&gt;key&lt;/strong&gt;. Supplier files identify a row by SKU (&lt;code&gt;ABC123-BLUE-M&lt;/code&gt;). Shopify's importer identifies a row by &lt;strong&gt;Handle&lt;/strong&gt; (&lt;code&gt;abc123-blue-m&lt;/code&gt;), and merges every CSV row sharing a handle into one product record before applying variant-level changes.&lt;/p&gt;

&lt;p&gt;This mismatch is invisible until it bites. As one merchant put it in 676989: &lt;em&gt;"the supplier file is keyed on SKU and Shopify's product CSV is keyed on Handle. Nothing warns you about the mismatch. If the handle column is blank or slightly wrong, the row does not update the product you meant, it creates a new one, and you find out when the store has two of everything."&lt;/em&gt; That is the single most expensive sentence in the thread. A price-update file built by &lt;em&gt;joining on SKU&lt;/em&gt; but written with a stale or hand-typed handle column is a duplicate-product generator, and the import summary will show zero errors — handle-keyed upserts silently create when they cannot match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix starts with a mapping, not a merge.&lt;/strong&gt; Before touching the supplier file, export a fresh product list and build a &lt;strong&gt;SKU → Handle map&lt;/strong&gt; from it. Not last month's copy, not the CSV you imported last time — a fresh export, because handles change when titles change and SKUs get reassigned. Join the supplier file to the map on SKU; let any SKU with no handle fall into an exceptions list for human review instead of being auto-created.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Excel Is a Data-Damage Tool, Not a Data Tool
&lt;/h2&gt;

&lt;p&gt;The second recurring theme in 676989 is that &lt;em&gt;opening the file is enough to break it&lt;/em&gt;. Excel silently rewrites identifiers on save:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Leading zeros stripped.&lt;/strong&gt; SKU &lt;code&gt;001234&lt;/code&gt; becomes &lt;code&gt;1234&lt;/code&gt; — which no longer matches anything in the SKU → Handle map, so the row either creates a duplicate or silently updates nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scientific notation.&lt;/strong&gt; Long numeric IDs (order IDs, barcode-adjacent fields, 11+ digit supplier codes) become &lt;code&gt;7.05E+11&lt;/code&gt;. The value &lt;em&gt;looks&lt;/em&gt; wrong only if someone scrolls to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Date coercion.&lt;/strong&gt; &lt;code&gt;01-2345&lt;/code&gt; or &lt;code&gt;3-4&lt;/code&gt; cells get converted to dates, destroying the identifier entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sorting a column independently.&lt;/strong&gt; One merchant's hard rule from the thread: &lt;em&gt;"I'd avoid sorting that CSV in Excel."&lt;/em&gt; A partial sort detaches rows from their products — the file now says SKU X costs $12 when the adjacent row still holds SKU Y's description.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical rule for anyone handling supplier files: &lt;strong&gt;never let the supplier file touch Excel before it is normalized.&lt;/strong&gt; Read it with a proper CSV parser, validate SKU format (length, charset, leading zeros), and fail loudly on rows that don't validate — do not "fix them quietly," because quiet fixes are how wrong prices ship to the storefront.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The Update Loop: Change Detection Before Re-Import
&lt;/h2&gt;

&lt;p&gt;A supplier price or stock update should &lt;strong&gt;never&lt;/strong&gt; be applied by re-importing the entire catalog. Full re-imports re-run every upsert rule in Section 1 across every row — one stale handle in a 2,000-row file creates one duplicate you won't notice until the Google Shopping feed flags two products with identical titles.&lt;/p&gt;

&lt;p&gt;The safe loop, assembled from the merchants' answers in 676989:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Export fresh state&lt;/strong&gt; from Shopify (products + variants + inventory).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalize the supplier file&lt;/strong&gt;: validate SKUs, strip formatting, parse prices/stocks as numbers, reject malformed rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Join to the SKU → Handle map&lt;/strong&gt; built in step 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detect changes&lt;/strong&gt; — compare the supplier's new price/stock against current values, and keep only rows where something actually changed. Change detection is also your safety net: a supplier file that suddenly shows 40% of SKUs at $0 is a broken export, not a sale, and detection will surface it as an anomaly instead of importing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review exceptions&lt;/strong&gt; (unmatched SKUs, zero prices, new products) in a separate list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Import&lt;/strong&gt;, and use Shopify's own stock safety check for inventory rows — the On hand (current)/(new) confirmation that one merchant called "the safety check" — before confirming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep an update log&lt;/strong&gt; of what was changed and when, so a bad import can be rolled back by re-running the previous good state.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This loop turns "import the file and pray" into a reviewable, reversible operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The Image-Prep Pipeline: Filenames Are the Join Key
&lt;/h2&gt;

&lt;p&gt;Thread 676739 asks merchants what they actually do with images between creation and upload. The answers converge on one discipline that most stores skip: &lt;strong&gt;the filename is the data model&lt;/strong&gt;. The most-cited pattern in the thread is a SKU-position naming rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ABC123-01.jpg&lt;/code&gt;, &lt;code&gt;ABC123-02.jpg&lt;/code&gt; — gallery order encoded in the filename&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ABC123-blue-01.jpg&lt;/code&gt; — variant shots carry their option value&lt;/li&gt;
&lt;li&gt;One folder per batch, with &lt;code&gt;approved / needs-editing / rejected&lt;/code&gt; subfolders, and &lt;strong&gt;only approved files get renamed&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Crop to a consistent aspect ratio and compress before upload (merchants cited ~2000 px on the long side as a working default)&lt;/li&gt;
&lt;li&gt;A spreadsheet mapping final filenames to product handles before the CSV is written&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why does this matter technically? Because Shopify's CSV binds variant images &lt;strong&gt;by URL-string identity&lt;/strong&gt;: the &lt;code&gt;Variant Image&lt;/code&gt; column must repeat, per variant row, a URL that exactly matches an &lt;code&gt;Image Src&lt;/code&gt; row in the same file — or an existing image in the Media library. When your files are named &lt;code&gt;ABC123-blue-01.jpg&lt;/code&gt; from the start, generating the cartesian expansion (5 colors × 4 sizes = 20 variant rows, each repeating its own color's image URL) is a mechanical join on the filename. When your files are named &lt;code&gt;IMG_4821.jpg&lt;/code&gt;, someone has to eyeball every row — and eyeballing 20-row variant blocks is exactly where "colors swapped / sizes show the wrong image" complaints are born. One merchant in the thread confirmed the slowest step is &lt;em&gt;"making sure each image belongs to the right color/option"&lt;/em&gt; — which is only slow when the naming scheme doesn't encode the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. WebP and the File-Prep Stage
&lt;/h2&gt;

&lt;p&gt;The image-prep stage is also where format conversion belongs — &lt;em&gt;before&lt;/em&gt; naming and upload, never after. 1688/Alibaba supplier galleries are predominantly &lt;strong&gt;WebP files behind signed CDN URLs&lt;/strong&gt; (&lt;code&gt;...webp?x-oss-process=image/...&lt;/code&gt;). If a WebP URL survives into the CSV, Shopify's async download queue can silently skip the fetch — the row imports "successfully," the image slot stays blank, and no error is surfaced anywhere.&lt;/p&gt;

&lt;p&gt;The conversion is a local binary operation: a browser &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; decodes the source via &lt;code&gt;drawImage&lt;/code&gt;, then &lt;code&gt;toDataURL('image/jpeg')&lt;/code&gt; re-encodes it. Two honest constraints: JPEG is 8-bit per channel, so higher bit-depth sources and alpha channels are downsampled (visually negligible for product photography, relevant for line art), and the re-encoded file must be re-hosted at a &lt;strong&gt;stable, standard-format URL&lt;/strong&gt; — a signed, expiring supplier URL re-entered into the CSV just moves the failure a few hours later when the signature expires before Shopify's queue fetches it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Local-first is not a privacy slogan here; it is a correctness argument.&lt;/strong&gt; Transpiling on your own machine means the CSV never contains a format Shopify will drop, and the file you produce — standard JPEGs plus a &lt;strong&gt;Matrixify-compliant ZIP&lt;/strong&gt; with every expanded variant row repeating its own image URL — is deterministic. You can run the same prep twice and get the same catalog. You cannot say that about a manual Excel session at 11 p.m. before a product launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. A Normalization Pipeline That Makes All Three Safer
&lt;/h2&gt;

&lt;p&gt;Every failure in this guide happens upstream of Shopify, which is the good news: fix the pipeline once, and every future supplier update inherits the fix.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read supplier files programmatically&lt;/strong&gt; — never Excel-first. Validate SKUs, preserve leading zeros, parse numbers strictly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transpile WebP to JPEG locally&lt;/strong&gt; (canvas decode → re-encode → stable output), so no unsupported binary or expiring signed URL ever enters a CSV. This is the same binary conversion as Section 5, running as a repeatable step instead of a manual one-off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the SKU → Handle map from a fresh export&lt;/strong&gt; and join, never blind-merge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate the cartesian expansion programmatically&lt;/strong&gt;, repeating each color's &lt;code&gt;Variant Image&lt;/code&gt; URL on every size row, and package the result as a Matrixify-compliant ZIP that Shopify's importer (or Matrixify) accepts without row-level hand-editing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run change detection and keep an update log&lt;/strong&gt;, so a price update touches only the rows that actually changed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is exactly the workflow class that a tool like &lt;strong&gt;EasyCatch (easycatch.org)&lt;/strong&gt; — a 100% Local-First 1688-to-Shopify copilot built around a &lt;strong&gt;Local Canvas Transpiler&lt;/strong&gt; for WebP-to-JPG conversion and &lt;strong&gt;Matrixify-compliant ZIP&lt;/strong&gt; generation — automates end to end. But the architecture matters more than the tool: normalize the source catalog locally, and the entire class of silent-import bugs disappears for every future batch, not just this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The two threads that anchor this guide say the same thing from opposite ends of the workflow. The supplier-update thread (676989) shows that updating a store breaks on &lt;em&gt;keying&lt;/em&gt; — SKU files against a handle-keyed importer, with Excel quietly corrupting the identifiers in between. The image-prep thread (676739) shows that the tedious, error-prone half of merchandising is &lt;em&gt;naming and matching&lt;/em&gt; — and that a filename convention converts variant-image binding from eyeball work into a join. Both are upstream problems. Handle them there — with a fresh SKU → Handle map, strict file validation, change detection, an update log, and a local transpile-and-package step — and "import" becomes the boring, reliable 5% of the job it was always supposed to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  About EasyCatch
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://easycatch.org" rel="noopener noreferrer"&gt;EasyCatch&lt;/a&gt; is a 100% Local-First copilot for 1688-to-Shopify sourcing. Its Local Canvas Transpiler converts supplier WebP images to JPG entirely in your browser, and its CSV engine expands color-by-size variants into Matrixify-compliant ZIPs with correct per-row image mapping — no supplier link or product data ever leaves your machine. Zero-Server Privacy by design.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
