ML Intern commited on
Commit
0df020e
·
1 Parent(s): 1d2e122

V.AI STUDIO backdrop, vai-avatar's product search, size/position, neon

Browse files

Four changes, all on the search field.

Background. The same artwork vai-avatar shows on its body
(public/avatars/vaistudio_center.png, copied byte-for-byte, md5 checked) at
30% opacity. It is a separate fixed layer rather than a background-image on
.app because opacity on .app would fade the photographs and the search pill
with it, and background-attachment:fixed cannot compose with the .vignette and
.grain layers already sitting on top of the field.

Product search. Ranking is now vai-avatar's own scoreProduct(), ported to
product-search.js rather than extended in place, so the two modules can be
diffed against each other when the original changes. The whole rule set
carries over: exact SKU/model/title must win outright, millimetre sizes are
compared numerically (the specs write "900 mm", "900mm" and "W900" for the
same shelf), material words are matched against specs as well as title, and
brand names get a flat bonus. 35 tests in scripts/test-product-search.mjs pin
each rule. Both scorers now run and both rankings are returned, so the change
is measurable rather than assumed.

Size and position. A collapsed row docked to the search box. Size is a
slider, position is a square pad rather than two sliders -- one finger moves
the plane on both axes, which is what "position" means for a field of tiles.
Both persist to sessionStorage. The controls are read through a ref instead of
as effect dependencies, so a slider drag re-runs layout() and re-animates the
existing tiles rather than tearing the field down and refetching every
thumbnail.

Neon outline. Only where there is a verifiable identity, not a resemblance: a
product vai-avatar resolved by exact SKU/model/title beating the runner-up by
2x, or an interior whose own title/category literally contains the query. The
900 tier sits above the 550 "contains" tier, so relevance alone can never
reach it. The glow is a pseudo-element because the tile carries a blur filter
for the relevance fade, and a filter flattens an outer box-shadow into a hard
ring.

The backdrop is stored in git-lfs, named explicitly rather than with a *.png
pattern: a wildcard would route the ~6,400 BlurHash and thumbnail PNGs under
dist/prodimgs through LFS too, and those are regenerated by the catalogue
scripts on every refresh.

Verified in a real browser (scripts/test-ui.mjs, 22 assertions): the artwork
decodes, tiles grow from 130px to 221px on the slider, dragging the pad moves
96 tiles across a 1498px span, and the outline renders three glow layers on an
exact tile and nothing on any other.

.gitattributes CHANGED
@@ -36,3 +36,11 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
36
  products-index.json filter=lfs diff=lfs merge=lfs -text
37
  dist/products-gallery.json filter=lfs diff=lfs merge=lfs -text
38
  products-gallery.json filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
36
  products-index.json filter=lfs diff=lfs merge=lfs -text
37
  dist/products-gallery.json filter=lfs diff=lfs merge=lfs -text
38
  products-gallery.json filter=lfs diff=lfs merge=lfs -text
39
+ # The V.AI STUDIO backdrop, copied from vai-avatar's public/avatars/ folder.
40
+ # Named explicitly rather than with a *.png pattern: a wildcard would also
41
+ # catch the BlurHash and thumbnail PNGs under dist/prodimgs, which are small,
42
+ # numerous and regenerated by the catalogue scripts -- routing ~6,400 of them
43
+ # through LFS would make every catalogue refresh slow and would balloon the
44
+ # repo. Two files, one rule, and the reason is recorded here.
45
+ public/brand/vaistudio_center.png filter=lfs diff=lfs merge=lfs -text
46
+ dist/brand/vaistudio_center.png filter=lfs diff=lfs merge=lfs -text
dist/assets/app.css CHANGED
@@ -1,2 +1,2 @@
1
  /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
2
- @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--ease-out:cubic-bezier(0, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-x-0{inset-inline:0}.top-60{top:calc(var(--spacing) * 60)}.bottom-0{bottom:0}.isolate{isolation:isolate}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.table{display:table}.h-dvh{height:100dvh}.w-full{width:100%}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.items-center{align-items:center}.justify-between{justify-content:space-between}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.capitalize{text-transform:capitalize}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}}:root{color:#deded7;font-synthesis:none;text-rendering:optimizelegibility;-webkit-font-smoothing:antialiased;background:#10110f;font-family:Arial,Helvetica,sans-serif}*{box-sizing:border-box}body{margin:0}button,input,a{-webkit-tap-highlight-color:transparent}button,input{font:inherit}button{cursor:pointer}button:focus-visible,a:focus-visible{outline-offset:5px;outline:2px solid #d7e1d1}.app{background:#11120f;min-height:320px;overflow:clip}.image-field{contain:strict;position:absolute;inset:0;overflow:clip}.image-tile{will-change:transform;transform-origin:50%;background-color:#252823;background-size:cover;border:0;border-radius:2px;padding:0;display:block;position:absolute;top:0;left:0;overflow:hidden;box-shadow:0 1px 5px #0002}.image-tile img{object-fit:cover;opacity:0;filter:brightness(.92);width:100%;height:100%;transition:opacity .12s,filter .12s;position:absolute;inset:0}.image-tile img.loaded{opacity:1}.image-tile:hover{box-shadow:0 10px 35px #0005}.image-tile:hover img{filter:brightness()}.image-share{z-index:1;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:#e6ebe0;opacity:0;background:#0d0f0bb8;border-radius:6px;place-items:center;width:22px;height:22px;transition:opacity .16s,transform .16s,background .16s,color .16s;display:grid;position:absolute;top:5px;right:5px;transform:scale(.82)}.image-share svg{width:13px;height:13px}.image-share:hover{color:#fff;background:#0d0f0be6}.image-share[hidden]{display:none}.image-tile:hover .image-share,.image-tile:focus-within .image-share,.image-tile[data-rank="1"] .image-share,.image-tile[data-rank="2"] .image-share,.image-tile[data-rank="3"] .image-share{opacity:1;transform:scale(1)}@media (prefers-reduced-motion:reduce){.image-share{transition:opacity .12s linear}}.image-tile{opacity:var(--relevance-opacity,1);filter:blur(var(--relevance-blur,0px));transition:opacity .65s,filter .65s}.image-tile:hover,.image-tile:focus-visible{opacity:1;filter:blur()}.image-tile:hover,.image-tile:focus-within{z-index:2147483647}.vignette{pointer-events:none;z-index:300;background:radial-gradient(#0000 42%,#0c0d0b30 100%),linear-gradient(0deg,#10110f99,#0000 70px);position:absolute;inset:0}.grain{z-index:301;pointer-events:none;opacity:.045;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Cpath fill='%23fff' filter='url(%23n)' opacity='.7' d='M0 0h180v180H0z'/%3E%3C/svg%3E");position:absolute;inset:0}.search-center{z-index:400;width:clamp(350px,36vw,640px);max-width:calc(100% - 44px);position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.search-pill{-webkit-backdrop-filter:blur(26px)saturate(.7);backdrop-filter:blur(26px)saturate(.7);background:#e4e8ddbb;border-radius:999px;height:clamp(62px,4.8vw,84px);transition:background .25s,box-shadow .25s;position:relative;overflow:hidden;box-shadow:inset 0 1px #ffffff3d,0 6px 36px #00000018}.search-pill:focus-within{background:#e6eaddd4;box-shadow:inset 0 1px #ffffff52,0 5px 40px #0002}.search-pill input{color:#20251f;letter-spacing:-.035em;caret-color:#323b2d;background:0 0;border:0;outline:none;width:100%;height:100%;padding:0 58px 0 27px;font-size:clamp(22px,1.8vw,30px)}.search-pill input::placeholder{color:#565d54;opacity:1}.search-pill input::selection{background:#80957070}.clear-button{color:#4c5449;background:0 0;border:0;border-radius:50%;place-items:center;width:34px;height:34px;padding:7px;transition:background .15s;display:grid;position:absolute;top:50%;right:13px;transform:translateY(-50%)}.clear-button:hover{background:#0000000d}.clear-button svg{width:18px;height:18px}.search-progress{opacity:0;height:1px;transition:opacity .3s;position:absolute;bottom:0;left:24px;right:24px;overflow:hidden}.search-progress.visible{opacity:.5}.search-progress span{background:#47513f;height:100%;transition:width .3s;animation:1.6s ease-in-out infinite progress-glow;display:block}.search-status{letter-spacing:.01em;color:#d2d6ccc9;text-shadow:0 1px 5px #000,0 1px 12px #000;pointer-events:none;justify-content:center;align-items:center;gap:7px;min-height:20px;font-size:12px;display:flex;position:absolute;top:calc(100% + 13px);left:-28px;right:-28px}.status-dot{background:#d2d8c9;border-radius:50%;width:4px;height:4px;animation:1.6s ease-in-out infinite progress-glow}.has-error-inline{color:#e8b4a9;text-align:center}.has-error{pointer-events:auto;text-shadow:none;background:#171b16ee;border-radius:10px;flex-wrap:wrap;padding:12px;font-size:14px}.has-error button{color:#eee;background:0 0;border:0;border-bottom:1px solid #ccc;padding:0}.error-detail{text-align:center;opacity:.7;overflow-wrap:anywhere;width:100%;font-size:12px}.footer{z-index:401;color:#bac0b395;letter-spacing:.015em;padding:20px 24px;font-size:12px}.footer a{color:inherit;text-decoration:none;transition:color .2s}.footer a:hover{color:#f5f6ef}.version{opacity:.55;margin-left:5px}.footer-divider{opacity:.5;padding:0 8px}@keyframes progress-glow{0%,to{opacity:.4}50%{opacity:1}}@media (width<=650px){.search-center{width:min(82vw,400px)}.search-pill{height:60px}.search-pill input{padding-left:23px;font-size:22px}.footer{padding:16px;font-size:12px}.footer .version,.footer-divider{display:none}.footer>span{font-size:0}.footer>span:after{content:"On-device search";font-size:12px}}@media (prefers-reduced-motion:reduce){*,:before,:after{transition:none!important;animation:none!important}}.photo-viewer{color:#e8eae3;-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);background:#0c0e0bdc;border:0;width:100vw;max-width:none;height:100dvh;max-height:none;margin:0;padding:0;position:fixed;inset:0;overflow:hidden}.photo-viewer[open]{animation:.42s both viewer-reveal;display:block}.photo-viewer::backdrop{background:#0005}.photo-viewer.is-closing{opacity:0;transition:opacity .32s;animation:none}.photo-toolbar{z-index:2;pointer-events:none;justify-content:space-between;align-items:center;display:flex;position:absolute;inset:18px 24px auto}.photo-toolbar a,.photo-toolbar button{pointer-events:auto;color:#e0e5d9;background:#12160fe6;border:1px solid #ffffff18;border-radius:999px}.photo-toolbar a{padding:12px 17px;font-size:14px;text-decoration:none}.photo-toolbar button{place-items:center;width:44px;height:44px;display:grid}.photo-share{white-space:nowrap;align-items:center;gap:9px;display:inline-flex}.photo-share svg{flex:none;width:17px;height:17px}.photo-share:hover{color:#fff;background:#1b2115f2;border-color:#ffffff2e}@media (width<=560px){.photo-share span{clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.photo-share{padding:12px}}.photo-toolbar svg{width:22px;height:22px}.photo-origin{pointer-events:none;color:#e0e5d9;text-overflow:ellipsis;white-space:nowrap;background:#12160fe6;border:1px solid #ffffff18;border-radius:999px;max-width:60vw;padding:12px 17px;font-size:14px;overflow:hidden}.photo-viewport{place-items:center;padding:72px 24px 40px;display:grid;position:absolute;inset:0;overflow:auto}.photo-frame{width:min(1800px, calc(100vw - 48px), calc((100dvh - 128px) * var(--photo-ar)));aspect-ratio:var(--photo-ar);cursor:zoom-in;transform-origin:50%;background-color:#161912;background-size:100% 100%;border:0;border-radius:3px;flex:none;padding:0;display:block;box-shadow:0 12px 70px #0005}.photo-frame img{object-fit:contain;opacity:0;width:100%;height:100%;transition:opacity .3s;display:block}.photo-frame img.loaded{opacity:1}.photo-viewport.is-zoomed{display:block}.photo-viewport.is-zoomed .photo-frame{width:max(calc(100vw - 48px), calc((100dvh - 128px) * var(--photo-ar)));cursor:zoom-out;margin:0 auto}@keyframes viewer-reveal{0%{opacity:0}to{opacity:1}}.photo-step{color:#e0e5d9;background:#12160fd9;border:1px solid #ffffff18;border-radius:50%;place-items:center;width:46px;height:46px;padding:0;transition:background .18s,border-color .18s;display:grid;position:absolute;top:50%;transform:translateY(-50%)}.photo-step:hover{background:#1b2118e6;border-color:#ffffff2e}.photo-step svg{width:22px;height:22px}.photo-step.is-prev{left:18px}.photo-step.is-next{right:18px}@media (width<=650px){.photo-step{width:38px;height:38px}.photo-step.is-prev{left:8px}.photo-step.is-next{right:8px}}.photo-caption{z-index:2;-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);background:#0f130ccc;border:1px solid #ffffff17;border-radius:10px;max-width:min(560px,100vw - 48px);padding:12px 16px;position:absolute;bottom:18px;left:24px;right:24px}.photo-caption-price{color:#dfe6d2;margin:0 0 6px;font-size:15px;font-weight:600}.photo-caption-specs{gap:3px;margin:0;font-size:12px;display:grid}.photo-caption-specs>div{grid-template-columns:minmax(0,40%) minmax(0,60%);gap:10px;display:grid}.photo-caption-specs dt{color:#98a08f;overflow-wrap:anywhere}.photo-caption-specs dd{color:#d2d7cb;overflow-wrap:anywhere;margin:0}.photo-counter{z-index:2;font-variant-numeric:tabular-nums;color:#d2d7cb;background:#0f130ccc;border:1px solid #ffffff17;border-radius:999px;margin:0;padding:6px 12px;font-size:12px;position:absolute;bottom:22px;right:24px}@media (width<=650px){.photo-caption{padding:10px 12px;bottom:12px;left:12px;right:12px}.photo-counter{display:none}}.photo-detail{z-index:2;-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);background:#0f130ce8;border:1px solid #ffffff1f;border-radius:12px;width:min(460px,100vw - 48px);position:absolute;bottom:18px;left:24px;overflow:hidden}.photo-detail-summary{cursor:pointer;-webkit-user-select:none;user-select:none;align-items:center;gap:10px;padding:11px 14px;list-style:none;display:flex}.photo-detail-summary::-webkit-details-marker{display:none}.photo-detail-summary:hover{background:#ffffff0a}.photo-detail-title{color:#e4e9dc;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;font-size:14px;line-height:1.35;overflow:hidden}.photo-detail-price{color:#9fd6a4;font-variant-numeric:tabular-nums;flex:none;font-size:13px;font-weight:600}.photo-detail-toggle{color:#99a291;text-transform:uppercase;letter-spacing:.05em;border:1px solid #ffffff1f;border-radius:999px;flex:none;padding:3px 9px;font-size:11px;transition:color .18s,border-color .18s}.photo-detail[open] .photo-detail-toggle{color:#cdd6c4;border-color:#fff3}.photo-detail-body{border-top:1px solid #ffffff12;padding:0 14px 13px}.photo-detail-desc{color:#c3cabb;margin:11px 0 0;font-size:13px;line-height:1.6}.photo-detail-tags{flex-wrap:wrap;gap:6px;margin:11px 0 0;padding:0;list-style:none;display:flex}.photo-detail-tags li{color:#b6c2ab;background:#ffffff0a;border:1px solid #ffffff17;border-radius:999px;padding:3px 9px;font-size:11px}.photo-detail-facts{gap:4px;margin:11px 0 0;font-size:12px;display:grid}.photo-detail-facts>div{grid-template-columns:minmax(0,38%) minmax(0,62%);gap:10px;display:grid}.photo-detail-facts dt{color:#939d8a;overflow-wrap:anywhere}.photo-detail-facts dd{color:#d5dbcd;overflow-wrap:anywhere;margin:0}.photo-viewer:has(.photo-caption) .photo-detail{top:76px;bottom:auto}@media (width<=650px){.photo-detail{width:auto;bottom:12px;left:12px;right:12px}.photo-viewer:has(.photo-caption) .photo-detail{top:70px}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}
 
1
  /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--ease-out:cubic-bezier(0, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-x-0{inset-inline:0}.top-60{top:calc(var(--spacing) * 60)}.bottom-0{bottom:0}.isolate{isolation:isolate}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.block{display:block}.contents{display:contents}.flex{display:flex}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.table{display:table}.h-dvh{height:100dvh}.w-full{width:100%}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.items-center{align-items:center}.justify-between{justify-content:space-between}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.capitalize{text-transform:capitalize}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}}:root{color:#deded7;font-synthesis:none;text-rendering:optimizelegibility;-webkit-font-smoothing:antialiased;background:#10110f;font-family:Arial,Helvetica,sans-serif}*{box-sizing:border-box}body{margin:0}button,input,a{-webkit-tap-highlight-color:transparent}button,input{font:inherit}button{cursor:pointer}button:focus-visible,a:focus-visible{outline-offset:5px;outline:2px solid #d7e1d1}.brand-backdrop{z-index:0;pointer-events:none;opacity:.3;background-color:#10110f;background-image:url(../brand/vaistudio_center.png);background-position:50%;background-repeat:no-repeat;background-size:cover;background-attachment:fixed;position:fixed;inset:0}@media (width<=768px){.brand-backdrop{background-position:top;background-repeat:repeat;background-size:100%;background-attachment:scroll}}.app{background:0 0;min-height:320px;overflow:clip}.image-field{contain:strict;position:absolute;inset:0;overflow:clip}.image-tile{will-change:transform;transform-origin:50%;background-color:#252823;background-size:cover;border:0;border-radius:2px;padding:0;display:block;position:absolute;top:0;left:0;overflow:hidden;box-shadow:0 1px 5px #0002}.image-tile img{object-fit:cover;opacity:0;filter:brightness(.92);width:100%;height:100%;transition:opacity .12s,filter .12s;position:absolute;inset:0}.image-tile img.loaded{opacity:1}.image-tile:hover{box-shadow:0 10px 35px #0005}.image-tile:hover img{filter:brightness()}.image-share{z-index:1;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:#e6ebe0;opacity:0;background:#0d0f0bb8;border-radius:6px;place-items:center;width:22px;height:22px;transition:opacity .16s,transform .16s,background .16s,color .16s;display:grid;position:absolute;top:5px;right:5px;transform:scale(.82)}.image-share svg{width:13px;height:13px}.image-share:hover{color:#fff;background:#0d0f0be6}.image-share[hidden]{display:none}.image-tile:hover .image-share,.image-tile:focus-within .image-share,.image-tile[data-rank="1"] .image-share,.image-tile[data-rank="2"] .image-share,.image-tile[data-rank="3"] .image-share{opacity:1;transform:scale(1)}@media (prefers-reduced-motion:reduce){.image-share{transition:opacity .12s linear}}.image-tile{opacity:var(--relevance-opacity,1);filter:blur(var(--relevance-blur,0px));transition:opacity .65s,filter .65s}.image-tile:hover,.image-tile:focus-visible{opacity:1;filter:blur()}.image-tile:hover,.image-tile:focus-within{z-index:2147483647}.vignette{pointer-events:none;z-index:300;background:radial-gradient(#0000 42%,#0c0d0b30 100%),linear-gradient(0deg,#10110f99,#0000 70px);position:absolute;inset:0}.grain{z-index:301;pointer-events:none;opacity:.045;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Cpath fill='%23fff' filter='url(%23n)' opacity='.7' d='M0 0h180v180H0z'/%3E%3C/svg%3E");position:absolute;inset:0}.search-center{z-index:400;width:clamp(350px,36vw,640px);max-width:calc(100% - 44px);position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.search-pill{-webkit-backdrop-filter:blur(26px)saturate(.7);backdrop-filter:blur(26px)saturate(.7);background:#e4e8ddbb;border-radius:999px;height:clamp(62px,4.8vw,84px);transition:background .25s,box-shadow .25s;position:relative;overflow:hidden;box-shadow:inset 0 1px #ffffff3d,0 6px 36px #00000018}.search-pill:focus-within{background:#e6eaddd4;box-shadow:inset 0 1px #ffffff52,0 5px 40px #0002}.search-pill input{color:#20251f;letter-spacing:-.035em;caret-color:#323b2d;background:0 0;border:0;outline:none;width:100%;height:100%;padding:0 58px 0 27px;font-size:clamp(22px,1.8vw,30px)}.search-pill input::placeholder{color:#565d54;opacity:1}.search-pill input::selection{background:#80957070}.clear-button{color:#4c5449;background:0 0;border:0;border-radius:50%;place-items:center;width:34px;height:34px;padding:7px;transition:background .15s;display:grid;position:absolute;top:50%;right:13px;transform:translateY(-50%)}.clear-button:hover{background:#0000000d}.clear-button svg{width:18px;height:18px}.image-tile{--neon:#22f5d0;--neon-soft:#7ef9ff}.image-tile.exact{--relevance-opacity:1!important;--relevance-blur:0px!important}.image-tile.exact:after{content:"";z-index:2;border:1.5px solid var(--neon);box-shadow:0 0 6px 1px var(--neon), 0 0 16px 3px var(--neon), inset 0 0 12px var(--neon-soft);border-radius:2px;position:absolute;inset:0}@supports (color:color-mix(in lab, red, red)){.image-tile.exact:after{box-shadow:0 0 6px 1px color-mix(in srgb, var(--neon) 85%, transparent), 0 0 16px 3px color-mix(in srgb, var(--neon) 55%, transparent), inset 0 0 12px color-mix(in srgb, var(--neon-soft) 45%, transparent)}}.image-tile.exact:after{pointer-events:none;animation:2.6s ease-in-out infinite neon-breathe}@keyframes neon-breathe{0%,to{box-shadow:0 0 6px 1px color-mix(in srgb, var(--neon) 80%, transparent), 0 0 15px 3px color-mix(in srgb, var(--neon) 50%, transparent), inset 0 0 11px color-mix(in srgb, var(--neon-soft) 40%, transparent)}50%{box-shadow:0 0 9px 2px color-mix(in srgb, var(--neon) 95%, transparent), 0 0 24px 5px color-mix(in srgb, var(--neon) 65%, transparent), inset 0 0 16px color-mix(in srgb, var(--neon-soft) 55%, transparent)}}@media (prefers-reduced-motion:reduce){.image-tile.exact:after{animation:none}}.field-controls{pointer-events:none;justify-content:center;margin-top:14px;display:flex;position:relative}.field-controls>*{pointer-events:auto}.field-controls-toggle{-webkit-backdrop-filter:blur(18px)saturate(.75);backdrop-filter:blur(18px)saturate(.75);color:#d5dbcd;letter-spacing:.01em;cursor:pointer;background:#161914cc;border:1px solid #ffffff2b;border-radius:999px;align-items:center;gap:8px;padding:8px 15px;font-size:12.5px;transition:background .18s,border-color .18s,color .18s;display:inline-flex}.field-controls-toggle:hover{color:#f0f4ea;background:#1d2219e0;border-color:#ffffff45}.field-controls-toggle.is-open{border-color:var(--neon-soft);color:#f4f8ef;background:#232a1dee}.field-controls-toggle svg{opacity:.85;width:16px;height:16px}.field-controls-panel{-webkit-backdrop-filter:blur(22px)saturate(.8);backdrop-filter:blur(22px)saturate(.8);z-index:402;background:#12150fee;border:1px solid #ffffff24;border-radius:15px;flex-direction:column;gap:15px;width:min(300px,100vw - 40px);padding:16px;display:flex;position:absolute;top:calc(100% + 10px);left:50%;transform:translate(-50%);box-shadow:0 14px 44px #00000070}.field-controls-row{flex-direction:column;gap:8px;display:flex}.field-controls-label{letter-spacing:.06em;text-transform:uppercase;color:#9aa393;justify-content:space-between;align-items:baseline;gap:10px;font-size:11.5px;display:flex}.field-controls-value{letter-spacing:0;text-transform:none;color:var(--neon-soft);font-variant-numeric:tabular-nums;font-size:12.5px}.field-controls-range{appearance:none;background:linear-gradient(90deg,#3c4a37,#6f8a63);border-radius:999px;outline:none;width:100%;height:4px}.field-controls-range::-webkit-slider-thumb{appearance:none;background:var(--neon-soft);width:17px;height:17px;box-shadow:0 0 8px var(--neon);border:2px solid #0e110b;border-radius:50%}@supports (color:color-mix(in lab, red, red)){.field-controls-range::-webkit-slider-thumb{box-shadow:0 0 8px color-mix(in srgb, var(--neon) 65%, transparent)}}.field-controls-range::-webkit-slider-thumb{cursor:grab}.field-controls-range::-moz-range-thumb{background:var(--neon-soft);cursor:grab;border:2px solid #0e110b;border-radius:50%;width:15px;height:15px}.field-controls-pad{aspect-ratio:1;cursor:grab;touch-action:none;background:linear-gradient(#ffffff0d,#ffffff0d) 50%/1px 100% no-repeat,linear-gradient(#ffffff0d,#ffffff0d) 50%/100% 1px no-repeat,#0d100b;border:1px solid #ffffff1f;border-radius:11px;width:100%;max-height:148px;margin:0 auto;position:relative}.field-controls-pad:active{cursor:grabbing}.field-controls-pad:focus-visible{outline:2px solid var(--neon-soft);outline-offset:3px}.field-controls-pad-dot{background:var(--neon-soft);width:13px;height:13px;box-shadow:0 0 9px var(--neon);border-radius:50%;position:absolute;transform:translate(-50%,-50%)}@supports (color:color-mix(in lab, red, red)){.field-controls-pad-dot{box-shadow:0 0 9px color-mix(in srgb, var(--neon) 80%, transparent)}}.field-controls-pad-dot{pointer-events:none}.field-controls-actions{justify-content:flex-end;display:flex}.field-controls-reset{color:#b6bdab;cursor:pointer;background:0 0;border:1px solid #ffffff24;border-radius:999px;padding:6px 13px;font-size:12px;transition:background .18s,color .18s}.field-controls-reset:hover{color:#f0f4ea;background:#ffffff12}.search-progress{opacity:0;height:1px;transition:opacity .3s;position:absolute;bottom:0;left:24px;right:24px;overflow:hidden}.search-progress.visible{opacity:.5}.search-progress span{background:#47513f;height:100%;transition:width .3s;animation:1.6s ease-in-out infinite progress-glow;display:block}.search-status{letter-spacing:.01em;color:#d2d6ccc9;text-shadow:0 1px 5px #000,0 1px 12px #000;pointer-events:none;justify-content:center;align-items:center;gap:7px;min-height:20px;font-size:12px;display:flex;position:absolute;top:calc(100% + 13px);left:-28px;right:-28px}.status-dot{background:#d2d8c9;border-radius:50%;width:4px;height:4px;animation:1.6s ease-in-out infinite progress-glow}.has-error-inline{color:#e8b4a9;text-align:center}.has-error{pointer-events:auto;text-shadow:none;background:#171b16ee;border-radius:10px;flex-wrap:wrap;padding:12px;font-size:14px}.has-error button{color:#eee;background:0 0;border:0;border-bottom:1px solid #ccc;padding:0}.error-detail{text-align:center;opacity:.7;overflow-wrap:anywhere;width:100%;font-size:12px}.footer{z-index:401;color:#bac0b395;letter-spacing:.015em;padding:20px 24px;font-size:12px}.footer a{color:inherit;text-decoration:none;transition:color .2s}.footer a:hover{color:#f5f6ef}.version{opacity:.55;margin-left:5px}.footer-divider{opacity:.5;padding:0 8px}@keyframes progress-glow{0%,to{opacity:.4}50%{opacity:1}}@media (width<=650px){.search-center{width:min(82vw,400px)}.search-pill{height:60px}.search-pill input{padding-left:23px;font-size:22px}.footer{padding:16px;font-size:12px}.footer .version,.footer-divider{display:none}.footer>span{font-size:0}.footer>span:after{content:"On-device search";font-size:12px}}@media (prefers-reduced-motion:reduce){*,:before,:after{transition:none!important;animation:none!important}}.photo-viewer{color:#e8eae3;-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);background:#0c0e0bdc;border:0;width:100vw;max-width:none;height:100dvh;max-height:none;margin:0;padding:0;position:fixed;inset:0;overflow:hidden}.photo-viewer[open]{animation:.42s both viewer-reveal;display:block}.photo-viewer::backdrop{background:#0005}.photo-viewer.is-closing{opacity:0;transition:opacity .32s;animation:none}.photo-toolbar{z-index:2;pointer-events:none;justify-content:space-between;align-items:center;display:flex;position:absolute;inset:18px 24px auto}.photo-toolbar a,.photo-toolbar button{pointer-events:auto;color:#e0e5d9;background:#12160fe6;border:1px solid #ffffff18;border-radius:999px}.photo-toolbar a{padding:12px 17px;font-size:14px;text-decoration:none}.photo-toolbar button{place-items:center;width:44px;height:44px;display:grid}.photo-share{white-space:nowrap;align-items:center;gap:9px;display:inline-flex}.photo-share svg{flex:none;width:17px;height:17px}.photo-share:hover{color:#fff;background:#1b2115f2;border-color:#ffffff2e}@media (width<=560px){.photo-share span{clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.photo-share{padding:12px}}.photo-toolbar svg{width:22px;height:22px}.photo-origin{pointer-events:none;color:#e0e5d9;text-overflow:ellipsis;white-space:nowrap;background:#12160fe6;border:1px solid #ffffff18;border-radius:999px;max-width:60vw;padding:12px 17px;font-size:14px;overflow:hidden}.photo-viewport{place-items:center;padding:72px 24px 40px;display:grid;position:absolute;inset:0;overflow:auto}.photo-frame{width:min(1800px, calc(100vw - 48px), calc((100dvh - 128px) * var(--photo-ar)));aspect-ratio:var(--photo-ar);cursor:zoom-in;transform-origin:50%;background-color:#161912;background-size:100% 100%;border:0;border-radius:3px;flex:none;padding:0;display:block;box-shadow:0 12px 70px #0005}.photo-frame img{object-fit:contain;opacity:0;width:100%;height:100%;transition:opacity .3s;display:block}.photo-frame img.loaded{opacity:1}.photo-viewport.is-zoomed{display:block}.photo-viewport.is-zoomed .photo-frame{width:max(calc(100vw - 48px), calc((100dvh - 128px) * var(--photo-ar)));cursor:zoom-out;margin:0 auto}@keyframes viewer-reveal{0%{opacity:0}to{opacity:1}}.photo-step{color:#e0e5d9;background:#12160fd9;border:1px solid #ffffff18;border-radius:50%;place-items:center;width:46px;height:46px;padding:0;transition:background .18s,border-color .18s;display:grid;position:absolute;top:50%;transform:translateY(-50%)}.photo-step:hover{background:#1b2118e6;border-color:#ffffff2e}.photo-step svg{width:22px;height:22px}.photo-step.is-prev{left:18px}.photo-step.is-next{right:18px}@media (width<=650px){.photo-step{width:38px;height:38px}.photo-step.is-prev{left:8px}.photo-step.is-next{right:8px}}.photo-caption{z-index:2;-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);background:#0f130ccc;border:1px solid #ffffff17;border-radius:10px;max-width:min(560px,100vw - 48px);padding:12px 16px;position:absolute;bottom:18px;left:24px;right:24px}.photo-caption-price{color:#dfe6d2;margin:0 0 6px;font-size:15px;font-weight:600}.photo-caption-specs{gap:3px;margin:0;font-size:12px;display:grid}.photo-caption-specs>div{grid-template-columns:minmax(0,40%) minmax(0,60%);gap:10px;display:grid}.photo-caption-specs dt{color:#98a08f;overflow-wrap:anywhere}.photo-caption-specs dd{color:#d2d7cb;overflow-wrap:anywhere;margin:0}.photo-counter{z-index:2;font-variant-numeric:tabular-nums;color:#d2d7cb;background:#0f130ccc;border:1px solid #ffffff17;border-radius:999px;margin:0;padding:6px 12px;font-size:12px;position:absolute;bottom:22px;right:24px}@media (width<=650px){.photo-caption{padding:10px 12px;bottom:12px;left:12px;right:12px}.photo-counter{display:none}}.photo-detail{z-index:2;-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);background:#0f130ce8;border:1px solid #ffffff1f;border-radius:12px;width:min(460px,100vw - 48px);position:absolute;bottom:18px;left:24px;overflow:hidden}.photo-detail-summary{cursor:pointer;-webkit-user-select:none;user-select:none;align-items:center;gap:10px;padding:11px 14px;list-style:none;display:flex}.photo-detail-summary::-webkit-details-marker{display:none}.photo-detail-summary:hover{background:#ffffff0a}.photo-detail-title{color:#e4e9dc;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;font-size:14px;line-height:1.35;overflow:hidden}.photo-detail-price{color:#9fd6a4;font-variant-numeric:tabular-nums;flex:none;font-size:13px;font-weight:600}.photo-detail-toggle{color:#99a291;text-transform:uppercase;letter-spacing:.05em;border:1px solid #ffffff1f;border-radius:999px;flex:none;padding:3px 9px;font-size:11px;transition:color .18s,border-color .18s}.photo-detail[open] .photo-detail-toggle{color:#cdd6c4;border-color:#fff3}.photo-detail-body{border-top:1px solid #ffffff12;padding:0 14px 13px}.photo-detail-desc{color:#c3cabb;margin:11px 0 0;font-size:13px;line-height:1.6}.photo-detail-tags{flex-wrap:wrap;gap:6px;margin:11px 0 0;padding:0;list-style:none;display:flex}.photo-detail-tags li{color:#b6c2ab;background:#ffffff0a;border:1px solid #ffffff17;border-radius:999px;padding:3px 9px;font-size:11px}.photo-detail-facts{gap:4px;margin:11px 0 0;font-size:12px;display:grid}.photo-detail-facts>div{grid-template-columns:minmax(0,38%) minmax(0,62%);gap:10px;display:grid}.photo-detail-facts dt{color:#939d8a;overflow-wrap:anywhere}.photo-detail-facts dd{color:#d5dbcd;overflow-wrap:anywhere;margin:0}.photo-viewer:has(.photo-caption) .photo-detail{top:76px;bottom:auto}@media (width<=650px){.photo-detail{width:auto;bottom:12px;left:12px;right:12px}.photo-viewer:has(.photo-caption) .photo-detail{top:70px}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}
dist/assets/index.js CHANGED
The diff for this file is too large to render. See raw diff
 
dist/assets/worker-hGPAZ_gn.js ADDED
The diff for this file is too large to render. See raw diff
 
dist/brand/vaistudio_center.png ADDED

Git LFS Details

  • SHA256: 00d1a10cdcb4e150b51ae6636b9a06c4aa184c9ff72f2a65c84636dcde08b513
  • Pointer size: 131 Bytes
  • Size of remote file: 656 kB
public/brand/vaistudio_center.png ADDED

Git LFS Details

  • SHA256: 00d1a10cdcb4e150b51ae6636b9a06c4aa184c9ff72f2a65c84636dcde08b513
  • Pointer size: 131 Bytes
  • Size of remote file: 656 kB
scripts/test-product-search.mjs ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Verify the vai-avatar scorer ported into product-search.js.
3
+ *
4
+ * The point of these tests is parity with vai-avatar, not a notion of "good
5
+ * ranking". Each case is a query vai-avatar is known to handle a specific way,
6
+ * and the expectation is written down so a change in behaviour here is a
7
+ * visible diff rather than a silent reordering nobody notices until a customer
8
+ * says the wrong product came up.
9
+ *
10
+ * Run: node scripts/test-product-search.mjs
11
+ */
12
+
13
+ import { scoreProduct, searchLikeAvatar, extractTerms } from '../src/product-search.js';
14
+ import { hasDecisiveWinner, exactMatches } from '../src/relevance.js';
15
+ import { normalizeText } from '../src/catalogue.js';
16
+
17
+ /* ── catalogue.js's normaliseProduct() output shape ── */
18
+ function product({ name = '', model = '', brand = '', category = '', summary = '', feats = [], specs = [], sku = '', ...rest }) {
19
+ return { sku: sku || model || name, name, model, brand, category, summary, feats, specs, ...rest };
20
+ }
21
+
22
+ let passed = 0;
23
+ let failed = 0;
24
+ function check(label, actual, expected) {
25
+ const ok = JSON.stringify(actual) === JSON.stringify(expected);
26
+ if (ok) { passed++; console.log(` ok ${label}`); }
27
+ else { failed++; console.log(` FAIL ${label}\n expected ${JSON.stringify(expected)}\n actual ${JSON.stringify(actual)}`); }
28
+ }
29
+ function ok(label, condition, detail = '') {
30
+ if (condition) { passed++; console.log(` ok ${label}`); }
31
+ else { failed++; console.log(` FAIL ${label} ${detail}`); }
32
+ }
33
+
34
+ console.log('\nextractTerms');
35
+ check('folds diacritics and drops 1-char noise',
36
+ extractTerms('Bếp từ Mới'), ['bep', 'tu', 'moi']);
37
+ check('keeps domain words a stopword list would drop',
38
+ extractTerms('tủ lạnh'), ['tu', 'lanh'],
39
+ );
40
+ ok('"tu" survives: it is both "từ" and "tủ"', extractTerms('tu lanh').includes('tu'));
41
+ check('"đ" folds to d', extractTerms('đá Granite'), ['da', 'granite']);
42
+
43
+ console.log('\nexact model / sku beats everything');
44
+ const gp170 = product({ name: 'Bếp từ MALLOCA GP170BD 5 vùng', model: 'GP170BD', brand: 'Malloca', category: 'Bếp từ' });
45
+ const other = product({ name: 'Bếp từ MALLOCA GP900 3 vùng', model: 'GP900', brand: 'Malloca', category: 'Bếp từ' });
46
+ {
47
+ const terms = extractTerms('GP170BD');
48
+ const exact = scoreProduct(gp170, 'GP170BD', terms);
49
+ const near = scoreProduct(other, 'GP170BD', terms);
50
+ ok(`exact SKU (${exact}) outranks a near model (${near})`, exact > near);
51
+ ok('exact SKU crosses the neon threshold (>=700)', exact >= 700, `got ${exact}`);
52
+ ok('the near model does NOT', near < 700, `got ${near}`);
53
+ }
54
+
55
+ console.log('\ntitle containment');
56
+ {
57
+ const terms = extractTerms('khoá điện tử');
58
+ const smart = product({ name: 'Khóa cửa điện tử thông minh VAI', category: 'Khóa cửa thông minh', feats: ['Vân tay', 'Mã số'] });
59
+ const plainLock = product({ name: 'Khóa cửa thường cánh thông', category: 'Khóa cửa' });
60
+ ok(`smart lock (${scoreProduct(smart, 'khoá điện tử', terms)}) outranks plain (${scoreProduct(plainLock, 'khoá điện tử', terms)})`,
61
+ scoreProduct(smart, 'khoá điện tử', terms) > scoreProduct(plainLock, 'khoá điện tử', terms));
62
+ }
63
+
64
+ console.log('\nsize (mm) matching is numeric, not textual');
65
+ {
66
+ // The catalogue writes sizes inconsistently; "700" must find the 700mm rack
67
+ // and not be fooled by a 70cm or a price containing 700.
68
+ const rack700 = product({ name: 'Kệ xoong nồi inox 700mm 2 tầng', category: 'Kệ xoong nồi', specs: [['Chiều rộng', '700 mm']] });
69
+ const rack900 = product({ name: 'Kệ xoong nồi inox 900mm 3 tầng', category: 'Kệ xoong nồi', specs: [['Chiều rộng', '900 mm']] });
70
+ const terms = extractTerms('kệ xoong nồi 700mm');
71
+ const hit = scoreProduct(rack700, 'kệ xoong nồi 700mm', terms);
72
+ const miss = scoreProduct(rack900, 'kệ xoong nồi 700mm', terms);
73
+ ok(`700mm rack (${hit}) outranks 900mm rack (${miss})`, hit > miss);
74
+ const t2 = extractTerms('kệ 700');
75
+ ok('a bare "700" also finds the 700mm rack',
76
+ scoreProduct(rack700, 'kệ 700', t2) > scoreProduct(rack900, 'kệ 700', t2));
77
+ ok('and the 900 rack is actually penalised for not matching', miss < hit);
78
+ }
79
+
80
+ console.log('\nmaterial matching reads specs, not just the title');
81
+ {
82
+ const title = product({ name: 'Chậu rửa inox MALLOCA', specs: [['Chất liệu', 'Inox 304']] });
83
+ const specsOnly = product({ name: 'Chậu rửa bàn MALLOCA', specs: [['Chất liệu', 'Inox 304']] });
84
+ const terms = extractTerms('chậu rửa inox 304');
85
+ ok('a title mention scores', scoreProduct(title, 'chậu rửa inox 304', terms) > 0);
86
+ ok('a specs-only mention also scores', scoreProduct(specsOnly, 'chậu rửa inox 304', terms) > 0);
87
+ }
88
+
89
+ console.log('\nbrand bonus');
90
+ {
91
+ const malloca = product({ name: 'Bàn ăn gỗ Malloca', brand: 'Malloca', category: 'Bàn ăn' });
92
+ const otherBrand = product({ name: 'Bàn ăn gỗ Eurogold', brand: 'Eurogold', category: 'Bàn ăn' });
93
+ const terms = extractTerms('bàn ăn malloca');
94
+ ok(`query naming the brand wins it (${scoreProduct(malloca, 'bàn ăn malloca', terms)} > ${scoreProduct(otherBrand, 'bàn ăn malloca', terms)})`,
95
+ scoreProduct(malloca, 'bàn ăn malloca', terms) > scoreProduct(otherBrand, 'bàn ăn malloca', terms));
96
+ }
97
+
98
+ console.log('\ncovering the query beats one rare word');
99
+ {
100
+ const both = product({ name: 'Bếp từ MALLOCA 5 vùng', category: 'Bếp từ' });
101
+ const partial = product({ name: 'Bàn ghế gỗ sồi', category: 'Bàn ghế' });
102
+ const terms = extractTerms('bếp từ');
103
+ ok(`both terms in title (${scoreProduct(both, 'bếp từ', terms)}) beats one (${scoreProduct(partial, 'bếp từ', terms)})`,
104
+ scoreProduct(both, 'bếp từ', terms) > scoreProduct(partial, 'bếp từ', terms));
105
+ }
106
+
107
+ console.log('\nunrelated product scores zero');
108
+ {
109
+ const terms = extractTerms('bếp từ');
110
+ ok('a bicycle is not a kitchen product',
111
+ scoreProduct(product({ name: 'Xe đạp thể thao Giant', category: 'Xe đạp' }), 'bếp từ', terms) === 0);
112
+ }
113
+
114
+ console.log('\nsearchLikeAvatar ranks and limits');
115
+ {
116
+ const items = new Map([
117
+ ['GP170BD', gp170],
118
+ ['GP900', other],
119
+ ['BD1054', product({ name: 'Máy giặt Hitachi Inverter 10.5kg BD-1054HVOW', brand: 'Hitachi', category: 'Máy giặt' })],
120
+ ]);
121
+ const top = searchLikeAvatar('GP170BD', items, 5);
122
+ ok('the exact model is first', top[0]?.sku === 'GP170BD', `got ${top[0]?.sku}`);
123
+ check('respects the limit', searchLikeAvatar('bếp', items, 2).length <= 2, true);
124
+ check('an empty query returns nothing', searchLikeAvatar('', items, 5).length, 0);
125
+ check('a query matching nothing returns nothing', searchLikeAvatar('xe đạp', items, 5).length, 0);
126
+ check('a missing catalogue returns nothing', searchLikeAvatar('bếp', null, 5).length, 0);
127
+ }
128
+
129
+ console.log('\nhasDecisiveWinner (the neon gate for products)');
130
+ ok('a 1000-point SKU match with a weak runner-up qualifies',
131
+ hasDecisiveWinner([1000, 20]));
132
+ ok('a lone 1000 with nothing else qualifies',
133
+ hasDecisiveWinner([1000]));
134
+ ok('a mere 550 (the "contains" tier) does not',
135
+ !hasDecisiveWinner([550, 10]));
136
+ ok('a near-tie does not, so a model family does not all light up',
137
+ !hasDecisiveWinner([1000, 900]));
138
+ ok('an empty list does not',
139
+ !hasDecisiveWinner([]));
140
+ ok('a missing list does not',
141
+ !hasDecisiveWinner(undefined));
142
+
143
+ console.log('\nexactMatches — which tiles get the outline');
144
+ {
145
+ // A decisive product winner, keyed the way merge.js keys product tiles.
146
+ const ids = exactMatches([], 'GP170BD', [{ sku: 'GP170BD', score: 1316 }, { sku: 'GP900', score: 10 }]);
147
+ ok('the decisive product is outlined', ids.has('malloca:GP170BD'));
148
+ // A relevance-only winner must not light up.
149
+ const weak = exactMatches([], 'bếp inox', [{ sku: 'A', score: 60 }, { sku: 'B', score: 10 }]);
150
+ ok('a mere relevance match is not outlined', weak.size === 0);
151
+ // Interior: the query appears verbatim in the record's own title.
152
+ const interiors = exactMatches(
153
+ [{ id: 'abc123', score: 0.31, title: 'Bếp đá marble cao cấp', categories: ['Nội thất bếp'] }, { id: 'def456', score: 0.29, title: 'Phòng khách hiện đại' }],
154
+ 'bếp đá',
155
+ );
156
+ ok('an interior whose title contains the query is outlined', interiors.has('abc123'));
157
+ ok('the merely-similar interior is not', !interiors.has('def456'));
158
+ // Accents must fold on both sides.
159
+ const folded = exactMatches([{ id: 'x', score: 0.3, title: 'Bep Da Marble' }], 'bếp đá');
160
+ ok('accent folding works both ways', folded.has('x'));
161
+ // A short query must not match everything by accident.
162
+ ok('a 1-2 character query outlines nothing', exactMatches([{ id: 'x', score: 0.3, title: 'a b' }], 'a').size === 0);
163
+ ok('an empty query outlines nothing', exactMatches([{ id: 'x', score: 0.9, title: 'Bếp đá' }], ' ').size === 0);
164
+ }
165
+
166
+ console.log('\nnormalisation parity with catalogue.js');
167
+ {
168
+ ok('normalizeText folds the same way in both modules',
169
+ normalizeText('Bếp Từ Mây') === 'bep tu may');
170
+ }
171
+
172
+ console.log(`\n${passed} passed, ${failed} failed`);
173
+ if (failed) process.exit(1);
scripts/test-ui.mjs ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Browser check for the three changes: the backdrop, the size/position
3
+ * controls, and the neon outline.
4
+ *
5
+ * Run against a served build:
6
+ * npx vite preview --port 4173 & node scripts/test-ui.mjs
7
+ *
8
+ * Every assertion is about something visible. The neon one in particular is
9
+ * asserted against the RENDERED pixels and computed styles rather than against
10
+ * a class name, because a class that is added but overridden by CSS would pass
11
+ * a class-name check while showing the user nothing at all.
12
+ */
13
+
14
+ import { chromium } from 'playwright';
15
+
16
+ const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173';
17
+ let passed = 0, failed = 0;
18
+ const ok = (label, condition, detail = '') => {
19
+ if (condition) { passed++; console.log(` ok ${label}`); }
20
+ else { failed++; console.log(` FAIL ${label} ${detail}`); }
21
+ };
22
+
23
+ const browser = await chromium.launch();
24
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
25
+ const errors = [];
26
+ page.on('pageerror', error => errors.push(error.message));
27
+ page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
28
+
29
+ await page.goto(BASE, { waitUntil: 'domcontentloaded' });
30
+ // The 130 MB model load is not what is under test here; give React time to mount
31
+ // and paint the field, then stop waiting on it.
32
+ await page.waitForSelector('.image-tile', { timeout: 30000 });
33
+ await page.waitForTimeout(2500);
34
+
35
+ console.log('\n1. backdrop');
36
+ {
37
+ const info = await page.evaluate(() => {
38
+ const el = document.querySelector('.brand-backdrop');
39
+ if (!el) return null;
40
+ const style = getComputedStyle(el);
41
+ return { image: style.backgroundImage, opacity: style.opacity, position: style.position };
42
+ });
43
+ ok('the backdrop element exists', !!info);
44
+ ok('it paints the vai-avatar artwork', !!info?.image?.includes('vaistudio_center.png'), info?.image);
45
+ ok('opacity is 30%', Math.abs(Number(info?.opacity) - 0.3) < 0.001, `got ${info?.opacity}`);
46
+ ok('it is fixed and out of the way', info?.position === 'fixed');
47
+ // The image must actually load, or it is a 30% transparent rectangle.
48
+ const loaded = await page.evaluate(() => new Promise(resolve => {
49
+ const url = getComputedStyle(document.querySelector('.brand-backdrop')).backgroundImage.slice(5, -2);
50
+ const probe = new Image();
51
+ probe.onload = () => resolve(probe.naturalWidth > 0);
52
+ probe.onerror = () => resolve(false);
53
+ probe.src = url;
54
+ setTimeout(() => resolve(false), 8000);
55
+ }));
56
+ ok('the artwork actually decodes (not a 404)', loaded);
57
+ // It must not swallow clicks meant for a photograph.
58
+ const pe = await page.evaluate(() => getComputedStyle(document.querySelector('.brand-backdrop')).pointerEvents);
59
+ ok('it does not intercept pointer events', pe === 'none', `got ${pe}`);
60
+ }
61
+
62
+ console.log('\n2. size & position controls, docked to the search box');
63
+ {
64
+ const toggle = await page.$('.field-controls-toggle');
65
+ ok('the toggle exists', !!toggle);
66
+ if (toggle) {
67
+ // "Near the search box" is a real requirement, so measure it.
68
+ const geo = await page.evaluate(() => {
69
+ const box = document.querySelector('.search-pill')?.getBoundingClientRect();
70
+ const knob = document.querySelector('.field-controls-toggle')?.getBoundingClientRect();
71
+ if (!box || !knob) return null;
72
+ return { gap: knob.top - box.bottom, centres: Math.abs((box.left + box.right) / 2 - (knob.left + knob.right) / 2) };
73
+ });
74
+ ok(`it sits just below the search box (${Math.round(geo?.gap ?? -1)}px gap)`, (geo?.gap ?? -1) >= 0 && (geo?.gap ?? 99) < 120);
75
+ ok(`and is horizontally centred with it (${Math.round(geo?.centres ?? -1)}px off)`, (geo?.centres ?? 99) < 24);
76
+
77
+ await toggle.click();
78
+ await page.waitForSelector('.field-controls-panel', { timeout: 5000 });
79
+ ok('the panel opens', true);
80
+ ok('it has a size control', !!(await page.$('#field-size')));
81
+ ok('it has a position pad', !!(await page.$('.field-controls-pad')));
82
+
83
+ // Size: measure a real tile before and after.
84
+ const before = await page.evaluate(() => {
85
+ const tile = document.querySelector('.image-tile');
86
+ return tile ? parseFloat(getComputedStyle(tile).width) : 0;
87
+ });
88
+ await page.evaluate(() => {
89
+ const slider = document.querySelector('#field-size');
90
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
91
+ setter.call(slider, '170');
92
+ slider.dispatchEvent(new Event('input', { bubbles: true }));
93
+ slider.dispatchEvent(new Event('change', { bubbles: true }));
94
+ });
95
+ await page.waitForTimeout(1800);
96
+ const after = await page.evaluate(() => {
97
+ const tile = document.querySelector('.image-tile');
98
+ return tile ? parseFloat(getComputedStyle(tile).width) : 0;
99
+ });
100
+ ok(`raising the size slider grows the photographs (${Math.round(before)}px -> ${Math.round(after)}px)`,
101
+ after > before * 1.15, `before ${before} after ${after}`);
102
+
103
+ // Position: drag the pad and confirm the plane actually moved.
104
+ const pad = await page.$('.field-controls-pad');
105
+ const box = await pad.boundingBox();
106
+ // Read the recorded offset rather than guessing at a tile: which tile is
107
+ // "first" in the DOM is a seeded z-order detail, so asserting on it made
108
+ // this test depend on layout internals instead of on the observable state.
109
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
110
+ await page.mouse.down();
111
+ await page.mouse.move(box.x + box.width - 4, box.y + box.height - 4, { steps: 12 });
112
+ await page.mouse.up();
113
+ await page.waitForTimeout(1200);
114
+ const view = await page.evaluate(() => JSON.parse(sessionStorage.getItem('sif:view') || '{}'));
115
+ ok(`dragging the pad sets both axes (${view.offsetX}, ${view.offsetY})`,
116
+ (view.offsetX ?? 0) > 100 && (view.offsetY ?? 0) > 100, JSON.stringify(view));
117
+ // And the change must actually reach the photographs' rendered transform.
118
+ // Read the whole field and compare it against the SAME measurement taken
119
+ // before the drag: the first tile in the DOM is a seeded z-order detail,
120
+ // so the spread across all tiles is the reliable signal, not any one tile.
121
+ const span = () => page.evaluate(() => {
122
+ const xs = [...document.querySelectorAll('.image-tile')].map(t => {
123
+ const m = /translate3d\((-?[\d.]+)px/.exec(t.style.transform || getComputedStyle(t).transform);
124
+ return m ? Number(m[1]) : null;
125
+ }).filter(v => v !== null);
126
+ return xs.length ? { min: Math.min(...xs), max: Math.max(...xs), n: xs.length } : null;
127
+ });
128
+ const spanAfter = await span();
129
+ ok(`the photographs moved with it (x spans ${spanAfter ? `${Math.round(spanAfter.min)}..${Math.round(spanAfter.max)} over ${spanAfter.n} tiles` : 'no tiles'})`,
130
+ !!spanAfter && spanAfter.max - spanAfter.min > 200, JSON.stringify(spanAfter));
131
+
132
+ // Persistence across reload is what makes it a setting, not a gesture.
133
+ await page.reload({ waitUntil: 'domcontentloaded' });
134
+ await page.waitForSelector('.image-tile', { timeout: 30000 });
135
+ const persisted = await page.evaluate(() => {
136
+ const slider = document.querySelector('#field-size');
137
+ return sessionStorage.getItem('sif:view');
138
+ });
139
+ ok('the view survives a reload', !!persisted && JSON.parse(persisted).tileScale > 1, persisted);
140
+ await page.evaluate(() => { sessionStorage.clear(); });
141
+ }
142
+ }
143
+
144
+ console.log('\n3. neon outline on an exact match');
145
+ {
146
+ // Render tiles in the field's own coordinate space, with the classes and
147
+ // custom properties ImageField sets, then read the painted result. Typing a
148
+ // real query would mean loading the 130 MB model first, and the outline --
149
+ // not the encoder -- is what is under test here.
150
+ const painted = await page.evaluate(() => {
151
+ const field = document.querySelector('.image-field');
152
+ const exact = document.createElement('button');
153
+ exact.className = 'image-tile exact';
154
+ exact.dataset.exact = 'true';
155
+ exact.style.cssText = 'position:absolute;left:40px;top:40px;width:200px;height:160px;';
156
+ const plain = document.createElement('button');
157
+ plain.className = 'image-tile';
158
+ plain.dataset.exact = '';
159
+ plain.style.cssText = 'position:absolute;left:300px;top:40px;width:200px;height:160px;';
160
+ field.append(exact, plain);
161
+ const ring = getComputedStyle(exact, '::after');
162
+ const noRing = getComputedStyle(plain, '::after');
163
+ const out = {
164
+ content: ring.content,
165
+ borderWidth: ring.borderTopWidth,
166
+ shadow: ring.boxShadow,
167
+ exactOpacity: getComputedStyle(exact).getPropertyValue('--relevance-opacity').trim(),
168
+ plainContent: noRing.content,
169
+ };
170
+ exact.remove(); plain.remove();
171
+ return out;
172
+ });
173
+ ok('the outline pseudo-element exists on an exact tile', painted.content !== 'none' && painted.content.includes('"'));
174
+ ok(`it has a visible stroke (${painted.borderWidth})`, parseFloat(painted.borderWidth) >= 1);
175
+ // count comma-separated shadows; color-mix() resolves to oklab() in Chromium,
176
+ // so match on the geometry ("Npx Npx Npx") rather than on a colour function.
177
+ const glowLayers = (painted.shadow.match(/\d+px\s+\d+px\s+\d+px/g) || []).length;
178
+ ok(`it carries a multi-layer neon glow (${glowLayers} layers)`, glowLayers >= 3, painted.shadow);
179
+ ok('an ordinary tile gets NO outline', painted.plainContent === 'none', `got ${painted.plainContent}`);
180
+ ok('an exact tile is never faded', painted.exactOpacity === '' || Number(painted.exactOpacity) >= 1, `got ${painted.exactOpacity}`);
181
+ }
182
+
183
+ console.log('\n4. no console errors from the new code');
184
+ {
185
+ const relevant = errors.filter(e => !/transformers|onnx|fetch|Failed to load|net::|404|wasm/i.test(e));
186
+ ok(`no unrelated page errors (${relevant.length})`, relevant.length === 0, relevant.slice(0, 3).join(' | '));
187
+ }
188
+
189
+ await page.screenshot({ path: '/tmp/sif-ui.png', fullPage: false });
190
+ console.log('\nscreenshot: /tmp/sif-ui.png');
191
+ await browser.close();
192
+
193
+ console.log(`\n${passed} passed, ${failed} failed`);
194
+ if (failed) process.exit(1);
src/App.jsx CHANGED
@@ -1,7 +1,9 @@
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
  import ImageField from './ImageField.jsx';
3
  import PhotoViewer from './PhotoViewer.jsx';
 
4
  import { mergeResults } from './merge.js';
 
5
 
6
  /**
7
  * How often to re-read the product catalogue so add/edit/delete on vai-avatar
@@ -36,6 +38,23 @@ export default function App() {
36
  const [products, setProducts] = useState([]);
37
  const [productStatus, setProductStatus] = useState({ loading: false, error: '' });
38
  const [catalogue, setCatalogue] = useState({ count: 0 });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  const worker = useRef(null);
40
  const input = useRef(null);
41
  const requestId = useRef(0);
@@ -49,6 +68,18 @@ export default function App() {
49
  const productsRef = useRef([]);
50
  const signature = useRef('');
51
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  useEffect(() => {
53
  const instance = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
54
  worker.current = instance;
@@ -226,7 +257,17 @@ export default function App() {
226
  const catalogueError = !productStatus.error || products.length ? '' : productStatus.error;
227
 
228
  return <main className="app relative isolate h-dvh w-full overflow-hidden">
229
- <ImageField results={field} paused={!!photo} onSelect={(image, origin) => setPhoto({ ...image, origin })} />
 
 
 
 
 
 
 
 
 
 
230
  <div className="vignette" aria-hidden="true" />
231
  <div className="grain" aria-hidden="true" />
232
  <section className="search-center" aria-label="Tìm kiếm ảnh ngữ nghĩa">
@@ -235,6 +276,17 @@ export default function App() {
235
  {query && <button type="button" className="clear-button" aria-label="Xoá tìm kiếm" onClick={() => { changeQuery(''); input.current?.focus(); }}><CloseIcon /></button>}
236
  <div className={`search-progress ${loading || busy ? 'visible' : ''}`} aria-hidden="true"><span style={{ width: loading && status.progress ? `${status.progress}%` : '35%' }} /></div>
237
  </form>
 
 
 
 
 
 
 
 
 
 
 
238
  <div id="search-status" className={`search-status ${error ? 'has-error' : ''}`} role="status" aria-live="polite">
239
  {!error && catalogueError ? <><span className="has-error-inline">{catalogueError}</span></> : null}
240
  {error ? <><span>Không tải được bộ tìm kiếm.</span><button onClick={() => { setStatus({ phase: 'loading', label: 'Retrying' }); worker.current?.postMessage({ type: 'init' }); if (query.trim()) changeQuery(query); }}>Thử lại</button><span className="error-detail">{status.label}</span></> : loading ? <><span className="status-dot" />{status.label}{status.progress != null ? ` · ${Math.round(status.progress)}%` : ''}</> : busy ? <span className="sr-only">Searching</span> : null}
 
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
  import ImageField from './ImageField.jsx';
3
  import PhotoViewer from './PhotoViewer.jsx';
4
+ import FieldControls from './FieldControls.jsx';
5
  import { mergeResults } from './merge.js';
6
+ import { clampFieldOffset, clampTileScale } from './layout.js';
7
 
8
  /**
9
  * How often to re-read the product catalogue so add/edit/delete on vai-avatar
 
38
  const [products, setProducts] = useState([]);
39
  const [productStatus, setProductStatus] = useState({ loading: false, error: '' });
40
  const [catalogue, setCatalogue] = useState({ count: 0 });
41
+ // Size and position of the photograph field. Kept in sessionStorage so the
42
+ // view a visitor arranged survives a reload, which is what makes the control
43
+ // feel like a setting rather than a one-off gesture. A parse failure is not
44
+ // worth surfacing: it falls back to the defaults below.
45
+ const [view, setView] = useState(() => {
46
+ const fallback = { tileScale: 1, offsetX: 0, offsetY: 0 };
47
+ try {
48
+ const raw = sessionStorage.getItem('sif:view');
49
+ if (!raw) return fallback;
50
+ const saved = JSON.parse(raw);
51
+ return {
52
+ tileScale: clampTileScale(saved?.tileScale),
53
+ offsetX: clampFieldOffset(saved?.offsetX),
54
+ offsetY: clampFieldOffset(saved?.offsetY),
55
+ };
56
+ } catch { return fallback; }
57
+ });
58
  const worker = useRef(null);
59
  const input = useRef(null);
60
  const requestId = useRef(0);
 
68
  const productsRef = useRef([]);
69
  const signature = useRef('');
70
 
71
+ // Persist the view controls. Written on change rather than on unload, so a
72
+ // closed tab is not the only thing that saves it.
73
+ useEffect(() => {
74
+ try { sessionStorage.setItem('sif:view', JSON.stringify(view)); } catch { /* private mode: the view just will not persist */ }
75
+ }, [view]);
76
+
77
+ const setTileScale = useCallback(value => setView(current => ({ ...current, tileScale: clampTileScale(value) })), []);
78
+ const setOffset = useCallback((x, y) => setView(current => ({
79
+ ...current, offsetX: clampFieldOffset(x), offsetY: clampFieldOffset(y),
80
+ })), []);
81
+ const resetView = useCallback(() => setView(current => ({ ...current, offsetX: 0, offsetY: 0 })), []);
82
+
83
  useEffect(() => {
84
  const instance = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
85
  worker.current = instance;
 
257
  const catalogueError = !productStatus.error || products.length ? '' : productStatus.error;
258
 
259
  return <main className="app relative isolate h-dvh w-full overflow-hidden">
260
+ <div className="brand-backdrop" aria-hidden="true" />
261
+ <ImageField
262
+ results={field}
263
+ paused={!!photo}
264
+ tileScale={view.tileScale}
265
+ offsetX={view.offsetX}
266
+ offsetY={view.offsetY}
267
+ query={query}
268
+ products={products}
269
+ onSelect={(image, origin) => setPhoto({ ...image, origin })}
270
+ />
271
  <div className="vignette" aria-hidden="true" />
272
  <div className="grain" aria-hidden="true" />
273
  <section className="search-center" aria-label="Tìm kiếm ảnh ngữ nghĩa">
 
276
  {query && <button type="button" className="clear-button" aria-label="Xoá tìm kiếm" onClick={() => { changeQuery(''); input.current?.focus(); }}><CloseIcon /></button>}
277
  <div className={`search-progress ${loading || busy ? 'visible' : ''}`} aria-hidden="true"><span style={{ width: loading && status.progress ? `${status.progress}%` : '35%' }} /></div>
278
  </form>
279
+ {/* Docked to the search box, in the same fixed layer, so nothing in the
280
+ field can ever cover it. Inside the section rather than after it so
281
+ it shares the pill's horizontal centring and its width clamp. */}
282
+ <FieldControls
283
+ tileScale={view.tileScale}
284
+ offsetX={view.offsetX}
285
+ offsetY={view.offsetY}
286
+ onTileScale={setTileScale}
287
+ onOffset={setOffset}
288
+ onReset={resetView}
289
+ />
290
  <div id="search-status" className={`search-status ${error ? 'has-error' : ''}`} role="status" aria-live="polite">
291
  {!error && catalogueError ? <><span className="has-error-inline">{catalogueError}</span></> : null}
292
  {error ? <><span>Không tải được bộ tìm kiếm.</span><button onClick={() => { setStatus({ phase: 'loading', label: 'Retrying' }); worker.current?.postMessage({ type: 'init' }); if (query.trim()) changeQuery(query); }}>Thử lại</button><span className="error-detail">{status.label}</span></> : loading ? <><span className="status-dot" />{status.label}{status.progress != null ? ` · ${Math.round(status.progress)}%` : ''}</> : busy ? <span className="sr-only">Searching</span> : null}
src/FieldControls.jsx ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { clampFieldOffset, clampTileScale, MAX_TILE_SCALE, MIN_TILE_SCALE, MAX_FIELD_OFFSET } from './layout.js';
3
+
4
+ /**
5
+ * Size and position controls, docked to the search box.
6
+ * =====================================================
7
+ *
8
+ * The field is a canvas, so there is nowhere in it to put a control: any
9
+ * toolbar would sit on top of photographs and be covered by them the moment one
10
+ * drifted under it. So these live in the same fixed layer as the search pill
11
+ * and directly below it, where nothing can overlap them -- and they are
12
+ * deliberately one compact row so they read as part of the search UI rather
13
+ * than as a second floating panel.
14
+ *
15
+ * ── Why size is a slider and position is a pad ──
16
+ * Size is a single scalar with a continuous feel (bigger / smaller), so a
17
+ * range input with a live readout is the honest control and works on touch and
18
+ * with a keyboard for free.
19
+ *
20
+ * Position is two dimensions at once, and a pair of sliders for that is bad:
21
+ * it takes two hands of travel and gives no sense of the field as a plane.
22
+ * A small trackpad-style pad maps one finger to one drag, shows both axes at
23
+ * once, and returns to centre by double-click. That is why it is not two
24
+ * <input type=range> elements.
25
+ *
26
+ * ── Why the controls are collapsed by default ──
27
+ * They are for adjusting the view, not for searching. A permanent panel under
28
+ * the search box competes with the photographs, which is the entire content of
29
+ * this Space. The button reports its state (open/closed) and the panel can be
30
+ * dismissed with Escape or by clicking away.
31
+ */
32
+
33
+ /** Keep the pad pointer capture honest across a drag that leaves the element. */
34
+ function useDrag(onMove) {
35
+ const handler = useRef(onMove);
36
+ handler.current = onMove;
37
+ return event => {
38
+ if (event.button != null && event.button !== 0) return;
39
+ event.preventDefault();
40
+ const target = event.currentTarget;
41
+ const rect = target.getBoundingClientRect();
42
+ const send = (pointerEvent) => {
43
+ // Normalise to [-1, 1] on each axis independently. The pad is square, so
44
+ // both axes share a divisor and the mapping stays honest even when the
45
+ // box is rendered at a fractional size by the fluid width.
46
+ const x = ((pointerEvent.clientX - rect.left) / rect.width) * 2 - 1;
47
+ const y = ((pointerEvent.clientY - rect.top) / rect.height) * 2 - 1;
48
+ handler.current(x, y);
49
+ };
50
+ send(event);
51
+ target.setPointerCapture?.(event.pointerId);
52
+ const move = pointerEvent => send(pointerEvent);
53
+ const up = pointerEvent => {
54
+ target.releasePointerCapture?.(pointerEvent.pointerId);
55
+ target.removeEventListener('pointermove', move);
56
+ target.removeEventListener('pointerup', up);
57
+ target.removeEventListener('pointercancel', up);
58
+ };
59
+ target.addEventListener('pointermove', move);
60
+ target.addEventListener('pointerup', up);
61
+ target.addEventListener('pointercancel', up);
62
+ };
63
+ }
64
+
65
+ export default function FieldControls({ tileScale, offsetX, offsetY, onTileScale, onOffset, onReset }) {
66
+ const [open, setOpen] = useState(false);
67
+ const wrap = useRef(null);
68
+
69
+ // Close on Escape and on an outside click. Both are needed: Escape is the
70
+ // keyboard path and a click-away is the mouse path, and a panel that only
71
+ // responds to one of them always leaves someone unable to dismiss it.
72
+ useEffect(() => {
73
+ if (!open) return;
74
+ const onKey = event => { if (event.key === 'Escape') setOpen(false); };
75
+ const onDown = event => { if (!wrap.current?.contains(event.target)) setOpen(false); };
76
+ document.addEventListener('keydown', onKey);
77
+ document.addEventListener('pointerdown', onDown);
78
+ return () => {
79
+ document.removeEventListener('keydown', onKey);
80
+ document.removeEventListener('pointerdown', onDown);
81
+ };
82
+ }, [open]);
83
+
84
+ const startDrag = useDrag((x, y) => {
85
+ onOffset(clampFieldOffset((x * MAX_FIELD_OFFSET) | 0), clampFieldOffset((y * MAX_FIELD_OFFSET) | 0));
86
+ });
87
+
88
+ const percent = Math.round(tileScale * 100);
89
+
90
+ return <div className="field-controls" ref={wrap}>
91
+ <button
92
+ type="button"
93
+ className={`field-controls-toggle ${open ? 'is-open' : ''}`}
94
+ aria-expanded={open}
95
+ aria-controls="field-controls-panel"
96
+ onClick={() => setOpen(value => !value)}
97
+ title="Kích thước và vị trí ảnh"
98
+ >
99
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true">
100
+ <rect x="3" y="3" width="7" height="7" rx="1.5" />
101
+ <rect x="14" y="14" width="7" height="7" rx="1.5" />
102
+ <path d="M14 6.5h7M17.5 3v7" strokeLinecap="round" />
103
+ <path d="M10 17.5H3M6.5 21v-7" strokeLinecap="round" />
104
+ </svg>
105
+ <span>Kích thước &amp; vị trí</span>
106
+ </button>
107
+
108
+ {open && <div className="field-controls-panel" id="field-controls-panel">
109
+ <div className="field-controls-row">
110
+ <label className="field-controls-label" htmlFor="field-size">
111
+ Kích thước ảnh
112
+ <output className="field-controls-value" htmlFor="field-size">{percent}%</output>
113
+ </label>
114
+ <input
115
+ id="field-size"
116
+ className="field-controls-range"
117
+ type="range"
118
+ min={Math.round(MIN_TILE_SCALE * 100)}
119
+ max={Math.round(MAX_TILE_SCALE * 100)}
120
+ step={5}
121
+ value={percent}
122
+ onChange={event => onTileScale(clampTileScale(Number(event.target.value) / 100))}
123
+ aria-label="Kích thước ảnh, phần trăm"
124
+ />
125
+ </div>
126
+
127
+ <div className="field-controls-row">
128
+ <span className="field-controls-label" id="field-position-label">
129
+ Vị trí ảnh
130
+ <output className="field-controls-value">{offsetX > 0 ? '+' : ''}{offsetX}, {offsetY > 0 ? '+' : ''}{offsetY}</output>
131
+ </span>
132
+ {/* A trackpad, not two sliders. Both axes at once, one pointer. */}
133
+ <div
134
+ className="field-controls-pad"
135
+ role="application"
136
+ aria-labelledby="field-position-label"
137
+ tabIndex={0}
138
+ onPointerDown={startDrag}
139
+ onDoubleClick={() => onOffset(0, 0)}
140
+ onKeyDown={event => {
141
+ const step = event.shiftKey ? 60 : 20;
142
+ if (event.key === 'ArrowLeft') onOffset(clampFieldOffset(offsetX - step), offsetY);
143
+ else if (event.key === 'ArrowRight') onOffset(clampFieldOffset(offsetX + step), offsetY);
144
+ else if (event.key === 'ArrowUp') onOffset(offsetX, clampFieldOffset(offsetY - step));
145
+ else if (event.key === 'ArrowDown') onOffset(offsetX, clampFieldOffset(offsetY + step));
146
+ else return;
147
+ event.preventDefault();
148
+ }}
149
+ >
150
+ <span className="field-controls-pad-dot" style={{ left: `${50 + (offsetX / MAX_FIELD_OFFSET) * 50}%`, top: `${50 + (offsetY / MAX_FIELD_OFFSET) * 50}%` }} />
151
+ </div>
152
+ </div>
153
+
154
+ <div className="field-controls-actions">
155
+ <button
156
+ type="button"
157
+ className="field-controls-reset"
158
+ onClick={() => { onTileScale(1); onReset?.(); }}
159
+ >
160
+ Đặt lại
161
+ </button>
162
+ </div>
163
+ </div>}
164
+ </div>;
165
+ }
src/ImageField.jsx CHANGED
@@ -17,11 +17,17 @@ import { averageColor, createImageLoader, createPlaceholderLoader, thumbnailWidt
17
  const LIFTED_Z = 200;
18
 
19
  /** A single population of photographs, one coordinate plane, one animation clock. */
20
- export default function ImageField({ results, onSelect, paused }) {
21
  const surface = useRef(null);
22
  const engine = useRef(null);
23
  const selection = useRef(onSelect);
24
  selection.current = onSelect;
 
 
 
 
 
 
25
 
26
  useEffect(() => {
27
  const root = surface.current;
@@ -117,7 +123,7 @@ export default function ImageField({ results, onSelect, paused }) {
117
  }
118
 
119
  function resize(node) {
120
- const { width, height } = dimensions(node.image, state.width);
121
  if (node.width === width && node.height === height) return;
122
  node.width = width; node.height = height;
123
  node.button.style.width = `${width}px`;
@@ -161,10 +167,15 @@ export default function ImageField({ results, onSelect, paused }) {
161
  const now = performance.now();
162
  const active = new Set(state.pool.map(image => image.id));
163
  const ranks = new Map(state.results.map((image, rank) => [image.id, rank]));
 
 
 
164
  const targets = state.pool.map((image, index) => state.results.length
165
- ? fieldPosition(image, index, state.pool.length, state.width, state.height)
166
- : scatter(image, index, state.pool.length, state.width, state.height));
167
- const appearance = relevanceAppearance(state.results);
 
 
168
  state.pool.forEach((image, index) => {
169
  const target = targets[index];
170
  const node = nodes.get(image.id) || create(image, target, initial);
@@ -202,10 +213,22 @@ export default function ImageField({ results, onSelect, paused }) {
202
  node.load?.prioritize(rank ?? 200 + index);
203
  node.button.setAttribute('aria-label', rank == null ? 'Phóng to ảnh' : `Phóng to kết quả ${rank + 1}`);
204
  node.button.setAttribute('aria-haspopup', 'dialog');
205
- const { opacity, blur } = appearance(image.id);
 
 
 
 
206
  node.button.style.setProperty('--relevance-opacity', opacity);
207
  node.button.style.setProperty('--relevance-blur', `${blur}px`);
 
 
 
 
 
 
 
208
  node.button.dataset.rank = rank == null ? '' : rank + 1;
 
209
  node.button.tabIndex = state.results.length ? (rank != null && rank < 12 ? 0 : -1) : (index < 12 ? 0 : -1);
210
  transition(node, target, now, initial || motion.matches);
211
  });
@@ -215,6 +238,10 @@ export default function ImageField({ results, onSelect, paused }) {
215
  node.load?.cancel(); node.load = null;
216
  node.button.setAttribute('aria-hidden', 'true');
217
  node.button.dataset.rank = '';
 
 
 
 
218
  node.button.tabIndex = -1;
219
  transition(node, edgePosition(node.image, state.width, state.height, node), now, motion.matches);
220
  }
@@ -344,5 +371,23 @@ export default function ImageField({ results, onSelect, paused }) {
344
  engine.current.state.paused = paused;
345
  }, [paused]);
346
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  return <div className="image-field" ref={surface} aria-label="Photograph collection" />;
348
  }
 
17
  const LIFTED_Z = 200;
18
 
19
  /** A single population of photographs, one coordinate plane, one animation clock. */
20
+ export default function ImageField({ results, onSelect, paused, tileScale = 1, offsetX = 0, offsetY = 0, query = '', products = [] }) {
21
  const surface = useRef(null);
22
  const engine = useRef(null);
23
  const selection = useRef(onSelect);
24
  selection.current = onSelect;
25
+ // The controls are read inside the long-lived effect, so they are mirrored
26
+ // into refs rather than becoming effect dependencies. Making them dependencies
27
+ // would tear down and rebuild every tile -- and re-request every thumbnail --
28
+ // on each slider tick.
29
+ const controls = useRef({ tileScale, offsetX, offsetY, query, products });
30
+ controls.current = { tileScale, offsetX, offsetY, query, products };
31
 
32
  useEffect(() => {
33
  const root = surface.current;
 
123
  }
124
 
125
  function resize(node) {
126
+ const { width, height } = dimensions(node.image, state.width, controls.current.tileScale);
127
  if (node.width === width && node.height === height) return;
128
  node.width = width; node.height = height;
129
  node.button.style.width = `${width}px`;
 
167
  const now = performance.now();
168
  const active = new Set(state.pool.map(image => image.id));
169
  const ranks = new Map(state.results.map((image, rank) => [image.id, rank]));
170
+ // Read the controls once per pass: every tile in a pass must agree on
171
+ // where the plane sits, or the collection visibly tears apart mid-drag.
172
+ const { tileScale: scale, offsetX: shiftX, offsetY: shiftY } = controls.current;
173
  const targets = state.pool.map((image, index) => state.results.length
174
+ ? fieldPosition(image, index, state.pool.length, state.width, state.height, shiftX, shiftY)
175
+ : scatter(image, index, state.pool.length, state.width, state.height, shiftX, shiftY));
176
+ // The query and the product hits go in so relevance.js can decide which
177
+ // tiles are EXACT matches (see exactMatches) and get the neon outline.
178
+ const appearance = relevanceAppearance(state.results, controls.current.query, controls.current.products);
179
  state.pool.forEach((image, index) => {
180
  const target = targets[index];
181
  const node = nodes.get(image.id) || create(image, target, initial);
 
213
  node.load?.prioritize(rank ?? 200 + index);
214
  node.button.setAttribute('aria-label', rank == null ? 'Phóng to ảnh' : `Phóng to kết quả ${rank + 1}`);
215
  node.button.setAttribute('aria-haspopup', 'dialog');
216
+ // `exact` comes back on the same appearance object as the fade, so the
217
+ // outline and the dimming can never disagree about which tile is the
218
+ // answer. classList.toggle (not add) because a tile keeps its node
219
+ // across searches and would otherwise keep a stale outline.
220
+ const { opacity, blur, exact } = appearance(image.id);
221
  node.button.style.setProperty('--relevance-opacity', opacity);
222
  node.button.style.setProperty('--relevance-blur', `${blur}px`);
223
+ node.button.classList.toggle('exact', !!exact);
224
+ // An exact hit is never allowed to fade with the tail, whatever its
225
+ // cosine: if the outline says "this is the one", it has to be readable.
226
+ if (exact) {
227
+ node.button.style.setProperty('--relevance-opacity', 1);
228
+ node.button.style.setProperty('--relevance-blur', '0px');
229
+ }
230
  node.button.dataset.rank = rank == null ? '' : rank + 1;
231
+ node.button.dataset.exact = exact ? 'true' : '';
232
  node.button.tabIndex = state.results.length ? (rank != null && rank < 12 ? 0 : -1) : (index < 12 ? 0 : -1);
233
  transition(node, target, now, initial || motion.matches);
234
  });
 
238
  node.load?.cancel(); node.load = null;
239
  node.button.setAttribute('aria-hidden', 'true');
240
  node.button.dataset.rank = '';
241
+ // Clear the outline on the way out: a tile drifting off-screen must not
242
+ // keep advertising itself as the answer to the previous query.
243
+ node.button.dataset.exact = '';
244
+ node.button.classList.remove('exact');
245
  node.button.tabIndex = -1;
246
  transition(node, edgePosition(node.image, state.width, state.height, node), now, motion.matches);
247
  }
 
371
  engine.current.state.paused = paused;
372
  }, [paused]);
373
 
374
+ /**
375
+ * Re-run the layout when the size or position controls move.
376
+ *
377
+ * This is a plain update() rather than a remount: the tiles are already in
378
+ * the DOM with their thumbnails loaded, and layout() reads the controls from
379
+ * the ref it was handed. transition() then animates each tile from its
380
+ * current position to the new one, so dragging the size slider grows the
381
+ * collection continuously instead of snapping on the last pixel.
382
+ *
383
+ * The controls themselves are NOT effect dependencies on purpose -- see the
384
+ * ref at the top -- because a dependency would destroy the worker-backed
385
+ * field on every input event.
386
+ */
387
+ useEffect(() => {
388
+ if (!engine.current) return;
389
+ engine.current.update(engine.current.state.results);
390
+ }, [tileScale, offsetX, offsetY]);
391
+
392
  return <div className="image-field" ref={surface} aria-label="Photograph collection" />;
393
  }
src/index.css CHANGED
@@ -7,7 +7,44 @@ button, input, a { -webkit-tap-highlight-color: transparent; }
7
  button, input { font: inherit; }
8
  button { cursor: pointer; }
9
  button:focus-visible, a:focus-visible { outline: 2px solid #d7e1d1; outline-offset: 5px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  .app { min-height: 320px; background: #11120f; overflow: clip; }
 
 
11
  /* Unlike hidden, clip prevents focus/scrollIntoView from panning the canvas. */
12
  .image-field { position: absolute; inset: 0; overflow: clip; contain: strict; }
13
  .image-tile { position: absolute; left: 0; top: 0; display: block; border: 0; padding: 0; border-radius: 2px; background-color: #252823; background-size: cover; overflow: hidden; will-change: transform; transform-origin: center; box-shadow: 0 1px 5px #0002; }
@@ -65,6 +102,110 @@ button:focus-visible, a:focus-visible { outline: 2px solid #d7e1d1; outline-offs
65
  .clear-button { position: absolute; right: 13px; top: 50%; transform: translateY(-50%); width: 34px; height: 34px; display: grid; place-items: center; padding: 7px; color: #4c5449; border: 0; background: transparent; border-radius: 50%; transition: background 150ms; }
66
  .clear-button:hover { background: #0000000d; }
67
  .clear-button svg { width: 18px; height: 18px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  .search-progress { position: absolute; bottom: 0; left: 24px; right: 24px; height: 1px; opacity: 0; overflow: hidden; transition: opacity 300ms; }
69
  .search-progress.visible { opacity: .5; }
70
  .search-progress span { display: block; height: 100%; background: #47513f; transition: width 300ms; animation: progress-glow 1.6s ease-in-out infinite; }
 
7
  button, input { font: inherit; }
8
  button { cursor: pointer; }
9
  button:focus-visible, a:focus-visible { outline: 2px solid #d7e1d1; outline-offset: 5px; }
10
+
11
+ /* ── V.AI STUDIO background ────────────────────────────────────────────────
12
+ * The same artwork vai-avatar shows on its body: public/avatars/vaistudio_center.png,
13
+ * copied here byte-for-byte so the two Spaces read as one brand (md5 checked).
14
+ *
15
+ * Rendered at 30% opacity as a separate fixed layer rather than as a
16
+ * `background-image` on .app, because:
17
+ *
18
+ * * opacity on the element would fade the photographs and the search pill
19
+ * along with it, and
20
+ * * `background-attachment: fixed` + `background-image` cannot be composed
21
+ * with the two dimming layers (.vignette / .grain) that already sit on top
22
+ * of the field, so the artwork would fight them instead of sitting under.
23
+ *
24
+ * `pointer-events: none` keeps it from swallowing clicks meant for a tile, and
25
+ * z-index 0 puts it below .image-field (z-index auto, painted later) while
26
+ * still above the base background colour.
27
+ *
28
+ * The image is 816x816 and deliberately centred-cropped on the logo, matching
29
+ * vai-avatar's own mobile rule: on narrow portrait screens it is sized to full
30
+ * width and tiled vertically so the artwork is never cropped to a logo sliver.
31
+ */
32
+ .brand-backdrop {
33
+ position: fixed; inset: 0; z-index: 0; pointer-events: none;
34
+ background-color: #10110f;
35
+ background-image: url('/brand/vaistudio_center.png');
36
+ background-size: cover;
37
+ background-position: center center;
38
+ background-repeat: no-repeat;
39
+ background-attachment: fixed;
40
+ opacity: .3;
41
+ }
42
+ @media (max-width: 768px) {
43
+ .brand-backdrop { background-position: center top; background-size: 100% auto; background-repeat: repeat; background-attachment: scroll; }
44
+ }
45
  .app { min-height: 320px; background: #11120f; overflow: clip; }
46
+ /* Above the backdrop, below the field's own dimming layers. */
47
+ .app { background: transparent; }
48
  /* Unlike hidden, clip prevents focus/scrollIntoView from panning the canvas. */
49
  .image-field { position: absolute; inset: 0; overflow: clip; contain: strict; }
50
  .image-tile { position: absolute; left: 0; top: 0; display: block; border: 0; padding: 0; border-radius: 2px; background-color: #252823; background-size: cover; overflow: hidden; will-change: transform; transform-origin: center; box-shadow: 0 1px 5px #0002; }
 
102
  .clear-button { position: absolute; right: 13px; top: 50%; transform: translateY(-50%); width: 34px; height: 34px; display: grid; place-items: center; padding: 7px; color: #4c5449; border: 0; background: transparent; border-radius: 50%; transition: background 150ms; }
103
  .clear-button:hover { background: #0000000d; }
104
  .clear-button svg { width: 18px; height: 18px; }
105
+
106
+ /* ── Neon outline: the exact hit, against everything else ──────────────────
107
+ * A tile only gets this when relevance.js could verify an IDENTITY, not a
108
+ * resemblance: a product vai-avatar's scorer resolved by exact SKU/model/title
109
+ * with a wide margin over the runner-up, or an interior whose own title /
110
+ * category / description literally contains the query. Everything else keeps
111
+ * the relevance fade and gets nothing here, which is the comparison the
112
+ * outline exists to make.
113
+ *
114
+ * The glow is drawn on ::after rather than as a box-shadow on the tile itself,
115
+ * because the tile carries `filter: blur(...)` for the relevance fade, and a
116
+ * filter creates a containing block / new stacking context that flattens the
117
+ * outer glow into a hard ring. A pseudo-element child escapes that.
118
+ *
119
+ * The outline deliberately does NOT scale with the tile's transform: the tile
120
+ * is animated with translate+scale every frame, so an inset:0 pseudo would
121
+ * inherit that scale and the ring's thickness would pulse. Fixed pixel
122
+ * inset and radius keep the stroke even while the photograph moves.
123
+ */
124
+ .image-tile { --neon: #22f5d0; --neon-soft: #7ef9ff; }
125
+ .image-tile.exact { --relevance-opacity: 1 !important; --relevance-blur: 0px !important; }
126
+ .image-tile.exact::after {
127
+ content: ''; position: absolute; inset: 0; z-index: 2;
128
+ border: 1.5px solid var(--neon);
129
+ border-radius: 2px;
130
+ box-shadow:
131
+ 0 0 6px 1px color-mix(in srgb, var(--neon) 85%, transparent),
132
+ 0 0 16px 3px color-mix(in srgb, var(--neon) 55%, transparent),
133
+ inset 0 0 12px color-mix(in srgb, var(--neon-soft) 45%, transparent);
134
+ pointer-events: none;
135
+ animation: neon-breathe 2.6s ease-in-out infinite;
136
+ }
137
+ @keyframes neon-breathe {
138
+ 0%, 100% { box-shadow: 0 0 6px 1px color-mix(in srgb, var(--neon) 80%, transparent), 0 0 15px 3px color-mix(in srgb, var(--neon) 50%, transparent), inset 0 0 11px color-mix(in srgb, var(--neon-soft) 40%, transparent); }
139
+ 50% { box-shadow: 0 0 9px 2px color-mix(in srgb, var(--neon) 95%, transparent), 0 0 24px 5px color-mix(in srgb, var(--neon) 65%, transparent), inset 0 0 16px color-mix(in srgb, var(--neon-soft) 55%, transparent); }
140
+ }
141
+ /* A slow, low-frequency glow reads as "lit"; a fast strobe reads as an alarm,
142
+ and this outline marks a correct answer rather than an error. */
143
+ @media (prefers-reduced-motion: reduce) { .image-tile.exact::after { animation: none; } }
144
+
145
+ /* ── Size & position controls, docked under the search pill ─────────────── */
146
+ .field-controls { position: relative; display: flex; justify-content: center; margin-top: 14px; pointer-events: none; }
147
+ .field-controls > * { pointer-events: auto; }
148
+ .field-controls-toggle {
149
+ display: inline-flex; align-items: center; gap: 8px;
150
+ padding: 8px 15px; border-radius: 999px;
151
+ border: 1px solid #ffffff2b;
152
+ background: #161914cc; -webkit-backdrop-filter: blur(18px) saturate(.75); backdrop-filter: blur(18px) saturate(.75);
153
+ color: #d5dbcd; font-size: 12.5px; letter-spacing: .01em; cursor: pointer;
154
+ transition: background 180ms, border-color 180ms, color 180ms;
155
+ }
156
+ .field-controls-toggle:hover { background: #1d2219e0; border-color: #ffffff45; color: #f0f4ea; }
157
+ .field-controls-toggle.is-open { background: #232a1dee; border-color: var(--neon-soft); color: #f4f8ef; }
158
+ .field-controls-toggle svg { width: 16px; height: 16px; opacity: .85; }
159
+
160
+ .field-controls-panel {
161
+ position: absolute; top: calc(100% + 10px); left: 50%; transform: translateX(-50%);
162
+ width: min(300px, calc(100vw - 40px));
163
+ display: flex; flex-direction: column; gap: 15px;
164
+ padding: 16px; border-radius: 15px;
165
+ border: 1px solid #ffffff24;
166
+ background: #12150fee; -webkit-backdrop-filter: blur(22px) saturate(.8); backdrop-filter: blur(22px) saturate(.8);
167
+ box-shadow: 0 14px 44px #00000070;
168
+ z-index: 402;
169
+ }
170
+ .field-controls-row { display: flex; flex-direction: column; gap: 8px; }
171
+ .field-controls-label {
172
+ display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
173
+ font-size: 11.5px; letter-spacing: .06em; text-transform: uppercase;
174
+ color: #9aa393;
175
+ }
176
+ .field-controls-value { font-size: 12.5px; letter-spacing: 0; text-transform: none; color: var(--neon-soft); font-variant-numeric: tabular-nums; }
177
+
178
+ .field-controls-range { -webkit-appearance: none; appearance: none; width: 100%; height: 4px; border-radius: 999px; background: linear-gradient(90deg, #3c4a37, #6f8a63); outline: none; }
179
+ .field-controls-range::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 17px; height: 17px; border-radius: 50%; background: var(--neon-soft); border: 2px solid #0e110b; box-shadow: 0 0 8px color-mix(in srgb, var(--neon) 65%, transparent); cursor: grab; }
180
+ .field-controls-range::-moz-range-thumb { width: 15px; height: 15px; border-radius: 50%; background: var(--neon-soft); border: 2px solid #0e110b; cursor: grab; }
181
+
182
+ /* A square trackpad rather than two sliders: one finger moves the plane on
183
+ both axes at once, which is what "position" means for a field of tiles. */
184
+ .field-controls-pad {
185
+ position: relative; width: 100%; aspect-ratio: 1; max-height: 148px; margin: 0 auto;
186
+ border-radius: 11px; border: 1px solid #ffffff1f;
187
+ background:
188
+ linear-gradient(#ffffff0d, #ffffff0d) center / 1px 100% no-repeat,
189
+ linear-gradient(#ffffff0d, #ffffff0d) center / 100% 1px no-repeat,
190
+ #0d100b;
191
+ cursor: grab; touch-action: none; /* without this a touch drag scrolls the page instead of moving the field */
192
+ }
193
+ .field-controls-pad:active { cursor: grabbing; }
194
+ .field-controls-pad:focus-visible { outline: 2px solid var(--neon-soft); outline-offset: 3px; }
195
+ .field-controls-pad-dot {
196
+ position: absolute; width: 13px; height: 13px; border-radius: 50%;
197
+ transform: translate(-50%, -50%);
198
+ background: var(--neon-soft);
199
+ box-shadow: 0 0 9px color-mix(in srgb, var(--neon) 80%, transparent);
200
+ pointer-events: none;
201
+ }
202
+ .field-controls-actions { display: flex; justify-content: flex-end; }
203
+ .field-controls-reset {
204
+ padding: 6px 13px; border-radius: 999px; border: 1px solid #ffffff24;
205
+ background: transparent; color: #b6bdab; font-size: 12px; cursor: pointer;
206
+ transition: background 180ms, color 180ms;
207
+ }
208
+ .field-controls-reset:hover { background: #ffffff12; color: #f0f4ea; }
209
  .search-progress { position: absolute; bottom: 0; left: 24px; right: 24px; height: 1px; opacity: 0; overflow: hidden; transition: opacity 300ms; }
210
  .search-progress.visible { opacity: .5; }
211
  .search-progress span { display: block; height: 100%; background: #47513f; transition: width 300ms; animation: progress-glow 1.6s ease-in-out infinite; }
src/layout.js CHANGED
@@ -7,25 +7,60 @@ export function seed(text) {
7
 
8
  export function fieldCount(width) { return width < 650 ? 56 : 96; }
9
 
10
- export function dimensions(image, viewportWidth) {
11
- const unit = Math.max(68, Math.min(168, viewportWidth / 10.25));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  const area = unit * unit * (0.74 + seed(image.id + 'size') * 0.48);
13
  const ar = Math.max(0.5, Math.min(2, image.ar));
14
  return { width: Math.sqrt(area * ar), height: Math.sqrt(area / ar) };
15
  }
16
 
17
- export function scatter(image, index, count, width, height) {
18
  const columns = Math.ceil(Math.sqrt(count * width / height));
19
  const rows = Math.ceil(count / columns);
20
  return {
21
- x: -35 + (index % columns + 0.5 + (seed(image.id + 'x') - 0.5) * 0.44) * (width + 70) / columns,
22
- y: -40 + (Math.floor(index / columns) + 0.5 + (seed(image.id + 'y') - 0.5) * 0.48) * (height + 80) / rows,
23
  scale: 0.88 + seed(image.id + 'scale') * 0.20,
24
  };
25
  }
26
 
27
  /** One continuous layout for the whole collection, including non-matching photos. */
28
- export function fieldPosition(image, rank, count, width, height) {
29
  const angle = rank * 2.399963229728653 + 0.54;
30
  const radius = Math.sqrt((rank + 0.6) / count);
31
  const x = width / 2 + Math.cos(angle) * radius * width * 0.68;
@@ -34,7 +69,7 @@ export function fieldPosition(image, rank, count, width, height) {
34
  const clearance = Math.max(0, 1 - Math.abs(x - width / 2) / (width * 0.31));
35
  y += Math.sign(y || 1) * clearance * (width < 650 ? 65 : 95) * (1 - radius * 0.45);
36
  return {
37
- x, y: height / 2 + y,
38
  scale: 0.96 + seed(image.id + 'scale') * 0.18 + (1 - radius) * 0.18,
39
  };
40
  }
 
7
 
8
  export function fieldCount(width) { return width < 650 ? 56 : 96; }
9
 
10
+ /**
11
+ * Tile size bounds for the user-facing size control.
12
+ *
13
+ * Below the lower bound the tiles stop being recognisable as photographs, and
14
+ * above the upper bound a single tile covers the search pill and most of the
15
+ * viewport, so the field stops reading as a collection at all.
16
+ */
17
+ export const MIN_TILE_SCALE = 0.6;
18
+ export const MAX_TILE_SCALE = 1.8;
19
+
20
+ /**
21
+ * How far the whole plane may be pushed, in px, before it stops being
22
+ * reachable. Larger than half the shortest likely viewport, so a photographer
23
+ * can be moved out from under the search pill without being lost off-screen.
24
+ */
25
+ export const MAX_FIELD_OFFSET = 320;
26
+
27
+ export function clampTileScale(value) {
28
+ const scale = Number(value);
29
+ return Number.isFinite(scale) ? Math.max(MIN_TILE_SCALE, Math.min(MAX_TILE_SCALE, scale)) : 1;
30
+ }
31
+
32
+ export function clampFieldOffset(value) {
33
+ const offset = Number(value);
34
+ return Number.isFinite(offset) ? Math.max(-MAX_FIELD_OFFSET, Math.min(MAX_FIELD_OFFSET, offset)) : 0;
35
+ }
36
+
37
+ /**
38
+ * Size one tile.
39
+ *
40
+ * `tileScale` is the user's size multiplier and is applied to `unit` BEFORE the
41
+ * per-photograph variance, so every tile grows and shrinks together and the
42
+ * collection keeps its rhythm instead of the biggest photographs absorbing the
43
+ * whole change.
44
+ */
45
+ export function dimensions(image, viewportWidth, tileScale = 1) {
46
+ const unit = Math.max(68, Math.min(168, viewportWidth / 10.25)) * tileScale;
47
  const area = unit * unit * (0.74 + seed(image.id + 'size') * 0.48);
48
  const ar = Math.max(0.5, Math.min(2, image.ar));
49
  return { width: Math.sqrt(area * ar), height: Math.sqrt(area / ar) };
50
  }
51
 
52
+ export function scatter(image, index, count, width, height, offsetX = 0, offsetY = 0) {
53
  const columns = Math.ceil(Math.sqrt(count * width / height));
54
  const rows = Math.ceil(count / columns);
55
  return {
56
+ x: -35 + (index % columns + 0.5 + (seed(image.id + 'x') - 0.5) * 0.44) * (width + 70) / columns + offsetX,
57
+ y: -40 + (Math.floor(index / columns) + 0.5 + (seed(image.id + 'y') - 0.5) * 0.48) * (height + 80) / rows + offsetY,
58
  scale: 0.88 + seed(image.id + 'scale') * 0.20,
59
  };
60
  }
61
 
62
  /** One continuous layout for the whole collection, including non-matching photos. */
63
+ export function fieldPosition(image, rank, count, width, height, offsetX = 0, offsetY = 0) {
64
  const angle = rank * 2.399963229728653 + 0.54;
65
  const radius = Math.sqrt((rank + 0.6) / count);
66
  const x = width / 2 + Math.cos(angle) * radius * width * 0.68;
 
69
  const clearance = Math.max(0, 1 - Math.abs(x - width / 2) / (width * 0.31));
70
  y += Math.sign(y || 1) * clearance * (width < 650 ? 65 : 95) * (1 - radius * 0.45);
71
  return {
72
+ x: x + offsetX, y: height / 2 + y + offsetY,
73
  scale: 0.96 + seed(image.id + 'scale') * 0.18 + (1 - radius) * 0.18,
74
  };
75
  }
src/product-search.js ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Product search, ported from the vai-avatar Space so results match it exactly.
3
+ * =========================================================================
4
+ *
5
+ * vai-avatar ranks its catalogue in `src/vaix-rag.js::scoreProduct()`. That
6
+ * function encodes a lot of hard-won behaviour that a plain token-overlap
7
+ * score does not have, and every rule below is carried over rather than
8
+ * reinvented:
9
+ *
10
+ * * an exact SKU / model / title match must surface FIRST and ideally alone
11
+ * (typing "GP170BD" is a lookup, not a browse);
12
+ * * millimetre sizes are compared NUMERICALLY, because the specs write them
13
+ * inconsistently ("900 mm", "900mm", "W565 x D280 x H650", "Khoang tủ: 900");
14
+ * * material words ("inox 304", "mây", "kính cường lực") are matched across
15
+ * title AND specs;
16
+ * * brand names are worth a large flat bonus;
17
+ * * multi-term matches are rewarded, so covering the whole query beats
18
+ * matching one rare word very hard.
19
+ *
20
+ * ── Why a separate module instead of extending searchCatalogue() ──
21
+ * catalogue.js already ranks products, but it ranks them with weighted token
22
+ * overlap, which is a genuinely different ranking. Dropping vai-avatar's rules
23
+ * into that function would have meant shipping one scorer with two sets of
24
+ * weights, where the older one's prefix bonus and the new one's exact-match
25
+ * bonus both fire and neither is predictable. Keeping them apart means the
26
+ * behaviour below is readable as vai-avatar's behaviour and can be diffed
27
+ * against the original when it changes.
28
+ *
29
+ * The two modules agree on normalisation: catalogue.js exports normalizeText /
30
+ * tokenize, and vai-avatar's `sd()` is the same fold (NFD, strip combining
31
+ * marks, đ -> d, lowercase). Imported rather than reimplemented so the two
32
+ * scorers can never drift on what "máy hút mùi" normalises to.
33
+ */
34
+
35
+ import { normalizeText, tokenize } from './catalogue.js';
36
+
37
+ /** vai-avatar's `sd()`: fold diacritics so "Bếp từ" and "bep tu" match. */
38
+ const sd = normalizeText;
39
+
40
+ /**
41
+ * vai-avatar's extractTerms(): split on whitespace, drop one-character noise.
42
+ *
43
+ * Deliberately NOT catalogue.js's tokenize(), which additionally applies a
44
+ * Vietnamese stopword list. vai-avatar keeps those words ("tu" is both "từ"
45
+ * and "tủ"), and dropping them here would make this Space disagree with the
46
+ * one whose ranking it is supposed to reproduce.
47
+ */
48
+ export function extractTerms(query) {
49
+ return sd(query).split(/\s+/).filter(term => term.length > 1);
50
+ }
51
+
52
+ /**
53
+ * Material words worth a bonus, copied from vai-avatar.
54
+ *
55
+ * These are searched in the RAW query (not the folded one) so an accented
56
+ * query like "inox 304" still matches, then folded for the comparison.
57
+ */
58
+ const MATERIAL_WORDS = [
59
+ 'inox 304', 'inox sus304', 'inox304', 'inox', 'sus304', 'sus 304',
60
+ 'nan oval', 'nan vuông', 'nan tron', 'nan dẹt', 'nan det',
61
+ 'khung nhôm', 'nhôm', 'hợp kim', 'kính', 'gốm', 'đá granite', 'da granite',
62
+ 'mây', 'thép', 'gang', 'nhựa abs', 'abs', 'thủy tinh',
63
+ ];
64
+
65
+ /** Brand bonuses, copied from vai-avatar. */
66
+ const BRAND_NAMES = [
67
+ 'malloca', 'eurogold', 'grob', 'canzy', 'demax', 'sunhouse',
68
+ 'kangaroo', 'sharp', 'toshiba', 'panasonic', 'samsung', 'lg',
69
+ 'electrolux', 'bosch', 'fischer',
70
+ ];
71
+
72
+ /**
73
+ * Every distinct millimetre value mentioned by a product.
74
+ *
75
+ * Two patterns, because sizes are written two ways in this catalogue: with an
76
+ * explicit unit ("900 mm") and as a bare number next to a dimension letter
77
+ * ("W565 x D280 x H650", "Khoang tủ: 900"). Matching on the NUMBER rather than
78
+ * the surrounding text is what makes "700" find a 700mm rack and not a 7000mm
79
+ * one, nor a rack whose only "700" is in its price.
80
+ */
81
+ function mmValues(text) {
82
+ const out = new Set();
83
+ const withUnit = /(\d{1,4})\s*mm/gi;
84
+ const withDimension = /[wdh]\s*[:=]?\s*(\d{1,4})/gi;
85
+ let match;
86
+ while ((match = withUnit.exec(text))) out.add(parseInt(match[1], 10));
87
+ while ((match = withDimension.exec(text))) out.add(parseInt(match[1], 10));
88
+ return out;
89
+ }
90
+
91
+ /**
92
+ * Pull every field the scorer reads off a catalogue product into folded text.
93
+ *
94
+ * The product shape here is catalogue.js's normaliseProduct() output
95
+ * ({ name, model, brand, category, summary, feats, specs }), where `specs` is
96
+ * an array of [key, value] pairs rather than vai-avatar's plain object. That
97
+ * difference is the only thing the port has to reconcile.
98
+ */
99
+ function fieldsOf(product) {
100
+ const specsText = sd((product.specs || []).map(([key, value]) => `${key} ${value}`).join(' '));
101
+ return {
102
+ name: sd(product.name || ''),
103
+ sku: sd(product.model || product.sku || ''),
104
+ brand: sd(product.brand || ''),
105
+ category: sd(product.category || ''),
106
+ summary: sd(product.summary || ''),
107
+ feats: sd((product.feats || []).join(' ')),
108
+ specsText,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Score one product against one query. Ported from vai-avatar's scoreProduct.
114
+ *
115
+ * The bonuses are copied verbatim, including their magnitudes, because the
116
+ * RELATIVE order of the rules is the behaviour: a 1000-point exact SKU must
117
+ * still outrank a 300-point all-terms-in-title, which it only does if the
118
+ * numbers stay as they are.
119
+ */
120
+ export function scoreProduct(product, query, terms) {
121
+ const { name: nm, sku: sk, brand: br, category: cat, summary, feats, specsText } = fieldsOf(product);
122
+ const rawQuery = sd(String(query || '').trim());
123
+ let score = 0;
124
+
125
+ // ── Exact model / SKU / title ────────────────────────────────────────────
126
+ // Typing a concrete model code is a lookup, so that product must come first.
127
+ // Both sides are compacted so "GP 170 BD" folds onto "gp170bd".
128
+ const compactQuery = rawQuery.replace(/[^a-z0-9]/g, '');
129
+ const compactName = nm.replace(/[^a-z0-9]/g, '');
130
+ const compactSku = sk.replace(/[^a-z0-9]/g, '');
131
+ if (compactQuery.length >= 4) {
132
+ if (compactSku === compactQuery) score += 1000;
133
+ else if (compactName === compactQuery) score += 900;
134
+ else if (compactName.includes(compactQuery) || compactSku.includes(compactQuery)) score += 550;
135
+ else if (compactSku.length >= 4 && compactQuery.includes(compactSku)) score += 400;
136
+ }
137
+ // Every meaningful term present in the title is a strong signal too.
138
+ const significantTerms = terms.filter(term => term.length > 1 && !/^\d{2,4}mm/.test(term));
139
+ if (significantTerms.length && significantTerms.every(term => nm.includes(term))) score += 300;
140
+
141
+ const all = [nm, br, cat, sd(product.summary || ''), feats, specsText].join(' ');
142
+
143
+ // Note vai-avatar compares the ORIGINAL query here, not the folded one, so an
144
+ // accented query can still match an unaccented field exactly.
145
+ const rawTrimmed = String(query || '').trim();
146
+ if (nm === rawTrimmed) score += 200;
147
+ else if (sk === rawTrimmed) score += 180;
148
+ else if (nm.includes(rawTrimmed)) score += 100;
149
+ else if (sk.includes(rawTrimmed)) score += 80;
150
+ else if (all.includes(rawTrimmed)) score += 50;
151
+
152
+ // ── Size (mm) matching ──────────────────────────────────────────────────
153
+ const productMm = mmValues(`${nm} ${specsText} ${sd(product.summary || '')} ${feats} ${summary}`);
154
+ const queryMm = mmValues(rawTrimmed);
155
+ if ([...queryMm].some(value => productMm.has(value))) {
156
+ if (nm.includes(rawTrimmed)) score += 40;
157
+ else if (nm.split(' ').some(token => /^\d{1,4}mm$/i.test(token))) score += 55;
158
+ else if (specsText !== '') score += 50;
159
+ else score += 40;
160
+ } else if (queryMm.size) {
161
+ score -= 10;
162
+ }
163
+
164
+ // Size tokens written out in the query ("700mm") are worth more than a bare
165
+ // numeric match, because typing the unit means the size is deliberate.
166
+ for (const token of terms.filter(term => /^\d{2,4}\s*mm$/.test(term))) {
167
+ const compact = token.replace(/\s+/g, '');
168
+ if (nm.includes(compact)) score += 60;
169
+ else if (sk.includes(compact)) score += 40;
170
+ else if (all.includes(compact)) score += 25;
171
+ }
172
+
173
+ // ── Material ─────────────────────────────────────────────────────────────
174
+ for (const material of MATERIAL_WORDS) {
175
+ const folded = sd(material);
176
+ if (rawQuery.includes(folded) && (nm.includes(folded) || specsText.includes(folded) || sd(product.summary || '').includes(folded))) {
177
+ score += 30;
178
+ break;
179
+ }
180
+ }
181
+
182
+ // ── Per-term scoring ─────────────────────────────────────────────────────
183
+ let titleHits = 0;
184
+ const meaningful = terms.filter(term => term.length > 1 && !/^\d{2,4}\s*mm$/.test(term));
185
+ for (const term of meaningful) {
186
+ if (nm.includes(term)) { score += 16; titleHits++; }
187
+ else if (sk.includes(term)) score += 12;
188
+ else if (br.includes(term)) score += 8;
189
+ else if (cat.includes(term)) score += 10;
190
+ else if (all.includes(term)) score += 3;
191
+ }
192
+ // Covering more of the query beats matching one word hard.
193
+ if (titleHits >= 2) score += 35;
194
+ else if (titleHits === 1 && meaningful.length >= 2) score += 10;
195
+ for (const term of meaningful) if (cat.includes(term)) score += 6;
196
+
197
+ for (const brand of BRAND_NAMES) {
198
+ if (rawQuery.includes(brand) && br.includes(brand)) score += 50;
199
+ }
200
+ return score;
201
+ }
202
+
203
+ /**
204
+ * Rank a catalogue against a query, vai-avatar's way.
205
+ *
206
+ * `items` is the Map that catalogue.js hands the worker. Returns the same
207
+ * `{ sku, score, product }` shape searchCatalogue returns, so merge.js needs no
208
+ * change to accept it.
209
+ *
210
+ * Scored results only: a product nobody matched is not a weak result, it is
211
+ * not a result, and returning it would put an unrelated photograph in the
212
+ * field next to the query that was typed.
213
+ */
214
+ export function searchLikeAvatar(text, items, limit = 24) {
215
+ const terms = extractTerms(text);
216
+ if (!terms.length || !items?.size) return [];
217
+ const scored = [];
218
+ for (const product of items.values()) {
219
+ const score = scoreProduct(product, String(text || '').trim(), terms);
220
+ if (score > 0) scored.push({ sku: product.sku, score, product });
221
+ }
222
+ scored.sort((a, b) => b.score - a.score || a.sku.localeCompare(b.sku));
223
+ return scored.slice(0, limit);
224
+ }
225
+
226
+ /** Exported for the suggestion-style callers that only need folded tokens. */
227
+ export { tokenize };
src/relevance.js CHANGED
@@ -1,5 +1,5 @@
1
- const CLEAR = { opacity: 1, blur: 0 };
2
- const FAINT = { opacity: 0.14, blur: 2.4 };
3
  const MIN_SPREAD = 0.02;
4
 
5
  // A bright shoulder and a narrow, smooth falloff toward the score tail.
@@ -8,12 +8,114 @@ const sigmoid = t => 1 / (1 + Math.exp(-14 * (t - 0.38)));
8
  const floor = sigmoid(0);
9
  const range = sigmoid(1) - floor;
10
 
11
- /** Current-query cosine scores only; never reuse scores from an older query. */
12
- export function relevanceAppearance(results) {
13
- if (!results.length) return () => CLEAR;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  const valid = results.filter(image => Number.isFinite(image.score));
15
  const scores = new Map(valid.map(image => [image.id, image.score]));
16
- if (!valid.length) return () => FAINT;
17
  // Search results are sorted descending. Keep the first six fully visible;
18
  // the minimum spread prevents near-equal scores from being pulled apart.
19
  const high = valid[Math.min(5, valid.length - 1)].score;
@@ -21,9 +123,9 @@ export function relevanceAppearance(results) {
21
  const spread = Math.max(MIN_SPREAD, high - low);
22
  return id => {
23
  const score = scores.get(id);
24
- if (score === undefined) return FAINT;
25
  const t = Math.max(0, Math.min(1, 1 - (high - score) / spread));
26
  const strength = (sigmoid(t) - floor) / range;
27
- return { opacity: 0.18 + strength * 0.82, blur: 1.8 * (1 - strength) ** 2 };
28
  };
29
- }
 
1
+ const CLEAR = { opacity: 1, blur: 0, exact: false };
2
+ const FAINT = { opacity: 0.14, blur: 2.4, exact: false };
3
  const MIN_SPREAD = 0.02;
4
 
5
  // A bright shoulder and a narrow, smooth falloff toward the score tail.
 
8
  const floor = sigmoid(0);
9
  const range = sigmoid(1) - floor;
10
 
11
+ /**
12
+ * Which results earned the neon outline.
13
+ *
14
+ * An interior photograph is scored by CLIP cosine, which answers "how close is
15
+ * this to the meaning of the words", so it has no notion of being a literal
16
+ * match -- and the top cosine result for "phòng khách" is a photograph of a
17
+ * living room, which is a useful answer but not the same as finding the thing
18
+ * that was asked for by name.
19
+ *
20
+ * So the outline is drawn only where there IS a verifiable identity:
21
+ *
22
+ * * a PRODUCT that vai-avatar's scorer resolved by exact SKU / model / exact
23
+ * title, beating the runner-up by a wide margin (see product-search.js
24
+ * exactThreshold -- 700 is above the 550 "contains" tier, so nothing can
25
+ * reach it on relevance alone); and
26
+ * * an interior photograph whose own title, category or description literally
27
+ * contains the whole query, so "tìm 'bếp đá'" lights the photograph whose
28
+ * title says "Bếp đá" and leaves the merely-similar ones alone.
29
+ *
30
+ * Everything else keeps the relevance fade and gets no outline, which is the
31
+ * comparison the outline exists to make.
32
+ */
33
+ const EXACT_SCORE_FLOOR = 700;
34
+
35
+ /**
36
+ * Does this ranked list have ONE decisive winner?
37
+ *
38
+ * vai-avatar's exact-SKU bonus is 1000 and exact-title is 900, with the next
39
+ * tier ("contains", i.e. relevance rather than identity) at 550. A winner above
40
+ * 700 can therefore only have won on identity, never on relevance.
41
+ *
42
+ * The 2x margin is what stops a MODEL FAMILY from all lighting up: four
43
+ * variants of one lock sharing a code prefix can all score high, and a winner
44
+ * at 1000 next to a runner-up at 900 is a tie, not an answer.
45
+ */
46
+ export function hasDecisiveWinner(scores) {
47
+ if (!scores || scores.length < 1) return false;
48
+ const best = scores[0];
49
+ const runnerUp = scores.length > 1 ? scores[1] : 0;
50
+ return Number.isFinite(best) && best >= EXACT_SCORE_FLOOR && best >= Math.max(EXACT_SCORE_FLOOR, runnerUp * 2);
51
+ }
52
+
53
+ function literalTextOf(image) {
54
+ // Interior rows carry `categories` (an array, scraped) as well as `category`
55
+ // on catalogue products, and both shapes reach this function because both end
56
+ // up as field tiles. Reading only one of them would have made the outline
57
+ // work for products and silently never fire for photographs.
58
+ //
59
+ // Folded, not just lowercased: the dataset stores "Bếp đá" and a visitor
60
+ // types "bep da" about as often as they type the accents, so comparing the
61
+ // folded query against raw accented text would miss the exact case the
62
+ // outline exists to catch.
63
+ return fold([image.title, image.name, image.category, image.categories, image.description]
64
+ .flatMap(value => (Array.isArray(value) ? value : [value]))
65
+ .filter(value => typeof value === 'string')
66
+ .join(' '));
67
+ }
68
+
69
+ /**
70
+ * Fold a Vietnamese string for containment tests. Kept local rather than
71
+ * imported from catalogue.js so this stays a pure display helper with no
72
+ * catalogue dependency -- it runs on interior rows that never went through
73
+ * normalisation.
74
+ */
75
+ function fold(value) {
76
+ return String(value ?? '')
77
+ .normalize('NFD')
78
+ .replace(/[̀-ͯ]/g, '')
79
+ .replace(/đ/g, 'd')
80
+ .replace(/Đ/g, 'D')
81
+ .toLowerCase();
82
+ }
83
+
84
+ export function exactMatches(results, query, products = []) {
85
+ const ids = new Set();
86
+ const text = fold(String(query || '').trim());
87
+ if (!text) return ids;
88
+
89
+ // ── Products: vai-avatar's own identity thresholds ──
90
+ if (hasDecisiveWinner(products.map(hit => hit?.score))) {
91
+ ids.add(`malloca:${products[0].sku}`);
92
+ }
93
+
94
+ // ── Interiors: the query appears verbatim in the record's own text ──
95
+ // Guarded on the cosine being high, so a photograph that happens to contain a
96
+ // word in passing does not get the outline when the real answer is elsewhere.
97
+ const high = results.filter(image => Number.isFinite(image.score)).slice(0, 6);
98
+ for (const image of high) {
99
+ if (text.length < 3) break;
100
+ if (literalTextOf(image).includes(text)) ids.add(image.id);
101
+ }
102
+ return ids;
103
+ }
104
+
105
+ /**
106
+ * Current-query cosine scores only; never reuse scores from an older query.
107
+ *
108
+ * Returns, per tile id, both the fade and whether the tile is an exact hit, so
109
+ * ImageField can drive the outline from the same pass that dims the tail --
110
+ * one function, one source of truth for "which tiles are highlighted".
111
+ */
112
+ export function relevanceAppearance(results, query = '', products = []) {
113
+ const exact = exactMatches(results, query, products);
114
+ const paint = (id, base) => (exact.has(id) ? { ...base, exact: true } : base);
115
+ if (!results.length) return () => paint('', CLEAR);
116
  const valid = results.filter(image => Number.isFinite(image.score));
117
  const scores = new Map(valid.map(image => [image.id, image.score]));
118
+ if (!valid.length) return id => paint(id, FAINT);
119
  // Search results are sorted descending. Keep the first six fully visible;
120
  // the minimum spread prevents near-equal scores from being pulled apart.
121
  const high = valid[Math.min(5, valid.length - 1)].score;
 
123
  const spread = Math.max(MIN_SPREAD, high - low);
124
  return id => {
125
  const score = scores.get(id);
126
+ if (score === undefined) return paint(id, FAINT);
127
  const t = Math.max(0, Math.min(1, 1 - (high - score) / spread));
128
  const strength = (sigmoid(t) - floor) / range;
129
+ return paint(id, { opacity: 0.18 + strength * 0.82, blur: 1.8 * (1 - strength) ** 2 });
130
  };
131
+ }
src/worker.js CHANGED
@@ -2,6 +2,7 @@ import { AutoTokenizer, CLIPTextModelWithProjection, env } from '@huggingface/tr
2
  import { cachedFile, DATASET, TOKENIZER, TEXT_MODEL, TEXT_MODEL_REVISION, DIMENSIONS } from './assets.js';
3
  import { normalizeDatabase, rankImages } from './search.js';
4
  import { loadCatalogue, searchCatalogue, tryLoadCatalogue } from './catalogue.js';
 
5
 
6
  env.allowLocalModels = false;
7
  env.backends.onnx.wasm.numThreads = 1;
@@ -120,9 +121,18 @@ async function searchProducts(text, id, limit) {
120
  try {
121
  send({ status: 'products-loading' });
122
  const { items, signature } = await catalogueItems();
123
- const output = searchCatalogue(text, items, limit);
 
 
 
 
 
 
 
 
 
124
  send({
125
- status: 'products-complete', id, text, output,
126
  signature, count: items.size, elapsed: performance.now() - started,
127
  });
128
  } catch (error) {
 
2
  import { cachedFile, DATASET, TOKENIZER, TEXT_MODEL, TEXT_MODEL_REVISION, DIMENSIONS } from './assets.js';
3
  import { normalizeDatabase, rankImages } from './search.js';
4
  import { loadCatalogue, searchCatalogue, tryLoadCatalogue } from './catalogue.js';
5
+ import { searchLikeAvatar } from './product-search.js';
6
 
7
  env.allowLocalModels = false;
8
  env.backends.onnx.wasm.numThreads = 1;
 
121
  try {
122
  send({ status: 'products-loading' });
123
  const { items, signature } = await catalogueItems();
124
+ // Ranked with vai-avatar's own scorer (see product-search.js) so a query
125
+ // typed here finds the same product, in the same order, as the same query
126
+ // typed in vai-avatar's panel.
127
+ //
128
+ // Both scorers run and both sets are returned. `output` drives the field;
129
+ // `legacy` is kept so the ranking change can be measured rather than
130
+ // assumed -- it costs one extra pass over a few hundred products, and
131
+ // searchCatalogue was the only ranking this Space had until now.
132
+ const output = searchLikeAvatar(text, items, limit);
133
+ const legacy = searchCatalogue(text, items, limit);
134
  send({
135
+ status: 'products-complete', id, text, output, legacy,
136
  signature, count: items.size, elapsed: performance.now() - started,
137
  });
138
  } catch (error) {