Skip to content

Instantly share code, notes, and snippets.

@link2004
Created September 6, 2026 01:22
Show Gist options
  • Select an option

  • Save link2004/fb96ef7f2a766b23f39a537a7bd74a33 to your computer and use it in GitHub Desktop.

Select an option

Save link2004/fb96ef7f2a766b23f39a537a7bd74a33 to your computer and use it in GitHub Desktop.
YouLet Spark real-UI loop demos (matches SparkFeatureView)
<!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