Created
September 6, 2026 01:22
-
-
Save link2004/fb96ef7f2a766b23f39a537a7bd74a33 to your computer and use it in GitHub Desktop.
YouLet Spark real-UI loop demos (matches SparkFeatureView)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="ja"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> | |
| <title>YouLet Spark — real UI demos</title> | |
| <style> | |
| :root { | |
| --ink: #11100e; | |
| --muted: #9a9aab; | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { | |
| margin: 0; padding: 0; | |
| background: #050507; | |
| color: #f4f4f7; | |
| font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Hiragino Sans", "Noto Sans JP", sans-serif; | |
| min-height: 100%; | |
| } | |
| header { | |
| max-width: 980px; margin: 0 auto; | |
| padding: 22px 18px 6px; | |
| } | |
| header h1 { margin: 0 0 6px; font-size: 18px; font-weight: 700; letter-spacing: -0.02em; } | |
| header p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; } | |
| .tabs { | |
| max-width: 980px; margin: 0 auto; | |
| display: flex; gap: 8px; overflow-x: auto; padding: 14px 18px; | |
| scrollbar-width: none; | |
| } | |
| .tabs::-webkit-scrollbar { display: none; } | |
| .tab { | |
| flex: 0 0 auto; border: 1px solid #2a2a35; background: #121218; | |
| color: #ddd; border-radius: 999px; padding: 9px 13px; font-size: 12px; | |
| cursor: pointer; white-space: nowrap; | |
| } | |
| .tab.active { background: #fff; color: #111; border-color: #fff; font-weight: 650; } | |
| .layout { | |
| max-width: 980px; margin: 0 auto; padding: 4px 18px 40px; | |
| display: grid; grid-template-columns: 390px 1fr; gap: 22px; align-items: start; | |
| } | |
| @media (max-width: 860px) { | |
| .layout { grid-template-columns: 1fr; justify-items: center; } | |
| .side { width: 100%; max-width: 390px; } | |
| } | |
| /* iPhone-ish frame matching 402x874 proportions */ | |
| .device { | |
| width: 360px; height: 740px; | |
| border-radius: 44px; | |
| background: #000; | |
| position: relative; | |
| overflow: hidden; | |
| box-shadow: 0 30px 80px rgba(0,0,0,.55); | |
| border: 1px solid #222; | |
| } | |
| .device .island { | |
| position: absolute; top: 11px; left: 50%; transform: translateX(-50%); | |
| width: 108px; height: 28px; background: #000; border-radius: 20px; z-index: 8; | |
| box-shadow: 0 0 0 1px #111; | |
| } | |
| /* Rainbow ScreenAuraBorder approximation */ | |
| .aura { | |
| pointer-events: none; | |
| position: absolute; inset: 0; | |
| border-radius: 44px; | |
| padding: 2.5px; | |
| background: conic-gradient(from var(--aura-angle, 0deg), | |
| #ff3a59, #ff9424, #fadb40, #5cdb80, #40c9ed, #6178fa, #b861f5, #ff3a59); | |
| -webkit-mask: | |
| linear-gradient(#000 0 0) content-box, | |
| linear-gradient(#000 0 0); | |
| -webkit-mask-composite: xor; | |
| mask-composite: exclude; | |
| filter: blur(0.2px); | |
| opacity: .9; | |
| animation: spinAura 12s linear infinite; | |
| z-index: 7; | |
| } | |
| .aura::before, .aura::after { | |
| content: ""; position: absolute; inset: -6px; border-radius: 50px; | |
| background: inherit; filter: blur(14px); opacity: .45; z-index: -1; | |
| } | |
| .aura::after { inset: -2px; filter: blur(4px); opacity: .7; } | |
| @keyframes spinAura { | |
| to { --aura-angle: 360deg; } | |
| } | |
| @property --aura-angle { | |
| syntax: '<angle>'; | |
| inherits: false; | |
| initial-value: 0deg; | |
| } | |
| .screen { | |
| position: absolute; inset: 0; | |
| background: #000; | |
| display: flex; flex-direction: column; | |
| z-index: 1; | |
| } | |
| /* floating header allowance like NavigationFeatureView */ | |
| .topbar { | |
| padding: 54px 18px 0; | |
| display: flex; justify-content: space-between; align-items: center; | |
| height: 66px; box-sizing: content-box; | |
| } | |
| .wordmark { | |
| font-size: 17px; font-weight: 800; letter-spacing: 0.04em; color: #fff; | |
| } | |
| .circles { display: flex; gap: 8px; } | |
| .circle-btn { | |
| width: 34px; height: 34px; border-radius: 50%; | |
| background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.12); | |
| display: grid; place-items: center; color: rgba(255,255,255,.7); font-size: 13px; | |
| } | |
| .main { | |
| flex: 1; display: flex; flex-direction: column; | |
| padding: 0 28px; | |
| min-height: 0; | |
| } | |
| .spacer { flex: 1 1 0; min-height: 8px; } | |
| .countdown { | |
| text-align: center; | |
| font-size: 13px; font-weight: 500; | |
| color: rgba(255,255,255,.58); | |
| font-variant-numeric: tabular-nums; | |
| margin-bottom: 14px; | |
| } | |
| .card { | |
| display: flex; flex-direction: column; align-items: center; | |
| text-align: center; | |
| } | |
| .stars { | |
| display: inline-flex; gap: 6px; align-items: center; | |
| padding: 10px 20px; margin-bottom: 22px; | |
| border-radius: 999px; background: rgba(255,255,255,.08); | |
| } | |
| .stars span { | |
| font-size: 15px; color: rgba(255,255,255,.3); line-height: 1; | |
| } | |
| .stars span.on { color: #fff; } | |
| .char-wrap { | |
| position: relative; | |
| width: min(100%, 260px); | |
| height: 260px; | |
| display: grid; place-items: center; | |
| } | |
| .char-glow { | |
| position: absolute; | |
| width: 72%; height: 72%; | |
| left: 14%; top: 18%; | |
| border-radius: 50%; | |
| background: radial-gradient(ellipse at center, rgba(255,255,255,.78), rgba(255,255,255,0) 70%); | |
| filter: blur(34px); | |
| transform: translateY(10px); | |
| pointer-events: none; | |
| } | |
| .char { | |
| position: relative; | |
| max-width: 100%; max-height: 260px; | |
| filter: | |
| drop-shadow(0 0 20px rgba(255,255,255,.55)) | |
| drop-shadow(0 12px 18px rgba(0,0,0,.18)); | |
| object-fit: contain; | |
| } | |
| .char.placeholder { | |
| width: 180px; height: 220px; | |
| border-radius: 40% 40% 35% 35%; | |
| background: linear-gradient(160deg, #5a4a6a, #1a1520); | |
| } | |
| .name-line { | |
| margin-top: 12px; | |
| font-size: 13px; font-weight: 600; | |
| letter-spacing: 1.2px; | |
| text-transform: uppercase; | |
| color: rgba(255,255,255,.5); | |
| display: flex; gap: 8px; align-items: center; justify-content: center; | |
| } | |
| .name-line .dot { opacity: .7; } | |
| .story { | |
| margin-top: 22px; | |
| color: #fff; | |
| font-weight: 600; | |
| line-height: 1.45; | |
| letter-spacing: -0.01em; | |
| max-width: 100%; | |
| } | |
| .post-marker { | |
| margin-top: 16px; | |
| display: inline-flex; gap: 6px; align-items: center; | |
| padding: 6px 10px; | |
| border-radius: 999px; | |
| background: rgba(255,255,255,.07); | |
| color: rgba(255,255,255,.55); | |
| font-size: 11px; font-weight: 600; | |
| cursor: pointer; border: 0; | |
| } | |
| .composer-wrap { | |
| padding: 0 18px 18px; | |
| padding-bottom: max(18px, env(safe-area-inset-bottom)); | |
| } | |
| .composer { | |
| min-height: 44px; | |
| display: flex; align-items: flex-end; gap: 4px; | |
| background: rgba(255,255,255,.08); | |
| border: 1px solid rgba(255,255,255,.12); | |
| border-radius: 999px; | |
| padding: 0 5px 0 0; | |
| } | |
| .composer input { | |
| flex: 1; border: 0; outline: 0; background: transparent; | |
| color: #fff; font-size: 16px; | |
| padding: 12px 0 12px 18px; | |
| min-width: 0; | |
| } | |
| .composer input::placeholder { color: rgba(255,255,255,.35); } | |
| .reactions { display: flex; } | |
| .reactions button { | |
| width: 44px; height: 44px; border: 0; background: transparent; | |
| font-size: 24px; cursor: pointer; line-height: 1; | |
| } | |
| .send { | |
| width: 44px; height: 44px; border: 0; background: transparent; | |
| display: grid; place-items: center; cursor: pointer; padding: 0; | |
| } | |
| .send i { | |
| width: 36px; height: 36px; border-radius: 50%; | |
| background: #fff; color: #000; | |
| display: grid; place-items: center; | |
| font-style: normal; font-size: 17px; font-weight: 700; | |
| } | |
| .send:disabled i { background: rgba(255,255,255,.2); color: transparent; } | |
| .sent { | |
| height: 44px; | |
| display: flex; align-items: center; justify-content: center; gap: 7px; | |
| border-radius: 999px; | |
| background: rgba(255,255,255,.06); | |
| border: 1px solid rgba(255,255,255,.1); | |
| color: rgba(255,255,255,.42); | |
| font-size: 14px; font-weight: 500; | |
| } | |
| .hidden { display: none !important; } | |
| .side { | |
| background: #121218; border: 1px solid #2a2a35; border-radius: 18px; padding: 16px; | |
| } | |
| .side h2 { margin: 0 0 6px; font-size: 16px; } | |
| .side .note { color: var(--muted); font-size: 13px; line-height: 1.5; margin-bottom: 12px; } | |
| .side .log { | |
| background: #0a0a10; border-radius: 12px; padding: 12px; | |
| min-height: 140px; font-size: 12px; color: #9a9aab; white-space: pre-wrap; line-height: 1.5; | |
| } | |
| .pill { | |
| display: inline-block; font-size: 11px; padding: 3px 8px; border-radius: 999px; | |
| background: #241820; color: #ff9db0; border: 1px solid #3a2430; margin-bottom: 8px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>YouLet Spark — 本物UIデモ</h1> | |
| <p>Swiftの <code>SparkFeatureView</code> / <code>SparkCardView</code> に合わせて再現。黒背景・虹オーラ・星・キャラ・大文字ネーム・下の返信カプセル(❤️🔥🧐)。タブは中身の仮説だけ変える。</p> | |
| </header> | |
| <div class="tabs" id="tabs"></div> | |
| <div class="layout"> | |
| <div class="device"> | |
| <div class="aura"></div> | |
| <div class="island"></div> | |
| <div class="screen"> | |
| <div class="topbar"> | |
| <div class="wordmark">YouLet</div> | |
| <div class="circles"> | |
| <div class="circle-btn">◎</div> | |
| <div class="circle-btn">☰</div> | |
| </div> | |
| </div> | |
| <div class="main"> | |
| <div class="spacer"></div> | |
| <div class="countdown" id="countdown">Next Spark in 2h 14m 08s</div> | |
| <div class="card" id="card"></div> | |
| <div class="spacer"></div> | |
| </div> | |
| <div class="composer-wrap"> | |
| <div class="composer" id="composer"> | |
| <input id="reply" type="text" autocomplete="off" /> | |
| <div class="reactions" id="reactions"> | |
| <button type="button" data-e="❤️" aria-label="heart">❤️</button> | |
| <button type="button" data-e="🔥" aria-label="fire">🔥</button> | |
| <button type="button" data-e="🧐" aria-label="thinking">🧐</button> | |
| </div> | |
| <button class="send hidden" id="send" type="button" aria-label="Send"><i>↑</i></button> | |
| </div> | |
| <div class="sent hidden" id="sent">✓ Sent</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="side"> | |
| <div id="meta"></div> | |
| <div class="log" id="log">操作するとここに流れます</div> | |
| </div> | |
| </div> | |
| <script> | |
| const CHAR = { | |
| wataru: 'https://youlet-character-images.s3.us-west-1.amazonaws.com/characters/310d8ad9-e71a-4552-acf5-860332e691d5/legacy-20260208-015142-8704152d72b06d1faf46/card.webp', | |
| hinata: 'https://youlet-character-images.s3.us-west-1.amazonaws.com/characters/a65462e9-f241-4e87-9578-7017674a456f/legacy-20260411-103312-6d7077af739bc6d560bf/card.webp', | |
| nana: 'https://youlet-character-images.s3.us-west-1.amazonaws.com/characters/9a6dbcbc-2952-4cbd-8cc7-cfee6461031a/e645ff98c3974ff5b16e89314076453f/card.webp', | |
| rosie: 'https://youlet-character-images.s3.us-west-1.amazonaws.com/characters/c203015b-147d-4acf-9c0c-104f4a3ca2e6/legacy-20260518-184738-f81678b9e22cdb346754/card.webp', | |
| }; | |
| const demos = [ | |
| { | |
| id: 'current', | |
| tab: 'いまのSpark', | |
| pill: '現行UIそのまま', | |
| note: '本番と同じシェル。返信は DM への Message / クイックリアクション。', | |
| item: { | |
| stars: 3, | |
| subject: 'Hinata', | |
| char: CHAR.hinata, | |
| elapsed: '2 hours ago', | |
| text: 'Anna、HinataがMountain Viewで友達と夕焼けを歩いてたよ。いい空気感。', | |
| hasPost: true, | |
| } | |
| }, | |
| { | |
| id: 'react', | |
| tab: 'リアクション完了感', | |
| pill: '変種1 · 中身だけ', | |
| note: 'UIは同じ。文を「返す義務」ではなく「見るだけでいい」トーンに寄せた版。', | |
| item: { | |
| stars: 2, | |
| subject: 'Wataru', | |
| char: CHAR.wataru, | |
| elapsed: '1 hour ago', | |
| text: 'Tomato、Wataruがバークリーで友達と床に転がってリラックスしてたよ。たまにはそういうダラダラする時間も必要だよな。', | |
| hasPost: true, | |
| } | |
| }, | |
| { | |
| id: 'shared', | |
| tab: '共通の話題', | |
| pill: '変種2 · 文言だけ', | |
| note: 'UIは同じ。二人の共通点を一文にした版。返信UIは本番どおり。', | |
| item: { | |
| stars: 3, | |
| subject: 'Rosie', | |
| char: CHAR.rosie, | |
| elapsed: '4 hours ago', | |
| text: 'Anna、Rosieもあなたも最近「外でゆっくり」してる。SFのピクニック、今ほしい空気どれ?', | |
| hasPost: false, | |
| } | |
| }, | |
| { | |
| id: 'now', | |
| tab: '受信者の今', | |
| pill: '変種3 · 文言だけ', | |
| note: 'UIは同じ。受信者の今を一文に織り込んだ版。', | |
| item: { | |
| stars: 3, | |
| subject: 'なな', | |
| char: CHAR.nana, | |
| elapsed: '3 hours ago', | |
| text: 'Wataru、いま開いてるよね。ななが横浜でちいかわの映画観てたみたい。初めての場所でグッズも手に入れて満喫してるね。', | |
| hasPost: false, | |
| } | |
| }, | |
| { | |
| id: 'question', | |
| tab: '質問で終わる', | |
| pill: '変種4 · 文言だけ', | |
| note: 'UIは同じ。最後を答えやすい問いにした版。撮影CTAは足していない。', | |
| item: { | |
| stars: 4, | |
| subject: 'Hinata', | |
| char: CHAR.hinata, | |
| elapsed: '50 minutes ago', | |
| text: 'Anna、Hinataが夕焼け歩いてた。あなたなら今、誰と歩きたい?', | |
| hasPost: false, | |
| } | |
| } | |
| ]; | |
| function headlineSize(text) { | |
| const n = [...text].length; | |
| if (n < 30) return 24; | |
| if (n < 60) return 21; | |
| if (n < 110) return 18; | |
| if (n < 170) return 16; | |
| return 15; | |
| } | |
| const card = document.getElementById('card'); | |
| const reply = document.getElementById('reply'); | |
| const reactions = document.getElementById('reactions'); | |
| const send = document.getElementById('send'); | |
| const sent = document.getElementById('sent'); | |
| const composer = document.getElementById('composer'); | |
| const logEl = document.getElementById('log'); | |
| const meta = document.getElementById('meta'); | |
| let current = demos[0]; | |
| let replied = false; | |
| function log(msg) { | |
| const t = new Date().toLocaleTimeString('ja-JP', { hour12: false }); | |
| const prev = logEl.textContent === '操作するとここに流れます' ? '' : logEl.textContent + '\n'; | |
| logEl.textContent = prev + `${t} ${msg}`; | |
| } | |
| function renderCard(item) { | |
| const stars = Array.from({length:5}, (_,i) => | |
| `<span class="${i < item.stars ? 'on' : ''}">${i < item.stars ? '★' : '☆'}</span>` | |
| ).join(''); | |
| card.innerHTML = ` | |
| <div class="stars" aria-label="${item.stars} out of 5 stars">${stars}</div> | |
| <div class="char-wrap"> | |
| <div class="char-glow"></div> | |
| ${item.char | |
| ? `<img class="char" alt="" src="${item.char}" />` | |
| : `<div class="char placeholder"></div>`} | |
| </div> | |
| <div class="name-line"> | |
| <span>${item.subject}</span> | |
| <span class="dot">·</span> | |
| <span style="letter-spacing:0;text-transform:none">${item.elapsed}</span> | |
| </div> | |
| <div class="story" style="font-size:${headlineSize(item.text)}px;line-height:1.5">${item.text}</div> | |
| ${item.hasPost ? `<button class="post-marker" id="postMarker" type="button">🖼 YouLet post</button>` : ''} | |
| `; | |
| const pm = document.getElementById('postMarker'); | |
| if (pm) pm.onclick = () => log('YouLet post マーカーをタップ(マップへ行く想定)'); | |
| } | |
| function setDemo(id) { | |
| current = demos.find(d => d.id === id) || demos[0]; | |
| document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.id === current.id)); | |
| meta.innerHTML = `<div class="pill">${current.pill}</div><h2>${current.tab}</h2><div class="note">${current.note}</div>`; | |
| renderCard(current.item); | |
| reply.value = ''; | |
| reply.placeholder = `Message ${current.item.subject}`; | |
| replied = false; | |
| composer.classList.remove('hidden'); | |
| sent.classList.add('hidden'); | |
| syncComposer(); | |
| log(`— ${current.tab} を表示`); | |
| } | |
| function syncComposer() { | |
| const has = reply.value.trim().length > 0; | |
| reactions.classList.toggle('hidden', has); | |
| send.classList.toggle('hidden', !has); | |
| } | |
| function doSend(kind, payload) { | |
| if (replied) return; | |
| replied = true; | |
| composer.classList.add('hidden'); | |
| sent.classList.remove('hidden'); | |
| log(kind === 'emoji' ? `クイックリアクション ${payload} → Sent` : `Message 送信「${payload}」→ Sent`); | |
| } | |
| reply.addEventListener('input', syncComposer); | |
| send.addEventListener('click', () => { | |
| const v = reply.value.trim(); | |
| if (!v) return; | |
| doSend('text', v); | |
| }); | |
| reply.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') { e.preventDefault(); send.click(); } | |
| }); | |
| reactions.querySelectorAll('button').forEach(btn => { | |
| btn.addEventListener('click', () => doSend('emoji', btn.dataset.e)); | |
| }); | |
| // countdown tick for realism | |
| let remain = 2*3600 + 14*60 + 8; | |
| setInterval(() => { | |
| remain = Math.max(0, remain - 1); | |
| const h = Math.floor(remain / 3600); | |
| const m = Math.floor((remain % 3600) / 60); | |
| const s = remain % 60; | |
| document.getElementById('countdown').textContent = | |
| `Next Spark in ${h}h ${String(m).padStart(2,'0')}m ${String(s).padStart(2,'0')}s`; | |
| }, 1000); | |
| const tabs = document.getElementById('tabs'); | |
| demos.forEach(d => { | |
| const b = document.createElement('button'); | |
| b.className = 'tab'; | |
| b.textContent = d.tab; | |
| b.dataset.id = d.id; | |
| b.onclick = () => setDemo(d.id); | |
| tabs.appendChild(b); | |
| }); | |
| setDemo('current'); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment