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