viewer: name, describe and link every photo the search finds

#3
by bep40 - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. .gitattributes +0 -13
  2. PLACEHOLDER +0 -1
  3. README.md +3 -6
  4. app.html +2 -2
  5. dist/app.html +2 -2
  6. dist/assets/app.css +1 -1
  7. dist/assets/index.js +0 -0
  8. dist/assets/neon-frame.css +0 -170
  9. dist/assets/share-fix.js +0 -193
  10. dist/assets/{worker-Bz7I_x9L.js → worker-BEj2THkI.js} +0 -0
  11. dist/assets/worker-Bq7vdIsj.js +0 -0
  12. dist/assets/worker-hEU_3YGf.js +0 -0
  13. dist/assets/worker-hGPAZ_gn.js +0 -0
  14. dist/assets/worker-n0zt6z0K.js +0 -0
  15. dist/assets/worker-nnOdWzlm.js +0 -0
  16. dist/brand/vaistudio_center.png +0 -3
  17. dist/catalogue/products.json +0 -0
  18. dist/catalogue/promos.json +0 -0
  19. dist/share/og-image.png +0 -3
  20. index.html +8 -58
  21. package-lock.json +0 -30
  22. package.json +1 -4
  23. public/brand/vaistudio_center.png +0 -3
  24. public/catalogue/products.json +0 -0
  25. public/catalogue/promos.json +0 -0
  26. public/share/og-image.png +0 -3
  27. repo/rebuild.py +0 -56
  28. scripts/gen-thumbnail.py +0 -108
  29. scripts/refresh-catalogue.mjs +6 -46
  30. scripts/refresh.mjs +0 -74
  31. scripts/test-avatar-interiors.mjs +0 -287
  32. scripts/test-merge.mjs +2 -48
  33. scripts/test-neon-ui.mjs +0 -256
  34. scripts/test-product-search.mjs +0 -173
  35. scripts/test-semantic-search.mjs +0 -123
  36. scripts/test-ui.mjs +0 -201
  37. scripts/test-viewer-ui.mjs +0 -151
  38. src/App.jsx +6 -89
  39. src/ImageField.jsx +20 -216
  40. src/PhotoViewer.jsx +37 -201
  41. src/assets.js +27 -110
  42. src/avatar-interiors.js +0 -224
  43. src/catalogue.js +0 -122
  44. src/detail.js +0 -369
  45. src/image-loader.js +3 -3
  46. src/index.css +3 -162
  47. src/layout.js +12 -96
  48. src/merge.js +94 -225
  49. src/neon-frame.css +0 -131
  50. src/product-search.js +0 -312
.gitattributes CHANGED
@@ -36,16 +36,3 @@ 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
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
47
-
48
- # Social-share thumbnail (generated by scripts/gen-thumbnail.py). Both copies in
49
- # LFS: the hub og:image resolves via resolve/main/dist/share/og-image.png.
50
- public/share/og-image.png filter=lfs diff=lfs merge=lfs -text
51
- dist/share/og-image.png 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
 
 
 
 
 
 
 
 
 
 
 
 
 
PLACEHOLDER DELETED
@@ -1 +0,0 @@
1
- null
 
 
README.md CHANGED
@@ -1,20 +1,17 @@
1
  ---
2
- title: Thư viện tìm kiếm ảnh theo ngữ nghĩa ngôn ngữ tự nhiên
3
  emoji: 🛋️
4
  colorFrom: yellow
5
  colorTo: red
6
  sdk: static
7
  pinned: false
8
- short_description: Web App AI thuộc hệ sinh thái VIBE CODE AI của V.AI STUDIO
9
- thumbnail: dist/share/og-image.png
10
  header: mini
11
  tags:
12
  - ml-intern
13
  ---
14
 
15
- # Thư viện tìm kiếm ảnh theo ngữ nghĩa ngôn ngữ tự nhiên
16
-
17
- Web App AI thuộc hệ sinh thái **VIBE CODE AI** của **V.AI STUDIO**.
18
 
19
  Phái sinh từ [`webml-community/semantic-image-field`](https://huggingface.co/spaces/webml-community/semantic-image-field):
20
  thay 24.989 ảnh Unsplash bằng **1.222 ảnh nội thất Việt Nam**, cho phép tìm bằng tiếng Việt.
 
1
  ---
2
+ title: Semantic Image Field — Nội thất
3
  emoji: 🛋️
4
  colorFrom: yellow
5
  colorTo: red
6
  sdk: static
7
  pinned: false
8
+ short_description: Tìm ảnh nội thất bằng tiếng Việt, chạy trong máy
 
9
  header: mini
10
  tags:
11
  - ml-intern
12
  ---
13
 
14
+ # Semantic Image Field — Nội thất
 
 
15
 
16
  Phái sinh từ [`webml-community/semantic-image-field`](https://huggingface.co/spaces/webml-community/semantic-image-field):
17
  thay 24.989 ảnh Unsplash bằng **1.222 ảnh nội thất Việt Nam**, cho phép tìm bằng tiếng Việt.
app.html CHANGED
@@ -4,10 +4,10 @@
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <meta name="theme-color" content="#111210" />
7
- <meta name="description" content="Web App AI thuộc hệ sinh thái VIBE CODE AI của V.AI STUDIO" />
8
  <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
9
  <link rel="preconnect" href="https://huggingface.co" />
10
- <title>Thư viện tìm kiếm ảnh theo ngữ nghĩa ngôn ngữ tự nhiên</title>
11
  </head>
12
  <body>
13
  <!--
 
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <meta name="theme-color" content="#111210" />
7
+ <meta name="description" content="Tìm ảnh nội thất Việt Nam theo ý nghĩa. Trường ảnh động chạy hoàn toàn trong trình duyệt bằng Transformers.js." />
8
  <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
9
  <link rel="preconnect" href="https://huggingface.co" />
10
+ <title>Semantic Image Field — Nội thất</title>
11
  </head>
12
  <body>
13
  <!--
dist/app.html CHANGED
@@ -4,10 +4,10 @@
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <meta name="theme-color" content="#111210" />
7
- <meta name="description" content="Web App AI thuộc hệ sinh thái VIBE CODE AI của V.AI STUDIO" />
8
  <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
9
  <link rel="preconnect" href="https://huggingface.co" />
10
- <title>Thư viện tìm kiếm ảnh theo ngữ nghĩa ngôn ngữ tự nhiên</title>
11
  <script type="module" crossorigin src="./assets/index.js"></script>
12
  <link rel="stylesheet" crossorigin href="./assets/app.css">
13
  </head>
 
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <meta name="theme-color" content="#111210" />
7
+ <meta name="description" content="Tìm ảnh nội thất Việt Nam theo ý nghĩa. Trường ảnh động chạy hoàn toàn trong trình duyệt bằng Transformers.js." />
8
  <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
9
  <link rel="preconnect" href="https://huggingface.co" />
10
+ <title>Semantic Image Field — Nội thất</title>
11
  <script type="module" crossorigin src="./assets/index.js"></script>
12
  <link rel="stylesheet" crossorigin href="./assets/app.css">
13
  </head>
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-border-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--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;--neon-angle:0deg}}}@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}.border{border-style:var(--tw-border-style);border-width:1px}.capitalize{text-transform:capitalize}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.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)}}@property --neon-angle{syntax:"<angle>";inherits:false;initial-value:0deg}.image-tile{--neon:#22f5d0;--neon-soft:#7ef9ff;--neon-run:2.6s}.image-tile.exact,.image-tile.featured{--relevance-opacity:1!important;--relevance-blur:0px!important}.image-tile.exact:before,.image-tile.featured:before{content:"";z-index:2;background:conic-gradient(from var(--neon-angle,0deg), var(--neon) 0deg, var(--neon-soft) 38deg, #fff 70deg, var(--neon-soft) 102deg, var(--neon) 148deg, #22f5d05c 250deg, #22f5d000 344deg, var(--neon) 360deg);-webkit-mask-composite:xor;pointer-events:none;-webkit-mask-composite:xor;-webkit-mask-source-type:auto,auto;-webkit-mask-composite:xor;-webkit-mask-source-type:auto,auto;border-radius:2px;padding:1px;position:absolute;inset:0;transform:none;-webkit-mask-image:linear-gradient(#000 0 0),linear-gradient(#000 0 0);mask-image:linear-gradient(#000 0 0),linear-gradient(#000 0 0);-webkit-mask-position:0 0,0 0;mask-position:0 0,0 0;-webkit-mask-size:auto,auto;mask-size:auto,auto;-webkit-mask-repeat:repeat,repeat;mask-repeat:repeat,repeat;-webkit-mask-clip:content-box,border-box;mask-clip:content-box,border-box;-webkit-mask-origin:content-box,border-box;mask-origin:content-box,border-box;-webkit-mask-composite:xor;mask-composite:exclude;-webkit-mask-source-type:auto,auto;mask-mode:match-source,match-source}.image-tile.exact:after,.image-tile.featured:after{content:"";z-index:1;background:conic-gradient(from var(--neon-angle,0deg), #22f5d000 0deg, #22f5d085 62deg, #b6fdffc4 92deg, #22f5d05e 138deg, #22f5d01f 250deg, #22f5d000 348deg, #22f5d000 360deg);-webkit-mask-composite:xor;filter:blur(4px);opacity:.82;box-shadow:none;pointer-events:none;-webkit-mask-composite:xor;-webkit-mask-source-type:auto,auto;-webkit-mask-composite:xor;-webkit-mask-source-type:auto,auto;border-radius:2px;padding:2.5px;position:absolute;inset:0;-webkit-mask-image:linear-gradient(#000 0 0),linear-gradient(#000 0 0);mask-image:linear-gradient(#000 0 0),linear-gradient(#000 0 0);-webkit-mask-position:0 0,0 0;mask-position:0 0,0 0;-webkit-mask-size:auto,auto;mask-size:auto,auto;-webkit-mask-repeat:repeat,repeat;mask-repeat:repeat,repeat;-webkit-mask-clip:content-box,border-box;mask-clip:content-box,border-box;-webkit-mask-origin:content-box,border-box;mask-origin:content-box,border-box;-webkit-mask-composite:xor;mask-composite:exclude;-webkit-mask-source-type:auto,auto;mask-mode:match-source,match-source}@keyframes neon-run{0%{--neon-angle:0deg}to{--neon-angle:360deg}}@supports at-rule(@property){.image-tile.exact:before,.image-tile.featured:before,.image-tile.exact:after,.image-tile.featured:after{animation:neon-run var(--neon-run) linear infinite}}.image-tile.featured{--neon-run:2s}.image-tile.featured:before{padding:1.5px}@media (prefers-reduced-motion:reduce){.image-tile.exact:before,.image-tile.exact:after,.image-tile.featured:before,.image-tile.featured:after{animation:none}}: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}.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}.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-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@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}
 
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}.block{display:block}.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}.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}.image-tile img.loaded{opacity:1}.image-tile:hover{box-shadow:0 10px 35px #0005}.image-tile:hover img{filter:brightness()}.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()}.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{display:none}.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}}@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}
dist/assets/index.js CHANGED
The diff for this file is too large to render. See raw diff
 
dist/assets/neon-frame.css DELETED
@@ -1,170 +0,0 @@
1
- /* ── Neon frame: a light that runs around the picture ──────────────────────
2
- *
3
- * Loaded from index.html AFTER dist/assets/app.css, and deliberately overriding
4
- * the ring that bundle paints. It exists because that ring is wrong in a way
5
- * only shows up on non-square tiles.
6
- *
7
- * THE BUG IT UNDOES
8
- * -----------------
9
- * The @keyframes neon-run shipped in app.css rotated the whole ::before layer
10
- * by one turn. That layer carries BOTH the conic gradient and the mask, so the
11
- * mask rotated with it and stopped lining up with the tile's edges. On a 16:9
12
- * photograph the painted border tilted off the frame and the highlight read as
13
- * a skewed smear rather than a light going round the picture -- which is
14
- * exactly what "a static neon border" looked like on screen. A tile that
15
- * happened to be nearly square hid the bug, so the effect only ever looked
16
- * broken on real photographs.
17
- *
18
- * WHAT MOVES NOW
19
- * --------------
20
- * The layer never transforms. What animates is the conic gradient's own start
21
- * angle, --neon-angle, so the bright head walks the perimeter and follows the
22
- * rectangle exactly: it runs down the long edge, turns the corner and comes
23
- * back along the short edge, like a light circling a picture frame.
24
- *
25
- * `--neon-angle` is REGISTERED with @property because an unregistered custom
26
- * property is an untyped token the engine cannot interpolate -- a 0deg to
27
- * 360deg keyframe on one flips once at the midpoint instead of sweeping. That
28
- * flip is worse than the static ring, which is why the animation is gated on
29
- * @supports below: a browser without registration gets the gradient and no
30
- * motion at all, i.e. a lit frame with the head parked at the top.
31
- *
32
- * `inherits: false` keeps the angle on the ring itself. The tile is transformed
33
- * every frame by the field engine, and an inherited animated property would be
34
- * recomputed against a moving box -- which is how the gradient ends up sliding
35
- * relative to the picture it is supposed to be travelling around.
36
- *
37
- * TWO LAYERS, ONE CLOCK
38
- * ---------------------
39
- * ::before is the crisp stroke. ::after is the halo: the same sweep, spread
40
- * over a wider arc and blurred, so the light blooms off the edge instead of
41
- * being cut flat against it. They are one pseudo-element apart because a
42
- * pseudo-element can only carry one background, and a neon frame needs both the
43
- * stroke and the glow.
44
- *
45
- * Both animate over the same duration from the same start angle, so the halo
46
- * stays locked to the head of the stroke on every lap rather than drifting
47
- * ahead of it.
48
- *
49
- * `mask` is what turns the full-size gradient into a ring. Without the composite
50
- * the gradient would fill the whole tile and hide the photograph. The -webkit-
51
- * forms are repeated because Safari needs the prefixed one, and the standard
52
- * alone would leave Safari with an opaque tile.
53
- *
54
- * src/neon-frame.css carries the same rules, so a rebuild produces the same
55
- * frame and this file becomes a no-op rather than a divergence.
56
- */
57
- @property --neon-angle {
58
- syntax: '<angle>';
59
- inherits: false;
60
- initial-value: 0deg;
61
- }
62
-
63
- .image-tile {
64
- --neon: #22f5d0;
65
- --neon-soft: #7ef9ff;
66
- --neon-run: 2.6s;
67
- }
68
-
69
- /* The crisp stroke: a white-hot head at 0deg, a comet tail fading back round
70
- to 250deg, and a clear gap before the head returns -- so the eye reads a
71
- single light travelling, not a ring that pulses in place. */
72
- .image-tile.exact::before,
73
- .image-tile.featured::before {
74
- padding: 1px;
75
- background: conic-gradient(from var(--neon-angle, 0deg),
76
- var(--neon) 0deg,
77
- var(--neon-soft) 38deg,
78
- #ffffff 70deg,
79
- var(--neon-soft) 102deg,
80
- var(--neon) 148deg,
81
- #22f5d05c 250deg,
82
- #22f5d000 344deg,
83
- var(--neon) 360deg);
84
- -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
85
- -webkit-mask-composite: xor;
86
- mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
87
- mask-composite: exclude;
88
- /* Any transform here drags the mask off the frame, which is the bug above.
89
- Stated explicitly so a later edit cannot reintroduce it by accident. */
90
- transform: none;
91
- }
92
-
93
- /* The halo. Same sweep from the same angle, spread wider so the light spills
94
- both inward off the edge and outward into the dark around the tile. */
95
- .image-tile.exact::after,
96
- .image-tile.featured::after {
97
- padding: 2.5px;
98
- background: conic-gradient(from var(--neon-angle, 0deg),
99
- #22f5d000 0deg,
100
- #22f5d085 62deg,
101
- #b6fdffc4 92deg,
102
- #22f5d05e 138deg,
103
- #22f5d01f 250deg,
104
- #22f5d000 348deg,
105
- #22f5d000 360deg);
106
- -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
107
- -webkit-mask-composite: xor;
108
- mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
109
- mask-composite: exclude;
110
- filter: blur(4px);
111
- opacity: .82;
112
- /* The stock rule animated box-shadow here instead. A box-shadow breathes in
113
- place -- it cannot run along the perimeter, because the shadow's shape is
114
- the tile's shape and never travels round it. */
115
- box-shadow: none;
116
- }
117
-
118
- /* The head sweeps one full turn clockwise; the halo sweeps the same turn over
119
- the same duration, so the two re-align every lap instead of drifting apart.
120
- *
121
- * NOTE this is a REPLACEMENT for app.css's neon-run, not an addition. Keyframes
122
- * are global and the last definition of a name wins, so this block takes the
123
- * name over from the built one for the whole page -- which is exactly what
124
- * retires its `rotate(1turn)`, and also why the ungated reset below matters.
125
- */
126
- @keyframes neon-run {
127
- from { --neon-angle: 0deg; }
128
- to { --neon-angle: 360deg; }
129
- }
130
-
131
- /* Both pseudo-elements default to NO animation, outside any @supports gate.
132
- *
133
- * This has to be unconditional, and it is the subtle part of this file.
134
- * app.css applies `animation: neon-run ...` to these selectors with no feature
135
- * test of its own, so a browser that cannot register @property would otherwise
136
- * run the keyframes above -- animating an untyped token, which snaps at the
137
- * halfway point. The ring would blink once per cycle and look broken in a way
138
- * the old static ring never did.
139
- *
140
- * Setting `animation: none` first and enabling it only inside @supports makes
141
- * the unsupported case the safe one: a lit frame, head parked at the top, no
142
- * motion. It also has to win on order against app.css, which this file loads
143
- * after, so the @supports rule below re-asserts the animation.
144
- */
145
- .image-tile.exact::before,
146
- .image-tile.featured::before,
147
- .image-tile.exact::after,
148
- .image-tile.featured::after { animation: none; }
149
-
150
- @supports at-rule(@property) {
151
- .image-tile.exact::before,
152
- .image-tile.featured::before,
153
- .image-tile.exact::after,
154
- .image-tile.featured::after {
155
- animation: neon-run var(--neon-run) linear infinite;
156
- }
157
- }
158
-
159
- /* A featured tile -- the exact answer to the query, or a photograph just added
160
- on vai-avatar -- runs faster and thinner, so the newest photographs are
161
- distinguishable from an exact hit by pace as well as by size. */
162
- .image-tile.featured { --neon-run: 2s; }
163
- .image-tile.featured::before { padding: 1.5px; }
164
-
165
- /* A slow, low-frequency sweep reads as "lit"; a fast strobe reads as an alarm,
166
- and this frame marks a correct answer rather than an error. */
167
- @media (prefers-reduced-motion: reduce) {
168
- .image-tile.exact::before, .image-tile.exact::after,
169
- .image-tile.featured::before, .image-tile.featured::after { animation: none; }
170
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dist/assets/share-fix.js DELETED
@@ -1,193 +0,0 @@
1
- /**
2
- * Repairs the share link on vai-avatar interior photographs, in the browser.
3
- * =========================================================================
4
- *
5
- * THE SYMPTOM
6
- * A photograph published on vai-avatar opens its share page at
7
- *
8
- * https://bep40-vai-avatar.hf.space/share/p/avatarimg%3Ab68dc779370dc70e
9
- *
10
- * which is vai-avatar's "Not Found" page. Every interior photograph is
11
- * affected, on the tile's share button and on the viewer's "Xem trang" alike.
12
- *
13
- * WHY IT HAPPENS
14
- * avatar-interiors.js gives every such photograph a namespaced `sku` of
15
- * `avatarimg:<image_id>`, so it travels the same code path as a catalogue
16
- * product: same viewer, same detail panel, same neon. Namespacing is
17
- * deliberate -- it is what stops a 16-hex image_id colliding with a baked
18
- * interior's id in merge.js's de-duplication set.
19
- *
20
- * But `avatarimg:...` is not an identity the product catalogue knows, and
21
- * vai-avatar's `/share/p/` resolves against exactly that catalogue. So the
22
- * photograph was sent to the product route, where no such SKU exists, and the
23
- * Space answered Not Found. There is nothing to fix on vai-avatar's side: the
24
- * record it would need to find does not exist. The photograph is an interior,
25
- * and the interior route is `/share/i/<image_id>`.
26
- *
27
- * WHY THIS IS A FILE AND NOT ONLY THE SOURCE FIX
28
- * src/assets.js now routes on `avatarImageId` first, and that is the real fix.
29
- * It is not, though, what the browser runs yet: this Space is `sdk: static`, so
30
- * the page is served from dist/, and dist/assets/index.js was built before that
31
- * fix existed. The neon frame has the same problem -- dist/assets/app.css still
32
- * carries the old `@keyframes neon-run { transform: rotate(1turn) }`, which
33
- * rotated the mask off the frame. Rebuilding needs a shell.
34
- *
35
- * So this corrects the URL where it is consumed, with no rebuild, and the
36
- * source fix carries the same logic forward. The two agree by construction:
37
- * both send `avatarimg:<id>` to `/share/i/<id>`.
38
- *
39
- * WHAT IT COSTS
40
- * One capture-phase listener and one MutationObserver. The observer watches
41
- * only `href` on the whole document -- the field already rebuilds 80 tiles per
42
- * keystroke, but share hrefs change on layout passes, not on the per-frame
43
- * transform writes, so this sees a handful of callbacks rather than 60/s. The
44
- * work per callback is one regex against an attribute value that is almost
45
- * always not a share href.
46
- *
47
- * Once the bundle is rebuilt from src/assets.js this repairs nothing, because
48
- * the hrefs it would find are already correct. It stays loaded rather than
49
- * un-loading itself: it is a few regexes against a handful of anchors, and a
50
- * self-disarming timer is a far worse thing to reason about than inert code.
51
- */
52
-
53
- const STUDIO = 'https://bep40-vai-avatar.hf.space';
54
-
55
- /** Anchors worth checking at all: the viewer plus the tile share buttons. */
56
- const SHARE_ANCHORS = 'a[href*="/share/"]';
57
-
58
- /**
59
- * A share href that names an interior photograph by its namespaced sku.
60
- *
61
- * Matches the literal `avatarimg` immediately after `/share/p/`, then either
62
- * separator: the value may not have been encoded (`avatarimg:`) or may have
63
- * been (`avatarimg%3A`). Only that one segment is taken as the separator, so an
64
- * id that itself contains a colon still comes through whole.
65
- */
66
- const BROKEN = /\/share\/p\/(avatarimg)(?::|%3A)([^/?#]+)/i;
67
-
68
- /**
69
- * The same URL, routed as the interior it is.
70
- *
71
- * The id is decoded first: it arrived percent-encoded inside the URL, and
72
- * re-encoding the raw `%3A` would produce `%253A`. `decodeURIComponent` throws
73
- * on a malformed escape, so a bad id is left alone rather than taking the whole
74
- * listener down -- one broken href must not break the page it sits on.
75
- *
76
- * An empty id is rejected too: `/share/i/` with nothing after it is its own
77
- * 404, so redirecting there would trade one dead link for another.
78
- */
79
- function interiorURL(href) {
80
- const match = BROKEN.exec(href);
81
- if (!match) return null;
82
- let id;
83
- try {
84
- id = decodeURIComponent(match[2]);
85
- } catch {
86
- return null;
87
- }
88
- id = id.trim();
89
- return id ? `${STUDIO}/share/i/${encodeURIComponent(id)}` : null;
90
- }
91
-
92
- /** Rewrite one anchor's href if it is mis-routed. Returns true if it changed. */
93
- function repair(anchor) {
94
- const href = anchor?.getAttribute?.('href');
95
- if (!href) return false;
96
- const fixed = interiorURL(href);
97
- if (!fixed || fixed === href) return false;
98
- anchor.setAttribute('href', fixed);
99
- // Assigning the IDL property as well is redundant with setAttribute for a
100
- // fully-qualified URL, and deliberately so: it keeps `anchor.href` on the
101
- // corrected string for any later read, however the property resolves.
102
- anchor.href = fixed;
103
- return true;
104
- }
105
-
106
- /**
107
- * Sweep `root` and everything under it. `root` defaults to the document.
108
- *
109
- * `root` ITSELF is checked, not only its descendants, and that is not
110
- * redundancy. React sets props on a detached element and then inserts it, so
111
- * the viewer's "Xem trang" anchor arrives in the tree already carrying its
112
- * href -- which means the `href` mutation happened off-tree and no observer ever
113
- * saw it. The only record that describes it is "this node was added", so a
114
- * descendant-only sweep would miss it and the visible link would stay wrong
115
- * until it was clicked.
116
- *
117
- * The tile share anchors take the opposite path and are covered either way:
118
- * ImageField appends the anchor empty and fills the href later on a live
119
- * element, which the `attributes` branch below sees.
120
- */
121
- function scan(root = document) {
122
- if (root.nodeType === Node.ELEMENT_NODE && root.matches?.(SHARE_ANCHORS)) {
123
- repair(root);
124
- }
125
- for (const anchor of root.querySelectorAll?.(SHARE_ANCHORS) ?? []) {
126
- repair(anchor);
127
- }
128
- }
129
-
130
- const observer = new MutationObserver(records => {
131
- for (const record of records) {
132
- if (record.type === 'attributes') {
133
- // The href changed on an element already in the field.
134
- repair(record.target);
135
- continue;
136
- }
137
- for (const node of record.addedNodes) {
138
- // Both places a share href is rendered: the anchor on a field tile, and
139
- // the viewer's "Xem trang".
140
- if (node.nodeType === Node.ELEMENT_NODE) scan(node);
141
- }
142
- }
143
- });
144
-
145
- function start() {
146
- scan();
147
- observer.observe(document.documentElement, {
148
- // href is the only attribute either the React viewer or the field engine
149
- // sets to move a share link, so nothing else needs watching.
150
- attributes: true,
151
- attributeFilter: ['href'],
152
- subtree: true,
153
- childList: true,
154
- });
155
- }
156
-
157
- if (document.readyState === 'loading') {
158
- document.addEventListener('DOMContentLoaded', start, { once: true });
159
- } else {
160
- start();
161
- }
162
-
163
- /**
164
- * A capture-phase click handler, which is what actually guarantees the fix.
165
- *
166
- * The observer corrects the visible href for the reader, but observer callbacks
167
- * run as microtasks after the mutation -- and a click can be dispatched on an
168
- * anchor before any callback has run for the node that inserted it. Repairing
169
- * during capture makes the corrected value the one the browser navigates to,
170
- * because the default action resolves the URL at activation time, after capture
171
- * has run.
172
- *
173
- * This is also the backstop that makes the fix independent of the observer
174
- * catching anything at all: however the link got into the document, clicking it
175
- * resolves it correctly.
176
- *
177
- * The event is deliberately NOT prevented: the aim is to redirect the
178
- * navigation, not suppress it, so middle-click, ctrl-click and "open in new
179
- * tab" all carry the corrected URL exactly as they would on a right one.
180
- */
181
- document.addEventListener('click', event => {
182
- const anchor = event.target?.closest?.('a[href]');
183
- if (anchor) repair(anchor);
184
- }, true);
185
-
186
- /**
187
- * Exposed for scripts/test-neon-ui.mjs.
188
- *
189
- * Not a debug hook: that test builds an anchor by hand with a deliberately
190
- * broken href, because the field's own links depend on a live network read of
191
- * the vai-avatar interior index that may not have landed when the test runs.
192
- */
193
- globalThis.__repairInteriorShareLinks = () => scan();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dist/assets/{worker-Bz7I_x9L.js → worker-BEj2THkI.js} RENAMED
The diff for this file is too large to render. See raw diff
 
dist/assets/worker-Bq7vdIsj.js DELETED
The diff for this file is too large to render. See raw diff
 
dist/assets/worker-hEU_3YGf.js DELETED
The diff for this file is too large to render. See raw diff
 
dist/assets/worker-hGPAZ_gn.js DELETED
The diff for this file is too large to render. See raw diff
 
dist/assets/worker-n0zt6z0K.js DELETED
The diff for this file is too large to render. See raw diff
 
dist/assets/worker-nnOdWzlm.js DELETED
The diff for this file is too large to render. See raw diff
 
dist/brand/vaistudio_center.png DELETED

Git LFS Details

  • SHA256: 00d1a10cdcb4e150b51ae6636b9a06c4aa184c9ff72f2a65c84636dcde08b513
  • Pointer size: 131 Bytes
  • Size of remote file: 656 kB
dist/catalogue/products.json CHANGED
The diff for this file is too large to render. See raw diff
 
dist/catalogue/promos.json CHANGED
The diff for this file is too large to render. See raw diff
 
dist/share/og-image.png DELETED

Git LFS Details

  • SHA256: 34e03830d57bc5b4ad3b357e8577f5bb32f95a18cf2c47bac405d6b3e6c81b5d
  • Pointer size: 131 Bytes
  • Size of remote file: 970 kB
index.html CHANGED
@@ -4,26 +4,10 @@
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <meta name="theme-color" content="#111210" />
7
- <meta name="description" content="Thư viện tìm kiếm ảnh nội thất theo ngữ nghĩa ngôn ngữ tự nhiên. Nhập mô tả bằng tiếng Việt, ứng dụng AI tìm đúng ảnh nội thất, sản phẩm Malloca/Bosch/Hafele và cập nhật tự động từ vai-avatar. Web App AI thuộc hệ sinh thái VIBE CODE AI của V.AI STUDIO" />
8
- <meta name="robots" content="index, follow" />
9
- <meta name="keywords" content="tìm ảnh theo ngữ nghĩa, tìm kiếm ảnh nội thất, AI tìm ảnh, V.AI STUDIO, VIBE CODE AI, Malloca, Bosch, Hafele, ảnh nội thất Việt Nam" />
10
- <meta property="og:title" content="Thư viện tìm kiếm ảnh theo ngữ nghĩa ngôn ngữ tự nhiên | V.AI STUDIO" />
11
- <meta property="og:type" content="website" />
12
- <meta property="og:description" content="Nhập mô tả bằng tiếng Việt và AI tìm đúng ảnh nội thất, sản phẩm gia dụng. Đồng bộ tự động ảnh mới nhất từ vai-avatar. Web App AI thuộc hệ sinh thái VIBE CODE AI của V.AI STUDIO" />
13
- <meta property="og:url" content="https://huggingface.co/spaces/bep40/semantic-image-field" />
14
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/semantic-image-field/resolve/main/dist/share/og-image.png" />
15
- <meta property="og:image:width" content="1200" />
16
- <meta property="og:image:height" content="675" />
17
- <meta property="og:image:alt" content="12 ảnh nội thất và sản phẩm mới nhất từ V.AI STUDIO" />
18
- <meta property="og:site_name" content="V.AI STUDIO" />
19
- <meta name="twitter:card" content="summary_large_image" />
20
- <meta name="twitter:title" content="Thư viện tìm kiếm ảnh theo ngữ nghĩa ngôn ngữ tự nhiên | V.AI STUDIO" />
21
- <meta name="twitter:description" content="Nhập mô tả bằng tiếng Việt và AI tìm đúng ảnh nội thất, sản phẩm gia dụng. Web App AI thuộc hệ sinh thái VIBE CODE AI của V.AI STUDIO" />
22
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/semantic-image-field/resolve/main/dist/share/og-image.png" />
23
- <link rel="canonical" href="https://huggingface.co/spaces/bep40/semantic-image-field" />
24
  <link rel="icon" type="image/svg+xml" href="./dist/favicon.svg" />
25
  <link rel="preconnect" href="https://huggingface.co" />
26
- <title>Thư viện tìm kiếm ảnh theo ngữ nghĩa ngôn ngữ tự nhiên | V.AI STUDIO</title>
27
  <!--
28
  This is the page the Space actually serves. It is NOT a Vite build input
29
  (that is app.html), so `vite build` never rewrites it.
@@ -32,51 +16,17 @@
32
  bundle, so they must always name files that EXIST.
33
 
34
  vite.config.js pins entryFileNames/assetsFileNames to stable names
35
- (assets/index.js, assets/app.css), so `npm run build` is sufficient on its
36
- own -- there is no content hash to chase. The hashed names that used to be
37
- listed here (index-CigEzVrZ.js / index-DcLscXnL.css) were deleted:
38
  those bundles were built from a source tree that had no product code at
39
  all, so the live Space was serving a build without the catalogue
40
  feature. See "Why the bundle was rebuilt" in README.md.
41
  -->
42
- <script type="module" crossorigin src="./dist/assets/index.js?v=20261110-1"></script>
43
- <link rel="stylesheet" crossorigin href="./dist/assets/app.css?v=20261110-1">
44
-
45
- <!--
46
- Loaded AFTER app.css, and deliberately overriding it.
47
-
48
- The ring app.css paints is the old one: its @keyframes neon-run rotated
49
- the whole ::before layer by a turn, and because that layer carries the mask
50
- as well as the gradient, the mask rotated with it and stopped lining up
51
- with the tile's edges. On a non-square photograph the painted border tilted
52
- off the frame, so the highlight read as a skewed smear rather than a light
53
- going round the picture -- which is what "a static neon border" looked
54
- like. src/index.css already carries the angle-based version, so a rebuild
55
- makes this stylesheet a no-op rather than a divergence. Order matters: it
56
- has to come after app.css to win, and it must NOT be renamed by the build.
57
- -->
58
- <link rel="stylesheet" href="./dist/assets/neon-frame.css?v=20261110-1" />
59
-
60
- <!--
61
- Repairs the share link on vai-avatar interior photographs.
62
-
63
- src/assets.js routes on `avatarImageId` first, which is the real fix, but
64
- this Space is `sdk: static` and serves dist/assets/index.js -- a bundle
65
- built before that fix existed. So every photograph still opens
66
- /share/p/avatarimg%3A... , which is vai-avatar's "Not Found" page: the
67
- `avatarimg:` sku is a namespace for de-duplication, not a catalogue
68
- identity, and /share/p/ resolves against the product catalogue where no
69
- such SKU exists. This corrects the href where it is consumed, with no
70
- rebuild, and goes inert once the bundle is rebuilt.
71
-
72
- Loaded as a module, and BEFORE the app bundle's work matters because it
73
- installs a capture-phase click listener on document -- the phase in which
74
- a mis-routed href can still be corrected before the browser resolves it
75
- for navigation.
76
- -->
77
- <script type="module" src="./dist/assets/share-fix.js?v=20261110-1"></script>
78
  </head>
79
  <body>
80
  <div id="root"></div>
81
  </body>
82
- </html>
 
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <meta name="theme-color" content="#111210" />
7
+ <meta name="description" content="Tìm ảnh nội thất Việt Nam theo ý nghĩa. Trường ảnh động chạy hoàn toàn trong trình duyệt bằng Transformers.js." />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  <link rel="icon" type="image/svg+xml" href="./dist/favicon.svg" />
9
  <link rel="preconnect" href="https://huggingface.co" />
10
+ <title>Semantic Image Field — Nội thất</title>
11
  <!--
12
  This is the page the Space actually serves. It is NOT a Vite build input
13
  (that is app.html), so `vite build` never rewrites it.
 
16
  bundle, so they must always name files that EXIST.
17
 
18
  vite.config.js pins entryFileNames/assetsFileNames to stable names
19
+ (assets/index.js, assets/app.css), so `npm run build` is sufficient on
20
+ its own -- there is no content hash to chase. The hashed names that used
21
+ to be listed here (index-CigEzVrZ.js / index-DcLscXnL.css) were deleted:
22
  those bundles were built from a source tree that had no product code at
23
  all, so the live Space was serving a build without the catalogue
24
  feature. See "Why the bundle was rebuilt" in README.md.
25
  -->
26
+ <script type="module" crossorigin src="./dist/assets/index.js"></script>
27
+ <link rel="stylesheet" crossorigin href="./dist/assets/app.css">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  </head>
29
  <body>
30
  <div id="root"></div>
31
  </body>
32
+ </html>
package-lock.json CHANGED
@@ -26,7 +26,6 @@
26
  "eslint-plugin-react-hooks": "^7.1.1",
27
  "eslint-plugin-react-refresh": "^0.5.7",
28
  "globals": "^17.12.0",
29
- "playwright": "^1.63.0",
30
  "vite": "^8.3.0"
31
  },
32
  "engines": {
@@ -4346,35 +4345,6 @@
4346
  "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
4347
  "license": "MIT"
4348
  },
4349
- "node_modules/playwright": {
4350
- "version": "1.63.0",
4351
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
4352
- "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
4353
- "dev": true,
4354
- "license": "Apache-2.0",
4355
- "dependencies": {
4356
- "playwright-core": "1.63.0"
4357
- },
4358
- "bin": {
4359
- "playwright": "cli.js"
4360
- },
4361
- "engines": {
4362
- "node": ">=20"
4363
- }
4364
- },
4365
- "node_modules/playwright-core": {
4366
- "version": "1.63.0",
4367
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
4368
- "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
4369
- "dev": true,
4370
- "license": "Apache-2.0",
4371
- "bin": {
4372
- "playwright-core": "cli.js"
4373
- },
4374
- "engines": {
4375
- "node": ">=20"
4376
- }
4377
- },
4378
  "node_modules/possible-typed-array-names": {
4379
  "version": "1.0.0",
4380
  "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
 
26
  "eslint-plugin-react-hooks": "^7.1.1",
27
  "eslint-plugin-react-refresh": "^0.5.7",
28
  "globals": "^17.12.0",
 
29
  "vite": "^8.3.0"
30
  },
31
  "engines": {
 
4345
  "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
4346
  "license": "MIT"
4347
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4348
  "node_modules/possible-typed-array-names": {
4349
  "version": "1.0.0",
4350
  "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
package.json CHANGED
@@ -7,8 +7,6 @@
7
  "dev": "vite",
8
  "build": "vite build",
9
  "preview": "vite preview",
10
- "test": "node scripts/test-avatar-interiors.mjs",
11
- "test:ui": "node scripts/test-neon-ui.mjs",
12
  "sync:catalogue": "node scripts/sync-catalogue.mjs",
13
  "refresh:catalogue": "node scripts/refresh-catalogue.mjs",
14
  "seed:catalogue": "node scripts/seed-catalogue.mjs"
@@ -32,10 +30,9 @@
32
  "eslint-plugin-react-hooks": "^7.1.1",
33
  "eslint-plugin-react-refresh": "^0.5.7",
34
  "globals": "^17.12.0",
35
- "playwright": "^1.63.0",
36
  "vite": "^8.3.0"
37
  },
38
  "engines": {
39
  "node": "^20.19.0 || >=22.12.0"
40
  }
41
- }
 
7
  "dev": "vite",
8
  "build": "vite build",
9
  "preview": "vite preview",
 
 
10
  "sync:catalogue": "node scripts/sync-catalogue.mjs",
11
  "refresh:catalogue": "node scripts/refresh-catalogue.mjs",
12
  "seed:catalogue": "node scripts/seed-catalogue.mjs"
 
30
  "eslint-plugin-react-hooks": "^7.1.1",
31
  "eslint-plugin-react-refresh": "^0.5.7",
32
  "globals": "^17.12.0",
 
33
  "vite": "^8.3.0"
34
  },
35
  "engines": {
36
  "node": "^20.19.0 || >=22.12.0"
37
  }
38
+ }
public/brand/vaistudio_center.png DELETED

Git LFS Details

  • SHA256: 00d1a10cdcb4e150b51ae6636b9a06c4aa184c9ff72f2a65c84636dcde08b513
  • Pointer size: 131 Bytes
  • Size of remote file: 656 kB
public/catalogue/products.json CHANGED
The diff for this file is too large to render. See raw diff
 
public/catalogue/promos.json CHANGED
The diff for this file is too large to render. See raw diff
 
public/share/og-image.png DELETED

Git LFS Details

  • SHA256: 34e03830d57bc5b4ad3b357e8577f5bb32f95a18cf2c47bac405d6b3e6c81b5d
  • Pointer size: 131 Bytes
  • Size of remote file: 970 kB
repo/rebuild.py DELETED
@@ -1,56 +0,0 @@
1
- #!/usr/bin/env python3
2
- import subprocess, os, re
3
- from pathlib import Path
4
-
5
- HF_TOKEN = os.environ.get("HF_TOKEN", "")
6
- REPO = "bep40/VSEARCH"
7
- CLONE = "/repo"
8
- SPACE_URL = "https://huggingface.co/spaces/"
9
- HEADERS = {"Authorization": f"Bearer {HF_TOKEN}"}
10
-
11
- def run(cmd, **kw):
12
- return subprocess.run(cmd, shell=True, cwd=CLONE, check=True,
13
- capture_output=True, text=True, env={**os.environ, "HF_TOKEN": HF_TOKEN})
14
-
15
- Path(CLONE).mkdir(parents=True, exist_ok=True)
16
- if not Path(CLONE).exists() or not Path(CLONE, ".git").exists():
17
- run("git clone -q --filter=blob:none --depth 1 " + SPACE_URL + REPO + " " + CLONE)
18
- os.chdir(CLONE)
19
- print("install:", run("npm install --silent --no-audit --no-fund").stdout[-200:])
20
- build = run("npm run build")
21
- print("build:", build.stdout[-400:])
22
- import re as re2
23
- html = Path(CLONE, "index.html").read_text()
24
- assets = {}
25
- for key in ("src", "href"):
26
- for m in re2.finditer(rf"(?:{key})=\"([^\"]+)\"", html):
27
- url = m.group(1).split("?")[0]
28
- assets[url] = None
29
- print("index.html references:", assets)
30
-
31
- # Upload via REST upload_file to avoid git auth prompts
32
- import urllib.request
33
- import urllib.parse
34
- import json
35
-
36
- def hf_upload(path_in_repo, path_or_fileobj):
37
- url = f"https://huggingface.co/api/spaces/{REPO}/upload_file"
38
- q = urllib.parse.urlencode({"create_pr": False, "revision": "main", "commit_message": "Upload rebuilt static asset"})
39
- body = b""
40
- if isinstance(path_or_fileobj, str):
41
- body = open(path_or_fileobj, "rb").read()
42
- else:
43
- body = path_or_fileobj.read()
44
- data = urllib.parse.urlencode({"path_in_repo": path_in_repo}).encode()
45
- data = data + b"&content=" + urllib.parse.quote(body)
46
- req = urllib.request.Request(url + "?" + q, data=data, headers={"Authorization": HEADERS["Authorization"], "Content-Type": "application/x-www-form-urlencoded"})
47
- with urllib.request.urlopen(req) as resp:
48
- return json.loads(resp.read())
49
-
50
- for url in sorted(assets):
51
- local = Path(CLONE, url)
52
- if not local.exists():
53
- raise SystemExit("missing asset: " + url)
54
- hf_upload(url, str(local))
55
- print("uploaded:", url, local.stat().st_size)
56
- print("ALL_OK")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/gen-thumbnail.py DELETED
@@ -1,108 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Generate the social-share thumbnail for the semantic-image-field Space.
4
-
5
- Always the CURRENT 12 newest photographs: 8 newest interiors (by created_at)
6
- from bep40/vai-avatar2-interior + 4 newest added products (by _added_at)
7
- from bep40/grob-products-updated. Composed as a 4x3 grid at 1200x675 (2:1
8
- social-card crop). Written to BOTH public/ and dist/ so whichever path the
9
- static Space serves, the resolved URL exists.
10
-
11
- Intended to run from a scheduled HF job (or locally) BEFORE the catalogue
12
- sync commits, so the committed thumbnail is refreshed at least as often as
13
- the mirror itself.
14
- """
15
- import io, json, os, sys, urllib.request
16
-
17
- INTERIOR_INDEX = "https://huggingface.co/datasets/bep40/vai-avatar2-interior/resolve/main/index.json"
18
- INTERIOR_API = "https://bep40-vai-avatar2-interior-api.hf.space"
19
- PRODUCTS_URL = "https://huggingface.co/datasets/bep40/grob-products-updated/resolve/main/products_url_added.json"
20
- N_INTERIORS = 8
21
- N_PRODUCTS = 4
22
- W, H = 1200, 675
23
- GAP = 6
24
- COLS, ROWS = 4, 3
25
- BG = (17, 20, 28)
26
-
27
- UA = {"User-Agent": "V.AI-STUDIO-thumbnail/1.0"}
28
-
29
- def fetch_json(url, timeout=25):
30
- req = urllib.request.Request(url, headers=UA)
31
- with urllib.request.urlopen(req, timeout=timeout) as r:
32
- return json.loads(r.read().decode("utf-8"))
33
-
34
- def fetch_bytes(url, timeout=25):
35
- req = urllib.request.Request(url, headers=UA)
36
- with urllib.request.urlopen(req, timeout=timeout) as r:
37
- return r.read()
38
-
39
- def main():
40
- from PIL import Image
41
- interiors = fetch_json(INTERIOR_INDEX)
42
- products = fetch_json(PRODUCTS_URL)
43
-
44
- dated = [r for r in interiors
45
- if isinstance(r, dict) and isinstance(r.get("created_at"), str)
46
- and r.get("created_at") and r.get("image_id")]
47
- dated.sort(key=lambda r: r["created_at"], reverse=True)
48
- top_i = dated[:N_INTERIORS]
49
-
50
- prods = [p for p in products
51
- if isinstance(p, dict) and isinstance(p.get("_added_at"), str)
52
- and p.get("_added_at") and (p.get("imgs") or p.get("i"))]
53
- prods.sort(key=lambda p: str(p["_added_at"]), reverse=True)
54
- top_p = prods[:N_PRODUCTS]
55
-
56
- urls = [f"{INTERIOR_API}/image/{r['image_id']}.jpg" for r in top_i]
57
- urls += [(p.get("imgs") or [p.get("i")])[0] for p in top_p]
58
-
59
- cell_w = (W - GAP * (COLS - 1)) // COLS
60
- cell_h = (H - GAP * (ROWS - 1)) // ROWS
61
-
62
- canvas = Image.new("RGB", (W, H), BG)
63
- cells = []
64
- for u in urls:
65
- try:
66
- data = fetch_bytes(u)
67
- im = Image.open(io.BytesIO(data))
68
- if im.mode not in ("RGB", "RGBA"):
69
- im = im.convert("RGB")
70
- if im.mode == "RGBA":
71
- im = im.convert("RGB")
72
- cells.append(im)
73
- except Exception as e:
74
- print(f"skip {u[:80]}: {e}", file=sys.stderr)
75
-
76
- if not cells:
77
- print("FATAL: no images could be fetched", file=sys.stderr)
78
- sys.exit(1)
79
-
80
- idx = 0
81
- for row in range(ROWS):
82
- for col in range(COLS):
83
- if idx >= len(cells):
84
- break
85
- im = cells[idx]
86
- idx += 1
87
- iw, ih = im.size
88
- scale = max(cell_w / iw, cell_h / ih)
89
- sw, sh = max(1, round(iw * scale)), max(1, round(ih * scale))
90
- im2 = im.resize((sw, sh), Image.LANCZOS)
91
- left = (sw - cell_w) // 2
92
- top = (sh - cell_h) // 2
93
- im2 = im2.crop((left, top, left + cell_w, top + cell_h))
94
- canvas.paste(im2, (col * (cell_w + GAP), row * (cell_h + GAP)))
95
-
96
- out = io.BytesIO()
97
- canvas.save(out, format="PNG", optimize=True)
98
- png = out.getvalue()
99
-
100
- os.makedirs("public/share", exist_ok=True)
101
- os.makedirs("dist/share", exist_ok=True)
102
- for path in ("public/share/og-image.png", "dist/share/og-image.png"):
103
- with open(path, "wb") as f:
104
- f.write(png)
105
- print(f"OK wrote {len(png)} bytes {W}x{H} from {len(cells)} images")
106
-
107
- if __name__ == "__main__":
108
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/refresh-catalogue.mjs CHANGED
@@ -32,7 +32,7 @@ const CLONE = process.env.CLONE_DIR || '/tmp/space';
32
  * is why the swap below happens only after it has run.
33
  */
34
  const STABLE_BUNDLE = 'assets/index.js';
35
- const STABLE_STYLE = 'assets/app.css';
36
 
37
  /** The two mirrored files, in both the source and the served location. */
38
  const MIRROR = ['products.json', 'promos.json'];
@@ -70,7 +70,7 @@ const MIRROR = ['products.json', 'promos.json'];
70
  * served was never pushed. That is a silent failure with no error anywhere, and
71
  * it is exactly the "sửa sp không đồng bộ" symptom.
72
  */
73
- const COMMITTABLE = ['index.html', 'public/catalogue', 'dist/catalogue', 'dist/assets', 'dist/share', 'public/share'];
74
 
75
  const ok = message => console.log(`[refresh] ${message}`);
76
  const warn = message => console.warn(`[refresh] WARNING: ${message}`);
@@ -99,23 +99,10 @@ function git(...args) {
99
  );
100
  }
101
 
102
- /**
103
- * Is git-lfs installed at all? (The smudge filter can only exist after
104
- * `git lfs install`, so availability is checked on the binary first, then
105
- * the clone-local filter is (re)wired with `git lfs install --local`.)
106
- */
107
- function hasGitLfsBinary() {
108
- try {
109
- return Boolean(shell('git lfs version 2>/dev/null || true').trim());
110
- } catch {
111
- return false;
112
- }
113
- }
114
-
115
- /** Is git-lfs BOTH installed AND wired as a smudge filter for this clone? */
116
  function hasGitLfs() {
117
  try {
118
- if (!hasGitLfsBinary()) return false;
119
  return Boolean(shell('git config --get filter.lfs.smudge || true').trim());
120
  } catch {
121
  return false;
@@ -174,7 +161,7 @@ function recordCount(relative) {
174
  /** True when index.html already names both stable assets. */
175
  function indexReferencesStable(html) {
176
  return /\.\/dist\/assets\/index\.js\b/.test(html)
177
- && /\.\/dist\/assets\/app\.css\b/.test(html);
178
  }
179
 
180
  /**
@@ -265,21 +252,6 @@ function main() {
265
  git('checkout', '-q', '-B', 'main', 'FETCH_HEAD');
266
  ok(`cloned ${REPO}`);
267
 
268
- // The og-image is LFS-tracked, and the pre-receive hook rejects raw binaries,
269
- // so the clone MUST be able to smudge/clean via git-lfs. `git lfs install
270
- // --local` inside the clone (a) loads the repository-local filters and (b)
271
- // fails visibly when git-lfs is not installed, instead of silently committing
272
- // a raw PNG pointer that a later push gets rejected for.
273
- if (hasGitLfsBinary()) {
274
- shell('git lfs install --local');
275
- if (!hasGitLfs()) {
276
- throw new Error('git lfs install --local did not wire the smudge filter');
277
- }
278
- ok('git-lfs wired for this clone');
279
- } else {
280
- throw new Error('git-lfs is required (og-image.png is LFS-tracked) — install git-lfs in the job');
281
- }
282
-
283
  // Informational only. COMMITTABLE excludes every LFS-tracked path, so a
284
  // missing git-lfs cannot lead to pointers being committed; it just means the
285
  // committed dist data cannot be verified from this clone.
@@ -294,16 +266,6 @@ function main() {
294
  // Copies vai-avatar's live product sources into public/catalogue/.
295
  shell('set -euo pipefail; node scripts/sync-catalogue.mjs');
296
 
297
- // Regenerate the social-share thumbnail (12 newest images) BEFORE the build,
298
- // so Vite copies public/share/ into dist/share/ and the committed og-image
299
- // is refreshed on every sync. Non-fatal: the previous thumbnail keeps
300
- // serving if the Hub is unreachable, but a HARD failure is not silent.
301
- shell('set -euo pipefail; python3 scripts/gen-thumbnail.py');
302
- for (const share of ['public/share/og-image.png', 'dist/share/og-image.png']) {
303
- requireRealFile(share, { why: 'the social-share thumbnail would be missing' });
304
- }
305
- ok('social-share thumbnail regenerated');
306
-
307
  // Guard before building: an empty or truncated mirror must never ship.
308
  for (const file of MIRROR) {
309
  requireRealFile(`public/catalogue/${file}`, { why: 'the mirror would ship empty' });
@@ -346,10 +308,8 @@ function main() {
346
  // Verify the served page BEFORE committing, and only AFTER repointing — the
347
  // original order checked the stale reference against the stale file and passed.
348
  const html = readFileSync(`${CLONE}/index.html`, 'utf8');
349
- // Query strings (`?v=...`) are cache busters, not part of the filename —
350
- // strip them so the existence check tests the real file on disk.
351
  const referenced = [...html.matchAll(/(?:src|href)="\.\/dist\/([^"]+)"/g)]
352
- .map(match => match[1].split('?')[0]);
353
  if (!referenced.length) {
354
  throw new Error('index.html references no dist/assets bundle — refusing to publish');
355
  }
 
32
  * is why the swap below happens only after it has run.
33
  */
34
  const STABLE_BUNDLE = 'assets/index.js';
35
+ const STABLE_STYLE = 'assets/index.css';
36
 
37
  /** The two mirrored files, in both the source and the served location. */
38
  const MIRROR = ['products.json', 'promos.json'];
 
70
  * served was never pushed. That is a silent failure with no error anywhere, and
71
  * it is exactly the "sửa sp không đồng bộ" symptom.
72
  */
73
+ const COMMITTABLE = ['index.html', 'public/catalogue', 'dist/catalogue', 'dist/assets'];
74
 
75
  const ok = message => console.log(`[refresh] ${message}`);
76
  const warn = message => console.warn(`[refresh] WARNING: ${message}`);
 
99
  );
100
  }
101
 
102
+ /** Is git-lfs both installed AND wired as a smudge filter for this clone? */
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  function hasGitLfs() {
104
  try {
105
+ if (!shell('git lfs version 2>/dev/null || true').trim()) return false;
106
  return Boolean(shell('git config --get filter.lfs.smudge || true').trim());
107
  } catch {
108
  return false;
 
161
  /** True when index.html already names both stable assets. */
162
  function indexReferencesStable(html) {
163
  return /\.\/dist\/assets\/index\.js\b/.test(html)
164
+ && /\.\/dist\/assets\/index\.css\b/.test(html);
165
  }
166
 
167
  /**
 
252
  git('checkout', '-q', '-B', 'main', 'FETCH_HEAD');
253
  ok(`cloned ${REPO}`);
254
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  // Informational only. COMMITTABLE excludes every LFS-tracked path, so a
256
  // missing git-lfs cannot lead to pointers being committed; it just means the
257
  // committed dist data cannot be verified from this clone.
 
266
  // Copies vai-avatar's live product sources into public/catalogue/.
267
  shell('set -euo pipefail; node scripts/sync-catalogue.mjs');
268
 
 
 
 
 
 
 
 
 
 
 
269
  // Guard before building: an empty or truncated mirror must never ship.
270
  for (const file of MIRROR) {
271
  requireRealFile(`public/catalogue/${file}`, { why: 'the mirror would ship empty' });
 
308
  // Verify the served page BEFORE committing, and only AFTER repointing — the
309
  // original order checked the stale reference against the stale file and passed.
310
  const html = readFileSync(`${CLONE}/index.html`, 'utf8');
 
 
311
  const referenced = [...html.matchAll(/(?:src|href)="\.\/dist\/([^"]+)"/g)]
312
+ .map(match => match[1]);
313
  if (!referenced.length) {
314
  throw new Error('index.html references no dist/assets bundle — refusing to publish');
315
  }
scripts/refresh.mjs DELETED
@@ -1,74 +0,0 @@
1
- #!/usr/bin/env node
2
- import { execFileSync } from 'node:child_process';
3
- import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
-
5
- const REPO = 'bep40/semantic-image-field';
6
- const CLONE = '/tmp/space';
7
- const STABLE_BUNDLE = 'assets/index.js';
8
- const STABLE_STYLE = 'assets/app.css';
9
- const MIRROR = ['products.json', 'promos.json'];
10
- const COMMITTABLE = ['index.html', 'public/catalogue', 'dist/catalogue', 'dist/assets', 'dist/share', 'public/share'];
11
-
12
- const ok = m => console.log(`[refresh] ${m}`);
13
-
14
- function run(command, args, { cwd = CLONE } = {}) {
15
- return execFileSync(command, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
16
- }
17
- function shell(script, cwd = CLONE) { return run('bash', ['-lc', script], { cwd }); }
18
- function git(...args) {
19
- return run('git', ['-c', `http.extraheader=Authorization: Bearer ${process.env.HF_TOKEN}`, ...args]);
20
- }
21
-
22
- function main() {
23
- if (!process.env.HF_TOKEN) throw new Error('HF_TOKEN required');
24
- rmSync(CLONE, { recursive: true, force: true });
25
- run('mkdir', ['-p', CLONE]);
26
- run('git', ['clone', '-q', `--filter=blob:none`, '--no-checkout', `https://huggingface.co/spaces/${REPO}`], { cwd: '/' });
27
- git('checkout', '-q', '-B', 'main', 'main');
28
- ok('cloned');
29
-
30
- shell('set -euo pipefail; npm install --silent --no-audit --no-fund');
31
- ok('dependencies installed');
32
- shell('set -euo pipefail; node scripts/sync-catalogue.mjs');
33
- for (const file of MIRROR) {
34
- const count = JSON.parse(readFileSync(`public/catalogue/${file}`, 'utf8')).length;
35
- ok(`public/catalogue/${file}: ${count} entries`);
36
- }
37
- shell('set -euo pipefail; npm run build');
38
- ok('built');
39
- for (const file of MIRROR) {
40
- const served = `dist/catalogue/${file}`;
41
- const source = JSON.parse(readFileSync(`public/catalogue/${file}`, 'utf8')).length;
42
- const built = JSON.parse(readFileSync(served, 'utf8')).length;
43
- if (source !== built) throw new Error(`${served} has ${built} but mirror has ${source}`);
44
- ok(`served mirror verified: ${file} (${built} entries)`);
45
- }
46
- const htmlPath = `${CLONE}/index.html`;
47
- let html = readFileSync(htmlPath, 'utf8');
48
- if (/\.\/dist\/assets\/index\.js\b/.test(html) && /\.\/dist\/assets\/app\.css\b/.test(html)) {
49
- ok('index.html already references stable bundle');
50
- } else {
51
- let next = html.replace(/(\.\/dist\/assets\/)index-[A-Za-z0-9_-]+\.js\b/g, `$1${STABLE_BUNDLE.slice('assets/'.length)}`);
52
- next = next.replace(/(\.\/dist\/assets\/)index-[A-Za-z0-9_-]+\.css\b/g, `$1${STABLE_STYLE.slice('assets/'.length)}`);
53
- if (next === html) throw new Error('index.html names bundle in unexpected way');
54
- writeFileSync(htmlPath, next);
55
- ok('index.html repointed to ' + STABLE_BUNDLE);
56
- }
57
- for (const asset of [STABLE_BUNDLE, STABLE_STYLE]) {
58
- if (!existsSync(`${CLONE}/dist/${asset}`)) throw new Error(`dist/${asset} missing`);
59
- }
60
- const commit = shell(`set -euo pipefail;
61
- git config user.name "semantic-image-field refresh";
62
- git config user.email "refresh@hf.space";
63
- git add -- ${COMMITTABLE.map(p => `'${p}'`).join(' ')};
64
- if git diff --cached --quiet; then echo NOCHANGE; else git commit -q -m "chore: rebuild with semantic search fix"; echo COMMITTED; fi`).trim();
65
- if (commit.includes('COMMITTED')) {
66
- git('push', '-q', 'origin', 'main');
67
- ok('pushed — the static Space will rebuild');
68
- } else {
69
- ok(`no change needed (HEAD ${shell('git rev-parse --short HEAD').trim()})`);
70
- }
71
- console.log('REFRESH_OK');
72
- }
73
- try { main(); }
74
- catch (e) { console.error('REFRESH_FAILED:', e.message, e.stderr || ''); process.exitCode = 1; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-avatar-interiors.mjs DELETED
@@ -1,287 +0,0 @@
1
- /**
2
- * Tests for the vai-avatar interior sync and the featured/neon emphasis.
3
- *
4
- * The emphasis rules are the part most likely to rot silently: a wrong tile
5
- * still renders, it just renders at the wrong size or without the ring, and
6
- * nothing throws. These assert the geometry and the ordering, not just "no
7
- * crash".
8
- *
9
- * The share-URL block is here for the same reason, and it is deliberately
10
- * Node-only. The browser copy of this rule lives in dist/assets/share-fix.js as
11
- * a string regex, because it has to run against the served bundle before a
12
- * rebuild; the routing decision itself lives in src/assets.js. These tests pin
13
- * the src/ half so the two cannot drift apart silently, and the browser half is
14
- * asserted separately in scripts/test-neon-ui.mjs.
15
- */
16
- import assert from 'node:assert/strict';
17
- import { mergeResults } from '../src/merge.js';
18
- import { foldText, searchAvatarInteriors } from '../src/avatar-interiors.js';
19
- import { shareURL } from '../src/assets.js';
20
-
21
- let passed = 0, failed = 0;
22
- const test = (name, fn) => {
23
- try { fn(); console.log(' ok ' + name); passed++; }
24
- catch (error) { console.log(' FAIL ' + name + '\n ' + error.message); failed++; }
25
- };
26
-
27
- const interior = (id, score) => ({ id: `interior:${id}`, blur: 'L000', ar: 1, score });
28
- const product = (sku, score) => ({ sku, name: 'Tủ bếp', ar: 1, score, images: ['https://x/y.jpg'] });
29
-
30
- console.log('\nvai-avatar interior sync');
31
- // ──────────────────────────────────────────────────────────────────────────
32
-
33
- test('Vietnamese folding makes diacritics optional on both sides', () => {
34
- // The whole reason foldText exists: a visitor types "bep da", the record says
35
- // "Bếp đá". Without folding these never match and the neon never fires.
36
- assert.equal(foldText('Bếp đá'), 'bep da');
37
- assert.equal(foldText('Phòng Ngủ'), 'phong ngu');
38
- });
39
-
40
- test('a whole-query match outranks a single-term match', () => {
41
- const items = [
42
- { avatarImageId: 'a', title: 'sofa phòng khách hiện đại', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z' },
43
- { avatarImageId: 'b', title: 'ghế sofa', description: 'mẫu ghế đơn', categories: [], keywords: [], addedAt: '2026-10-02T00:00:00Z' },
44
- ];
45
- const hits = searchAvatarInteriors('sofa phòng khách', items);
46
- assert.equal(hits[0].avatarImageId, 'a', 'the full-query match must rank first');
47
- });
48
-
49
- test('a whole-query match scores above the exact-outline threshold', () => {
50
- // 900 is the same floor relevance.js uses, and it is what makes merge.js treat
51
- // this as identity rather than resemblance. If this dropped below 700 the neon
52
- // would silently stop appearing on newly added photographs.
53
- const items = [{ avatarImageId: 'a', title: 'chuyển sang phong cách classical', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z' }];
54
- assert.ok(searchAvatarInteriors('classical', items)[0].score >= 700);
55
- });
56
-
57
- test('an unrelated query returns nothing rather than everything', () => {
58
- const items = [{ avatarImageId: 'a', title: 'sofa phòng khách', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z' }];
59
- assert.equal(searchAvatarInteriors('móc treo', items).length, 0);
60
- });
61
-
62
- test('a query too short to be meaningful is rejected', () => {
63
- const items = [{ avatarImageId: 'a', title: 'a', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z' }];
64
- assert.equal(searchAvatarInteriors('a', items).length, 0);
65
- });
66
-
67
- console.log('\nshare URLs');
68
- // ──────────────────────────────────────────────────────────────────────────
69
-
70
- const STUDIO = 'https://bep40-vai-avatar.hf.space';
71
- const broken = 'https://bep40-vai-avatar.hf.space/share/p/avatarimg%3Ab68dc779370dc70e';
72
- const fixed = `${STUDIO}/share/i/b68dc779370dc70e`;
73
-
74
- test('the reported broken URL is now the interior route', () => {
75
- // The exact address from the report. `avatarimg:` is a namespace for
76
- // de-duplication, not a catalogue identity, so /share/p/ resolved to nothing
77
- // and vai-avatar answered "Not Found" for every interior photograph.
78
- assert.equal(shareURL({ avatarImageId: 'b68dc779370dc70e', sku: 'avatarimg:b68dc779370dc70e' }), fixed);
79
- });
80
-
81
- test('the bug is reproducible against the old logic', () => {
82
- // Pins what the fix is fixing, so the assertion above cannot pass for an
83
- // unrelated reason. Without the avatarImageId branch, `sku` wins and the link
84
- // is the dead one -- byte-identical to the reported URL.
85
- const photo = { sku: 'avatarimg:b68dc779370dc70e' };
86
- const oldRoute = `${STUDIO}/share/p/${encodeURIComponent(photo.sku)}`;
87
- assert.equal(oldRoute, broken, 'the reported URL must be what the old routing produced');
88
- assert.notEqual(shareURL(photo), oldRoute, 'shareURL still routes an avatarimg sku to /share/p/');
89
- });
90
-
91
- test('an interior is routed even when it has lost avatarImageId', () => {
92
- // merge.js rewrites `id` into a namespaced tile key, so a record can arrive
93
- // carrying the sku but not the original field. The prefix is unwrapped rather
94
- // than treated as an identity, which is what this fallback exists for.
95
- assert.equal(shareURL({ sku: 'avatarimg:b68dc779370dc70e' }), fixed);
96
- });
97
-
98
- test('an empty avatarimg prefix yields no share URL at all', () => {
99
- // /share/i/ with nothing after it is its own 404. Returning null lets the
100
- // caller omit the button rather than render a dead link.
101
- assert.equal(shareURL({ sku: 'avatarimg:' }), null);
102
- assert.equal(shareURL({ avatarImageId: ' ' }), null);
103
- });
104
-
105
- test('a real product still routes to /share/p/', () => {
106
- // The fix must not disturb the route that works. A product keeps its slug, and
107
- // /share/p/ resolves all three identity forms.
108
- assert.equal(shareURL({ sku: 'MH800TC' }), `${STUDIO}/share/p/MH800TC`);
109
- assert.equal(
110
- shareURL({ sku: 'MH800TC', slug: 'mh800tc-malloca-may-hut-mui-gan-tu-am-tu-80cm' }),
111
- `${STUDIO}/share/p/mh800tc-malloca-may-hut-mui-gan-tu-am-tu-80cm`,
112
- 'the slug must still win over the sku',
113
- );
114
- });
115
-
116
- test('a baked interior without a sku keeps its own route', () => {
117
- // No sku at all: the id branch is what answers, and it must not be captured by
118
- // the avatarimg fallback.
119
- assert.equal(shareURL({ id: 'a1b2c3d4e5' }), `${STUDIO}/share/i/a1b2c3d4e5`);
120
- });
121
-
122
- test('a record with nothing to share returns null', () => {
123
- assert.equal(shareURL(null), null);
124
- assert.equal(shareURL(undefined), null);
125
- assert.equal(shareURL({}), null);
126
- assert.equal(shareURL('nope'), null);
127
- });
128
-
129
- console.log('\nfeatured tiles');
130
- // ──────────────────────────────────────────────────────────────────────────
131
-
132
- test('vai-avatar interiors land at the head of the field', () => {
133
- // "near the search box" is implemented as list order: index 0 is the slot
134
- // nearest the pill. A new photograph must not land mid-field.
135
- const out = mergeResults(
136
- [interior('a', 0.9), interior('b', 0.8)],
137
- [],
138
- { avatarInteriors: [{ avatarImageId: 'new1', title: 'Mới', addedAt: '2026-10-05T00:00:00Z' }] },
139
- );
140
- assert.equal(out[0].avatarImageId, 'new1');
141
- assert.ok(out[0].featured, 'a new photograph is featured');
142
- });
143
-
144
- test('newest first: the most recent photograph is the first tile', () => {
145
- const out = mergeResults([], [], { avatarInteriors: [
146
- { avatarImageId: 'older', title: 'Cũ', addedAt: '2026-10-01T00:00:00Z' },
147
- { avatarImageId: 'newer', title: 'Mới', addedAt: '2026-10-05T00:00:00Z' },
148
- ] });
149
- assert.equal(out[0].avatarImageId, 'newer');
150
- });
151
-
152
- test('a featured tile is measurably larger than an ordinary one', () => {
153
- const out = mergeResults([], [], { avatarInteriors: [
154
- { avatarImageId: 'n', title: 'Mới', addedAt: '2026-10-05T00:00:00Z' },
155
- ] });
156
- assert.ok(out[0].featuredScale > 1, 'featuredScale must exceed 1');
157
- });
158
-
159
- test('a featured interior keeps a finite score so it is not dimmed', () => {
160
- // relevanceAppearance() dims any tile whose score is not finite -- a featured
161
- // tile rendered at 14% opacity would be the opposite of the request.
162
- const out = mergeResults([], [], { avatarInteriors: [{ avatarImageId: 'n', title: 'Mới' }] });
163
- assert.ok(Number.isFinite(out[0].score), 'score must be finite, got ' + out[0].score);
164
- });
165
-
166
- test('a decisive product winner is featured and reaches the head', () => {
167
- // 1000 = exact SKU in vai-avatar's scorer, next tier is 550, so this is
168
- // identity, and it should be presented like the answer rather than like a hit.
169
- // The shape is the worker's own `{ sku, score, product }` wrapper.
170
- const out = mergeResults(
171
- [interior('a', 0.9)],
172
- [{ sku: 'SKU1', score: 1000, product: { sku: 'SKU1', name: 'Tủ bếp', ar: 1, images: ['https://cdn/x.jpg'] } }],
173
- { query: 'SKU1' },
174
- );
175
- const hit = out.find(r => r.sku === 'SKU1');
176
- assert.ok(hit?.featured, 'the exact product is featured');
177
- assert.ok(hit.featuredScale > 1);
178
- });
179
-
180
- test('a non-decisive product is NOT featured', () => {
181
- // Two products scoring 1000 and 900 is a model family, not an answer. Enlarging
182
- // it would be misleading, and the neon must not ring it either.
183
- const out = mergeResults([interior('a', 0.9)], [
184
- { sku: 'SKU1', score: 1000, product: { sku: 'SKU1', name: 'A', ar: 1, images: ['https://cdn/x.jpg'] } },
185
- { sku: 'SKU2', score: 900, product: { sku: 'SKU2', name: 'B', ar: 1, images: ['https://cdn/x.jpg'] } },
186
- ], { query: 'SKU1' });
187
- assert.ok(!out.find(r => r.sku === 'SKU1')?.featured, 'a tie must not be featured');
188
- });
189
-
190
- test('enlargement and the neon agree: every featured interior is an exact hit', () => {
191
- // The two decisions come from one source (exactMatches). This asserts the
192
- // consequence -- a tile is never enlarged without being ringed.
193
- const out = mergeResults([interior('a', 0.9)], [], {
194
- avatarInteriors: [{ avatarImageId: 'n', title: 'phong cách classical', score: 900 }],
195
- query: 'classical',
196
- });
197
- const tile = out[0];
198
- assert.ok(tile.featured && tile.featuredScale > 1);
199
- });
200
-
201
- console.log('\nnamespace safety');
202
- // ──────────────────────────────────────────────────────────────────────────
203
-
204
- test('an avatar interior cannot be shadowed by a colliding raw id', () => {
205
- // A bare id in a shared namespace would let one record silently drop another
206
- // with no error. Namespacing is what makes that impossible.
207
- const out = mergeResults([interior('n', 0.5)], [], {
208
- avatarInteriors: [{ avatarImageId: 'n', title: 'Mới' }],
209
- });
210
- assert.equal(out.length, 2, 'both records survive: ' + JSON.stringify(out.map(r => r.id)));
211
- });
212
-
213
- test('a duplicate avatar interior is listed once', () => {
214
- const out = mergeResults([], [], { avatarInteriors: [
215
- { avatarImageId: 'n', title: 'Mới' }, { avatarImageId: 'n', title: 'Mới' },
216
- ] });
217
- assert.equal(out.filter(r => r.avatarImageId === 'n').length, 1);
218
- });
219
-
220
- test('an interior without an avatar id is skipped, not rendered blank', () => {
221
- const out = mergeResults([interior('a', 0.9)], [], { avatarInteriors: [{ title: 'không có id' }] });
222
- assert.equal(out.length, 1);
223
- });
224
-
225
- test('an empty avatar list changes nothing', () => {
226
- const base = mergeResults([interior('a', 0.9), interior('b', 0.8)], [product('S1', 500)]);
227
- assert.equal(mergeResults([interior('a', 0.9), interior('b', 0.8)], [product('S1', 500)], { avatarInteriors: [] }).length, base.length);
228
- });
229
-
230
- console.log('\nonly the newest ring');
231
- // ──────────────────────────────────────────────────────────────────────────
232
-
233
- /** Build `n` avatar interiors, newest last, and merge them into the field. */
234
- const freshSet = n => Array.from({ length: n }, (_, i) => ({
235
- avatarImageId: `id${i}`,
236
- title: `Ảnh ${i}`,
237
- addedAt: `2026-10-${String((i % 28) + 1).padStart(2, '0')}T00:00:00Z`,
238
- }));
239
-
240
- test('exactly ten of many new photographs carry the neon', () => {
241
- // The brief asks for more photographs on the page AND for only the newest to
242
- // ring. Those pull against each other, so the count is pinned: 24 rings is
243
- // decoration, not emphasis.
244
- const out = mergeResults([], [], { avatarInteriors: freshSet(40) });
245
- const ringed = out.filter(r => r.featured);
246
- assert.equal(ringed.length, 10, 'expected 10 ringed, got ' + ringed.length);
247
- });
248
-
249
- test('the un-ringed ones are still shown as ordinary tiles', () => {
250
- // "Chỉ chọn 10 ảnh mới nhất có viền neon còn lại hiển thị bình thường" -- the
251
- // rest must be VISIBLE, just not ringed. Dropping them would satisfy the first
252
- // half of the sentence and break the second.
253
- const out = mergeResults([], [], { avatarInteriors: freshSet(40) });
254
- assert.equal(out.length, 40, 'all 40 should be in the field, got ' + out.length);
255
- assert.equal(out.filter(r => !r.featured).length, 30);
256
- });
257
-
258
- test('an un-ringed tile has no featuredScale, so it renders at normal size', () => {
259
- const out = mergeResults([], [], { avatarInteriors: freshSet(40) });
260
- const plain = out.filter(r => !r.featured);
261
- assert.ok(plain.every(r => r.featuredScale === undefined), 'an ordinary tile carries a scale');
262
- });
263
-
264
- test('the ten ringed are the newest ten, in order', () => {
265
- // mergedAt is per-record, so equal dates sort by input order; distinct dates
266
- // make the intent unambiguous.
267
- const items = Array.from({ length: 15 }, (_, i) => ({
268
- avatarImageId: `x${i}`,
269
- title: `Ảnh ${i}`,
270
- addedAt: `2026-10-${String(i + 1).padStart(2, '0')}T00:00:00Z`,
271
- }));
272
- const ringed = mergeResults([], [], { avatarInteriors: items }).filter(r => r.featured);
273
- assert.equal(ringed.length, 10);
274
- // x14 is day 15, the newest. The ringed set must be days 15 down to 6.
275
- assert.equal(ringed[0].avatarImageId, 'x14', 'newest is not first');
276
- assert.equal(ringed[9].avatarImageId, 'x5');
277
- const plain = mergeResults([], [], { avatarInteriors: items }).filter(r => !r.featured);
278
- assert.equal(plain[0].avatarImageId, 'x4', 'the oldest ringed-off one is day 5');
279
- });
280
-
281
- test('fewer than ten new photographs still all ring', () => {
282
- const out = mergeResults([], [], { avatarInteriors: freshSet(4) });
283
- assert.equal(out.filter(r => r.featured).length, 4);
284
- });
285
-
286
- console.log(`\n${passed} passed, ${failed} failed`);
287
- process.exit(failed ? 1 : 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-merge.mjs CHANGED
@@ -57,7 +57,7 @@ test('gives each product a resolvable absolute src', () => {
57
  });
58
 
59
  test('accepts the worker shape ({sku, score, product}) and the flat shape', () => {
60
- const wrapped = mergeResults(interiors(4), [{ sku: 'A', score: 900, product: product('A') }]);
61
  const flat = mergeResults(interiors(4), [product('A')]);
62
  assert.equal(wrapped.filter(r => r.sku).length, 1);
63
  assert.equal(flat.filter(r => r.sku).length, 1);
@@ -84,7 +84,7 @@ test('never duplicates an interior or a product', () => {
84
  test('caps how many products enter the field', () => {
85
  const many = Array.from({ length: 60 }, (_, i) => product(`S${i}`));
86
  const out = mergeResults(interiors(60), many);
87
- assert.equal(out.filter(r => r.sku).length, 24);
88
  // The strongest catalogue hits are the ones kept.
89
  assert.deepEqual(out.filter(r => r.sku).slice(0, 3).map(r => r.sku), ['S0', 'S1', 'S2']);
90
  });
@@ -161,50 +161,4 @@ test('interior scores are never modified', () => {
161
  assert.deepEqual(ranked.map(r => r.score), before);
162
  });
163
 
164
- test('a product query crowds interiors out of the field', () => {
165
- // "kệ xoong" is a product ask, so once a product clears the keyword floor the
166
- // CLIP interiors must not be sprinkled through the results -- only the racks.
167
- // The product's name must actually carry the query's words (or their rack
168
- // synonyms), which is what separates a product answer from a substring hit.
169
- const ranked = interiors(30).map((r, i) => ({ ...r, score: 0.5 - i * 0.001 }));
170
- const hits = [
171
- { sku: 'KV1', name: 'Kệ xoong nồi inox', ar: 1, images: ['https://cdn.example/kv1.jpg'], score: 367 },
172
- { sku: 'KV2', name: 'Giá xoong nồi inox', ar: 1, images: ['https://cdn.example/kv2.jpg'], score: 367 },
173
- ];
174
- const out = mergeResults(ranked, hits, { query: 'kệ xoong' });
175
- assert.equal(out.filter(r => r.sku).length, 2);
176
- assert.equal(out.filter(r => !r.sku).length, 0, 'interiors must be dropped for a product query');
177
- });
178
-
179
- test('browse mode keeps interiors even when products arrive', () => {
180
- // No query means browse: the newest interiors front row must stay; the
181
- // index-time products are decoration, not the answer.
182
- const ranked = interiors(6).map((r, i) => ({ ...r, score: 0.5 - i * 0.001 }));
183
- const out = mergeResults(ranked, [product('A', { score: 367 })]);
184
- assert.equal(out.filter(r => !r.sku).length, 6, 'browse keeps all interiors');
185
- });
186
-
187
- test('every product hit above the keyword floor is featured (neon ring + scale)', () => {
188
- // The brief wants "toàn bộ ảnh là kệ xoong nội... có viền neon và kích thước
189
- // thư giãn hơn": all relevant racks, not just the top 3, get the ring+scale.
190
- const hits = [
191
- product('A', { score: 367 }), product('B', { score: 367 }),
192
- product('C', { score: 367 }), product('D', { score: 367 }),
193
- ];
194
- const out = mergeResults(interiors(2), hits, { query: 'kệ xoong' });
195
- const featured = out.filter(r => r.sku && r.featured);
196
- assert.equal(featured.length, 4, 'all keyword hits above the floor are featured');
197
- });
198
-
199
- test('a product whose only lead points into a dead V.AISTUDIO dir is skipped', () => {
200
- const dead = {
201
- sku: 'EPD4635VX', name: 'Giá', ar: 1,
202
- images: ['https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/eurogold_img/EPD4635VX.jpg'],
203
- };
204
- const live = product('EPO8360VX');
205
- const out = mergeResults(interiors(3), [dead, live]);
206
- const skus = out.filter(r => r.sku).map(r => r.sku);
207
- assert.deepEqual(skus, ['EPO8360VX']);
208
- });
209
-
210
  console.log(`\n${passed} passed`);
 
57
  });
58
 
59
  test('accepts the worker shape ({sku, score, product}) and the flat shape', () => {
60
+ const wrapped = mergeResults(interiors(4), [{ sku: 'A', score: 9, product: product('A') }]);
61
  const flat = mergeResults(interiors(4), [product('A')]);
62
  assert.equal(wrapped.filter(r => r.sku).length, 1);
63
  assert.equal(flat.filter(r => r.sku).length, 1);
 
84
  test('caps how many products enter the field', () => {
85
  const many = Array.from({ length: 60 }, (_, i) => product(`S${i}`));
86
  const out = mergeResults(interiors(60), many);
87
+ assert.equal(out.filter(r => r.sku).length, 12);
88
  // The strongest catalogue hits are the ones kept.
89
  assert.deepEqual(out.filter(r => r.sku).slice(0, 3).map(r => r.sku), ['S0', 'S1', 'S2']);
90
  });
 
161
  assert.deepEqual(ranked.map(r => r.score), before);
162
  });
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  console.log(`\n${passed} passed`);
scripts/test-neon-ui.mjs DELETED
@@ -1,256 +0,0 @@
1
- /**
2
- * Browser check for the featured + running-neon emphasis.
3
- *
4
- * Complements test-avatar-interiors.mjs: that file proves the data is correct,
5
- * this one proves the browser actually PAINTS it. The failure mode that matters
6
- * most here is silent -- a mask-composite the browser ignores renders an opaque
7
- * tile instead of a ring, which no assertion on the data would ever catch.
8
- *
9
- * node scripts/test-neon-ui.mjs # against a local preview
10
- * BASE_URL=... node scripts/test-neon-ui.mjs
11
- */
12
- import { chromium } from 'playwright';
13
-
14
- const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173/';
15
- let passed = 0, failed = 0;
16
- const test = async (name, fn) => {
17
- try { await fn(); console.log(' ok ' + name); passed++; }
18
- catch (error) { console.log(' FAIL ' + name + '\n ' + error.message.split('\n')[0]); failed++; }
19
- };
20
- const assert = (cond, message) => { if (!cond) throw new Error(message); };
21
-
22
- const browser = await chromium.launch();
23
- const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
24
- const errors = [];
25
- page.on('pageerror', error => errors.push(error.message));
26
-
27
- await page.goto(BASE, { waitUntil: 'domcontentloaded' });
28
- // The worker downloads the CLIP text tower before it can answer; give it room.
29
- await page.waitForSelector('.image-tile', { timeout: 120000 });
30
-
31
- console.log('\nfield + controls');
32
- // ──────────────────────────────────────────────────────────────────────────
33
-
34
- await test('the field renders tiles', async () => {
35
- const n = await page.locator('.image-tile').count();
36
- assert(n > 10, 'expected a populated field, got ' + n);
37
- });
38
-
39
- await test('the size and position controls are gone', async () => {
40
- // Explicitly asserted rather than assumed: the brief asked for them removed,
41
- // and a leftover panel under the pill would be a visible regression.
42
- assert(await page.locator('.field-controls').count() === 0, '.field-controls still present');
43
- assert(await page.getByRole('button', { name: /kích thước|vị trí/i }).count() === 0, 'a control button is still rendered');
44
- });
45
-
46
- await test('the search pill is unobstructed', async () => {
47
- const box = await page.locator('.search-pill').boundingBox();
48
- assert(box && box.height > 20, 'the search pill has no box');
49
- });
50
-
51
- console.log('\nfeatured + neon');
52
- // ──────────────────────────────────────────────────────────────────────────
53
-
54
- await test('a fresh vai-avatar photograph appears as a featured tile', async () => {
55
- // Waits for the live index rather than assuming the network was fast.
56
- await page.waitForSelector('.image-tile.featured', { timeout: 60000 });
57
- const n = await page.locator('.image-tile.featured').count();
58
- assert(n > 0, 'no featured tile rendered');
59
- });
60
-
61
- await test('the featured tile is larger than an ordinary tile', async () => {
62
- const featured = await page.locator('.image-tile.featured').first().boundingBox();
63
- const ordinary = await page.locator('.image-tile:not(.featured)').first().boundingBox();
64
- const area = b => b.width * b.height;
65
- assert(area(featured) > area(ordinary) * 1.15,
66
- `featured ${Math.round(area(featured))}px² vs ordinary ${Math.round(area(ordinary))}px²`);
67
- });
68
-
69
- await test('a fresh photograph loads from vai-avatar, CORS included', async () => {
70
- // The tiles fall back to BlurHash silently on a CORS failure, so this is the
71
- // only place that failure would show up at all.
72
- await page.waitForFunction(() => {
73
- const tile = document.querySelector('.image-tile.featured');
74
- return tile?.querySelector('img.loaded');
75
- }, { timeout: 60000 });
76
- });
77
-
78
- await test('a fresh photograph wears the running neon with no query typed', async () => {
79
- // Asserted separately from the exact-match case because the two are different
80
- // CSS selectors: a newest photograph rings before anything has been searched,
81
- // and dropping that would make the feature invisible on page load.
82
- const ring = await page.evaluate(() => {
83
- const tile = document.querySelector('.image-tile.featured');
84
- const style = getComputedStyle(tile, '::before');
85
- return { animation: style.animationName, mask: style.maskImage || style.webkitMaskImage };
86
- });
87
- assert(ring.animation === 'neon-run', 'a fresh photograph has no running neon: ' + ring.animation);
88
- assert(ring.mask && ring.mask !== 'none', 'the ring has no mask, so it would fill the tile');
89
- });
90
-
91
- await test('the ring layer itself never moves', async () => {
92
- // The regression this whole file is about. ::before carries the mask as well
93
- // as the gradient, so rotating the layer rotates the mask too and the painted
94
- // border slides off the tile's edges -- which on a 16:9 photograph reads as a
95
- // skewed smear, not a light going round the frame.
96
- //
97
- // Asserted on a REAL tile, and cross-checked for shape: a square tile would
98
- // hide a rotated mask just as well as it hid the original bug, so the test
99
- // fails if it cannot find a tile that is demonstrably not square.
100
- const ring = await page.evaluate(() => {
101
- const nonSquare = [...document.querySelectorAll('.image-tile.exact, .image-tile.featured')]
102
- .map(tile => ({ tile, box: tile.getBoundingClientRect() }))
103
- .filter(({ box }) => box.width > 40 && box.height > 40
104
- && Math.abs(box.width / box.height - 1) > 0.15);
105
- if (!nonSquare.length) return { checked: false };
106
- const { tile, box } = nonSquare[0];
107
- const style = getComputedStyle(tile, '::before');
108
- return {
109
- checked: true,
110
- transform: style.transform,
111
- angle: style.getPropertyValue('--neon-angle').trim(),
112
- ratio: box.width / box.height,
113
- };
114
- });
115
- assert(ring.checked, 'no non-square ringed tile on screen to test against');
116
- assert(/^(none|matrix\(1, 0, 0, 1, 0, 0\)|matrix\(1, 0, 0, 1, 0px, 0px\))$/.test(ring.transform),
117
- `the ring layer is transformed (${ring.transform}) on a ${ring.ratio.toFixed(2)}:1 tile, so its mask has rotated off the frame`);
118
- assert(ring.angle && /deg|rad|grad|turn/.test(ring.angle),
119
- 'the sweep angle is not resolving to an angle: "' + ring.angle + '"');
120
- });
121
-
122
- await test('a fresh photograph is fully lit, not dimmed as unranked', async () => {
123
- const opacity = await page.evaluate(() => {
124
- const tile = document.querySelector('.image-tile.featured');
125
- return parseFloat(getComputedStyle(tile).opacity);
126
- });
127
- assert(opacity > 0.9, 'a fresh photograph is faded to ' + opacity);
128
- });
129
-
130
- console.log('\nquery emphasis');
131
- // ──────────────────────────────────────────────────────────────────────────
132
-
133
- await test('an exact query rings a tile with the running neon', async () => {
134
- await page.fill('input[type=text]', 'phòng khách');
135
- await page.waitForSelector('.image-tile.exact', { timeout: 60000 });
136
-
137
- const ring = await page.evaluate(() => {
138
- const tile = document.querySelector('.image-tile.exact');
139
- const style = getComputedStyle(tile, '::before');
140
- return {
141
- animation: style.animationName,
142
- background: style.backgroundImage,
143
- mask: style.maskImage || style.webkitMaskImage,
144
- };
145
- });
146
- assert(ring.animation === 'neon-run', 'the ring is not animating: ' + ring.animation);
147
- assert(/conic-gradient/.test(ring.background), 'the ring is not a conic gradient');
148
- assert(ring.mask && ring.mask !== 'none', 'the ring has no mask, so it would fill the tile');
149
- });
150
-
151
- await test('the neon sweep actually advances between frames', async () => {
152
- // A gradient that is present but not advancing is the exact failure the brief
153
- // asked to avoid, and it looks correct in a single screenshot.
154
- //
155
- // Read the sweep angle, NOT `transform`. The transform is now pinned to none
156
- // on purpose -- it is the thing that used to drag the mask off the frame -- so
157
- // asserting on it would be asserting the old bug is still there.
158
- const read = () => page.evaluate(() => {
159
- const tile = document.querySelector('.image-tile.exact');
160
- if (!tile) return null;
161
- const raw = getComputedStyle(tile, '::before').getPropertyValue('--neon-angle').trim();
162
- const value = Number.parseFloat(raw);
163
- return Number.isFinite(value) ? value : raw;
164
- });
165
- const a = await read();
166
- assert(a !== null, 'no exact tile to read');
167
- await page.waitForTimeout(700);
168
- const b = await read();
169
- assert(a !== b, `the sweep angle did not change (${a} -> ${b})`);
170
- });
171
-
172
- await test('the head stays inside the frame on a non-square tile', async () => {
173
- // The mask is the tile's own box, so the bright head has to sit on the edge
174
- // for every aspect ratio. Verified by reading the gradient's own angle out of
175
- // the computed style: it must be a resolved angle on every ringed tile, not
176
- // just on the ones that happen to be square.
177
- const angles = await page.evaluate(() => {
178
- return [...document.querySelectorAll('.image-tile.exact, .image-tile.featured')].map(tile => {
179
- const box = tile.getBoundingClientRect();
180
- const raw = getComputedStyle(tile, '::before').getPropertyValue('--neon-angle').trim();
181
- return { ratio: box.width / box.height, raw };
182
- });
183
- });
184
- for (const { ratio, raw } of angles) {
185
- assert(raw && /deg$/.test(raw),
186
- `a ${ratio.toFixed(2)}:1 tile has sweep angle "${raw}", so the head is not on the frame`);
187
- }
188
- });
189
-
190
- await test('an exact tile is never dimmed', async () => {
191
- const opacity = await page.evaluate(() => {
192
- const tile = document.querySelector('.image-tile.exact');
193
- return parseFloat(getComputedStyle(tile).opacity);
194
- });
195
- assert(opacity > 0.9, 'an exact match is faded to ' + opacity);
196
- });
197
-
198
- await test('a query with no exact answer rings nothing', async () => {
199
- await page.fill('input[type=text]', 'zzzqqqxyz không tồn tại');
200
- await page.waitForTimeout(6000);
201
- assert(await page.locator('.image-tile.exact').count() === 0,
202
- 'an unresolvable query still produced an exact match');
203
- });
204
-
205
- console.log('\nshare links');
206
- // ──────────────────────────────────────────────────────────────────────────
207
-
208
- await test('an interior photograph never points /share/p/ at an avatarimg sku', async () => {
209
- // The regression: `avatarimg:` is a namespace for de-duplication, not a
210
- // catalogue identity, so /share/p/ resolves to nothing and vai-avatar answers
211
- // "Not Found". Every interior photograph was affected.
212
- //
213
- // Driven off a hand-built anchor rather than the live field: the field's own
214
- // hrefs depend on a network read of the vai-avatar interior index, which may
215
- // not have landed when this runs. This asserts the routing rule itself.
216
- const href = await page.evaluate(() => {
217
- const anchor = document.createElement('a');
218
- anchor.href = 'https://bep40-vai-avatar.hf.space/share/p/avatarimg%3Ab68dc779370dc70e';
219
- document.body.append(anchor);
220
- globalThis.__repairInteriorShareLinks?.();
221
- const resolved = anchor.href;
222
- anchor.remove();
223
- return resolved;
224
- });
225
- assert(href === 'https://bep40-vai-avatar.hf.space/share/i/b68dc779370dc70e',
226
- 'the mis-routed link was not corrected, it resolved to ' + href);
227
- });
228
-
229
- await test('a real product link is left alone', async () => {
230
- // The shim must not touch a link that resolves. Rewriting this would send a
231
- // shopper to a 404 dressed up as a fix.
232
- const href = await page.evaluate(() => {
233
- const anchor = document.createElement('a');
234
- anchor.href = 'https://bep40-vai-avatar.hf.space/share/p/MH800TC';
235
- document.body.append(anchor);
236
- globalThis.__repairInteriorShareLinks?.();
237
- const resolved = anchor.href;
238
- anchor.remove();
239
- return resolved;
240
- });
241
- assert(href === 'https://bep40-vai-avatar.hf.space/share/p/MH800TC',
242
- 'a valid product link was rewritten to ' + href);
243
- });
244
-
245
- console.log('\nno regressions');
246
- // ──────────────────────────────────────────────────────────────────────────
247
-
248
- await test('no uncaught page errors', async () => {
249
- assert(errors.length === 0, errors.slice(0, 3).join(' | '));
250
- });
251
-
252
- await page.screenshot({ path: '/tmp/neon-field.png', fullPage: false });
253
- await browser.close();
254
- console.log(`\n${passed} passed, ${failed} failed`);
255
- console.log('screenshot: /tmp/neon-field.png');
256
- process.exit(failed ? 1 : 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-product-search.mjs DELETED
@@ -1,173 +0,0 @@
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-semantic-search.mjs DELETED
@@ -1,123 +0,0 @@
1
- /**
2
- * Nghiệm thu hành vi tìm kiếm: ngữ nghĩa (ảnh interiors) vs lookup sản phẩm.
3
- *
4
- * Chạy: node scripts/test-semantic-search.mjs
5
- *
6
- * Bốn nhóm kiểm tra, đúng bốn cam kết của Space:
7
- *
8
- * 1. Phân loại từ khóa -- "bàn"/"ghế"/"nội thất"/"nhà phố" là từ ngữ
9
- * nghĩa; "kệ"/"xoong"/"bếp"/"tủ"/"máy" vẫn là từ sản phẩm.
10
- * 2. Đồng nghĩa -- "nhà phố" mở rộng ra "nhà ống"; "biệt thự" ra "villa".
11
- * 3. Ảnh interiors -- tìm "nhà phố" thấy caption "Nhà ống", KHÔNG thấy
12
- * "Đèn ống" (false positive đã được đo và loại).
13
- * 4. Crowd-out -- "bàn ghế"/"nội thất" GIỮ ảnh interiors; "kệ xoong"
14
- * VẪN gỡ interiors (chỉ còn sản phẩm); lookup SKU chính xác vẫn
15
- * crowd-out và trả đúng 1 sản phẩm đầu tiên.
16
- */
17
- import { productLikeTerms, isSemanticTerm, expandQueryTerms } from '../src/semantic-terms.js';
18
- import { mergeResults } from '../src/merge.js';
19
- import { searchLikeAvatar } from '../src/product-search.js';
20
- import { searchAvatarInteriors } from '../src/avatar-interiors.js';
21
-
22
- let failures = 0;
23
- function check(name, ok, detail = '') {
24
- console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`);
25
- if (!ok) failures++;
26
- }
27
-
28
- /** Ghi sản phẩm vào dạng Map mà catalogue.js/buildCatalogue() trả về. */
29
- function toItems(products) {
30
- const items = new Map();
31
- for (const p of products) items.set(p.sku, p);
32
- return items;
33
- }
34
-
35
- /* ─────────────────────── 1. Phân loại từ khóa ─────────────────────── */
36
-
37
- check('"bàn ghế" toàn từ ngữ nghĩa', productLikeTerms(['ban', 'ghe']).length === 0);
38
- check('"kệ xoong" toàn từ sản phẩm', JSON.stringify(productLikeTerms(['ke', 'xoong'])) === '["ke","xoong"]');
39
- check('"nội thất" toàn từ ngữ nghĩa', productLikeTerms(['noi', 'that']).length === 0);
40
- check('"nhà phố" toàn từ ngữ nghĩa', productLikeTerms(['nha', 'pho']).length === 0);
41
- check('"biệt thự" toàn từ ngữ nghĩa', productLikeTerms(['biet', 'thu']).length === 0);
42
- check('"bếp từ" vẫn là từ sản phẩm', productLikeTerms(['bep', 'tu']).length === 2);
43
- check('"máy hút mùi" vẫn là từ sản phẩm', productLikeTerms(['may', 'hut', 'mui']).length === 3);
44
- check('isSemanticTerm(ban) đúng', isSemanticTerm('ban') === true);
45
- check('isSemanticTerm(ke) sai (kệ là dòng SP chủ lực)', isSemanticTerm('ke') === false);
46
-
47
- /* ─────────────────────── 2. Đồng nghĩa ─────────────────────── */
48
-
49
- check('mở rộng "nhà phố" có "ong" (nhà ống)', expandQueryTerms(['nha', 'pho']).includes('ong'));
50
- check('mở rộng "biệt thự" có "villa"', expandQueryTerms(['biet', 'thu']).includes('villa'));
51
- check('mở rộng "nội thất" có "that"', expandQueryTerms(['noi']).includes('that'));
52
- check('mở rộng "ghe" sinh "ban" (cặp bàn-ghế là đồng nghĩa thật)', JSON.stringify(expandQueryTerms(['ghe'])) === '["ghe","ban"]');
53
-
54
- /* ─────────────────────── 3. Ảnh interiors (caption lexical) ─────────────────────── */
55
-
56
- const avatarInteriors = [
57
- { sku: 'avatarimg:1', avatarImageId: '1', title: 'Nhà ống mặt tiền', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z', images: ['https://cdn.example/1.jpg'], src: 'https://cdn.example/1.jpg' },
58
- { sku: 'avatarimg:2', avatarImageId: '2', title: 'Biệt thự vườn xanh', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z', images: ['https://cdn.example/2.jpg'], src: 'https://cdn.example/2.jpg' },
59
- { sku: 'avatarimg:3', avatarImageId: '3', title: 'Đèn ống LED âm trần', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z', images: ['https://cdn.example/3.jpg'], src: 'https://cdn.example/3.jpg' },
60
- { sku: 'avatarimg:4', avatarImageId: '4', title: 'Nội thất phòng khách gỗ', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z', images: ['https://cdn.example/4.jpg'], src: 'https://cdn.example/4.jpg' },
61
- ];
62
-
63
- const nhaPhoHits = searchAvatarInteriors('nhà phố', avatarInteriors);
64
- check('tìm "nhà phố" thấy ảnh "Nhà ống"', nhaPhoHits.some(r => r.avatarImageId === '1'), nhaPhoHits.map(r => r.title).join(' | '));
65
- check('tìm "nhà phố" thấy ảnh "Biệt thự"', nhaPhoHits.some(r => r.avatarImageId === '2'));
66
- check('tìm "nhà phố" KHÔNG thấy "Đèn ống" (false positive)', !nhaPhoHits.some(r => r.avatarImageId === '3'), nhaPhoHits.map(r => r.title).join(' | '));
67
-
68
- const noiThatHits = searchAvatarInteriors('nội thất', avatarInteriors);
69
- check('tìm "nội thất" thấy ảnh "Nội thất phòng khách"', noiThatHits.some(r => r.avatarImageId === '4'), noiThatHits.map(r => r.title).join(' | '));
70
-
71
- /* ─────────────────────── 4. Crowd-out trong mergeResults ─────────────────────── */
72
-
73
- /** Ảnh interiors đã được CLIP xếp hạng (điểm cosine), dạng worker trả về. */
74
- const clipInteriors = [
75
- { id: 'a1b2c3d4e5', url: 'phong-khach-01.jpg', score: 0.91, title: 'Phòng khách gỗ', category: 'phong khach' },
76
- { id: 'f6e5d4c3b2', url: 'phong-khach-02.jpg', score: 0.87, title: 'Phòng khách sáng', category: 'phong khach' },
77
- ];
78
-
79
- const banGhe = toItems([
80
- { sku: 'BAN-OC-001', model: 'BAN-OC-001', name: 'Bàn ghế gỗ óc chó', brand: 'Malloca', category: 'ban-ghe', summary: 'Bàn ghế ăn gỗ óc chó nhập khẩu', specs: [], feats: [], images: ['https://cdn.example/ban.jpg'], price: '4.500.000₫', priceNum: 4500000 },
81
- ]);
82
- const banGheHits = searchLikeAvatar('bàn ghế', banGhe, 24);
83
- console.log(` [info] score "bàn ghế" = ${banGheHits.map(h => `${h.sku}:${h.score}`).join(', ')}`);
84
-
85
- const mergedBanGhe = mergeResults(clipInteriors, banGheHits, { query: 'bàn ghế', avatarInteriors: [], recentProducts: [] });
86
- // CLIP interior tiles giữ id GỐC (a1b2c3d4e5...), không có prefix `interior:` —
87
- // prefix chỉ là key de-dup nội bộ của mergeResults. Kiểm tra trên id gốc.
88
- check('tìm "bàn ghế" VẪN GIỮ ảnh interiors (semantic)', mergedBanGhe.some(t => ['a1b2c3d4e5', 'f6e5d4c3b2'].includes(String(t.id))), mergedBanGhe.map(t => t.id).join(', '));
89
- check('tìm "bàn ghế" vẫn có sản phẩm xen kẽ', mergedBanGhe.some(t => t.sku === 'BAN-OC-001'));
90
-
91
- const noiThat = toItems([
92
- { sku: 'NT-001', model: 'NT-001', name: 'Nội thất gỗ phòng khách', brand: 'Malloca', category: 'noi-that', summary: '', specs: [], feats: [], images: ['https://cdn.example/nt.jpg'], price: '7.000.000₫', priceNum: 7000000 },
93
- ]);
94
- const noiThatProductHits = searchLikeAvatar('nội thất', noiThat, 24);
95
- const mergedNoiThat = mergeResults(clipInteriors, noiThatProductHits, { query: 'nội thất', avatarInteriors: [], recentProducts: [] });
96
- check('tìm "nội thất" VẪN GIỮ ảnh interiors (semantic)', mergedNoiThat.some(t => ['a1b2c3d4e5', 'f6e5d4c3b2'].includes(String(t.id))), mergedNoiThat.map(t => t.id).join(', '));
97
-
98
- const keXoong = toItems([
99
- { sku: 'GX-170', model: 'GX-170', name: 'Giá xoong nồi inox Eurogold', brand: 'Eurogold', category: 'ke-xoong', summary: 'Kệ xoong treo tường inox 304', specs: [], feats: [], images: ['https://cdn.example/ke.jpg'], price: '590.000₫', priceNum: 590000 },
100
- ]);
101
- const keXoongHits = searchLikeAvatar('kệ xoong', keXoong, 24);
102
- console.log(` [info] score "kệ xoong" = ${keXoongHits.map(h => `${h.sku}:${h.score}`).join(', ')}`);
103
- const mergedKeXoong = mergeResults(clipInteriors, keXoongHits, { query: 'kệ xoong', avatarInteriors: [], recentProducts: [] });
104
- check('tìm "kệ xoong" VẪN gỡ interiors (product query)', !mergedKeXoong.some(t => String(t.id).startsWith('interior:')), mergedKeXoong.map(t => t.id).join(', '));
105
- check('tìm "kệ xoong" chỉ còn sản phẩm', mergedKeXoong.every(t => t.sku === 'GX-170'));
106
-
107
- /* ─────────────── 5. Lookup SKU: chính xác 100%, vẫn crowd-out ─────────────── */
108
-
109
- const skuMap = toItems([
110
- { sku: 'GP170BD', model: 'GP170BD', name: 'Giá xoong nồi Eurogold GP170BD', brand: 'Eurogold', category: 'ke-xoong', summary: '', specs: [], feats: [], images: ['https://cdn.example/gp.jpg'], price: '850.000₫', priceNum: 850000 },
111
- ]);
112
- const skuHits = searchLikeAvatar('GP170BD', skuMap, 24);
113
- console.log(` [info] score "GP170BD" = ${skuHits.map(h => `${h.sku}:${h.score}`).join(', ')}`);
114
- check('lookup SKU "GP170BD" trúng chính xác sản phẩm đó', skuHits[0]?.sku === 'GP170BD');
115
- check('lookup SKU đạt ngưỡng identity (≥700)', (skuHits[0]?.score ?? 0) >= 700, `score=${skuHits[0]?.score}`);
116
- const mergedSku = mergeResults(clipInteriors, skuHits, { query: 'GP170BD', avatarInteriors: [], recentProducts: [] });
117
- check('lookup SKU crowd-out interiors (đây là tra cứu, không phải duyệt ảnh)', !mergedSku.some(t => String(t.id).startsWith('interior:')), mergedSku.map(t => t.id).join(', '));
118
- check('lookup SKU trả đúng 1 sản phẩm, đầu danh sách', mergedSku[0]?.sku === 'GP170BD');
119
-
120
- /* ─────────────────────── tổng kết ─────────────────────── */
121
-
122
- console.log(failures ? `\n${failures} TEST FAIL` : '\nALL TESTS PASS');
123
- process.exit(failures ? 1 : 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-ui.mjs DELETED
@@ -1,201 +0,0 @@
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
- // Image CORS from the dataset CDN is filtered out deliberately: it is a
186
- // PRE-EXISTING property of how this Space loads interior photographs (the
187
- // DATASET URL in assets.js is unchanged by this work), it depends on the
188
- // headless host's own network policy rather than on any code under test, and
189
- // surfacing it here would bury a real regression under a thousand lines of
190
- // noise. The tile layer falls back to the BlurHash placeholder, so the field
191
- // still paints without it.
192
- const relevant = errors.filter(e => !/transformers|onnx|fetch|Failed to load|net::|404|wasm/i.test(e) && !/CORS policy/i.test(e));
193
- ok(`no unrelated page errors (${relevant.length})`, relevant.length === 0, relevant.slice(0, 2).join(' | '));
194
- }
195
-
196
- await page.screenshot({ path: '/tmp/sif-ui.png', fullPage: false });
197
- console.log('\nscreenshot: /tmp/sif-ui.png');
198
- await browser.close();
199
-
200
- console.log(`\n${passed} passed, ${failed} failed`);
201
- if (failed) process.exit(1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-viewer-ui.mjs DELETED
@@ -1,151 +0,0 @@
1
- /**
2
- * Browser checks for the restored viewer CSS, the ten-ring limit, and pill
3
- * clearance.
4
- *
5
- * The CSS regression this file exists for was silent: opening a photograph
6
- * produced an unstyled <dialog> that still worked, still showed the image, and
7
- * just looked nothing like the design. Nothing throws, so only a computed-style
8
- * assertion catches it.
9
- */
10
- import { chromium } from 'playwright';
11
-
12
- const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173/';
13
- let passed = 0, failed = 0;
14
- const test = async (name, fn) => {
15
- try { await fn(); console.log(' ok ' + name); passed++; }
16
- catch (error) { console.log(' FAIL ' + name + '\n ' + error.message.split('\n')[0]); failed++; }
17
- };
18
- const assert = (cond, message) => { if (!cond) throw new Error(message); };
19
-
20
- const browser = await chromium.launch();
21
- const page = await browser.newPage({ viewport: { width: 1600, height: 950 } });
22
- const errors = [];
23
- page.on('pageerror', error => errors.push(error.message));
24
-
25
- await page.goto(BASE, { waitUntil: 'domcontentloaded' });
26
- await page.waitForSelector('.image-tile', { timeout: 120000 });
27
- await page.waitForTimeout(6000);
28
-
29
- console.log('\nrestored viewer CSS');
30
- // ──────────────────────────────────────────────────────────────────────────
31
-
32
- await test('the viewer opens full-screen with its own background', async () => {
33
- // force: true because the field is deliberately alive -- every tile drifts a
34
- // few px per frame, so a click aimed at a position sampled a moment ago can
35
- // land beside the tile. That is the field working, not a bug, and it is why
36
- // the shot here is dispatched at the element rather than at coordinates.
37
- await page.locator('.image-tile').first().click({ force: true, timeout: 15000 });
38
- await page.waitForSelector('.photo-viewer[open]', { timeout: 30000 });
39
- const box = await page.locator('.photo-viewer').boundingBox();
40
- const style = await page.evaluate(() => {
41
- const v = document.querySelector('.photo-viewer');
42
- const s = getComputedStyle(v);
43
- return { position: s.position, bg: s.backgroundColor, w: s.width, h: s.height };
44
- });
45
- assert(style.position === 'fixed', 'viewer is ' + style.position + ', not fixed');
46
- assert(box.width >= 1500 && box.height >= 900, 'viewer is ' + Math.round(box.width) + 'x' + Math.round(box.height));
47
- assert(style.bg !== 'rgba(0, 0, 0, 0)', 'viewer has no background');
48
- });
49
-
50
- await test('the toolbar is a bar, not stacked plain text', async () => {
51
- // The regression rendered this as inline-flow divs at full width. A real
52
- // toolbar is absolutely positioned over the image, which is what stops the
53
- // share button sitting on top of the photograph.
54
- const style = await page.evaluate(() => {
55
- const t = document.querySelector('.photo-toolbar');
56
- return t ? { position: getComputedStyle(t).position, display: getComputedStyle(t).display } : null;
57
- });
58
- assert(style, 'no .photo-toolbar found');
59
- assert(style.position === 'absolute', 'toolbar is ' + style.position);
60
- });
61
-
62
- await test('the close button is sized and round, not a bare glyph', async () => {
63
- const box = await page.locator('.photo-toolbar button').first().boundingBox();
64
- assert(box && box.width >= 28 && box.height >= 28,
65
- 'close button is ' + (box ? Math.round(box.width) + 'x' + Math.round(box.height) : 'missing'));
66
- });
67
-
68
- await test('the frame sits inside the viewport, not flush to an edge', async () => {
69
- const box = await page.locator('.photo-frame').boundingBox();
70
- assert(box && box.width > 100 && box.height > 100, 'frame is ' + JSON.stringify(box));
71
- assert(box.x >= 0 && box.y >= 0 && box.x + box.width <= 1601 && box.y + box.height <= 951,
72
- 'frame escapes the viewport');
73
- });
74
-
75
- await test('the detail panel is styled when the photograph has metadata', async () => {
76
- const n = await page.locator('.photo-detail').count();
77
- if (!n) { console.log(' (skipped: this photograph has no metadata panel)'); return; }
78
- const style = await page.evaluate(() => {
79
- const d = document.querySelector('.photo-detail');
80
- const s = getComputedStyle(d);
81
- return { position: s.position, z: s.zIndex };
82
- });
83
- assert(['absolute', 'fixed'].includes(style.position), 'panel is ' + style.position);
84
- });
85
-
86
- console.log('\nten rings, not sixty');
87
- // ──────────────────────────────────────────────────────────────────────────
88
-
89
- await test('at most ten tiles carry the neon', async () => {
90
- await page.keyboard.press('Escape');
91
- await page.waitForTimeout(1500);
92
- const ringed = await page.locator('.image-tile.featured').count();
93
- assert(ringed <= 10, ringed + ' tiles are ringed, expected at most 10');
94
- });
95
-
96
- await test('more photographs are shown than before', async () => {
97
- const total = await page.locator('.image-tile').count();
98
- assert(total > 96, 'only ' + total + ' tiles; the field should now hold 150 on desktop');
99
- });
100
-
101
- await test('un-ringed new photographs are still visible', async () => {
102
- // "còn lại hiển thị bình thường" -- the rest must render as ordinary tiles.
103
- const plain = await page.evaluate(() => {
104
- const tiles = [...document.querySelectorAll('.image-tile')];
105
- return tiles.filter(t => !t.classList.contains('featured')).length;
106
- });
107
- assert(plain > 10, 'no un-ringed tiles found');
108
- });
109
-
110
- console.log('\npill clearance');
111
- // ──────────────────────────────────────────────────────────────────────────
112
-
113
- await test('no ringed tile overlaps the search pill', async () => {
114
- // The brief is explicit: big, near the search box, but NOT covering it.
115
- // Measured against every ringed tile's real box, not its centre point.
116
- const worst = await page.evaluate(() => {
117
- const pill = document.querySelector('.search-pill').getBoundingClientRect();
118
- let overlap = 0, who = null;
119
- for (const tile of document.querySelectorAll('.image-tile.featured')) {
120
- const r = tile.getBoundingClientRect();
121
- const w = Math.max(0, Math.min(r.right, pill.right) - Math.max(r.left, pill.left));
122
- const h = Math.max(0, Math.min(r.bottom, pill.bottom) - Math.max(r.top, pill.top));
123
- const area = w * h;
124
- if (area > overlap) { overlap = area; who = tile.dataset.imageId; }
125
- }
126
- return { overlap, who, pill: pill.width * pill.height };
127
- });
128
- assert(worst.overlap === 0,
129
- `tile ${worst.who} covers ${Math.round(worst.overlap)}px² of the pill (${Math.round(worst.pill)}px² total)`);
130
- });
131
-
132
- await test('a ringed tile is still larger than an ordinary one', async () => {
133
- const r = await page.evaluate(() => {
134
- const area = el => { const b = el.getBoundingClientRect(); return b.width * b.height; };
135
- const f = document.querySelector('.image-tile.featured');
136
- const o = [...document.querySelectorAll('.image-tile')].find(t => !t.classList.contains('featured'));
137
- return { f: area(f), o: area(o) };
138
- });
139
- assert(r.f > r.o * 1.15, `ringed ${Math.round(r.f)}px² vs ordinary ${Math.round(r.o)}px²`);
140
- });
141
-
142
- console.log('\nno regressions');
143
- // ──────────────────────────────────────────────────────────────────────────
144
-
145
- await test('no uncaught page errors', async () => {
146
- assert(errors.length === 0, errors.slice(0, 3).join(' | '));
147
- });
148
-
149
- await browser.close();
150
- console.log(`\n${passed} passed, ${failed} failed`);
151
- process.exit(failed ? 1 : 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/App.jsx CHANGED
@@ -2,7 +2,6 @@ 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
- import { searchAvatarInteriors, loadRecentAvatarInteriors } from './avatar-interiors.js';
6
 
7
  /**
8
  * How often to re-read the product catalogue so add/edit/delete on vai-avatar
@@ -37,20 +36,6 @@ export default function App() {
37
  const [products, setProducts] = useState([]);
38
  const [productStatus, setProductStatus] = useState({ loading: false, error: '' });
39
  const [catalogue, setCatalogue] = useState({ count: 0 });
40
- // The newest interior photographs published on vai-avatar, already matched
41
- // against the current query by the worker (see avatar-interiors.js). Kept as
42
- // its own state so a new photograph appearing upstream reaches the field on
43
- // the next poll even when nothing has been typed.
44
- const [avatarInteriors, setAvatarInteriors] = useState([]);
45
- // The newest ADDED PRODUCTS (via vai-avatar "Thêm SP"). Kept as its own state
46
- // so a product pasted on vai-avatar shows up in the field -- ringed and
47
- // enlarged like the newest interiors -- on the next poll, with no query.
48
- const [recentProducts, setRecentProducts] = useState([]);
49
- // The unfiltered newest-first set, held so clearing the query can restore it.
50
- // Without this the field would keep showing only the photographs that happened
51
- // to match the last search, and a new upload would not appear until it was
52
- // typed at.
53
- const avatarPool = useRef([]);
54
  const worker = useRef(null);
55
  const input = useRef(null);
56
  const requestId = useRef(0);
@@ -64,14 +49,6 @@ export default function App() {
64
  const productsRef = useRef([]);
65
  const signature = useRef('');
66
 
67
- /**
68
- * The newest-first interior list, used by the fullscreen viewer for time-based
69
- * left/right navigation. Kept in a ref (not state) so the render of the
70
- * viewer never re-renders when the pool updates -- only the navigation
71
- * behaviour changes.
72
- */
73
- const timeList = useRef([]);
74
-
75
  useEffect(() => {
76
  const instance = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
77
  worker.current = instance;
@@ -88,34 +65,9 @@ export default function App() {
88
  if (data.status === 'products-loading') setProductStatus(current => ({ ...current, loading: true }));
89
  if (data.status === 'products-complete' && data.id === productId.current) {
90
  setProducts(data.output); setProductStatus({ loading: false, error: '' });
91
- // The matched interiors ride the same message, so one setState pair
92
- // updates both halves of the field atomically -- no frame where a new
93
- // photograph sits next to results for a query that did not produce it.
94
- if (Array.isArray(data.interiors)) setAvatarInteriors(data.interiors);
95
  if (typeof data.signature === 'string') signature.current = data.signature;
96
  if (data.count) setCatalogue({ count: data.count });
97
  }
98
- // vai-avatar published new interiors. `changed` is false on the vast
99
- // majority of ticks, and skipping the setState then is what stops the
100
- // field being torn down and rebuilt every 30s for no visible reason.
101
- if (data.status === 'avatar-updated') {
102
- if (data.changed) {
103
- // With no query, every new photograph is shown. That is the whole
104
- // point of the feature: a photograph added on vai-avatar has to be
105
- // visible here without anyone having to search for it. With a query,
106
- // the list is filtered to the ones that answer it, so the "new" row at
107
- // the top of the field is always relevant to what was actually typed.
108
- const text = queryRef.current.trim();
109
- avatarPool.current = data.items ?? [];
110
- setAvatarInteriors(text ? searchAvatarInteriors(text, avatarPool.current) : avatarPool.current);
111
- // The newest added products, ringed on the same field as the newest
112
- // interiors. `data.recentProducts` is already newest-first and
113
- // image-bearing (the worker filters), so it drops straight in.
114
- if (Array.isArray(data.recentProducts)) setRecentProducts(data.recentProducts);
115
- // Ask for a re-rank so the products arrive from the same snapshot.
116
- if (text) worker.current?.postMessage({ type: 'search-products', text, id: ++productId.current, limit: 48 });
117
- }
118
- }
119
  if (data.status === 'products-error' && data.id === productId.current) {
120
  setProducts([]); setProductStatus({ loading: false, error: data.message });
121
  }
@@ -130,7 +82,7 @@ export default function App() {
130
  setCatalogue({ count: data.count });
131
  if (changed) {
132
  const text = queryRef.current.trim();
133
- if (text) worker.current?.postMessage({ type: 'search-products', text, id: ++productId.current, limit: 48 });
134
  }
135
  }
136
  // Only surface a refresh failure if the panel has nothing to show: a
@@ -171,20 +123,6 @@ export default function App() {
171
  return () => { instance.terminate(); };
172
  }, []);
173
 
174
- // Load the time-ordered interior list once, so the viewer can page by date.
175
- useEffect(() => {
176
- let cancelled = false;
177
- loadRecentAvatarInteriors(400).then(list => {
178
- if (!cancelled) {
179
- timeList.current = list;
180
- if (!avatarPool.current.length && list.length) {
181
- avatarPool.current = list;
182
- }
183
- }
184
- }).catch(console.error);
185
- return () => { cancelled = true; };
186
- }, []);
187
-
188
  useEffect(() => { productsRef.current = products; }, [products]);
189
 
190
  // Poll the catalogue so add / edit / delete on vai-avatar propagate here.
@@ -222,21 +160,14 @@ export default function App() {
222
  worker.current?.postMessage({ type: 'clear' });
223
  setResults([]); setBusy(false);
224
  setProducts([]); setProductStatus({ loading: false, error: '' });
225
- // Clearing the query restores the unfiltered newest-first set, so the
226
- // photographs just added on vai-avatar are visible again rather than the
227
- // field being left showing only the tail of the previous search.
228
- setAvatarInteriors(avatarPool.current);
229
  return;
230
  }
231
  setBusy(true);
232
  setProductStatus({ loading: true, error: '' });
233
- // Filtered locally first so the featured row does not blank out on every
234
- // keystroke while the worker's own answer is still in flight.
235
- setAvatarInteriors(searchAvatarInteriors(text, avatarPool.current));
236
  // The worker coalesces queued requests; typing needs no artificial debounce.
237
  worker.current?.postMessage({ type: 'search', text, id });
238
  // Products share the query but are ranked against their own catalogue.
239
- worker.current?.postMessage({ type: 'search-products', text, id: ++productId.current, limit: 48 });
240
  }, []);
241
 
242
  useEffect(() => {
@@ -285,14 +216,7 @@ export default function App() {
285
  * so a product added there appears here within one poll (30s) with no rebuild
286
  * and no re-embedding.
287
  */
288
- const field = useMemo(
289
- // `query` is a dependency because mergeResults needs it to decide which
290
- // product is the decisive winner, and that decision changes as the query
291
- // changes. Without it, a tile enlarged for the previous query's exact answer
292
- // would keep its size under the new one.
293
- () => mergeResults(results, products, { avatarInteriors, recentProducts, query }),
294
- [results, products, avatarInteriors, recentProducts, query],
295
- );
296
 
297
  /**
298
  * Catalogue trouble is still reported, but only when it leaves the field
@@ -302,14 +226,7 @@ export default function App() {
302
  const catalogueError = !productStatus.error || products.length ? '' : productStatus.error;
303
 
304
  return <main className="app relative isolate h-dvh w-full overflow-hidden">
305
- <div className="brand-backdrop" aria-hidden="true" />
306
- <ImageField
307
- results={field}
308
- paused={!!photo}
309
- query={query}
310
- products={products}
311
- onSelect={(image, origin) => setPhoto({ ...image, origin })}
312
- />
313
  <div className="vignette" aria-hidden="true" />
314
  <div className="grain" aria-hidden="true" />
315
  <section className="search-center" aria-label="Tìm kiếm ảnh ngữ nghĩa">
@@ -327,6 +244,6 @@ export default function App() {
327
  <a className="pointer-events-auto" href="https://github.com/huggingface/transformers.js-examples/tree/main/semantic-image-search-web" target="_blank" rel="noreferrer">Transformers.js <span className="version">4.3.0</span></a>
328
  <span>{catalogue.count ? `${catalogue.count.toLocaleString()} sản phẩm` : 'sản phẩm'}<span className="footer-divider">/</span> {count.toLocaleString()} ảnh</span>
329
  </footer>
330
- {photo && <PhotoViewer photo={photo} timeList={timeList.current} onClose={() => setPhoto(null)} />}
331
  </main>;
332
- }
 
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
  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
  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;
 
65
  if (data.status === 'products-loading') setProductStatus(current => ({ ...current, loading: true }));
66
  if (data.status === 'products-complete' && data.id === productId.current) {
67
  setProducts(data.output); setProductStatus({ loading: false, error: '' });
 
 
 
 
68
  if (typeof data.signature === 'string') signature.current = data.signature;
69
  if (data.count) setCatalogue({ count: data.count });
70
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  if (data.status === 'products-error' && data.id === productId.current) {
72
  setProducts([]); setProductStatus({ loading: false, error: data.message });
73
  }
 
82
  setCatalogue({ count: data.count });
83
  if (changed) {
84
  const text = queryRef.current.trim();
85
+ if (text) worker.current?.postMessage({ type: 'search-products', text, id: ++productId.current, limit: 24 });
86
  }
87
  }
88
  // Only surface a refresh failure if the panel has nothing to show: a
 
123
  return () => { instance.terminate(); };
124
  }, []);
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  useEffect(() => { productsRef.current = products; }, [products]);
127
 
128
  // Poll the catalogue so add / edit / delete on vai-avatar propagate here.
 
160
  worker.current?.postMessage({ type: 'clear' });
161
  setResults([]); setBusy(false);
162
  setProducts([]); setProductStatus({ loading: false, error: '' });
 
 
 
 
163
  return;
164
  }
165
  setBusy(true);
166
  setProductStatus({ loading: true, error: '' });
 
 
 
167
  // The worker coalesces queued requests; typing needs no artificial debounce.
168
  worker.current?.postMessage({ type: 'search', text, id });
169
  // Products share the query but are ranked against their own catalogue.
170
+ worker.current?.postMessage({ type: 'search-products', text, id: ++productId.current, limit: 24 });
171
  }, []);
172
 
173
  useEffect(() => {
 
216
  * so a product added there appears here within one poll (30s) with no rebuild
217
  * and no re-embedding.
218
  */
219
+ const field = useMemo(() => mergeResults(results, products), [results, products]);
 
 
 
 
 
 
 
220
 
221
  /**
222
  * Catalogue trouble is still reported, but only when it leaves the field
 
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">
 
244
  <a className="pointer-events-auto" href="https://github.com/huggingface/transformers.js-examples/tree/main/semantic-image-search-web" target="_blank" rel="noreferrer">Transformers.js <span className="version">4.3.0</span></a>
245
  <span>{catalogue.count ? `${catalogue.count.toLocaleString()} sản phẩm` : 'sản phẩm'}<span className="footer-divider">/</span> {count.toLocaleString()} ảnh</span>
246
  </footer>
247
+ {photo && <PhotoViewer photo={photo} onClose={() => setPhoto(null)} />}
248
  </main>;
249
+ }
src/ImageField.jsx CHANGED
@@ -3,31 +3,15 @@ import collection from './collection.json';
3
  import { dimensions, fieldPosition, fieldCount, scatter, edgePosition, seed } from './layout.js';
4
  import { sampleMotion, transition } from './motion.js';
5
  import { relevanceAppearance } from './relevance.js';
6
- import { imageURL, tileURL, shareURL } from './assets.js';
7
  import { averageColor, createImageLoader, createPlaceholderLoader, thumbnailWidth } from './image-loader.js';
8
 
9
- /**
10
- * z-index given to the tile the pointer is over.
11
- *
12
- * Clears every seeded value above (1 + floor(seed(id) * 100), so 1..100) and
13
- * every other z-index the field uses -- vignette 300, grain 301, search pill
14
- * 400, footer 401 -- so the lifted tile sits above its neighbours without
15
- * pushing the search box under the user's photographs.
16
- */
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, query = '', products = [] }) {
21
  const surface = useRef(null);
22
  const engine = useRef(null);
23
  const selection = useRef(onSelect);
24
  selection.current = onSelect;
25
- // The query and products are read inside the long-lived effect, so they are
26
- // mirrored into a ref rather than becoming effect dependencies. Making them
27
- // dependencies would tear down and rebuild every tile -- and re-request every
28
- // thumbnail -- on every keystroke.
29
- const controls = useRef({ query, products });
30
- controls.current = { query, products };
31
 
32
  useEffect(() => {
33
  const root = surface.current;
@@ -41,104 +25,33 @@ export default function ImageField({ results, onSelect, paused, query = '', prod
41
 
42
  function create(image, target, initial = false) {
43
  const button = document.createElement('button');
44
- // `featured` is set here, at creation, rather than toggled per frame like
45
- // `exact`: it comes from the record itself and does not change for a
46
- // given tile while it is on screen, so there is nothing to re-evaluate.
47
- button.className = image.featured ? 'image-tile featured' : 'image-tile';
48
  button.type = 'button';
49
  button.dataset.imageId = image.id;
50
  button.style.backgroundColor = averageColor(image.blur);
51
- /**
52
- * A share link on the tile itself, so a photograph can be sent without
53
- * opening it first.
54
- *
55
- * The tile stays a <button> -- it has to, for keyboard activation and for
56
- * aria-haspopup="dialog" to mean anything -- and the anchor is appended
57
- * inside it. Nesting an <a> in a <button> is not valid HTML, so the anchor
58
- * is aria-hidden and tabIndex -1: it is a pointer affordance, and the
59
- * reachable way to share is the link in the viewer, which is a real link
60
- * with a real name. A keyboard user is not offered something that behaves
61
- * differently from its accessible name.
62
- *
63
- * Clicking it must not also open the viewer, so its handler stops
64
- * propagation; the tile's own onclick never sees the event.
65
- */
66
- const share = document.createElement('a');
67
- share.className = 'image-share';
68
- share.tabIndex = -1;
69
- share.setAttribute('aria-hidden', 'true');
70
- /**
71
- * Opens in a new tab, like the viewer's own share link. Navigating the
72
- * current tab away would destroy the search the user is standing in, and
73
- * the field cannot be rebuilt from history -- there is no route to come
74
- * back to.
75
- */
76
- share.target = '_blank';
77
- share.rel = 'noopener noreferrer';
78
- share.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">'
79
- + '<path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" /></svg>';
80
- share.addEventListener('click', event => event.stopPropagation());
81
- button.append(share);
82
- button._share = share;
83
- // Stack order belongs to the photograph, never to its search rank -- EXCEPT
84
- // that a featured tile -- the exact answer, a top keyword hit, or a just
85
- // added avatar photograph -- must sit visibly ON TOP of the field around
86
- // it, which is the whole point of enlarging it. So featured tiles draw in
87
- // a layer above every ordinary tile (which can at most reach 100; 300 is
88
- // safely clear of the 1..100 seed range and below the 400 reserved for the
89
- // search pill glass in index.css).
90
- const baseZ = image.featured ? 300 + Math.floor(seed(image.id) * 40) : 1 + Math.floor(seed(image.id) * 100);
91
- button.style.zIndex = baseZ;
92
  root.append(button);
93
  const home = initial ? target : edgePosition(image, state.width, state.height, target);
94
- // baseZ is kept on the node so liftUnderPointer can restore it verbatim
95
- // when the pointer moves on.
96
- const node = { image, button, baseZ, lifted: false, ...home, target: home, motion: null, exit: false, phase: seed(image.id) * Math.PI * 2 };
97
  resize(node);
98
  button.style.transform = `translate3d(${home.x - node.width / 2}px,${home.y - node.height / 2}px,0) scale(${home.scale})`;
99
  nodes.set(image.id, node);
100
  node.cancelPlaceholder = placeholders.request(image.blur, url => {
101
  if (url && nodes.get(image.id) === node && !node.photo) button.style.backgroundImage = `url("${url}")`;
102
  });
103
- button.onclick = event => {
104
- /**
105
- * The share anchor is a child of this button, so its click reaches here
106
- * too. stopPropagation on the anchor is not enough on its own: the tile
107
- * drifts under the pointer every frame, so by the time the click lands
108
- * the browser may have re-targeted the event at the button itself, and
109
- * the photograph opens behind a tab that was supposed to be shared.
110
- * Testing the composed path covers both cases.
111
- */
112
- const path = event.composedPath ? event.composedPath() : [event.target];
113
- if (path.includes(share)) return;
114
- selection.current(
115
- // A product carries its full gallery so the viewer can page through it,
116
- // exactly as the old product strip did. Interiors have no `images` and
117
- // are unchanged.
118
- //
119
- // Read `node.image` at click time rather than closing over `image`: a
120
- // node is created once and then reused across searches, and the record
121
- // it was created from may have been an ambient tile from
122
- // collection.json -- id/url/ar/blur only. Closing over the original
123
- // meant a photograph that later matched the query kept opening with
124
- // none of its title, categories or source, because the richer search
125
- // result was never written back to the node. layout() assigns
126
- // node.image on every pass, so the click always sees the current one.
127
- { ...node.image, previewURL: node.photo?.currentSrc || node.photo?.src },
128
  button.getBoundingClientRect().toJSON(),
129
- );
130
- };
131
  return node;
132
  }
133
 
134
  function resize(node) {
135
- // A featured tile -- the exact answer to the query, or a photograph just
136
- // added on vai-avatar -- carries its own scale multiplier. Read from the
137
- // record rather than passed down, so a tile that becomes featured keeps
138
- // the same identity (and therefore the same place in the layout) it had
139
- // as an ordinary tile.
140
- const scale = node.image?.featuredScale || 1;
141
- const { width, height } = dimensions(node.image, state.width, scale);
142
  if (node.width === width && node.height === height) return;
143
  node.width = width; node.height = height;
144
  node.button.style.width = `${width}px`;
@@ -160,19 +73,7 @@ export default function ImageField({ results, onSelect, paused, query = '', prod
160
  if (node.exit || nodes.get(node.image.id) !== node) { thumbnails.release(photo); return; }
161
  photo.alt = ''; photo.draggable = false; photo.className = '';
162
  node.photo = photo;
163
- /**
164
- * insertBefore rather than append, so the share anchor stays the tile's
165
- * last child.
166
- *
167
- * The tile has no stacking context of its own, and both children are
168
- * absolutely positioned -- so with the <img> appended last, it paints
169
- * over the anchor and every click on the share button opens the viewer
170
- * instead of sharing. z-index cannot fix it either: it is set in a
171
- * stylesheet, and re-appending the photo reorders the DOM, so the two
172
- * swap paint order on each cached thumbnail. Keeping the anchor last in
173
- * the DOM fixes it by construction, for every image, loaded or not.
174
- */
175
- node.button.insertBefore(photo, node.button._share);
176
  if (!motion.matches) photo.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 180, easing: 'ease-out' });
177
  photo.classList.add('loaded');
178
  }, priority);
@@ -182,55 +83,13 @@ export default function ImageField({ results, onSelect, paused, query = '', prod
182
  const now = performance.now();
183
  const active = new Set(state.pool.map(image => image.id));
184
  const ranks = new Map(state.results.map((image, rank) => [image.id, rank]));
185
- // Read the controls once per pass: every tile in a pass must agree on
186
- // where the plane sits, or the collection visibly tears apart mid-drag.
187
- const { offsetX: shiftX, offsetY: shiftY } = controls.current;
188
- // fieldPosition() whenever a query is active OR anything is featured, and
189
- // scatter() only for a plain, featureless browse.
190
- //
191
- // The reason is that scatter() lays tiles out on a jittered grid with no
192
- // notion of the search pill, so index 0 lands in a corner and a featured
193
- // tile can end up underneath the pill with the pill's own glass sitting on
194
- // top of it. fieldPosition() spirals out from the centre and already has
195
- // the clearance term that pushes tiles around the pill. Using it for the
196
- // idle state too is what puts the ten newest photographs in a readable
197
- // arc below the search box instead of wherever the grid happens to put
198
- // them.
199
- const ordered = state.results.length || state.pool.some(image => image.featured);
200
- const targets = state.pool.map((image, index) => ordered
201
- ? fieldPosition(image, index, state.pool.length, state.width, state.height, shiftX, shiftY)
202
- : scatter(image, index, state.pool.length, state.width, state.height, shiftX, shiftY));
203
- // The query and the product hits go in so relevance.js can decide which
204
- // tiles are EXACT matches (see exactMatches) and get the neon outline.
205
- const appearance = relevanceAppearance(state.results, controls.current.query, controls.current.products);
206
  state.pool.forEach((image, index) => {
207
  const target = targets[index];
208
  const node = nodes.get(image.id) || create(image, target, initial);
209
- // Keep the node's record current, not just its position: `image` here is
210
- // whatever the pool now holds, which for a photograph that has become a
211
- // search result is the full row (title, categories, source) rather than
212
- // the metadata-free collection entry the node was first built from.
213
- // Without this, opening a matched photograph shows nothing about it.
214
- node.image = image;
215
- /**
216
- * Point the tile's share link at the current record, and hide it when
217
- * there is nothing to share. Updated alongside node.image for the same
218
- * reason: the node may have been built from a record that carried no
219
- * shareable identity, and a photograph's address is derived from it.
220
- */
221
- const share = node.button._share;
222
- if (share) {
223
- const href = shareURL(image);
224
- if (href) {
225
- share.href = href;
226
- share.dataset.shareFor = String(image.id);
227
- share.removeAttribute('hidden');
228
- } else {
229
- share.removeAttribute('href');
230
- share.removeAttribute('data-share-for');
231
- share.hidden = true;
232
- }
233
- }
234
  node.target = target; node.exit = false;
235
  node.button.removeAttribute('aria-hidden');
236
  const rank = ranks.get(image.id);
@@ -240,22 +99,10 @@ export default function ImageField({ results, onSelect, paused, query = '', prod
240
  node.load?.prioritize(rank ?? 200 + index);
241
  node.button.setAttribute('aria-label', rank == null ? 'Phóng to ảnh' : `Phóng to kết quả ${rank + 1}`);
242
  node.button.setAttribute('aria-haspopup', 'dialog');
243
- // `exact` comes back on the same appearance object as the fade, so the
244
- // outline and the dimming can never disagree about which tile is the
245
- // answer. classList.toggle (not add) because a tile keeps its node
246
- // across searches and would otherwise keep a stale outline.
247
- const { opacity, blur, exact } = appearance(image.id);
248
  node.button.style.setProperty('--relevance-opacity', opacity);
249
  node.button.style.setProperty('--relevance-blur', `${blur}px`);
250
- node.button.classList.toggle('exact', !!exact);
251
- // An exact hit is never allowed to fade with the tail, whatever its
252
- // cosine: if the outline says "this is the one", it has to be readable.
253
- if (exact) {
254
- node.button.style.setProperty('--relevance-opacity', 1);
255
- node.button.style.setProperty('--relevance-blur', '0px');
256
- }
257
  node.button.dataset.rank = rank == null ? '' : rank + 1;
258
- node.button.dataset.exact = exact ? 'true' : '';
259
  node.button.tabIndex = state.results.length ? (rank != null && rank < 12 ? 0 : -1) : (index < 12 ? 0 : -1);
260
  transition(node, target, now, initial || motion.matches);
261
  });
@@ -265,10 +112,6 @@ export default function ImageField({ results, onSelect, paused, query = '', prod
265
  node.load?.cancel(); node.load = null;
266
  node.button.setAttribute('aria-hidden', 'true');
267
  node.button.dataset.rank = '';
268
- // Clear the outline on the way out: a tile drifting off-screen must not
269
- // keep advertising itself as the answer to the previous query.
270
- node.button.dataset.exact = '';
271
- node.button.classList.remove('exact');
272
  node.button.tabIndex = -1;
273
  transition(node, edgePosition(node.image, state.width, state.height, node), now, motion.matches);
274
  }
@@ -340,47 +183,8 @@ export default function ImageField({ results, onSelect, paused, query = '', prod
340
  if (event.pointerType === 'touch') return;
341
  state.pointer.x = (event.clientX / state.width - 0.5) * 2;
342
  state.pointer.y = (event.clientY / state.height - 0.5) * 2;
343
- liftUnderPointer(event.target);
344
- };
345
- /**
346
- * Raise the photograph under the pointer above its neighbours.
347
- *
348
- * The field overlaps tiles deliberately and each tile's z-index is a stable
349
- * seed of its id, so a tile can end up permanently buried under whichever
350
- * neighbour hashed higher. CSS :hover cannot rescue it, because a buried
351
- * element never matches :hover -- which is the chicken-and-egg the share
352
- * button was stuck in. Measured on the idle field: of 79 in-viewport share
353
- * buttons, not one was the topmost element at its own centre; 13 were
354
- * covered by another tile's photograph and 8 by another tile outright.
355
- *
356
- * Lifting whichever tile the pointer is over makes the next hover land on
357
- * the tile the user is aiming at, and then its own :hover reveals the
358
- * share button normally. The seeded resting order is restored when the
359
- * pointer leaves, so the composition stays deterministic across reloads.
360
- */
361
- function liftUnderPointer(target) {
362
- const tile = target?.closest?.('.image-tile');
363
- for (const node of nodes.values()) {
364
- if (node.lifted && node.button !== tile) restoreZ(node);
365
- }
366
- if (!tile) return;
367
- const node = [...nodes.values()].find(n => n.button === tile);
368
- if (node && !node.lifted) {
369
- node.lifted = true;
370
- tile.style.zIndex = LIFTED_Z;
371
- }
372
- }
373
- function restoreZ(node) {
374
- node.lifted = false;
375
- node.button.style.zIndex = node.baseZ;
376
- }
377
- const leave = () => {
378
- state.pointer.x = state.pointer.y = 0;
379
- // Restore the seeded resting order. Leaving the field is the only
380
- // unambiguous signal that the user is done arranging it, and doing it
381
- // here is what keeps the composition identical on every reload.
382
- for (const node of nodes.values()) if (node.lifted) restoreZ(node);
383
  };
 
384
  root.addEventListener('pointermove', move); root.addEventListener('pointerleave', leave);
385
  frame = requestAnimationFrame(animate);
386
  engine.current = { state, update };
 
3
  import { dimensions, fieldPosition, fieldCount, scatter, edgePosition, seed } from './layout.js';
4
  import { sampleMotion, transition } from './motion.js';
5
  import { relevanceAppearance } from './relevance.js';
6
+ import { imageURL, tileURL } from './assets.js';
7
  import { averageColor, createImageLoader, createPlaceholderLoader, thumbnailWidth } from './image-loader.js';
8
 
 
 
 
 
 
 
 
 
 
 
9
  /** A single population of photographs, one coordinate plane, one animation clock. */
10
+ export default function ImageField({ results, onSelect, paused }) {
11
  const surface = useRef(null);
12
  const engine = useRef(null);
13
  const selection = useRef(onSelect);
14
  selection.current = onSelect;
 
 
 
 
 
 
15
 
16
  useEffect(() => {
17
  const root = surface.current;
 
25
 
26
  function create(image, target, initial = false) {
27
  const button = document.createElement('button');
28
+ button.className = 'image-tile';
 
 
 
29
  button.type = 'button';
30
  button.dataset.imageId = image.id;
31
  button.style.backgroundColor = averageColor(image.blur);
32
+ // Stack order belongs to the photograph, never to its search rank.
33
+ button.style.zIndex = 1 + Math.floor(seed(image.id) * 100);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  root.append(button);
35
  const home = initial ? target : edgePosition(image, state.width, state.height, target);
36
+ const node = { image, button, ...home, target: home, motion: null, exit: false, phase: seed(image.id) * Math.PI * 2 };
 
 
37
  resize(node);
38
  button.style.transform = `translate3d(${home.x - node.width / 2}px,${home.y - node.height / 2}px,0) scale(${home.scale})`;
39
  nodes.set(image.id, node);
40
  node.cancelPlaceholder = placeholders.request(image.blur, url => {
41
  if (url && nodes.get(image.id) === node && !node.photo) button.style.backgroundImage = `url("${url}")`;
42
  });
43
+ button.onclick = () => selection.current(
44
+ // A product carries its full gallery so the viewer can page through it,
45
+ // exactly as the old product strip did. Interiors have no `images` and
46
+ // are unchanged.
47
+ { ...image, previewURL: node.photo?.currentSrc || node.photo?.src },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  button.getBoundingClientRect().toJSON(),
49
+ );
 
50
  return node;
51
  }
52
 
53
  function resize(node) {
54
+ const { width, height } = dimensions(node.image, state.width);
 
 
 
 
 
 
55
  if (node.width === width && node.height === height) return;
56
  node.width = width; node.height = height;
57
  node.button.style.width = `${width}px`;
 
73
  if (node.exit || nodes.get(node.image.id) !== node) { thumbnails.release(photo); return; }
74
  photo.alt = ''; photo.draggable = false; photo.className = '';
75
  node.photo = photo;
76
+ node.button.append(photo);
 
 
 
 
 
 
 
 
 
 
 
 
77
  if (!motion.matches) photo.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 180, easing: 'ease-out' });
78
  photo.classList.add('loaded');
79
  }, priority);
 
83
  const now = performance.now();
84
  const active = new Set(state.pool.map(image => image.id));
85
  const ranks = new Map(state.results.map((image, rank) => [image.id, rank]));
86
+ const targets = state.pool.map((image, index) => state.results.length
87
+ ? fieldPosition(image, index, state.pool.length, state.width, state.height)
88
+ : scatter(image, index, state.pool.length, state.width, state.height));
89
+ const appearance = relevanceAppearance(state.results);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  state.pool.forEach((image, index) => {
91
  const target = targets[index];
92
  const node = nodes.get(image.id) || create(image, target, initial);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  node.target = target; node.exit = false;
94
  node.button.removeAttribute('aria-hidden');
95
  const rank = ranks.get(image.id);
 
99
  node.load?.prioritize(rank ?? 200 + index);
100
  node.button.setAttribute('aria-label', rank == null ? 'Phóng to ảnh' : `Phóng to kết quả ${rank + 1}`);
101
  node.button.setAttribute('aria-haspopup', 'dialog');
102
+ const { opacity, blur } = appearance(image.id);
 
 
 
 
103
  node.button.style.setProperty('--relevance-opacity', opacity);
104
  node.button.style.setProperty('--relevance-blur', `${blur}px`);
 
 
 
 
 
 
 
105
  node.button.dataset.rank = rank == null ? '' : rank + 1;
 
106
  node.button.tabIndex = state.results.length ? (rank != null && rank < 12 ? 0 : -1) : (index < 12 ? 0 : -1);
107
  transition(node, target, now, initial || motion.matches);
108
  });
 
112
  node.load?.cancel(); node.load = null;
113
  node.button.setAttribute('aria-hidden', 'true');
114
  node.button.dataset.rank = '';
 
 
 
 
115
  node.button.tabIndex = -1;
116
  transition(node, edgePosition(node.image, state.width, state.height, node), now, motion.matches);
117
  }
 
183
  if (event.pointerType === 'touch') return;
184
  state.pointer.x = (event.clientX / state.width - 0.5) * 2;
185
  state.pointer.y = (event.clientY / state.height - 0.5) * 2;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  };
187
+ const leave = () => { state.pointer.x = state.pointer.y = 0; };
188
  root.addEventListener('pointermove', move); root.addEventListener('pointerleave', leave);
189
  frame = requestAnimationFrame(animate);
190
  engine.current = { state, update };
src/PhotoViewer.jsx CHANGED
@@ -1,12 +1,11 @@
1
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
  import { imageURL, shareURL } from './assets.js';
3
- import { detailOf, GENERIC_HEADING } from './detail.js';
4
 
5
  /**
6
  * Full-screen viewer shared by the interior field and the product catalogue.
7
  *
8
- * A `photo` is either an interior record ({ id, url, ar, blur, source }) or
9
- * a product photo ({ sku, src, images, name, price, specs }). Interior records
10
  * carry no `images` field, but keying the gallery off product identity anyway
11
  * keeps the two paths unambiguous rather than merely incidental.
12
  */
@@ -16,7 +15,7 @@ function resolveSrc(photo, width) {
16
  return imageURL(photo, width);
17
  }
18
 
19
- export default function PhotoViewer({ photo, timeList, onClose }) {
20
  const dialog = useRef(null);
21
  const frame = useRef(null);
22
  const closing = useRef(false);
@@ -27,21 +26,9 @@ export default function PhotoViewer({ photo, timeList, onClose }) {
27
  // Measured from the decoded image, so a product photo is not letterboxed
28
  // against an assumed ratio on first paint.
29
  const [measured, setMeasured] = useState(0);
30
- const [position, setPosition] = useState(0); // product gallery index
31
- const [timePosition, setTimePosition] = useState(0); // time-ordered navigation index
32
 
33
- /**
34
- * A catalogue product, or a photograph?
35
- *
36
- * Not `Boolean(photo.sku)`: a photograph published on vai-avatar carries a
37
- * namespaced `sku` too (`avatarimg:<image_id>`), which is what lets it share
38
- * this viewer and the field with a product. Keying on it announced every such
39
- * photograph as "Sản phẩm (avatarimg:...)" and rendered the product price and
40
- * spec caption underneath it. `avatarImageId` is present only on records read
41
- * from the vai-avatar interior index -- and detail.js derives the flag from the
42
- * same field, so the two cannot disagree.
43
- */
44
- const isProduct = Boolean(photo.sku) && !photo.avatarImageId;
45
 
46
  // Only a product has a gallery worth paging through.
47
  const gallery = useMemo(
@@ -49,21 +36,6 @@ export default function PhotoViewer({ photo, timeList, onClose }) {
49
  [isProduct, photo.images],
50
  );
51
 
52
- /**
53
- * Time-ordered list of interiors (newest → oldest) handed down from the
54
- * field. When active, the arrows step through photographs by `addedAt`, and
55
- * the counter shows both the slot and the date of the current photo.
56
- *
57
- * If absent, the viewer falls back to the existing behaviour: a single
58
- * photograph or a product's own gallery.
59
- */
60
- const steppableList = useMemo(() => {
61
- if (timeList && Array.isArray(timeList) && timeList.length > 1) {
62
- return timeList;
63
- }
64
- return gallery;
65
- }, [timeList, gallery]);
66
-
67
  /**
68
  * The frame actually on screen.
69
  *
@@ -72,16 +44,10 @@ export default function PhotoViewer({ photo, timeList, onClose }) {
72
  * the counter while the photograph stayed frozen.
73
  */
74
  const src = useMemo(
75
- () => {
76
- const list = steppableList;
77
- const idx = timeList && timeList.length > 1 ? timePosition : position;
78
- return (list && list[idx]) ? resolveSrc(list[idx], 2400) : resolveSrc(photo, 2400);
79
- },
80
- [steppableList, timePosition, position, photo],
81
  );
82
- const preview = steppableList
83
- ? resolveSrc(steppableList[timeList && timeList.length > 1 ? timePosition : position], 640)
84
- : (photo.previewURL || resolveSrc(photo, 640));
85
  const ar = photo.ar || measured || 4 / 3;
86
 
87
  const sourceTransform = useCallback(() => {
@@ -97,7 +63,7 @@ export default function PhotoViewer({ photo, timeList, onClose }) {
97
  useEffect(() => { setLoaded(false); setMeasured(0); setZoomed(false); }, [src]);
98
 
99
  // Opening a different photo starts that product's gallery from the beginning.
100
- useEffect(() => { setPosition(0); setTimePosition(0); }, [photo]);
101
 
102
  useEffect(() => {
103
  const previous = document.activeElement;
@@ -143,155 +109,71 @@ export default function PhotoViewer({ photo, timeList, onClose }) {
143
  }, [onClose, sourceTransform]);
144
 
145
  /**
146
- * Step through the steppable list (product gallery OR time-ordered interiors),
147
- * clamped at the ends.
148
  *
149
  * The clamp lives inside the updater so rapid arrow presses cannot drift the
150
  * index past the end and land on an empty frame.
151
  */
152
  const step = useCallback(delta => {
153
- const total = steppableList?.length ?? 0;
154
  if (total < 2) return;
155
- const setTime = timeList && timeList.length > 1 ? setTimePosition : setPosition;
156
- setTime(current => {
157
  const next = current + delta;
158
  return next < 0 || next >= total ? current : next;
159
  });
160
- }, [steppableList, timeList]);
161
 
162
  useEffect(() => {
163
- if (!steppableList || steppableList.length < 2) return;
164
  const onKey = event => {
165
  if (event.key === 'ArrowRight') { event.preventDefault(); step(1); }
166
  else if (event.key === 'ArrowLeft') { event.preventDefault(); step(-1); }
167
  };
168
  window.addEventListener('keydown', onKey);
169
  return () => window.removeEventListener('keydown', onKey);
170
- }, [steppableList, step]);
171
 
172
  // Escape stays with the dialog's own cancel event; only arrows are added here.
173
  const alt = isProduct
174
  ? `${photo.name || 'Sản phẩm'}${photo.sku ? ` (${photo.sku})` : ''}`
175
  : 'Ảnh nội thất đã phóng to';
176
 
177
- /**
178
- * A plain label, never a link.
179
- *
180
- * This used to render "View source ↗" pointed at photo.source. Nothing in the
181
- * viewer names or links to the scraped origin any more -- not the button, not
182
- * a "Nguồn" row in the fact table. The share link in the toolbar is the single
183
- * way out of the app, and the photograph's address is the only URL it needs.
184
- */
185
- const caption = <span className="photo-origin">
186
- {isProduct ? (photo.name || photo.sku) : ((photo.categories || [])[0] || photo.title || 'Interior design')}
187
- </span>;
188
 
189
  // Paging controls appear only when there is somewhere to go.
190
- const stepping = Boolean(steppableList && steppableList.length > 1);
191
- const usingTimeList = Boolean(timeList && timeList.length > 1);
192
 
193
  /**
194
- * What the share sheet shows above the URL.
195
  *
196
- * The record's own name, falling back to the heading the panel uses so the
197
- * sheet is never blank. Nothing scraped: a share card titled after daibau.vn
198
- * or noithatphuphat.com would carry somebody else's site name into the
199
- * reader's own messages, which is the same reason the panel stopped naming
200
- * them.
201
  */
202
- const shareTitle = (isProduct
203
- ? [photo.name, photo.n, photo.sku].find(value => typeof value === 'string' && value.trim())
204
- : [photo.title, detail.heading].find(value => typeof value === 'string' && value.trim())
205
- ) || 'Ảnh nội thất V.AI STUDIO';
206
-
207
  const share = shareURL(photo);
208
  const specs = isProduct && Array.isArray(photo.specs) ? photo.specs : null;
209
 
210
- const [shareLabel, setShareLabel] = useState('');
211
- const onShare = useCallback(async ({ url, title, label }) => {
212
- const announce = text => {
213
- label(text);
214
- window.clearTimeout(announce.timer);
215
- announce.timer = window.setTimeout(() => label(''), 1800);
216
- };
217
- if (navigator.share) {
218
- try {
219
- await navigator.share({ title, url });
220
- announce('Đã chia sẻ');
221
- } catch {
222
- // AbortError here means the sheet was swiped away. Nothing to report.
223
- }
224
- return;
225
- }
226
- if (navigator.clipboard?.writeText) {
227
- try {
228
- await navigator.clipboard.writeText(url);
229
- announce('Đã sao chép liên kết');
230
- return;
231
- } catch { /* fall through to the legacy path */ }
232
- }
233
- // Last resort, for a non-secure context or a blocked clipboard: a temporary
234
- // input selected and copied synchronously, which still works where the async
235
- // API above does not.
236
- const field = document.createElement('input');
237
- field.value = url;
238
- field.setAttribute('aria-hidden', 'true');
239
- document.body.append(field);
240
- field.select();
241
- const copied = document.execCommand?.('copy');
242
- field.remove();
243
- announce(copied ? 'Đã sao chép liên kết' : 'Không chia sẻ được');
244
- }, []);
245
-
246
- const detail = useMemo(() => detailOf(photo), [photo]);
247
- const generic = detail.description.startsWith('Ảnh trong bộ sưu tập');
248
- const hasDetail = Boolean(
249
- detail.room
250
- || detail.tags.length > 1
251
- || (detail.heading && detail.heading !== GENERIC_HEADING)
252
- || (detail.description && !generic),
253
- );
254
-
255
- const nowIdx = timeList && timeList.length > 1 ? timePosition : position;
256
- const currentItem = steppableList ? steppableList[nowIdx] : null;
257
- const dateLabel = (currentItem?.addedAt || photo.createdAt || '').slice(0, 10);
258
-
259
  return <dialog ref={dialog} className="photo-viewer" aria-label="Ảnh toàn màn hình"
260
  onCancel={event => { event.preventDefault(); close(); }}
261
  onClick={event => { if (event.target === dialog.current) close(); }}>
262
  <div className="photo-toolbar">
263
- {share ? <>
264
- {/*
265
- Two different jobs, two different controls.
266
-
267
- This button hands the photograph to whatever the reader already uses --
268
- Messages, Zalo, Telegram, the system sheet on iOS and Android. That is
269
- the gesture the icon already implied, and the old <a target="_blank">
270
- could not offer it: it opened a browser tab, not the reader's own apps.
271
- navigator.share is where the platform's share sheet lives; where it is
272
- absent or the user dismisses it, the clipboard takes over so there is
273
- always something to paste.
274
-
275
- A dismissal is not an error -- navigator.share rejects with AbortError
276
- every time the sheet is swiped away -- so a refusal is swallowed
277
- silently and nothing is announced.
278
- */}
279
- <button type="button" className="photo-share" onClick={() => onShare({ url: share, title: shareTitle, label: setShareLabel })}>
280
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" /></svg>
281
- <span>{shareLabel || (isProduct ? 'Chia sẻ sản phẩm' : 'Chia sẻ ảnh')}</span>
282
- </button>
283
- <a className="photo-open" href={share} target="_blank" rel="noopener noreferrer"
284
- title={isProduct ? 'Xem sản phẩm trên V.AI STUDIO' : 'Xem ảnh nội thất trên V.AI STUDIO'}>
285
- <span>Xem trang</span>
286
- </a>
287
- </> : null}
288
  {caption}
289
  <button type="button" aria-label="Đóng ảnh" autoFocus onClick={close}>
290
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="m6 6 12 12M6 18 18 6" /></svg>
291
  </button>
292
  </div>
293
  <div className={`photo-viewport ${zoomed ? 'is-zoomed' : ''}`} onClick={event => { if (event.target === event.currentTarget) close(); }}>
294
- {stepping && nowIdx > 0 && <button type="button" className="photo-step is-prev" aria-label="Ảnh trước"
295
  onClick={event => { event.stopPropagation(); step(-1); }}>
296
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="m14 6-6 6 6 6" /></svg>
297
  </button>}
@@ -311,65 +193,19 @@ export default function PhotoViewer({ photo, timeList, onClose }) {
311
  }
312
  }} />
313
  </button>
314
- {stepping && nowIdx < steppableList.length - 1 && <button type="button" className="photo-step is-next" aria-label="Ảnh tiếp theo"
315
  onClick={event => { event.stopPropagation(); step(1); }}>
316
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="m10 6 6 6-6 6" /></svg>
317
  </button>}
318
  </div>
319
  {isProduct && (photo.price || (specs && specs.length)) ? <div className="photo-caption">
320
  {photo.price && <p className="photo-caption-price">{photo.price}</p>}
321
- {specs && specs.length > 0 ? <dl className="photo-caption-specs">
322
  {specs.slice(0, 6).map(([key, value], index) => (
323
  <div key={index}><dt>{key}</dt><dd>{value}</dd></div>
324
  ))}
325
- </dl> : null}
326
  </div> : null}
327
- {/*
328
- Detail panel, for BOTH kinds of record.
329
-
330
- Collapsible rather than always-open because the viewer is a photo first:
331
- on a phone there is no room for both. Closed by default keeps the first
332
- impression the photograph, while the summary line still shows the title
333
- and the price.
334
-
335
- <details>/<summary> rather than a button plus state: keyboard- and
336
- screen-reader-correct for free, and it survives a re-render mid-read.
337
- */}
338
- {hasDetail ? (
339
- <details className="photo-detail">
340
- <summary className="photo-detail-summary">
341
- <span className="photo-detail-title">{detail.heading}</span>
342
- {photo.price ? <span className="photo-detail-price">{photo.price}</span> : null}
343
- <span className="photo-detail-toggle" aria-hidden="true">Chi tiết</span>
344
- </summary>
345
- <div className="photo-detail-body">
346
- {detail.description ? <p className="photo-detail-desc">{detail.description}</p> : null}
347
- {detail.tags.length > 0 ? <ul className="photo-detail-tags">
348
- {detail.tags.map(tag => <li key={tag}>{tag}</li>)}
349
- </ul> : null}
350
- {detail.facts.length > 0 ? <dl className="photo-detail-facts">
351
- {detail.facts.map(([key, value], index) => (
352
- <div key={index}><dt>{key}</dt><dd>{value}</dd></div>
353
- ))}
354
- </dl> : null}
355
- {/*
356
- No "Xem bài gốc" link here, and no "Nguồn" row either. The panel
357
- answers "what am I looking at", and the share link in the toolbar
358
- above already carries the photograph's public address. Naming the
359
- scraped origin -- daibau.vn, noithatphuphat.com -- in the table put
360
- somebody else's domain in the middle of our own interface, on 1,113
361
- of the 1,222 photographs, for no gain to a reader.
362
- */}
363
- </div>
364
- </details>
365
- ) : null}
366
- {stepping ? (
367
- <p className="photo-counter" role="status" aria-live="polite"
368
- title={usingTimeList ? `Duyệt theo thời gian: ${dateLabel}` : ''}>
369
- {usingTimeList && dateLabel
370
- ? <span className="photo-date" title={currentItem.addedAt}>{dateLabel}</span>
371
- : `${nowIdx + 1} / ${steppableList.length}`}
372
- </p>
373
- ) : null}
374
  </dialog>;
375
- }
 
1
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
  import { imageURL, shareURL } from './assets.js';
 
3
 
4
  /**
5
  * Full-screen viewer shared by the interior field and the product catalogue.
6
  *
7
+ * A `photo` is either an interior record ({ id, url, ar, blur, source }) or a
8
+ * product photo ({ sku, src, images, name, price, specs }). Interior records
9
  * carry no `images` field, but keying the gallery off product identity anyway
10
  * keeps the two paths unambiguous rather than merely incidental.
11
  */
 
15
  return imageURL(photo, width);
16
  }
17
 
18
+ export default function PhotoViewer({ photo, onClose }) {
19
  const dialog = useRef(null);
20
  const frame = useRef(null);
21
  const closing = useRef(false);
 
26
  // Measured from the decoded image, so a product photo is not letterboxed
27
  // against an assumed ratio on first paint.
28
  const [measured, setMeasured] = useState(0);
29
+ const [position, setPosition] = useState(0);
 
30
 
31
+ const isProduct = Boolean(photo.sku);
 
 
 
 
 
 
 
 
 
 
 
32
 
33
  // Only a product has a gallery worth paging through.
34
  const gallery = useMemo(
 
36
  [isProduct, photo.images],
37
  );
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  /**
40
  * The frame actually on screen.
41
  *
 
44
  * the counter while the photograph stayed frozen.
45
  */
46
  const src = useMemo(
47
+ () => (gallery ? (gallery[position] ?? gallery[0]) : resolveSrc(photo, 2400)),
48
+ [gallery, position, photo],
 
 
 
 
49
  );
50
+ const preview = gallery ? src : (photo.previewURL || resolveSrc(photo, 640));
 
 
51
  const ar = photo.ar || measured || 4 / 3;
52
 
53
  const sourceTransform = useCallback(() => {
 
63
  useEffect(() => { setLoaded(false); setMeasured(0); setZoomed(false); }, [src]);
64
 
65
  // Opening a different photo starts that product's gallery from the beginning.
66
+ useEffect(() => { setPosition(0); }, [photo]);
67
 
68
  useEffect(() => {
69
  const previous = document.activeElement;
 
109
  }, [onClose, sourceTransform]);
110
 
111
  /**
112
+ * Step through a product gallery, clamped to the ends.
 
113
  *
114
  * The clamp lives inside the updater so rapid arrow presses cannot drift the
115
  * index past the end and land on an empty frame.
116
  */
117
  const step = useCallback(delta => {
118
+ const total = gallery?.length ?? 0;
119
  if (total < 2) return;
120
+ setPosition(current => {
 
121
  const next = current + delta;
122
  return next < 0 || next >= total ? current : next;
123
  });
124
+ }, [gallery]);
125
 
126
  useEffect(() => {
127
+ if (!gallery || gallery.length < 2) return;
128
  const onKey = event => {
129
  if (event.key === 'ArrowRight') { event.preventDefault(); step(1); }
130
  else if (event.key === 'ArrowLeft') { event.preventDefault(); step(-1); }
131
  };
132
  window.addEventListener('keydown', onKey);
133
  return () => window.removeEventListener('keydown', onKey);
134
+ }, [gallery, step]);
135
 
136
  // Escape stays with the dialog's own cancel event; only arrows are added here.
137
  const alt = isProduct
138
  ? `${photo.name || 'Sản phẩm'}${photo.sku ? ` (${photo.sku})` : ''}`
139
  : 'Ảnh nội thất đã phóng to';
140
 
141
+ const caption = photo.source
142
+ ? <a href={photo.source} target="_blank" rel="noreferrer">View source ↗</a>
143
+ : <span className="photo-origin">
144
+ {isProduct ? (photo.name || photo.sku) : ((photo.categories || [])[0] || photo.title || 'Interior design')}
145
+ </span>;
 
 
 
 
 
 
146
 
147
  // Paging controls appear only when there is somewhere to go.
148
+ const stepping = Boolean(gallery && gallery.length > 1);
 
149
 
150
  /**
151
+ * The share page for this photo, or null when there is nothing to share.
152
  *
153
+ * It is keyed off `photo`, not `src`, so paging through a product's gallery
154
+ * keeps one stable link to the product rather than a different link per
155
+ * image -- sharing the 3rd photo of an appliance should still point at the
156
+ * appliance.
 
157
  */
 
 
 
 
 
158
  const share = shareURL(photo);
159
  const specs = isProduct && Array.isArray(photo.specs) ? photo.specs : null;
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  return <dialog ref={dialog} className="photo-viewer" aria-label="Ảnh toàn màn hình"
162
  onCancel={event => { event.preventDefault(); close(); }}
163
  onClick={event => { if (event.target === dialog.current) close(); }}>
164
  <div className="photo-toolbar">
165
+ {share ? <a className="photo-share" href={share} target="_blank" rel="noopener noreferrer"
166
+ title={isProduct ? 'Xem sản phẩm trên V.AI STUDIO' : 'Xem ảnh nội thất trên V.AI STUDIO'}>
167
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" /></svg>
168
+ <span>Xem thêm</span>
169
+ </a> : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  {caption}
171
  <button type="button" aria-label="Đóng ảnh" autoFocus onClick={close}>
172
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="m6 6 12 12M6 18 18 6" /></svg>
173
  </button>
174
  </div>
175
  <div className={`photo-viewport ${zoomed ? 'is-zoomed' : ''}`} onClick={event => { if (event.target === event.currentTarget) close(); }}>
176
+ {stepping && position > 0 && <button type="button" className="photo-step is-prev" aria-label="Ảnh trước"
177
  onClick={event => { event.stopPropagation(); step(-1); }}>
178
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="m14 6-6 6 6 6" /></svg>
179
  </button>}
 
193
  }
194
  }} />
195
  </button>
196
+ {stepping && position < gallery.length - 1 && <button type="button" className="photo-step is-next" aria-label="Ảnh tiếp theo"
197
  onClick={event => { event.stopPropagation(); step(1); }}>
198
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="m10 6 6 6-6 6" /></svg>
199
  </button>}
200
  </div>
201
  {isProduct && (photo.price || (specs && specs.length)) ? <div className="photo-caption">
202
  {photo.price && <p className="photo-caption-price">{photo.price}</p>}
203
+ {specs && specs.length > 0 && <dl className="photo-caption-specs">
204
  {specs.slice(0, 6).map(([key, value], index) => (
205
  <div key={index}><dt>{key}</dt><dd>{value}</dd></div>
206
  ))}
207
+ </dl>}
208
  </div> : null}
209
+ {stepping ? <p className="photo-counter" role="status" aria-live="polite">{position + 1} / {gallery.length}</p> : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  </dialog>;
211
+ }
src/assets.js CHANGED
@@ -1,18 +1,14 @@
1
- /**
2
- * Image database: 1,222 Vietnamese interior-design photographs, encoded offline with
3
- * CLIP-ViT-B-32 and stored as raw little-endian float32 in 512-d rows.
4
- */
5
  export const DATASET = 'https://huggingface.co/datasets/bep40/interior-image-field-assets/resolve/main/';
6
 
7
- /**
8
- * Text tower. `sentence-transformers/clip-ViT-B-32-multilingual-v1` maps 50+
9
- * languages (including Vietnamese) into the same 512-d space as CLIP-ViT-B-32,
10
- * so a Vietnamese query retrieves the matching interior photograph. The stock
11
- * repository ships a SentenceTransformer wrapper whose ONNX export stops at
12
- * 768-d `last_hidden_state`, so this fork adds a `text_model.onnx` that
13
- * includes mean pooling and the 768->512 projection, letting Transformers.js
14
- * run it under the usual CLIP file naming.
15
- */
16
  export const TEXT_MODEL = 'bep40/clip-vit-b32-multilingual-vi-text';
17
 
18
  /**
@@ -21,20 +17,25 @@ export const TEXT_MODEL = 'bep40/clip-vit-b32-multilingual-vi-text';
21
  *
22
  * The repo uploaded a broken export first (commit 68dc949, 136,951,792 bytes).
23
  * Its graph declares `masked` as [batch_size, sequence_length, 768] while an
24
- * attention mask only ever has 1 feature, so ONNX shape inference aborts. A
25
- * later commit (9e1d535, 136,915,522 bytes) re-exported the graph with no
26
- * `value_info` at all, which loads fine -- that is the revision pinned here.
27
  *
28
- * Pinning matters beyond reproducibility: transformers.js requests the
29
- * *unversioned* /resolve/main/ URL unless `revision` is given, and caches under
30
- * that same URL. Naming an explicit commit changes the cache key, so visitors
31
- * who cached the broken graph fetch the known-good file instead.
 
 
 
 
 
 
32
  */
33
  export const TEXT_MODEL_REVISION = '9e1d53553228b17abfe6986fbe92e22bd6bd6bad';
34
  export const TOKENIZER = 'sentence-transformers/clip-ViT-B-32-multilingual-v1';
35
  export const DIMENSIONS = 512;
36
 
37
- /** The URL of an interior photograph in the dataset repo, at a CDN width. */
38
  export function imageURL(image, width = 360) {
39
  return `${DATASET}images/${image.url}?w=${width}&q=80`;
40
  }
@@ -110,97 +111,13 @@ export async function cachedFile(url, onProgress) {
110
  */
111
  const VAI_STUDIO = 'https://bep40-vai-avatar.hf.space';
112
 
113
- /**
114
- * The prefix avatar-interiors.js puts on a vai-avatar photograph's `sku`.
115
- *
116
- * Namespacing it there is deliberate -- it is what stops a 16-hex image_id
117
- * colliding with a baked interior's id in merge.js's de-duplication set, and it
118
- * is what lets a photograph travel the same code path as a catalogue product
119
- * (same viewer, same panel, same neon, same share anchor).
120
- *
121
- * It is NOT a catalogue identity, and /share/p/ resolves against the product
122
- * catalogue -- so a photograph sent through that route answers "Not Found".
123
- * Nothing about that is fixable on vai-avatar's side: no SKU of that shape
124
- * exists there to find. It has to be routed as the interior it is.
125
- */
126
- const AVATAR_SKU_PREFIX = 'avatarimg:';
127
-
128
- /**
129
- * Whether this record is an interior photograph published on vai-avatar.
130
- *
131
- * Either field is conclusive on its own. `avatarImageId` is set on every such
132
- * photograph; the namespaced `sku` covers a record that has lost it.
133
- */
134
- function isAvatarInterior(photo) {
135
- return Boolean(photo.avatarImageId) || String(photo.sku ?? '').startsWith(AVATAR_SKU_PREFIX);
136
- }
137
-
138
- /**
139
- * The interior id behind a record, or '' when it has none.
140
- *
141
- * Two sources, in order, and the second is a fallback rather than an
142
- * alternative: `avatarImageId` is the field avatar-interiors.js sets, present on
143
- * every vai-avatar photograph. But a record can reach shareURL() having lost it
144
- * -- merge.js rewrites `id` into a namespaced tile key, and any path that
145
- * rebuilds a record from its `sku` alone would carry the prefix but not the
146
- * original field. That is exactly the shape that produced
147
- * /share/p/avatarimg%3Ab68dc779370dc70e, which is a "Not Found" page for every
148
- * single photograph in the vai-avatar interior set.
149
- *
150
- * So the namespaced sku is unwrapped as well, and a prefix that is present but
151
- * empty (`avatarimg:` with nothing after it) yields ''.
152
- */
153
- function interiorIdOf(photo) {
154
- const direct = String(photo.avatarImageId ?? '').trim();
155
- if (direct) return direct;
156
- const sku = String(photo.sku ?? '').trim();
157
- return sku.startsWith(AVATAR_SKU_PREFIX)
158
- ? sku.slice(AVATAR_SKU_PREFIX.length).trim()
159
- : '';
160
- }
161
-
162
  export function shareURL(photo) {
163
  if (!photo || typeof photo !== 'object') return null;
164
- /**
165
- * A photograph published on vai-avatar carries BOTH `avatarImageId` and a
166
- * namespaced `sku` of `avatarimg:<image_id>`.
167
- *
168
- * `sku` exists so the record travels the same path as a catalogue product --
169
- * same viewer, same detail panel, same neon, same share anchor -- and it has
170
- * to stay for that. But it is not an identity the product catalogue knows,
171
- * and it must not decide the URL: `/share/p/` resolves against the product
172
- * catalogue, so every such photograph sent through it answered "Not Found".
173
- * The whole vai-avatar interior set was affected, on the tile's share button
174
- * and on the viewer's "Xem trang" alike.
175
- *
176
- * The interior identity is therefore resolved first and unconditionally, and
177
- * /share/i/ gets the raw id -- no prefix to strip, and nothing to keep in
178
- * step with whatever namespace merge.js uses for its tile ids.
179
- */
180
- const interiorId = interiorIdOf(photo);
181
- if (interiorId) return `${VAI_STUDIO}/share/i/${encodeURIComponent(interiorId)}`;
182
- /**
183
- * A record that still says it is an interior but carries no usable id gets NO
184
- * share URL, rather than falling through to the product branch below.
185
- *
186
- * That fall-through is a trap worth naming, because it is exactly how the bug
187
- * comes back. `avatarimg:` with nothing after it has no id to share, and every
188
- * remaining branch here would still produce a URL for it: the product branch
189
- * would encode the prefix itself into `/share/p/avatarimg%3A`, which is the
190
- * dead route all over again, and the id branch would send it to `/share/i/`
191
- * with an empty segment, which 404s in its own right. Returning null lets the
192
- * caller omit the button instead, which is the only outcome that is true.
193
- */
194
- if (isAvatarInterior(photo)) return null;
195
- if (photo.sku || photo.slug) {
196
- // slug first, then sku, then mod. A shared URL is a permanent, public,
197
- // indexable address and the one thing about a product a reader can see:
198
- // `MH800TC` says nothing about what is linked, while
199
- // `mh800tc-malloca-may-hut-mui-gan-tu-am-tu-80cm` reads as the product's name
200
- // to a person and to a crawler. The slug is already the canonical key this
201
- // Space's catalogue uses, and vai-avatar's /share/p/ matches on it first, so
202
- // this changes how the link reads, not whether it resolves.
203
- const id = String(photo.slug || photo.sku || photo.mod || '').trim();
204
  return id ? `${VAI_STUDIO}/share/p/${encodeURIComponent(id)}` : null;
205
  }
206
  const id = String(photo.id ?? '').trim();
 
1
+
2
+ // Image database: 1,222 Vietnamese interior-design photographs, encoded offline with
3
+ // CLIP-ViT-B-32 and stored as raw little-endian float32 in 512-d rows.
 
4
  export const DATASET = 'https://huggingface.co/datasets/bep40/interior-image-field-assets/resolve/main/';
5
 
6
+ // Text tower. `sentence-transformers/clip-ViT-B-32-multilingual-v1` maps 50+ languages
7
+ // (including Vietnamese) into the same 512-d space as CLIP-ViT-B-32, so a Vietnamese
8
+ // query retrieves the matching interior photograph. The stock repository ships a
9
+ // SentenceTransformer wrapper whose ONNX export stops at 768-d `last_hidden_state`,
10
+ // so this fork adds a `text_model.onnx` that includes mean pooling and the 768->512
11
+ // projection, letting Transformers.js run it under the usual CLIP file naming.
 
 
 
12
  export const TEXT_MODEL = 'bep40/clip-vit-b32-multilingual-vi-text';
13
 
14
  /**
 
17
  *
18
  * The repo uploaded a broken export first (commit 68dc949, 136,951,792 bytes).
19
  * Its graph declares `masked` as [batch_size, sequence_length, 768] while an
20
+ * attention mask only ever has 1 feature, so ONNX shape inference aborts with
21
+ *
22
+ * [ShapeInferenceError] Can't merge shape info ... Inferred=1 Declared=768
23
  *
24
+ * and the app dies at "Không tải được bộ tìm kiếm". A later commit (9e1d535,
25
+ * 136,915,522 bytes) re-exported the graph with no `value_info` at all, which
26
+ * loads fine -- that is the revision pinned here.
27
+ *
28
+ * Pinning matters beyond reproducibility. transformers.js requests the
29
+ * *unversioned* `/resolve/main/...` URL unless `revision` is given, and caches
30
+ * under that same URL. Anyone who loaded the app while `main` still pointed at
31
+ * the broken graph keeps the bad bytes indefinitely: reload and "Thử lại" both
32
+ * replay from that cache. Naming an explicit commit changes the cache key, so
33
+ * those visitors fetch the known-good file instead of staying stuck.
34
  */
35
  export const TEXT_MODEL_REVISION = '9e1d53553228b17abfe6986fbe92e22bd6bd6bad';
36
  export const TOKENIZER = 'sentence-transformers/clip-ViT-B-32-multilingual-v1';
37
  export const DIMENSIONS = 512;
38
 
 
39
  export function imageURL(image, width = 360) {
40
  return `${DATASET}images/${image.url}?w=${width}&q=80`;
41
  }
 
111
  */
112
  const VAI_STUDIO = 'https://bep40-vai-avatar.hf.space';
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  export function shareURL(photo) {
115
  if (!photo || typeof photo !== 'object') return null;
116
+ if (photo.sku) {
117
+ // The sku is the identity vai-avatar writes edits under, so it is always
118
+ // present for a product; `mod` is the fallback for the handful of rows that
119
+ // carry only a model code.
120
+ const id = String(photo.sku || photo.mod || '').trim();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  return id ? `${VAI_STUDIO}/share/p/${encodeURIComponent(id)}` : null;
122
  }
123
  const id = String(photo.id ?? '').trim();
src/avatar-interiors.js DELETED
@@ -1,224 +0,0 @@
1
- /**
2
- * Interior photographs published on vai-avatar, read live.
3
- * =========================================================================
4
- *
5
- * This Space used to know about exactly one collection of interiors: the 1,222
6
- * photographs baked into bep40/interior-image-field-assets as a frozen CLIP
7
- * matrix. Anything added on vai-avatar -- an AI image, a photo a customer sent
8
- * by Zalo, an edit of either -- was invisible here until somebody re-ran a
9
- * 1,222-image embed, which nobody was going to do.
10
- *
11
- * vai-avatar writes its interiors to a dataset this file reads directly:
12
- *
13
- * bep40/vai-avatar2-interior index.json (1,134 rows)
14
- *
15
- * `index.json` is the one to use, not `metadata.json`, because it carries
16
- * `created_at`. That single field is what makes "newest" answerable: 153 of the
17
- * rows have one, spanning 2026-09-20 to 2026-10-05, and the newest is the image
18
- * generated minutes before this file was written. `metadata.json` has 1,752
19
- * rows but no timestamps at all, so it cannot order anything.
20
- *
21
- * ── Why the dataset and not the API ──
22
- * bep40-vai-avatar2-interior-api serves the same records and serves images, but
23
- * the dataset is the canonical write target: the Space writes there, the Hub
24
- * versions it, and a read needs no second Space to be awake. Both were checked
25
- * to return `access-control-allow-origin` echoing the caller, which is what
26
- * lets a tile load cross-origin from this Space at all -- without it every tile
27
- * would silently fall back to its BlurHash and the failure would look like a
28
- * layout bug rather than a network one.
29
- */
30
-
31
- // Semantic synonym expansion ("nhà phố" -> nhà ống, "biệt thự" -> villa),
32
- // so a caption spelled one way answers a query typed another way.
33
- import { expandQueryTerms } from './semantic-terms.js';
34
-
35
- /** The dataset vai-avatar's interior index is committed to. */
36
- export const AVATAR_INTERIOR_DATASET = 'https://huggingface.co/datasets/bep40/vai-avatar2-interior/resolve/main/';
37
-
38
- /**
39
- * Image host, preferred over the dataset path because it is the URL vai-avatar's
40
- * own share pages use, so the same photograph resolves the same way everywhere.
41
- * The dataset copy under images/ is the versioned fallback.
42
- */
43
- export const AVATAR_INTERIOR_API = 'https://bep40-vai-avatar2-interior-api.hf.space';
44
-
45
- /** How many recent additions are shown at once. */
46
- export const RECENT_LIMIT = 60;
47
-
48
- /**
49
- * How many of the newest photographs carry the neon ring.
50
- *
51
- * Separate from RECENT_LIMIT on purpose. The brief asks for the newest photos to
52
- * be highlighted, and it also asks for more photographs to be visible on the
53
- * page -- and those pull in opposite directions if one list drives both. Twenty-
54
- * four glowing rings on a dense field is not emphasis, it is decoration: the
55
- * eye stops being able to tell which one matters.
56
- *
57
- * So the newest RECENT_LIMIT photographs are shown, and only the newest
58
- * FEATURED_LIMIT of them ring. Everything else renders as an ordinary tile with
59
- * no ring, which is what makes the ten that do ring legible.
60
- */
61
- export const FEATURED_LIMIT = 10;
62
-
63
- /**
64
- * Rows to walk, newest first.
65
- *
66
- * Fixed rather than time-windowed on purpose. A window ("anything from the last
67
- * 7 days") silently empties itself when nobody adds anything for a week, and the
68
- * feature would go quiet with nothing failing. Fixed-N plus an explicit
69
- * created_at sort means the panel always shows the same NUMBER of newest images
70
- * and "newest" is always meaningful.
71
- *
72
- * 200 rather than RECENT_LIMIT because the filter below drops rows without a
73
- * timestamp and rows whose image cannot be addressed, so the raw slice has to be
74
- * comfortably larger than the target to still yield RECENT_LIMIT records.
75
- */
76
- const RECENT_ROWS = 200;
77
-
78
- /**
79
- * A usable absolute image URL for a record, or '' if none can be built.
80
- *
81
- * The id is used rather than `src`: `src` points at third-party CDNs as well as
82
- * local copies, and one of those URLs failing on a customer's network would put a
83
- * permanently broken tile in the field with no visible cause.
84
- */
85
- function leadImage(record) {
86
- const id = String(record?.image_id || '').trim();
87
- return id ? `${AVATAR_INTERIOR_API}/image/${encodeURIComponent(id)}.jpg` : '';
88
- }
89
-
90
- /** Fold Vietnamese diacritics so "Bếp đá" and "bep da" compare equal. */
91
- export function foldText(value) {
92
- return String(value ?? '')
93
- .normalize('NFD')
94
- .replace(/[̀-ͯ]/g, '')
95
- .replace(/đ/g, 'd')
96
- .replace(/Đ/g, 'D')
97
- .toLowerCase();
98
- }
99
-
100
- /**
101
- * Turn one index.json row into a field record.
102
- *
103
- * The shape is deliberately the one merge.js already understands for a product
104
- * (`sku` present, `images` an array) so a fresh interior photograph travels
105
- * the same path as a catalogue product: same viewer, same share anchor, same neon.
106
- * Interiors from the baked dataset have no `sku`, and keeping the two apart
107
- * would put every new photograph on its own code path for no gain.
108
- */
109
- function toRecord(row) {
110
- const id = String(row?.image_id || '').trim();
111
- if (!id) return null;
112
- const image = leadImage(row);
113
- if (!image) return null;
114
- // Title first, then the article's alt text, then the post title: `desc` is
115
- // what a person typed when they added the image, which is the most specific
116
- // thing on the row and the most likely to be what someone later types back.
117
- const title = String(row.desc || row.alt || (row.post_titles || [])[0] || '').trim();
118
- const categories = []
119
- .concat(row.categories || [], row.room_hints || [], row.labels || [])
120
- .map(value => String(value || '').trim())
121
- .filter(Boolean);
122
- const keywords = (row.keywords || []).map(value => String(value || '').trim()).filter(Boolean);
123
- const w = Number(row.w), h = Number(row.h);
124
- const ar = Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 ? w / h : 1;
125
- return {
126
- // Namespaced so it can never collide with a Malloca SKU or a baked interior
127
- // id in merge.js's de-duplication set.
128
- sku: `avatarimg:${id}`,
129
- avatarImageId: id,
130
- title,
131
- name: title,
132
- description: String(row.desc || '').trim(),
133
- categories,
134
- keywords,
135
- source: String(row.source || '').trim(),
136
- link: String((row.post_urls || [])[0] || '').trim(),
137
- ar: Math.max(0.5, Math.min(2, ar)),
138
- images: [image],
139
- src: image,
140
- /** ISO string. The sort key, and what "new" means everywhere below. */
141
- addedAt: String(row.created_at || '').trim(),
142
- };
143
- }
144
-
145
- /**
146
- * Read the newest interior photographs published on vai-avatar.
147
- *
148
- * Returns records sorted newest-first, capped at `limit`. An empty array on any
149
- * failure -- this is an enhancement on top of a working search, and a Hub
150
- * hiccup must not take the field down with it.
151
- */
152
- export async function loadRecentAvatarInteriors(limit = RECENT_LIMIT) {
153
- try {
154
- const response = await fetch(`${AVATAR_INTERIOR_DATASET}index.json`, { cache: 'no-cache' });
155
- if (!response.ok) return [];
156
- const rows = await response.json();
157
- if (!Array.isArray(rows)) return [];
158
- const dated = rows
159
- .filter(row => row && typeof row.created_at === 'string' && row.created_at)
160
- .sort((a, b) => (a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : 0))
161
- .slice(0, RECENT_ROWS);
162
- const records = [];
163
- for (const row of dated) {
164
- const record = toRecord(row);
165
- if (record) records.push(record);
166
- }
167
- records.sort((a, b) => (a.addedAt < b.addedAt ? 1 : a.addedAt > b.addedAt ? -1 : 0));
168
- return records.slice(0, limit);
169
- } catch {
170
- // Network failure, CORS, malformed JSON: the field keeps working without it.
171
- return [];
172
- }
173
- }
174
-
175
- /**
176
- * Interiors from vai-avatar that match a query.
177
- *
178
- * The baked CLIP matrix cannot score these -- it was computed from 1,222 images
179
- * and holds no vector for anything added since -- so the query is matched
180
- * against the record's own text instead. That is exact for how a person actually
181
- * adds an image: they type a caption, and that caption is what somebody later
182
- * types back when searching for it.
183
- *
184
- * A record containing the WHOLE query scores 900, which is the same identity
185
- * threshold relevance.js uses for the neon outline, so an exact literal match
186
- * here lights up for the same reason a matched SKU does.
187
- *
188
- * The term set is EXPANDED with semantic synonyms (semantic-terms.js) before
189
- * matching: a caption "Nhà ống mặt tiền" must answer a search for "nhà phố",
190
- * and "Biệt thự vườn" must answer "biệt thự". Expansion lives in one shared
191
- * table so the synonym pairs can never drift between the two Spaces that read
192
- * this dataset.
193
- *
194
- * Additive to the CLIP results, never a replacement: returning nothing leaves
195
- * the field exactly as it was before this feature existed.
196
- */
197
- export function searchAvatarInteriors(text, items, limit = RECENT_LIMIT) {
198
- const folded = foldText(String(text || '').trim());
199
- if (folded.length < 2 || !items?.length) return [];
200
- const terms = folded.split(/\s+/).filter(term => term.length > 1);
201
- // "nhà phố" -> [nha, pho, ong, biet, thu]: a caption spelled "nhà ống" or
202
- // "biệt thự" is the same intent as the query, and lexical-only matching
203
- // would never see it. See semantic-terms.js for why the table is narrow.
204
- const matchTerms = expandQueryTerms(terms);
205
- const scored = [];
206
- for (const item of items) {
207
- const haystack = foldText([item.title, item.description, ...(item.categories || []), ...(item.keywords || [])].join(' '));
208
- let score = 0;
209
- if (folded.length >= 3 && haystack.includes(folded)) score += 900;
210
- let hits = 0;
211
- for (const term of matchTerms) if (haystack.includes(term)) { score += 25; hits++; }
212
- if (!hits) continue;
213
- // Covering the whole query beats matching one rare word hard.
214
- score += hits * 10;
215
- // A single accidental 25-point term (e.g. "tu" matching one caption) is
216
- // noise: unrelated interiors must not appear at all. Keep only interiors
217
- // with a real match — either the whole-phrase 900 identity hit, or enough
218
- // term coverage to reach a meaningful 60 points.
219
- if (score < 900 && score < 60) continue;
220
- scored.push({ ...item, score });
221
- }
222
- scored.sort((a, b) => b.score - a.score || (a.addedAt < b.addedAt ? 1 : -1));
223
- return scored.slice(0, limit);
224
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/catalogue.js CHANGED
@@ -237,10 +237,6 @@ export function normalizeProduct(record) {
237
  if (!identity || !name) return null;
238
  return {
239
  sku: identity,
240
- // Carried through so the viewer's share link can address the product by its
241
- // readable slug. Already the canonical key in products-gallery.json, and
242
- // vai-avatar's /share/p/ matches on it first.
243
- slug,
244
  model: text(record.mod, record.model, sku),
245
  name,
246
  brand: text(record.brand),
@@ -253,11 +249,6 @@ export function normalizeProduct(record) {
253
  specs: specsOf(record.specs),
254
  feats: featuresOf(record),
255
  addedBy: text(record._addedBy, record._source),
256
- // The recency of an add via vai-avatar's "Thêm SP". Preserved so the field
257
- // can enlarge recently-added products the same way it enlarges recently
258
- // published interiors; a brand-new Grandx/Garis row is as much "new" as the
259
- // newest avatar interior is.
260
- addedAt: record._added_at || record.addedAt || null,
261
  };
262
  }
263
 
@@ -905,40 +896,6 @@ function isTombstone(record) {
905
  return flag === true || flag === 1 || flag === 'true' || flag === '1';
906
  }
907
 
908
- /**
909
- * Image directories on the V.AISTUDIO Space that no longer exist (verified 404
910
- * on /resolve/main/). A URL under one of these can never render a photograph,
911
- * so a row whose LEAD image points here would paint a broken tile. The old
912
- * Eurogold export lived in /eurogold_img/ and /eurogold_tb_img/; the current
913
- * live sources live at www.eurogold.vn (see the conflict rows below).
914
- */
915
- const DEAD_IMAGE_DIRS = [
916
- '/eurogold_img/',
917
- '/eurogold_tb_img/',
918
- '/demax_img/',
919
- '/canzy_img/',
920
- '/images/',
921
- ];
922
-
923
- const DEAD_IMAGE_HOSTS = ['huggingface.co/spaces/bep40/v.aistudio'];
924
-
925
- /** True when a URL points into a V.AISTUDIO directory that no longer exists. */
926
- function isDeadImageUrl(url) {
927
- if (typeof url !== 'string' || !url) return false;
928
- const lower = url.toLowerCase();
929
- if (!DEAD_IMAGE_HOSTS.some(host => lower.includes(host))) return false;
930
- return DEAD_IMAGE_DIRS.some(dir => lower.includes(dir));
931
- }
932
-
933
- /** The first renderable URL in a record's gallery, or ''. */
934
- function leadImageOf(record) {
935
- return imagesOf(record)[0] || '';
936
- }
937
-
938
- function leadIsDead(record) {
939
- return isDeadImageUrl(leadImageOf(record));
940
- }
941
-
942
  /**
943
  * Build the product map from both live sources.
944
  *
@@ -969,37 +926,6 @@ export function buildCatalogue(addedRecords, promos, baseRecords) {
969
  function upsert(key, patch, supersede) {
970
  const existing = raw.get(key);
971
  if (!existing) { raw.set(key, { ...patch }); return; }
972
- /**
973
- * Prefer the row whose LEAD image can actually render.
974
- *
975
- * The base catalogue carries two rows for the same Eurogold/SKU in the
976
- * conflict cases (one with a live www.eurogold.vn photo, one whose
977
- * /eurogold_img/... URL is dead), and the plain merge below lets the LAST
978
- * row overwrite the good photograph with a 404. Guarding the merge keeps
979
- * the live image: a dead-lead patch only replaces the existing row when
980
- * the existing row is dead too, so a good photo can never be clobbered by
981
- * a broken one and a logo can never become a product's lead.
982
- */
983
- const existingDead = leadIsDead(existing);
984
- const patchDead = leadIsDead(patch);
985
- if (!existingDead && patchDead) {
986
- // The patch may still carry a fresh name / price / specs; only its
987
- // image fields are refused. Applying the rest of the patch but keeping
988
- // the existing (live) gallery is what lets an edit land without
989
- // clobbering a photograph. Base rows upsert without a `supersede`
990
- // table, so the images are restored explicitly rather than via
991
- // EDIT_SUPERSEDES.
992
- const merged = { ...existing };
993
- if (supersede) {
994
- for (const [field, stale] of supersede) {
995
- if (patch[field] === undefined) continue;
996
- for (const name of stale) delete merged[name];
997
- }
998
- }
999
- const liveImgs = imagesOf(existing);
1000
- raw.set(key, { ...merged, ...patch, imgs: liveImgs });
1001
- return;
1002
- }
1003
  const merged = { ...existing };
1004
  if (supersede) {
1005
  for (const [field, stale] of supersede) {
@@ -1077,57 +1003,9 @@ export function buildCatalogue(addedRecords, promos, baseRecords) {
1077
  const product = normalizeProduct(record);
1078
  if (product) items.set(key, product);
1079
  }
1080
- recoverDeadLeads(items);
1081
  return items;
1082
  }
1083
 
1084
- /**
1085
- * Recover products whose ONLY photograph is a dead V.AISTUDIO URL.
1086
- *
1087
- * The /eurogold_img/ and /eurogold_tb_img/ exports were deleted from the
1088
- * V.AISTUDIO Space, leaving a residue of products whose lead image 404s and
1089
- * whose live twin (www.eurogold.vn) is missing from the base rows. For most
1090
- * of them the dead filename is itself a SKU that DOES exist with a live
1091
- * image:
1092
- *
1093
- * EPD4635VX -> EPD4635.jpg (one product, VX and non-VX variants)
1094
- * EUM101 -> EUM101L.jpg (Liên hoàn / Liên hoàn hộp nhôm)
1095
- * EUC87848EB-> EUC87848GA.jpg (Chậu rửa bát chống xước)
1096
- * EAS1200G -> EAS1200.jpg (Giá mắc quần áo nâng hạ điện, G = gang trắng)
1097
- * ETT103B -> ETT01B.jpg (Thanh treo góc tủ áo)
1098
- *
1099
- * The filename is the identity the V.AISTUDIO admin gave the upload, so
1100
- * mapping through it is a restore, not a guess. Only used when the product
1101
- * actually HAS a dead lead and the target row exists with its own live lead,
1102
- * and only the target's LEAD image is borrowed -- never a gallery, and never
1103
- * a logo.
1104
- */
1105
- const EUROGOLD_DEAD_RECOVERY = {
1106
- 'EPD4635VX': 'EPD4635', 'EUM101': 'EUM101L', 'ETW8080VX': 'ETW8080',
1107
- 'ETW8090VX': 'ETW8090', 'EUC87848EB': 'EUC87848GA', 'EUC88050GA': 'EUC88050GE',
1108
- 'EAS1200G': 'EAS1200', 'EAS1200P': 'EAS1200', 'ETT103B': 'ETT01B', 'ETT103M': 'ETT01B',
1109
- };
1110
-
1111
- function recoverDeadLeads(items) {
1112
- if (!items) return;
1113
- for (const [key, product] of items) {
1114
- const lead = Array.isArray(product.images) ? product.images[0] : '';
1115
- if (!lead || !isDeadImageUrl(lead)) continue;
1116
- let targetSku = EUROGOLD_DEAD_RECOVERY[key.toUpperCase()];
1117
- if (!targetSku) {
1118
- // Fall back to the dead filename itself: "EPD4635.jpg" names SKU EPD4635.
1119
- const file = lead.split('/').pop().replace(/\.[a-z0-9]+$/i, '').toUpperCase();
1120
- if (file && file !== key.toUpperCase()) targetSku = file;
1121
- }
1122
- if (!targetSku) continue;
1123
- const target = items.get(targetSku);
1124
- if (!target) continue;
1125
- const targetLead = Array.isArray(target.images) ? target.images[0] : '';
1126
- if (!targetLead || isDeadImageUrl(targetLead)) continue;
1127
- product.images = [targetLead, ...product.images.filter(url => !isDeadImageUrl(url))];
1128
- }
1129
- }
1130
-
1131
  /**
1132
  * Content hash over identity plus the fields the UI renders.
1133
  *
 
237
  if (!identity || !name) return null;
238
  return {
239
  sku: identity,
 
 
 
 
240
  model: text(record.mod, record.model, sku),
241
  name,
242
  brand: text(record.brand),
 
249
  specs: specsOf(record.specs),
250
  feats: featuresOf(record),
251
  addedBy: text(record._addedBy, record._source),
 
 
 
 
 
252
  };
253
  }
254
 
 
896
  return flag === true || flag === 1 || flag === 'true' || flag === '1';
897
  }
898
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
899
  /**
900
  * Build the product map from both live sources.
901
  *
 
926
  function upsert(key, patch, supersede) {
927
  const existing = raw.get(key);
928
  if (!existing) { raw.set(key, { ...patch }); return; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
929
  const merged = { ...existing };
930
  if (supersede) {
931
  for (const [field, stale] of supersede) {
 
1003
  const product = normalizeProduct(record);
1004
  if (product) items.set(key, product);
1005
  }
 
1006
  return items;
1007
  }
1008
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1009
  /**
1010
  * Content hash over identity plus the fields the UI renders.
1011
  *
src/detail.js DELETED
@@ -1,369 +0,0 @@
1
- /**
2
- * What is known about the open photo, beyond the pixels.
3
- * =====================================================
4
- *
5
- * The worker already loads the whole interior index and hands each record
6
- * through untouched -- `image-embeddings.json` rows carry `title`,
7
- * `categories` and `source` -- and the catalogue loader already normalises
8
- * every product into `summary`, `specs` and `feats`. None of it was displayed.
9
- * The viewer showed a price and a few specs for a product, and for an interior
10
- * photograph it showed nothing at all: no title, no room, no categories, no
11
- * link back to the article the image came from.
12
- *
13
- * This module turns both record shapes into one presentable summary. It is a
14
- * port of `detailOf` from the vai-avatar Space's share-seo.ts, so the in-app
15
- * viewer and the /share/ landing page agree on what a photograph is called and
16
- * which room it shows. The two cannot import each other -- this is a static
17
- * Space with no server and vai-avatar is a separate repo -- so the logic is
18
- * duplicated rather than fetched, and both copies are tested against the real
19
- * datasets.
20
- *
21
- * Pure derivation only: no fetching, no globals, and nothing here runs in
22
- * the worker. It takes whatever the caller already has.
23
- */
24
-
25
- /**
26
- * Fold to a comparison key: lower case, no diacritics, no punctuation.
27
- *
28
- * `đ` needs its own replacement: it has no canonical decomposition, so NFD
29
- * alone leaves "Nhà Đông" and "Nha Dong" as different strings.
30
- */
31
- export function normKey(value) {
32
- return String(value ?? '')
33
- .normalize('NFD')
34
- .replace(/[̀-ͯ]/g, '')
35
- .replace(/đ/g, 'd')
36
- .replace(/Đ/g, 'D')
37
- .toLowerCase()
38
- .replace(/[^a-z0-9]+/g, ' ')
39
- .trim();
40
- }
41
-
42
- /**
43
- * Strip the scraped HTML the index and catalogue carry.
44
- *
45
- * Worth doing twice over: the markup would otherwise be shown verbatim in the
46
- * panel, and an entity like `&hellip;` tokenizes as the junk word "hellip".
47
- */
48
- function plain(raw, max = 0) {
49
- let text = String(raw ?? '');
50
- // The scraper writes Python-style container reprs into some fields, so an
51
- // empty description can literally be the two characters "[]".
52
- if (!text || text === '[]' || text === '{}') return '';
53
- text = text
54
- .replace(/<[^>]*>/g, ' ')
55
- .replace(/&#\d+;/g, ' ')
56
- .replace(/&[a-z]+;/gi, ' ')
57
- .replace(/\s+/g, ' ')
58
- .trim();
59
- return max > 0 && text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
60
- }
61
-
62
- /**
63
- * Rooms, as `match key -> display label`.
64
- *
65
- * The two halves differ deliberately. Matching folds diacritics away because
66
- * the scraped categories spell a room inconsistently; the label is the
67
- * correctly-spelled Vietnamese that belongs in a heading, since deriving it from
68
- * the key would print "Van Phong" with no accents at all.
69
- */
70
- const ROOMS = [
71
- ['phong khach', 'Phòng khách'],
72
- ['phong ngu', 'Phòng ngủ'],
73
- ['phong tam', 'Phòng tắm'],
74
- ['phong an', 'Phòng ăn'],
75
- ['phong lam viec', 'Phòng làm việc'],
76
- ['phong thong', 'Phòng thông'],
77
- ['sanh', 'Sảnh'],
78
- ['van phong', 'Văn phòng'],
79
- ['ban cong', 'Ban công'],
80
- ['san thuong', 'Sân thượng'],
81
- ['hanh lang', 'Hành lang'],
82
- ['cau thang', 'Cầu thang'],
83
- ['garage', 'Garage'],
84
- ['tang lau', 'Tầng lầu'],
85
- ['nha ve sinh', 'Nhà vệ sinh'],
86
- ['kho', 'Kho'],
87
- ['bep', 'Bếp'],
88
- ['baner', 'Banner'],
89
- ['tap ket', 'Tấp kết'],
90
- ];
91
-
92
- /**
93
- * Categories that describe the SOURCE ARTICLE, not the room.
94
- *
95
- * These are scraped page titles, so the same field mixes "NỘI THẤT CĂN HỘ" with
96
- * "BÁO GIÁ" and "GIỚI THIỆU". Showing an editorial section as if it were a room
97
- * is simply wrong, so these are kept out of the headline and shown as topics.
98
- */
99
- const EDITORIAL = [
100
- 'bao gia', 'gioi thieu', 'xay dung', 'sua nha', 'ai anh', 'tin tuc',
101
- 'kien thuc', 'video', 'hinh anh', 'thiet ke kien truc', 'blog', 'cap nhat',
102
- 'mua ban', 'lien he', 'tuyen dung',
103
- ];
104
-
105
- /**
106
- * The room a photograph shows, if its categories name one.
107
- *
108
- * Matching is on WORD SET, not on the whole string: the categories prepend a
109
- * topic, so "NỘI THẤT VĂN PHÒNG" never matches "van phong" by prefix or
110
- * equality. Requiring every word of the room to be present, and scoring by how
111
- * specific the match is, makes both "phong khach" and "noi that phong khach"
112
- * resolve while "van phong" still cannot claim a category that only says "phong".
113
- */
114
- export function roomOf(categories) {
115
- const wanted = ROOMS.map(([key, label]) => ({ label, words: new Set(normKey(key).split(' ')) }));
116
- for (const category of categories) {
117
- const have = new Set(normKey(category).split(' ').filter(Boolean));
118
- if (!have.size) continue;
119
- let best = null;
120
- let bestScore = 0;
121
- for (const room of wanted) {
122
- let hits = 0;
123
- for (const word of room.words) if (have.has(word)) hits++;
124
- if (hits !== room.words.size) continue;
125
- // Prefer the most specific room, so "phong khach" beats a bare "phong".
126
- if (room.words.size > bestScore) { bestScore = room.words.size; best = room.label; }
127
- }
128
- if (best) return best;
129
- }
130
- return '';
131
- }
132
-
133
- /**
134
- * Removed: hostOf().
135
- *
136
- * It existed only to put the scraped origin's hostname in the fact table --
137
- * "Nguồn: daibau.vn", "Nguồn: malloca.com". Nothing reads that any more, and
138
- * keeping it would keep `source` and `sourceHost` alive in detailOf's return,
139
- * which is the part that matters: a field named `source` on a panel object is
140
- * an invitation for the next person to render it again.
141
- *
142
- * The origin URLs are still on the records themselves (image-embeddings.json
143
- * carries `source`, the catalogue carries `link`) and still go to vai-avatar,
144
- * where schema.org "license" carries the attribution for crawlers. What is gone
145
- * is naming them in front of a reader.
146
- */
147
-
148
- /**
149
- * The heading used when an interior record carries no title and no category.
150
- *
151
- * Exported so the viewer can tell "this photo is generically titled" apart from
152
- * "this photo has a real title", rather than repeating the literal in two files
153
- * and letting them drift apart.
154
- */
155
- export const GENERIC_HEADING = 'Ảnh nội thất';
156
-
157
- /**
158
- * Building and project names, which are a category but not a topic.
159
- *
160
- * "VINHOMES GRAND PARK" says which development a photograph belongs to, not
161
- * what it depicts. Naming it as a subject reads as a non-sequitur ("thuộc chủ
162
- * đề Vinhomes Grand Park"), so it is dropped from the sentence -- the title
163
- * almost always mentions the project anyway, and it reads better there.
164
- */
165
- const PROJECT = /vinhomes|masteri|elysian|gamuda|global\s*city|the\s*global/i;
166
-
167
- /**
168
- * Describe an interior photograph, in Vietnamese, from what the record has.
169
- *
170
- * The dataset carries no description field -- image-embeddings.json has id, url,
171
- * ar, blur, title, categories, source and nothing else -- so the panel used to
172
- * fall back to one fixed sentence that named nothing: "Ảnh trong bộ sưu tập thiết
173
- * kế nội thất Việt Nam của V.AI STUDIO". Every photograph read identically.
174
- *
175
- * What it does carry is a title and a category list, and those are enough to
176
- * say something specific and true about each photo. The sentence leads with the
177
- * room when the categories name one, names up to two topics, and otherwise --
178
- * which is 679 of the 1,222 records, mostly those tagged only with a project or
179
- * with an unaccented category -- it uses the title, because on those records
180
- * the title is the most informative field there is.
181
- *
182
- * Every clause is derived from a field on the record. Nothing is invented.
183
- *
184
- * The categories are scraped and untidy: "Uncategorized", unaccented
185
- * "XAY DUNG VA CAI TAO", "The Global City" and "THE GLOBAL CITY" both present,
186
- * and a handful of nothing at all. An unaccented category is dropped from the
187
- * sentence rather than rendered as-is, since "Thuộc chủ đề Xay dung va cai tao"
188
- * is worse than saying nothing -- the topic survives as a tag below, which is
189
- * where an unaccented label still reads fine.
190
- */
191
- function describeInterior(record, room, categories) {
192
- const title = plain(record?.title, 160);
193
- const accented = value => /[̀-ͯ]/.test(value.normalize('NFD'));
194
-
195
- const topics = categories
196
- .filter(value => {
197
- const key = normKey(value);
198
- return key
199
- && key !== 'uncategorized'
200
- && accented(value)
201
- && !EDITORIAL.includes(key)
202
- && !PROJECT.test(value)
203
- && normKey(value) !== normKey(room);
204
- })
205
- .map(value => value.charAt(0).toUpperCase() + value.slice(1).toLowerCase());
206
-
207
- // Drop a topic the title already says outright, so "Phòng khách đẹp" is not
208
- // followed by a phrase restating "Phòng khách".
209
- const titleKey = normKey(title);
210
- const extra = topics.filter(topic => !titleKey.includes(normKey(topic)));
211
-
212
- const parts = [];
213
- if (room) parts.push(`Ảnh chụp ${room.toLowerCase()}`);
214
- if (extra.length) {
215
- // Two topics read as a list; more than two is noise, so name the first few
216
- // and count the rest.
217
- const named = extra.slice(0, 2).join(' và ');
218
- const rest = extra.length - Math.min(2, extra.length);
219
- parts.push(rest > 0 ? `thuộc chủ đề ${named} và ${rest} chủ đề khác` : `thuộc chủ đề ${named}`);
220
- }
221
- if (!parts.length) {
222
- // No usable topic. The title is still worth saying -- it is the article's
223
- // own headline, and on a record like "Sofa đã bàn giao cho căn hộ chung cư"
224
- // it is the only description that exists.
225
- if (title && title !== GENERIC_HEADING) {
226
- return `Ảnh minh hoạ cho bài viết “${title}” trong bộ sưu tập thiết kế nội thất Việt Nam của V.AI STUDIO.`;
227
- }
228
- return 'Ảnh trong bộ sưu tập thiết kế nội thất Việt Nam của V.AI STUDIO.';
229
- }
230
- const sentence = parts.join(', ');
231
- return `${sentence.charAt(0).toUpperCase()}${sentence.slice(1)}, trong bộ sưu tập thiết kế nội thất Việt Nam của V.AI STUDIO.`;
232
- }
233
-
234
- /**
235
- * Describe a catalogue product from its spec sheet when it has no summary.
236
- *
237
- * Two thirds of the catalogue -- 6,347 of the 9,631 gallery rows -- carries an
238
- * empty `summary`, because the field was never scraped from their source pages.
239
- * Their panel therefore had no prose at all: a product name, a spec table, a
240
- * feature list, and nothing connecting them.
241
- *
242
- * A spec sheet is a description in a different grammar. Naming the product's
243
- * kind and material in a sentence -- "Bếp ga âm tủ bằng inox 304, kích thước
244
- * 80cm, 3 vùng nấu" -- says what it is and what distinguishes it, from fields
245
- * that are already there. The keys are filtered so only the ones that describe
246
- * the object survive: prices, stock flags, warranty boilerplate and section
247
- * headings are noise in a sentence.
248
- *
249
- * Falls back to the feature list when there are no usable specs, and to the
250
- * brand and category when there is neither -- which is what the caller had
251
- * before, so a product with nothing at all still says something true.
252
- */
253
- const SPEC_SKIP = /nhóm sản phẩm|^loại|^giá|^giá bán|xuất xứ|^bảo hành|^tình trạng|^số lượng|^giao hàng|^liên hệ|thông tin|^hướng dẫn|^chính sách|^mã sản phẩm|^sku|^brand|^thương hiệu/i;
254
-
255
- /** Keys that name the object itself -- used first, in this order of preference. */
256
- const SPEC_HEADLINE = [
257
- /^(loại sản phẩm|loại bếp|loại chậu|nhóm sản phẩm)/i,
258
- /^(chất liệu|màu sắc|kiểu dáng|kích thước)/i,
259
- ];
260
-
261
- function describeProduct(record, specs, feats) {
262
- const usable = specs.filter(([key]) => key && !SPEC_SKIP.test(key));
263
- if (!usable.length) {
264
- if (feats.length) {
265
- const named = feats.slice(0, 2).join(', ');
266
- const rest = feats.length - Math.min(2, feats.length);
267
- return `Sản phẩm nổi bật với ${named}${rest > 0 ? ` và ${rest} đặc điểm khác` : ''}.`;
268
- }
269
- return [plain(record.brand, 40), plain(record.category ?? record.c, 80)]
270
- .filter(Boolean).join(' · ') || '';
271
- }
272
-
273
- const clauses = [];
274
- for (const matcher of SPEC_HEADLINE) {
275
- const hit = usable.find(([key]) => matcher.test(key));
276
- // Take the first matching spec and drop it from the pool, so a later clause
277
- // cannot restate the same field.
278
- if (hit) clauses.push(hit[1]);
279
- }
280
- // Two headline fields, then the two most specific remaining facts -- enough to
281
- // distinguish the product without turning the panel into the spec table it
282
- // already shows.
283
- for (const [key, value] of usable) {
284
- if (clauses.length >= 4) break;
285
- if (clauses.includes(value)) continue;
286
- clauses.push(value);
287
- }
288
-
289
- const name = plain(record.name ?? record.n, 160) || plain(record.sku, 40);
290
- const body = clauses.slice(0, 4).join(', ');
291
- return name && body ? `${name}: ${body}.` : (name || body);
292
- }
293
-
294
- /**
295
- * Build the panel contents for one open record.
296
- *
297
- * `record` is whatever the field holds: an interior row ({ id, url, title,
298
- * categories, source }) or a catalogue product ({ sku, name, price, summary,
299
- * specs, feats, link, images }). Both are flattened to the same shape, so the
300
- * viewer renders one component for either.
301
- */
302
-
303
- export function detailOf(record) {
304
- /**
305
- * A product, or a photograph published on vai-avatar?
306
- *
307
- * `sku` alone cannot answer this. avatar-interiors.js gives every vai-avatar
308
- * photograph a namespaced `sku` (`avatarimg:<id>`) precisely so it travels the
309
- * same code path as a catalogue product -- same viewer, same panel, same
310
- * neon -- and that is worth keeping. But it is not a catalogue identity, and
311
- * keying the panel off its presence rendered every such photograph as a
312
- * product: the heading was the literal `avatarimg:b68dc779370dc70e`, the
313
- * description came from an empty spec sheet, and no room, category or tag
314
- * appeared even though the record carries all of them.
315
- *
316
- * `avatarImageId` is what actually distinguishes them. It is present only on
317
- * records read from the vai-avatar interior index, and nothing else in the
318
- * catalogue has it.
319
- */
320
- const isProduct = Boolean(record?.sku) && !record?.avatarImageId;
321
- const categories = Array.isArray(record?.categories)
322
- ? record.categories.map(value => plain(value, 60)).filter(Boolean)
323
- : [];
324
- const room = roomOf(categories);
325
- const roomKey = normKey(room);
326
- const editorial = categories.filter(value => EDITORIAL.includes(normKey(value)));
327
-
328
- const tags = (room ? [room] : []).concat(
329
- categories.filter(value => normKey(value) !== roomKey && !EDITORIAL.includes(normKey(value))),
330
- ).slice(0, 10);
331
-
332
- const specs = Array.isArray(record?.specs)
333
- ? record.specs
334
- .filter(pair => Array.isArray(pair) && pair.length >= 2)
335
- .map(([key, value]) => [plain(key, 60), plain(value, 160)])
336
- .filter(([key, value]) => key && value)
337
- : [];
338
- const feats = Array.isArray(record?.feats)
339
- ? record.feats.map(value => plain(value, 90)).filter(Boolean)
340
- : [];
341
-
342
- const facts = [];
343
- let heading;
344
- let description = plain(record?.summary ?? record?.sum ?? record?.desc, 400);
345
-
346
- if (isProduct) {
347
- heading = plain(record.name ?? record.n, 160) || plain(record.sku, 40);
348
- if (!description) {
349
- description = describeProduct(record, specs, feats)
350
- || [plain(record.brand, 40), plain(record.category ?? record.c, 80)].filter(Boolean).join(' · ');
351
- }
352
- // The catalogue already normalises a full spec sheet and a feature list;
353
- // showing four of each is what made the panel worth opening at all.
354
- for (const [key, value] of specs.slice(0, 4)) facts.push([key, value]);
355
- for (const feature of feats.slice(0, 3)) facts.push(['Tính năng', feature]);
356
- } else {
357
- heading = plain(record?.title, 160) || room || GENERIC_HEADING;
358
- if (!description) description = describeInterior(record, room, categories);
359
- if (record?.id) facts.push(['Mã ảnh', String(record.id)]);
360
- if (room) facts.push(['Không gian', room]);
361
- }
362
-
363
- if (editorial.length) facts.push(['Chủ đề', editorial.join(', ')]);
364
-
365
- // No `source`, no `sourceHost`. The panel describes what the reader is
366
- // looking at; where it was scraped from is not part of that, and a field
367
- // carrying it is only ever going to be rendered again.
368
- return { heading, room, description, tags, facts, isProduct };
369
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/image-loader.js CHANGED
@@ -1,5 +1,5 @@
1
  /** Bounded download/decode queue, with at most two image insertions per frame. */
2
- export function createImageLoader({ concurrency = 12, cacheSize = 128 } = {}) {
3
  const queue = [], completed = [];
4
  const active = new Set(), cache = new Map();
5
  let disposed = false, frame = 0, scheduled = false;
@@ -14,7 +14,7 @@ export function createImageLoader({ concurrency = 12, cacheSize = 128 } = {}) {
14
 
15
  function flush() {
16
  frame = 0;
17
- for (let i = 0; i < 4 && completed.length; i++) {
18
  const job = completed.shift();
19
  if (job.cancelled) remember(job.image);
20
  else job.receive(job.image);
@@ -36,7 +36,7 @@ export function createImageLoader({ concurrency = 12, cacheSize = 128 } = {}) {
36
  active.add(job);
37
  const photo = job.image = new Image();
38
  photo.decoding = 'async';
39
- photo.fetchPriority = job.priority < 24 ? 'high' : 'low';
40
  photo.src = job.url;
41
  // Decoding completes before this element is attached to the animated field.
42
  photo.decode().then(() => {
 
1
  /** Bounded download/decode queue, with at most two image insertions per frame. */
2
+ export function createImageLoader({ concurrency = 6, cacheSize = 64 } = {}) {
3
  const queue = [], completed = [];
4
  const active = new Set(), cache = new Map();
5
  let disposed = false, frame = 0, scheduled = false;
 
14
 
15
  function flush() {
16
  frame = 0;
17
+ for (let i = 0; i < 2 && completed.length; i++) {
18
  const job = completed.shift();
19
  if (job.cancelled) remember(job.image);
20
  else job.receive(job.image);
 
36
  active.add(job);
37
  const photo = job.image = new Image();
38
  photo.decoding = 'async';
39
+ photo.fetchPriority = job.priority < 12 ? 'high' : 'low';
40
  photo.src = job.url;
41
  // Decoding completes before this element is attached to the animated field.
42
  photo.decode().then(() => {
src/index.css CHANGED
@@ -1,5 +1,4 @@
1
  @import 'tailwindcss';
2
- @import './neon-frame.css';
3
 
4
  :root { font-family: Arial, Helvetica, sans-serif; color: #deded7; background: #10110f; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; }
5
  * { box-sizing: border-box; }
@@ -8,92 +7,17 @@ button, input, a { -webkit-tap-highlight-color: transparent; }
8
  button, input { font: inherit; }
9
  button { cursor: pointer; }
10
  button:focus-visible, a:focus-visible { outline: 2px solid #d7e1d1; outline-offset: 5px; }
11
-
12
- /* ── V.AI STUDIO background ────────────────────────────────────────────────
13
- * The same artwork vai-avatar shows on its body: public/avatars/vaistudio_center.png,
14
- * copied here byte-for-byte so the two Spaces read as one brand (md5 checked).
15
- *
16
- * Rendered at 30% opacity as a separate fixed layer rather than as a
17
- * `background-image` on .app, because:
18
- *
19
- * * opacity on the element would fade the photographs and the search pill
20
- * along with it, and
21
- * * `background-attachment: fixed` + `background-image` cannot be composed
22
- * with the two dimming layers (.vignette / .grain) that already sit on top
23
- * of the field, so the artwork would fight them instead of sitting under.
24
- *
25
- * `pointer-events: none` keeps it from swallowing clicks meant for a tile, and
26
- * z-index 0 puts it below .image-field (z-index auto, painted later) while
27
- * still above the base background colour.
28
- *
29
- * The image is 816x816 and deliberately centred-cropped on the logo, matching
30
- * vai-avatar's own mobile rule: on narrow portrait screens it is sized to full
31
- * width and tiled vertically so the artwork is never cropped to a logo sliver.
32
- */
33
- .brand-backdrop {
34
- position: fixed; inset: 0; z-index: 0; pointer-events: none;
35
- background-color: #10110f;
36
- background-image: url('/brand/vaistudio_center.png');
37
- background-size: cover;
38
- background-position: center center;
39
- background-repeat: no-repeat;
40
- background-attachment: fixed;
41
- opacity: .3;
42
- }
43
- @media (max-width: 768px) {
44
- .brand-backdrop { background-position: center top; background-size: 100% auto; background-repeat: repeat; background-attachment: scroll; }
45
- }
46
  .app { min-height: 320px; background: #11120f; overflow: clip; }
47
- /* Above the backdrop, below the field's own dimming layers. */
48
- .app { background: transparent; }
49
  /* Unlike hidden, clip prevents focus/scrollIntoView from panning the canvas. */
50
  .image-field { position: absolute; inset: 0; overflow: clip; contain: strict; }
51
  .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; }
52
- /* position:absolute so the share anchor's z-index:1 actually stacks above the
53
- photograph. A z-index on a static element is ignored, and without this the
54
- <img> -- appended after the anchor when the thumbnail loads -- paints on top
55
- of it, so the share button opens the viewer instead of sharing. */
56
- .image-tile img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; filter: brightness(.92); transition: opacity 120ms ease, filter 120ms ease; }
57
  .image-tile img.loaded { opacity: 1; }
58
  .image-tile:hover { box-shadow: 0 10px 35px #0005; }
59
  .image-tile:hover img { filter: brightness(1); }
60
- /* z-index:1 is load-bearing. The tile holds two children -- the share anchor
61
- and, once it loads, an <img> at width/height 100%. The image is appended
62
- later, so without this it paints on top and every click lands on the
63
- photograph: the share button opens the viewer it was meant to bypass. */
64
- /* Share link on the tile. Hidden until hover so the field stays a field: 150
65
- photographs each wearing a button turns the plane into a control panel. It is
66
- aria-hidden and tabIndex -1 (see ImageField), so this is purely a pointer
67
- affordance -- the reachable share is the named link inside the viewer.
68
- Sized in the tile's own coordinate space, so it scales with the photo. */
69
- .image-share { position: absolute; z-index: 1; right: 5px; top: 5px; display: grid; place-items: center; width: 22px; height: 22px; border-radius: 6px; background: #0d0f0bb8; -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); color: #e6ebe0; opacity: 0; transform: scale(.82); transition: opacity 160ms ease, transform 160ms ease, background 160ms ease, color 160ms ease; }
70
- .image-share svg { width: 13px; height: 13px; }
71
- .image-share:hover { background: #0d0f0be6; color: #ffffff; }
72
- .image-share[hidden] { display: none; }
73
- .image-tile:hover .image-share, .image-tile:focus-within .image-share { opacity: 1; transform: scale(1); }
74
- /* Top-ranked results are the ones most likely to be sent, so theirs show the
75
- button without waiting for hover. */
76
- .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); }
77
- @media (prefers-reduced-motion: reduce) { .image-share { transition: opacity 120ms linear; } }
78
- /* Relevance is a soft per-photo treatment, not a second results layer.
79
- * neon-frame.css owns the one exception -- a ringed tile is never dimmed --
80
- * because that is a property of the ring, not of the fade. */
81
  .image-tile { opacity: var(--relevance-opacity, 1); filter: blur(var(--relevance-blur, 0px)); transition: opacity 650ms ease, filter 650ms ease; }
82
  .image-tile:hover, .image-tile:focus-visible { opacity: 1; filter: blur(0); }
83
- /*
84
- Lift the hovered tile above its neighbours.
85
- Required for the share button to be usable at all. Tiles overlap by design and
86
- each tile's own z-index is a stable seed of its id (ImageField.jsx sets 1..100
87
- from a hash), not its search rank -- so a tile stays buried under whichever
88
- neighbour hashed higher, permanently. Measured on the idle field: of 79
89
- in-viewport share buttons, 0 were the topmost element at their own centre --
90
- 13 were covered by another tile's photograph, 8 by another tile outright, and
91
- the rest hit their own icon. There is no way to express "raise me above my
92
- neighbours" for the button alone, because the button cannot be hovered until
93
- it is on top. Scoped to .image-tile:hover, which also covers :focus-within, so
94
- keyboard users reach the same affordance.
95
- */
96
- .image-tile:hover, .image-tile:focus-within { z-index: 2147483647; }
97
  .vignette { position: absolute; inset: 0; pointer-events: none; z-index: 300; background: radial-gradient(ellipse at center, transparent 42%, #0c0d0b30 100%), linear-gradient(0deg, #10110f99, transparent 70px); }
98
  .grain { position: absolute; inset: 0; 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"); }
99
  .search-center { position: absolute; z-index: 400; width: clamp(350px, 36vw, 640px); max-width: calc(100% - 44px); left: 50%; top: 50%; transform: translate(-50%, -50%); }
@@ -105,35 +29,6 @@ button:focus-visible, a:focus-visible { outline: 2px solid #d7e1d1; outline-offs
105
  .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; }
106
  .clear-button:hover { background: #0000000d; }
107
  .clear-button svg { width: 18px; height: 18px; }
108
-
109
- /* ── The neon frame lives in src/neon-frame.css ─────────────────────────────
110
- * Imported at the top of this file rather than kept inline, for one reason: it
111
- * also has to ship as dist/assets/neon-frame.css, which index.html loads after
112
- * the built app.css.
113
- *
114
- * The Space is `sdk: static`, so dist/ -- not src/ -- is what the browser gets.
115
- * Until the bundle is rebuilt from source, that built app.css still carries the
116
- * old `@keyframes neon-run { transform: rotate(1turn) }`, which rotated the
117
- * ring's mask along with its gradient and pulled the painted border off the
118
- * tile's edges. The override file is the fix that does not need a rebuild; this
119
- * import is what stops the next rebuild from dropping it.
120
- *
121
- * One definition, two delivery routes. They are kept in step deliberately: the
122
- * override is inert once the build includes these rules, rather than the two
123
- * quietly describing different effects.
124
- */
125
-
126
- /* ── Search status, footer, and the full-screen viewer ────────────────────
127
- * Restored wholesale. The previous edit removed this whole tail by accident
128
- * while stripping the size/position controls, which left every one of these
129
- * classes unstyled: opening a photo rendered a raw <dialog> with its default
130
- * UA box, the share toolbar stacked vertically as plain text, and the error
131
- * state had no card behind it. The controls were 12 selectors; what went with
132
- * them was everything after them in the file.
133
- */
134
- /* The progress hairline along the bottom of the pill. Restored with the rest
135
- of the tail -- without it the loading bar rendered as a plain block and the
136
- pill gained a visible box while the CLIP model downloaded. */
137
  .search-progress { position: absolute; bottom: 0; left: 24px; right: 24px; height: 1px; opacity: 0; overflow: hidden; transition: opacity 300ms; }
138
  .search-progress.visible { opacity: .5; }
139
  .search-progress span { display: block; height: 100%; background: #47513f; transition: width 300ms; animation: progress-glow 1.6s ease-in-out infinite; }
@@ -175,15 +70,10 @@ button:focus-visible, a:focus-visible { outline: 2px solid #d7e1d1; outline-offs
175
  (share, caption, close) under justify-content: space-between, so the caption
176
  is pushed to the middle and the share button to the leading edge. */
177
  .photo-share { display: inline-flex; align-items: center; gap: 9px; white-space: nowrap; }
178
- /* The label is the accessible name, and it doubles as the confirmation channel:
179
- "Đã chia sẻ" appears here for 1.8s after a share. So the span must be
180
- visually hidden, not display:none -- display:none takes the text out of the
181
- accessibility tree too, leaving an icon-only button with no name at all, and
182
- a phone-width button whose confirmation the reader can never see. */
183
  .photo-share svg { width: 17px; height: 17px; flex: none; }
184
  .photo-share:hover { background: #1b2115f2; border-color: #ffffff2e; color: #fff; }
185
  @media (max-width: 560px) {
186
- .photo-share span { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
187
  .photo-share { padding: 12px; }
188
  }
189
  .photo-toolbar svg { width: 22px; height: 22px; }
@@ -210,24 +100,6 @@ button:focus-visible, a:focus-visible { outline: 2px solid #d7e1d1; outline-offs
210
  .photo-step.is-next { right: 8px; }
211
  }
212
 
213
- /* Date chip in the time-navigation counter. Only visible when paging through
214
- the interior list by addedAt: newest → oldest. */
215
- .photo-counter .photo-date {
216
- display: inline-flex;
217
- margin-left: 8px;
218
- padding: 1px 7px;
219
- font-size: 10px;
220
- font-weight: 600;
221
- letter-spacing: .06em;
222
- color: #9fd6a4;
223
- background: #2a36227a;
224
- border: 1px solid #4a6b3a33;
225
- border-radius: 5px;
226
- }
227
- @media (max-width: 650px) {
228
- .photo-counter .photo-date { display: none; }
229
- }
230
-
231
  /* Product caption inside the viewer: the same fields the detail card shows, so
232
  a photo opened from the product strip carries its context with it. */
233
  .photo-caption { position: absolute; z-index: 2; left: 24px; right: 24px; bottom: 18px; max-width: min(560px, calc(100vw - 48px)); padding: 12px 16px; border: 1px solid #ffffff17; border-radius: 10px; background: #0f130ccc; -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); }
@@ -242,37 +114,6 @@ button:focus-visible, a:focus-visible { outline: 2px solid #d7e1d1; outline-offs
242
  .photo-counter { display: none; }
243
  }
244
 
245
- /* Detail card inside the viewer, for interiors and products alike: title,
246
- description, room/topic tags, fact table and a link to the source article --
247
- none of which the viewer showed before. Collapsed by default so the
248
- photograph is the first impression on a phone. */
249
- .photo-detail { position: absolute; z-index: 2; left: 24px; bottom: 18px; width: min(460px, calc(100vw - 48px)); overflow: hidden; border: 1px solid #ffffff1f; border-radius: 12px; background: #0f130ce8; -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); }
250
- .photo-detail-summary { display: flex; align-items: center; gap: 10px; padding: 11px 14px; cursor: pointer; list-style: none; -webkit-user-select: none; user-select: none; }
251
- .photo-detail-summary::-webkit-details-marker { display: none; }
252
- .photo-detail-summary:hover { background: #ffffff0a; }
253
- .photo-detail-title { flex: 1; min-width: 0; overflow: hidden; font-size: 14px; line-height: 1.35; color: #e4e9dc; text-overflow: ellipsis; white-space: nowrap; }
254
- .photo-detail-price { flex: none; font-size: 13px; font-weight: 600; color: #9fd6a4; font-variant-numeric: tabular-nums; }
255
- .photo-detail-toggle { flex: none; padding: 3px 9px; font-size: 11px; color: #99a291; text-transform: uppercase; letter-spacing: .05em; border: 1px solid #ffffff1f; border-radius: 999px; transition: color 180ms; }
256
- .photo-detail[open] .photo-detail-toggle { color: #cdd6c4; border-color: #ffffff33; }
257
- .photo-detail-body { padding: 0 14px 13px; border-top: 1px solid #ffffff12; }
258
- .photo-detail-desc { margin: 11px 0 0; font-size: 13px; line-height: 1.6; color: #c3cabb; }
259
- .photo-detail-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 11px 0 0; padding: 0; list-style: none; }
260
- .photo-detail-tags li { padding: 3px 9px; font-size: 11px; color: #b6c2ab; background: #ffffff0a; border: 1px solid #ffffff17; border-radius: 999px; }
261
- .photo-detail-facts { display: grid; gap: 4px; margin: 11px 0 0; font-size: 12px; }
262
- .photo-detail-facts > div { display: grid; grid-template-columns: minmax(0, 38%) minmax(0, 62%); gap: 10px; }
263
- .photo-detail-facts dt { color: #939d8a; overflow-wrap: anywhere; }
264
- .photo-detail-facts dd { margin: 0; color: #d5dbcd; overflow-wrap: anywhere; }
265
- /* The outbound "Xem bài gốc" link is gone from the panel; see PhotoViewer.
266
- No replacement rule -- the source site now appears only as a "Nguồn" row in
267
- the fact table. */
268
- /* When the price/spec caption is also on screen, the detail card moves to the
269
- top instead: both are anchored bottom-left and would collide. */
270
- .photo-viewer:has(.photo-caption) .photo-detail { top: 76px; bottom: auto; }
271
- @media (max-width: 650px) {
272
- .photo-detail { left: 12px; right: 12px; bottom: 12px; width: auto; }
273
- .photo-viewer:has(.photo-caption) .photo-detail { top: 70px; }
274
- }
275
-
276
  /* Malloca product strip. Sits above the vignette so products stay legible
277
  over the photograph field, and below the search pill's stacking level. */
278
  @media (max-width: 650px) {
 
1
  @import 'tailwindcss';
 
2
 
3
  :root { font-family: Arial, Helvetica, sans-serif; color: #deded7; background: #10110f; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; }
4
  * { box-sizing: border-box; }
 
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; }
14
+ .image-tile img { width: 100%; height: 100%; object-fit: cover; opacity: 0; filter: brightness(.92); transition: opacity 120ms ease, filter 120ms ease; }
 
 
 
 
15
  .image-tile img.loaded { opacity: 1; }
16
  .image-tile:hover { box-shadow: 0 10px 35px #0005; }
17
  .image-tile:hover img { filter: brightness(1); }
18
+ /* Relevance is a soft per-photo treatment, not a second results layer. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  .image-tile { opacity: var(--relevance-opacity, 1); filter: blur(var(--relevance-blur, 0px)); transition: opacity 650ms ease, filter 650ms ease; }
20
  .image-tile:hover, .image-tile:focus-visible { opacity: 1; filter: blur(0); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  .vignette { position: absolute; inset: 0; pointer-events: none; z-index: 300; background: radial-gradient(ellipse at center, transparent 42%, #0c0d0b30 100%), linear-gradient(0deg, #10110f99, transparent 70px); }
22
  .grain { position: absolute; inset: 0; 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"); }
23
  .search-center { position: absolute; z-index: 400; width: clamp(350px, 36vw, 640px); max-width: calc(100% - 44px); left: 50%; top: 50%; transform: translate(-50%, -50%); }
 
29
  .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; }
30
  .clear-button:hover { background: #0000000d; }
31
  .clear-button svg { width: 18px; height: 18px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  .search-progress { position: absolute; bottom: 0; left: 24px; right: 24px; height: 1px; opacity: 0; overflow: hidden; transition: opacity 300ms; }
33
  .search-progress.visible { opacity: .5; }
34
  .search-progress span { display: block; height: 100%; background: #47513f; transition: width 300ms; animation: progress-glow 1.6s ease-in-out infinite; }
 
70
  (share, caption, close) under justify-content: space-between, so the caption
71
  is pushed to the middle and the share button to the leading edge. */
72
  .photo-share { display: inline-flex; align-items: center; gap: 9px; white-space: nowrap; }
 
 
 
 
 
73
  .photo-share svg { width: 17px; height: 17px; flex: none; }
74
  .photo-share:hover { background: #1b2115f2; border-color: #ffffff2e; color: #fff; }
75
  @media (max-width: 560px) {
76
+ .photo-share span { display: none; }
77
  .photo-share { padding: 12px; }
78
  }
79
  .photo-toolbar svg { width: 22px; height: 22px; }
 
100
  .photo-step.is-next { right: 8px; }
101
  }
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  /* Product caption inside the viewer: the same fields the detail card shows, so
104
  a photo opened from the product strip carries its context with it. */
105
  .photo-caption { position: absolute; z-index: 2; left: 24px; right: 24px; bottom: 18px; max-width: min(560px, calc(100vw - 48px)); padding: 12px 16px; border: 1px solid #ffffff17; border-radius: 10px; background: #0f130ccc; -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); }
 
114
  .photo-counter { display: none; }
115
  }
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  /* Malloca product strip. Sits above the vignette so products stay legible
118
  over the photograph field, and below the search pill's stacking level. */
119
  @media (max-width: 650px) {
src/layout.js CHANGED
@@ -5,121 +5,37 @@ export function seed(text) {
5
  return (hash >>> 0) / 4294967295;
6
  }
7
 
8
- /**
9
- * How many photographs the field holds.
10
- *
11
- * 150 on desktop / 96 on a phone, up from 96 / 56. The raise is deliberate: the
12
- * featured tiles are now 1.5x larger, so they occupy noticeably more of the
13
- * plane than before, and at the old counts the gaps between them read as empty
14
- * space rather than as a dense field. More ordinary tiles fills that space
15
- * without touching the emphasis -- the newest photographs still ring, and there
16
- * are still only ten of those.
17
- *
18
- * A phone is raised less than a desktop because tile AREA is bounded by the
19
- * viewport, not by the tile count: past ~100 the extra tiles fall below their own
20
- * thumbnail size and the field turns to grain.
21
- */
22
- export function fieldCount(width) { return width < 650 ? 96 : 150; }
23
 
24
- /**
25
- * Tile size bounds for the user-facing size control.
26
- *
27
- * Below the lower bound the tiles stop being recognisable as photographs, and
28
- * above the upper bound a single tile covers the search pill and most of the
29
- * viewport, so the field stops reading as a collection at all.
30
- */
31
- export const MIN_TILE_SCALE = 0.6;
32
- export const MAX_TILE_SCALE = 1.8;
33
-
34
- /**
35
- * How far the whole plane may be pushed, in px, before it stops being
36
- * reachable. Larger than half the shortest likely viewport, so a photographer
37
- * can be moved out from under the search pill without being lost off-screen.
38
- */
39
- export const MAX_FIELD_OFFSET = 320;
40
-
41
- export function clampTileScale(value) {
42
- const scale = Number(value);
43
- return Number.isFinite(scale) ? Math.max(MIN_TILE_SCALE, Math.min(MAX_TILE_SCALE, scale)) : 1;
44
- }
45
-
46
- export function clampFieldOffset(value) {
47
- const offset = Number(value);
48
- return Number.isFinite(offset) ? Math.max(-MAX_FIELD_OFFSET, Math.min(MAX_FIELD_OFFSET, offset)) : 0;
49
- }
50
-
51
- /**
52
- * Size one tile.
53
- *
54
- * `tileScale` is the user's size multiplier and is applied to `unit` BEFORE the
55
- * per-photograph variance, so every tile grows and shrinks together and the
56
- * collection keeps its rhythm instead of the biggest photographs absorbing the
57
- * whole change.
58
- */
59
- export function dimensions(image, viewportWidth, tileScale = 1) {
60
- const unit = Math.max(68, Math.min(168, viewportWidth / 10.25)) * tileScale;
61
  const area = unit * unit * (0.74 + seed(image.id + 'size') * 0.48);
62
  const ar = Math.max(0.5, Math.min(2, image.ar));
63
  return { width: Math.sqrt(area * ar), height: Math.sqrt(area / ar) };
64
  }
65
 
66
- export function scatter(image, index, count, width, height, offsetX = 0, offsetY = 0) {
67
  const columns = Math.ceil(Math.sqrt(count * width / height));
68
  const rows = Math.ceil(count / columns);
69
  return {
70
- x: -35 + (index % columns + 0.5 + (seed(image.id + 'x') - 0.5) * 0.44) * (width + 70) / columns + offsetX,
71
- y: -40 + (Math.floor(index / columns) + 0.5 + (seed(image.id + 'y') - 0.5) * 0.48) * (height + 80) / rows + offsetY,
72
  scale: 0.88 + seed(image.id + 'scale') * 0.20,
73
  };
74
  }
75
 
76
  /** One continuous layout for the whole collection, including non-matching photos. */
77
- export function fieldPosition(image, rank, count, width, height, offsetX = 0, offsetY = 0) {
78
  const angle = rank * 2.399963229728653 + 0.54;
79
  const radius = Math.sqrt((rank + 0.6) / count);
80
- // 0.62 instead of 0.68: the top results hug the centre -- and therefore the
81
- // search pill -- more tightly, so the answer to a query sits visibly NEAR
82
- // the box the visitor typed in, not pushed out towards the field's rim.
83
- const x = width / 2 + Math.cos(angle) * radius * width * 0.62;
84
- let y = Math.sin(angle) * radius * height * 0.66;
85
  // Ease the central photographs around the pill, without a separate results layer.
86
- //
87
- // Clearance has to account for the TILE, not just its centre. A featured tile
88
- // is 1.5x its neighbours, so a distance tuned to clear an ordinary tile still
89
- // lets a featured corner land under the pill. `dimensions()` is therefore
90
- // called here for the real half-height, and the push grows with it.
91
  const clearance = Math.max(0, 1 - Math.abs(x - width / 2) / (width * 0.31));
92
- // The tile is scaled by `scale` on top of its CSS size, and that scale reaches
93
- // 1.32 for a central featured tile -- so the clearance has to be computed from
94
- // the RENDERED half-extent. Measuring the unscaled box let a 289px tile clear
95
- // the pill by 221px and still land on it.
96
- const scale = 0.96 + seed(image.id + 'scale') * 0.18 + (1 - radius) * 0.18;
97
- const size = dimensions(image, width, image?.featuredScale || 1);
98
- const half = Math.max(size.width, size.height) / 2 * scale;
99
- // PILL_HALF is the search box's own half-width plus margin, so "far enough" is
100
- // measured from the box rather than from its centreline. The 22px margin
101
- // (previously 34) lets a result settle closer to the box while still clearing
102
- // the glass itself.
103
- const pillHalf = (width < 650 ? 165 : 235) + 22;
104
- // 80/118 was a generous shove; 74/96 keeps the same shape but brings the arc
105
- // in so the top results read as "attached" to the pill rather than banished.
106
- y += Math.sign(y || 1) * clearance * (width < 650 ? 60 : 92) * (1 - radius * 0.45);
107
- // If the tile's own rendered box could still reach the pill, push it clear along
108
- // y. Both conditions must hold: the tile is near the pill's row AND its column
109
- // overlaps the pill's width. Only genuinely-overlapping tiles move, so the arc
110
- // keeps its shape and tiles already clear are left alone.
111
- if (clearance > 0
112
- && Math.abs(y) < half + 40
113
- && Math.abs(x - width / 2) < pillHalf + half) {
114
- y += Math.sign(y || 1) * (half + 40 - Math.abs(y));
115
- }
116
  return {
117
- x: x + offsetX, y: height / 2 + y + offsetY,
118
- // The same `scale` the clearance was computed from. Recomputing it here
119
- // would be a second, independently-evaluated expression for one number, and
120
- // the clearance would silently stop matching what is drawn if the two ever
121
- // drifted apart.
122
- scale,
123
  };
124
  }
125
 
 
5
  return (hash >>> 0) / 4294967295;
6
  }
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;
32
+ let y = Math.sin(angle) * radius * height * 0.70;
 
 
 
33
  // Ease the central photographs around the pill, without a separate results layer.
 
 
 
 
 
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
  }
41
 
src/merge.js CHANGED
@@ -1,47 +1,77 @@
1
  /**
2
  * Fold Malloca products into the interior ranking, so both kinds of photograph
3
  * occupy the SAME field and travel the same path once opened.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  */
5
 
6
  /** A product has to be worth showing, but not so many that interiors vanish. */
7
- const PRODUCT_LIMIT = 24;
8
 
9
  /** Insert product `i` after roughly (i+1) * STRIDE interiors. */
10
  const STRIDE = 2;
11
 
12
- /** De-duplication keys, one namespace per kind of record. */
 
 
 
 
 
 
 
 
13
  const interiorKey = id => `interior:${id}`;
14
  const productKey = sku => `malloca:${sku}`;
15
 
16
- import { exactMatches, hasDecisiveWinner } from './relevance.js';
17
- import { FEATURED_LIMIT } from './avatar-interiors.js';
18
- import { normalizeText } from './catalogue.js';
19
- import { productLikeTerms, isSpaceQuery } from './semantic-terms.js';
20
-
21
- const NAME_WORD_SYNONYMS = { ke: ['gia'], gia: ['ke'] };
22
-
23
- const DEAD_IMAGE_DIRS = [
24
- '/eurogold_img/',
25
- '/eurogold_tb_img/',
26
- '/demax_img/',
27
- '/canzy_img/',
28
- '/images/',
29
- ];
30
-
31
- function isDeadImageUrl(url) {
32
- if (typeof url !== 'string' || !url) return false;
33
- if (!/huggingface\.co\/spaces\/bep40\/v\.aistudio/i.test(url)) return false;
34
- const lower = url.toLowerCase();
35
- return DEAD_IMAGE_DIRS.some(dir => lower.includes(dir));
36
- }
37
-
38
  function leadImage(product) {
39
  for (const value of product?.images ?? []) {
40
- if (typeof value === 'string' && /^(https?:\/\/|data:image\/)/i.test(value) && !isDeadImageUrl(value)) return value;
41
  }
42
  return '';
43
  }
44
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  function tileDefaults(product) {
46
  return {
47
  id: productKey(product.sku),
@@ -49,6 +79,20 @@ function tileDefaults(product) {
49
  };
50
  }
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  function assignRelevance(list) {
53
  const interiorAt = i => (i >= 0 && i < list.length && typeof list[i].score === 'number' ? i : -1);
54
  const floor = list.reduce((min, r) => (Number.isFinite(r.score) ? Math.min(min, r.score) : min), Infinity);
@@ -65,170 +109,42 @@ function assignRelevance(list) {
65
  return list;
66
  }
67
 
 
 
 
 
 
 
 
 
68
  function unwrap(item) {
69
  if (!item || typeof item !== 'object') return null;
70
  if (item.product && typeof item.product === 'object') return item.product;
71
  return item;
72
  }
73
 
74
- const avatarInteriorKey = id => `avatar-interior:${id}`;
75
-
76
- function plainTile(record) {
77
- return {
78
- ...record,
79
- ...tileDefaults(record),
80
- id: avatarInteriorKey(record.avatarImageId),
81
- score: Number.isFinite(record.score) ? record.score : 1,
82
- };
83
- }
84
-
85
- function featuredTile(record, scale = 1.5) {
86
- return {
87
- ...record,
88
- ...tileDefaults(record),
89
- id: avatarInteriorKey(record.avatarImageId),
90
- featured: true,
91
- featuredScale: scale,
92
- score: Number.isFinite(record.score) ? record.score : 1,
93
- };
94
- }
95
-
96
  export function mergeResults(interiors, products, options = {}) {
97
  const productLimit = options.productLimit ?? PRODUCT_LIMIT;
98
  const interiorLimit = options.interiorLimit ?? 60;
99
 
100
  const seen = new Set();
101
  const out = [];
102
-
103
- const fresh = [];
104
- const ordered = (options.avatarInteriors ?? [])
105
- .filter(item => item && typeof item === 'object' && item.avatarImageId)
106
- .sort((a, b) => (a.addedAt < b.addedAt ? 1 : a.addedAt > b.addedAt ? -1 : 0));
107
-
108
- const featuredCount = options.featuredLimit ?? FEATURED_LIMIT;
109
- for (const [index, item] of ordered.entries()) {
110
- const key = avatarInteriorKey(item.avatarImageId);
111
- if (seen.has(key)) continue;
112
- seen.add(key);
113
- fresh.push(index < featuredCount ? featuredTile(item, options.avatarScale ?? 1.5) : plainTile(item));
114
- }
115
-
116
- const RING_LIMIT = options.recentProductRing ?? 12;
117
- const showNewRow = String(options.query ?? "").trim().length === 0;
118
- const recents = showNewRow
119
- ? (options.recentProducts ?? [])
120
- .filter(item => item && typeof item === 'object' && item.sku && !seen.has(productKey(item.sku)))
121
- .sort((a, b) => (String(a.addedAt || '') < String(b.addedAt || '') ? 1 : String(a.addedAt || '') > String(b.addedAt || '') ? -1 : 0))
122
- : [];
123
- const freshProducts = [];
124
- for (const [index, item] of recents.entries()) {
125
- const src = leadImage(item);
126
- if (!src) continue;
127
- const key = productKey(item.sku);
128
- if (seen.has(key)) continue;
129
- seen.add(key);
130
- const tile = {
131
- ...item,
132
- ...tileDefaults(item),
133
- src,
134
- score: Number.isFinite(item.score) ? item.score : 1,
135
- featured: true,
136
- featuredScale: options.recentScale ?? 1.45,
137
- };
138
- freshProducts.push(tile);
139
- if (freshProducts.length >= RING_LIMIT) break;
140
- }
141
- for (let i = freshProducts.length - 1; i >= 0; i--) fresh.unshift(freshProducts[i]);
142
- // `fresh` là avatar interiors (mới nhất + những ảnh khớp query). Luôn đưa vào
143
- // field — trước đây chỉ đưa khi KHÔNG có query (showNewRow), nên một ảnh mới
144
- // đăng trên vai-avatar có caption khớp "nhà phố" không bao giờ hiện khi tìm
145
- // "nhà phố". Từ khóa query vẫn được tôn trọng: avatarInteriors đi qua
146
- // searchAvatarInteriors ở App.jsx rồi mới tới đây, nên chỉ ảnh khớp xuất hiện.
147
- out.unshift(...fresh);
148
-
149
- const KEYWORD_FLOOR = options.keywordFloor ?? 300;
150
- const productAnswerItems = (products ?? []).filter(item => {
151
- const product = unwrap(item);
152
- const src = leadImage(product);
153
- return product?.sku && src;
154
- });
155
-
156
- const queryTokens = extractQueryTokens(options.query);
157
- const productTokens = productLikeTerms(queryTokens);
158
-
159
- const decisive = hasDecisiveWinner((products ?? []).map(hit => Number.isFinite(hit?.score) ? hit.score : 0));
160
- const hasProductAnswer = productTokens.length >= 2
161
- && productAnswerItems.some(item => {
162
- const product = unwrap(item);
163
- const score = Number.isFinite(item?.score) ? item.score : 0;
164
- if (score < KEYWORD_FLOOR) return false;
165
- const name = normalizeText(product.name || '');
166
- return productTokens.every(token => {
167
- if (wordIn(name, token)) return true;
168
- const synonyms = NAME_WORD_SYNONYMS[token];
169
- return !!synonyms && synonyms.some(alias => wordIn(name, alias));
170
- });
171
- });
172
- /**
173
- * Truy vấn THUẦN NGỮ NGHĨA thì không bao giờ crowd-out ảnh interiors.
174
- *
175
- * "bàn ghế" có thể khiến scorer dựng một sản phẩm "Bàn ghế gỗ óc chó" đạt
176
- * 929 điểm (300 tất cả token trong tên + 100 tên chứa query + 2×16 title +
177
- * 35 hits + ...) — vượt ngưỡng identity 700 dù KHÔNG phải trùng SKU/tên
178
- * chính xác — và hasDecisiveWinner gỡ sạch ảnh khỏi field. Nhưng "bàn ghế",
179
- * "nội thất", "phòng khách", "nhà phố" là câu hỏi về KHÔNG GIAN: người gõ
180
- * chúng muốn xem ẢNH trước. Chỉ khi query mang token sản phẩm thật sự
181
- * (productTokens.length > 0: "kệ xoong", "GP170BD", "máy hút mùi") thì
182
- * identity/decisive mới được gỡ ảnh.
183
- */
184
- const spaceQuery = isSpaceQuery(options.query);
185
- const pureSemanticQuery = productTokens.length === 0;
186
- if (!pureSemanticQuery && (decisive || hasProductAnswer)) interiors = [];
187
-
188
- /**
189
- * Truy vấn KHÔNG GIAN thì không trả sản phẩm "vô tình trùng token".
190
- *
191
- * "phòng khách" làm scoreProduct() dựng 24 sản phẩm khóa cửa "dùng cho cửa
192
- * phòng ngủ", tay nắm "phòng tắm", vòi rửa "phong cách Nhật" — mỗi cái ghim
193
- * một từ lẻ ("khach", "phong") trong tên/spec dài. Chúng không phải là câu
194
- * trả lời cho "phòng khách", và chúng cướp chỗ của ảnh nội thất thật.
195
- *
196
- * Quy tắc: với space-query, một sản phẩm chỉ được giữ nếu TÊN của nó chứa
197
- * ít nhất một token "đặc tả" của query — tức token không phải từ ngữ nghĩa
198
- * ("sofa phòng khách" → giữ sofa vì tên chứa "sofa"; "phòng ngủ" thuần
199
- * không-gian → không SP, field là 100% ảnh). Trúng SKU/model chính xác
200
- * (decisive, xử lý ở scoredHits qua isExact) vẫn luôn được giữ.
201
- */
202
- // Sản phẩm chỉ được ở lại nếu tên chứa một token đặc tả thật sự của query.
203
- function staysInSpaceQuery(product) {
204
- if (!spaceQuery || !product?.name) return true;
205
- const name = normalizeText(product.name);
206
- return productTokens.some(token => wordIn(name, token));
207
- }
208
-
209
- const cosineFloor = options.cosFloor ?? 0.18;
210
- const cosineRel = options.cosRel ?? 0.45;
211
- let topCosine = 0;
212
  for (const image of interiors ?? []) {
213
- if (Number.isFinite(image?.score) && image.score > topCosine) topCosine = image.score;
214
- }
215
- const effectiveFloor = Math.max(cosineFloor, topCosine * cosineRel);
216
- for (const image of interiors ?? []) {
217
- const score = image?.score;
218
- const filteredScore = score === undefined || !Number.isFinite(score) || score >= effectiveFloor;
219
- if (!image?.id || !filteredScore || seen.has(interiorKey(image.id))) continue;
220
  seen.add(interiorKey(image.id));
221
  out.push(image);
222
  if (out.length >= interiorLimit) break;
223
  }
224
 
225
  const hits = [];
226
- const exact = exactMatches(interiors ?? [], options.query ?? '', products ?? []);
227
- const keywordFeatured = options.keywordFeatured ?? productLimit;
228
-
229
- const scoredHits = [];
230
- const isProductQuery = productTokens.length > 0;
231
- const productFloor = options.productFloor ?? (isProductQuery ? 20 : 120);
232
  for (const item of products ?? []) {
233
  const product = unwrap(item);
234
  const src = leadImage(product);
@@ -236,64 +152,17 @@ export function mergeResults(interiors, products, options = {}) {
236
  const key = productKey(product.sku);
237
  if (seen.has(key)) continue;
238
  seen.add(key);
239
- const isExact = exact.has(key);
240
- const score = Number.isFinite(item?.score) ? item.score : 0;
241
- if (score !== 0 && score < productFloor) continue;
242
- // Space query: đừng để sản phẩm "trùng bừa một từ" chiếm field (xem
243
- // staysInSpaceQuery). Trúng SKU/model chính xác vẫn giữ — isExact là kết
244
- // quả của `exactMatches`, chỉ set khi có decisive winner >= 700.
245
- if (!isExact && !staysInSpaceQuery(product)) continue;
246
- scoredHits.push({ product, src, key, isExact, score });
247
- if (scoredHits.length >= productLimit) break;
248
  }
249
- const freshAdded = options.freshAdded ?? 4;
250
- scoredHits.sort((a, b) => b.score - a.score);
251
- const freshOrdered = scoredHits
252
- .filter(hit => {
253
- const at = hit.product?.addedAt;
254
- return typeof at === 'string' ? at.length > 0 : Number.isFinite(at) && at > 0;
255
- })
256
- .sort((a, b) => String(b.product?.addedAt || '').localeCompare(String(a.product?.addedAt || '')));
257
- const freshSkus = new Set(freshOrdered.slice(0, freshAdded).map(hit => hit.product.sku));
258
- scoredHits.forEach((hit, rank) => {
259
- // Products never ring (neon) on semantic queries (ban, ghe, noi that, nha
260
- // pho, biet thu): the ring says "this IS the photo you asked for", and a
261
- // kitchen accessory thumbnail next to a semantic query would say the
262
- // opposite. Only real photographs (CLIP interiors, fresh vai-avatar shots)
263
- // get the ring; decisive exact SKUs still ring.
264
- const isSemanticQuery = productTokens.length === 0;
265
- const featuredByKeyword = hit.isExact
266
- || (rank < keywordFeatured && hit.score >= KEYWORD_FLOOR && !isSemanticQuery);
267
- const fresh = freshSkus.has(hit.product.sku);
268
- const featured = hit.isExact || featuredByKeyword || fresh;
269
- const scale = hit.isExact ? (options.exactScale ?? 1.55)
270
- : (fresh ? (options.freshScale ?? 1.45) : (options.keywordScale ?? 1.4));
271
- hits.push({
272
- ...hit.product,
273
- ...tileDefaults(hit.product),
274
- src: hit.src,
275
- ...(featured ? { featured: true, featuredScale: scale } : {}),
276
- });
277
- });
278
  if (!hits.length) return out;
279
 
280
- const featuredHits = hits.filter(hit => hit.featured);
281
- const restHits = hits.filter(hit => !hit.featured);
282
- for (let i = featuredHits.length - 1; i >= 0; i--) out.splice(i, 0, featuredHits[i]);
283
- for (let i = restHits.length - 1; i >= 0; i--) {
284
- out.splice(Math.min(out.length, (i + 1) * STRIDE), 0, restHits[i]);
 
285
  }
286
  return assignRelevance(out);
287
  }
288
-
289
- function extractQueryTokens(query) {
290
- const text = normalizeText(String(query || '').trim());
291
- if (!text) return [];
292
- return text.split(/\s+/).filter(token => token.length > 1);
293
- }
294
-
295
- function wordIn(text, token) {
296
- if (!text || !token) return false;
297
- const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
298
- return new RegExp(`(^|[^a-z0-9])${escaped}($|[^a-z0-9])`).test(text);
299
- }
 
1
  /**
2
  * Fold Malloca products into the interior ranking, so both kinds of photograph
3
  * occupy the SAME field and travel the same path once opened.
4
+ *
5
+ * ── Why this is done by interleaving, not by embedding the products ──
6
+ * A product image could in principle be pushed through the same CLIP image
7
+ * tower as the interiors and baked into the 512-d matrix. That was rejected
8
+ * deliberately:
9
+ *
10
+ * * The product set is written by vai-avatar at runtime, and a baked matrix
11
+ * can only know what existed when it was built, so "Thêm SP" would stay
12
+ * invisible until somebody re-ran an embed over the whole catalogue.
13
+ * * There is no image tower in the pinned model repo to do it with: it ships
14
+ * `text_model.onnx` and nothing else, so encoding product photos would need
15
+ * a second model and a second download.
16
+ *
17
+ * The catalogue is small and live (a few hundred products, not thousands), so
18
+ * ranking it lexically per query is cheap. Its hits are then woven into the
19
+ * interior ranking by rank position, which is what makes a product appear
20
+ * among the interior photographs instead of in a separate bar.
21
+ *
22
+ * ── Why the interleaving is positional and not append-only ──
23
+ * Appending products after the interiors would make "bếp" show thirty kitchen
24
+ * photographs and then the ovens, which reads as a second result list rather
25
+ * than one field. Spacing them by rank puts each product roughly where its
26
+ * strength justifies it, so a query that matches both kinds of image produces
27
+ * one continuous, plausibly-ordered plane.
28
  */
29
 
30
  /** A product has to be worth showing, but not so many that interiors vanish. */
31
+ const PRODUCT_LIMIT = 12;
32
 
33
  /** Insert product `i` after roughly (i+1) * STRIDE interiors. */
34
  const STRIDE = 2;
35
 
36
+ /**
37
+ * De-duplication keys, one namespace per kind of record.
38
+ *
39
+ * Sharing a single set would mean an interior id and a product key competing
40
+ * for the same slot, so any collision silently DROPS a product from the field
41
+ * with no error. Tagging both sides removes the possibility rather than
42
+ * narrowing it: interior ids are opaque strings, and it is not the caller's
43
+ * job to know they happen to be 10 hex characters today.
44
+ */
45
  const interiorKey = id => `interior:${id}`;
46
  const productKey = sku => `malloca:${sku}`;
47
 
48
+ /**
49
+ * The image that represents a product in the field.
50
+ *
51
+ * Only absolute http(s) and inline data URLs are kept. Anything else -- a
52
+ * relative path, a blob, an object -- cannot be rendered by an <img> here, and
53
+ * a tile that renders nothing is worse than a product that stays in the
54
+ * catalogue and is reachable through search.
55
+ */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  function leadImage(product) {
57
  for (const value of product?.images ?? []) {
58
+ if (typeof value === 'string' && /^(https?:\/\/|data:image\/)/i.test(value)) return value;
59
  }
60
  return '';
61
  }
62
 
63
+ /**
64
+ * Product photographs are catalogue shots, not interiors, so they arrive with
65
+ * none of the geometry the field needs. Two defaults stand in for it:
66
+ *
67
+ * id -- every tile is keyed on it, and the product's own sku is the natural
68
+ * stable identity. Layout seeds (position, size, phase) are derived
69
+ * from it, so a product keeps the same place and scale across
70
+ * searches instead of jumping when it re-ranks.
71
+ * ar -- dimensions() clamps this to [0.5, 2]. Without a value it computes
72
+ * NaN and the tile collapses to no size at all. Product shots are
73
+ * square-ish, so 1 is the honest default.
74
+ */
75
  function tileDefaults(product) {
76
  return {
77
  id: productKey(product.sku),
 
79
  };
80
  }
81
 
82
+ /**
83
+ * Give every product the visual prominence of the photograph it sits beside.
84
+ *
85
+ * This is load-bearing rather than cosmetic. relevanceAppearance() dims and
86
+ * blurs any tile whose `score` is not a finite number -- it is written for
87
+ * cosine results -- so a product merged in WITHOUT a score would come out at
88
+ * 14% opacity and blurred, i.e. effectively invisible in the field. Borrowing
89
+ * the neighbouring interior score makes a product look exactly as relevant as
90
+ * the photograph next to it, which is the intended reading.
91
+ *
92
+ * A query that matches products but no interiors at all has no neighbour to
93
+ * copy; those get a descending synthetic score so the strongest hit is the
94
+ * brightest thing on screen.
95
+ */
96
  function assignRelevance(list) {
97
  const interiorAt = i => (i >= 0 && i < list.length && typeof list[i].score === 'number' ? i : -1);
98
  const floor = list.reduce((min, r) => (Number.isFinite(r.score) ? Math.min(min, r.score) : min), Infinity);
 
109
  return list;
110
  }
111
 
112
+ /**
113
+ * Unwrap a catalogue hit.
114
+ *
115
+ * The worker ranks hits as `{ sku, score, product }`, while a caller that has
116
+ * already normalised them passes the product itself. Both carry a `sku`, so
117
+ * checking `product` FIRST is what stops the wrapper being mistaken for the
118
+ * record: the wrapper has no `images`, and taking it would drop the product.
119
+ */
120
  function unwrap(item) {
121
  if (!item || typeof item !== 'object') return null;
122
  if (item.product && typeof item.product === 'object') return item.product;
123
  return item;
124
  }
125
 
126
+ /**
127
+ * Produce one merged, de-duplicated list for the field.
128
+ *
129
+ * `interiors` are the CLIP-ranked photographs (already sorted, highest first);
130
+ * `products` are the lexically-ranked catalogue hits. Only the ORDER of the
131
+ * products is meaningful -- their scores come from token overlap, so they are
132
+ * not comparable with a cosine and are deliberately never blended into one.
133
+ */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  export function mergeResults(interiors, products, options = {}) {
135
  const productLimit = options.productLimit ?? PRODUCT_LIMIT;
136
  const interiorLimit = options.interiorLimit ?? 60;
137
 
138
  const seen = new Set();
139
  const out = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  for (const image of interiors ?? []) {
141
+ if (!image?.id || seen.has(interiorKey(image.id))) continue;
 
 
 
 
 
 
142
  seen.add(interiorKey(image.id));
143
  out.push(image);
144
  if (out.length >= interiorLimit) break;
145
  }
146
 
147
  const hits = [];
 
 
 
 
 
 
148
  for (const item of products ?? []) {
149
  const product = unwrap(item);
150
  const src = leadImage(product);
 
152
  const key = productKey(product.sku);
153
  if (seen.has(key)) continue;
154
  seen.add(key);
155
+ hits.push({ ...product, ...tileDefaults(product), src });
156
+ if (hits.length >= productLimit) break;
 
 
 
 
 
 
 
157
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  if (!hits.length) return out;
159
 
160
+ /**
161
+ * Walk backwards while splicing. Inserting forwards would shift every later
162
+ * insertion point, so product 5 would land somewhere the loop already passed.
163
+ */
164
+ for (let i = hits.length - 1; i >= 0; i--) {
165
+ out.splice(Math.min(out.length, (i + 1) * STRIDE), 0, hits[i]);
166
  }
167
  return assignRelevance(out);
168
  }
 
 
 
 
 
 
 
 
 
 
 
 
src/neon-frame.css DELETED
@@ -1,131 +0,0 @@
1
- /**
2
- * --neon-angle must be REGISTERED to be animatable: an unregistered custom
3
- * property is not a number the engine can interpolate, so a keyframe from 0deg
4
- * to 360deg would flip once at the midpoint instead of sweeping.
5
- *
6
- * `inherits: false` keeps the angle on the ring itself. The tile is transformed
7
- * every frame by the field engine, and an inherited animated property would be
8
- * recomputed against a moving box, which is how the gradient ends up sliding
9
- * relative to the picture it is supposed to be travelling around.
10
- *
11
- * These rules are kept byte-compatible with dist/assets/neon-frame.css, which is
12
- * what the Space loads today on top of the built app.css. The build therefore
13
- * reproduces this frame rather than replacing it, and the override file becomes
14
- * a no-op instead of a divergence between two definitions of the same effect.
15
- */
16
- @property --neon-angle {
17
- syntax: '<angle>';
18
- inherits: false;
19
- initial-value: 0deg;
20
- }
21
-
22
- .image-tile { --neon: #22f5d0; --neon-soft: #7ef9ff; --neon-run: 2.6s; }
23
- .image-tile.exact { --relevance-opacity: 1 !important; --relevance-blur: 0px !important; }
24
- /* A featured tile -- the exact answer, or a photograph just added on
25
- vai-avatar -- is never dimmed either. Same reason: it is the thing being
26
- pointed at, and a ring around something at 14% opacity is not a pointer. */
27
- .image-tile.featured { --relevance-opacity: 1 !important; --relevance-blur: 0px !important; }
28
-
29
- /* The ring is drawn on `.exact` AND `.featured`. They are not alternatives: the
30
- brief asks for the running neon on the exact result in every query AND on the
31
- newest photographs, so both ring. A featured tile that is also the exact
32
- answer matches both selectors and still renders exactly one ring. */
33
- .image-tile.exact::before,
34
- .image-tile.featured::before {
35
- content: ''; position: absolute; inset: 0; z-index: 2;
36
- border-radius: 2px;
37
- /* A 1px gap between the photo edge and the light keeps the frame legible
38
- against the tile's dark background. */
39
- padding: 1px;
40
- /* `from var(--neon-angle)` is the whole effect. The layer itself never moves:
41
- no transform, so the mask stays welded to the tile's edges and the gradient
42
- is what sweeps. The stops are a white-hot head, a comet tail fading back
43
- round to 250deg, and a clear gap before the head returns -- so the eye
44
- reads one light travelling rather than a ring pulsing in place. */
45
- background: conic-gradient(from var(--neon-angle, 0deg),
46
- var(--neon) 0deg,
47
- var(--neon-soft) 38deg,
48
- #ffffff 70deg,
49
- var(--neon-soft) 102deg,
50
- var(--neon) 148deg,
51
- #22f5d05c 250deg,
52
- #22f5d000 344deg,
53
- var(--neon) 360deg);
54
- -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
55
- -webkit-mask-composite: xor;
56
- mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
57
- mask-composite: exclude;
58
- /* Any transform here drags the mask off the frame, which is exactly the bug
59
- this replaced. Stated explicitly so a later edit cannot reintroduce it. */
60
- transform: none;
61
- pointer-events: none;
62
- }
63
-
64
- /* The halo: the same sweep from the same angle, spread wider and blurred, so
65
- the light blooms off the edge instead of being cut flat against it. A
66
- pseudo-element can only carry one background, and a neon frame needs both the
67
- stroke and the glow -- hence two layers, one pseudo-element apart. */
68
- .image-tile.exact::after,
69
- .image-tile.featured::after {
70
- content: ''; position: absolute; inset: 0; z-index: 1;
71
- border-radius: 2px;
72
- /* Half the previous padding, halving the ring width. */
73
- padding: 2.5px;
74
- background: conic-gradient(from var(--neon-angle, 0deg),
75
- #22f5d000 0deg,
76
- #22f5d085 62deg,
77
- #b6fdffc4 92deg,
78
- #22f5d05e 138deg,
79
- #22f5d01f 250deg,
80
- #22f5d000 348deg,
81
- #22f5d000 360deg);
82
- -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
83
- -webkit-mask-composite: xor;
84
- mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
85
- mask-composite: exclude;
86
- filter: blur(4px);
87
- opacity: .82;
88
- /* No box-shadow animation. A shadow's shape is the tile's shape, so it
89
- breathes in place and can never run along the perimeter -- which is why it
90
- is replaced by the gradient sweep above rather than merely re-timed. */
91
- box-shadow: none;
92
- pointer-events: none;
93
- }
94
-
95
- /* The head sweeps one full turn clockwise; the halo sweeps the same turn over
96
- the same duration, so the two re-align every lap instead of drifting apart. */
97
- @keyframes neon-run {
98
- from { --neon-angle: 0deg; }
99
- to { --neon-angle: 360deg; }
100
- }
101
-
102
- /*
103
- * Gate the animation behind @supports at-rule(@property).
104
- *
105
- * Without the registration the angle is an untyped token, so `neon-run` would
106
- * jump from 0deg to 360deg at the halfway point -- the ring would blink rather
107
- * than run, which is worse than the plain static ring it replaced. Declaring the
108
- * angle and then animating it unconditionally would put that blink on every
109
- * browser that has not shipped @property. Browsers outside the gate keep the
110
- * gradient, which is a lit neon border with the bright head parked at the top.
111
- */
112
- @supports at-rule(@property) {
113
- .image-tile.exact::before,
114
- .image-tile.featured::before,
115
- .image-tile.exact::after,
116
- .image-tile.featured::after {
117
- animation: neon-run var(--neon-run) linear infinite;
118
- }
119
- }
120
-
121
- /* A featured tile runs faster and thinner, so the newest photographs are
122
- distinguishable from an exact hit by pace as well as by size. */
123
- .image-tile.featured { --neon-run: 2s; }
124
- .image-tile.featured::before { padding: 1.5px; }
125
-
126
- /* A slow, low-frequency sweep reads as "lit"; a fast strobe reads as an alarm,
127
- and this outline marks a correct answer rather than an error. */
128
- @media (prefers-reduced-motion: reduce) {
129
- .image-tile.exact::before, .image-tile.exact::after,
130
- .image-tile.featured::before, .image-tile.featured::after { animation: none; }
131
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/product-search.js DELETED
@@ -1,312 +0,0 @@
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
- * Synonym pairs folded at query time.
74
- *
75
- * "Kệ xoong" and "giá xoong" name the same kitchen rack: the catalogue's
76
- * Eurogold rows spell it "Giá xoong nồi..." while the query typing it from a
77
- * shop floor says "kệ". Folding one term onto the other in the NAME comparison
78
- * is what lets a Eurogold giá-xoong match a kệ-xoong query. Deliberately a
79
- * one-way fold of a specific pair (not a thesaurus) so "giá" (price) in a
80
- * price string can never accidentally match "kệ" (rack).
81
- */
82
- const NAME_TERM_SYNONYMS = {
83
- ke: ['gia'],
84
- gia: ['ke'],
85
- };
86
-
87
- /**
88
- * Does the folded name carry `term`, or one of its synonyms?
89
- *
90
- * The synonym only fires on the NAME field (never brand/category/summary), so
91
- * a "giá" (price) mention in a spec or description cannot inflate a rack
92
- * query, and only when the term is one of the rack words we actually fold.
93
- */
94
- function nameHas(nm, term) {
95
- if (nm.includes(term)) return true;
96
- const synonyms = NAME_TERM_SYNONYMS[term];
97
- if (!synonyms) return false;
98
- for (const alias of synonyms) {
99
- if (nm.includes(alias)) return true;
100
- }
101
- return false;
102
- }
103
-
104
- /**
105
- * Every distinct millimetre value mentioned by a product.
106
- *
107
- * Two patterns, because sizes are written two ways in this catalogue: with an
108
- * explicit unit ("900 mm") and as a bare number next to a dimension letter
109
- * ("W565 x D280 x H650", "Khoang tủ: 900"). Matching on the NUMBER rather than
110
- * the surrounding text is what makes "700" find a 700mm rack and not a 7000mm
111
- * one, nor a rack whose only "700" is in its price.
112
- */
113
- function mmValues(text) {
114
- const out = new Set();
115
- const withUnit = /(\d{1,4})\s*mm/gi;
116
- const withDimension = /[wdh]\s*[:=]?\s*(\d{1,4})/gi;
117
- let match;
118
- while ((match = withUnit.exec(text))) out.add(parseInt(match[1], 10));
119
- while ((match = withDimension.exec(text))) out.add(parseInt(match[1], 10));
120
- return out;
121
- }
122
-
123
- /**
124
- * Pull every field the scorer reads off a catalogue product into folded text.
125
- *
126
- * The product shape here is catalogue.js's normaliseProduct() output
127
- * ({ name, model, brand, category, summary, feats, specs }), where `specs` is
128
- * an array of [key, value] pairs rather than vai-avatar's plain object. That
129
- * difference is the only thing the port has to reconcile.
130
- */
131
- function fieldsOf(product) {
132
- const specsText = sd((product.specs || []).map(([key, value]) => `${key} ${value}`).join(' '));
133
- return {
134
- name: sd(product.name || ''),
135
- sku: sd(product.model || product.sku || ''),
136
- brand: sd(product.brand || ''),
137
- category: sd(product.category || ''),
138
- summary: sd(product.summary || ''),
139
- feats: sd((product.feats || []).join(' ')),
140
- specsText,
141
- };
142
- }
143
-
144
- /**
145
- * Score one product against one query. Ported from vai-avatar's scoreProduct.
146
- *
147
- * The bonuses are copied verbatim, including their magnitudes, because the
148
- * RELATIVE order of the rules is the behaviour: a 1000-point exact SKU must
149
- * still outrank a 300-point all-terms-in-title, which it only does if the
150
- * numbers stay as they are.
151
- */
152
- export function scoreProduct(product, query, terms) {
153
- const { name: nm, sku: sk, brand: br, category: cat, summary, feats, specsText } = fieldsOf(product);
154
- const rawQuery = sd(String(query || '').trim());
155
- let score = 0;
156
-
157
- // ── Exact model / SKU / title ────────────────────────────────────────────
158
- // Typing a concrete model code is a lookup, so that product must come first.
159
- // Both sides are compacted so "GP 170 BD" folds onto "gp170bd".
160
- const compactQuery = rawQuery.replace(/[^a-z0-9]/g, '');
161
- const compactName = nm.replace(/[^a-z0-9]/g, '');
162
- const compactSku = sk.replace(/[^a-z0-9]/g, '');
163
- if (compactQuery.length >= 4) {
164
- if (compactSku === compactQuery) score += 1000;
165
- else if (compactName === compactQuery) score += 900;
166
- else if (compactName.includes(compactQuery) || compactSku.includes(compactQuery)) score += 550;
167
- // The sku-as-substring tier must not fire for a TRIVIALLY SHORT sku. A
168
- // query "eurogold" must not award 400 to a Grob product whose sku is
169
- // "GOLD" (4 chars) just because "eurogold" happens to contain "gold" --
170
- // that is why brand searches kept surfacing unrelated "gold" items above
171
- // the Eurogold ones. A sku is a specific identity only when it is 5+
172
- // characters (or strictly longer than half the query), so require both.
173
- else if (compactSku.length >= Math.max(5, compactQuery.length / 2) && compactQuery.includes(compactSku)) score += 400;
174
- }
175
- // Every meaningful term present in the title is a strong signal too.
176
- const significantTerms = terms.filter(term => term.length > 1 && !/^\d{2,4}mm/.test(term));
177
- if (significantTerms.length && significantTerms.every(term => nameHas(nm, term))) score += 300;
178
-
179
- const all = [nm, br, cat, sd(product.summary || ''), feats, specsText].join(' ');
180
-
181
- // Note vai-avatar compares the ORIGINAL query here, not the folded one, so an
182
- // accented query can still match an unaccented field exactly.
183
- const rawTrimmed = String(query || '').trim();
184
- if (nm === rawTrimmed) score += 200;
185
- else if (sk === rawTrimmed) score += 180;
186
- else if (nm.includes(rawTrimmed)) score += 100;
187
- else if (sk.includes(rawTrimmed)) score += 80;
188
- else if (all.includes(rawTrimmed)) score += 50;
189
-
190
- // ── Size (mm) matching ──────────────────────────────────────────────────
191
- const productMm = mmValues(`${nm} ${specsText} ${sd(product.summary || '')} ${feats} ${summary}`);
192
- const queryMm = mmValues(rawTrimmed);
193
- if ([...queryMm].some(value => productMm.has(value))) {
194
- if (nm.includes(rawTrimmed)) score += 40;
195
- else if (nm.split(' ').some(token => /^\d{1,4}mm$/i.test(token))) score += 55;
196
- else if (specsText !== '') score += 50;
197
- else score += 40;
198
- } else if (queryMm.size) {
199
- score -= 10;
200
- }
201
-
202
- // Size tokens written out in the query ("700mm") are worth more than a bare
203
- // numeric match, because typing the unit means the size is deliberate.
204
- for (const token of terms.filter(term => /^\d{2,4}\s*mm$/.test(term))) {
205
- const compact = token.replace(/\s+/g, '');
206
- if (nm.includes(compact)) score += 60;
207
- else if (sk.includes(compact)) score += 40;
208
- else if (all.includes(compact)) score += 25;
209
- }
210
-
211
- // ── Material ─────────────────────────────────────────────────────────────
212
- for (const material of MATERIAL_WORDS) {
213
- const folded = sd(material);
214
- if (rawQuery.includes(folded) && (nm.includes(folded) || specsText.includes(folded) || sd(product.summary || '').includes(folded))) {
215
- score += 30;
216
- break;
217
- }
218
- }
219
-
220
- // ── Per-term scoring ─────────────────────────────────────────────────────
221
- let titleHits = 0;
222
- const meaningful = terms.filter(term => term.length > 1 && !/^\d{2,4}\s*mm$/.test(term));
223
- for (const term of meaningful) {
224
- if (nameHas(nm, term)) { score += 16; titleHits++; }
225
- else if (sk.includes(term)) score += 12;
226
- else if (br.includes(term)) score += 8;
227
- else if (cat.includes(term)) score += 10;
228
- else if (all.includes(term)) score += 3;
229
- }
230
- // Covering more of the query beats matching one word hard.
231
- if (titleHits >= 2) score += 35;
232
- else if (titleHits === 1 && meaningful.length >= 2) score += 10;
233
- for (const term of meaningful) if (cat.includes(term)) score += 6;
234
-
235
- for (const brand of BRAND_NAMES) {
236
- if (rawQuery.includes(brand) && br.includes(brand)) score += 50;
237
- }
238
- return score;
239
- }
240
-
241
- /**
242
- * Rank a catalogue against a query, vai-avatar's way.
243
- *
244
- * `items` is the Map that catalogue.js hands the worker. Returns the same
245
- * `{ sku, score, product }` shape searchCatalogue returns, so merge.js needs no
246
- * change to accept it.
247
- *
248
- * Scored results only: a product nobody matched is not a weak result, it is
249
- * not a result, and returning it would put an unrelated photograph in the
250
- * field next to the query that was typed.
251
- */
252
- export function searchLikeAvatar(text, items, limit = 24) {
253
- const terms = extractTerms(text);
254
- if (!terms.length || !items?.size) return [];
255
- const scored = [];
256
- for (const product of items.values()) {
257
- const score = scoreProduct(product, String(text || '').trim(), terms);
258
- // A score below 24 is a single weak token scrape, not an answer: it would
259
- // paint an unrelated photograph next to the query typed. Kept in sync with
260
- // the merge.js productFloor default (20) plus the two strongest weak tiers
261
- // (16 title + 3+10 hit bonus) — anything under it is noise.
262
- if (score >= 24) scored.push({ sku: product.sku, score, product });
263
- }
264
- scored.sort((a, b) => b.score - a.score || a.sku.localeCompare(b.sku));
265
-
266
- /**
267
- * Break equal-score ties so one brand-family does not swallow the field.
268
- *
269
- * "kệ xoong" matches ~70 racks across Grob/Hafele/Eurogold at the same
270
- * score, and the plain sku tie-break above lets the alphabetically-first
271
- * family (Grob's C1..., Hafele's 5...) take every slot -- Eurogold's E...
272
- * rows never surface even though they are equally the answer. Round-robin
273
- * WITHIN each equal-score group: one product per brand, repeated, so the
274
- * field shows every family that actually matched, while a genuinely higher
275
- * score still outranks a lower one.
276
- */
277
- if (scored.length > 1) {
278
- const byScore = new Map();
279
- for (const hit of scored) {
280
- if (!byScore.has(hit.score)) byScore.set(hit.score, []);
281
- byScore.get(hit.score).push(hit);
282
- }
283
- const ordered = Array.from(byScore.keys()).sort((a, b) => b - a);
284
- const roundRobin = [];
285
- for (const score of ordered) {
286
- const group = byScore.get(score);
287
- const grouped = new Map();
288
- for (const hit of group) {
289
- const brand = (hit.product?.brand || '').toLowerCase() || 'unknown';
290
- if (!grouped.has(brand)) grouped.set(brand, []);
291
- grouped.get(brand).push(hit);
292
- }
293
- let progress = true;
294
- while (progress) {
295
- progress = false;
296
- for (const entries of grouped.values()) {
297
- if (entries.length) {
298
- roundRobin.push(entries.shift());
299
- progress = true;
300
- }
301
- }
302
- }
303
- }
304
- scored.length = 0;
305
- scored.push(...roundRobin);
306
- }
307
-
308
- return scored.slice(0, limit);
309
- }
310
-
311
- /** Exported for the suggestion-style callers that only need folded tokens. */
312
- export { tokenize };