@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 -- 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 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 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) { }