File size: 21,060 Bytes
8f2852f
703cbe5
8f2852f
 
 
 
 
 
 
 
0df020e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f2852f
0df020e
 
8f2852f
 
 
4641f85
 
 
 
 
8f2852f
 
 
4641f85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87f3556
 
 
8f2852f
 
4641f85
 
 
 
 
 
 
 
 
 
 
 
 
 
8f2852f
 
 
 
 
 
 
 
 
 
 
0df020e
703cbe5
 
 
 
0df020e
703cbe5
 
 
 
 
 
83cbb13
703cbe5
 
 
83cbb13
c9e39f3
f134575
 
 
 
 
 
 
 
83cbb13
 
 
f134575
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290bf1c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f134575
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
703cbe5
f134575
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
703cbe5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
@import 'tailwindcss';
@import './neon-frame.css';

:root { font-family: Arial, Helvetica, sans-serif; color: #deded7; background: #10110f; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; }
* { 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: 2px solid #d7e1d1; outline-offset: 5px; }

/* ── V.AI STUDIO background ────────────────────────────────────────────────
 * The same artwork vai-avatar shows on its body: public/avatars/vaistudio_center.png,
 * copied here byte-for-byte so the two Spaces read as one brand (md5 checked).
 *
 * Rendered at 30% opacity as a separate fixed layer rather than as a
 * `background-image` on .app, because:
 *
 *   * opacity on the element would fade the photographs and the search pill
 *     along with it, and
 *   * `background-attachment: fixed` + `background-image` cannot be composed
 *     with the two dimming layers (.vignette / .grain) that already sit on top
 *     of the field, so the artwork would fight them instead of sitting under.
 *
 * `pointer-events: none` keeps it from swallowing clicks meant for a tile, and
 * z-index 0 puts it below .image-field (z-index auto, painted later) while
 * still above the base background colour.
 *
 * The image is 816x816 and deliberately centred-cropped on the logo, matching
 * vai-avatar's own mobile rule: on narrow portrait screens it is sized to full
 * width and tiled vertically so the artwork is never cropped to a logo sliver.
 */
.brand-backdrop {
  position: fixed; inset: 0; z-index: 0; pointer-events: none;
  background-color: #10110f;
  background-image: url('/brand/vaistudio_center.png');
  background-size: cover;
  background-position: center center;
  background-repeat: no-repeat;
  background-attachment: fixed;
  opacity: .3;
}
@media (max-width: 768px) {
  .brand-backdrop { background-position: center top; background-size: 100% auto; background-repeat: repeat; background-attachment: scroll; }
}
.app { min-height: 320px; background: #11120f; overflow: clip; }
/* Above the backdrop, below the field's own dimming layers. */
.app { background: transparent; }
/* Unlike hidden, clip prevents focus/scrollIntoView from panning the canvas. */
.image-field { position: absolute; inset: 0; overflow: clip; contain: strict; }
.image-tile { position: absolute; left: 0; top: 0; display: block; border: 0; padding: 0; border-radius: 2px; background-color: #252823; background-size: cover; overflow: hidden; will-change: transform; transform-origin: center; box-shadow: 0 1px 5px #0002; }
/* position:absolute so the share anchor's z-index:1 actually stacks above the
   photograph. A z-index on a static element is ignored, and without this the
   <img> -- appended after the anchor when the thumbnail loads -- paints on top
   of it, so the share button opens the viewer instead of sharing. */
.image-tile img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; filter: brightness(.92); transition: opacity 120ms ease, filter 120ms ease; }
.image-tile img.loaded { opacity: 1; }
.image-tile:hover { box-shadow: 0 10px 35px #0005; }
.image-tile:hover img { filter: brightness(1); }
/* z-index:1 is load-bearing. The tile holds two children -- the share anchor
   and, once it loads, an <img> at width/height 100%. The image is appended
   later, so without this it paints on top and every click lands on the
   photograph: the share button opens the viewer it was meant to bypass. */
/* Share link on the tile. Hidden until hover so the field stays a field: 150
   photographs each wearing a button turns the plane into a control panel. It is
   aria-hidden and tabIndex -1 (see ImageField), so this is purely a pointer
   affordance -- the reachable share is the named link inside the viewer.
   Sized in the tile's own coordinate space, so it scales with the photo. */
.image-share { position: absolute; z-index: 1; right: 5px; top: 5px; display: grid; place-items: center; width: 22px; height: 22px; border-radius: 6px; background: #0d0f0bb8; -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); color: #e6ebe0; opacity: 0; transform: scale(.82); transition: opacity 160ms ease, transform 160ms ease, background 160ms ease, color 160ms ease; }
.image-share svg { width: 13px; height: 13px; }
.image-share:hover { background: #0d0f0be6; color: #ffffff; }
.image-share[hidden] { display: none; }
.image-tile:hover .image-share, .image-tile:focus-within .image-share { opacity: 1; transform: scale(1); }
/* Top-ranked results are the ones most likely to be sent, so theirs show the
   button without waiting for hover. */
.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 120ms linear; } }
/* Relevance is a soft per-photo treatment, not a second results layer.
 * neon-frame.css owns the one exception -- a ringed tile is never dimmed --
 * because that is a property of the ring, not of the fade. */
.image-tile { opacity: var(--relevance-opacity, 1); filter: blur(var(--relevance-blur, 0px)); transition: opacity 650ms ease, filter 650ms ease; }
.image-tile:hover, .image-tile:focus-visible { opacity: 1; filter: blur(0); }
/*
   Lift the hovered tile above its neighbours.
   Required for the share button to be usable at all. Tiles overlap by design and
   each tile's own z-index is a stable seed of its id (ImageField.jsx sets 1..100
   from a hash), not its search rank -- so a tile stays buried under whichever
   neighbour hashed higher, permanently. Measured on the idle field: of 79
   in-viewport share buttons, 0 were the topmost element at their own centre --
   13 were covered by another tile's photograph, 8 by another tile outright, and
   the rest hit their own icon. There is no way to express "raise me above my
   neighbours" for the button alone, because the button cannot be hovered until
   it is on top. Scoped to .image-tile:hover, which also covers :focus-within, so
   keyboard users reach the same affordance.
*/
.image-tile:hover, .image-tile:focus-within { z-index: 2147483647; }
.vignette { position: absolute; inset: 0; pointer-events: none; z-index: 300; background: radial-gradient(ellipse at center, transparent 42%, #0c0d0b30 100%), linear-gradient(0deg, #10110f99, transparent 70px); }
.grain { position: absolute; inset: 0; z-index: 301; pointer-events: none; opacity: .045; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Cpath fill='%23fff' filter='url(%23n)' opacity='.7' d='M0 0h180v180H0z'/%3E%3C/svg%3E"); }
.search-center { position: absolute; z-index: 400; width: clamp(350px, 36vw, 640px); max-width: calc(100% - 44px); left: 50%; top: 50%; transform: translate(-50%, -50%); }
.search-pill { position: relative; height: clamp(62px, 4.8vw, 84px); border-radius: 999px; background: #e4e8ddbb; -webkit-backdrop-filter: blur(26px) saturate(.7); backdrop-filter: blur(26px) saturate(.7); box-shadow: inset 0 1px 0 #ffffff3d, 0 6px 36px #00000018; overflow: hidden; transition: background 250ms, box-shadow 250ms; }
.search-pill:focus-within { background: #e6eaddd4; box-shadow: inset 0 1px 0 #ffffff52, 0 5px 40px #0002; }
.search-pill input { width: 100%; height: 100%; background: transparent; color: #20251f; border: 0; outline: none; padding: 0 58px 0 27px; font-size: clamp(22px, 1.8vw, 30px); letter-spacing: -.035em; caret-color: #323b2d; }
.search-pill input::placeholder { color: #565d54; opacity: 1; }
.search-pill input::selection { background: #80957070; }
.clear-button { position: absolute; right: 13px; top: 50%; transform: translateY(-50%); width: 34px; height: 34px; display: grid; place-items: center; padding: 7px; color: #4c5449; border: 0; background: transparent; border-radius: 50%; transition: background 150ms; }
.clear-button:hover { background: #0000000d; }
.clear-button svg { width: 18px; height: 18px; }

/* ── The neon frame lives in src/neon-frame.css ─────────────────────────────
 * Imported at the top of this file rather than kept inline, for one reason: it
 * also has to ship as dist/assets/neon-frame.css, which index.html loads after
 * the built app.css.
 *
 * The Space is `sdk: static`, so dist/ -- not src/ -- is what the browser gets.
 * Until the bundle is rebuilt from source, that built app.css still carries the
 * old `@keyframes neon-run { transform: rotate(1turn) }`, which rotated the
 * ring's mask along with its gradient and pulled the painted border off the
 * tile's edges. The override file is the fix that does not need a rebuild; this
 * import is what stops the next rebuild from dropping it.
 *
 * One definition, two delivery routes. They are kept in step deliberately: the
 * override is inert once the build includes these rules, rather than the two
 * quietly describing different effects.
 */

/* ── Search status, footer, and the full-screen viewer ────────────────────
 * Restored wholesale. The previous edit removed this whole tail by accident
 * while stripping the size/position controls, which left every one of these
 * classes unstyled: opening a photo rendered a raw <dialog> with its default
 * UA box, the share toolbar stacked vertically as plain text, and the error
 * state had no card behind it. The controls were 12 selectors; what went with
 * them was everything after them in the file.
 */
/* The progress hairline along the bottom of the pill. Restored with the rest
   of the tail -- without it the loading bar rendered as a plain block and the
   pill gained a visible box while the CLIP model downloaded. */
.search-progress { position: absolute; bottom: 0; left: 24px; right: 24px; height: 1px; opacity: 0; overflow: hidden; transition: opacity 300ms; }
.search-progress.visible { opacity: .5; }
.search-progress span { display: block; height: 100%; background: #47513f; transition: width 300ms; animation: progress-glow 1.6s ease-in-out infinite; }
.search-status { position: absolute; top: calc(100% + 13px); left: -28px; right: -28px; display: flex; justify-content: center; align-items: center; gap: 7px; min-height: 20px; font-size: 12px; letter-spacing: .01em; color: #d2d6ccc9; text-shadow: 0 1px 5px #000, 0 1px 12px #000; pointer-events: none; }
.status-dot { width: 4px; height: 4px; border-radius: 50%; background: #d2d8c9; animation: progress-glow 1.6s ease-in-out infinite; }
.has-error-inline { color: #e8b4a9; text-align: center; }
.has-error { flex-wrap: wrap; pointer-events: auto; border-radius: 10px; background: #171b16ee; padding: 12px; text-shadow: none; font-size: 14px; }
.has-error button { border: 0; border-bottom: 1px solid #ccc; padding: 0; background: transparent; color: #eee; }
.error-detail { width: 100%; font-size: 12px; text-align: center; opacity: .7; overflow-wrap: anywhere; }
.footer { z-index: 401; padding: 20px 24px; color: #bac0b395; font-size: 12px; letter-spacing: .015em; }
.footer a { color: inherit; text-decoration: none; transition: color 200ms; }
.footer a:hover { color: #f5f6ef; }
.version { margin-left: 5px; opacity: .55; }
.footer-divider { padding: 0 8px; opacity: .5; }
@keyframes progress-glow { 0%,100% { opacity: .4; } 50% { opacity: 1; } }
@media (max-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 { animation: none !important; transition: none !important; }
}

.photo-viewer { position: fixed; inset: 0; width: 100vw; height: 100dvh; max-width: none; max-height: none; margin: 0; padding: 0; border: 0; color: #e8eae3; background: #0c0e0bdc; backdrop-filter: blur(18px); overflow: hidden; }
.photo-viewer[open] { display: block; animation: viewer-reveal 420ms ease both; }
.photo-viewer::backdrop { background: #0005; }
.photo-viewer.is-closing { opacity: 0; transition: opacity 320ms ease; animation: none; }
.photo-toolbar { position: absolute; z-index: 2; inset: 18px 24px auto; display: flex; align-items: center; justify-content: space-between; pointer-events: none; }
.photo-toolbar a, .photo-toolbar button { pointer-events: auto; background: #12160fe6; border: 1px solid #ffffff18; color: #e0e5d9; border-radius: 999px; }
.photo-toolbar a { font-size: 14px; padding: 12px 17px; text-decoration: none; }
.photo-toolbar button { width: 44px; height: 44px; display: grid; place-items: center; }

/* The share link sits left of the caption. The toolbar is a 3-child flex row
   (share, caption, close) under justify-content: space-between, so the caption
   is pushed to the middle and the share button to the leading edge. */
.photo-share { display: inline-flex; align-items: center; gap: 9px; white-space: nowrap; }
/* The label is the accessible name, and it doubles as the confirmation channel:
   "Đã chia sẻ" appears here for 1.8s after a share. So the span must be
   visually hidden, not display:none -- display:none takes the text out of the
   accessibility tree too, leaving an icon-only button with no name at all, and
   a phone-width button whose confirmation the reader can never see. */
.photo-share svg { width: 17px; height: 17px; flex: none; }
.photo-share:hover { background: #1b2115f2; border-color: #ffffff2e; color: #fff; }
@media (max-width: 560px) {
  .photo-share span { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
  .photo-share { padding: 12px; }
}
.photo-toolbar svg { width: 22px; height: 22px; }
.photo-origin { pointer-events: none; background: #12160fe6; border: 1px solid #ffffff18; color: #e0e5d9; border-radius: 999px; font-size: 14px; padding: 12px 17px; max-width: 60vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.photo-viewport { position: absolute; inset: 0; display: grid; place-items: center; padding: 72px 24px 40px; overflow: auto; }
.photo-frame { display: block; flex: none; padding: 0; border: 0; border-radius: 3px; width: min(1800px, calc(100vw - 48px), calc((100dvh - 128px) * var(--photo-ar))); aspect-ratio: var(--photo-ar); background-size: 100% 100%; background-color: #161912; cursor: zoom-in; box-shadow: 0 12px 70px #0005; transform-origin: center; }
.photo-frame img { display: block; width: 100%; height: 100%; object-fit: contain; opacity: 0; transition: opacity 300ms; }
.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))); margin: 0 auto; cursor: zoom-out; }
@keyframes viewer-reveal { from { opacity: 0; } to { opacity: 1; } }

/* Product gallery paging. The arrows sit inside the viewport so they inherit
   its scroll clipping; step() is a no-op at the ends, so the buttons are only
   rendered when there is somewhere to go. */
.photo-step { position: absolute; top: 50%; transform: translateY(-50%); width: 46px; height: 46px; display: grid; place-items: center; padding: 0; color: #e0e5d9; background: #12160fd9; border: 1px solid #ffffff18; border-radius: 50%; transition: background 180ms, border-color 180ms; }
.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 (max-width: 650px) {
  .photo-step { width: 38px; height: 38px; }
  .photo-step.is-prev { left: 8px; }
  .photo-step.is-next { right: 8px; }
}

/* Date chip in the time-navigation counter. Only visible when paging through
   the interior list by addedAt: newest → oldest. */
.photo-counter .photo-date {
  display: inline-flex;
  margin-left: 8px;
  padding: 1px 7px;
  font-size: 10px;
  font-weight: 600;
  letter-spacing: .06em;
  color: #9fd6a4;
  background: #2a36227a;
  border: 1px solid #4a6b3a33;
  border-radius: 5px;
}
@media (max-width: 650px) {
  .photo-counter .photo-date { display: none; }
}

/* Product caption inside the viewer: the same fields the detail card shows, so
   a photo opened from the product strip carries its context with it. */
.photo-caption { position: absolute; z-index: 2; left: 24px; right: 24px; bottom: 18px; max-width: min(560px, calc(100vw - 48px)); padding: 12px 16px; border: 1px solid #ffffff17; border-radius: 10px; background: #0f130ccc; -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); }
.photo-caption-price { margin: 0 0 6px; font-size: 15px; font-weight: 600; color: #dfe6d2; }
.photo-caption-specs { margin: 0; display: grid; gap: 3px; font-size: 12px; }
.photo-caption-specs > div { display: grid; grid-template-columns: minmax(0, 40%) minmax(0, 60%); gap: 10px; }
.photo-caption-specs dt { color: #98a08f; overflow-wrap: anywhere; }
.photo-caption-specs dd { margin: 0; color: #d2d7cb; overflow-wrap: anywhere; }
.photo-counter { position: absolute; z-index: 2; right: 24px; bottom: 22px; margin: 0; padding: 6px 12px; font-size: 12px; font-variant-numeric: tabular-nums; color: #d2d7cb; background: #0f130ccc; border: 1px solid #ffffff17; border-radius: 999px; }
@media (max-width: 650px) {
  .photo-caption { left: 12px; right: 12px; bottom: 12px; padding: 10px 12px; }
  .photo-counter { display: none; }
}

/* Detail card inside the viewer, for interiors and products alike: title,
   description, room/topic tags, fact table and a link to the source article --
   none of which the viewer showed before. Collapsed by default so the
   photograph is the first impression on a phone. */
.photo-detail { position: absolute; z-index: 2; left: 24px; bottom: 18px; width: min(460px, calc(100vw - 48px)); overflow: hidden; border: 1px solid #ffffff1f; border-radius: 12px; background: #0f130ce8; -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); }
.photo-detail-summary { display: flex; align-items: center; gap: 10px; padding: 11px 14px; cursor: pointer; list-style: none; -webkit-user-select: none; user-select: none; }
.photo-detail-summary::-webkit-details-marker { display: none; }
.photo-detail-summary:hover { background: #ffffff0a; }
.photo-detail-title { flex: 1; min-width: 0; overflow: hidden; font-size: 14px; line-height: 1.35; color: #e4e9dc; text-overflow: ellipsis; white-space: nowrap; }
.photo-detail-price { flex: none; font-size: 13px; font-weight: 600; color: #9fd6a4; font-variant-numeric: tabular-nums; }
.photo-detail-toggle { flex: none; padding: 3px 9px; font-size: 11px; color: #99a291; text-transform: uppercase; letter-spacing: .05em; border: 1px solid #ffffff1f; border-radius: 999px; transition: color 180ms; }
.photo-detail[open] .photo-detail-toggle { color: #cdd6c4; border-color: #ffffff33; }
.photo-detail-body { padding: 0 14px 13px; border-top: 1px solid #ffffff12; }
.photo-detail-desc { margin: 11px 0 0; font-size: 13px; line-height: 1.6; color: #c3cabb; }
.photo-detail-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 11px 0 0; padding: 0; list-style: none; }
.photo-detail-tags li { padding: 3px 9px; font-size: 11px; color: #b6c2ab; background: #ffffff0a; border: 1px solid #ffffff17; border-radius: 999px; }
.photo-detail-facts { display: grid; gap: 4px; margin: 11px 0 0; font-size: 12px; }
.photo-detail-facts > div { display: grid; grid-template-columns: minmax(0, 38%) minmax(0, 62%); gap: 10px; }
.photo-detail-facts dt { color: #939d8a; overflow-wrap: anywhere; }
.photo-detail-facts dd { margin: 0; color: #d5dbcd; overflow-wrap: anywhere; }
/* The outbound "Xem bài gốc" link is gone from the panel; see PhotoViewer.
   No replacement rule -- the source site now appears only as a "Nguồn" row in
   the fact table. */
/* When the price/spec caption is also on screen, the detail card moves to the
   top instead: both are anchored bottom-left and would collide. */
.photo-viewer:has(.photo-caption) .photo-detail { top: 76px; bottom: auto; }
@media (max-width: 650px) {
  .photo-detail { left: 12px; right: 12px; bottom: 12px; width: auto; }
  .photo-viewer:has(.photo-caption) .photo-detail { top: 70px; }
}

/* Malloca product strip. Sits above the vignette so products stay legible
   over the photograph field, and below the search pill's stacking level. */
@media (max-width: 650px) {
}