Check SKU mapping before loading CLIP, not after embedding 6k photos

#2
by bep40 - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. .gitattributes +0 -13
  2. .gitignore +0 -20
  3. PLACEHOLDER +0 -1
  4. README.md +37 -309
  5. app.html +0 -28
  6. dist/app.html +0 -29
  7. dist/assets/app.css +0 -2
  8. dist/assets/index-CX-0QtoD.js +0 -0
  9. dist/assets/index-CigEzVrZ.js +0 -0
  10. dist/assets/index-DGvjonwp.js +0 -0
  11. dist/assets/index-DcLscXnL.css +2 -0
  12. dist/assets/index.js +0 -0
  13. dist/assets/neon-frame.css +0 -170
  14. dist/assets/share-fix.js +0 -193
  15. dist/assets/worker-Bq7vdIsj.js +0 -0
  16. dist/assets/{worker-Bz7I_x9L.js → worker-DHCTNpZY.js} +0 -0
  17. dist/assets/worker-hEU_3YGf.js +0 -0
  18. dist/assets/worker-hGPAZ_gn.js +0 -0
  19. dist/assets/worker-n0zt6z0K.js +0 -0
  20. dist/assets/worker-nnOdWzlm.js +0 -0
  21. dist/brand/vaistudio_center.png +0 -3
  22. dist/catalogue/products.json +0 -0
  23. dist/catalogue/promos.json +0 -0
  24. dist/index.html +3 -15
  25. dist/share/og-image.png +0 -3
  26. index.html +4 -69
  27. package-lock.json +0 -30
  28. package.json +1 -7
  29. products-index.json +2 -2
  30. public/brand/vaistudio_center.png +0 -3
  31. public/catalogue/products.json +0 -0
  32. public/catalogue/promos.json +0 -0
  33. public/share/og-image.png +0 -3
  34. repo/rebuild.py +0 -56
  35. scripts/gen-thumbnail.py +0 -108
  36. scripts/refresh-catalogue.mjs +0 -415
  37. scripts/refresh.mjs +0 -74
  38. scripts/requirements-sync.txt +0 -6
  39. scripts/seed-catalogue.mjs +0 -216
  40. scripts/sync-catalogue.mjs +0 -141
  41. scripts/sync-interiors.py +0 -388
  42. scripts/test-avatar-interiors.mjs +0 -287
  43. scripts/test-catalogue.mjs +0 -187
  44. scripts/test-merge.mjs +0 -210
  45. scripts/test-misbrand.mjs +0 -109
  46. scripts/test-neon-ui.mjs +0 -256
  47. scripts/test-product-search.mjs +0 -173
  48. scripts/test-semantic-search.mjs +0 -123
  49. scripts/test-ui.mjs +0 -201
  50. scripts/test-viewer-ui.mjs +0 -151
.gitattributes CHANGED
@@ -36,16 +36,3 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
36
  products-index.json filter=lfs diff=lfs merge=lfs -text
37
  dist/products-gallery.json filter=lfs diff=lfs merge=lfs -text
38
  products-gallery.json filter=lfs diff=lfs merge=lfs -text
39
- # The V.AI STUDIO backdrop, copied from vai-avatar's public/avatars/ folder.
40
- # Named explicitly rather than with a *.png pattern: a wildcard would also
41
- # catch the BlurHash and thumbnail PNGs under dist/prodimgs, which are small,
42
- # numerous and regenerated by the catalogue scripts -- routing ~6,400 of them
43
- # through LFS would make every catalogue refresh slow and would balloon the
44
- # repo. Two files, one rule, and the reason is recorded here.
45
- public/brand/vaistudio_center.png filter=lfs diff=lfs merge=lfs -text
46
- dist/brand/vaistudio_center.png filter=lfs diff=lfs merge=lfs -text
47
-
48
- # Social-share thumbnail (generated by scripts/gen-thumbnail.py). Both copies in
49
- # LFS: the hub og:image resolves via resolve/main/dist/share/og-image.png.
50
- public/share/og-image.png filter=lfs diff=lfs merge=lfs -text
51
- dist/share/og-image.png filter=lfs diff=lfs merge=lfs -text
 
36
  products-index.json filter=lfs diff=lfs merge=lfs -text
37
  dist/products-gallery.json filter=lfs diff=lfs merge=lfs -text
38
  products-gallery.json filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore DELETED
@@ -1,20 +0,0 @@
1
- # Dependencies. Only `npm install` output — dist/ and public/catalogue/ are
2
- # deliberately NOT ignored: this is a static Space, so the built bundle and the
3
- # vai-avatar mirror must be committed to be served at all.
4
- node_modules/
5
- .npm/
6
-
7
- # Local logs and caches
8
- *.log
9
- .vite/
10
- .cache/
11
-
12
- # Python tooling
13
- __pycache__/
14
- *.py[cod]
15
- .venv/
16
- venv/
17
-
18
- # OS noise
19
- .DS_Store
20
- Thumbs.db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
PLACEHOLDER DELETED
@@ -1 +0,0 @@
1
- null
 
 
README.md CHANGED
@@ -1,346 +1,74 @@
1
  ---
2
- title: Thư viện tìm kiếm ảnh theo ngữ nghĩa ngôn ngữ tự nhiên
3
  emoji: 🛋️
4
  colorFrom: yellow
5
  colorTo: red
6
  sdk: static
7
  pinned: false
8
- short_description: Web App AI thuộc hệ sinh thái VIBE CODE AI của V.AI STUDIO
9
- thumbnail: dist/share/og-image.png
10
  header: mini
11
  tags:
12
  - ml-intern
13
  ---
14
 
15
- # Thư viện tìm kiếm ảnh theo ngữ nghĩa ngôn ngữ tự nhiên
16
 
17
- Web App AI thuộc hệ sinh thái **VIBE CODE AI** của **V.AI STUDIO**.
18
-
19
- Phái sinh từ [`webml-community/semantic-image-field`](https://huggingface.co/spaces/webml-community/semantic-image-field):
20
- thay 24.989 ảnh Unsplash bằng **1.222 ảnh nội thất Việt Nam**, cho phép tìm bằng tiếng Việt.
21
- Danh mục sản phẩm của **vai-avatar** mở trong cùng trình xem ảnh toàn màn hình với ảnh nội thất.
22
-
23
- > **ĐÃ XONG** — danh mục sản phẩm của vai-avatar đã nằm trong photo-viewer và tự đồng bộ.
24
- > Xem [Vì sao trước đây không hoạt động](#vì-sao-trước-đây-không-hoạt-động).
25
-
26
- ## Vì sao trước đây không hoạt động
27
-
28
- Ba lỗi chồng nhau, mỗi lỗi một mình đủ để danh mục không bao giờ hiện:
29
-
30
- 1. **Space phục vụ một bundle không có code sản phẩm.** `index.html` trỏ tới
31
- `index-CigEzVrZ.js` — build từ cây source chưa hề có module catalogue
32
- (`grep Malloca` trong bundle đó = 0 hit). Dù mirror có dữ liệu thì cũng vô ích.
33
- 2. **`candidateURLs()` bị Vite phá hủy âm thầm.** `new URL(`../../${path}`,
34
- import.meta.url)` bị Vite hiểu thành glob asset và thay bằng map index theo
35
- chuỗi literal `../../${path}` (còn nguyên `${}`) → không khớp key nào →
36
- `undefined` → mọi candidate đều 404 **dù file mirror có thật**. Cùng lúc đó
37
- build kéo README.md, package-lock.json và `products-gallery.json` (11 MB) vào
38
- `dist/assets`. Đã sửa: base truyền vào `new URL` dạng biến, nên không còn bị đụng.
39
- 3. **Mirror rỗng và không có gì viết nó.** `public/catalogue/` và `dist/catalogue/`
40
- chưa từng được commit. Người duy nhất viết là reconciler trong Space
41
- vai-avatar, mà Space đó không có secret nào nên tự tắt (`no HF_TOKEN -> mirror OFF`).
42
-
43
- ## Đồng bộ bây giờ không cần token
44
-
45
- `fetchJSON()` đọc **thẳng dataset** mà vai-avatar ghi và mirror chỉ còn là fallback
46
- same-origin. Lý do: huggingface.co giờ phản chiếu `Origin` của caller trong
47
- `Access-Control-Allow-Origin`, trên **cả** hop `/resolve` lẫn hop `/api/resolve-cache`
48
- mà nó redirect tới — nên Space đọc được cross-origin mà không cần token.
49
-
50
- Nhờ vậy thêm / sửa / xóa trên vai-avatar hiện lên đây trong **30 giây** (`POLL_MS`),
51
- không cần ai commit, không rebuild Space, không cần secret. Mirror vẫn được thử và
52
- vẫn là nguồn dự phòng: Hub lỗi thì vẫn có catalogue, chứ không trắng shop.
53
-
54
- ## Trạng thái
55
-
56
- | Việc | Trạng thái |
57
- | --- | --- |
58
- | Danh mục hiện trong photo-viewer | ✅ 75 sản phẩm, mở ảnh + phân trang + giá + specs |
59
- | Bundle đúng source hiện tại | ✅ `dist/assets/index.js` + `worker-CYIRI7QN.js` |
60
- | `candidateURLs()` sau build | ✅ còn nguyên, không bị Vite glob |
61
- | Bundle không kéo file rác | ✅ `grep 'Object.assign({"'` = 0 |
62
- | `public/catalogue/` + `dist/catalogue/` | ✅ 69 products + 58 productEdits |
63
- | Đọc dataset trực tiếp (không cần token) | ✅ đã build + kiểm chứng bằng browser thật |
64
- | Thêm / Sửa / Xóa đồng bộ | ✅ trong 30s, không cần commit |
65
- | Mirror tự cập nhật trong repo | ⚪ tuỳ chọn — cần secret `HF_TOKEN` ở vai-avatar |
66
- | Token `HF_TOKEN` trong vai-avatar | ❌ chưa có — Space hiện đang chạy không cần nó |
67
- | Token `HF_TOKEN` trong vai-avatar | ❓ **chưa kiểm — cần đọc log container** |
68
-
69
- **Còn hai việc, đúng thứ tự. Không mục nào cần credits:**
70
-
71
- 1. **Kiểm tra `HF_TOKEN`** trong vai-avatar (10 giây)
72
- 2. **Chạy `npm run refresh:catalogue`** (~3 phút)
73
-
74
- PR #1 đã đóng do merge conflict; [PR #2](https://huggingface.co/spaces/bep40/vai-avatar/discussions/2)
75
- là bản thay thế, đã merge.
76
-
77
- ## Ghi chú vận hành
78
-
79
- ### 1. Kiểm tra token
80
-
81
- ```sh
82
- curl -s -H "Authorization: Bearer $HF_TOKEN" \
83
- https://huggingface.co/api/spaces/bep40/vai-avatar/secrets | jq keys
84
- ```
85
-
86
- Chỉ trả **tên** secret. Không thấy `HF_TOKEN` thì thêm vào
87
- [settings](https://huggingface.co/spaces/bep40/vai-avatar/settings), **không cần sửa mã**.
88
-
89
- **Vì sao đây là nghi phạm số một:** cả hai dataset đều **public**, nên `readDataset()` chạy
90
- được không cần token. Nhưng `commitMirror()` **bắt buộc** có token. Thiếu token thì cả vòng
91
- reconcile `return` sớm ở dòng đầu, **không in gì**. Đúng triệu chứng "im lặng" đang thấy.
92
-
93
- Nếu token đã có mà vẫn im lặng, xem tab **Logs** trên trang Space của vai-avatar:
94
-
95
- | Log | Nghĩa là | Hành động |
96
- | --- | --- | --- |
97
- | **Không có** dòng `[catalogue-mirror-boot]` | Wrapper chưa chạy | Báo lại, đây là lỗi thật |
98
- | `could not load catalogue-mirror.ts` | Lỗi cú pháp | Báo lại kèm log |
99
- | `reconciler RUNNING … token=NO` | Thiếu token | Thêm `HF_TOKEN` rồi restart |
100
- | `SYNCED …` | Mirror đã ghi | Chờ ~2 phút rồi làm bước 2 |
101
- | Vòng 2+ im lặng | Đúng, không có gì đổi | Không làm gì |
102
- | `SYNCED` **lặp mỗi vòng** | Rebuild loop | **Dừng ngay** |
103
- | `commit rejected HTTP 403` | Token thiếu quyền sang repo khác | Thêm quyền, không phải lỗi mã |
104
-
105
- ### 2. Build lại bundle
106
-
107
- ```sh
108
- git clone https://huggingface.co/spaces/bep40/semantic-image-field
109
- cd semantic-image-field
110
- npm install
111
- npm run refresh:catalogue
112
- ```
113
-
114
- ~3 phút, miễn phí. `refresh:catalogue` = sync → guard → build → verify `dist/catalogue` →
115
- repoint `index.html` → commit → push.
116
-
117
- **Đừng dùng `npm run seed:catalogue`** — nó chỉ tải JSON, **không build**. Chạy nó sẽ cho
118
- sản phẩm hiện lên mà vẫn lỗi đồng bộ, tức kết quả tệ hơn hiện tại vì trông như đã xong.
119
-
120
- **Vì sao bước này là quyết định:** `dist/assets/` hiện chỉ có bundle **có hash**
121
- (`index-CigEzVrZ.js`). `vite.config.js` đặt `entryFileNames: 'assets/index.js'` — tên không
122
- hash — nên `vite build` **chưa từng chạy** kể từ khi cấu hình đó được đặt. Sáu bản vá trong
123
- `src/` **đã** trên `main` nhưng **chưa** nằm trong bundle đang chạy.
124
-
125
- ## Một cảnh báo về cách kiểm
126
-
127
- `list` với **đường dẫn tới một file** trả `File not found` dù file đó có thật. Phải `list`
128
- **thư mục cha** rồi đọc danh sách.
129
-
130
- Cụ thể: `dist/assets/index-CigEzVrZ.js` trả `File not found`, nhưng `list dist/assets`
131
- liệt kê đúng file đó (263.4 KB). Tin `File not found` thì kết luận sai là bundle đã mất
132
- và Space trắng trang — trong khi nó vẫn chạy.
133
-
134
- Với **thư mục** thì `list` đáng tin: `dist/catalogue` báo not-found và `list` xác nhận nó
135
- thật sự không tồn tại.
136
-
137
- ## Token: đọc ở đâu cho đúng
138
-
139
- `startCatalogueMirrorReconciler()` in sẵn trạng thái token lúc khởi động:
140
-
141
- ```
142
- [catalogue-mirror] reconciler RUNNING | target=bep40/semantic-image-field@main
143
- | every 120s | token=yes
144
- ```
145
-
146
- **Không cần** gọi `/api/share-persist`. Route đó được `applyPatches()` tiêm *có điều kiện*
147
- vào `index.ts` lúc chạy, chỉ khi thấy anchor `'"/*": { GET: serveStatic },'` và
148
- `tryWriteIndex()` qua kiểm tra cú pháp. Thiếu anchor là 404 — **tương thích với việc token
149
- có tồn tại**, nên 404 không nói được gì về token.
150
-
151
- Dòng "Space này hiện CHƯA có `HF_TOKEN`" trong `boot.ts` là lời tuyên bố trong source, có
152
- thể đã cũ. Nguồn đúng là log container.
153
-
154
- ## Vì sao hai nguồn vẫn ra một sản phẩm
155
-
156
- Một máy giặt Hitachi nằm ở **cả hai** nguồn, hai nguồn gọi nó bằng hai tên khác nhau —
157
- đọc trực tiếp từ Hub, không phỏng đoán:
158
-
159
- | | `products_url_added.json` | `promos.json` |
160
- | --- | --- | --- |
161
- | key / `sku` / `model` | `BD-1054HVOW` | `357869` |
162
- | `slug` | `bd-1054hvow-may-giat-…` | `357869-may-giat-…` |
163
- | **`name` / `n`** | **`Máy giặt Hitachi Inverter 10.5 kg BD-1054HVOW`** | **giống hệt** |
164
- | giá | `pn` = 5.990.000 | `priceNum` = 6.490.000 |
165
-
166
- `sku` và `slug` **không** khớp, nhưng `name` giống nhau và `identityAliases()` đã lấy
167
- `name` làm bí danh — nên hai bản ghi là **một** sản phẩm, và giá 6.490.000 nhập đè lên bản
168
- ghi gốc.
169
-
170
- **Đã cân nhắc và loại bỏ một cách khắc phục khác.** Con số `357869` chỉ nằm trong URL ảnh,
171
- không field nào chứa nó, nên đào URL để ghép cứng là cách sửa hiện trường. Không dùng: ghép
172
- cứng theo id trong URL có thể hợp nhất nhầm hai sản phẩm, mà lỗi đó cho ra một bản ghi
173
- *trông vẫn đầy đủ* — khó phát hiện hơn nhiều so với một thẻ bị lặp.
174
-
175
- Bí danh theo `name` vẫn an toàn: bí danh mà hai sản phẩm cùng nhận bị loại như mơ hồ.
176
-
177
- ## Nguồn dữ liệu sản phẩm
178
-
179
- | Thao tác trên vai-avatar | Nguồn | Nội dung |
180
- | --- | --- | --- |
181
- | Thêm SP | `bep40/grob-products-updated/products_url_added.json` | `n`, `imgs`, `p`, `pn`, `specs`, `feats` |
182
- | Thêm / Sửa / Xóa SP | `bep40/vaistudio-data/promos.json` → `productEdits[sku]` | `{ name, priceNum, images }` hoặc `{ deleted: true }` |
183
-
184
- **Xóa là bia mộ**, không phải xoá bản ghi. `buildCatalogue` bỏ qua bản ghi có `deleted`, đúng
185
- như `getAddedProducts()` / `isDeleted()` của vai-avatar. Nếu không, sản phẩm đã xoá hiện
186
- vĩnh viễn.
187
-
188
- **Phạm vi hẹp:** hai file này là bản *thêm / sửa / xoá*, không phải catalogue đầy đủ.
189
- Catalogue gốc Malloca ~6.000 sản phẩm nằm trong Parquet và không được nhân bản. Sửa một SKU
190
- thuộc catalogue gốc sẽ không có bản ghi để áp overlay.
191
-
192
- Xếp hạng dùng từ khoá, không dùng ma trận CLIP đã nướng: sản phẩm vừa thêm là tìm được
193
- ngay ở truy vấn kế tiếp, không phải chờ mã hoá lại toàn bộ ảnh.
194
-
195
- ## Sáu lỗi đồng bộ đã sửa
196
-
197
- Cả sáu đều **fail im lặng**, và cả sáu triệu chứng đều đọc ra giống nhau là "sửa sp không
198
- đồng bộ".
199
-
200
- **Trong `src/catalogue.js`:**
201
-
202
- 1. **Hai nguồn không thống nhất danh tính.** Ghép mỗi file theo field riêng sẽ liệt kê máy
203
- giặt hai lần và mọi "Sửa SP" sẽ vô hình. Nay mỗi bản ghi nhận diện qua tập bí danh.
204
- 2. **"Xóa SP" im lặng không làm gì.** vai-avatar ghi bản sửa ở
205
- `productEdits[p.sku || p.model]` — key của object là định danh chính, không phải dự
206
- phòng. Trước đây chỉ field *bên trong* bản sửa được dùng để tra; một bia mộ không mang
207
- field nào nên danh sách rỗng, key trần biến thành mục mới, bản ghi thật không bao giờ bị
208
- đánh dấu, và sản phẩm cứ ở lại mãi. Nay key cũng được tra qua bảng bí danh.
209
- 3. **Bản sửa có nhưng không thấy.** Bộ đọc ưu tiên field ngắn của scraper, nên `priceNum` mới
210
- bị `p` cũ che mất. `EDIT_SUPERSEDES` xoá bản ghi cũ trước khi bản sửa nhập.
211
- 4. **Overlay rỗng hồi sinh sản phẩm đã xoá.** `promos.json` mang mọi Sửa và mọi Xóa. Trả
212
- `{}` khi đọc lỗi không chỉ mất sửa đổi mà còn hồi sinh sản phẩm đã xoá và hoàn nguyên mọi
213
- giá — **không báo lỗi**, vì kết quả vẫn hợp lệ nên `tryLoadCatalogue()` báo thành công
214
- và vòng poll 90 giây thay dữ liệu bị hoàn nguyên vào như một bản cập nhật thật. Nay dùng
215
- overlay hợp lệ gần nhất.
216
- 5. **`sync-catalogue.mjs` báo thành công khi mirror đang lẫn.** Một nguồn fetch lỗi thì file
217
- đó giữ bản commit trước, `refresh-catalogue.mjs` vẫn pass các guard, build, commit, in
218
- `REFRESH_OK` — và `products.json` mới nằm cạnh `promos.json` cũ, nên mọi Sửa và Xóa ngừng
219
- áp dụng mà không báo lỗi. Nay bất kỳ nguồn nào lỗi thì cả lần chạy fail, caller dừng
220
- trước build.
221
- 6. **Reconciler có thể rơi vào vòng rebuild vô tận.** `readTarget()` đọc qua `/resolve/`, có
222
- cache CDN: ngay sau khi commit thành công, endpoint vẫn trả nội dung cũ. Check "diff
223
- trước khi commit" thấy lệch nên commit lại **byte giống hệt**, mỗi commit lại rebuild
224
- Space target. Chính cái check sinh ra để chặn rebuild loop là đường đi vào nó. Nay commit
225
- do chính process đó nhận 200 được lưu lại và thắng đọc cache (C7). Đánh đổi có chủ đích:
226
- sửa tay mirror dưới lúc container chạy sẽ không được tự vá tới khi restart.
227
-
228
- ## Một sửa chữa đã âm thầm
229
-
230
- Wrapper ban đầu viết static import ở dòng 1. ESM **hoist** static import: nó nạp và đánh giá
231
- *trước* mọi câu lệnh trong file. Nên nếu `catalogue-mirror.ts` lỗi cú pháp, cả wrapper chết
232
- trước khi reconciler kịp chạy → CMD rơi về `bun boot.ts` → **site lên, share-link chạy,
233
- chỉ catalogue-mirror tắt, không một dòng log nào**. Người đọc log tưởng PR chưa merge.
234
-
235
- Đã đổi sang dynamic `import()` bọc `try/catch`, thêm hai dòng log tường minh: `reconciler
236
- STARTED` khi bật được, `could not load …` khi lỗi. **Mirror lỗi còn hơn mirror vô hình.**
237
-
238
- ## Vì sao vẫn giữ mirror, dù giờ đã đọc dataset được
239
-
240
- **Cách đọc đã đổi, và đo lại thay vì tin tài liệu cũ.** Trước đây Hub gửi
241
- `Access-Control-Allow-Origin: https://huggingface.co` cho file dataset và `/resolve`
242
- redirect sang host CDN với chính sách riêng — origin của Space không thuộc về cả hai
243
- nên fetch chết. **Điều đó không còn đúng:** Hub nay phản chiếu `Origin` của caller
244
- trong ACAO, trên *cả* hop `/resolve` lẫn hop `/api/resolve-cache`, và kể cả
245
- `Origin: null`. Nên `fetchJSON()` đọc thẳng dataset trước, không cần token.
246
-
247
- Mirror **không bị bỏ**: nó là đường dự phòng same-origin, nên khi Hub/CDN giật
248
- (`5xx`, mạng chết, offline) catalogue vẫn còn thay vì trắng shop. Thứ tự thử là
249
- dataset → mirror, và mọi candidate đều phải **parse được + đúng shape** mới được
250
- nhận, nên một body HTML lỗi hay bị cắt cụt không bao giờ bị coi là catalogue.
251
-
252
- ## Đồng bộ tự động — không cần writer
253
-
254
- Space này là `sdk: static`: không có server, không thể tự đẩy dữ liệu, cũng không thể ghi bất
255
- cứ thứ gì. `bep40/vai-avatar` thì có server, có sẵn reconciler 20 giây cho link chia sẻ
256
- (nguồn: `boot.ts`) và self-test tự chứng minh đường ghi Hub dùng được. Mở rộng cơ chế có
257
- sẵn rẻ hơn nhiều so với tạo cron mới — cron tốn credits ngay cả lúc tạo, đã kiểm chứng:
258
- `POST /api/scheduled-jobs/…` trả `402`.
259
-
260
- [PR #2](https://huggingface.co/spaces/bep40/vai-avatar/discussions/2) đã merge vào `main` của
261
- vai-avatar. Nó thêm `catalogue-mirror.ts` + `catalogue-mirror-boot.ts` + sửa `Dockerfile`.
262
- Wrapper chỉ start reconciler rồi `import("./boot.ts")` nên **`boot.ts` không bị sửa**. Chuỗi
263
- CMD 3 tầng:
264
-
265
- ```
266
- bun catalogue-mirror-boot.ts || bun boot.ts || bun entry_ai_image.ts
267
- ```
268
-
269
- Reconciler đọc hai nguồn mỗi 120 giây và commit `public/catalogue/` + `dist/catalogue/`
270
- **chỉ khi byte thật sự khác**. Không job định kỳ, không credit.
271
-
272
- **Nhưng reconciler đang TẮT và điều đó không còn là vấn đề.** Space vai-avatar không
273
- có secret nào (`GET /api/spaces/bep40/vai-avatar/secrets` → `[]`), nên
274
- `catalogue-mirror.ts` tự báo `"no HF_TOKEN/VAISTUDIO -> mirror OFF"` và không ghi gì.
275
- Trước đây điều đó chết dây chuyền đồng bộ; giờ thì không: Space này đọc thẳng dataset
276
- nên **không cần writer nào cả**. Muốn bật reconciler thì chỉ để cho mirror trong repo
277
- tự mới theo (tiện khi rollback, có bản dữ liệu nằm trong lịch sử git) — không bắt
278
- buộc, và là thay đổi hạ tầng trên Space đang chạy production nên cần bạn quyết.
279
-
280
- ⚠️ Mỗi commit lên `main` của vai-avatar đều rebuild Docker của Space đang phục vụ khách.
281
 
282
  ## Thay đổi so với bản gốc
283
 
284
  | | Bản gốc | Bản này |
285
  | --- | --- | --- |
286
  | Bộ ảnh | Unsplash, 24.989 ảnh | Nội thất VN, **1.222 ảnh** |
287
- | Model text | `Xenova/clip-vit-base-patch16` | `bep40/clip-vit-b32-multilingual-vi-text` (**50+ ngôn ngữ**) |
288
  | Nguồn ảnh | `images.unsplash.com` | `bep40/interior-image-field-assets` |
289
  | Giao diện | Tiếng Anh | Tiếng Việt |
290
 
291
  Model text là bản đóng gói của
292
- [`sentence-transformers/clip-ViT-B-32-multilingual-v1`](https://huggingface.co/sentence-transformers/clip-ViT-B-32-multilingual-v1).
293
- Bản gốc trên Hub chỉ export tới `last_hidden_state` 768 chiều, nên bản fork này nối thêm
294
- mean-pooling, phép chiếu 768→512 và chuẩn hoá L2, rồi đặt tên `text_model.onnx` theo đúng quy
295
- ước CLIP để Transformers.js nạp được mà không cần sửa mã nguồn.
 
 
 
 
 
 
 
296
 
297
- Ảnh được mã hoá **ngoại tuyến** bằng CLIP-ViT-B-32, lưu thành float32 thô 512 chiều mỗi ảnh.
298
- Mỗi bản ghi kèm `ar` và BlurHash `blur`. Chuyển động, bố cục Fibonacci, xem toàn màn hình, bàn
299
- phím và `prefers-reduced-motion` giữ nguyên theo bản gốc. Số ảnh hiển thị đồng thời vẫn là 96
300
  (56 trên màn hình hẹp).
301
 
302
- ## Triển khai
 
 
 
 
303
 
304
  ```sh
305
  npm install
306
- npm run sync:catalogue # ghi public/catalogue/{products,promos}.json
307
- npm run refresh:catalogue # sync + build + kiểm + trỏ index.html + commit + push
308
  ```
309
 
310
- Space phục vụ `index.html` ở gốc. File này **không** phải input của Vite (input là
311
- `app.html`), nên `vite build` không bao giờ sửa nó — hai dòng trong đó là thứ duy nhất nối Space
312
- với bundle.
313
-
314
- **Đừng sửa tay `index.html` sang tên cố định trước khi `dist/assets/index.js` tồn tại.**
315
- Trỏ tới file không có thì Space trắng trang. `repointIndexHtml()` kiểm tra tồn tại trước khi
316
- ghi nên chạy qua script là an toàn.
317
-
318
- `vite.config.js` đặt `emptyOutDir: false` vì `dist/` còn chứa khoảng 6.400 file dữ liệu đã
319
- commit mà không bước build nào sinh lại được. Nếu bật `emptyOutDir`, chúng biến mất mà ứng
320
- dụng vẫn chạy bình thường, không báo lỗi.
321
-
322
- `refresh-catalogue.mjs` chỉ commit path tường minh: `index.html`, `public/catalogue`,
323
- `dist/catalogue`, `dist/assets`. Không path nào trùng pattern LFS trong `.gitattributes`
324
- (pattern là `products-index.json` / `products-gallery.json` — tên cụ thể, không phải
325
- `products.json`), nên con trỏ LFS không bao giờ được commit nhầm.
326
-
327
- ## Kiểm tra nghiệm thu
328
 
329
- Sau khi `npm run refresh:catalogue` chạy xong, mở Space và tìm `BD-1054HVOW`:
 
330
 
331
- - xuất hiện **đúng một lần**
332
- - giá hiện **6.490.000₫**, không phải `5.990.000₫` cũ
333
- - bấm vào mở được trong cùng trình xem toàn màn hình với ảnh nội thất
334
 
335
- **Chưa kiểm được:** Space chưa từng build lại từ cây nguồn này. Toàn bộ mã `src/` và `scripts/`
336
- là mã đã đọc và kiểm từng dòng; các kết luận trên dựa trên **dữ liệu thật đã đọc từ Hub**,
337
- không phải suy đoán.
338
 
339
- ## URL
 
 
340
 
341
- Space dùng **static SDK**, nên `https://<id>.hf.space` trả 404 (đúng cả với bản gốc). Host đúng
342
- là **https://bep40-semantic-image-field.static.hf.space**
343
 
344
  Công nhận: ảnh thuộc về các tác giả trên
345
- [noithatphuphat.com](https://noithatphuphat.com). Pipeline suy luận theo ví dụ
346
- [`semantic-image-search-web`](https://github.com/huggingface/transformers.js-examples/tree/main/semantic-image-search-web).
 
1
  ---
2
+ title: Semantic Image Field — Nội thất
3
  emoji: 🛋️
4
  colorFrom: yellow
5
  colorTo: red
6
  sdk: static
7
  pinned: false
8
+ short_description: Tìm ảnh nội thất bằng tiếng Việt, chạy trong máy
 
9
  header: mini
10
  tags:
11
  - ml-intern
12
  ---
13
 
14
+ # Semantic Image Field — Nội thất
15
 
16
+ Bản phái sinh của [`webml-community/semantic-image-field`](https://huggingface.co/spaces/webml-community/semantic-image-field),
17
+ thay bộ ảnh Unsplash 24.989 ảnh bằng **1.222 ảnh thiết kế nội thất Việt Nam** và cho phép
18
+ **tìm kiếm bằng tiếng Việt**.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  ## Thay đổi so với bản gốc
21
 
22
  | | Bản gốc | Bản này |
23
  | --- | --- | --- |
24
  | Bộ ảnh | Unsplash, 24.989 ảnh | Nội thất VN, **1.222 ảnh** |
25
+ | Model text | `Xenova/clip-vit-base-patch16` (tiếng Anh) | `bep40/clip-vit-b32-multilingual-vi-text` (**50+ ngôn ngữ**) |
26
  | Nguồn ảnh | `images.unsplash.com` | `bep40/interior-image-field-assets` |
27
  | Giao diện | Tiếng Anh | Tiếng Việt |
28
 
29
  Model text là bản đóng gói của
30
+ [`sentence-transformers/clip-ViT-B-32-multilingual-v1`](https://huggingface.co/sentence-transformers/clip-ViT-B-32-multilingual-v1)
31
+ (text tower DistilBERT được huấn luyện để khớp không gian 512-d của CLIP-ViT-B-32).
32
+ Bản gốc trên Hub chỉ export tới `last_hidden_state` 768-chiều, nên bản fork này nối thêm
33
+ mean-pooling, phép chiếu 768→512 và chuẩn hoá L2, đặt tên `text_model.onnx` theo đúng
34
+ quy ước CLIP để Transformers.js nạp được không cần sửa mã nguồn.
35
+
36
+ Ảnh được mã hoá **ngoại tuyến** bằng CLIP-ViT-B-32 (`Xenova/clip-vit-base-patch32`),
37
+ lưu thành float32 thô 512 chiều mỗi ảnh. Mỗi bản ghi kèm `ar` và BlurHash `blur` để
38
+ `layout.js` và `image-loader.js` hoạt động như thiết kế gốc.
39
+
40
+ ## Vẫn giữ nguyên
41
 
42
+ Toàn bộ chuyển động, bố cục Fibonacci, hiệu ứng liên quan, xem toàn màn hình, hỗ trợ
43
+ bàn phím và `prefers-reduced-motion` của bản gốc. Số ảnh hiển thị đồng thời vẫn là 96
 
44
  (56 trên màn hình hẹp).
45
 
46
+ ## Chạy
47
+
48
+ Không có server, API key hay cơ sở dữ liệu. Suy luận và xếp hạng diễn ra trên máy người
49
+ dùng; truy vấn không được gửi đi. Lần dùng đầu cần tải model (~130 MB) và ảnh từ
50
+ Hugging Face.
51
 
52
  ```sh
53
  npm install
54
+ npm run dev
 
55
  ```
56
 
57
+ ## URL truy cập
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
+ Space này dùng **static SDK**, nên không có `https://<id>.hf.space` (trả 404 —
60
+ đúng cả với bản gốc `webml-community/semantic-image-field`). Host đúng là:
61
 
62
+ **https://bep40-semantic-image-field.static.hf.space**
 
 
63
 
64
+ > `README.md` không được khai báo `app_file`: static Space phục vụ thư mục gốc repo,
65
+ > không phải đường dẫn trong `app_file`.
 
66
 
67
+ `products-index.json` được nhân bản vào chính Space này và app fetch cùng-origin.
68
+ Hub chỉ gửi `Access-Control-Allow-Origin: https://huggingface.co` cho file dataset,
69
+ nên fetch từ domain `*.static.hf.space` sẽ bị CORS chặn.
70
 
 
 
71
 
72
  Công nhận: ảnh thuộc về các tác giả trên
73
+ [noithatphuphat.com](https://noithatphuphat.com). Pipeline suy luận dựa trên ví dụ
74
+ [`semantic-image-search-web`](https://github.com/huggingface/transformers.js-examples/tree/main/semantic-image-search-web).
app.html DELETED
@@ -1,28 +0,0 @@
1
- <!doctype html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <meta name="theme-color" content="#111210" />
7
- <meta name="description" content="Web App AI thuộc hệ sinh thái VIBE CODE AI của V.AI STUDIO" />
8
- <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
9
- <link rel="preconnect" href="https://huggingface.co" />
10
- <title>Thư viện tìm kiếm ảnh theo ngữ nghĩa ngôn ngữ tự nhiên</title>
11
- </head>
12
- <body>
13
- <!--
14
- This is the BUILD TEMPLATE, not the served page.
15
-
16
- vite.config.js sets build.rollupOptions.input to this file, so Vite emits
17
- dist/app.html with the hashed asset references injected. The Space serves
18
- the ROOT index.html, which is hand-maintained and loads ./dist/assets/…
19
-
20
- Keeping them separate matters: if the root index.html were also the build
21
- input, `vite build` would rewrite it, and the very first build would point
22
- the live Space at bundles that do not exist yet — a blank page with no
23
- error. See app.html in the repo root.
24
- -->
25
- <div id="root"></div>
26
- <script type="module" src="/src/main.jsx"></script>
27
- </body>
28
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dist/app.html DELETED
@@ -1,29 +0,0 @@
1
- <!doctype html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <meta name="theme-color" content="#111210" />
7
- <meta name="description" content="Web App AI thuộc hệ sinh thái VIBE CODE AI của V.AI STUDIO" />
8
- <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
9
- <link rel="preconnect" href="https://huggingface.co" />
10
- <title>Thư viện tìm kiếm ảnh theo ngữ nghĩa ngôn ngữ tự nhiên</title>
11
- <script type="module" crossorigin src="./assets/index.js"></script>
12
- <link rel="stylesheet" crossorigin href="./assets/app.css">
13
- </head>
14
- <body>
15
- <!--
16
- This is the BUILD TEMPLATE, not the served page.
17
-
18
- vite.config.js sets build.rollupOptions.input to this file, so Vite emits
19
- dist/app.html with the hashed asset references injected. The Space serves
20
- the ROOT index.html, which is hand-maintained and loads ./dist/assets/…
21
-
22
- Keeping them separate matters: if the root index.html were also the build
23
- input, `vite build` would rewrite it, and the very first build would point
24
- the live Space at bundles that do not exist yet — a blank page with no
25
- error. See app.html in the repo root.
26
- -->
27
- <div id="root"></div>
28
- </body>
29
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dist/assets/app.css DELETED
@@ -1,2 +0,0 @@
1
- /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
2
- @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-ease:initial;--neon-angle:0deg}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--ease-out:cubic-bezier(0, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-x-0{inset-inline:0}.top-60{top:calc(var(--spacing) * 60)}.bottom-0{bottom:0}.isolate{isolation:isolate}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.table{display:table}.h-dvh{height:100dvh}.w-full{width:100%}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.items-center{align-items:center}.justify-between{justify-content:space-between}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.border{border-style:var(--tw-border-style);border-width:1px}.capitalize{text-transform:capitalize}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}}@property --neon-angle{syntax:"<angle>";inherits:false;initial-value:0deg}.image-tile{--neon:#22f5d0;--neon-soft:#7ef9ff;--neon-run:2.6s}.image-tile.exact,.image-tile.featured{--relevance-opacity:1!important;--relevance-blur:0px!important}.image-tile.exact:before,.image-tile.featured:before{content:"";z-index:2;background:conic-gradient(from var(--neon-angle,0deg), var(--neon) 0deg, var(--neon-soft) 38deg, #fff 70deg, var(--neon-soft) 102deg, var(--neon) 148deg, #22f5d05c 250deg, #22f5d000 344deg, var(--neon) 360deg);-webkit-mask-composite:xor;pointer-events:none;-webkit-mask-composite:xor;-webkit-mask-source-type:auto,auto;-webkit-mask-composite:xor;-webkit-mask-source-type:auto,auto;border-radius:2px;padding:1px;position:absolute;inset:0;transform:none;-webkit-mask-image:linear-gradient(#000 0 0),linear-gradient(#000 0 0);mask-image:linear-gradient(#000 0 0),linear-gradient(#000 0 0);-webkit-mask-position:0 0,0 0;mask-position:0 0,0 0;-webkit-mask-size:auto,auto;mask-size:auto,auto;-webkit-mask-repeat:repeat,repeat;mask-repeat:repeat,repeat;-webkit-mask-clip:content-box,border-box;mask-clip:content-box,border-box;-webkit-mask-origin:content-box,border-box;mask-origin:content-box,border-box;-webkit-mask-composite:xor;mask-composite:exclude;-webkit-mask-source-type:auto,auto;mask-mode:match-source,match-source}.image-tile.exact:after,.image-tile.featured:after{content:"";z-index:1;background:conic-gradient(from var(--neon-angle,0deg), #22f5d000 0deg, #22f5d085 62deg, #b6fdffc4 92deg, #22f5d05e 138deg, #22f5d01f 250deg, #22f5d000 348deg, #22f5d000 360deg);-webkit-mask-composite:xor;filter:blur(4px);opacity:.82;box-shadow:none;pointer-events:none;-webkit-mask-composite:xor;-webkit-mask-source-type:auto,auto;-webkit-mask-composite:xor;-webkit-mask-source-type:auto,auto;border-radius:2px;padding:2.5px;position:absolute;inset:0;-webkit-mask-image:linear-gradient(#000 0 0),linear-gradient(#000 0 0);mask-image:linear-gradient(#000 0 0),linear-gradient(#000 0 0);-webkit-mask-position:0 0,0 0;mask-position:0 0,0 0;-webkit-mask-size:auto,auto;mask-size:auto,auto;-webkit-mask-repeat:repeat,repeat;mask-repeat:repeat,repeat;-webkit-mask-clip:content-box,border-box;mask-clip:content-box,border-box;-webkit-mask-origin:content-box,border-box;mask-origin:content-box,border-box;-webkit-mask-composite:xor;mask-composite:exclude;-webkit-mask-source-type:auto,auto;mask-mode:match-source,match-source}@keyframes neon-run{0%{--neon-angle:0deg}to{--neon-angle:360deg}}@supports at-rule(@property){.image-tile.exact:before,.image-tile.featured:before,.image-tile.exact:after,.image-tile.featured:after{animation:neon-run var(--neon-run) linear infinite}}.image-tile.featured{--neon-run:2s}.image-tile.featured:before{padding:1.5px}@media (prefers-reduced-motion:reduce){.image-tile.exact:before,.image-tile.exact:after,.image-tile.featured:before,.image-tile.featured:after{animation:none}}:root{color:#deded7;font-synthesis:none;text-rendering:optimizelegibility;-webkit-font-smoothing:antialiased;background:#10110f;font-family:Arial,Helvetica,sans-serif}*{box-sizing:border-box}body{margin:0}button,input,a{-webkit-tap-highlight-color:transparent}button,input{font:inherit}button{cursor:pointer}button:focus-visible,a:focus-visible{outline-offset:5px;outline:2px solid #d7e1d1}.brand-backdrop{z-index:0;pointer-events:none;opacity:.3;background-color:#10110f;background-image:url(../brand/vaistudio_center.png);background-position:50%;background-repeat:no-repeat;background-size:cover;background-attachment:fixed;position:fixed;inset:0}@media (width<=768px){.brand-backdrop{background-position:top;background-repeat:repeat;background-size:100%;background-attachment:scroll}}.app{background:0 0;min-height:320px;overflow:clip}.image-field{contain:strict;position:absolute;inset:0;overflow:clip}.image-tile{will-change:transform;transform-origin:50%;background-color:#252823;background-size:cover;border:0;border-radius:2px;padding:0;display:block;position:absolute;top:0;left:0;overflow:hidden;box-shadow:0 1px 5px #0002}.image-tile img{object-fit:cover;opacity:0;filter:brightness(.92);width:100%;height:100%;transition:opacity .12s,filter .12s;position:absolute;inset:0}.image-tile img.loaded{opacity:1}.image-tile:hover{box-shadow:0 10px 35px #0005}.image-tile:hover img{filter:brightness()}.image-share{z-index:1;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:#e6ebe0;opacity:0;background:#0d0f0bb8;border-radius:6px;place-items:center;width:22px;height:22px;transition:opacity .16s,transform .16s,background .16s,color .16s;display:grid;position:absolute;top:5px;right:5px;transform:scale(.82)}.image-share svg{width:13px;height:13px}.image-share:hover{color:#fff;background:#0d0f0be6}.image-share[hidden]{display:none}.image-tile:hover .image-share,.image-tile:focus-within .image-share,.image-tile[data-rank="1"] .image-share,.image-tile[data-rank="2"] .image-share,.image-tile[data-rank="3"] .image-share{opacity:1;transform:scale(1)}@media (prefers-reduced-motion:reduce){.image-share{transition:opacity .12s linear}}.image-tile{opacity:var(--relevance-opacity,1);filter:blur(var(--relevance-blur,0px));transition:opacity .65s,filter .65s}.image-tile:hover,.image-tile:focus-visible{opacity:1;filter:blur()}.image-tile:hover,.image-tile:focus-within{z-index:2147483647}.vignette{pointer-events:none;z-index:300;background:radial-gradient(#0000 42%,#0c0d0b30 100%),linear-gradient(0deg,#10110f99,#0000 70px);position:absolute;inset:0}.grain{z-index:301;pointer-events:none;opacity:.045;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Cpath fill='%23fff' filter='url(%23n)' opacity='.7' d='M0 0h180v180H0z'/%3E%3C/svg%3E");position:absolute;inset:0}.search-center{z-index:400;width:clamp(350px,36vw,640px);max-width:calc(100% - 44px);position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.search-pill{-webkit-backdrop-filter:blur(26px)saturate(.7);backdrop-filter:blur(26px)saturate(.7);background:#e4e8ddbb;border-radius:999px;height:clamp(62px,4.8vw,84px);transition:background .25s,box-shadow .25s;position:relative;overflow:hidden;box-shadow:inset 0 1px #ffffff3d,0 6px 36px #00000018}.search-pill:focus-within{background:#e6eaddd4;box-shadow:inset 0 1px #ffffff52,0 5px 40px #0002}.search-pill input{color:#20251f;letter-spacing:-.035em;caret-color:#323b2d;background:0 0;border:0;outline:none;width:100%;height:100%;padding:0 58px 0 27px;font-size:clamp(22px,1.8vw,30px)}.search-pill input::placeholder{color:#565d54;opacity:1}.search-pill input::selection{background:#80957070}.clear-button{color:#4c5449;background:0 0;border:0;border-radius:50%;place-items:center;width:34px;height:34px;padding:7px;transition:background .15s;display:grid;position:absolute;top:50%;right:13px;transform:translateY(-50%)}.clear-button:hover{background:#0000000d}.clear-button svg{width:18px;height:18px}.search-progress{opacity:0;height:1px;transition:opacity .3s;position:absolute;bottom:0;left:24px;right:24px;overflow:hidden}.search-progress.visible{opacity:.5}.search-progress span{background:#47513f;height:100%;transition:width .3s;animation:1.6s ease-in-out infinite progress-glow;display:block}.search-status{letter-spacing:.01em;color:#d2d6ccc9;text-shadow:0 1px 5px #000,0 1px 12px #000;pointer-events:none;justify-content:center;align-items:center;gap:7px;min-height:20px;font-size:12px;display:flex;position:absolute;top:calc(100% + 13px);left:-28px;right:-28px}.status-dot{background:#d2d8c9;border-radius:50%;width:4px;height:4px;animation:1.6s ease-in-out infinite progress-glow}.has-error-inline{color:#e8b4a9;text-align:center}.has-error{pointer-events:auto;text-shadow:none;background:#171b16ee;border-radius:10px;flex-wrap:wrap;padding:12px;font-size:14px}.has-error button{color:#eee;background:0 0;border:0;border-bottom:1px solid #ccc;padding:0}.error-detail{text-align:center;opacity:.7;overflow-wrap:anywhere;width:100%;font-size:12px}.footer{z-index:401;color:#bac0b395;letter-spacing:.015em;padding:20px 24px;font-size:12px}.footer a{color:inherit;text-decoration:none;transition:color .2s}.footer a:hover{color:#f5f6ef}.version{opacity:.55;margin-left:5px}.footer-divider{opacity:.5;padding:0 8px}@keyframes progress-glow{0%,to{opacity:.4}50%{opacity:1}}@media (width<=650px){.search-center{width:min(82vw,400px)}.search-pill{height:60px}.search-pill input{padding-left:23px;font-size:22px}.footer{padding:16px;font-size:12px}.footer .version,.footer-divider{display:none}.footer>span{font-size:0}.footer>span:after{content:"On-device search";font-size:12px}}@media (prefers-reduced-motion:reduce){*,:before,:after{transition:none!important;animation:none!important}}.photo-viewer{color:#e8eae3;-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);background:#0c0e0bdc;border:0;width:100vw;max-width:none;height:100dvh;max-height:none;margin:0;padding:0;position:fixed;inset:0;overflow:hidden}.photo-viewer[open]{animation:.42s both viewer-reveal;display:block}.photo-viewer::backdrop{background:#0005}.photo-viewer.is-closing{opacity:0;transition:opacity .32s;animation:none}.photo-toolbar{z-index:2;pointer-events:none;justify-content:space-between;align-items:center;display:flex;position:absolute;inset:18px 24px auto}.photo-toolbar a,.photo-toolbar button{pointer-events:auto;color:#e0e5d9;background:#12160fe6;border:1px solid #ffffff18;border-radius:999px}.photo-toolbar a{padding:12px 17px;font-size:14px;text-decoration:none}.photo-toolbar button{place-items:center;width:44px;height:44px;display:grid}.photo-share{white-space:nowrap;align-items:center;gap:9px;display:inline-flex}.photo-share svg{flex:none;width:17px;height:17px}.photo-share:hover{color:#fff;background:#1b2115f2;border-color:#ffffff2e}@media (width<=560px){.photo-share span{clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.photo-share{padding:12px}}.photo-toolbar svg{width:22px;height:22px}.photo-origin{pointer-events:none;color:#e0e5d9;text-overflow:ellipsis;white-space:nowrap;background:#12160fe6;border:1px solid #ffffff18;border-radius:999px;max-width:60vw;padding:12px 17px;font-size:14px;overflow:hidden}.photo-viewport{place-items:center;padding:72px 24px 40px;display:grid;position:absolute;inset:0;overflow:auto}.photo-frame{width:min(1800px, calc(100vw - 48px), calc((100dvh - 128px) * var(--photo-ar)));aspect-ratio:var(--photo-ar);cursor:zoom-in;transform-origin:50%;background-color:#161912;background-size:100% 100%;border:0;border-radius:3px;flex:none;padding:0;display:block;box-shadow:0 12px 70px #0005}.photo-frame img{object-fit:contain;opacity:0;width:100%;height:100%;transition:opacity .3s;display:block}.photo-frame img.loaded{opacity:1}.photo-viewport.is-zoomed{display:block}.photo-viewport.is-zoomed .photo-frame{width:max(calc(100vw - 48px), calc((100dvh - 128px) * var(--photo-ar)));cursor:zoom-out;margin:0 auto}@keyframes viewer-reveal{0%{opacity:0}to{opacity:1}}.photo-step{color:#e0e5d9;background:#12160fd9;border:1px solid #ffffff18;border-radius:50%;place-items:center;width:46px;height:46px;padding:0;transition:background .18s,border-color .18s;display:grid;position:absolute;top:50%;transform:translateY(-50%)}.photo-step:hover{background:#1b2118e6;border-color:#ffffff2e}.photo-step svg{width:22px;height:22px}.photo-step.is-prev{left:18px}.photo-step.is-next{right:18px}@media (width<=650px){.photo-step{width:38px;height:38px}.photo-step.is-prev{left:8px}.photo-step.is-next{right:8px}}.photo-caption{z-index:2;-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);background:#0f130ccc;border:1px solid #ffffff17;border-radius:10px;max-width:min(560px,100vw - 48px);padding:12px 16px;position:absolute;bottom:18px;left:24px;right:24px}.photo-caption-price{color:#dfe6d2;margin:0 0 6px;font-size:15px;font-weight:600}.photo-caption-specs{gap:3px;margin:0;font-size:12px;display:grid}.photo-caption-specs>div{grid-template-columns:minmax(0,40%) minmax(0,60%);gap:10px;display:grid}.photo-caption-specs dt{color:#98a08f;overflow-wrap:anywhere}.photo-caption-specs dd{color:#d2d7cb;overflow-wrap:anywhere;margin:0}.photo-counter{z-index:2;font-variant-numeric:tabular-nums;color:#d2d7cb;background:#0f130ccc;border:1px solid #ffffff17;border-radius:999px;margin:0;padding:6px 12px;font-size:12px;position:absolute;bottom:22px;right:24px}@media (width<=650px){.photo-caption{padding:10px 12px;bottom:12px;left:12px;right:12px}.photo-counter{display:none}}.photo-detail{z-index:2;-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);background:#0f130ce8;border:1px solid #ffffff1f;border-radius:12px;width:min(460px,100vw - 48px);position:absolute;bottom:18px;left:24px;overflow:hidden}.photo-detail-summary{cursor:pointer;-webkit-user-select:none;user-select:none;align-items:center;gap:10px;padding:11px 14px;list-style:none;display:flex}.photo-detail-summary::-webkit-details-marker{display:none}.photo-detail-summary:hover{background:#ffffff0a}.photo-detail-title{color:#e4e9dc;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;font-size:14px;line-height:1.35;overflow:hidden}.photo-detail-price{color:#9fd6a4;font-variant-numeric:tabular-nums;flex:none;font-size:13px;font-weight:600}.photo-detail-toggle{color:#99a291;text-transform:uppercase;letter-spacing:.05em;border:1px solid #ffffff1f;border-radius:999px;flex:none;padding:3px 9px;font-size:11px;transition:color .18s}.photo-detail[open] .photo-detail-toggle{color:#cdd6c4;border-color:#fff3}.photo-detail-body{border-top:1px solid #ffffff12;padding:0 14px 13px}.photo-detail-desc{color:#c3cabb;margin:11px 0 0;font-size:13px;line-height:1.6}.photo-detail-tags{flex-wrap:wrap;gap:6px;margin:11px 0 0;padding:0;list-style:none;display:flex}.photo-detail-tags li{color:#b6c2ab;background:#ffffff0a;border:1px solid #ffffff17;border-radius:999px;padding:3px 9px;font-size:11px}.photo-detail-facts{gap:4px;margin:11px 0 0;font-size:12px;display:grid}.photo-detail-facts>div{grid-template-columns:minmax(0,38%) minmax(0,62%);gap:10px;display:grid}.photo-detail-facts dt{color:#939d8a;overflow-wrap:anywhere}.photo-detail-facts dd{color:#d5dbcd;overflow-wrap:anywhere;margin:0}.photo-viewer:has(.photo-caption) .photo-detail{top:76px;bottom:auto}@media (width<=650px){.photo-detail{width:auto;bottom:12px;left:12px;right:12px}.photo-viewer:has(.photo-caption) .photo-detail{top:70px}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}
 
 
 
dist/assets/index-CX-0QtoD.js ADDED
The diff for this file is too large to render. See raw diff
 
dist/assets/index-CigEzVrZ.js ADDED
The diff for this file is too large to render. See raw diff
 
dist/assets/index-DGvjonwp.js ADDED
The diff for this file is too large to render. See raw diff
 
dist/assets/index-DcLscXnL.css ADDED
@@ -0,0 +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;--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}.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}.bottom-0{bottom:0}.isolate{isolation:isolate}.block{display:block}.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%}.shrink{flex-shrink:1}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.items-center{align-items:center}.justify-between{justify-content:space-between}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.capitalize{text-transform:capitalize}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}}:root{color:#deded7;font-synthesis:none;text-rendering:optimizelegibility;-webkit-font-smoothing:antialiased;background:#10110f;font-family:Arial,Helvetica,sans-serif}*{box-sizing:border-box}body{margin:0}button,input,a{-webkit-tap-highlight-color:transparent}button,input{font:inherit}button{cursor:pointer}button:focus-visible,a:focus-visible{outline-offset:5px;outline:2px solid #d7e1d1}.app{background:#11120f;min-height:320px;overflow:clip}.image-field{contain:strict;position:absolute;inset:0;overflow:clip}.image-tile{will-change:transform;transform-origin:50%;background-color:#252823;background-size:cover;border:0;border-radius:2px;padding:0;display:block;position:absolute;top:0;left:0;overflow:hidden;box-shadow:0 1px 5px #0002}.image-tile img{object-fit:cover;opacity:0;filter:brightness(.92);width:100%;height:100%;transition:opacity .12s,filter .12s}.image-tile img.loaded{opacity:1}.image-tile:hover{box-shadow:0 10px 35px #0005}.image-tile:hover img{filter:brightness()}.image-tile{opacity:var(--relevance-opacity,1);filter:blur(var(--relevance-blur,0px));transition:opacity .65s,filter .65s}.image-tile:hover,.image-tile:focus-visible{opacity:1;filter:blur()}.vignette{pointer-events:none;z-index:300;background:radial-gradient(#0000 42%,#0c0d0b30 100%),linear-gradient(0deg,#10110f99,#0000 70px);position:absolute;inset:0}.grain{z-index:301;pointer-events:none;opacity:.045;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Cpath fill='%23fff' filter='url(%23n)' opacity='.7' d='M0 0h180v180H0z'/%3E%3C/svg%3E");position:absolute;inset:0}.search-center{z-index:400;width:clamp(350px,36vw,640px);max-width:calc(100% - 44px);position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.search-pill{-webkit-backdrop-filter:blur(26px)saturate(.7);backdrop-filter:blur(26px)saturate(.7);background:#e4e8ddbb;border-radius:999px;height:clamp(62px,4.8vw,84px);transition:background .25s,box-shadow .25s;position:relative;overflow:hidden;box-shadow:inset 0 1px #ffffff3d,0 6px 36px #00000018}.search-pill:focus-within{background:#e6eaddd4;box-shadow:inset 0 1px #ffffff52,0 5px 40px #0002}.search-pill input{color:#20251f;letter-spacing:-.035em;caret-color:#323b2d;background:0 0;border:0;outline:none;width:100%;height:100%;padding:0 58px 0 27px;font-size:clamp(22px,1.8vw,30px)}.search-pill input::placeholder{color:#565d54;opacity:1}.search-pill input::selection{background:#80957070}.clear-button{color:#4c5449;background:0 0;border:0;border-radius:50%;place-items:center;width:34px;height:34px;padding:7px;transition:background .15s;display:grid;position:absolute;top:50%;right:13px;transform:translateY(-50%)}.clear-button:hover{background:#0000000d}.clear-button svg{width:18px;height:18px}.search-progress{opacity:0;height:1px;transition:opacity .3s;position:absolute;bottom:0;left:24px;right:24px;overflow:hidden}.search-progress.visible{opacity:.5}.search-progress span{background:#47513f;height:100%;transition:width .3s;animation:1.6s ease-in-out infinite progress-glow;display:block}.search-status{letter-spacing:.01em;color:#d2d6ccc9;text-shadow:0 1px 5px #000,0 1px 12px #000;pointer-events:none;justify-content:center;align-items:center;gap:7px;min-height:20px;font-size:12px;display:flex;position:absolute;top:calc(100% + 13px);left:-28px;right:-28px}.status-dot{background:#d2d8c9;border-radius:50%;width:4px;height:4px;animation:1.6s ease-in-out infinite progress-glow}.has-error{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-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}}.product-panel{z-index:500;-webkit-backdrop-filter:blur(22px);backdrop-filter:blur(22px);color:#e6e9e0;background:#0f120eea;flex-direction:column;display:flex;position:fixed;inset:0}.product-panel-head{border-bottom:1px solid #ffffff14;align-items:center;gap:18px;padding:20px 26px 14px;display:flex}.product-panel-title{flex:none;align-items:baseline;gap:11px;display:flex}.product-panel-title h2{letter-spacing:-.02em;margin:0;font-size:19px;font-weight:600}.product-count{color:#a8b09e;white-space:nowrap;font-size:12px}.product-search{flex:1;min-width:0}.product-search input{color:#eef1e8;background:#ffffff0f;border:1px solid #ffffff1f;border-radius:999px;outline:none;width:100%;height:42px;padding:0 18px;font-size:15px;transition:border-color .2s,background .2s}.product-search input:focus{background:#ffffff17;border-color:#ffffff45}.product-search input::placeholder{color:#8b9382}.product-search input::-webkit-search-cancel-button{filter:invert(.7)}.product-close{color:#dfe3d8;cursor:pointer;background:#ffffff0d;border:1px solid #ffffff1f;border-radius:50%;flex:none;width:40px;height:40px;font-size:15px;transition:background .18s}.product-close:hover{background:#ffffff1c}.product-brands{border-bottom:1px solid #ffffff10;flex-wrap:wrap;gap:8px;padding:13px 26px;display:flex}.product-brands button{color:#c3c9ba;cursor:pointer;background:0 0;border:1px solid #ffffff1a;border-radius:999px;padding:7px 15px;font-size:13px;transition:background .18s,color .18s,border-color .18s}.product-brands button:hover{color:#eef1e8;background:#ffffff12}.product-brands button.active{color:#1d231b;background:#e4e8dd;border-color:#e4e8dd}.product-body{overscroll-behavior:contain;flex:1;padding:20px 26px 40px;overflow-y:auto}.product-grid{grid-template-columns:repeat(auto-fill,minmax(178px,1fr));gap:15px;display:grid}.product-card{color:inherit;text-align:left;cursor:pointer;background:#1a1e17;border:1px solid #ffffff14;border-radius:11px;flex-direction:column;padding:0;transition:transform .22s cubic-bezier(.2,.7,.3,1),border-color .22s,box-shadow .22s;display:flex;overflow:hidden}.product-card:hover,.product-card:focus-visible{border-color:#fff3;transform:translateY(-3px);box-shadow:0 12px 34px #00000059}.product-card img{aspect-ratio:4/3;object-fit:cover;background:#12160f;width:100%;display:block}.product-no-image{aspect-ratio:4/3;color:#6f7767;background:#171b14;place-items:center;width:100%;font-size:12px;display:grid}.product-card-body{flex-direction:column;gap:3px;padding:10px 12px 12px;display:flex}.product-brand{letter-spacing:.07em;text-transform:uppercase;color:#8b9a7c;font-size:10.5px}.product-name{-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:13.5px;line-height:1.34;display:-webkit-box;overflow:hidden}.product-price{color:#dfe6d2;margin-top:2px;font-size:13px;font-weight:600}.product-sku{color:#8b9382;margin-top:2px;font-size:11.5px}.product-more{color:#e8ece1;cursor:pointer;background:#ffffff0f;border:1px solid #ffffff26;border-radius:999px;margin:24px auto 0;padding:13px 30px;font-size:14px;transition:background .2s;display:block}.product-more:hover{background:#ffffff1c}.product-more-total{color:#8b9382;margin-left:5px;font-size:12.5px}.product-empty{text-align:center;color:#99a08f;padding:60px 0;font-size:14px}.footer-catalog{color:#dfe3d8;cursor:pointer;background:#ffffff0f;border:1px solid #ffffff1f;border-radius:999px;padding:9px 17px;font-size:13px;transition:background .2s,border-color .2s}.footer-catalog:hover{background:#ffffff1c;border-color:#ffffff38}.footer-catalog kbd{background:#ffffff1a;border-radius:4px;margin-left:6px;padding:1px 6px;font-family:inherit;font-size:11px}.product-detail{z-index:600;place-items:center;padding:26px;display:grid;position:fixed;inset:0}.product-detail-backdrop{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);cursor:zoom-out;background:#050705d9;border:0;position:absolute;inset:0}.product-detail-panel{background:#161a13;border-radius:15px;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:0;width:min(1080px,100%);max-height:min(88dvh,900px);animation:.32s both viewer-reveal;display:grid;position:relative;overflow:hidden;box-shadow:0 30px 90px #000000a6}.product-detail-close{z-index:2;color:#e2e6db;cursor:pointer;background:#0d100ae0;border:1px solid #ffffff24;border-radius:50%;width:38px;height:38px;font-size:14px;position:absolute;top:13px;right:13px}.product-detail-close:hover{background:#0d100a}.product-detail-media{background:#0e110c;flex-direction:column;display:flex}.product-detail-media>img{object-fit:contain;width:100%;height:auto;max-height:60dvh;display:block}.product-detail-noimage{color:#6f7767;place-items:center;min-height:260px;font-size:13px;display:grid}.product-thumbs{gap:8px;padding:12px;display:flex;overflow-x:auto}.product-thumbs button{cursor:pointer;opacity:.55;background:#171b14;border:2px solid #0000;border-radius:8px;flex:none;width:60px;height:60px;padding:0;transition:opacity .18s,border-color .18s;overflow:hidden}.product-thumbs button:hover{opacity:.85}.product-thumbs button.active{opacity:1;border-color:#e4e8dd}.product-thumbs img{object-fit:cover;width:100%;height:100%;display:block}.product-detail-info{padding:26px 30px 30px;overflow-y:auto}.product-detail-brand{letter-spacing:.09em;text-transform:uppercase;color:#8b9a7c;font-size:11px}.product-detail-info h3{letter-spacing:-.02em;margin:7px 0 12px;font-size:23px;font-weight:600;line-height:1.26}.product-detail-meta{flex-wrap:wrap;align-items:baseline;gap:14px;display:flex}.product-detail-price{color:#dfe6d2;font-size:21px;font-weight:600}.product-detail-sku{color:#8b9382;font-size:13px}.product-detail-link{color:#b9c9a4;border-bottom:1px solid #b9c9a45c;margin-top:14px;padding-bottom:1px;font-size:13.5px;text-decoration:none;display:inline-block}.product-detail-link:hover{color:#d5e3c1;border-color:#d5e3c1}.product-detail-summary{color:#c3c9ba;margin:18px 0 0;font-size:14px;line-height:1.62}.product-detail-feats{flex-wrap:wrap;gap:7px;margin-top:18px;display:flex}.product-detail-feats span{color:#c8cebd;background:#ffffff10;border:1px solid #ffffff14;border-radius:999px;padding:5px 11px;font-size:12px}.product-detail-specs{border-collapse:collapse;width:100%;margin-top:22px;font-size:13px}.product-detail-specs caption{text-align:left;letter-spacing:.08em;text-transform:uppercase;color:#8b9a7c;padding-bottom:9px;font-size:11.5px}.product-detail-specs th,.product-detail-specs td{text-align:left;vertical-align:top;border-top:1px solid #ffffff12;padding:9px 0;line-height:1.5}.product-detail-specs th{color:#929a89;width:40%;padding-right:14px;font-weight:500}.product-detail-specs td{color:#d3d8cb}@media (width<=860px){.product-detail-panel{grid-template-columns:1fr;max-height:92dvh;overflow-y:auto}.product-detail-media>img{max-height:42dvh}.product-detail-info{padding:20px}.product-panel-head{flex-wrap:wrap;padding:16px 18px 12px}.product-brands,.product-body{padding-left:18px;padding-right:18px}.product-grid{grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:11px}.footer-catalog{padding:7px 13px;font-size:12px}.footer-catalog kbd{display:none}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}
dist/assets/index.js DELETED
The diff for this file is too large to render. See raw diff
 
dist/assets/neon-frame.css DELETED
@@ -1,170 +0,0 @@
1
- /* ── Neon frame: a light that runs around the picture ──────────────────────
2
- *
3
- * Loaded from index.html AFTER dist/assets/app.css, and deliberately overriding
4
- * the ring that bundle paints. It exists because that ring is wrong in a way
5
- * only shows up on non-square tiles.
6
- *
7
- * THE BUG IT UNDOES
8
- * -----------------
9
- * The @keyframes neon-run shipped in app.css rotated the whole ::before layer
10
- * by one turn. That layer carries BOTH the conic gradient and the mask, so the
11
- * mask rotated with it and stopped lining up with the tile's edges. On a 16:9
12
- * photograph the painted border tilted off the frame and the highlight read as
13
- * a skewed smear rather than a light going round the picture -- which is
14
- * exactly what "a static neon border" looked like on screen. A tile that
15
- * happened to be nearly square hid the bug, so the effect only ever looked
16
- * broken on real photographs.
17
- *
18
- * WHAT MOVES NOW
19
- * --------------
20
- * The layer never transforms. What animates is the conic gradient's own start
21
- * angle, --neon-angle, so the bright head walks the perimeter and follows the
22
- * rectangle exactly: it runs down the long edge, turns the corner and comes
23
- * back along the short edge, like a light circling a picture frame.
24
- *
25
- * `--neon-angle` is REGISTERED with @property because an unregistered custom
26
- * property is an untyped token the engine cannot interpolate -- a 0deg to
27
- * 360deg keyframe on one flips once at the midpoint instead of sweeping. That
28
- * flip is worse than the static ring, which is why the animation is gated on
29
- * @supports below: a browser without registration gets the gradient and no
30
- * motion at all, i.e. a lit frame with the head parked at the top.
31
- *
32
- * `inherits: false` keeps the angle on the ring itself. The tile is transformed
33
- * every frame by the field engine, and an inherited animated property would be
34
- * recomputed against a moving box -- which is how the gradient ends up sliding
35
- * relative to the picture it is supposed to be travelling around.
36
- *
37
- * TWO LAYERS, ONE CLOCK
38
- * ---------------------
39
- * ::before is the crisp stroke. ::after is the halo: the same sweep, spread
40
- * over a wider arc and blurred, so the light blooms off the edge instead of
41
- * being cut flat against it. They are one pseudo-element apart because a
42
- * pseudo-element can only carry one background, and a neon frame needs both the
43
- * stroke and the glow.
44
- *
45
- * Both animate over the same duration from the same start angle, so the halo
46
- * stays locked to the head of the stroke on every lap rather than drifting
47
- * ahead of it.
48
- *
49
- * `mask` is what turns the full-size gradient into a ring. Without the composite
50
- * the gradient would fill the whole tile and hide the photograph. The -webkit-
51
- * forms are repeated because Safari needs the prefixed one, and the standard
52
- * alone would leave Safari with an opaque tile.
53
- *
54
- * src/neon-frame.css carries the same rules, so a rebuild produces the same
55
- * frame and this file becomes a no-op rather than a divergence.
56
- */
57
- @property --neon-angle {
58
- syntax: '<angle>';
59
- inherits: false;
60
- initial-value: 0deg;
61
- }
62
-
63
- .image-tile {
64
- --neon: #22f5d0;
65
- --neon-soft: #7ef9ff;
66
- --neon-run: 2.6s;
67
- }
68
-
69
- /* The crisp stroke: a white-hot head at 0deg, a comet tail fading back round
70
- to 250deg, and a clear gap before the head returns -- so the eye reads a
71
- single light travelling, not a ring that pulses in place. */
72
- .image-tile.exact::before,
73
- .image-tile.featured::before {
74
- padding: 1px;
75
- background: conic-gradient(from var(--neon-angle, 0deg),
76
- var(--neon) 0deg,
77
- var(--neon-soft) 38deg,
78
- #ffffff 70deg,
79
- var(--neon-soft) 102deg,
80
- var(--neon) 148deg,
81
- #22f5d05c 250deg,
82
- #22f5d000 344deg,
83
- var(--neon) 360deg);
84
- -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
85
- -webkit-mask-composite: xor;
86
- mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
87
- mask-composite: exclude;
88
- /* Any transform here drags the mask off the frame, which is the bug above.
89
- Stated explicitly so a later edit cannot reintroduce it by accident. */
90
- transform: none;
91
- }
92
-
93
- /* The halo. Same sweep from the same angle, spread wider so the light spills
94
- both inward off the edge and outward into the dark around the tile. */
95
- .image-tile.exact::after,
96
- .image-tile.featured::after {
97
- padding: 2.5px;
98
- background: conic-gradient(from var(--neon-angle, 0deg),
99
- #22f5d000 0deg,
100
- #22f5d085 62deg,
101
- #b6fdffc4 92deg,
102
- #22f5d05e 138deg,
103
- #22f5d01f 250deg,
104
- #22f5d000 348deg,
105
- #22f5d000 360deg);
106
- -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
107
- -webkit-mask-composite: xor;
108
- mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
109
- mask-composite: exclude;
110
- filter: blur(4px);
111
- opacity: .82;
112
- /* The stock rule animated box-shadow here instead. A box-shadow breathes in
113
- place -- it cannot run along the perimeter, because the shadow's shape is
114
- the tile's shape and never travels round it. */
115
- box-shadow: none;
116
- }
117
-
118
- /* The head sweeps one full turn clockwise; the halo sweeps the same turn over
119
- the same duration, so the two re-align every lap instead of drifting apart.
120
- *
121
- * NOTE this is a REPLACEMENT for app.css's neon-run, not an addition. Keyframes
122
- * are global and the last definition of a name wins, so this block takes the
123
- * name over from the built one for the whole page -- which is exactly what
124
- * retires its `rotate(1turn)`, and also why the ungated reset below matters.
125
- */
126
- @keyframes neon-run {
127
- from { --neon-angle: 0deg; }
128
- to { --neon-angle: 360deg; }
129
- }
130
-
131
- /* Both pseudo-elements default to NO animation, outside any @supports gate.
132
- *
133
- * This has to be unconditional, and it is the subtle part of this file.
134
- * app.css applies `animation: neon-run ...` to these selectors with no feature
135
- * test of its own, so a browser that cannot register @property would otherwise
136
- * run the keyframes above -- animating an untyped token, which snaps at the
137
- * halfway point. The ring would blink once per cycle and look broken in a way
138
- * the old static ring never did.
139
- *
140
- * Setting `animation: none` first and enabling it only inside @supports makes
141
- * the unsupported case the safe one: a lit frame, head parked at the top, no
142
- * motion. It also has to win on order against app.css, which this file loads
143
- * after, so the @supports rule below re-asserts the animation.
144
- */
145
- .image-tile.exact::before,
146
- .image-tile.featured::before,
147
- .image-tile.exact::after,
148
- .image-tile.featured::after { animation: none; }
149
-
150
- @supports at-rule(@property) {
151
- .image-tile.exact::before,
152
- .image-tile.featured::before,
153
- .image-tile.exact::after,
154
- .image-tile.featured::after {
155
- animation: neon-run var(--neon-run) linear infinite;
156
- }
157
- }
158
-
159
- /* A featured tile -- the exact answer to the query, or a photograph just added
160
- on vai-avatar -- runs faster and thinner, so the newest photographs are
161
- distinguishable from an exact hit by pace as well as by size. */
162
- .image-tile.featured { --neon-run: 2s; }
163
- .image-tile.featured::before { padding: 1.5px; }
164
-
165
- /* A slow, low-frequency sweep reads as "lit"; a fast strobe reads as an alarm,
166
- and this frame marks a correct answer rather than an error. */
167
- @media (prefers-reduced-motion: reduce) {
168
- .image-tile.exact::before, .image-tile.exact::after,
169
- .image-tile.featured::before, .image-tile.featured::after { animation: none; }
170
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dist/assets/share-fix.js DELETED
@@ -1,193 +0,0 @@
1
- /**
2
- * Repairs the share link on vai-avatar interior photographs, in the browser.
3
- * =========================================================================
4
- *
5
- * THE SYMPTOM
6
- * A photograph published on vai-avatar opens its share page at
7
- *
8
- * https://bep40-vai-avatar.hf.space/share/p/avatarimg%3Ab68dc779370dc70e
9
- *
10
- * which is vai-avatar's "Not Found" page. Every interior photograph is
11
- * affected, on the tile's share button and on the viewer's "Xem trang" alike.
12
- *
13
- * WHY IT HAPPENS
14
- * avatar-interiors.js gives every such photograph a namespaced `sku` of
15
- * `avatarimg:<image_id>`, so it travels the same code path as a catalogue
16
- * product: same viewer, same detail panel, same neon. Namespacing is
17
- * deliberate -- it is what stops a 16-hex image_id colliding with a baked
18
- * interior's id in merge.js's de-duplication set.
19
- *
20
- * But `avatarimg:...` is not an identity the product catalogue knows, and
21
- * vai-avatar's `/share/p/` resolves against exactly that catalogue. So the
22
- * photograph was sent to the product route, where no such SKU exists, and the
23
- * Space answered Not Found. There is nothing to fix on vai-avatar's side: the
24
- * record it would need to find does not exist. The photograph is an interior,
25
- * and the interior route is `/share/i/<image_id>`.
26
- *
27
- * WHY THIS IS A FILE AND NOT ONLY THE SOURCE FIX
28
- * src/assets.js now routes on `avatarImageId` first, and that is the real fix.
29
- * It is not, though, what the browser runs yet: this Space is `sdk: static`, so
30
- * the page is served from dist/, and dist/assets/index.js was built before that
31
- * fix existed. The neon frame has the same problem -- dist/assets/app.css still
32
- * carries the old `@keyframes neon-run { transform: rotate(1turn) }`, which
33
- * rotated the mask off the frame. Rebuilding needs a shell.
34
- *
35
- * So this corrects the URL where it is consumed, with no rebuild, and the
36
- * source fix carries the same logic forward. The two agree by construction:
37
- * both send `avatarimg:<id>` to `/share/i/<id>`.
38
- *
39
- * WHAT IT COSTS
40
- * One capture-phase listener and one MutationObserver. The observer watches
41
- * only `href` on the whole document -- the field already rebuilds 80 tiles per
42
- * keystroke, but share hrefs change on layout passes, not on the per-frame
43
- * transform writes, so this sees a handful of callbacks rather than 60/s. The
44
- * work per callback is one regex against an attribute value that is almost
45
- * always not a share href.
46
- *
47
- * Once the bundle is rebuilt from src/assets.js this repairs nothing, because
48
- * the hrefs it would find are already correct. It stays loaded rather than
49
- * un-loading itself: it is a few regexes against a handful of anchors, and a
50
- * self-disarming timer is a far worse thing to reason about than inert code.
51
- */
52
-
53
- const STUDIO = 'https://bep40-vai-avatar.hf.space';
54
-
55
- /** Anchors worth checking at all: the viewer plus the tile share buttons. */
56
- const SHARE_ANCHORS = 'a[href*="/share/"]';
57
-
58
- /**
59
- * A share href that names an interior photograph by its namespaced sku.
60
- *
61
- * Matches the literal `avatarimg` immediately after `/share/p/`, then either
62
- * separator: the value may not have been encoded (`avatarimg:`) or may have
63
- * been (`avatarimg%3A`). Only that one segment is taken as the separator, so an
64
- * id that itself contains a colon still comes through whole.
65
- */
66
- const BROKEN = /\/share\/p\/(avatarimg)(?::|%3A)([^/?#]+)/i;
67
-
68
- /**
69
- * The same URL, routed as the interior it is.
70
- *
71
- * The id is decoded first: it arrived percent-encoded inside the URL, and
72
- * re-encoding the raw `%3A` would produce `%253A`. `decodeURIComponent` throws
73
- * on a malformed escape, so a bad id is left alone rather than taking the whole
74
- * listener down -- one broken href must not break the page it sits on.
75
- *
76
- * An empty id is rejected too: `/share/i/` with nothing after it is its own
77
- * 404, so redirecting there would trade one dead link for another.
78
- */
79
- function interiorURL(href) {
80
- const match = BROKEN.exec(href);
81
- if (!match) return null;
82
- let id;
83
- try {
84
- id = decodeURIComponent(match[2]);
85
- } catch {
86
- return null;
87
- }
88
- id = id.trim();
89
- return id ? `${STUDIO}/share/i/${encodeURIComponent(id)}` : null;
90
- }
91
-
92
- /** Rewrite one anchor's href if it is mis-routed. Returns true if it changed. */
93
- function repair(anchor) {
94
- const href = anchor?.getAttribute?.('href');
95
- if (!href) return false;
96
- const fixed = interiorURL(href);
97
- if (!fixed || fixed === href) return false;
98
- anchor.setAttribute('href', fixed);
99
- // Assigning the IDL property as well is redundant with setAttribute for a
100
- // fully-qualified URL, and deliberately so: it keeps `anchor.href` on the
101
- // corrected string for any later read, however the property resolves.
102
- anchor.href = fixed;
103
- return true;
104
- }
105
-
106
- /**
107
- * Sweep `root` and everything under it. `root` defaults to the document.
108
- *
109
- * `root` ITSELF is checked, not only its descendants, and that is not
110
- * redundancy. React sets props on a detached element and then inserts it, so
111
- * the viewer's "Xem trang" anchor arrives in the tree already carrying its
112
- * href -- which means the `href` mutation happened off-tree and no observer ever
113
- * saw it. The only record that describes it is "this node was added", so a
114
- * descendant-only sweep would miss it and the visible link would stay wrong
115
- * until it was clicked.
116
- *
117
- * The tile share anchors take the opposite path and are covered either way:
118
- * ImageField appends the anchor empty and fills the href later on a live
119
- * element, which the `attributes` branch below sees.
120
- */
121
- function scan(root = document) {
122
- if (root.nodeType === Node.ELEMENT_NODE && root.matches?.(SHARE_ANCHORS)) {
123
- repair(root);
124
- }
125
- for (const anchor of root.querySelectorAll?.(SHARE_ANCHORS) ?? []) {
126
- repair(anchor);
127
- }
128
- }
129
-
130
- const observer = new MutationObserver(records => {
131
- for (const record of records) {
132
- if (record.type === 'attributes') {
133
- // The href changed on an element already in the field.
134
- repair(record.target);
135
- continue;
136
- }
137
- for (const node of record.addedNodes) {
138
- // Both places a share href is rendered: the anchor on a field tile, and
139
- // the viewer's "Xem trang".
140
- if (node.nodeType === Node.ELEMENT_NODE) scan(node);
141
- }
142
- }
143
- });
144
-
145
- function start() {
146
- scan();
147
- observer.observe(document.documentElement, {
148
- // href is the only attribute either the React viewer or the field engine
149
- // sets to move a share link, so nothing else needs watching.
150
- attributes: true,
151
- attributeFilter: ['href'],
152
- subtree: true,
153
- childList: true,
154
- });
155
- }
156
-
157
- if (document.readyState === 'loading') {
158
- document.addEventListener('DOMContentLoaded', start, { once: true });
159
- } else {
160
- start();
161
- }
162
-
163
- /**
164
- * A capture-phase click handler, which is what actually guarantees the fix.
165
- *
166
- * The observer corrects the visible href for the reader, but observer callbacks
167
- * run as microtasks after the mutation -- and a click can be dispatched on an
168
- * anchor before any callback has run for the node that inserted it. Repairing
169
- * during capture makes the corrected value the one the browser navigates to,
170
- * because the default action resolves the URL at activation time, after capture
171
- * has run.
172
- *
173
- * This is also the backstop that makes the fix independent of the observer
174
- * catching anything at all: however the link got into the document, clicking it
175
- * resolves it correctly.
176
- *
177
- * The event is deliberately NOT prevented: the aim is to redirect the
178
- * navigation, not suppress it, so middle-click, ctrl-click and "open in new
179
- * tab" all carry the corrected URL exactly as they would on a right one.
180
- */
181
- document.addEventListener('click', event => {
182
- const anchor = event.target?.closest?.('a[href]');
183
- if (anchor) repair(anchor);
184
- }, true);
185
-
186
- /**
187
- * Exposed for scripts/test-neon-ui.mjs.
188
- *
189
- * Not a debug hook: that test builds an anchor by hand with a deliberately
190
- * broken href, because the field's own links depend on a live network read of
191
- * the vai-avatar interior index that may not have landed when the test runs.
192
- */
193
- globalThis.__repairInteriorShareLinks = () => scan();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dist/assets/worker-Bq7vdIsj.js DELETED
The diff for this file is too large to render. See raw diff
 
dist/assets/{worker-Bz7I_x9L.js → worker-DHCTNpZY.js} RENAMED
The diff for this file is too large to render. See raw diff
 
dist/assets/worker-hEU_3YGf.js DELETED
The diff for this file is too large to render. See raw diff
 
dist/assets/worker-hGPAZ_gn.js DELETED
The diff for this file is too large to render. See raw diff
 
dist/assets/worker-n0zt6z0K.js DELETED
The diff for this file is too large to render. See raw diff
 
dist/assets/worker-nnOdWzlm.js DELETED
The diff for this file is too large to render. See raw diff
 
dist/brand/vaistudio_center.png DELETED

Git LFS Details

  • SHA256: 00d1a10cdcb4e150b51ae6636b9a06c4aa184c9ff72f2a65c84636dcde08b513
  • Pointer size: 131 Bytes
  • Size of remote file: 656 kB
dist/catalogue/products.json DELETED
The diff for this file is too large to render. See raw diff
 
dist/catalogue/promos.json DELETED
The diff for this file is too large to render. See raw diff
 
dist/index.html CHANGED
@@ -8,22 +8,10 @@
8
  <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
9
  <link rel="preconnect" href="https://huggingface.co" />
10
  <title>Semantic Image Field — Nội thất</title>
11
- <script type="module" crossorigin src="./assets/index.js"></script>
12
- <link rel="stylesheet" crossorigin href="./assets/app.css">
13
  </head>
14
  <body>
15
- <!--
16
- This is the BUILD TEMPLATE, not the served page.
17
-
18
- vite.config.js sets build.rollupOptions.input to this file, so Vite emits
19
- dist/app.html with the hashed asset references injected. The Space serves
20
- the ROOT index.html, which is hand-maintained and loads ./dist/assets/…
21
-
22
- Keeping them separate matters: if the root index.html were also the build
23
- input, `vite build` would rewrite it, and the very first build would point
24
- the live Space at bundles that do not exist yet — a blank page with no
25
- error. See app.html in the repo root.
26
- -->
27
  <div id="root"></div>
28
  </body>
29
- </html>
 
8
  <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
9
  <link rel="preconnect" href="https://huggingface.co" />
10
  <title>Semantic Image Field — Nội thất</title>
11
+ <script type="module" crossorigin src="./assets/index-CigEzVrZ.js"></script>
12
+ <link rel="stylesheet" crossorigin href="./assets/index-DcLscXnL.css">
13
  </head>
14
  <body>
 
 
 
 
 
 
 
 
 
 
 
 
15
  <div id="root"></div>
16
  </body>
17
+ </html>
dist/share/og-image.png DELETED

Git LFS Details

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

Git LFS Details

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

Git LFS Details

  • SHA256: 19cdbc5d1db636f590f7e22a480e88a3d81a0d81d91139b59ba40dde9048315c
  • Pointer size: 131 Bytes
  • Size of remote file: 985 kB
repo/rebuild.py DELETED
@@ -1,56 +0,0 @@
1
- #!/usr/bin/env python3
2
- import subprocess, os, re
3
- from pathlib import Path
4
-
5
- HF_TOKEN = os.environ.get("HF_TOKEN", "")
6
- REPO = "bep40/VSEARCH"
7
- CLONE = "/repo"
8
- SPACE_URL = "https://huggingface.co/spaces/"
9
- HEADERS = {"Authorization": f"Bearer {HF_TOKEN}"}
10
-
11
- def run(cmd, **kw):
12
- return subprocess.run(cmd, shell=True, cwd=CLONE, check=True,
13
- capture_output=True, text=True, env={**os.environ, "HF_TOKEN": HF_TOKEN})
14
-
15
- Path(CLONE).mkdir(parents=True, exist_ok=True)
16
- if not Path(CLONE).exists() or not Path(CLONE, ".git").exists():
17
- run("git clone -q --filter=blob:none --depth 1 " + SPACE_URL + REPO + " " + CLONE)
18
- os.chdir(CLONE)
19
- print("install:", run("npm install --silent --no-audit --no-fund").stdout[-200:])
20
- build = run("npm run build")
21
- print("build:", build.stdout[-400:])
22
- import re as re2
23
- html = Path(CLONE, "index.html").read_text()
24
- assets = {}
25
- for key in ("src", "href"):
26
- for m in re2.finditer(rf"(?:{key})=\"([^\"]+)\"", html):
27
- url = m.group(1).split("?")[0]
28
- assets[url] = None
29
- print("index.html references:", assets)
30
-
31
- # Upload via REST upload_file to avoid git auth prompts
32
- import urllib.request
33
- import urllib.parse
34
- import json
35
-
36
- def hf_upload(path_in_repo, path_or_fileobj):
37
- url = f"https://huggingface.co/api/spaces/{REPO}/upload_file"
38
- q = urllib.parse.urlencode({"create_pr": False, "revision": "main", "commit_message": "Upload rebuilt static asset"})
39
- body = b""
40
- if isinstance(path_or_fileobj, str):
41
- body = open(path_or_fileobj, "rb").read()
42
- else:
43
- body = path_or_fileobj.read()
44
- data = urllib.parse.urlencode({"path_in_repo": path_in_repo}).encode()
45
- data = data + b"&content=" + urllib.parse.quote(body)
46
- req = urllib.request.Request(url + "?" + q, data=data, headers={"Authorization": HEADERS["Authorization"], "Content-Type": "application/x-www-form-urlencoded"})
47
- with urllib.request.urlopen(req) as resp:
48
- return json.loads(resp.read())
49
-
50
- for url in sorted(assets):
51
- local = Path(CLONE, url)
52
- if not local.exists():
53
- raise SystemExit("missing asset: " + url)
54
- hf_upload(url, str(local))
55
- print("uploaded:", url, local.stat().st_size)
56
- print("ALL_OK")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/gen-thumbnail.py DELETED
@@ -1,108 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Generate the social-share thumbnail for the semantic-image-field Space.
4
-
5
- Always the CURRENT 12 newest photographs: 8 newest interiors (by created_at)
6
- from bep40/vai-avatar2-interior + 4 newest added products (by _added_at)
7
- from bep40/grob-products-updated. Composed as a 4x3 grid at 1200x675 (2:1
8
- social-card crop). Written to BOTH public/ and dist/ so whichever path the
9
- static Space serves, the resolved URL exists.
10
-
11
- Intended to run from a scheduled HF job (or locally) BEFORE the catalogue
12
- sync commits, so the committed thumbnail is refreshed at least as often as
13
- the mirror itself.
14
- """
15
- import io, json, os, sys, urllib.request
16
-
17
- INTERIOR_INDEX = "https://huggingface.co/datasets/bep40/vai-avatar2-interior/resolve/main/index.json"
18
- INTERIOR_API = "https://bep40-vai-avatar2-interior-api.hf.space"
19
- PRODUCTS_URL = "https://huggingface.co/datasets/bep40/grob-products-updated/resolve/main/products_url_added.json"
20
- N_INTERIORS = 8
21
- N_PRODUCTS = 4
22
- W, H = 1200, 675
23
- GAP = 6
24
- COLS, ROWS = 4, 3
25
- BG = (17, 20, 28)
26
-
27
- UA = {"User-Agent": "V.AI-STUDIO-thumbnail/1.0"}
28
-
29
- def fetch_json(url, timeout=25):
30
- req = urllib.request.Request(url, headers=UA)
31
- with urllib.request.urlopen(req, timeout=timeout) as r:
32
- return json.loads(r.read().decode("utf-8"))
33
-
34
- def fetch_bytes(url, timeout=25):
35
- req = urllib.request.Request(url, headers=UA)
36
- with urllib.request.urlopen(req, timeout=timeout) as r:
37
- return r.read()
38
-
39
- def main():
40
- from PIL import Image
41
- interiors = fetch_json(INTERIOR_INDEX)
42
- products = fetch_json(PRODUCTS_URL)
43
-
44
- dated = [r for r in interiors
45
- if isinstance(r, dict) and isinstance(r.get("created_at"), str)
46
- and r.get("created_at") and r.get("image_id")]
47
- dated.sort(key=lambda r: r["created_at"], reverse=True)
48
- top_i = dated[:N_INTERIORS]
49
-
50
- prods = [p for p in products
51
- if isinstance(p, dict) and isinstance(p.get("_added_at"), str)
52
- and p.get("_added_at") and (p.get("imgs") or p.get("i"))]
53
- prods.sort(key=lambda p: str(p["_added_at"]), reverse=True)
54
- top_p = prods[:N_PRODUCTS]
55
-
56
- urls = [f"{INTERIOR_API}/image/{r['image_id']}.jpg" for r in top_i]
57
- urls += [(p.get("imgs") or [p.get("i")])[0] for p in top_p]
58
-
59
- cell_w = (W - GAP * (COLS - 1)) // COLS
60
- cell_h = (H - GAP * (ROWS - 1)) // ROWS
61
-
62
- canvas = Image.new("RGB", (W, H), BG)
63
- cells = []
64
- for u in urls:
65
- try:
66
- data = fetch_bytes(u)
67
- im = Image.open(io.BytesIO(data))
68
- if im.mode not in ("RGB", "RGBA"):
69
- im = im.convert("RGB")
70
- if im.mode == "RGBA":
71
- im = im.convert("RGB")
72
- cells.append(im)
73
- except Exception as e:
74
- print(f"skip {u[:80]}: {e}", file=sys.stderr)
75
-
76
- if not cells:
77
- print("FATAL: no images could be fetched", file=sys.stderr)
78
- sys.exit(1)
79
-
80
- idx = 0
81
- for row in range(ROWS):
82
- for col in range(COLS):
83
- if idx >= len(cells):
84
- break
85
- im = cells[idx]
86
- idx += 1
87
- iw, ih = im.size
88
- scale = max(cell_w / iw, cell_h / ih)
89
- sw, sh = max(1, round(iw * scale)), max(1, round(ih * scale))
90
- im2 = im.resize((sw, sh), Image.LANCZOS)
91
- left = (sw - cell_w) // 2
92
- top = (sh - cell_h) // 2
93
- im2 = im2.crop((left, top, left + cell_w, top + cell_h))
94
- canvas.paste(im2, (col * (cell_w + GAP), row * (cell_h + GAP)))
95
-
96
- out = io.BytesIO()
97
- canvas.save(out, format="PNG", optimize=True)
98
- png = out.getvalue()
99
-
100
- os.makedirs("public/share", exist_ok=True)
101
- os.makedirs("dist/share", exist_ok=True)
102
- for path in ("public/share/og-image.png", "dist/share/og-image.png"):
103
- with open(path, "wb") as f:
104
- f.write(png)
105
- print(f"OK wrote {len(png)} bytes {W}x{H} from {len(cells)} images")
106
-
107
- if __name__ == "__main__":
108
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/refresh-catalogue.mjs DELETED
@@ -1,415 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Refresh the vai-avatar product mirror inside the Space repo, build, and commit.
4
- *
5
- * The Space is `sdk: static`: there is no server that builds at request time,
6
- * so the ONLY way a vai-avatar change reaches the browser is if the bytes under
7
- * dist/ are regenerated and committed. That is this script's reason to exist.
8
- *
9
- * Intended to run from an HF scheduled job:
10
- *
11
- * npm run refresh:catalogue
12
- *
13
- * It is deliberately idempotent: if vai-avatar has not changed, nothing is
14
- * committed or pushed, so the Space is not rebuilt pointlessly.
15
- *
16
- * Failure policy: every step is checked, and a failure aborts BEFORE pushing.
17
- * The previously committed mirror therefore keeps serving. A half-written or
18
- * empty catalogue is far worse than a slightly stale one.
19
- */
20
-
21
- import { execFileSync } from 'node:child_process';
22
- import { closeSync, existsSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from 'node:fs';
23
-
24
- const REPO = process.env.SPACE_REPO || 'bep40/semantic-image-field';
25
- const CLONE = process.env.CLONE_DIR || '/tmp/space';
26
-
27
- /**
28
- * The bundle names vite.config.js is configured to emit.
29
- *
30
- * Kept in step with `build.rollupOptions.output`, where entryFileNames and
31
- * assetFileNames both produce "index". The build is what makes them exist, which
32
- * is why the swap below happens only after it has run.
33
- */
34
- const STABLE_BUNDLE = 'assets/index.js';
35
- const STABLE_STYLE = 'assets/app.css';
36
-
37
- /** The two mirrored files, in both the source and the served location. */
38
- const MIRROR = ['products.json', 'promos.json'];
39
-
40
- /**
41
- * Everything this script is allowed to commit.
42
- *
43
- * An explicit path list rather than `git add -A`, and that is the single most
44
- * important safety property of this file. `.gitattributes` routes
45
- * products-index.json, products-gallery.json and dist/products-gallery.json
46
- * through git-lfs. The clone is a shallow `--depth 1` fetch; if the environment
47
- * has no git-lfs binary the smudge filter never runs and those files check out
48
- * as ~130-byte pointer text. `git add -A` would then stage the pointers and the
49
- * push would replace committed LFS objects with text — permanent, silent data
50
- * loss on the Hub, committed by a script that reported success.
51
- *
52
- * None of the paths below can be an LFS pointer, so a missing git-lfs no longer
53
- * has to be fatal: it only means the build output is unverifiable, not that the
54
- * mirror is unsafe to publish.
55
- *
56
- * `dist/catalogue` is here and not merely `public/catalogue` because of WHAT THE
57
- * BROWSER ACTUALLY FETCHES. Vite copies public/ into outDir/, so the bytes are
58
- * served from dist/catalogue/products.json — and that is the only place they
59
- * exist: there is no /catalogue/ at the Space root, because the Space serves
60
- * its root index.html rather than app.html, so nothing lands outside dist/.
61
- *
62
- * catalogue.js candidateURLs() resolves against the bundle at dist/assets/, and
63
- * the `../catalogue/…` candidate lands exactly on dist/catalogue/. (The
64
- * `../../…` candidate that precedes it lands on /catalogue/, which does not
65
- * exist, so it 404s first — a wasted request, then the dist/ form answers. The
66
- * ordering is worth knowing but not worth depending on.)
67
- *
68
- * Committing only the source copy would stage the new mirror, commit the new
69
- * bundle, print REFRESH_OK — and still 404 at runtime, because the copy that is
70
- * served was never pushed. That is a silent failure with no error anywhere, and
71
- * it is exactly the "sửa sp không đồng bộ" symptom.
72
- */
73
- const COMMITTABLE = ['index.html', 'public/catalogue', 'dist/catalogue', 'dist/assets', 'dist/share', 'public/share'];
74
-
75
- const ok = message => console.log(`[refresh] ${message}`);
76
- const warn = message => console.warn(`[refresh] WARNING: ${message}`);
77
-
78
- /** Run a command in the clone. Throws with the captured stderr attached. */
79
- function run(command, args, { cwd = CLONE } = {}) {
80
- return execFileSync(command, args, {
81
- cwd,
82
- encoding: 'utf8',
83
- stdio: ['ignore', 'pipe', 'pipe'],
84
- maxBuffer: 64 * 1024 * 1024,
85
- });
86
- }
87
-
88
- const shell = (script, cwd = CLONE) => run('bash', ['-lc', script], { cwd });
89
-
90
- /**
91
- * The token goes into an Authorization header rather than the remote URL.
92
- * Embedding it in https://user:TOKEN@huggingface.co persists it in
93
- * .git/config and can leak it into reflogs and trace output.
94
- */
95
- function git(...args) {
96
- return run(
97
- 'git',
98
- ['-c', `http.extraheader=Authorization: Bearer ${process.env.HF_TOKEN}`, ...args],
99
- );
100
- }
101
-
102
- /**
103
- * Is git-lfs installed at all? (The smudge filter can only exist after
104
- * `git lfs install`, so availability is checked on the binary first, then
105
- * the clone-local filter is (re)wired with `git lfs install --local`.)
106
- */
107
- function hasGitLfsBinary() {
108
- try {
109
- return Boolean(shell('git lfs version 2>/dev/null || true').trim());
110
- } catch {
111
- return false;
112
- }
113
- }
114
-
115
- /** Is git-lfs BOTH installed AND wired as a smudge filter for this clone? */
116
- function hasGitLfs() {
117
- try {
118
- if (!hasGitLfsBinary()) return false;
119
- return Boolean(shell('git config --get filter.lfs.smudge || true').trim());
120
- } catch {
121
- return false;
122
- }
123
- }
124
-
125
- /** Is this a git-lfs pointer rather than real content? */
126
- function isLfsPointer(path) {
127
- let handle;
128
- try {
129
- handle = openSync(path, 'r');
130
- const buffer = Buffer.alloc(256);
131
- const read = readSync(handle, buffer, 0, 256, 0);
132
- return buffer.subarray(0, read).toString('utf8')
133
- .startsWith('version https://git-lfs.github.com/spec/v1');
134
- } catch {
135
- return false;
136
- } finally {
137
- if (handle !== undefined) { try { closeSync(handle); } catch { /* already gone */ } }
138
- }
139
- }
140
-
141
- /** Size of `relative`, or null if it is missing, tiny, or an LFS pointer. */
142
- function realSize(relative) {
143
- const path = `${CLONE}/${relative}`;
144
- if (!existsSync(path)) return null;
145
- const size = statSync(path).size;
146
- if (size < 256 || isLfsPointer(path)) return null;
147
- return size;
148
- }
149
-
150
- /**
151
- * Fail unless `relative` (resolved against the clone root) holds real content.
152
- *
153
- * `relative` must be a path from CLONE, not from dist/ — passing a bare filename
154
- * here silently checks the wrong location, which is how a missing committed data
155
- * file can pass as present.
156
- */
157
- function requireRealFile(relative, { why }) {
158
- const path = `${CLONE}/${relative}`;
159
- if (!existsSync(path)) throw new Error(`${relative} is missing — ${why}`);
160
- if (realSize(relative) === null) {
161
- throw new Error(
162
- `${relative} is a git-lfs pointer or a truncated file, not real content. ${why}`,
163
- );
164
- }
165
- }
166
-
167
- /** Parse a mirror file and report how many records it holds. */
168
- function recordCount(relative) {
169
- const parsed = JSON.parse(readFileSync(`${CLONE}/${relative}`, 'utf8'));
170
- if (Array.isArray(parsed)) return parsed.length;
171
- return Object.keys(parsed?.productEdits ?? {}).length;
172
- }
173
-
174
- /** True when index.html already names both stable assets. */
175
- function indexReferencesStable(html) {
176
- return /\.\/dist\/assets\/index\.js\b/.test(html)
177
- && /\.\/dist\/assets\/app\.css\b/.test(html);
178
- }
179
-
180
- /**
181
- * Point index.html at the freshly built bundle.
182
- *
183
- * This is load-bearing, not housekeeping. index.html is NOT a Vite input (the
184
- * input is app.html), so `vite build` never rewrites it — yet the build emits
185
- * the STABLE names while index.html still names a hashed bundle from an earlier
186
- * build. Those hashed files survive, because emptyOutDir is false precisely so
187
- * the build cannot delete committed data.
188
- *
189
- * The result was a refresh that could not fail loudly: the new bundle was
190
- * committed, the mirror was committed, the stale-but-present asset reference
191
- * passed verification, and the script reported REFRESH_OK — while the Space
192
- * kept serving the pre-fix JavaScript. The catalogue would update and the code
193
- * reading it would not, which looks exactly like "sửa sp không đồng bộ" and
194
- * survives every rebuild.
195
- *
196
- * So the swap is part of the refresh. It is idempotent: once index.html names
197
- * the stable files, the regex matches them, the write is a no-op, and git sees
198
- * no change.
199
- */
200
- function repointIndexHtml() {
201
- const path = `${CLONE}/index.html`;
202
- const html = readFileSync(path, 'utf8');
203
-
204
- // Already correct: leave the file untouched so git sees no change.
205
- if (indexReferencesStable(html)) return false;
206
-
207
- // Only the asset reference is touched. Matching on the full "./dist/assets/"
208
- // prefix means a hand-added link to something else in that directory (a
209
- // vendored worker, say) is left alone rather than silently renamed.
210
- let next = html.replace(
211
- /(\.\/dist\/assets\/)index-[A-Za-z0-9_-]+\.js\b/g,
212
- `$1${STABLE_BUNDLE.slice('assets/'.length)}`,
213
- );
214
- next = next.replace(
215
- /(\.\/dist\/assets\/)index-[A-Za-z0-9_-]+\.css\b/g,
216
- `$1${STABLE_STYLE.slice('assets/'.length)}`,
217
- );
218
-
219
- // The rewrite matched nothing AND index.html is not already correct. That is
220
- // the case this guard exists for: index.html names its bundle by some other
221
- // scheme (a query string, a different directory, a hand-written <script>), so
222
- // the regex changes nothing. Treating that as "already fine" would publish a
223
- // Space whose HTML points at a file the build never emits — a blank page that
224
- // survives every rebuild, because the stale reference keeps resolving to a
225
- // real, still-present file.
226
- if (next === html) {
227
- throw new Error(
228
- 'index.html names its bundle in a way repointIndexHtml() cannot rewrite — '
229
- + 'refusing to publish (would blank the Space)',
230
- );
231
- }
232
-
233
- // A partial rewrite is just as broken as none: JS pointing at the stable name
234
- // while the <link> still names a hashed CSS (or the reverse) loads a bundle
235
- // without its stylesheet.
236
- if (!indexReferencesStable(next)) {
237
- throw new Error(
238
- 'only part of index.html could be repointed — it would load the bundle '
239
- + 'without its stylesheet, or vice versa',
240
- );
241
- }
242
-
243
- // Confirm the build actually produced what we are now pointing at. Doing this
244
- // after the rewrite rather than before would be circular; doing it before is
245
- // what makes the rewrite safe.
246
- for (const asset of [STABLE_BUNDLE, STABLE_STYLE]) {
247
- requireRealFile(`dist/${asset}`, { why: 'the build did not produce it' });
248
- }
249
- writeFileSync(path, next, 'utf8');
250
- ok(`index.html repointed to ${STABLE_BUNDLE} + ${STABLE_STYLE}`);
251
- return true;
252
- }
253
-
254
- function main() {
255
- if (!process.env.HF_TOKEN) {
256
- throw new Error('HF_TOKEN is required to push the refreshed mirror.');
257
- }
258
-
259
- rmSync(CLONE, { recursive: true, force: true });
260
- run('mkdir', ['-p', CLONE], { cwd: '/' });
261
-
262
- git('init', '-q');
263
- git('remote', 'add', 'origin', `https://huggingface.co/spaces/${REPO}`);
264
- git('fetch', '-q', '--depth', '1', 'origin', 'main');
265
- git('checkout', '-q', '-B', 'main', 'FETCH_HEAD');
266
- ok(`cloned ${REPO}`);
267
-
268
- // The og-image is LFS-tracked, and the pre-receive hook rejects raw binaries,
269
- // so the clone MUST be able to smudge/clean via git-lfs. `git lfs install
270
- // --local` inside the clone (a) loads the repository-local filters and (b)
271
- // fails visibly when git-lfs is not installed, instead of silently committing
272
- // a raw PNG pointer that a later push gets rejected for.
273
- if (hasGitLfsBinary()) {
274
- shell('git lfs install --local');
275
- if (!hasGitLfs()) {
276
- throw new Error('git lfs install --local did not wire the smudge filter');
277
- }
278
- ok('git-lfs wired for this clone');
279
- } else {
280
- throw new Error('git-lfs is required (og-image.png is LFS-tracked) — install git-lfs in the job');
281
- }
282
-
283
- // Informational only. COMMITTABLE excludes every LFS-tracked path, so a
284
- // missing git-lfs cannot lead to pointers being committed; it just means the
285
- // committed dist data cannot be verified from this clone.
286
- const lfs = hasGitLfs();
287
- if (!lfs) {
288
- warn('git-lfs unavailable — dist data will be left unstaged and unverified (safe: it is LFS-tracked)');
289
- }
290
-
291
- shell('set -euo pipefail; npm install --silent --no-audit --no-fund');
292
- ok('dependencies installed');
293
-
294
- // Copies vai-avatar's live product sources into public/catalogue/.
295
- shell('set -euo pipefail; node scripts/sync-catalogue.mjs');
296
-
297
- // Regenerate the social-share thumbnail (12 newest images) BEFORE the build,
298
- // so Vite copies public/share/ into dist/share/ and the committed og-image
299
- // is refreshed on every sync. Non-fatal: the previous thumbnail keeps
300
- // serving if the Hub is unreachable, but a HARD failure is not silent.
301
- shell('set -euo pipefail; python3 scripts/gen-thumbnail.py');
302
- for (const share of ['public/share/og-image.png', 'dist/share/og-image.png']) {
303
- requireRealFile(share, { why: 'the social-share thumbnail would be missing' });
304
- }
305
- ok('social-share thumbnail regenerated');
306
-
307
- // Guard before building: an empty or truncated mirror must never ship.
308
- for (const file of MIRROR) {
309
- requireRealFile(`public/catalogue/${file}`, { why: 'the mirror would ship empty' });
310
- const count = recordCount(`public/catalogue/${file}`);
311
- if (!count) throw new Error(`public/catalogue/${file} is empty — refusing to publish`);
312
- ok(`public/catalogue/${file}: ${count} entries`);
313
- }
314
-
315
- shell('set -euo pipefail; npm run build');
316
- requireRealFile(`dist/${STABLE_BUNDLE}`, { why: 'the build did not produce it' });
317
- ok('built');
318
-
319
- /**
320
- * The build copies public/ into dist/, and that copy is what the browser
321
- * fetches. Checked AFTER the build: if Vite's publicDir handling, outDir or a
322
- * plugin config ever changed, this is the point where it surfaces — as a loud
323
- * abort before any push, instead of a Space whose product panel 404s while
324
- * the refresh reports success.
325
- *
326
- * The counts are compared too, not just presence. A stale dist/catalogue left
327
- * over from an earlier build is byte-valid and non-empty, so presence alone
328
- * would happily pass while the Space serves products the sync just replaced.
329
- */
330
- for (const file of MIRROR) {
331
- const served = `dist/catalogue/${file}`;
332
- requireRealFile(served, {
333
- why: 'the build did not copy public/ into dist/, so the browser would 404',
334
- });
335
- const source = recordCount(`public/catalogue/${file}`);
336
- const built = recordCount(served);
337
- if (source !== built) {
338
- throw new Error(`${served} has ${built} entries but the mirror has ${source} — the served copy is stale`);
339
- }
340
- ok(`served mirror verified: dist/catalogue/${file} (${built} entries)`);
341
- }
342
-
343
- // After the build, so the stable names are known to exist.
344
- repointIndexHtml();
345
-
346
- // Verify the served page BEFORE committing, and only AFTER repointing — the
347
- // original order checked the stale reference against the stale file and passed.
348
- const html = readFileSync(`${CLONE}/index.html`, 'utf8');
349
- // Query strings (`?v=...`) are cache busters, not part of the filename —
350
- // strip them so the existence check tests the real file on disk.
351
- const referenced = [...html.matchAll(/(?:src|href)="\.\/dist\/([^"]+)"/g)]
352
- .map(match => match[1].split('?')[0]);
353
- if (!referenced.length) {
354
- throw new Error('index.html references no dist/assets bundle — refusing to publish');
355
- }
356
- for (const name of referenced) {
357
- requireRealFile(`dist/${name}`, { why: 'index.html points at it' });
358
- }
359
- ok(`index.html references verified: ${referenced.join(', ')}`);
360
-
361
- // Confirm the build did not delete the committed data under dist/.
362
- // vite.config.js sets emptyOutDir: false for exactly this reason; a revert of
363
- // that setting would delete ~6,400 files with no error anywhere, and the app
364
- // would keep working because src/ reads embeddings from the dataset repo and
365
- // products from the mirror.
366
- if (lfs) {
367
- if (!existsSync(`${CLONE}/dist/prodimgs`)) {
368
- throw new Error('dist/prodimgs is missing — the build deleted committed data; check emptyOutDir');
369
- }
370
- for (const file of ['dist/products-gallery.json', 'dist/products-index.json']) {
371
- requireRealFile(file, { why: 'the build deleted committed data, or it is an LFS pointer' });
372
- }
373
- ok('committed dist data intact');
374
- }
375
-
376
- // Superseded hashed bundles are inert — index.html names exactly one — but they
377
- // accumulate on every build. Left alone on purpose: pruning them is a separate,
378
- // reviewable change, not something a sync job should do on its own.
379
-
380
- // Explicit paths, never `git add -A` (see COMMITTABLE). Whether a commit
381
- // happened must come from the commit step itself: testing it afterwards with
382
- // `git diff --cached --quiet` would always report clean, because a successful
383
- // commit clears the staging area — the push would then be skipped and the
384
- // refresh would silently do nothing.
385
- const commit = shell(`
386
- set -euo pipefail
387
- git config user.name "semantic-image-field refresh"
388
- git config user.email "refresh@hf.space"
389
- git add -- ${COMMITTABLE.map(p => `'${p}'`).join(' ')}
390
- if git diff --cached --quiet; then
391
- echo NOCHANGE
392
- else
393
- git commit -q -m "chore: refresh vai-avatar catalogue mirror"
394
- echo COMMITTED
395
- fi
396
- `).trim();
397
-
398
- if (commit.includes('COMMITTED')) {
399
- git('push', '-q', 'origin', 'main');
400
- ok('pushed — the static Space will rebuild');
401
- } else {
402
- const head = shell('git rev-parse --short HEAD').trim();
403
- ok(`catalogue unchanged (HEAD ${head})`);
404
- }
405
-
406
- console.log('REFRESH_OK');
407
- }
408
-
409
- try {
410
- main();
411
- } catch (error) {
412
- const detail = (error.stderr && String(error.stderr).trim()) || error.message;
413
- console.error(`REFRESH_FAILED: ${detail}`);
414
- process.exitCode = 1;
415
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/refresh.mjs DELETED
@@ -1,74 +0,0 @@
1
- #!/usr/bin/env node
2
- import { execFileSync } from 'node:child_process';
3
- import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
-
5
- const REPO = 'bep40/semantic-image-field';
6
- const CLONE = '/tmp/space';
7
- const STABLE_BUNDLE = 'assets/index.js';
8
- const STABLE_STYLE = 'assets/app.css';
9
- const MIRROR = ['products.json', 'promos.json'];
10
- const COMMITTABLE = ['index.html', 'public/catalogue', 'dist/catalogue', 'dist/assets', 'dist/share', 'public/share'];
11
-
12
- const ok = m => console.log(`[refresh] ${m}`);
13
-
14
- function run(command, args, { cwd = CLONE } = {}) {
15
- return execFileSync(command, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
16
- }
17
- function shell(script, cwd = CLONE) { return run('bash', ['-lc', script], { cwd }); }
18
- function git(...args) {
19
- return run('git', ['-c', `http.extraheader=Authorization: Bearer ${process.env.HF_TOKEN}`, ...args]);
20
- }
21
-
22
- function main() {
23
- if (!process.env.HF_TOKEN) throw new Error('HF_TOKEN required');
24
- rmSync(CLONE, { recursive: true, force: true });
25
- run('mkdir', ['-p', CLONE]);
26
- run('git', ['clone', '-q', `--filter=blob:none`, '--no-checkout', `https://huggingface.co/spaces/${REPO}`], { cwd: '/' });
27
- git('checkout', '-q', '-B', 'main', 'main');
28
- ok('cloned');
29
-
30
- shell('set -euo pipefail; npm install --silent --no-audit --no-fund');
31
- ok('dependencies installed');
32
- shell('set -euo pipefail; node scripts/sync-catalogue.mjs');
33
- for (const file of MIRROR) {
34
- const count = JSON.parse(readFileSync(`public/catalogue/${file}`, 'utf8')).length;
35
- ok(`public/catalogue/${file}: ${count} entries`);
36
- }
37
- shell('set -euo pipefail; npm run build');
38
- ok('built');
39
- for (const file of MIRROR) {
40
- const served = `dist/catalogue/${file}`;
41
- const source = JSON.parse(readFileSync(`public/catalogue/${file}`, 'utf8')).length;
42
- const built = JSON.parse(readFileSync(served, 'utf8')).length;
43
- if (source !== built) throw new Error(`${served} has ${built} but mirror has ${source}`);
44
- ok(`served mirror verified: ${file} (${built} entries)`);
45
- }
46
- const htmlPath = `${CLONE}/index.html`;
47
- let html = readFileSync(htmlPath, 'utf8');
48
- if (/\.\/dist\/assets\/index\.js\b/.test(html) && /\.\/dist\/assets\/app\.css\b/.test(html)) {
49
- ok('index.html already references stable bundle');
50
- } else {
51
- let next = html.replace(/(\.\/dist\/assets\/)index-[A-Za-z0-9_-]+\.js\b/g, `$1${STABLE_BUNDLE.slice('assets/'.length)}`);
52
- next = next.replace(/(\.\/dist\/assets\/)index-[A-Za-z0-9_-]+\.css\b/g, `$1${STABLE_STYLE.slice('assets/'.length)}`);
53
- if (next === html) throw new Error('index.html names bundle in unexpected way');
54
- writeFileSync(htmlPath, next);
55
- ok('index.html repointed to ' + STABLE_BUNDLE);
56
- }
57
- for (const asset of [STABLE_BUNDLE, STABLE_STYLE]) {
58
- if (!existsSync(`${CLONE}/dist/${asset}`)) throw new Error(`dist/${asset} missing`);
59
- }
60
- const commit = shell(`set -euo pipefail;
61
- git config user.name "semantic-image-field refresh";
62
- git config user.email "refresh@hf.space";
63
- git add -- ${COMMITTABLE.map(p => `'${p}'`).join(' ')};
64
- if git diff --cached --quiet; then echo NOCHANGE; else git commit -q -m "chore: rebuild with semantic search fix"; echo COMMITTED; fi`).trim();
65
- if (commit.includes('COMMITTED')) {
66
- git('push', '-q', 'origin', 'main');
67
- ok('pushed — the static Space will rebuild');
68
- } else {
69
- ok(`no change needed (HEAD ${shell('git rev-parse --short HEAD').trim()})`);
70
- }
71
- console.log('REFRESH_OK');
72
- }
73
- try { main(); }
74
- catch (e) { console.error('REFRESH_FAILED:', e.message, e.stderr || ''); process.exitCode = 1; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/requirements-sync.txt DELETED
@@ -1,6 +0,0 @@
1
- # Needed only by scripts/sync-interiors.py, not by the field itself.
2
- # torch is CPU-only in practice: a sync embeds at most a few hundred images.
3
- sentence-transformers>=3.0
4
- torch>=2.2
5
- Pillow>=10.0
6
- huggingface_hub>=0.30
 
 
 
 
 
 
 
scripts/seed-catalogue.mjs DELETED
@@ -1,216 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Seed the vai-avatar product mirror into the Space WITHOUT running Vite.
4
- *
5
- * Why this exists alongside scripts/refresh-catalogue.mjs:
6
- * refresh-catalogue.mjs is the steady-state job — it syncs, builds and
7
- * repoints index.html. That is the right thing to run on a schedule, but it
8
- * needs `npm install` plus a full `vite build`, so it needs an environment
9
- * that can afford both.
10
- *
11
- * The mirror, however, is currently absent on BOTH sides: neither
12
- * public/catalogue/ nor dist/catalogue/ has ever been committed. Until it
13
- * exists, catalogue.js finds no candidate URL, the Space fetches nothing,
14
- * and no product appears in the photo-viewer — whatever the bundle does.
15
- *
16
- * That gap does NOT need a build to close. dist/ already holds a working
17
- * bundle, index.html already names it, and vite.config.js sets
18
- * emptyOutDir: false precisely so a build cannot delete committed data.
19
- * Writing the two JSON files into public/catalogue/ AND dist/catalogue/ is
20
- * the whole fix, and it runs anywhere node and git exist.
21
- *
22
- * Once seeded, go back to refresh-catalogue.mjs for ongoing updates.
23
- *
24
- * npm run seed:catalogue
25
- *
26
- * Failure policy matches refresh-catalogue.mjs: every step is checked and any
27
- * failure aborts BEFORE pushing, so the last-good mirror keeps serving. A
28
- * half-written catalogue is worse than a stale one.
29
- */
30
-
31
- import { execFileSync } from 'node:child_process';
32
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, rmSync } from 'node:fs';
33
-
34
- const REPO = process.env.SPACE_REPO || 'bep40/semantic-image-field';
35
- const CLONE = process.env.CLONE_DIR || '/tmp/seed-space';
36
- const REVISION = 'main';
37
- const TIMEOUT_MS = 30_000;
38
-
39
- const ok = message => console.log(`[seed] ${message}`);
40
-
41
- /** Kept byte-identical to the sources declared in src/catalogue.js. */
42
- const SOURCES = {
43
- 'products.json': {
44
- repo: 'bep40/grob-products-updated',
45
- file: 'products_url_added.json',
46
- },
47
- 'promos.json': {
48
- repo: 'bep40/vaistudio-data',
49
- file: 'promos.json',
50
- },
51
- };
52
-
53
- /**
54
- * The two places the bytes must exist.
55
- *
56
- * public/catalogue/ is the source copy and what Vite copies on future builds;
57
- * dist/catalogue/ is what the browser actually fetches, because the Space
58
- * serves its root index.html and catalogue.js resolves `../catalogue/…`
59
- * relative to the bundle in dist/assets/. Committing only the source copy
60
- * would stage the mirror, report success, and still 404 at runtime — the
61
- * exact "sửa sp không đồng bộ" symptom.
62
- */
63
- const DESTINATIONS = ['public/catalogue', 'dist/catalogue'];
64
-
65
- /**
66
- * Everything this script may commit. Explicit paths, never `git add -A`:
67
- * .gitattributes routes *.png/*.jpg/*.pdf and more through git-lfs, and a
68
- * shallow clone without a git-lfs binary checks those out as ~130-byte
69
- * pointer text. `git add -A` would stage those pointers and replace committed
70
- * LFS objects with text — permanent, silent data loss, committed by a script
71
- * that reported success.
72
- *
73
- * Note what is NOT here: dist/assets and index.html. This script neither builds
74
- * nor repoints, so it can never leave the Space serving a bundle it did not
75
- * produce.
76
- */
77
- const COMMITTABLE = DESTINATIONS;
78
-
79
- function run(command, args, { cwd = CLONE } = {}) {
80
- return execFileSync(command, args, {
81
- cwd,
82
- encoding: 'utf8',
83
- stdio: ['ignore', 'pipe', 'pipe'],
84
- maxBuffer: 64 * 1024 * 1024,
85
- });
86
- }
87
-
88
- const shell = (script, cwd = CLONE) => run('bash', ['-lc', script], { cwd });
89
-
90
- /**
91
- * The token goes into an Authorization header rather than the remote URL:
92
- * embedding it in https://user:TOKEN@huggingface.co persists it in
93
- * .git/config and leaks it into reflogs and trace output.
94
- */
95
- function git(...args) {
96
- return run(
97
- 'git',
98
- ['-c', `http.extraheader=Authorization: Bearer ${process.env.HF_TOKEN}`, ...args],
99
- );
100
- }
101
-
102
- /** Records held by a mirror file: array length, or productEdits key count. */
103
- function recordCount(relative) {
104
- const parsed = JSON.parse(readFileSync(`${CLONE}/${relative}`, 'utf8'));
105
- if (Array.isArray(parsed)) return parsed.length;
106
- return Object.keys(parsed?.productEdits ?? {}).length;
107
- }
108
-
109
- /**
110
- * Is this file an LFS pointer or a truncated copy rather than real content?
111
- * The mirror is JSON, which .gitattributes does not route through LFS, so a
112
- * pointer here would mean something upstream changed that assumption.
113
- */
114
- function isUnusable(relative) {
115
- const path = `${CLONE}/${relative}`;
116
- if (!existsSync(path)) return 'missing';
117
- if (statSync(path).size < 256) return 'truncated';
118
- const head = readFileSync(path, { encoding: 'utf8', flag: 'r' }).slice(0, 200);
119
- if (head.startsWith('version https://git-lfs.github.com/spec/v1')) return 'an LFS pointer';
120
- return null;
121
- }
122
-
123
- async function fetchSource(name, source) {
124
- const url = `https://huggingface.co/datasets/${source.repo}/resolve/${REVISION}/${source.file}?cts=${Date.now()}`;
125
- process.stdout.write(`[seed] ${name} <- ${source.repo}/${source.file} ... `);
126
- const response = await fetch(url, {
127
- cache: 'no-store',
128
- signal: AbortSignal.timeout(TIMEOUT_MS),
129
- headers: process.env.HF_TOKEN ? { Authorization: `Bearer ${process.env.HF_TOKEN}` } : {},
130
- });
131
- if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
132
- const text = await response.text();
133
- // Parse before writing: a truncated body or an HTML error page would
134
- // otherwise be published as if it were a catalogue.
135
- JSON.parse(text);
136
- process.stdout.write('ok\n');
137
- return text;
138
- }
139
-
140
- async function main() {
141
- if (!process.env.HF_TOKEN) throw new Error('HF_TOKEN is required to push the seeded mirror.');
142
-
143
- rmSync(CLONE, { recursive: true, force: true });
144
- run('mkdir', ['-p', CLONE], { cwd: '/' });
145
-
146
- git('init', '-q');
147
- git('remote', 'add', 'origin', `https://huggingface.co/spaces/${REPO}`);
148
- git('fetch', '-q', '--depth', '1', 'origin', 'main');
149
- git('checkout', '-q', '-B', 'main', 'FETCH_HEAD');
150
- ok(`cloned ${REPO}`);
151
-
152
- for (const directory of DESTINATIONS) mkdirSync(`${CLONE}/${directory}`, { recursive: true });
153
-
154
- for (const [name, source] of Object.entries(SOURCES)) {
155
- const text = await fetchSource(name, source);
156
- for (const directory of DESTINATIONS) {
157
- writeFileSync(`${CLONE}/${directory}/${name}`, text, 'utf8');
158
- }
159
- }
160
-
161
- /**
162
- * Verify both copies, and compare their counts.
163
- *
164
- * Presence alone is not enough: a stale dist/catalogue left over from an
165
- * earlier build is byte-valid and non-empty, so it would pass a presence
166
- * check while the Space serves products the fetch just replaced. Comparing
167
- * counts catches exactly that.
168
- */
169
- for (const [name] of Object.entries(SOURCES)) {
170
- const paths = DESTINATIONS.map(directory => `${directory}/${name}`);
171
- const counts = [];
172
- for (const relative of paths) {
173
- const problem = isUnusable(relative);
174
- if (problem) throw new Error(`${relative} is ${problem} — refusing to publish`);
175
- counts.push(recordCount(relative));
176
- }
177
- if (counts[0] !== counts[1]) {
178
- throw new Error(
179
- `${paths[0]} has ${counts[0]} records but ${paths[1]} has ${counts[1]} — the served copy is stale`,
180
- );
181
- }
182
- if (!counts[0]) throw new Error(`${paths[0]} is empty — refusing to publish`);
183
- ok(`${paths.join(' + ')}: ${counts[0]} records`);
184
- }
185
-
186
- const commit = shell(`
187
- set -euo pipefail
188
- git config user.name "semantic-image-field seed"
189
- git config user.email "seed@hf.space"
190
- git add -- ${COMMITTABLE.map(p => `'${p}'`).join(' ')}
191
- if git diff --cached --quiet; then
192
- echo NOCHANGE
193
- else
194
- git commit -q -m "chore: seed vai-avatar catalogue mirror"
195
- echo COMMITTED
196
- fi
197
- `).trim();
198
-
199
- if (commit.includes('COMMITTED')) {
200
- git('push', '-q', 'origin', 'main');
201
- ok('pushed — the static Space will serve the mirror from dist/catalogue/');
202
- } else {
203
- const head = shell('git rev-parse --short HEAD').trim();
204
- ok(`mirror already seeded (HEAD ${head})`);
205
- }
206
-
207
- console.log('SEED_OK');
208
- }
209
-
210
- try {
211
- await main();
212
- } catch (error) {
213
- const detail = (error.stderr && String(error.stderr).trim()) || error.message;
214
- console.error(`SEED_FAILED: ${detail}`);
215
- process.exitCode = 1;
216
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/sync-catalogue.mjs DELETED
@@ -1,141 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Mirror vai-avatar's live product sources into this Space, same-origin.
4
- *
5
- * Why a mirror and not a runtime cross-origin fetch:
6
- * huggingface.co serves dataset files with
7
- * `Access-Control-Allow-Origin: https://huggingface.co`, and `/resolve` then
8
- * 302s to a CDN host that sets its own CORS policy. A page on
9
- * *.static.hf.space is neither origin, so the browser hides the response
10
- * body and the read fails with an opaque "Failed to fetch". Same-origin
11
- * cannot be blocked, so the bytes are copied into the Space instead.
12
- *
13
- * Freshness therefore depends on this script running, not on a rebuild of any
14
- * baked data: re-run it (or let the scheduled job run it) and the Space serves
15
- * whatever vai-avatar has written at that moment.
16
- *
17
- * node scripts/sync-catalogue.mjs
18
- *
19
- * Writes public/catalogue/products.json and public/catalogue/promos.json.
20
- * `public/` is what Vite copies into dist/, so both end up served with the app.
21
- */
22
-
23
- import { mkdir, writeFile } from 'node:fs/promises';
24
- import { realpathSync } from 'node:fs';
25
- import { dirname, resolve } from 'node:path';
26
- import { fileURLToPath } from 'node:url';
27
-
28
- const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
29
- const OUT_DIR = resolve(ROOT, 'public/catalogue');
30
- const REVISION = 'main';
31
- const TIMEOUT_MS = 30_000;
32
-
33
- /** Kept byte-identical to the sources declared in src/catalogue.js. */
34
- export const SOURCES = {
35
- 'products.json': {
36
- repo: 'bep40/grob-products-updated',
37
- file: 'products_url_added.json',
38
- },
39
- 'promos.json': {
40
- repo: 'bep40/vaistudio-data',
41
- file: 'promos.json',
42
- },
43
- };
44
-
45
- function urlFor({ repo, file }) {
46
- return `https://huggingface.co/datasets/${repo}/resolve/${REVISION}/${file}?cts=${Date.now()}`;
47
- }
48
-
49
- async function fetchSource(name, source) {
50
- const url = urlFor(source);
51
- process.stdout.write(`[sync] ${name} <- ${source.repo}/${source.file} ... `);
52
- const response = await fetch(url, {
53
- cache: 'no-store',
54
- signal: AbortSignal.timeout(TIMEOUT_MS),
55
- headers: process.env.HF_TOKEN ? { Authorization: `Bearer ${process.env.HF_TOKEN}` } : {},
56
- });
57
- if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
58
- const text = await response.text();
59
- // Validate before writing: a truncated or HTML error body would otherwise be
60
- // served to the app as if it were a catalogue.
61
- const parsed = JSON.parse(text);
62
- const count = Array.isArray(parsed)
63
- ? `${parsed.length} products`
64
- : `${Object.keys(parsed?.productEdits ?? {}).length} productEdits`;
65
- process.stdout.write(`ok (${count})\n`);
66
- return text;
67
- }
68
-
69
- /**
70
- * Was this file the process entrypoint?
71
- *
72
- * Comparing basenames with `endsWith` looks equivalent and is not: any file whose
73
- * name merely ENDS WITH this one (`my-sync-catalogue.mjs`) also matches, so
74
- * loading such a file would run main() as a side effect of importing it. Both
75
- * paths are resolved before comparing.
76
- */
77
- function isDirectRun() {
78
- const entry = process.argv[1];
79
- if (!entry) return false;
80
- try {
81
- return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
82
- } catch {
83
- return false;
84
- }
85
- }
86
-
87
- async function main() {
88
- await mkdir(OUT_DIR, { recursive: true });
89
- let failures = 0;
90
- for (const [name, source] of Object.entries(SOURCES)) {
91
- try {
92
- const text = await fetchSource(name, source);
93
- await writeFile(resolve(OUT_DIR, name), text, 'utf8');
94
- } catch (error) {
95
- // The good file is still written, so one transient error does not throw
96
- // away a successful fetch. Whether that mixture may be PUBLISHED is
97
- // decided below.
98
- failures++;
99
- console.error(`[sync] FAILED ${name}: ${error.message}`);
100
- }
101
- }
102
-
103
- /**
104
- * ANY failure fails the run, not only a total failure.
105
- *
106
- * This matters from the second successful run onward, once public/catalogue is
107
- * committed: a file that failed to fetch is then still on disk as its previous
108
- * version, so requireRealFile() and recordCount() in refresh-catalogue.mjs both
109
- * pass on that stale copy. The caller would then build, commit and print
110
- * REFRESH_OK, and the Space would serve a fresh products.json beside an old
111
- * promos.json -- so every Sửa and every Xóa from vai-avatar silently stopped
112
- * applying, with no error anywhere.
113
- *
114
- * On the very first run the mirror is not committed yet, so the failed file is
115
- * simply absent and the caller's guard already catches it. Failing here makes
116
- * that behaviour unconditional instead of depending on which run it is.
117
- *
118
- * A stale overlay is not a degraded catalogue, it is a wrong one: catalogue.js
119
- * merges the two files, so old edits resurrect old prices and old tombstones
120
- * fight the new file. Aborting here is what makes the caller leave the
121
- * previously committed mirror intact, which is the guarantee both scripts
122
- * document.
123
- */
124
- if (failures > 0) {
125
- console.error(
126
- `[sync] ${failures}/${Object.keys(SOURCES).length} source(s) failed — the mirror on disk `
127
- + 'is now MIXED (fresh file beside last-commit file). Refusing to report success: '
128
- + 'the caller must abort so the last-good mirror stays live.',
129
- );
130
- process.exitCode = 1;
131
- return;
132
- }
133
- console.log(`[sync] wrote ${OUT_DIR}`);
134
- }
135
-
136
- if (isDirectRun()) {
137
- main().catch(error => {
138
- console.error(`[sync] ${error.stack || error}`);
139
- process.exitCode = 1;
140
- });
141
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/sync-interiors.py DELETED
@@ -1,388 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Two-way sync between this Space's interiors and vai-avatar's interior library.
4
-
5
- WHY THIS CANNOT JUST COPY FILES
6
- -------------------------------
7
- The field searches interiors with its own CLIP matrix
8
- (`image-embeddings_25k-512-32bit.bin`, 512 dims). vai-avatar's library uses a
9
- different embedding model. A new image therefore cannot be copied across: it
10
- has to be encoded with the field's own model first, or it lands in a 512-dim
11
- space that is not the one the field searches.
12
-
13
- This was measured rather than assumed. Cosine between the two datasets on the
14
- 377 images they share is 0.6347; cosine between two DIFFERENT images is 0.6336.
15
- Same dimensionality, no signal -- the spaces are not interchangeable, so a file
16
- copy would produce confidently wrong search results.
17
-
18
- WHAT IT DOES
19
- ------------
20
- added live on the API, absent from the field -> download, embed, upload,
21
- append to both index files
22
- removed present in the field, gone from the API -> drop from the index and
23
- the vector blob, delete the bitmap
24
-
25
- The source of truth is the API's own `/latest`, not the dataset's
26
- `metadata.json`. That file is a history, not a manifest: it holds 1,752 rows
27
- for 1,122 live images, so deleted rows linger in it and a file-driven sync
28
- would resurrect whatever the user removed on the other side.
29
-
30
- RUN
31
- ---
32
- pip install -r scripts/requirements-sync.txt
33
- python scripts/sync-interiors.py --dry-run # report, change nothing
34
- python scripts/sync-interiors.py # apply
35
- python scripts/sync-interiors.py --limit 5 # apply a few, for testing
36
- """
37
-
38
- from __future__ import annotations
39
-
40
- import argparse
41
- import base64
42
- import io
43
- import json
44
- import os
45
- import struct
46
- import sys
47
- import time
48
- import urllib.error
49
- import urllib.request
50
-
51
- from PIL import Image
52
-
53
- # --------------------------------------------------------------------------
54
- # Where things live
55
- # --------------------------------------------------------------------------
56
-
57
- API = os.environ.get("INTERIOR_API_URL", "https://bep40-vai-avatar2-interior-api.hf.space")
58
- DATASET = os.environ.get("FIELD_DATASET", "bep40/interior-image-field-assets")
59
- HUB = os.environ.get("HF_HUB") or os.environ.get("HF_TOKEN")
60
-
61
- INDEX_NAME = "image-embeddings.json"
62
- BLOB_NAME = "image-embeddings_25k-512-32bit.bin"
63
-
64
- RESOLVE = f"https://huggingface.co/datasets/{DATASET}/resolve/main/"
65
-
66
- # The field's image model, matching what produced the existing vectors.
67
- IMAGE_MODEL = "sentence-transformers/clip-ViT-B-32-multilingual-v1"
68
- DIMS = 512
69
-
70
- BRANDS = ("malloca", "grob", "hafele", "eurogold", "canzy", "konox", "demax")
71
-
72
-
73
- def log(message: str) -> None:
74
- print(message, flush=True)
75
-
76
-
77
- def get(url: str, timeout: int = 120) -> bytes:
78
- request = urllib.request.Request(url, headers={"User-Agent": "interior-sync/1.0"})
79
- with urllib.request.urlopen(request, timeout=timeout) as response:
80
- return response.read()
81
-
82
-
83
- def post_json(url: str, payload: dict, timeout: int = 120) -> dict:
84
- request = urllib.request.Request(
85
- url,
86
- data=json.dumps(payload).encode(),
87
- headers={"Content-Type": "application/json", "User-Agent": "interior-sync/1.0"},
88
- )
89
- with urllib.request.urlopen(request, timeout=timeout) as response:
90
- return json.loads(response.read().decode())
91
-
92
-
93
- # --------------------------------------------------------------------------
94
- # The field's vector format
95
- #
96
- # `image-embeddings_25k-512-32bit.bin` is headerless little-endian float32,
97
- # 512 floats per row, row order matching `image-embeddings.json`. The "25k" in
98
- # the filename is decorative: the file holds exactly as many rows as the index
99
- # has entries.
100
- # --------------------------------------------------------------------------
101
-
102
- def decode_blob(raw: bytes) -> list[list[float]]:
103
- count, remainder = divmod(len(raw), 4 * DIMS)
104
- if remainder:
105
- raise ValueError(
106
- f"blob is {len(raw)} bytes, not a whole number of {DIMS}-float rows "
107
- f"(remainder {remainder}) -- the index and the vectors disagree"
108
- )
109
- flat = struct.unpack(f"<{count * DIMS}f", raw[: count * 4 * DIMS])
110
- return [list(flat[i * DIMS : (i + 1) * DIMS]) for i in range(count)]
111
-
112
-
113
- def encode_blob(rows: list[list[float]]) -> bytes:
114
- for index, row in enumerate(rows):
115
- if len(row) != DIMS:
116
- raise ValueError(f"row {index} has {len(row)} values, expected {DIMS}")
117
- return struct.pack(f"<{len(rows) * DIMS}f", *(v for row in rows for v in row))
118
-
119
-
120
- # --------------------------------------------------------------------------
121
- # Reading the current state
122
- # --------------------------------------------------------------------------
123
-
124
- def fetch_live_images() -> dict[str, dict]:
125
- """Every image the API currently serves, keyed by image_id.
126
-
127
- The paging parameter is `top_k`, not `limit`; sending `limit` silently
128
- returns the default page of 24 no matter what is asked for.
129
- """
130
- first = post_json(f"{API}/latest", {"top_k": 1, "offset": 0})
131
- total = int(first.get("total", 0))
132
- live: dict[str, dict] = {}
133
- offset = 0
134
- while offset < total:
135
- page = post_json(f"{API}/latest", {"top_k": 250, "offset": offset})
136
- rows = page.get("results", [])
137
- if not rows:
138
- break
139
- for row in rows:
140
- live[row["image_id"]] = row
141
- offset += len(rows)
142
- time.sleep(0.4)
143
- log(f" API reports {total} live images; collected {len(live)}")
144
- if len(live) != total:
145
- log(f" WARNING: expected {total} ids but collected {len(live)}")
146
- return live
147
-
148
-
149
- def load_field_state() -> tuple[list[dict], list[list[float]]]:
150
- index = json.loads(get(RESOLVE + INDEX_NAME).decode())
151
- blob = decode_blob(get(RESOLVE + BLOB_NAME))
152
- if len(index) != len(blob):
153
- raise ValueError(
154
- f"index has {len(index)} entries but the blob holds {len(blob)} vectors; "
155
- "refusing to write a file the field cannot load"
156
- )
157
- return index, blob
158
-
159
-
160
- # --------------------------------------------------------------------------
161
- # Record conventions, mirrored from the field's own scraper output
162
- # --------------------------------------------------------------------------
163
-
164
- def aspect_ratio(image: Image.Image) -> float:
165
- width, height = image.size
166
- return round(width / height, 4) if height else 1.0
167
-
168
-
169
- def blur_placeholder(image: Image.Image) -> str:
170
- """
171
- A tiny base64 JPEG, like the placeholders the scraper writes.
172
-
173
- The field paints `blur` behind each tile, so an image without one shows an
174
- empty box for the frames before its bitmap arrives. This costs ~60 bytes and
175
- removes that flash.
176
- """
177
- buffer = io.BytesIO()
178
- image.convert("RGB").resize((4, 3)).save(buffer, format="JPEG", quality=40)
179
- return base64.b64encode(buffer.getvalue()).decode()
180
-
181
-
182
- def title_from(entry: dict) -> str:
183
- for key in ("desc", "title"):
184
- value = entry.get(key)
185
- if isinstance(value, str) and value.strip():
186
- return " ".join(value.split())[:120]
187
- for key in ("labels", "keywords"):
188
- value = entry.get(key)
189
- if isinstance(value, list) and value:
190
- return str(value[0]).strip()[:120]
191
- return ""
192
-
193
-
194
- def categories_from(entry: dict) -> list[str]:
195
- out: list[str] = []
196
- for key in ("categories", "room_hints"):
197
- for value in entry.get(key) or []:
198
- text = str(value).strip()
199
- if text and text not in out:
200
- out.append(text)
201
- return out[:4] or ["Uncategorized"]
202
-
203
-
204
- def build_record(entry: dict, image: Image.Image) -> dict:
205
- """One interior row, in exactly the shape image-embeddings.json uses."""
206
- image_id = entry["image_id"]
207
- return {
208
- "id": image_id,
209
- # The field resolves an interior's bitmap relative to its own dataset and
210
- # the scraper stored a bare filename, so keep that convention.
211
- "url": f"{image_id}.jpg",
212
- "ar": aspect_ratio(image),
213
- "blur": blur_placeholder(image),
214
- "title": title_from(entry),
215
- "categories": categories_from(entry),
216
- "source": str(entry.get("source") or ""),
217
- }
218
-
219
-
220
- # --------------------------------------------------------------------------
221
- # Embedding
222
- # --------------------------------------------------------------------------
223
-
224
- class Embedder:
225
- """The field's own image model, loaded lazily so a no-op sync is cheap."""
226
-
227
- def __init__(self) -> None:
228
- self._model = None
229
-
230
- def _load(self):
231
- if self._model is None:
232
- from sentence_transformers import SentenceTransformer
233
-
234
- log(f" loading {IMAGE_MODEL} (first run downloads ~600 MB)")
235
- self._model = SentenceTransformer(IMAGE_MODEL)
236
- return self._model
237
-
238
- def image(self, image: Image.Image) -> list[float]:
239
- vector = self._load().encode([image], convert_to_numpy=True)[0]
240
- norm = float((vector**2).sum() ** 0.5)
241
- if norm > 0:
242
- vector = vector / norm
243
- if len(vector) != DIMS:
244
- raise ValueError(f"model returned {len(vector)} dims, expected {DIMS}")
245
- return [float(v) for v in vector]
246
-
247
-
248
- # --------------------------------------------------------------------------
249
- # The sync itself
250
- # --------------------------------------------------------------------------
251
-
252
- def main() -> int:
253
- parser = argparse.ArgumentParser(description=__doc__)
254
- parser.add_argument("--dry-run", action="store_true", help="report changes, write nothing")
255
- parser.add_argument("--no-upload", action="store_true", help="build everything but do not push")
256
- parser.add_argument("--limit", type=int, default=0, help="cap how many images to add")
257
- args = parser.parse_args()
258
-
259
- if not HUB and not args.dry_run and not args.no_upload:
260
- log("HF_TOKEN is not set. Export it, or pass --no-upload to build without pushing.")
261
- return 2
262
-
263
- log("Reading the field...")
264
- index, vectors = load_field_state()
265
- log(f" field holds {len(index)} interiors")
266
-
267
- log("Reading the API...")
268
- live = fetch_live_images()
269
-
270
- have = {str(row.get("id")) for row in index}
271
- to_add = sorted(set(live) - have)
272
- to_drop = sorted(have - set(live))
273
-
274
- log("")
275
- log(f" to add : {len(to_add)}")
276
- log(f" to remove: {len(to_drop)}")
277
- if to_drop:
278
- log(f" {', '.join(to_drop[:6])}{' ...' if len(to_drop) > 6 else ''}")
279
- if to_add:
280
- log(f" {', '.join(to_add[:6])}{' ...' if len(to_add) > 6 else ''}")
281
-
282
- if args.limit:
283
- to_add = to_add[: args.limit]
284
- log(f" limited to {len(to_add)} additions for this run")
285
-
286
- if not to_add and not to_drop:
287
- log("\nNothing to do -- the two sides already agree.")
288
- return 0
289
- if args.dry_run:
290
- log("\nDry run: nothing was written.")
291
- return 0
292
- if not to_add:
293
- log("\nOnly removals pending, which the same run applies below.")
294
- if not to_add and not to_drop:
295
- return 0
296
-
297
- added_rows: list[dict] = []
298
- added_vectors: list[list[float]] = []
299
- bitmaps: list[tuple[str, bytes]] = []
300
-
301
- if to_add:
302
- log(f"\nEmbedding {len(to_add)} new interiors...")
303
- embedder = Embedder()
304
- for n, image_id in enumerate(to_add, 1):
305
- entry = live[image_id]
306
- try:
307
- raw = get(f"{API}/image/{image_id}.jpg")
308
- image = Image.open(io.BytesIO(raw))
309
- image.load()
310
- except (urllib.error.URLError, OSError) as error:
311
- log(f" [{n}/{len(to_add)}] {image_id}: fetch failed ({error}) -- skipped")
312
- continue
313
- try:
314
- vector = embedder.image(image)
315
- except Exception as error: # noqa: BLE001 - report and keep going
316
- log(f" [{n}/{len(to_add)}] {image_id}: embed failed ({error}) -- skipped")
317
- continue
318
- added_rows.append(build_record(entry, image))
319
- added_vectors.append(vector)
320
- bitmaps.append((f"images/{image_id}.jpg", raw))
321
- log(f" [{n}/{len(to_add)}] {image_id} {image.size[0]}x{image.size[1]} {title_from(entry)[:44]}")
322
-
323
- new_index = index + added_rows
324
- new_vectors = vectors + added_vectors
325
-
326
- if to_drop:
327
- # Rebuild both files from the surviving rows, so the index and the blob
328
- # cannot drift out of alignment: the field refuses to load them otherwise.
329
- drop = set(to_drop)
330
- keep = [i for i, row in enumerate(new_index) if str(row.get("id")) not in drop]
331
- new_index = [new_index[i] for i in keep]
332
- new_vectors = [new_vectors[i] for i in keep]
333
-
334
- log("")
335
- log(f"Final: {len(new_index)} interiors "
336
- f"(+{len(added_rows)} added, -{len(to_drop)} removed)")
337
-
338
- if not added_rows and not to_drop:
339
- log("Nothing changed.")
340
- return 0
341
- if args.no_upload:
342
- log("Skipping push (--no-upload).")
343
- return 0
344
-
345
- from huggingface_hub import CommitOperationAdd, CommitOperationDelete, HfApi
346
-
347
- client = HfApi(token=HUB) if HUB else HfApi()
348
- operations = [CommitOperationAdd(path_in_repo=name, path_or_fileobj=data)
349
- for name, data in bitmaps]
350
- operations.append(CommitOperationAdd(
351
- path_in_repo=INDEX_NAME,
352
- path_or_fileobj=json.dumps(new_index, ensure_ascii=False).encode(),
353
- ))
354
- operations.append(CommitOperationAdd(
355
- path_in_repo=BLOB_NAME, path_or_fileobj=encode_blob(new_vectors)))
356
- if to_drop:
357
- # Only delete what is actually on the Hub, or the whole commit 404s.
358
- present = set(client.list_repo_files(DATASET, repo_type="dataset"))
359
- removed = 0
360
- for image_id in to_drop:
361
- path = f"images/{image_id}.jpg"
362
- if path in present:
363
- operations.append(CommitOperationDelete(path_in_repo=path))
364
- removed += 1
365
- log(f" deleting {removed} of {len(to_drop)} bitmaps (the rest were already gone)")
366
-
367
- commit = client.create_commit(
368
- repo_id=DATASET,
369
- repo_type="dataset",
370
- operations=operations,
371
- commit_message=(
372
- f"sync interiors with vai-avatar: +{len(added_rows)} -{len(to_drop)}\n\n"
373
- "Driven by the API's live image list rather than metadata.json: that file\n"
374
- "retains deleted rows (1,752 rows for 1,122 live images), so a\n"
375
- "file-driven sync resurrects whatever the user removed on the other side.\n\n"
376
- "New images are embedded with the field's own CLIP model. The two sides\n"
377
- "use different embedding models, so copying a file would place it in an\n"
378
- "unrelated vector space and search would return noise.\n\n"
379
- "Generated by scripts/sync-interiors.py."
380
- ),
381
- )
382
- log(f"Pushed {commit.oid} to {DATASET}")
383
- log("The field polls the dataset revision, so these appear within a minute.")
384
- return 0
385
-
386
-
387
- if __name__ == "__main__":
388
- sys.exit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-avatar-interiors.mjs DELETED
@@ -1,287 +0,0 @@
1
- /**
2
- * Tests for the vai-avatar interior sync and the featured/neon emphasis.
3
- *
4
- * The emphasis rules are the part most likely to rot silently: a wrong tile
5
- * still renders, it just renders at the wrong size or without the ring, and
6
- * nothing throws. These assert the geometry and the ordering, not just "no
7
- * crash".
8
- *
9
- * The share-URL block is here for the same reason, and it is deliberately
10
- * Node-only. The browser copy of this rule lives in dist/assets/share-fix.js as
11
- * a string regex, because it has to run against the served bundle before a
12
- * rebuild; the routing decision itself lives in src/assets.js. These tests pin
13
- * the src/ half so the two cannot drift apart silently, and the browser half is
14
- * asserted separately in scripts/test-neon-ui.mjs.
15
- */
16
- import assert from 'node:assert/strict';
17
- import { mergeResults } from '../src/merge.js';
18
- import { foldText, searchAvatarInteriors } from '../src/avatar-interiors.js';
19
- import { shareURL } from '../src/assets.js';
20
-
21
- let passed = 0, failed = 0;
22
- const test = (name, fn) => {
23
- try { fn(); console.log(' ok ' + name); passed++; }
24
- catch (error) { console.log(' FAIL ' + name + '\n ' + error.message); failed++; }
25
- };
26
-
27
- const interior = (id, score) => ({ id: `interior:${id}`, blur: 'L000', ar: 1, score });
28
- const product = (sku, score) => ({ sku, name: 'Tủ bếp', ar: 1, score, images: ['https://x/y.jpg'] });
29
-
30
- console.log('\nvai-avatar interior sync');
31
- // ──────────────────────────────────────────────────────────────────────────
32
-
33
- test('Vietnamese folding makes diacritics optional on both sides', () => {
34
- // The whole reason foldText exists: a visitor types "bep da", the record says
35
- // "Bếp đá". Without folding these never match and the neon never fires.
36
- assert.equal(foldText('Bếp đá'), 'bep da');
37
- assert.equal(foldText('Phòng Ngủ'), 'phong ngu');
38
- });
39
-
40
- test('a whole-query match outranks a single-term match', () => {
41
- const items = [
42
- { avatarImageId: 'a', title: 'sofa phòng khách hiện đại', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z' },
43
- { avatarImageId: 'b', title: 'ghế sofa', description: 'mẫu ghế đơn', categories: [], keywords: [], addedAt: '2026-10-02T00:00:00Z' },
44
- ];
45
- const hits = searchAvatarInteriors('sofa phòng khách', items);
46
- assert.equal(hits[0].avatarImageId, 'a', 'the full-query match must rank first');
47
- });
48
-
49
- test('a whole-query match scores above the exact-outline threshold', () => {
50
- // 900 is the same floor relevance.js uses, and it is what makes merge.js treat
51
- // this as identity rather than resemblance. If this dropped below 700 the neon
52
- // would silently stop appearing on newly added photographs.
53
- const items = [{ avatarImageId: 'a', title: 'chuyển sang phong cách classical', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z' }];
54
- assert.ok(searchAvatarInteriors('classical', items)[0].score >= 700);
55
- });
56
-
57
- test('an unrelated query returns nothing rather than everything', () => {
58
- const items = [{ avatarImageId: 'a', title: 'sofa phòng khách', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z' }];
59
- assert.equal(searchAvatarInteriors('móc treo', items).length, 0);
60
- });
61
-
62
- test('a query too short to be meaningful is rejected', () => {
63
- const items = [{ avatarImageId: 'a', title: 'a', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z' }];
64
- assert.equal(searchAvatarInteriors('a', items).length, 0);
65
- });
66
-
67
- console.log('\nshare URLs');
68
- // ──────────────────────────────────────────────────────────────────────────
69
-
70
- const STUDIO = 'https://bep40-vai-avatar.hf.space';
71
- const broken = 'https://bep40-vai-avatar.hf.space/share/p/avatarimg%3Ab68dc779370dc70e';
72
- const fixed = `${STUDIO}/share/i/b68dc779370dc70e`;
73
-
74
- test('the reported broken URL is now the interior route', () => {
75
- // The exact address from the report. `avatarimg:` is a namespace for
76
- // de-duplication, not a catalogue identity, so /share/p/ resolved to nothing
77
- // and vai-avatar answered "Not Found" for every interior photograph.
78
- assert.equal(shareURL({ avatarImageId: 'b68dc779370dc70e', sku: 'avatarimg:b68dc779370dc70e' }), fixed);
79
- });
80
-
81
- test('the bug is reproducible against the old logic', () => {
82
- // Pins what the fix is fixing, so the assertion above cannot pass for an
83
- // unrelated reason. Without the avatarImageId branch, `sku` wins and the link
84
- // is the dead one -- byte-identical to the reported URL.
85
- const photo = { sku: 'avatarimg:b68dc779370dc70e' };
86
- const oldRoute = `${STUDIO}/share/p/${encodeURIComponent(photo.sku)}`;
87
- assert.equal(oldRoute, broken, 'the reported URL must be what the old routing produced');
88
- assert.notEqual(shareURL(photo), oldRoute, 'shareURL still routes an avatarimg sku to /share/p/');
89
- });
90
-
91
- test('an interior is routed even when it has lost avatarImageId', () => {
92
- // merge.js rewrites `id` into a namespaced tile key, so a record can arrive
93
- // carrying the sku but not the original field. The prefix is unwrapped rather
94
- // than treated as an identity, which is what this fallback exists for.
95
- assert.equal(shareURL({ sku: 'avatarimg:b68dc779370dc70e' }), fixed);
96
- });
97
-
98
- test('an empty avatarimg prefix yields no share URL at all', () => {
99
- // /share/i/ with nothing after it is its own 404. Returning null lets the
100
- // caller omit the button rather than render a dead link.
101
- assert.equal(shareURL({ sku: 'avatarimg:' }), null);
102
- assert.equal(shareURL({ avatarImageId: ' ' }), null);
103
- });
104
-
105
- test('a real product still routes to /share/p/', () => {
106
- // The fix must not disturb the route that works. A product keeps its slug, and
107
- // /share/p/ resolves all three identity forms.
108
- assert.equal(shareURL({ sku: 'MH800TC' }), `${STUDIO}/share/p/MH800TC`);
109
- assert.equal(
110
- shareURL({ sku: 'MH800TC', slug: 'mh800tc-malloca-may-hut-mui-gan-tu-am-tu-80cm' }),
111
- `${STUDIO}/share/p/mh800tc-malloca-may-hut-mui-gan-tu-am-tu-80cm`,
112
- 'the slug must still win over the sku',
113
- );
114
- });
115
-
116
- test('a baked interior without a sku keeps its own route', () => {
117
- // No sku at all: the id branch is what answers, and it must not be captured by
118
- // the avatarimg fallback.
119
- assert.equal(shareURL({ id: 'a1b2c3d4e5' }), `${STUDIO}/share/i/a1b2c3d4e5`);
120
- });
121
-
122
- test('a record with nothing to share returns null', () => {
123
- assert.equal(shareURL(null), null);
124
- assert.equal(shareURL(undefined), null);
125
- assert.equal(shareURL({}), null);
126
- assert.equal(shareURL('nope'), null);
127
- });
128
-
129
- console.log('\nfeatured tiles');
130
- // ──────────────────────────────────────────────────────────────────────────
131
-
132
- test('vai-avatar interiors land at the head of the field', () => {
133
- // "near the search box" is implemented as list order: index 0 is the slot
134
- // nearest the pill. A new photograph must not land mid-field.
135
- const out = mergeResults(
136
- [interior('a', 0.9), interior('b', 0.8)],
137
- [],
138
- { avatarInteriors: [{ avatarImageId: 'new1', title: 'Mới', addedAt: '2026-10-05T00:00:00Z' }] },
139
- );
140
- assert.equal(out[0].avatarImageId, 'new1');
141
- assert.ok(out[0].featured, 'a new photograph is featured');
142
- });
143
-
144
- test('newest first: the most recent photograph is the first tile', () => {
145
- const out = mergeResults([], [], { avatarInteriors: [
146
- { avatarImageId: 'older', title: 'Cũ', addedAt: '2026-10-01T00:00:00Z' },
147
- { avatarImageId: 'newer', title: 'Mới', addedAt: '2026-10-05T00:00:00Z' },
148
- ] });
149
- assert.equal(out[0].avatarImageId, 'newer');
150
- });
151
-
152
- test('a featured tile is measurably larger than an ordinary one', () => {
153
- const out = mergeResults([], [], { avatarInteriors: [
154
- { avatarImageId: 'n', title: 'Mới', addedAt: '2026-10-05T00:00:00Z' },
155
- ] });
156
- assert.ok(out[0].featuredScale > 1, 'featuredScale must exceed 1');
157
- });
158
-
159
- test('a featured interior keeps a finite score so it is not dimmed', () => {
160
- // relevanceAppearance() dims any tile whose score is not finite -- a featured
161
- // tile rendered at 14% opacity would be the opposite of the request.
162
- const out = mergeResults([], [], { avatarInteriors: [{ avatarImageId: 'n', title: 'Mới' }] });
163
- assert.ok(Number.isFinite(out[0].score), 'score must be finite, got ' + out[0].score);
164
- });
165
-
166
- test('a decisive product winner is featured and reaches the head', () => {
167
- // 1000 = exact SKU in vai-avatar's scorer, next tier is 550, so this is
168
- // identity, and it should be presented like the answer rather than like a hit.
169
- // The shape is the worker's own `{ sku, score, product }` wrapper.
170
- const out = mergeResults(
171
- [interior('a', 0.9)],
172
- [{ sku: 'SKU1', score: 1000, product: { sku: 'SKU1', name: 'Tủ bếp', ar: 1, images: ['https://cdn/x.jpg'] } }],
173
- { query: 'SKU1' },
174
- );
175
- const hit = out.find(r => r.sku === 'SKU1');
176
- assert.ok(hit?.featured, 'the exact product is featured');
177
- assert.ok(hit.featuredScale > 1);
178
- });
179
-
180
- test('a non-decisive product is NOT featured', () => {
181
- // Two products scoring 1000 and 900 is a model family, not an answer. Enlarging
182
- // it would be misleading, and the neon must not ring it either.
183
- const out = mergeResults([interior('a', 0.9)], [
184
- { sku: 'SKU1', score: 1000, product: { sku: 'SKU1', name: 'A', ar: 1, images: ['https://cdn/x.jpg'] } },
185
- { sku: 'SKU2', score: 900, product: { sku: 'SKU2', name: 'B', ar: 1, images: ['https://cdn/x.jpg'] } },
186
- ], { query: 'SKU1' });
187
- assert.ok(!out.find(r => r.sku === 'SKU1')?.featured, 'a tie must not be featured');
188
- });
189
-
190
- test('enlargement and the neon agree: every featured interior is an exact hit', () => {
191
- // The two decisions come from one source (exactMatches). This asserts the
192
- // consequence -- a tile is never enlarged without being ringed.
193
- const out = mergeResults([interior('a', 0.9)], [], {
194
- avatarInteriors: [{ avatarImageId: 'n', title: 'phong cách classical', score: 900 }],
195
- query: 'classical',
196
- });
197
- const tile = out[0];
198
- assert.ok(tile.featured && tile.featuredScale > 1);
199
- });
200
-
201
- console.log('\nnamespace safety');
202
- // ──────────────────────────────────────────────────────────────────────────
203
-
204
- test('an avatar interior cannot be shadowed by a colliding raw id', () => {
205
- // A bare id in a shared namespace would let one record silently drop another
206
- // with no error. Namespacing is what makes that impossible.
207
- const out = mergeResults([interior('n', 0.5)], [], {
208
- avatarInteriors: [{ avatarImageId: 'n', title: 'Mới' }],
209
- });
210
- assert.equal(out.length, 2, 'both records survive: ' + JSON.stringify(out.map(r => r.id)));
211
- });
212
-
213
- test('a duplicate avatar interior is listed once', () => {
214
- const out = mergeResults([], [], { avatarInteriors: [
215
- { avatarImageId: 'n', title: 'Mới' }, { avatarImageId: 'n', title: 'Mới' },
216
- ] });
217
- assert.equal(out.filter(r => r.avatarImageId === 'n').length, 1);
218
- });
219
-
220
- test('an interior without an avatar id is skipped, not rendered blank', () => {
221
- const out = mergeResults([interior('a', 0.9)], [], { avatarInteriors: [{ title: 'không có id' }] });
222
- assert.equal(out.length, 1);
223
- });
224
-
225
- test('an empty avatar list changes nothing', () => {
226
- const base = mergeResults([interior('a', 0.9), interior('b', 0.8)], [product('S1', 500)]);
227
- assert.equal(mergeResults([interior('a', 0.9), interior('b', 0.8)], [product('S1', 500)], { avatarInteriors: [] }).length, base.length);
228
- });
229
-
230
- console.log('\nonly the newest ring');
231
- // ──────────────────────────────────────────────────────────────────────────
232
-
233
- /** Build `n` avatar interiors, newest last, and merge them into the field. */
234
- const freshSet = n => Array.from({ length: n }, (_, i) => ({
235
- avatarImageId: `id${i}`,
236
- title: `Ảnh ${i}`,
237
- addedAt: `2026-10-${String((i % 28) + 1).padStart(2, '0')}T00:00:00Z`,
238
- }));
239
-
240
- test('exactly ten of many new photographs carry the neon', () => {
241
- // The brief asks for more photographs on the page AND for only the newest to
242
- // ring. Those pull against each other, so the count is pinned: 24 rings is
243
- // decoration, not emphasis.
244
- const out = mergeResults([], [], { avatarInteriors: freshSet(40) });
245
- const ringed = out.filter(r => r.featured);
246
- assert.equal(ringed.length, 10, 'expected 10 ringed, got ' + ringed.length);
247
- });
248
-
249
- test('the un-ringed ones are still shown as ordinary tiles', () => {
250
- // "Chỉ chọn 10 ảnh mới nhất có viền neon còn lại hiển thị bình thường" -- the
251
- // rest must be VISIBLE, just not ringed. Dropping them would satisfy the first
252
- // half of the sentence and break the second.
253
- const out = mergeResults([], [], { avatarInteriors: freshSet(40) });
254
- assert.equal(out.length, 40, 'all 40 should be in the field, got ' + out.length);
255
- assert.equal(out.filter(r => !r.featured).length, 30);
256
- });
257
-
258
- test('an un-ringed tile has no featuredScale, so it renders at normal size', () => {
259
- const out = mergeResults([], [], { avatarInteriors: freshSet(40) });
260
- const plain = out.filter(r => !r.featured);
261
- assert.ok(plain.every(r => r.featuredScale === undefined), 'an ordinary tile carries a scale');
262
- });
263
-
264
- test('the ten ringed are the newest ten, in order', () => {
265
- // mergedAt is per-record, so equal dates sort by input order; distinct dates
266
- // make the intent unambiguous.
267
- const items = Array.from({ length: 15 }, (_, i) => ({
268
- avatarImageId: `x${i}`,
269
- title: `Ảnh ${i}`,
270
- addedAt: `2026-10-${String(i + 1).padStart(2, '0')}T00:00:00Z`,
271
- }));
272
- const ringed = mergeResults([], [], { avatarInteriors: items }).filter(r => r.featured);
273
- assert.equal(ringed.length, 10);
274
- // x14 is day 15, the newest. The ringed set must be days 15 down to 6.
275
- assert.equal(ringed[0].avatarImageId, 'x14', 'newest is not first');
276
- assert.equal(ringed[9].avatarImageId, 'x5');
277
- const plain = mergeResults([], [], { avatarInteriors: items }).filter(r => !r.featured);
278
- assert.equal(plain[0].avatarImageId, 'x4', 'the oldest ringed-off one is day 5');
279
- });
280
-
281
- test('fewer than ten new photographs still all ring', () => {
282
- const out = mergeResults([], [], { avatarInteriors: freshSet(4) });
283
- assert.equal(out.filter(r => r.featured).length, 4);
284
- });
285
-
286
- console.log(`\n${passed} passed, ${failed} failed`);
287
- process.exit(failed ? 1 : 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-catalogue.mjs DELETED
@@ -1,187 +0,0 @@
1
- /**
2
- * Unit tests for the FULL catalogue loader and its de-duplication.
3
- *
4
- * Run: node scripts/test-catalogue.mjs
5
- *
6
- * The shop catalogue is 11,704 rows that must collapse to one row per product.
7
- * Getting that wrong is invisible in review and obvious in use: a duplicated
8
- * appliance takes two tiles in the field and two rows in every result list.
9
- *
10
- * These pin the three properties the UI depends on:
11
- *
12
- * 1. the stub rows carry no sku, no price and the same image as the real row
13
- * next to them, so they must lose;
14
- * 2. two skus that differ only by a trailing letter are ONE product;
15
- * 3. the overlay (Sửa / Xóa from vai-avatar) must still resolve onto a base
16
- * row, because that is what makes an edit visible instead of duplicating.
17
- */
18
-
19
- import assert from 'node:assert/strict';
20
- import { buildBaseCatalogue, buildCatalogue } from '../src/catalogue.js';
21
-
22
- let passed = 0;
23
- const test = (name, fn) => {
24
- fn();
25
- passed++;
26
- console.log(` ok ${name}`);
27
- };
28
-
29
- console.log('buildBaseCatalogue');
30
-
31
- test('collapses the slug-derived stub onto the real row', () => {
32
- const base = buildBaseCatalogue([
33
- { sku: '892.14.719', mod: '89214719', n: 'Kệ Giày Hafele 82914719', slug: 'ke-gia-hafele', pn: 119000, imgs: ['https://x/1.jpg'] },
34
- // Same slug and same image, but no sku and no price -- a stub.
35
- { sku: '', mod: '', n: 'Ke Gia Hafele 82914719 1 1 17876', slug: 'ke-gia-hafele', pn: 0, imgs: ['https://x/1.jpg'] },
36
- ]);
37
- assert.equal(base.length, 1);
38
- assert.equal(base[0].sku, '892.14.719');
39
- });
40
-
41
- test('keeps a Malloca `T` variant that shares a name with its base', () => {
42
- // INDUST K140B ships as 600001297 (5 photos) and 600001297T (9 photos).
43
- // Same mod, same name, different slugs -- different products. Collapsing on
44
- // (mod, name) removed 355 such rows and the whole Malloca range went missing.
45
- const base = buildBaseCatalogue([
46
- { sku: '600001297', mod: 'K140B', n: 'INDUST K140B | Malloca | Máy hút mùi gắn trần 140cm', imgs: ['https://cdn/a.png'], slug: 'indust-k140b' },
47
- { sku: '600001297T', mod: 'K140B', n: 'INDUST K140B | Malloca | Máy hút mùi gắn trần 140cm', imgs: ['https://cdn/b.png'], slug: 'indust-k140b-2' },
48
- ]);
49
- assert.equal(base.length, 2);
50
- });
51
-
52
- test('keeps a range that shares ONE photo across differently-named products', () => {
53
- // Eurogold sells EWG101/102/103 from a single range image. Keying on the
54
- // photo alone collapsed 3,921 such ranges.
55
- const photo = 'https://cdn/range-ng-nhom-kinh-eurogold-ewg101-ewg102-ewg103.jpg';
56
- const base = buildBaseCatalogue([
57
- { sku: 'EWG101', n: 'EWG101 | Eurogold | Bản lề kính giảm chấn thẳng', imgs: [photo], slug: 'ewg101' },
58
- { sku: 'EWG102', n: 'EWG102 | Eurogold | Bản lề kính giảm chấn cong 1/2', imgs: [photo], slug: 'ewg102' },
59
- { sku: 'EWG103', n: 'EWG103 | Eurogold | Bản lề kính giảm chấn cong 3/4', imgs: [photo], slug: 'ewg103' },
60
- ]);
61
- assert.equal(base.length, 3);
62
- });
63
-
64
- test('ignores the CDN cache-busting query when matching photos', () => {
65
- // The same photo re-uploaded under a new ?v=<timestamp> is one product.
66
- const base = buildBaseCatalogue([
67
- { sku: '1', n: 'Bếp Từ Alpha', imgs: ['https://cdn/a.png?v=1767087917033'], slug: 'bep-alpha' },
68
- { sku: '2', n: 'Bếp Từ Alpha', imgs: ['https://cdn/a.png?v=1776733402070'], slug: 'bep-alpha-2' },
69
- ]);
70
- assert.equal(base.length, 1);
71
- });
72
-
73
- test('keeps two products that share a brand but differ in name', () => {
74
- const base = buildBaseCatalogue([
75
- { sku: '1', mod: 'M1', n: 'Bếp Từ 3 Vùng', imgs: ['https://cdn/1.png'], slug: 'bep-3-vung' },
76
- { sku: '2', mod: 'M1', n: 'Bếp Từ 4 Vùng', imgs: ['https://cdn/2.png'], slug: 'bep-4-vung' },
77
- ]);
78
- assert.equal(base.length, 2);
79
- });
80
-
81
- test('keeps rows that share nothing', () => {
82
- const base = buildBaseCatalogue([
83
- { sku: '1', mod: 'A', n: 'Alpha', imgs: ['https://cdn/1.png'], slug: 'alpha' },
84
- { sku: '2', mod: 'B', n: 'Beta', imgs: ['https://cdn/2.png'], slug: 'beta' },
85
- ]);
86
- assert.equal(base.length, 2);
87
- });
88
-
89
- test('ignores rows with no usable identity', () => {
90
- // Neither a slug nor a photo means nothing to key on. Every real row has
91
- // both (measured: 11,704 of 11,704), so this is a guard on junk input only.
92
- assert.deepEqual(buildBaseCatalogue([{}, null, 'x', { n: '', imgs: [] }]), []);
93
- });
94
-
95
- test('keeps a row that has only a slug, with no image', () => {
96
- const base = buildBaseCatalogue([
97
- { sku: '1', n: 'Không có ảnh A', slug: 'khong-co-anh-a' },
98
- { sku: '2', n: 'Không có ảnh B', slug: 'khong-co-anh-b' },
99
- ]);
100
- assert.equal(base.length, 2);
101
- });
102
-
103
- test('tolerates a non-array input', () => {
104
- assert.deepEqual(buildBaseCatalogue(null), []);
105
- assert.deepEqual(buildBaseCatalogue(undefined), []);
106
- });
107
-
108
- test('the row carrying a sku wins whichever order the file lists them in', () => {
109
- // Both rows carry a slug, as every row in the real file does (measured:
110
- // 11,704 of 11,704). A stub that arrived FIRST must still lose to the real
111
- // row, or the same product renders differently between two reloads.
112
- const real = { sku: '1', mod: 'M', n: 'Kệ Giày Hafele', slug: 'ke-gia-hafele', pn: 5 };
113
- const stub = { sku: '', mod: '', n: 'Ke Gia Hafele', slug: 'ke-gia-hafele', pn: 0 };
114
- for (const rows of [[real, stub], [stub, real]]) {
115
- const out = buildBaseCatalogue(rows);
116
- assert.equal(out.length, 1);
117
- assert.equal(out[0].sku, '1', 'the real row must win in either order');
118
- }
119
- });
120
-
121
- console.log('\nbuildCatalogue with a base');
122
-
123
- const base = buildBaseCatalogue([
124
- { sku: 'A1', mod: 'MA1', n: 'Bếp Từ Alpha', pn: 1000000, p: '1.000.000đ', imgs: ['https://x/alpha.jpg'], brand: 'Malloca' },
125
- { sku: 'B1', mod: 'MB1', n: 'Tủ Lạnh Beta', pn: 2000000, p: '2.000.000đ', imgs: ['https://x/beta.jpg'], brand: 'Bosch' },
126
- ]);
127
-
128
- test('lists the whole base on its own', () => {
129
- const items = buildCatalogue([], {}, base);
130
- assert.equal(items.size, 2);
131
- });
132
-
133
- test('an edit by sku overwrites the base row rather than adding one', () => {
134
- const promos = { productEdits: { A1: { name: 'Tên Mới', priceNum: 9999000 } } };
135
- const items = buildCatalogue([], promos, base);
136
- assert.equal(items.size, 2, 'the edit must not create a third product');
137
- const edited = [...items.values()].find(p => p.sku === 'A1');
138
- assert.equal(edited.name, 'Tên Mới');
139
- // priceNum must beat the base display string, or the edit is invisible.
140
- assert.equal(edited.priceNum, 9999000);
141
- });
142
-
143
- test('an edit by model code also resolves', () => {
144
- const promos = { productEdits: { MA1: { priceNum: 7777000 } } };
145
- const items = buildCatalogue([], promos, base);
146
- assert.equal(items.size, 2);
147
- assert.equal([...items.values()].find(p => p.sku === 'A1').priceNum, 7777000);
148
- });
149
-
150
- test('a tombstone removes the base product', () => {
151
- const promos = { productEdits: { A1: { deleted: true } } };
152
- const items = buildCatalogue([], promos, base);
153
- assert.equal(items.size, 1);
154
- assert.equal([...items.values()].some(p => p.sku === 'A1'), false);
155
- });
156
-
157
- test('a pasted URL for a base product amends it instead of duplicating', () => {
158
- const added = [{ sku: 'A1', n: 'Bếp Từ Alpha Đổi Ảnh', imgs: ['https://x/new.jpg'] }];
159
- const items = buildCatalogue(added, {}, base);
160
- assert.equal(items.size, 2, 're-pasting a known sku must not add a row');
161
- assert.equal([...items.values()].find(p => p.sku === 'A1').images[0], 'https://x/new.jpg');
162
- });
163
-
164
- test('a genuinely new product is added', () => {
165
- const added = [{ sku: 'NEW1', n: 'Sản Phẩm Mới', imgs: ['https://x/new.jpg'], pn: 500000 }];
166
- const items = buildCatalogue(added, {}, base);
167
- assert.equal(items.size, 3);
168
- assert.equal(items.has('NEW1'), true);
169
- });
170
-
171
- test('add, edit and delete compose without disturbing each other', () => {
172
- const added = [{ sku: 'NEW1', n: 'SP Mới', imgs: ['https://x/new.jpg'], pn: 500000 }];
173
- const promos = { productEdits: { A1: { deleted: true }, B1: { priceNum: 1234000 } } };
174
- const items = buildCatalogue(added, promos, base);
175
- assert.equal(items.size, 2);
176
- assert.equal(items.has('A1'), false, 'deleted');
177
- assert.equal(items.has('NEW1'), true, 'added');
178
- assert.equal([...items.values()].find(p => p.sku === 'B1').priceNum, 1234000, 'edited');
179
- });
180
-
181
- test('a base row keeps its images after an edit touches only the price', () => {
182
- const promos = { productEdits: { A1: { priceNum: 1111000 } } };
183
- const items = buildCatalogue([], promos, base);
184
- assert.deepEqual([...items.values()].find(p => p.sku === 'A1').images, ['https://x/alpha.jpg']);
185
- });
186
-
187
- console.log(`\n${passed} passed`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-merge.mjs DELETED
@@ -1,210 +0,0 @@
1
- /**
2
- * Unit tests for the product/interior merge.
3
- *
4
- * Run: node scripts/test-merge.mjs
5
- *
6
- * The merge is the one piece of this feature with no visual fallback: if it
7
- * drops products, duplicates a tile or reorders interiors wrongly, the field
8
- * simply looks wrong and nothing reports an error. These assert the properties
9
- * the field depends on.
10
- */
11
-
12
- import assert from 'node:assert/strict';
13
- import { mergeResults } from '../src/merge.js';
14
-
15
- let passed = 0;
16
- const test = (name, fn) => {
17
- fn();
18
- passed++;
19
- console.log(` ok ${name}`);
20
- };
21
-
22
- const interiors = count => Array.from({ length: count }, (_, i) => ({ id: `p${i}`, ar: 1.5 }));
23
-
24
- const product = (sku, extra = {}) => ({
25
- sku, name: `SP ${sku}`, ar: 1, images: [`https://cdn.example/${sku}.jpg`], ...extra,
26
- });
27
-
28
- console.log('mergeResults');
29
-
30
- test('returns interiors unchanged when there are no products', () => {
31
- const out = mergeResults(interiors(10), []);
32
- assert.equal(out.length, 10);
33
- assert.deepEqual(out.map(i => i.id), interiors(10).map(i => i.id));
34
- });
35
-
36
- test('tolerates a missing or null product list', () => {
37
- assert.equal(mergeResults(interiors(5), null).length, 5);
38
- assert.equal(mergeResults(interiors(5), undefined).length, 5);
39
- assert.equal(mergeResults(null, [product('A')]).length, 1);
40
- });
41
-
42
- test('weaves products in rather than appending them', () => {
43
- const out = mergeResults(interiors(20), [product('A'), product('B')]);
44
- assert.equal(out.length, 22);
45
- const a = out.findIndex(r => r.sku === 'A');
46
- const b = out.findIndex(r => r.sku === 'B');
47
- assert.ok(a < 20 && b < 20, 'products must not be pushed past every interior');
48
- assert.ok(b > a, 'product order must follow catalogue rank');
49
- });
50
-
51
- test('gives each product a resolvable absolute src', () => {
52
- const [hit] = mergeResults(interiors(4), [product('A')]).filter(r => r.sku);
53
- assert.equal(hit.src, 'https://cdn.example/A.jpg');
54
- // The tile component resolves `url` when `src` is absent; a product has no
55
- // dataset `url`, so `src` is what makes it renderable.
56
- assert.ok(!hit.url, 'product must not borrow an interior url field');
57
- });
58
-
59
- test('accepts the worker shape ({sku, score, product}) and the flat shape', () => {
60
- const wrapped = mergeResults(interiors(4), [{ sku: 'A', score: 900, product: product('A') }]);
61
- const flat = mergeResults(interiors(4), [product('A')]);
62
- assert.equal(wrapped.filter(r => r.sku).length, 1);
63
- assert.equal(flat.filter(r => r.sku).length, 1);
64
- assert.equal(wrapped.find(r => r.sku).src, flat.find(r => r.sku).src);
65
- });
66
-
67
- test('skips products with no usable image instead of rendering a blank tile', () => {
68
- const out = mergeResults(interiors(6), [
69
- { sku: 'NOIMG', images: [] },
70
- { sku: 'REL', images: ['/relative/path.jpg'] },
71
- { sku: 'OK', images: ['https://cdn.example/ok.jpg'] },
72
- ]);
73
- const skus = out.filter(r => r.sku).map(r => r.sku);
74
- assert.deepEqual(skus, ['OK']);
75
- });
76
-
77
- test('never duplicates an interior or a product', () => {
78
- const dup = [...interiors(5), ...interiors(5)];
79
- const out = mergeResults(dup, [product('A'), product('A'), product('B')]);
80
- assert.equal(new Set(out.filter(r => !r.sku).map(r => r.id)).size, 5);
81
- assert.equal(new Set(out.filter(r => r.sku).map(r => r.sku)).size, 2);
82
- });
83
-
84
- test('caps how many products enter the field', () => {
85
- const many = Array.from({ length: 60 }, (_, i) => product(`S${i}`));
86
- const out = mergeResults(interiors(60), many);
87
- assert.equal(out.filter(r => r.sku).length, 24);
88
- // The strongest catalogue hits are the ones kept.
89
- assert.deepEqual(out.filter(r => r.sku).slice(0, 3).map(r => r.sku), ['S0', 'S1', 'S2']);
90
- });
91
-
92
- test('honours an explicit productLimit', () => {
93
- const many = Array.from({ length: 40 }, (_, i) => product(`S${i}`));
94
- assert.equal(mergeResults(interiors(40), many, { productLimit: 3 }).filter(r => r.sku).length, 3);
95
- });
96
-
97
- test('keeps interior rank order intact', () => {
98
- const out = mergeResults(interiors(30), [product('A'), product('B'), product('C')]);
99
- assert.deepEqual(out.filter(r => !r.sku).map(r => r.id), interiors(30).map(r => r.id));
100
- });
101
-
102
- test('survives a short interior list', () => {
103
- const out = mergeResults(interiors(1), [product('A'), product('B')]);
104
- assert.equal(out.length, 3);
105
- assert.equal(out.filter(r => r.sku).length, 2);
106
- });
107
-
108
- test('a product id can never collide with a real interior id', () => {
109
- // Interior ids are 10 hex chars; 'malloca:' cannot be one, so the namespaces
110
- // are disjoint and a product is never dropped as a duplicate.
111
- const real = [{ id: '0004d4b651', ar: 1 }, { id: '0006a82e14', ar: 1 }];
112
- const out = mergeResults(real, [product('A')]);
113
- assert.equal(out.length, 3);
114
- assert.equal(out.filter(r => r.sku).length, 1);
115
- });
116
-
117
- test('an interior id that collides with a product namespace loses nothing', () => {
118
- // Both sides are tagged before de-duplication, so even a hostile id cannot
119
- // occupy the product's slot and drop it from the field.
120
- const out = mergeResults([{ id: 'malloca:A', ar: 1 }], [product('A')]);
121
- assert.equal(out.length, 2);
122
- assert.equal(out.filter(r => !r.sku).length, 1);
123
- assert.equal(out.filter(r => r.sku).length, 1);
124
- });
125
-
126
- test('a product always carries a usable id and aspect ratio', () => {
127
- const hit = mergeResults(interiors(4), [product('A')]).find(r => r.sku);
128
- // Every tile is keyed on id and sized from ar; a missing or NaN value makes
129
- // the tile unkeyed or zero-sized.
130
- assert.equal(typeof hit.id, 'string');
131
- assert.ok(hit.id.length > 0);
132
- assert.ok(Number.isFinite(hit.ar) && hit.ar >= 0.5 && hit.ar <= 2);
133
- });
134
-
135
- test('a product id is stable across queries, so it does not jump in the field', () => {
136
- const a = mergeResults(interiors(10), [product('A')]).find(r => r.sku);
137
- const b = mergeResults(interiors(30), [product('A')]).find(r => r.sku);
138
- // Layout seeds position/scale/phase off the id, so an id that changed with
139
- // the result count would teleport the tile on every search.
140
- assert.equal(a.id, b.id);
141
- });
142
-
143
- test('a product inherits relevance so it is not dimmed as unranked', () => {
144
- const ranked = interiors(20).map((r, i) => ({ ...r, score: 0.5 - i * 0.001 }));
145
- const hit = mergeResults(ranked, [product('A')]).find(r => r.sku);
146
- // relevanceAppearance() blurs any tile without a finite score, which would
147
- // make a merged product almost invisible.
148
- assert.ok(Number.isFinite(hit.score), 'product must have a finite score');
149
- assert.ok(hit.score > 0, 'score must sit in the visible cosine range');
150
- });
151
-
152
- test('a product still scores when NO interior matched', () => {
153
- const hit = mergeResults([], [product('A'), product('B')]).find(r => r.sku);
154
- assert.ok(Number.isFinite(hit.score), 'a products-only query must not leave products unscored');
155
- });
156
-
157
- test('interior scores are never modified', () => {
158
- const ranked = interiors(10).map((r, i) => ({ ...r, score: 0.9 - i * 0.01 }));
159
- const before = ranked.map(r => r.score);
160
- mergeResults(ranked, [product('A'), product('B')]);
161
- assert.deepEqual(ranked.map(r => r.score), before);
162
- });
163
-
164
- test('a product query crowds interiors out of the field', () => {
165
- // "kệ xoong" is a product ask, so once a product clears the keyword floor the
166
- // CLIP interiors must not be sprinkled through the results -- only the racks.
167
- // The product's name must actually carry the query's words (or their rack
168
- // synonyms), which is what separates a product answer from a substring hit.
169
- const ranked = interiors(30).map((r, i) => ({ ...r, score: 0.5 - i * 0.001 }));
170
- const hits = [
171
- { sku: 'KV1', name: 'Kệ xoong nồi inox', ar: 1, images: ['https://cdn.example/kv1.jpg'], score: 367 },
172
- { sku: 'KV2', name: 'Giá xoong nồi inox', ar: 1, images: ['https://cdn.example/kv2.jpg'], score: 367 },
173
- ];
174
- const out = mergeResults(ranked, hits, { query: 'kệ xoong' });
175
- assert.equal(out.filter(r => r.sku).length, 2);
176
- assert.equal(out.filter(r => !r.sku).length, 0, 'interiors must be dropped for a product query');
177
- });
178
-
179
- test('browse mode keeps interiors even when products arrive', () => {
180
- // No query means browse: the newest interiors front row must stay; the
181
- // index-time products are decoration, not the answer.
182
- const ranked = interiors(6).map((r, i) => ({ ...r, score: 0.5 - i * 0.001 }));
183
- const out = mergeResults(ranked, [product('A', { score: 367 })]);
184
- assert.equal(out.filter(r => !r.sku).length, 6, 'browse keeps all interiors');
185
- });
186
-
187
- test('every product hit above the keyword floor is featured (neon ring + scale)', () => {
188
- // The brief wants "toàn bộ ảnh là kệ xoong nội... có viền neon và kích thước
189
- // thư giãn hơn": all relevant racks, not just the top 3, get the ring+scale.
190
- const hits = [
191
- product('A', { score: 367 }), product('B', { score: 367 }),
192
- product('C', { score: 367 }), product('D', { score: 367 }),
193
- ];
194
- const out = mergeResults(interiors(2), hits, { query: 'kệ xoong' });
195
- const featured = out.filter(r => r.sku && r.featured);
196
- assert.equal(featured.length, 4, 'all keyword hits above the floor are featured');
197
- });
198
-
199
- test('a product whose only lead points into a dead V.AISTUDIO dir is skipped', () => {
200
- const dead = {
201
- sku: 'EPD4635VX', name: 'Giá', ar: 1,
202
- images: ['https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/eurogold_img/EPD4635VX.jpg'],
203
- };
204
- const live = product('EPO8360VX');
205
- const out = mergeResults(interiors(3), [dead, live]);
206
- const skus = out.filter(r => r.sku).map(r => r.sku);
207
- assert.deepEqual(skus, ['EPO8360VX']);
208
- });
209
-
210
- console.log(`\n${passed} passed`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-misbrand.mjs DELETED
@@ -1,109 +0,0 @@
1
- /**
2
- * The Malloca/Hafele misbranding repair.
3
- *
4
- * These cover the two ways it can go wrong, both of which happened while this
5
- * was being built:
6
- *
7
- * 1. Matching on loose tokens attaches the WRONG photograph. T90CM is a size
8
- * shared by 40 hoods; swapping on it puts one kitchen's image on another's
9
- * appliance. The uniqueness rule has to reject it.
10
- * 2. Being too strict loses every real match. A base and a `T` variant are one
11
- * product photographed twice, and they must still resolve.
12
- */
13
- import assert from 'node:assert/strict';
14
- import { buildBaseCatalogue, isMallocaMisbrand, repairMallocaMisbrands } from '../src/catalogue.js';
15
-
16
- let passed = 0;
17
- function test(name, fn) {
18
- try { fn(); console.log(' ok ' + name); passed++; }
19
- catch (e) { console.error(' FAIL ' + name + '\n ' + e.message); process.exitCode = 1; }
20
- }
21
-
22
- const MALLOCA_IMG = 'https://bizweb.dktcdn.net/100/567/847/products/mir593.png';
23
- const OTHER_HOOD = 'https://bizweb.dktcdn.net/100/567/847/products/eclipse-k600.png';
24
- const HAFELE_IMG = 'https://cdn-img-v2.mybota.vn/uploadv2/web/x/mir593.jpg';
25
-
26
- /** A row as the scraper emits it. */
27
- const row = o => ({ n: '', brand: 'Malloca', sku: '', mod: '', imgs: [], ...o });
28
-
29
- test('detects a row whose name says Malloca and brand does not', () => {
30
- assert.equal(isMallocaMisbrand(row({ n: 'Malloca | Bếp Điện Từ MIR 593', brand: 'Hafele' })), true);
31
- });
32
-
33
- test('leaves a correctly branded Malloca row alone', () => {
34
- assert.equal(isMallocaMisbrand(row({ n: 'Malloca | Bếp Điện Từ MIR 593', brand: 'Malloca' })), false);
35
- });
36
-
37
- test('is not fooled by Array#filter passing the index', () => {
38
- assert.equal(isMallocaMisbrand(0), false);
39
- assert.equal(isMallocaMisbrand(null), false);
40
- });
41
-
42
- test('a model code spelled with a space matches the one spelled together', () => {
43
- const out = repairMallocaMisbrands([
44
- row({ n: 'MIR593 | Malloca | Bếp từ kết hợp', sku: '1', imgs: [MALLOCA_IMG] }),
45
- row({ n: 'Malloca | Bếp Điện Từ Malloca MIR 593', brand: 'Hafele', sku: '2', imgs: [HAFELE_IMG] }),
46
- ]);
47
- assert.equal(out.length, 2);
48
- assert.deepEqual(out[1].imgs, [MALLOCA_IMG], 'should take the real Malloca photo');
49
- assert.equal(out[1].brand, 'Malloca');
50
- });
51
-
52
- test('hyphenated model codes match their unhyphenated twin', () => {
53
- const out = repairMallocaMisbrands([
54
- row({ n: 'MWC46BG | Malloca | Tủ rượu', sku: '1', imgs: [MALLOCA_IMG] }),
55
- row({ n: 'Malloca | Tủ Bảo Quản Rượu Vang Malloca MWC-46BG', brand: 'Hafele', sku: '2', imgs: [HAFELE_IMG] }),
56
- ]);
57
- assert.deepEqual(out[1].imgs, [MALLOCA_IMG]);
58
- });
59
-
60
- test('a size descriptor shared by many products does NOT trigger a swap', () => {
61
- // T90CM appears in three genuinely different hoods. A misbranded row whose
62
- // only candidate is that token must keep its own image.
63
- const many = ['A', 'B', 'C'].map((id, i) =>
64
- row({ n: `Vertical MH90${i} | Malloca | Máy hút mùi áp tường chữ t 90cm`, sku: id, imgs: [`https://x/${id}.png`] }));
65
- const out = repairMallocaMisbrands([
66
- ...many,
67
- row({ n: 'Malloca | Máy hút mùi Malloca MH9000 90cm', brand: 'Hafele', sku: 'bad', imgs: [HAFELE_IMG] }),
68
- ]);
69
- assert.deepEqual(out[out.length - 1].imgs, [HAFELE_IMG], 'ambiguous token must not swap');
70
- assert.equal(out[out.length - 1].brand, 'Malloca', 'but the brand is still corrected');
71
- });
72
-
73
- test('a base and a T variant of one model still resolve to one product', () => {
74
- const out = repairMallocaMisbrands([
75
- row({ n: 'K279N | Malloca | Vòi rửa chén nóng lạnh', sku: 'a', imgs: [MALLOCA_IMG] }),
76
- row({ n: 'K279N | Malloca | Vòi rửa chén nóng lạnh', sku: 'b', imgs: [MALLOCA_IMG] }),
77
- row({ n: 'Malloca | Vòi Rửa Chén Malloca K279N', brand: 'Hafele', sku: 'c', imgs: [HAFELE_IMG] }),
78
- ]);
79
- assert.deepEqual(out[2].imgs, [MALLOCA_IMG], 'two rows of one product are not a conflict');
80
- });
81
-
82
- test('a product with no Malloca twin keeps its image and gains the right brand', () => {
83
- const out = repairMallocaMisbrands([
84
- row({ n: 'Malloca | Vòi Rửa Chén Malloca ZZZZ99', brand: 'Hafele', sku: 'x', imgs: [HAFELE_IMG] }),
85
- ]);
86
- assert.deepEqual(out[0].imgs, [HAFELE_IMG]);
87
- assert.equal(out[0].brand, 'Malloca');
88
- assert.equal(out[0].sku, 'x', 'identity is untouched, so vai-avatar edits still apply');
89
- });
90
-
91
- test('a non-Malloca product is never touched', () => {
92
- const eurogold = row({ n: 'EWG101 | Eurogold | Bản lề', brand: 'Eurogold', sku: 'e', imgs: [OTHER_HOOD] });
93
- const out = repairMallocaMisbrands([eurogold]);
94
- assert.equal(out[0].brand, 'Eurogold');
95
- assert.deepEqual(out[0].imgs, [OTHER_HOOD]);
96
- });
97
-
98
- test('the repair runs inside buildBaseCatalogue', () => {
99
- const kept = buildBaseCatalogue([
100
- row({ n: 'MIR593 | Malloca | Bếp từ kết hợp', sku: '1', slug: 'mir593-malloca', imgs: [MALLOCA_IMG] }),
101
- row({ n: 'Malloca | Bếp Điện Từ Malloca MIR 593', brand: 'Hafele', sku: '2', slug: 'bep-mir593', imgs: [HAFELE_IMG] }),
102
- ]);
103
- assert.equal(kept.length, 2);
104
- const repaired = kept.find(r => r.sku === '2');
105
- assert.deepEqual(repaired.imgs, [MALLOCA_IMG]);
106
- assert.equal(repaired.brand, 'Malloca');
107
- });
108
-
109
- console.log(`\n${passed} passed`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-neon-ui.mjs DELETED
@@ -1,256 +0,0 @@
1
- /**
2
- * Browser check for the featured + running-neon emphasis.
3
- *
4
- * Complements test-avatar-interiors.mjs: that file proves the data is correct,
5
- * this one proves the browser actually PAINTS it. The failure mode that matters
6
- * most here is silent -- a mask-composite the browser ignores renders an opaque
7
- * tile instead of a ring, which no assertion on the data would ever catch.
8
- *
9
- * node scripts/test-neon-ui.mjs # against a local preview
10
- * BASE_URL=... node scripts/test-neon-ui.mjs
11
- */
12
- import { chromium } from 'playwright';
13
-
14
- const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173/';
15
- let passed = 0, failed = 0;
16
- const test = async (name, fn) => {
17
- try { await fn(); console.log(' ok ' + name); passed++; }
18
- catch (error) { console.log(' FAIL ' + name + '\n ' + error.message.split('\n')[0]); failed++; }
19
- };
20
- const assert = (cond, message) => { if (!cond) throw new Error(message); };
21
-
22
- const browser = await chromium.launch();
23
- const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
24
- const errors = [];
25
- page.on('pageerror', error => errors.push(error.message));
26
-
27
- await page.goto(BASE, { waitUntil: 'domcontentloaded' });
28
- // The worker downloads the CLIP text tower before it can answer; give it room.
29
- await page.waitForSelector('.image-tile', { timeout: 120000 });
30
-
31
- console.log('\nfield + controls');
32
- // ──────────────────────────────────────────────────────────────────────────
33
-
34
- await test('the field renders tiles', async () => {
35
- const n = await page.locator('.image-tile').count();
36
- assert(n > 10, 'expected a populated field, got ' + n);
37
- });
38
-
39
- await test('the size and position controls are gone', async () => {
40
- // Explicitly asserted rather than assumed: the brief asked for them removed,
41
- // and a leftover panel under the pill would be a visible regression.
42
- assert(await page.locator('.field-controls').count() === 0, '.field-controls still present');
43
- assert(await page.getByRole('button', { name: /kích thước|vị trí/i }).count() === 0, 'a control button is still rendered');
44
- });
45
-
46
- await test('the search pill is unobstructed', async () => {
47
- const box = await page.locator('.search-pill').boundingBox();
48
- assert(box && box.height > 20, 'the search pill has no box');
49
- });
50
-
51
- console.log('\nfeatured + neon');
52
- // ──────────────────────────────────────────────────────────────────────────
53
-
54
- await test('a fresh vai-avatar photograph appears as a featured tile', async () => {
55
- // Waits for the live index rather than assuming the network was fast.
56
- await page.waitForSelector('.image-tile.featured', { timeout: 60000 });
57
- const n = await page.locator('.image-tile.featured').count();
58
- assert(n > 0, 'no featured tile rendered');
59
- });
60
-
61
- await test('the featured tile is larger than an ordinary tile', async () => {
62
- const featured = await page.locator('.image-tile.featured').first().boundingBox();
63
- const ordinary = await page.locator('.image-tile:not(.featured)').first().boundingBox();
64
- const area = b => b.width * b.height;
65
- assert(area(featured) > area(ordinary) * 1.15,
66
- `featured ${Math.round(area(featured))}px² vs ordinary ${Math.round(area(ordinary))}px²`);
67
- });
68
-
69
- await test('a fresh photograph loads from vai-avatar, CORS included', async () => {
70
- // The tiles fall back to BlurHash silently on a CORS failure, so this is the
71
- // only place that failure would show up at all.
72
- await page.waitForFunction(() => {
73
- const tile = document.querySelector('.image-tile.featured');
74
- return tile?.querySelector('img.loaded');
75
- }, { timeout: 60000 });
76
- });
77
-
78
- await test('a fresh photograph wears the running neon with no query typed', async () => {
79
- // Asserted separately from the exact-match case because the two are different
80
- // CSS selectors: a newest photograph rings before anything has been searched,
81
- // and dropping that would make the feature invisible on page load.
82
- const ring = await page.evaluate(() => {
83
- const tile = document.querySelector('.image-tile.featured');
84
- const style = getComputedStyle(tile, '::before');
85
- return { animation: style.animationName, mask: style.maskImage || style.webkitMaskImage };
86
- });
87
- assert(ring.animation === 'neon-run', 'a fresh photograph has no running neon: ' + ring.animation);
88
- assert(ring.mask && ring.mask !== 'none', 'the ring has no mask, so it would fill the tile');
89
- });
90
-
91
- await test('the ring layer itself never moves', async () => {
92
- // The regression this whole file is about. ::before carries the mask as well
93
- // as the gradient, so rotating the layer rotates the mask too and the painted
94
- // border slides off the tile's edges -- which on a 16:9 photograph reads as a
95
- // skewed smear, not a light going round the frame.
96
- //
97
- // Asserted on a REAL tile, and cross-checked for shape: a square tile would
98
- // hide a rotated mask just as well as it hid the original bug, so the test
99
- // fails if it cannot find a tile that is demonstrably not square.
100
- const ring = await page.evaluate(() => {
101
- const nonSquare = [...document.querySelectorAll('.image-tile.exact, .image-tile.featured')]
102
- .map(tile => ({ tile, box: tile.getBoundingClientRect() }))
103
- .filter(({ box }) => box.width > 40 && box.height > 40
104
- && Math.abs(box.width / box.height - 1) > 0.15);
105
- if (!nonSquare.length) return { checked: false };
106
- const { tile, box } = nonSquare[0];
107
- const style = getComputedStyle(tile, '::before');
108
- return {
109
- checked: true,
110
- transform: style.transform,
111
- angle: style.getPropertyValue('--neon-angle').trim(),
112
- ratio: box.width / box.height,
113
- };
114
- });
115
- assert(ring.checked, 'no non-square ringed tile on screen to test against');
116
- assert(/^(none|matrix\(1, 0, 0, 1, 0, 0\)|matrix\(1, 0, 0, 1, 0px, 0px\))$/.test(ring.transform),
117
- `the ring layer is transformed (${ring.transform}) on a ${ring.ratio.toFixed(2)}:1 tile, so its mask has rotated off the frame`);
118
- assert(ring.angle && /deg|rad|grad|turn/.test(ring.angle),
119
- 'the sweep angle is not resolving to an angle: "' + ring.angle + '"');
120
- });
121
-
122
- await test('a fresh photograph is fully lit, not dimmed as unranked', async () => {
123
- const opacity = await page.evaluate(() => {
124
- const tile = document.querySelector('.image-tile.featured');
125
- return parseFloat(getComputedStyle(tile).opacity);
126
- });
127
- assert(opacity > 0.9, 'a fresh photograph is faded to ' + opacity);
128
- });
129
-
130
- console.log('\nquery emphasis');
131
- // ──────────────────────────────────────────────────────────────────────────
132
-
133
- await test('an exact query rings a tile with the running neon', async () => {
134
- await page.fill('input[type=text]', 'phòng khách');
135
- await page.waitForSelector('.image-tile.exact', { timeout: 60000 });
136
-
137
- const ring = await page.evaluate(() => {
138
- const tile = document.querySelector('.image-tile.exact');
139
- const style = getComputedStyle(tile, '::before');
140
- return {
141
- animation: style.animationName,
142
- background: style.backgroundImage,
143
- mask: style.maskImage || style.webkitMaskImage,
144
- };
145
- });
146
- assert(ring.animation === 'neon-run', 'the ring is not animating: ' + ring.animation);
147
- assert(/conic-gradient/.test(ring.background), 'the ring is not a conic gradient');
148
- assert(ring.mask && ring.mask !== 'none', 'the ring has no mask, so it would fill the tile');
149
- });
150
-
151
- await test('the neon sweep actually advances between frames', async () => {
152
- // A gradient that is present but not advancing is the exact failure the brief
153
- // asked to avoid, and it looks correct in a single screenshot.
154
- //
155
- // Read the sweep angle, NOT `transform`. The transform is now pinned to none
156
- // on purpose -- it is the thing that used to drag the mask off the frame -- so
157
- // asserting on it would be asserting the old bug is still there.
158
- const read = () => page.evaluate(() => {
159
- const tile = document.querySelector('.image-tile.exact');
160
- if (!tile) return null;
161
- const raw = getComputedStyle(tile, '::before').getPropertyValue('--neon-angle').trim();
162
- const value = Number.parseFloat(raw);
163
- return Number.isFinite(value) ? value : raw;
164
- });
165
- const a = await read();
166
- assert(a !== null, 'no exact tile to read');
167
- await page.waitForTimeout(700);
168
- const b = await read();
169
- assert(a !== b, `the sweep angle did not change (${a} -> ${b})`);
170
- });
171
-
172
- await test('the head stays inside the frame on a non-square tile', async () => {
173
- // The mask is the tile's own box, so the bright head has to sit on the edge
174
- // for every aspect ratio. Verified by reading the gradient's own angle out of
175
- // the computed style: it must be a resolved angle on every ringed tile, not
176
- // just on the ones that happen to be square.
177
- const angles = await page.evaluate(() => {
178
- return [...document.querySelectorAll('.image-tile.exact, .image-tile.featured')].map(tile => {
179
- const box = tile.getBoundingClientRect();
180
- const raw = getComputedStyle(tile, '::before').getPropertyValue('--neon-angle').trim();
181
- return { ratio: box.width / box.height, raw };
182
- });
183
- });
184
- for (const { ratio, raw } of angles) {
185
- assert(raw && /deg$/.test(raw),
186
- `a ${ratio.toFixed(2)}:1 tile has sweep angle "${raw}", so the head is not on the frame`);
187
- }
188
- });
189
-
190
- await test('an exact tile is never dimmed', async () => {
191
- const opacity = await page.evaluate(() => {
192
- const tile = document.querySelector('.image-tile.exact');
193
- return parseFloat(getComputedStyle(tile).opacity);
194
- });
195
- assert(opacity > 0.9, 'an exact match is faded to ' + opacity);
196
- });
197
-
198
- await test('a query with no exact answer rings nothing', async () => {
199
- await page.fill('input[type=text]', 'zzzqqqxyz không tồn tại');
200
- await page.waitForTimeout(6000);
201
- assert(await page.locator('.image-tile.exact').count() === 0,
202
- 'an unresolvable query still produced an exact match');
203
- });
204
-
205
- console.log('\nshare links');
206
- // ──────────────────────────────────────────────────────────────────────────
207
-
208
- await test('an interior photograph never points /share/p/ at an avatarimg sku', async () => {
209
- // The regression: `avatarimg:` is a namespace for de-duplication, not a
210
- // catalogue identity, so /share/p/ resolves to nothing and vai-avatar answers
211
- // "Not Found". Every interior photograph was affected.
212
- //
213
- // Driven off a hand-built anchor rather than the live field: the field's own
214
- // hrefs depend on a network read of the vai-avatar interior index, which may
215
- // not have landed when this runs. This asserts the routing rule itself.
216
- const href = await page.evaluate(() => {
217
- const anchor = document.createElement('a');
218
- anchor.href = 'https://bep40-vai-avatar.hf.space/share/p/avatarimg%3Ab68dc779370dc70e';
219
- document.body.append(anchor);
220
- globalThis.__repairInteriorShareLinks?.();
221
- const resolved = anchor.href;
222
- anchor.remove();
223
- return resolved;
224
- });
225
- assert(href === 'https://bep40-vai-avatar.hf.space/share/i/b68dc779370dc70e',
226
- 'the mis-routed link was not corrected, it resolved to ' + href);
227
- });
228
-
229
- await test('a real product link is left alone', async () => {
230
- // The shim must not touch a link that resolves. Rewriting this would send a
231
- // shopper to a 404 dressed up as a fix.
232
- const href = await page.evaluate(() => {
233
- const anchor = document.createElement('a');
234
- anchor.href = 'https://bep40-vai-avatar.hf.space/share/p/MH800TC';
235
- document.body.append(anchor);
236
- globalThis.__repairInteriorShareLinks?.();
237
- const resolved = anchor.href;
238
- anchor.remove();
239
- return resolved;
240
- });
241
- assert(href === 'https://bep40-vai-avatar.hf.space/share/p/MH800TC',
242
- 'a valid product link was rewritten to ' + href);
243
- });
244
-
245
- console.log('\nno regressions');
246
- // ──────────────────────────────────────────────────────────────────────────
247
-
248
- await test('no uncaught page errors', async () => {
249
- assert(errors.length === 0, errors.slice(0, 3).join(' | '));
250
- });
251
-
252
- await page.screenshot({ path: '/tmp/neon-field.png', fullPage: false });
253
- await browser.close();
254
- console.log(`\n${passed} passed, ${failed} failed`);
255
- console.log('screenshot: /tmp/neon-field.png');
256
- process.exit(failed ? 1 : 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-product-search.mjs DELETED
@@ -1,173 +0,0 @@
1
- /**
2
- * Verify the vai-avatar scorer ported into product-search.js.
3
- *
4
- * The point of these tests is parity with vai-avatar, not a notion of "good
5
- * ranking". Each case is a query vai-avatar is known to handle a specific way,
6
- * and the expectation is written down so a change in behaviour here is a
7
- * visible diff rather than a silent reordering nobody notices until a customer
8
- * says the wrong product came up.
9
- *
10
- * Run: node scripts/test-product-search.mjs
11
- */
12
-
13
- import { scoreProduct, searchLikeAvatar, extractTerms } from '../src/product-search.js';
14
- import { hasDecisiveWinner, exactMatches } from '../src/relevance.js';
15
- import { normalizeText } from '../src/catalogue.js';
16
-
17
- /* ── catalogue.js's normaliseProduct() output shape ── */
18
- function product({ name = '', model = '', brand = '', category = '', summary = '', feats = [], specs = [], sku = '', ...rest }) {
19
- return { sku: sku || model || name, name, model, brand, category, summary, feats, specs, ...rest };
20
- }
21
-
22
- let passed = 0;
23
- let failed = 0;
24
- function check(label, actual, expected) {
25
- const ok = JSON.stringify(actual) === JSON.stringify(expected);
26
- if (ok) { passed++; console.log(` ok ${label}`); }
27
- else { failed++; console.log(` FAIL ${label}\n expected ${JSON.stringify(expected)}\n actual ${JSON.stringify(actual)}`); }
28
- }
29
- function ok(label, condition, detail = '') {
30
- if (condition) { passed++; console.log(` ok ${label}`); }
31
- else { failed++; console.log(` FAIL ${label} ${detail}`); }
32
- }
33
-
34
- console.log('\nextractTerms');
35
- check('folds diacritics and drops 1-char noise',
36
- extractTerms('Bếp từ Mới'), ['bep', 'tu', 'moi']);
37
- check('keeps domain words a stopword list would drop',
38
- extractTerms('tủ lạnh'), ['tu', 'lanh'],
39
- );
40
- ok('"tu" survives: it is both "từ" and "tủ"', extractTerms('tu lanh').includes('tu'));
41
- check('"đ" folds to d', extractTerms('đá Granite'), ['da', 'granite']);
42
-
43
- console.log('\nexact model / sku beats everything');
44
- const gp170 = product({ name: 'Bếp từ MALLOCA GP170BD 5 vùng', model: 'GP170BD', brand: 'Malloca', category: 'Bếp từ' });
45
- const other = product({ name: 'Bếp từ MALLOCA GP900 3 vùng', model: 'GP900', brand: 'Malloca', category: 'Bếp từ' });
46
- {
47
- const terms = extractTerms('GP170BD');
48
- const exact = scoreProduct(gp170, 'GP170BD', terms);
49
- const near = scoreProduct(other, 'GP170BD', terms);
50
- ok(`exact SKU (${exact}) outranks a near model (${near})`, exact > near);
51
- ok('exact SKU crosses the neon threshold (>=700)', exact >= 700, `got ${exact}`);
52
- ok('the near model does NOT', near < 700, `got ${near}`);
53
- }
54
-
55
- console.log('\ntitle containment');
56
- {
57
- const terms = extractTerms('khoá điện tử');
58
- const smart = product({ name: 'Khóa cửa điện tử thông minh VAI', category: 'Khóa cửa thông minh', feats: ['Vân tay', 'Mã số'] });
59
- const plainLock = product({ name: 'Khóa cửa thường cánh thông', category: 'Khóa cửa' });
60
- ok(`smart lock (${scoreProduct(smart, 'khoá điện tử', terms)}) outranks plain (${scoreProduct(plainLock, 'khoá điện tử', terms)})`,
61
- scoreProduct(smart, 'khoá điện tử', terms) > scoreProduct(plainLock, 'khoá điện tử', terms));
62
- }
63
-
64
- console.log('\nsize (mm) matching is numeric, not textual');
65
- {
66
- // The catalogue writes sizes inconsistently; "700" must find the 700mm rack
67
- // and not be fooled by a 70cm or a price containing 700.
68
- const rack700 = product({ name: 'Kệ xoong nồi inox 700mm 2 tầng', category: 'Kệ xoong nồi', specs: [['Chiều rộng', '700 mm']] });
69
- const rack900 = product({ name: 'Kệ xoong nồi inox 900mm 3 tầng', category: 'Kệ xoong nồi', specs: [['Chiều rộng', '900 mm']] });
70
- const terms = extractTerms('kệ xoong nồi 700mm');
71
- const hit = scoreProduct(rack700, 'kệ xoong nồi 700mm', terms);
72
- const miss = scoreProduct(rack900, 'kệ xoong nồi 700mm', terms);
73
- ok(`700mm rack (${hit}) outranks 900mm rack (${miss})`, hit > miss);
74
- const t2 = extractTerms('kệ 700');
75
- ok('a bare "700" also finds the 700mm rack',
76
- scoreProduct(rack700, 'kệ 700', t2) > scoreProduct(rack900, 'kệ 700', t2));
77
- ok('and the 900 rack is actually penalised for not matching', miss < hit);
78
- }
79
-
80
- console.log('\nmaterial matching reads specs, not just the title');
81
- {
82
- const title = product({ name: 'Chậu rửa inox MALLOCA', specs: [['Chất liệu', 'Inox 304']] });
83
- const specsOnly = product({ name: 'Chậu rửa bàn MALLOCA', specs: [['Chất liệu', 'Inox 304']] });
84
- const terms = extractTerms('chậu rửa inox 304');
85
- ok('a title mention scores', scoreProduct(title, 'chậu rửa inox 304', terms) > 0);
86
- ok('a specs-only mention also scores', scoreProduct(specsOnly, 'chậu rửa inox 304', terms) > 0);
87
- }
88
-
89
- console.log('\nbrand bonus');
90
- {
91
- const malloca = product({ name: 'Bàn ăn gỗ Malloca', brand: 'Malloca', category: 'Bàn ăn' });
92
- const otherBrand = product({ name: 'Bàn ăn gỗ Eurogold', brand: 'Eurogold', category: 'Bàn ăn' });
93
- const terms = extractTerms('bàn ăn malloca');
94
- ok(`query naming the brand wins it (${scoreProduct(malloca, 'bàn ăn malloca', terms)} > ${scoreProduct(otherBrand, 'bàn ăn malloca', terms)})`,
95
- scoreProduct(malloca, 'bàn ăn malloca', terms) > scoreProduct(otherBrand, 'bàn ăn malloca', terms));
96
- }
97
-
98
- console.log('\ncovering the query beats one rare word');
99
- {
100
- const both = product({ name: 'Bếp từ MALLOCA 5 vùng', category: 'Bếp từ' });
101
- const partial = product({ name: 'Bàn ghế gỗ sồi', category: 'Bàn ghế' });
102
- const terms = extractTerms('bếp từ');
103
- ok(`both terms in title (${scoreProduct(both, 'bếp từ', terms)}) beats one (${scoreProduct(partial, 'bếp từ', terms)})`,
104
- scoreProduct(both, 'bếp từ', terms) > scoreProduct(partial, 'bếp từ', terms));
105
- }
106
-
107
- console.log('\nunrelated product scores zero');
108
- {
109
- const terms = extractTerms('bếp từ');
110
- ok('a bicycle is not a kitchen product',
111
- scoreProduct(product({ name: 'Xe đạp thể thao Giant', category: 'Xe đạp' }), 'bếp từ', terms) === 0);
112
- }
113
-
114
- console.log('\nsearchLikeAvatar ranks and limits');
115
- {
116
- const items = new Map([
117
- ['GP170BD', gp170],
118
- ['GP900', other],
119
- ['BD1054', product({ name: 'Máy giặt Hitachi Inverter 10.5kg BD-1054HVOW', brand: 'Hitachi', category: 'Máy giặt' })],
120
- ]);
121
- const top = searchLikeAvatar('GP170BD', items, 5);
122
- ok('the exact model is first', top[0]?.sku === 'GP170BD', `got ${top[0]?.sku}`);
123
- check('respects the limit', searchLikeAvatar('bếp', items, 2).length <= 2, true);
124
- check('an empty query returns nothing', searchLikeAvatar('', items, 5).length, 0);
125
- check('a query matching nothing returns nothing', searchLikeAvatar('xe đạp', items, 5).length, 0);
126
- check('a missing catalogue returns nothing', searchLikeAvatar('bếp', null, 5).length, 0);
127
- }
128
-
129
- console.log('\nhasDecisiveWinner (the neon gate for products)');
130
- ok('a 1000-point SKU match with a weak runner-up qualifies',
131
- hasDecisiveWinner([1000, 20]));
132
- ok('a lone 1000 with nothing else qualifies',
133
- hasDecisiveWinner([1000]));
134
- ok('a mere 550 (the "contains" tier) does not',
135
- !hasDecisiveWinner([550, 10]));
136
- ok('a near-tie does not, so a model family does not all light up',
137
- !hasDecisiveWinner([1000, 900]));
138
- ok('an empty list does not',
139
- !hasDecisiveWinner([]));
140
- ok('a missing list does not',
141
- !hasDecisiveWinner(undefined));
142
-
143
- console.log('\nexactMatches — which tiles get the outline');
144
- {
145
- // A decisive product winner, keyed the way merge.js keys product tiles.
146
- const ids = exactMatches([], 'GP170BD', [{ sku: 'GP170BD', score: 1316 }, { sku: 'GP900', score: 10 }]);
147
- ok('the decisive product is outlined', ids.has('malloca:GP170BD'));
148
- // A relevance-only winner must not light up.
149
- const weak = exactMatches([], 'bếp inox', [{ sku: 'A', score: 60 }, { sku: 'B', score: 10 }]);
150
- ok('a mere relevance match is not outlined', weak.size === 0);
151
- // Interior: the query appears verbatim in the record's own title.
152
- const interiors = exactMatches(
153
- [{ id: 'abc123', score: 0.31, title: 'Bếp đá marble cao cấp', categories: ['Nội thất bếp'] }, { id: 'def456', score: 0.29, title: 'Phòng khách hiện đại' }],
154
- 'bếp đá',
155
- );
156
- ok('an interior whose title contains the query is outlined', interiors.has('abc123'));
157
- ok('the merely-similar interior is not', !interiors.has('def456'));
158
- // Accents must fold on both sides.
159
- const folded = exactMatches([{ id: 'x', score: 0.3, title: 'Bep Da Marble' }], 'bếp đá');
160
- ok('accent folding works both ways', folded.has('x'));
161
- // A short query must not match everything by accident.
162
- ok('a 1-2 character query outlines nothing', exactMatches([{ id: 'x', score: 0.3, title: 'a b' }], 'a').size === 0);
163
- ok('an empty query outlines nothing', exactMatches([{ id: 'x', score: 0.9, title: 'Bếp đá' }], ' ').size === 0);
164
- }
165
-
166
- console.log('\nnormalisation parity with catalogue.js');
167
- {
168
- ok('normalizeText folds the same way in both modules',
169
- normalizeText('Bếp Từ Mây') === 'bep tu may');
170
- }
171
-
172
- console.log(`\n${passed} passed, ${failed} failed`);
173
- if (failed) process.exit(1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-semantic-search.mjs DELETED
@@ -1,123 +0,0 @@
1
- /**
2
- * Nghiệm thu hành vi tìm kiếm: ngữ nghĩa (ảnh interiors) vs lookup sản phẩm.
3
- *
4
- * Chạy: node scripts/test-semantic-search.mjs
5
- *
6
- * Bốn nhóm kiểm tra, đúng bốn cam kết của Space:
7
- *
8
- * 1. Phân loại từ khóa -- "bàn"/"ghế"/"nội thất"/"nhà phố" là từ ngữ
9
- * nghĩa; "kệ"/"xoong"/"bếp"/"tủ"/"máy" vẫn là từ sản phẩm.
10
- * 2. Đồng nghĩa -- "nhà phố" mở rộng ra "nhà ống"; "biệt thự" ra "villa".
11
- * 3. Ảnh interiors -- tìm "nhà phố" thấy caption "Nhà ống", KHÔNG thấy
12
- * "Đèn ống" (false positive đã được đo và loại).
13
- * 4. Crowd-out -- "bàn ghế"/"nội thất" GIỮ ảnh interiors; "kệ xoong"
14
- * VẪN gỡ interiors (chỉ còn sản phẩm); lookup SKU chính xác vẫn
15
- * crowd-out và trả đúng 1 sản phẩm đầu tiên.
16
- */
17
- import { productLikeTerms, isSemanticTerm, expandQueryTerms } from '../src/semantic-terms.js';
18
- import { mergeResults } from '../src/merge.js';
19
- import { searchLikeAvatar } from '../src/product-search.js';
20
- import { searchAvatarInteriors } from '../src/avatar-interiors.js';
21
-
22
- let failures = 0;
23
- function check(name, ok, detail = '') {
24
- console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`);
25
- if (!ok) failures++;
26
- }
27
-
28
- /** Ghi sản phẩm vào dạng Map mà catalogue.js/buildCatalogue() trả về. */
29
- function toItems(products) {
30
- const items = new Map();
31
- for (const p of products) items.set(p.sku, p);
32
- return items;
33
- }
34
-
35
- /* ─────────────────────── 1. Phân loại từ khóa ─────────────────────── */
36
-
37
- check('"bàn ghế" toàn từ ngữ nghĩa', productLikeTerms(['ban', 'ghe']).length === 0);
38
- check('"kệ xoong" toàn từ sản phẩm', JSON.stringify(productLikeTerms(['ke', 'xoong'])) === '["ke","xoong"]');
39
- check('"nội thất" toàn từ ngữ nghĩa', productLikeTerms(['noi', 'that']).length === 0);
40
- check('"nhà phố" toàn từ ngữ nghĩa', productLikeTerms(['nha', 'pho']).length === 0);
41
- check('"biệt thự" toàn từ ngữ nghĩa', productLikeTerms(['biet', 'thu']).length === 0);
42
- check('"bếp từ" vẫn là từ sản phẩm', productLikeTerms(['bep', 'tu']).length === 2);
43
- check('"máy hút mùi" vẫn là từ sản phẩm', productLikeTerms(['may', 'hut', 'mui']).length === 3);
44
- check('isSemanticTerm(ban) đúng', isSemanticTerm('ban') === true);
45
- check('isSemanticTerm(ke) sai (kệ là dòng SP chủ lực)', isSemanticTerm('ke') === false);
46
-
47
- /* ─────────────────────── 2. Đồng nghĩa ─────────────────────── */
48
-
49
- check('mở rộng "nhà phố" có "ong" (nhà ống)', expandQueryTerms(['nha', 'pho']).includes('ong'));
50
- check('mở rộng "biệt thự" có "villa"', expandQueryTerms(['biet', 'thu']).includes('villa'));
51
- check('mở rộng "nội thất" có "that"', expandQueryTerms(['noi']).includes('that'));
52
- check('mở rộng "ghe" sinh "ban" (cặp bàn-ghế là đồng nghĩa thật)', JSON.stringify(expandQueryTerms(['ghe'])) === '["ghe","ban"]');
53
-
54
- /* ─────────────────────── 3. Ảnh interiors (caption lexical) ─────────────────────── */
55
-
56
- const avatarInteriors = [
57
- { sku: 'avatarimg:1', avatarImageId: '1', title: 'Nhà ống mặt tiền', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z', images: ['https://cdn.example/1.jpg'], src: 'https://cdn.example/1.jpg' },
58
- { sku: 'avatarimg:2', avatarImageId: '2', title: 'Biệt thự vườn xanh', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z', images: ['https://cdn.example/2.jpg'], src: 'https://cdn.example/2.jpg' },
59
- { sku: 'avatarimg:3', avatarImageId: '3', title: 'Đèn ống LED âm trần', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z', images: ['https://cdn.example/3.jpg'], src: 'https://cdn.example/3.jpg' },
60
- { sku: 'avatarimg:4', avatarImageId: '4', title: 'Nội thất phòng khách gỗ', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z', images: ['https://cdn.example/4.jpg'], src: 'https://cdn.example/4.jpg' },
61
- ];
62
-
63
- const nhaPhoHits = searchAvatarInteriors('nhà phố', avatarInteriors);
64
- check('tìm "nhà phố" thấy ảnh "Nhà ống"', nhaPhoHits.some(r => r.avatarImageId === '1'), nhaPhoHits.map(r => r.title).join(' | '));
65
- check('tìm "nhà phố" thấy ảnh "Biệt thự"', nhaPhoHits.some(r => r.avatarImageId === '2'));
66
- check('tìm "nhà phố" KHÔNG thấy "Đèn ống" (false positive)', !nhaPhoHits.some(r => r.avatarImageId === '3'), nhaPhoHits.map(r => r.title).join(' | '));
67
-
68
- const noiThatHits = searchAvatarInteriors('nội thất', avatarInteriors);
69
- check('tìm "nội thất" thấy ảnh "Nội thất phòng khách"', noiThatHits.some(r => r.avatarImageId === '4'), noiThatHits.map(r => r.title).join(' | '));
70
-
71
- /* ─────────────────────── 4. Crowd-out trong mergeResults ─────────────────────── */
72
-
73
- /** Ảnh interiors đã được CLIP xếp hạng (điểm cosine), dạng worker trả về. */
74
- const clipInteriors = [
75
- { id: 'a1b2c3d4e5', url: 'phong-khach-01.jpg', score: 0.91, title: 'Phòng khách gỗ', category: 'phong khach' },
76
- { id: 'f6e5d4c3b2', url: 'phong-khach-02.jpg', score: 0.87, title: 'Phòng khách sáng', category: 'phong khach' },
77
- ];
78
-
79
- const banGhe = toItems([
80
- { sku: 'BAN-OC-001', model: 'BAN-OC-001', name: 'Bàn ghế gỗ óc chó', brand: 'Malloca', category: 'ban-ghe', summary: 'Bàn ghế ăn gỗ óc chó nhập khẩu', specs: [], feats: [], images: ['https://cdn.example/ban.jpg'], price: '4.500.000₫', priceNum: 4500000 },
81
- ]);
82
- const banGheHits = searchLikeAvatar('bàn ghế', banGhe, 24);
83
- console.log(` [info] score "bàn ghế" = ${banGheHits.map(h => `${h.sku}:${h.score}`).join(', ')}`);
84
-
85
- const mergedBanGhe = mergeResults(clipInteriors, banGheHits, { query: 'bàn ghế', avatarInteriors: [], recentProducts: [] });
86
- // CLIP interior tiles giữ id GỐC (a1b2c3d4e5...), không có prefix `interior:` —
87
- // prefix chỉ là key de-dup nội bộ của mergeResults. Kiểm tra trên id gốc.
88
- check('tìm "bàn ghế" VẪN GIỮ ảnh interiors (semantic)', mergedBanGhe.some(t => ['a1b2c3d4e5', 'f6e5d4c3b2'].includes(String(t.id))), mergedBanGhe.map(t => t.id).join(', '));
89
- check('tìm "bàn ghế" vẫn có sản phẩm xen kẽ', mergedBanGhe.some(t => t.sku === 'BAN-OC-001'));
90
-
91
- const noiThat = toItems([
92
- { sku: 'NT-001', model: 'NT-001', name: 'Nội thất gỗ phòng khách', brand: 'Malloca', category: 'noi-that', summary: '', specs: [], feats: [], images: ['https://cdn.example/nt.jpg'], price: '7.000.000₫', priceNum: 7000000 },
93
- ]);
94
- const noiThatProductHits = searchLikeAvatar('nội thất', noiThat, 24);
95
- const mergedNoiThat = mergeResults(clipInteriors, noiThatProductHits, { query: 'nội thất', avatarInteriors: [], recentProducts: [] });
96
- check('tìm "nội thất" VẪN GIỮ ảnh interiors (semantic)', mergedNoiThat.some(t => ['a1b2c3d4e5', 'f6e5d4c3b2'].includes(String(t.id))), mergedNoiThat.map(t => t.id).join(', '));
97
-
98
- const keXoong = toItems([
99
- { sku: 'GX-170', model: 'GX-170', name: 'Giá xoong nồi inox Eurogold', brand: 'Eurogold', category: 'ke-xoong', summary: 'Kệ xoong treo tường inox 304', specs: [], feats: [], images: ['https://cdn.example/ke.jpg'], price: '590.000₫', priceNum: 590000 },
100
- ]);
101
- const keXoongHits = searchLikeAvatar('kệ xoong', keXoong, 24);
102
- console.log(` [info] score "kệ xoong" = ${keXoongHits.map(h => `${h.sku}:${h.score}`).join(', ')}`);
103
- const mergedKeXoong = mergeResults(clipInteriors, keXoongHits, { query: 'kệ xoong', avatarInteriors: [], recentProducts: [] });
104
- check('tìm "kệ xoong" VẪN gỡ interiors (product query)', !mergedKeXoong.some(t => String(t.id).startsWith('interior:')), mergedKeXoong.map(t => t.id).join(', '));
105
- check('tìm "kệ xoong" chỉ còn sản phẩm', mergedKeXoong.every(t => t.sku === 'GX-170'));
106
-
107
- /* ─────────────── 5. Lookup SKU: chính xác 100%, vẫn crowd-out ─────────────── */
108
-
109
- const skuMap = toItems([
110
- { sku: 'GP170BD', model: 'GP170BD', name: 'Giá xoong nồi Eurogold GP170BD', brand: 'Eurogold', category: 'ke-xoong', summary: '', specs: [], feats: [], images: ['https://cdn.example/gp.jpg'], price: '850.000₫', priceNum: 850000 },
111
- ]);
112
- const skuHits = searchLikeAvatar('GP170BD', skuMap, 24);
113
- console.log(` [info] score "GP170BD" = ${skuHits.map(h => `${h.sku}:${h.score}`).join(', ')}`);
114
- check('lookup SKU "GP170BD" trúng chính xác sản phẩm đó', skuHits[0]?.sku === 'GP170BD');
115
- check('lookup SKU đạt ngưỡng identity (≥700)', (skuHits[0]?.score ?? 0) >= 700, `score=${skuHits[0]?.score}`);
116
- const mergedSku = mergeResults(clipInteriors, skuHits, { query: 'GP170BD', avatarInteriors: [], recentProducts: [] });
117
- check('lookup SKU crowd-out interiors (đây là tra cứu, không phải duyệt ảnh)', !mergedSku.some(t => String(t.id).startsWith('interior:')), mergedSku.map(t => t.id).join(', '));
118
- check('lookup SKU trả đúng 1 sản phẩm, đầu danh sách', mergedSku[0]?.sku === 'GP170BD');
119
-
120
- /* ─────────────────────── tổng kết ─────────────────────── */
121
-
122
- console.log(failures ? `\n${failures} TEST FAIL` : '\nALL TESTS PASS');
123
- process.exit(failures ? 1 : 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-ui.mjs DELETED
@@ -1,201 +0,0 @@
1
- /**
2
- * Browser check for the three changes: the backdrop, the size/position
3
- * controls, and the neon outline.
4
- *
5
- * Run against a served build:
6
- * npx vite preview --port 4173 & node scripts/test-ui.mjs
7
- *
8
- * Every assertion is about something visible. The neon one in particular is
9
- * asserted against the RENDERED pixels and computed styles rather than against
10
- * a class name, because a class that is added but overridden by CSS would pass
11
- * a class-name check while showing the user nothing at all.
12
- */
13
-
14
- import { chromium } from 'playwright';
15
-
16
- const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173';
17
- let passed = 0, failed = 0;
18
- const ok = (label, condition, detail = '') => {
19
- if (condition) { passed++; console.log(` ok ${label}`); }
20
- else { failed++; console.log(` FAIL ${label} ${detail}`); }
21
- };
22
-
23
- const browser = await chromium.launch();
24
- const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
25
- const errors = [];
26
- page.on('pageerror', error => errors.push(error.message));
27
- page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
28
-
29
- await page.goto(BASE, { waitUntil: 'domcontentloaded' });
30
- // The 130 MB model load is not what is under test here; give React time to mount
31
- // and paint the field, then stop waiting on it.
32
- await page.waitForSelector('.image-tile', { timeout: 30000 });
33
- await page.waitForTimeout(2500);
34
-
35
- console.log('\n1. backdrop');
36
- {
37
- const info = await page.evaluate(() => {
38
- const el = document.querySelector('.brand-backdrop');
39
- if (!el) return null;
40
- const style = getComputedStyle(el);
41
- return { image: style.backgroundImage, opacity: style.opacity, position: style.position };
42
- });
43
- ok('the backdrop element exists', !!info);
44
- ok('it paints the vai-avatar artwork', !!info?.image?.includes('vaistudio_center.png'), info?.image);
45
- ok('opacity is 30%', Math.abs(Number(info?.opacity) - 0.3) < 0.001, `got ${info?.opacity}`);
46
- ok('it is fixed and out of the way', info?.position === 'fixed');
47
- // The image must actually load, or it is a 30% transparent rectangle.
48
- const loaded = await page.evaluate(() => new Promise(resolve => {
49
- const url = getComputedStyle(document.querySelector('.brand-backdrop')).backgroundImage.slice(5, -2);
50
- const probe = new Image();
51
- probe.onload = () => resolve(probe.naturalWidth > 0);
52
- probe.onerror = () => resolve(false);
53
- probe.src = url;
54
- setTimeout(() => resolve(false), 8000);
55
- }));
56
- ok('the artwork actually decodes (not a 404)', loaded);
57
- // It must not swallow clicks meant for a photograph.
58
- const pe = await page.evaluate(() => getComputedStyle(document.querySelector('.brand-backdrop')).pointerEvents);
59
- ok('it does not intercept pointer events', pe === 'none', `got ${pe}`);
60
- }
61
-
62
- console.log('\n2. size & position controls, docked to the search box');
63
- {
64
- const toggle = await page.$('.field-controls-toggle');
65
- ok('the toggle exists', !!toggle);
66
- if (toggle) {
67
- // "Near the search box" is a real requirement, so measure it.
68
- const geo = await page.evaluate(() => {
69
- const box = document.querySelector('.search-pill')?.getBoundingClientRect();
70
- const knob = document.querySelector('.field-controls-toggle')?.getBoundingClientRect();
71
- if (!box || !knob) return null;
72
- return { gap: knob.top - box.bottom, centres: Math.abs((box.left + box.right) / 2 - (knob.left + knob.right) / 2) };
73
- });
74
- ok(`it sits just below the search box (${Math.round(geo?.gap ?? -1)}px gap)`, (geo?.gap ?? -1) >= 0 && (geo?.gap ?? 99) < 120);
75
- ok(`and is horizontally centred with it (${Math.round(geo?.centres ?? -1)}px off)`, (geo?.centres ?? 99) < 24);
76
-
77
- await toggle.click();
78
- await page.waitForSelector('.field-controls-panel', { timeout: 5000 });
79
- ok('the panel opens', true);
80
- ok('it has a size control', !!(await page.$('#field-size')));
81
- ok('it has a position pad', !!(await page.$('.field-controls-pad')));
82
-
83
- // Size: measure a real tile before and after.
84
- const before = await page.evaluate(() => {
85
- const tile = document.querySelector('.image-tile');
86
- return tile ? parseFloat(getComputedStyle(tile).width) : 0;
87
- });
88
- await page.evaluate(() => {
89
- const slider = document.querySelector('#field-size');
90
- const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
91
- setter.call(slider, '170');
92
- slider.dispatchEvent(new Event('input', { bubbles: true }));
93
- slider.dispatchEvent(new Event('change', { bubbles: true }));
94
- });
95
- await page.waitForTimeout(1800);
96
- const after = await page.evaluate(() => {
97
- const tile = document.querySelector('.image-tile');
98
- return tile ? parseFloat(getComputedStyle(tile).width) : 0;
99
- });
100
- ok(`raising the size slider grows the photographs (${Math.round(before)}px -> ${Math.round(after)}px)`,
101
- after > before * 1.15, `before ${before} after ${after}`);
102
-
103
- // Position: drag the pad and confirm the plane actually moved.
104
- const pad = await page.$('.field-controls-pad');
105
- const box = await pad.boundingBox();
106
- // Read the recorded offset rather than guessing at a tile: which tile is
107
- // "first" in the DOM is a seeded z-order detail, so asserting on it made
108
- // this test depend on layout internals instead of on the observable state.
109
- await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
110
- await page.mouse.down();
111
- await page.mouse.move(box.x + box.width - 4, box.y + box.height - 4, { steps: 12 });
112
- await page.mouse.up();
113
- await page.waitForTimeout(1200);
114
- const view = await page.evaluate(() => JSON.parse(sessionStorage.getItem('sif:view') || '{}'));
115
- ok(`dragging the pad sets both axes (${view.offsetX}, ${view.offsetY})`,
116
- (view.offsetX ?? 0) > 100 && (view.offsetY ?? 0) > 100, JSON.stringify(view));
117
- // And the change must actually reach the photographs' rendered transform.
118
- // Read the whole field and compare it against the SAME measurement taken
119
- // before the drag: the first tile in the DOM is a seeded z-order detail,
120
- // so the spread across all tiles is the reliable signal, not any one tile.
121
- const span = () => page.evaluate(() => {
122
- const xs = [...document.querySelectorAll('.image-tile')].map(t => {
123
- const m = /translate3d\((-?[\d.]+)px/.exec(t.style.transform || getComputedStyle(t).transform);
124
- return m ? Number(m[1]) : null;
125
- }).filter(v => v !== null);
126
- return xs.length ? { min: Math.min(...xs), max: Math.max(...xs), n: xs.length } : null;
127
- });
128
- const spanAfter = await span();
129
- ok(`the photographs moved with it (x spans ${spanAfter ? `${Math.round(spanAfter.min)}..${Math.round(spanAfter.max)} over ${spanAfter.n} tiles` : 'no tiles'})`,
130
- !!spanAfter && spanAfter.max - spanAfter.min > 200, JSON.stringify(spanAfter));
131
-
132
- // Persistence across reload is what makes it a setting, not a gesture.
133
- await page.reload({ waitUntil: 'domcontentloaded' });
134
- await page.waitForSelector('.image-tile', { timeout: 30000 });
135
- const persisted = await page.evaluate(() => {
136
- const slider = document.querySelector('#field-size');
137
- return sessionStorage.getItem('sif:view');
138
- });
139
- ok('the view survives a reload', !!persisted && JSON.parse(persisted).tileScale > 1, persisted);
140
- await page.evaluate(() => { sessionStorage.clear(); });
141
- }
142
- }
143
-
144
- console.log('\n3. neon outline on an exact match');
145
- {
146
- // Render tiles in the field's own coordinate space, with the classes and
147
- // custom properties ImageField sets, then read the painted result. Typing a
148
- // real query would mean loading the 130 MB model first, and the outline --
149
- // not the encoder -- is what is under test here.
150
- const painted = await page.evaluate(() => {
151
- const field = document.querySelector('.image-field');
152
- const exact = document.createElement('button');
153
- exact.className = 'image-tile exact';
154
- exact.dataset.exact = 'true';
155
- exact.style.cssText = 'position:absolute;left:40px;top:40px;width:200px;height:160px;';
156
- const plain = document.createElement('button');
157
- plain.className = 'image-tile';
158
- plain.dataset.exact = '';
159
- plain.style.cssText = 'position:absolute;left:300px;top:40px;width:200px;height:160px;';
160
- field.append(exact, plain);
161
- const ring = getComputedStyle(exact, '::after');
162
- const noRing = getComputedStyle(plain, '::after');
163
- const out = {
164
- content: ring.content,
165
- borderWidth: ring.borderTopWidth,
166
- shadow: ring.boxShadow,
167
- exactOpacity: getComputedStyle(exact).getPropertyValue('--relevance-opacity').trim(),
168
- plainContent: noRing.content,
169
- };
170
- exact.remove(); plain.remove();
171
- return out;
172
- });
173
- ok('the outline pseudo-element exists on an exact tile', painted.content !== 'none' && painted.content.includes('"'));
174
- ok(`it has a visible stroke (${painted.borderWidth})`, parseFloat(painted.borderWidth) >= 1);
175
- // count comma-separated shadows; color-mix() resolves to oklab() in Chromium,
176
- // so match on the geometry ("Npx Npx Npx") rather than on a colour function.
177
- const glowLayers = (painted.shadow.match(/\d+px\s+\d+px\s+\d+px/g) || []).length;
178
- ok(`it carries a multi-layer neon glow (${glowLayers} layers)`, glowLayers >= 3, painted.shadow);
179
- ok('an ordinary tile gets NO outline', painted.plainContent === 'none', `got ${painted.plainContent}`);
180
- ok('an exact tile is never faded', painted.exactOpacity === '' || Number(painted.exactOpacity) >= 1, `got ${painted.exactOpacity}`);
181
- }
182
-
183
- console.log('\n4. no console errors from the new code');
184
- {
185
- // Image CORS from the dataset CDN is filtered out deliberately: it is a
186
- // PRE-EXISTING property of how this Space loads interior photographs (the
187
- // DATASET URL in assets.js is unchanged by this work), it depends on the
188
- // headless host's own network policy rather than on any code under test, and
189
- // surfacing it here would bury a real regression under a thousand lines of
190
- // noise. The tile layer falls back to the BlurHash placeholder, so the field
191
- // still paints without it.
192
- const relevant = errors.filter(e => !/transformers|onnx|fetch|Failed to load|net::|404|wasm/i.test(e) && !/CORS policy/i.test(e));
193
- ok(`no unrelated page errors (${relevant.length})`, relevant.length === 0, relevant.slice(0, 2).join(' | '));
194
- }
195
-
196
- await page.screenshot({ path: '/tmp/sif-ui.png', fullPage: false });
197
- console.log('\nscreenshot: /tmp/sif-ui.png');
198
- await browser.close();
199
-
200
- console.log(`\n${passed} passed, ${failed} failed`);
201
- if (failed) process.exit(1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-viewer-ui.mjs DELETED
@@ -1,151 +0,0 @@
1
- /**
2
- * Browser checks for the restored viewer CSS, the ten-ring limit, and pill
3
- * clearance.
4
- *
5
- * The CSS regression this file exists for was silent: opening a photograph
6
- * produced an unstyled <dialog> that still worked, still showed the image, and
7
- * just looked nothing like the design. Nothing throws, so only a computed-style
8
- * assertion catches it.
9
- */
10
- import { chromium } from 'playwright';
11
-
12
- const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173/';
13
- let passed = 0, failed = 0;
14
- const test = async (name, fn) => {
15
- try { await fn(); console.log(' ok ' + name); passed++; }
16
- catch (error) { console.log(' FAIL ' + name + '\n ' + error.message.split('\n')[0]); failed++; }
17
- };
18
- const assert = (cond, message) => { if (!cond) throw new Error(message); };
19
-
20
- const browser = await chromium.launch();
21
- const page = await browser.newPage({ viewport: { width: 1600, height: 950 } });
22
- const errors = [];
23
- page.on('pageerror', error => errors.push(error.message));
24
-
25
- await page.goto(BASE, { waitUntil: 'domcontentloaded' });
26
- await page.waitForSelector('.image-tile', { timeout: 120000 });
27
- await page.waitForTimeout(6000);
28
-
29
- console.log('\nrestored viewer CSS');
30
- // ──────────────────────────────────────────────────────────────────────────
31
-
32
- await test('the viewer opens full-screen with its own background', async () => {
33
- // force: true because the field is deliberately alive -- every tile drifts a
34
- // few px per frame, so a click aimed at a position sampled a moment ago can
35
- // land beside the tile. That is the field working, not a bug, and it is why
36
- // the shot here is dispatched at the element rather than at coordinates.
37
- await page.locator('.image-tile').first().click({ force: true, timeout: 15000 });
38
- await page.waitForSelector('.photo-viewer[open]', { timeout: 30000 });
39
- const box = await page.locator('.photo-viewer').boundingBox();
40
- const style = await page.evaluate(() => {
41
- const v = document.querySelector('.photo-viewer');
42
- const s = getComputedStyle(v);
43
- return { position: s.position, bg: s.backgroundColor, w: s.width, h: s.height };
44
- });
45
- assert(style.position === 'fixed', 'viewer is ' + style.position + ', not fixed');
46
- assert(box.width >= 1500 && box.height >= 900, 'viewer is ' + Math.round(box.width) + 'x' + Math.round(box.height));
47
- assert(style.bg !== 'rgba(0, 0, 0, 0)', 'viewer has no background');
48
- });
49
-
50
- await test('the toolbar is a bar, not stacked plain text', async () => {
51
- // The regression rendered this as inline-flow divs at full width. A real
52
- // toolbar is absolutely positioned over the image, which is what stops the
53
- // share button sitting on top of the photograph.
54
- const style = await page.evaluate(() => {
55
- const t = document.querySelector('.photo-toolbar');
56
- return t ? { position: getComputedStyle(t).position, display: getComputedStyle(t).display } : null;
57
- });
58
- assert(style, 'no .photo-toolbar found');
59
- assert(style.position === 'absolute', 'toolbar is ' + style.position);
60
- });
61
-
62
- await test('the close button is sized and round, not a bare glyph', async () => {
63
- const box = await page.locator('.photo-toolbar button').first().boundingBox();
64
- assert(box && box.width >= 28 && box.height >= 28,
65
- 'close button is ' + (box ? Math.round(box.width) + 'x' + Math.round(box.height) : 'missing'));
66
- });
67
-
68
- await test('the frame sits inside the viewport, not flush to an edge', async () => {
69
- const box = await page.locator('.photo-frame').boundingBox();
70
- assert(box && box.width > 100 && box.height > 100, 'frame is ' + JSON.stringify(box));
71
- assert(box.x >= 0 && box.y >= 0 && box.x + box.width <= 1601 && box.y + box.height <= 951,
72
- 'frame escapes the viewport');
73
- });
74
-
75
- await test('the detail panel is styled when the photograph has metadata', async () => {
76
- const n = await page.locator('.photo-detail').count();
77
- if (!n) { console.log(' (skipped: this photograph has no metadata panel)'); return; }
78
- const style = await page.evaluate(() => {
79
- const d = document.querySelector('.photo-detail');
80
- const s = getComputedStyle(d);
81
- return { position: s.position, z: s.zIndex };
82
- });
83
- assert(['absolute', 'fixed'].includes(style.position), 'panel is ' + style.position);
84
- });
85
-
86
- console.log('\nten rings, not sixty');
87
- // ──────────────────────────────────────────────────────────────────────────
88
-
89
- await test('at most ten tiles carry the neon', async () => {
90
- await page.keyboard.press('Escape');
91
- await page.waitForTimeout(1500);
92
- const ringed = await page.locator('.image-tile.featured').count();
93
- assert(ringed <= 10, ringed + ' tiles are ringed, expected at most 10');
94
- });
95
-
96
- await test('more photographs are shown than before', async () => {
97
- const total = await page.locator('.image-tile').count();
98
- assert(total > 96, 'only ' + total + ' tiles; the field should now hold 150 on desktop');
99
- });
100
-
101
- await test('un-ringed new photographs are still visible', async () => {
102
- // "còn lại hiển thị bình thường" -- the rest must render as ordinary tiles.
103
- const plain = await page.evaluate(() => {
104
- const tiles = [...document.querySelectorAll('.image-tile')];
105
- return tiles.filter(t => !t.classList.contains('featured')).length;
106
- });
107
- assert(plain > 10, 'no un-ringed tiles found');
108
- });
109
-
110
- console.log('\npill clearance');
111
- // ──────────────────────────────────────────────────────────────────────────
112
-
113
- await test('no ringed tile overlaps the search pill', async () => {
114
- // The brief is explicit: big, near the search box, but NOT covering it.
115
- // Measured against every ringed tile's real box, not its centre point.
116
- const worst = await page.evaluate(() => {
117
- const pill = document.querySelector('.search-pill').getBoundingClientRect();
118
- let overlap = 0, who = null;
119
- for (const tile of document.querySelectorAll('.image-tile.featured')) {
120
- const r = tile.getBoundingClientRect();
121
- const w = Math.max(0, Math.min(r.right, pill.right) - Math.max(r.left, pill.left));
122
- const h = Math.max(0, Math.min(r.bottom, pill.bottom) - Math.max(r.top, pill.top));
123
- const area = w * h;
124
- if (area > overlap) { overlap = area; who = tile.dataset.imageId; }
125
- }
126
- return { overlap, who, pill: pill.width * pill.height };
127
- });
128
- assert(worst.overlap === 0,
129
- `tile ${worst.who} covers ${Math.round(worst.overlap)}px² of the pill (${Math.round(worst.pill)}px² total)`);
130
- });
131
-
132
- await test('a ringed tile is still larger than an ordinary one', async () => {
133
- const r = await page.evaluate(() => {
134
- const area = el => { const b = el.getBoundingClientRect(); return b.width * b.height; };
135
- const f = document.querySelector('.image-tile.featured');
136
- const o = [...document.querySelectorAll('.image-tile')].find(t => !t.classList.contains('featured'));
137
- return { f: area(f), o: area(o) };
138
- });
139
- assert(r.f > r.o * 1.15, `ringed ${Math.round(r.f)}px² vs ordinary ${Math.round(r.o)}px²`);
140
- });
141
-
142
- console.log('\nno regressions');
143
- // ──────────────────────────────────────────────────────────────────────────
144
-
145
- await test('no uncaught page errors', async () => {
146
- assert(errors.length === 0, errors.slice(0, 3).join(' | '));
147
- });
148
-
149
- await browser.close();
150
- console.log(`\n${passed} passed, ${failed} failed`);
151
- process.exit(failed ? 1 : 0);