*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } /* Typography tokens: two faces with exclusive roles. --font-display (Orbitron) is opt-in on a small set of display elements; --font-body (Raleway) sets every other string of UI text via `body`. */ :root { --font-display: "Orbitron Variable", sans-serif; --font-body: "Raleway Variable", sans-serif; /* Modular type scale, ratio 1.25 (major third), base 1rem. */ --text-xs: 0.8rem; --text-sm: 1rem; --text-md: 1.25rem; --text-lg: 1.5625rem; --text-xl: 1.9531rem; --text-2xl: 2.4414rem; --text-3xl: 3.0518rem; /* Swipe verdict colours (issue #345). Divergent in hue *and* in lightness so LIKE and NOPE stay tellable apart in greyscale, and chosen to sit on the navy rather than fight it. Neither collides with the cyan accent. Shared with the Like/Nope buttons when #344 lands. */ --color-like: #3DE8A8; --color-nope: #FF5C8A; --color-error: #FF5C8A; /* Palette tokens (issue #347). One deliberate, slightly desaturated cyan accent (down from the default-neon cyan so it reads intentional), one destructive pink that harmonizes with --color-nope, plus the navy surfaces, borders and text colours that repeat across the stylesheet. Hover tints and the jelly-gradient stops live in the cyan family below, so the accent/destructive hexes appear only here. */ --color-accent: #28CDE6; --color-destructive: #FF6B9D; /* Navy surfaces, deepest to lightest. */ --color-navy-deep: #071426; --color-navy-base: #0A1F3D; --color-navy-mid: #0A3D91; --color-card-bg: #0A357C; /* Borders and focus rings. */ --color-border: #1C4273; --color-border-light: #9BF2FF; --color-ring: #3a67ed; --color-ring-dark: #12246e; /* Text. */ --color-text: #f0f4f8; --color-text-bright: #fdfdfe; --color-white: #ffffff; /* Cyan accent family: hover tints and jelly-gradient stops, all derived from --color-accent. */ --color-accent-tint: #7EEBFF; --color-accent-light: #5FE2FF; --color-accent-soft: #4BE0FF; --color-accent-hover: #63E5FF; --color-accent-bright: #98F1FF; --color-accent-bright-active: #6ADFFF; --color-accent-mid: #28CFFF; --color-accent-deep: #1B7BFF; --color-accent-deep-hover: #2B8BFF; --color-accent-deep-active: #156AE0; --color-accent-dark: #082E73; --color-accent-deeper: #1452B8; --color-accent-faint: #c9f8ff; --color-accent-pale: #bfefff; --color-accent-white: #dff8ff; /* Corner-radius scale. pill for interactive controls, card for cards and modals, badge for small pills/status chips, round for avatars and other fully-round shapes. */ --radius-pill: 999px; --radius-card: 16px; --radius-badge: 8px; --radius-round: 50%; /* Z-index scale: stamp feedback sits over the deck, the modal above everything. */ --z-stamp: 2; --z-modal: 1000; /* Shared keyboard-focus treatment (issue #353): one :focus-visible ring for every interactive control, drawn from these tokens so it can only drift in one place. Two-tone — a bright outline that reads against the navy paired with a cyan glow from the accent family — so it never relies on the cyan alone. Composed purely from existing --color-* tokens (no raw palette literal). */ --focus-ring: var(--color-text-bright); --focus-ring-offset: 3px; --focus-glow: 0 0 0 2px var(--color-border-light), 0 0 14px color-mix(in srgb, var(--color-accent-light) 55%, transparent); } body { font-family: var(--font-body); font-size: var(--text-sm); line-height: 1.5; background: var(--color-navy-deep); background: linear-gradient(0deg,rgba(7, 20, 38, 1) 0%, rgba(10, 31, 61, 1) 60%); color: var(--color-text); min-height: 100dvh; display: flex; flex-direction: column; align-items: center; } main { width: 90vw; padding: 1.5rem 0; position: relative; } button, input, textarea, select { font: inherit; } h1, h2, h3, h4, h5 { margin-bottom: 1rem; line-height: 1.2; } p { margin-bottom: 1rem; } img { max-width: 100%; display: block; } .app-logo { width: 90vw; height: auto; margin: 0 auto; } /* jelly button */ .jelly-button { position: relative; padding: 1.5rem; border: 4px solid var(--color-ring-dark); border-radius: var(--radius-pill); outline: var(--color-ring) solid 4px; cursor: pointer; overflow: hidden; box-shadow: 0 0 0 7px var(--color-text-bright); background: linear-gradient( to bottom, var(--color-accent-tint) 0%, var(--color-accent) 24%, var(--color-accent-deep) 76%, var(--color-navy-mid) 100% ); color: var(--color-text-bright); font-family: var(--font-display); font-size: var(--text-lg); font-weight: 800; letter-spacing: 0.03em; text-shadow: 0 1px 0 rgba(0, 0, 0, 0.9), 0 -1px 0 rgba(0, 0, 0, 0.55), 1px 0 0 rgba(0, 0, 0, 0.55), -1px 0 0 rgba(0, 0, 0, 0.55), 0 2px 4px rgba(0, 0, 0, 0.45), 0 0 4px rgba(255, 255, 255, 0.22); transition: transform 0.16s ease, box-shadow 0.16s ease, filter 0.16s ease; } .jelly-button::before { content: ""; position: absolute; width: 72%; height: 58%; top: -14%; left: -12%; border-radius: var(--radius-round); background: radial-gradient( ellipse at center, rgba(255,255,255,0.95) 0%, rgba(255,255,255,0.75) 24%, rgba(255,255,255,0.22) 44%, rgba(255,255,255,0) 68% ); transform: rotate(-22deg) scaleY(0.42); opacity: 0.95; pointer-events: none; } .jelly-button::after { content: ""; position: absolute; width: 14px; height: 14px; top: 11%; left: 18%; border-radius: var(--radius-round); background: radial-gradient( circle at 35% 35%, rgba(255,255,255,0.90), rgba(255,255,255,0.2) 70%, transparent 100% ); opacity: 0.9; pointer-events: none; } .jelly-button:hover { transform: translateY(-4px) scale(1.02); filter: brightness(1.06) saturate(1.08); box-shadow: 0 0 0 7px var(--color-text-bright), 0 16px 26px rgba(0, 0, 0, 0.38); background: linear-gradient( to bottom, var(--color-accent-bright) 0%, var(--color-accent-soft) 24%, var(--color-accent-deep-hover) 76%, var(--color-accent-deeper) 100% ); } .jelly-button:hover::before { opacity: 1; transform: rotate(-22deg) scaleY(0.46); filter: brightness(1.08); } .jelly-button:hover::after { opacity: 1; transform: scale(1.08); } .jelly-button:active { transform: translateY(2px) scale(0.985); filter: brightness(0.95) saturate(0.98); box-shadow: 0 0 0 7px var(--color-text-bright), 0 7px 12px rgba(0, 0, 0, 0.28), inset 0 5px 10px rgba(0, 0, 0, 0.24); background: linear-gradient( to bottom, var(--color-accent-bright-active) 0%, var(--color-accent-mid) 24%, var(--color-accent-deep-active) 76%, var(--color-accent-dark) 100% ); } .jelly-button:active::before { opacity: 0.9; transform: rotate(-22deg) scaleY(0.34); } .jelly-button:active::after { transform: scale(0.92); opacity: 0.82; } .button-container { display: flex; flex-wrap: wrap; justify-content: space-around; align-items: center; gap: 25px; margin-top: 2rem; } .modal { position: fixed; top: 0; left: 0; width: 100vw; height: 100dvh; background: rgba(7, 20, 38, 0.95); display: flex; flex-direction: column; justify-content: start; align-items: center; padding-top: 20%; gap: 1rem; z-index: var(--z-modal); } /* The shared Modal component renders a native . Reset the user-agent dialog styles (border, padding, margin, white background) so the fixed overlay above stays exactly as the visual design intends. The dialog itself is the overlay, so its ::backdrop is transparent. */ dialog.modal { border: none; padding: 0; padding-top: 20%; margin: 0; color: inherit; background: rgba(7, 20, 38, 0.95); max-width: none; max-height: none; } dialog.modal::backdrop { background: transparent; } /* Applied by Modal while a dialog is open to lock page scroll. */ .modal-open { overflow: hidden; } .modal-inner { width: 80%; display: flex; flex-direction: column; justify-content: start; align-items: center; gap: 1rem; background: rgba(7, 20, 38, 0.75); border: 2px solid var(--color-text); border-radius: var(--radius-card); box-shadow: 2px 2px 5px 2px var(--color-ring); padding: 1.5rem 0; } .modal-genre { height: 85%; overflow-y: auto; } img.match-logo { height: 75px; margin-top: -.75rem; margin-bottom: -.75rem; } img.match-poster { width: 150px; border-radius: var(--radius-badge); margin-top: -.75rem; } .modal-inner h2 { font-size: var(--text-xl); text-align: center; } /* The match modal's headline is the app's signature display moment. */ .match-headline { font-family: var(--font-display); } .match-title { font-family: var(--font-display); font-size: var(--text-xl); margin-bottom: .25rem; text-align: center; color: var(--color-accent); } .room-code-input { width: 70%; padding: 0.65rem 1rem; border-radius: var(--radius-pill); border: 1px solid var(--color-border-light); background: linear-gradient( to bottom, color-mix(in srgb, var(--color-accent-deep) 28%, transparent), rgba(10, 61, 145, 0.38) ); color: var(--color-text-bright); font-size: var(--text-md); font-weight: 500; box-shadow: inset 0 2px 4px rgba(255, 255, 255, 0.08), inset 0 -3px 6px rgba(0, 0, 0, 0.18); outline: none; transition: border-color 0.16s ease, box-shadow 0.16s ease, background 0.16s ease, transform 0.16s ease; } .room-code-input::placeholder { color: rgba(253, 253, 254, 0.55); } .room-code-input:hover { border-color: var(--color-accent-faint); box-shadow: inset 0 2px 4px rgba(255, 255, 255, 0.1), inset 0 -3px 6px rgba(0, 0, 0, 0.2); } .room-code-input:focus { border-color: var(--color-text-bright); background: linear-gradient( to bottom, color-mix(in srgb, var(--color-accent) 22%, transparent), color-mix(in srgb, var(--color-accent-deep) 42%, transparent) ); box-shadow: inset 0 2px 4px rgba(255, 255, 255, 0.12), inset 0 -3px 6px rgba(0, 0, 0, 0.22), 0 0 0 2px color-mix(in srgb, var(--color-border-light) 18%, transparent); transform: translateY(-1px); } /* Button roles (issue #347): every control picks exactly one role. - .btn-primary — the compact jelly, the app's signature action: a smaller, quieter member of the .jelly-button family (same gradient logic and ring idea, dialed down — smaller padding, font, radius and ring). Reusable via .jelly-button--compact; issue #344 will apply this treatment to the Like/Nope pair when it lands. - .btn-secondary — a flat, quiet pill with no gloss. - .btn-destructive — the destructive action, coloured from the destructive token. Legacy class names remain as thin aliases grouped into the role selectors where old markup still needs them (button.end-session for HostWaiting); none carries its own background/border/colour. */ .btn-primary, .jelly-button--compact { position: relative; padding: 0.4rem 1.25rem; border: 2px solid var(--color-ring-dark); border-radius: var(--radius-pill); outline: var(--color-ring) solid 3px; cursor: pointer; overflow: hidden; box-shadow: 0 0 0 4px var(--color-text-bright); background: linear-gradient( to bottom, var(--color-accent-tint) 0%, var(--color-accent) 24%, var(--color-accent-deep) 76%, var(--color-navy-mid) 100% ); color: var(--color-text-bright); font-family: var(--font-display); font-size: var(--text-sm); font-weight: 800; letter-spacing: 0.03em; text-shadow: 0 1px 0 rgba(0, 0, 0, 0.9), 0 -1px 0 rgba(0, 0, 0, 0.55), 1px 0 0 rgba(0, 0, 0, 0.55), -1px 0 0 rgba(0, 0, 0, 0.55), 0 2px 4px rgba(0, 0, 0, 0.45), 0 0 4px rgba(255, 255, 255, 0.22); transition: transform 0.16s ease, box-shadow 0.16s ease, filter 0.16s ease; } /* The modal-layout width belongs to the modal, not the role. .btn-primary is a reusable role (issue #344 will apply .jelly-button--compact to the Like/Nope pair), and a baked-in 70% width would overflow a flex row. Every current .btn-primary lives inside .modal-inner, so scoping the width there preserves today's layout exactly. */ .modal-inner .btn-primary { width: 70%; } .btn-primary::before, .jelly-button--compact::before { content: ""; position: absolute; width: 55%; height: 45%; top: -18%; left: -15%; border-radius: var(--radius-round); background: radial-gradient( ellipse at center, rgba(255,255,255,0.95) 0%, rgba(255,255,255,0.75) 24%, rgba(255,255,255,0.22) 44%, rgba(255,255,255,0) 68% ); transform: rotate(-22deg) scaleY(0.42); opacity: 0.9; pointer-events: none; } .btn-primary::after, .jelly-button--compact::after { content: ""; position: absolute; width: 9px; height: 9px; top: 14%; left: 16%; border-radius: var(--radius-round); background: radial-gradient( circle at 35% 35%, rgba(255,255,255,0.90), rgba(255,255,255,0.2) 70%, transparent 100% ); opacity: 0.9; pointer-events: none; } .btn-primary:hover, .jelly-button--compact:hover { transform: translateY(-2px) scale(1.01); filter: brightness(1.06) saturate(1.08); box-shadow: 0 0 0 4px var(--color-text-bright), 0 8px 14px rgba(0, 0, 0, 0.34), 0 0 16px color-mix(in srgb, var(--color-accent) 30%, transparent); background: linear-gradient( to bottom, var(--color-accent-bright) 0%, var(--color-accent-soft) 24%, var(--color-accent-deep-hover) 76%, var(--color-accent-deeper) 100% ); } .btn-primary:hover::before, .jelly-button--compact:hover::before { opacity: 1; transform: rotate(-22deg) scaleY(0.46); filter: brightness(1.08); } .btn-primary:hover::after, .jelly-button--compact:hover::after { opacity: 1; transform: scale(1.08); } .btn-primary:active, .jelly-button--compact:active { transform: translateY(1px) scale(0.985); filter: brightness(0.95) saturate(0.98); box-shadow: 0 0 0 4px var(--color-text-bright), 0 4px 8px rgba(0, 0, 0, 0.26), inset 0 4px 8px rgba(0, 0, 0, 0.22); background: linear-gradient( to bottom, var(--color-accent-bright-active) 0%, var(--color-accent-mid) 24%, var(--color-accent-deep-active) 76%, var(--color-accent-dark) 100% ); } .btn-primary:active::before, .jelly-button--compact:active::before { opacity: 0.9; transform: rotate(-22deg) scaleY(0.34); } .btn-primary:active::after, .jelly-button--compact:active::after { transform: scale(0.92); opacity: 0.82; } .btn-primary:disabled, .btn-primary[aria-disabled="true"] { background: linear-gradient( to bottom, color-mix(in srgb, var(--color-accent-light) 35%, transparent) 0%, color-mix(in srgb, var(--color-accent) 28%, transparent) 100% ); color: rgba(7, 20, 38, 0.55); border-color: color-mix(in srgb, var(--color-border-light) 38%, transparent); box-shadow: none; transform: none; filter: saturate(0.55) brightness(0.95); cursor: not-allowed; } .form-error { color: var(--color-error); font-size: var(--text-sm); text-align: center; } .error-banner { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.5rem 1rem; margin-bottom: 1rem; background: rgba(7, 20, 38, 0.72); border: 1px solid var(--color-error); border-radius: var(--radius-card); color: var(--color-error); font-size: var(--text-sm); text-align: center; } .error-banner .error-dismiss { background: transparent; border: none; color: var(--color-error); font-size: var(--text-md); cursor: pointer; padding: 0 0.25rem; line-height: 1; } .deck-error { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; height: 100%; color: var(--color-error); font-size: var(--text-sm); text-align: center; } .deck-error p { margin-bottom: 0; } .deck-end { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; height: 100%; color: var(--color-text-bright); font-size: var(--text-sm); text-align: center; } .deck-end p { margin-bottom: 0; } .retry-deck { background: var(--color-navy-deep); color: var(--color-text); padding: 0.5rem 1.25rem; border-radius: var(--radius-card); font-size: var(--text-sm); font-weight: 600; border: 1px solid var(--color-accent); cursor: pointer; } .btn-primary:disabled::before, .btn-primary:disabled::after, .btn-primary:disabled:hover::before, .btn-primary:disabled:active::before { opacity: 0; } .btn-primary:disabled:hover, .btn-primary:disabled:focus, .btn-primary:disabled:active { background: linear-gradient( to bottom, color-mix(in srgb, var(--color-accent-light) 35%, transparent) 0%, color-mix(in srgb, var(--color-accent) 28%, transparent) 100% ); border-color: color-mix(in srgb, var(--color-border-light) 38%, transparent); box-shadow: none; transform: none; filter: saturate(0.55) brightness(0.95); cursor: not-allowed; } .btn-primary-link { text-decoration: none; text-align: center; padding: 0.36rem 1.62rem; font-size: var(--text-sm); } /* The match modal's Open-in-Jellyfin link must escape the modal-scoped width so it sits snug around its label. Same (0,2,0) specificity as `.modal-inner .btn-primary` and declared later, so it wins. */ .modal-inner .btn-primary-link { width: auto; } .btn-secondary { background: var(--color-navy-deep); color: var(--color-text); padding: 0.5rem 1.25rem; border-radius: var(--radius-pill); border: 1px solid var(--color-border); font-size: var(--text-sm); font-weight: 600; cursor: pointer; transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease, background 0.18s ease, border-color 0.18s ease; } .btn-secondary:hover { background: var(--color-navy-mid); border-color: var(--color-accent); color: var(--color-text); transform: translateY(-2px); } .btn-secondary:focus { outline: none; border-color: var(--color-text-bright); } .btn-secondary:active { transform: translateY(1px) scale(0.98); filter: brightness(0.97); } .btn-secondary:disabled { background: var(--color-navy-deep); color: rgba(240, 244, 248, 0.45); border-color: var(--color-border); cursor: not-allowed; transform: none; opacity: 0.65; } .btn-destructive, button.end-session { background: var(--color-destructive); color: var(--color-navy-base); padding: 0.5rem 1.25rem; border-radius: var(--radius-pill); border: 1px solid var(--color-navy-base); font-size: var(--text-sm); font-weight: 600; cursor: pointer; transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease; } .btn-destructive:hover, button.end-session:hover { transform: translateY(-2px); filter: brightness(1.05); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.22); } .btn-destructive:active, button.end-session:active { transform: translateY(1px) scale(0.98); filter: brightness(0.95); } /* Shared keyboard-focus treatment (issue #353): every interactive control draws its :focus-visible ring from the --focus-* tokens defined in :root, so there is exactly one place the treatment can drift. The custom toggles/checkboxes are styled through their visually-hidden input's sibling, since the real input carries no visible box. The outline shifts the bright ring outside the resting ring (e.g. .jelly-button's 7px white shadow) so keyboard focus stays visually distinct. The genre radio family (.custom-radio:has(input:focus-visible)) is deliberately NOT part of this comma-separated selector list: a single unsupported selector invalidates the whole rule, and :has() is the newest feature here — unsupported on Firefox ESR 115 and older, Samsung Internet < 20, and older Android WebView / smart-TV browsers. Keeping it in the shared list would silently drop every control's focus ring on those engines. It is isolated in its own rule directly below so an engine lacking :has() loses only the radio family, never the rest. */ .jelly-button:focus-visible, .btn-primary:focus-visible, .btn-primary-link:focus-visible, .jelly-button--compact:focus-visible, .btn-secondary:focus-visible, .btn-destructive:focus-visible, button.end-session:focus-visible, .error-dismiss:focus-visible, .retry-deck:focus-visible, .jelly-toggle input:focus-visible + .slider, .jelly-check input:focus-visible + .checkmark { outline: 2px solid var(--focus-ring); outline-offset: var(--focus-ring-offset); box-shadow: var(--focus-glow); } .custom-radio:has(input:focus-visible) { outline: 2px solid var(--focus-ring); outline-offset: var(--focus-ring-offset); box-shadow: var(--focus-glow); } .jelly-check { display: flex; align-items: center; gap: 0.85rem; color: var(--color-text-bright); font-size: var(--text-md); font-weight: 600; cursor: pointer; user-select: none; } .jelly-check input { position: absolute; opacity: 0; pointer-events: none; } .checkmark { position: relative; width: 28px; height: 28px; border-radius: var(--radius-badge); border: 2px solid var(--color-border-light); background: linear-gradient( to bottom, color-mix(in srgb, var(--color-accent) 18%, transparent), rgba(10, 61, 145, 0.4) ); box-shadow: inset 0 2px 4px rgba(255,255,255,0.08), inset 0 -3px 6px rgba(0,0,0,0.2); transition: background 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease, border-color 0.16s ease; } .jelly-check:hover .checkmark { border-color: var(--color-accent-faint); box-shadow: inset 0 2px 4px rgba(255,255,255,0.1), inset 0 -3px 6px rgba(0,0,0,0.22); transform: translateY(-1px); } .jelly-check input:checked + .checkmark { background: linear-gradient( to bottom, var(--color-accent-tint) 0%, var(--color-accent) 35%, var(--color-accent-deep) 100% ); border-color: var(--color-text-bright); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-border-light) 16%, transparent), 0 0 16px color-mix(in srgb, var(--color-accent) 30%, transparent); } .checkmark::after { content: ""; position: absolute; left: 8px; top: 3px; width: 7px; height: 14px; border: solid var(--color-navy-deep); border-width: 0 3px 3px 0; transform: rotate(45deg) scale(0); transition: transform 0.14s ease; } .jelly-check input:checked + .checkmark::after { transform: rotate(45deg) scale(1); } .jelly-toggle { display: flex; align-items: center; justify-content: space-between; width: 70%; color: var(--color-text-bright); font-size: var(--text-md); font-weight: 600; cursor: pointer; user-select: none; } .swipe-toggle { width: auto; gap: .75rem; font-size: var(--text-sm); font-weight: 500; } .hide-watched-span { margin-left: auto; } .jelly-toggle input { position: absolute; opacity: 0; pointer-events: none; } .slider { position: relative; width: 60px; height: 32px; border-radius: var(--radius-pill); border: 2px solid var(--color-border-light); background: linear-gradient( to bottom, color-mix(in srgb, var(--color-accent) 14%, transparent), rgba(10, 61, 145, 0.4) ); box-shadow: inset 0 2px 4px rgba(255,255,255,0.08), inset 0 -3px 6px rgba(0,0,0,0.22); transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; } .slider::before { content: ""; position: absolute; top: 3px; left: 4px; width: 22px; height: 22px; border-radius: var(--radius-round); background: linear-gradient( to bottom, var(--color-text-bright), var(--color-accent-pale) ); box-shadow: 0 2px 6px rgba(0,0,0,0.25), inset 0 1px 2px rgba(255,255,255,0.65); transition: transform 0.18s ease, background 0.18s ease; } .jelly-toggle:hover .slider { border-color: var(--color-accent-faint); } .jelly-toggle input:checked + .slider { background: linear-gradient( to bottom, var(--color-accent-tint) 0%, var(--color-accent) 35%, var(--color-accent-deep) 100% ); border-color: var(--color-text-bright); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-border-light) 14%, transparent), 0 0 18px color-mix(in srgb, var(--color-accent) 30%, transparent); } .jelly-toggle input:checked + .slider::before { transform: translateX(28px); background: linear-gradient( to bottom, var(--color-white), var(--color-accent-white) ); } .solo { margin-top: 1.5rem; border-top: 1px solid color-mix(in srgb, var(--color-text) 67%, transparent); padding-top: 1.5rem; margin-bottom: 1.5rem; } .host-waiting { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; } .room-code-block { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; margin: 0; } .room-code-label { font-size: var(--text-md); font-weight: 500; } /* The room code is the one number a user has to read aloud across a room. It is the single largest, most confident element on this screen. */ .room-code { font-family: var(--font-display); font-size: var(--text-3xl); font-weight: 800; letter-spacing: 0.08em; line-height: 1.1; color: var(--color-accent); } .waiting-text { font-size: var(--text-lg); margin-bottom: 0; animation: jello-vertical-normal 3s ease-in 1s infinite normal none; } @keyframes jello-vertical-normal { 0% { transform: scale3d(1, 1, 1); } 15% { transform: scale3d(0.75, 1.25, 1); } 20% { transform: scale3d(1.25, 0.75, 1); } 25% { transform: scale3d(0.85, 1.15, 1); } 33% { transform: scale3d(1.05, 0.95, 1); } 38% { transform: scale3d(0.95, 1.05, 1); } 50% { transform: scale3d(1, 1, 1); } 100% { transform: scale3d(1, 1, 1); } } .waiting-logo-container { display: flex; align-items: center; justify-content: center; gap: 5%; margin-bottom: 10px; padding: 25px 0; } .waiting-logo-container img { width: 40%; } .swipe-header { display: flex; justify-content: space-between; align-items: center; gap: 10px; } div.mode-badge { background: var(--color-navy-deep); color: var(--color-text); border-radius: var(--radius-pill); padding: 0.25rem 1rem; border: 1px solid var(--color-border); font-size: var(--text-sm); font-weight: 300; text-align: center; margin-right: auto; display: none; } .custom-radio input[type="radio"] { position: absolute; opacity: 0; width: 0; height: 0; } .custom-radio { display: flex; background: var(--color-navy-deep); color: var(--color-text); padding: 0.5rem 0.75rem; align-items: center; justify-content: center; text-align: center; border-radius: var(--radius-pill); font-size: var(--text-sm); font-weight: 600; border: 1px solid var(--color-accent); cursor: pointer; transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; } .custom-radio:hover { background: var(--color-navy-mid); border-color: var(--color-accent); color: var(--color-text); transform: translateY(-1px); } .custom-radio.active { background: var(--color-accent); border-radius: var(--radius-pill); border-color: var(--color-border); color: var(--color-navy-deep); } .custom-radio.active:hover { background: var(--color-accent-soft); border-color: var(--color-accent-faint); color: var(--color-navy-deep); } div.genre-inputs { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 15px 0; padding: 10px; } .swipe-main { display: flex; flex-direction: column; align-items: center; gap: 1rem; margin-top: .5rem; padding: 10px; } .swipe-deck { position: relative; width: 100%; max-width: 400px; /* Size the deck from the available viewport height (issue #351): the deck is capped by a fixed pixel height but shrinks to a share of the visible viewport on short phones so the whole swipe screen fits without scrolling. */ height: min(650px, 62dvh); /* Room at the top of the deck for the back cards of the stack to peek into (issue #343). Cards are inset by this much rather than filling the deck, so the stack stays inside the deck's own footprint instead of riding up over the header. Must clear the deepest card's sliver — see the geometry note in CardItemView.tsx. */ --deck-stack-reserve: 32px; } .card-item-container { perspective: 1200px; position: absolute; inset: var(--deck-stack-reserve, 0) 0 0 0; user-select: none; } /* Back cards of the deck stack (issue #343): soften the shadow so several stacked cards don't turn muddy when offset. The top card keeps the strong full-size shadow. */ .card-item-container.stack-back .card-item { box-shadow: 2px 4px 6px rgba(0, 0, 0, 0.22), inset 0 2px 6px rgba(255, 255, 255, 0.08); } /* Swipe verdict feedback (issue #345). Both the stamps and the rim are driven inline from the live drag position, so they carry an almost-imperceptible 60ms transition rather than the 0.2s the old edge glow used — feedback tied to a gesture must not chase the finger. Deliberately NOT covered by the prefers-reduced-motion block at the end of this file: per #353, direct manipulation feedback stays. */ .swipe-stamp, .swipe-rim { position: absolute; z-index: var(--z-stamp); pointer-events: none; opacity: 0; /* One transition declaration for both elements, so there is exactly one place this duration can drift. The rim never scales; declaring it is harmless. */ transition: opacity 60ms linear, scale 60ms linear; } /* The secondary cue: the card's own edge takes the verdict colour. Matches div.card-item's 20px radius and the container's box exactly. */ .swipe-rim { inset: 0; border-radius: var(--radius-card); } .swipe-rim-like { box-shadow: inset 0 0 0 3px var(--color-like), 0 0 28px rgba(61, 232, 168, 0.45); } .swipe-rim-nope { box-shadow: inset 0 0 0 3px var(--color-nope), 0 0 28px rgba(255, 92, 138, 0.45); } .swipe-stamp { top: 10%; display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 1.1rem; background: rgba(7, 20, 38, 0.72); font-family: var(--font-display); font-size: var(--text-lg); font-weight: 800; letter-spacing: 0.08em; line-height: 1.1; text-transform: uppercase; } /* LIKE and NOPE must be tellable apart with no colour vision at all, so they differ in glyph, word, border-style, corner radius and tilt as well as hue. */ .swipe-stamp-like { left: 7%; color: var(--color-like); border: 4px solid var(--color-like); border-radius: var(--radius-pill); transform: rotate(-14deg); } .swipe-stamp-nope { right: 7%; color: var(--color-nope); border: 4px dashed var(--color-nope); border-radius: var(--radius-badge); transform: rotate(14deg); } .swipe-stamp-glyph { font-size: var(--text-md); } .card-item-inner { position: relative; width: 100%; height: 100%; transform-style: preserve-3d; transition: transform 0.7s cubic-bezier(0.22, 1, 0.36, 1); } div.card-item { position: absolute; box-sizing: border-box; inset: 0; display: flex; flex-direction: column; align-items: center; background: var(--color-card-bg); border-radius: var(--radius-card); box-shadow: 4px 8px 8px rgba(0, 0, 0, 0.38), inset 0 2px 6px rgba(255,255,255,0.08); overflow: hidden; backface-visibility: hidden; -webkit-backface-visibility: hidden; } .card-item-container.flipped .card-item-inner { transform: rotateY(180deg); } div.front { justify-content: center; /* The poster frame below is sized from the card's available height (issue #351), so the fixed 60px top/bottom padding no longer reserves space a short deck can't afford — it would push the frame past the card's bottom edge on a 667px-tall phone. */ padding: 0; } div.back { transform: rotateY(180deg); padding: 1.5rem; /* The details face holds the title, info row, description, cast and a full-width trailer button/iframe, so it is taller than a short viewport-derived deck (issue #351). Scroll it internally so the lower content (cast, trailer) stays reachable instead of being clipped by the card's overflow: hidden. */ overflow-y: auto; -webkit-overflow-scrolling: touch; } img.card-item-poster { max-width: 100%; height: auto; display: block; user-select: none; -webkit-user-drag: none; } /* Poster frame (issue #350): the card poster reserves a stable 2:3 box so the deck never shifts when the image arrives. The navy placeholder shows while loading and the poster fades in over it on load. */ div.poster-frame { position: relative; /* Height-constrained (issue #351): the frame is sized from the card's available height and shrinks in width while holding aspect-ratio 2 / 3, so a short deck never crops or clips the poster. Sizing arithmetic (review follow-up on #351): the -20px term cancels the 20px margin-top below, so the frame's margin box is 52px shorter than the card, and div.front's justify-content: center splits that 52px evenly — 26px of free space above the margin box plus the 20px margin puts the frame's top edge ~46px down, clearing the absolutely-positioned media-type chip (top:10px, ~34px tall incl. padding and border, so its bottom edge is ~44px) and leaving a ~26px gap at the card's bottom. The poster img is object-fit: contain and follows the frame. */ height: calc(100% - 20px - 52px); max-width: 100%; aspect-ratio: 2 / 3; margin-top: 20px; background: var(--color-navy-deep); border-radius: var(--radius-badge); overflow: hidden; } div.poster-frame img.card-item-poster { width: 100%; height: 100%; object-fit: contain; opacity: 0; transition: opacity 0.2s ease; } div.poster-frame.poster-frame-loaded img.card-item-poster { opacity: 1; } /* The null/failed-poster fallback label sits centred inside the reserved box (issue #350). Only the swipe card sets showNoPosterLabel, and only inside the frame, so it can rely on the frame's relative positioning. */ div.poster-frame .no-poster { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; text-align: center; padding: 0.5rem; color: var(--color-text); font-size: var(--text-sm); } div.media-type { position: absolute; top: 10px; left: 10px; background: color-mix(in srgb, var(--color-navy-deep) 67%, transparent); color: var(--color-text); border-radius: var(--radius-badge); padding: 0.25rem 1rem; border: 1px solid var(--color-border); font-size: var(--text-sm); font-weight: 300; text-align: center; } div.back { padding: 1rem 1.5rem; border: 2px solid var(--color-accent); gap: .5rem; } .card-item-title { font-family: var(--font-display); font-size: var(--text-2xl); margin-bottom: 0; color: var(--color-accent); } div.card-item-info { display: flex; gap: .5rem; margin-bottom: 0.5rem; } div.card-item-score, div.card-item-runtime, div.card-item-year { background: color-mix(in srgb, var(--color-navy-deep) 67%, transparent); color: var(--color-accent); border-radius: var(--radius-badge); padding: 0.25rem 1rem; border: 1px solid var(--color-border); font-size: var(--text-sm); font-weight: 400; text-align: center; } div.trailer { margin-bottom: 1rem; width: 100%; } button.watch-trailer { /* secondary role — styling lives on .btn-secondary; only layout stays here */ width: 100%; } button.watch-trailer:disabled { cursor: default; } .trailer iframe { width: 100%; aspect-ratio: 16 / 9; border: 0; border-radius: var(--radius-card); display: block; } p.trailer-unavailable { margin: 0; text-align: center; font-size: var(--text-sm); font-weight: 300; color: var(--color-text); opacity: 0.82; } p.card-item-description { font-size: var(--text-sm); font-weight: 300; font-style: italic; margin: 0; } div.card-item-cast { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem; margin-top: auto; } h3.cast-loading { padding-bottom: 70%; } div.actor-card { width: 60px; display: flex; flex-direction: column; align-items: center; font-size: var(--text-xs); font-weight: 300; } img.actor-image { width: 60px; height: 60px; object-fit: cover; border-radius: var(--radius-round); border: 2px solid var(--color-accent); } p.actor-name { margin-bottom: 0; } button.undo-button { min-width: 120px; height: auto; } /* Control row under the deck: Nope | Undo | Like (issue #344). */ .swipe-controls { display: flex; align-items: center; justify-content: center; gap: 1rem; } p.card-item-instructions { margin-bottom: 0; margin-top: -.5rem; background: color-mix(in srgb, var(--color-navy-deep) 80%, transparent); color: var(--color-text); border-radius: var(--radius-badge); padding: 0.25rem 1rem; } div.swipe-footer { display: flex; align-items: center; justify-content: space-between; } div.modal-match-list { height: 85%; width: 85%; padding: 5px 5px 15px 5px; overflow-y: auto; color: var(--color-accent); } div.match-list-container { display: grid; grid-template-columns: 1fr; margin-top: -.75rem; margin-bottom: 1rem; gap: 1rem; } div.match-list-item { display: flex; padding: .5rem; width: auto; border: 1px solid var(--color-accent); padding: 0.5rem 0.75rem; border-radius: var(--radius-pill); align-items: center; } img.match-list-img { height: 175px; border-radius: var(--radius-badge); } div.match-list-info { display: flex; flex-direction: column; margin-left: 1rem; justify-content: center; align-items: center; gap: .25rem; } .match-list-title { font-size: var(--text-md); margin-bottom: .5rem; margin-right: auto; color: var(--color-text); } .match-list-score, .match-list-runtime, .match-list-year { background: color-mix(in srgb, var(--color-navy-deep) 67%, transparent); color: var(--color-accent); border-radius: var(--radius-badge); margin-right: auto; padding: 0.25rem .75rem; border: 1px solid var(--color-border); font-size: var(--text-xs); font-weight: 400; text-align: center; } .match-list-button { /* secondary role — styling lives on .btn-secondary; only layout stays here */ font-size: var(--text-sm); padding: 0.5rem 1rem; margin-top: .25rem; margin-right: auto; text-decoration: none; text-align: center; height: auto; } .match-list-empty { color: var(--color-text-bright); font-size: var(--text-md); font-weight: 600; text-align: center; margin: 1rem 0; } @media (max-width: 400px) { .match-list-title { font-size: var(--text-sm); } .match-list-button { padding: 0.5rem .75rem; } div.match-list-container { margin-bottom: 0; } } @media (min-width: 768px) { .app-logo { width: 60vw; } .jelly-button { font-size: var(--text-2xl); padding: 2rem; } .button-container { gap: 6rem; justify-content: center; } .modal { justify-content: center; padding-top: 0; } dialog.modal { padding-top: 0; } .room-code-input, .jelly-toggle { width: 40%; } /* Same (0,2,0) specificity as the modal-scoped base rule and declared later, so this overrides it at desktop. A bare `.btn-primary` selector here would lose to the descendant-scope base rule, so scope it to the modal too. */ .modal-inner .btn-primary { width: 40%; } .slider { width: 72px; height: 38px; } .slider::before { top: 4px; left: 4px; width: 26px; height: 26px; } .jelly-toggle input:checked + .slider::before { transform: translateX(34px); } div.mode-badge { display: revert; } .btn-primary, .btn-primary-link, .btn-secondary, .btn-destructive { padding: 0.5rem 1.25rem; } div.genre-inputs { grid-template-columns: 1fr 1fr 1fr; } div.match-list-container { grid-template-columns: repeat(2, 1fr); gap: 1rem; } div.modal-match-list { height: auto; max-height: 85%; width: 80%; padding: 1.5rem 0; } } /* Reduced motion (issue #343, coordinating with #353): the deck promote animation is a state-change transition, so per the #353 philosophy we keep it but shorten it rather than removing it. Scoped to the card container so direct-manipulation drag feedback is untouched. */ @media (prefers-reduced-motion: reduce) { /* The waiting loop runs indefinitely on a screen the user may sit on for minutes, so it must stop entirely under reduce (issue #353). */ .waiting-text { animation: none; } /* The card detail flip becomes a crossfade instead of a 3D rotateY (issue #353). Neutralize the preserve-3d / backface machinery and swap the faces via opacity. The inactive face is also hidden (visibility alongside opacity 0) so its trailer button/iframe are neither seen nor clickable when not flipped, while the active face stays fully usable. */ .card-item-inner { transform-style: flat; transition: none; } .card-item-container.flipped .card-item-inner { transform: none; } div.card-item { backface-visibility: visible; -webkit-backface-visibility: visible; transition: opacity 0.15s ease, visibility 0.15s ease; } div.back { transform: none; } .card-item-container:not(.flipped) div.card-item.back, .card-item-container.flipped div.card-item.front { opacity: 0; visibility: hidden; } /* Decorative hover/state transitions stay (they communicate state) but are shortened via targeted selectors only. NOT the blanket `*, *::before, *::after` pattern, which would clobber the swipe-stamp/rim 60ms verdict feedback and the inline drag rules (issue #353). */ .jelly-button, .btn-primary, .jelly-button--compact, .btn-secondary, .btn-destructive, .room-code-input, .checkmark, .jelly-check .checkmark::after, .slider, .slider::before, .custom-radio, div.poster-frame img.card-item-poster { transition-duration: 0.15s; } /* The old container-level duration is now redundant because SwipeDeck (issue #399) derives the rest/exit durations from a single prefers-reduced-motion read and threads them into each card as an inline transition, but it stays as a harmless fallback (issue #353). */ .card-item-container { transition-duration: 0.15s; } }