Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save link2004/e21645cdb70ea79bc0f7f00078e364da to your computer and use it in GitHub Desktop.
YouLet Spark loop interactive demos
<!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 Loop Demos</title>
<style>
:root {
--bg: #0b0b0f;
--card: #16161d;
--line: #2a2a35;
--text: #f4f4f7;
--muted: #9a9aab;
--accent: #ff5a7a;
--accent2: #7c6cff;
--good: #3dd68c;
--warn: #ffb020;
--phone: #111118;
--safe: #1c1c24;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Noto Sans JP", sans-serif; }
body { min-height: 100vh; }
header {
padding: 28px 20px 8px;
max-width: 1100px;
margin: 0 auto;
}
header h1 { font-size: 22px; margin: 0 0 6px; letter-spacing: -0.02em; }
header p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
.tabs {
display: flex; gap: 8px; overflow-x: auto; padding: 16px 20px 8px;
max-width: 1100px; margin: 0 auto; scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }
.tab {
flex: 0 0 auto; border: 1px solid var(--line); background: var(--card);
color: var(--text); border-radius: 999px; padding: 10px 14px; font-size: 13px;
cursor: pointer; white-space: nowrap;
}
.tab.active { background: linear-gradient(135deg, var(--accent), var(--accent2)); border-color: transparent; }
.layout {
max-width: 1100px; margin: 0 auto; padding: 8px 20px 40px;
display: grid; grid-template-columns: 360px 1fr; gap: 24px; align-items: start;
}
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
.phone-wrap { justify-content: center; }
}
.phone-wrap { display: flex; justify-content: flex-start; }
.phone {
width: 340px; height: 700px; background: var(--phone);
border: 1px solid var(--line); border-radius: 36px; overflow: hidden;
position: relative; box-shadow: 0 20px 60px rgba(0,0,0,.45);
}
.notch {
position: absolute; top: 10px; left: 50%; transform: translateX(-50%);
width: 110px; height: 24px; background: #000; border-radius: 20px; z-index: 5;
}
.screen { height: 100%; display: flex; flex-direction: column; background: #0e0e14; }
.status {
padding: 42px 18px 10px; display: flex; justify-content: space-between; align-items: center;
font-size: 12px; color: var(--muted);
}
.status strong { color: var(--text); font-size: 14px; }
.content { flex: 1; overflow: auto; padding: 8px 16px 16px; }
.spark-card {
background: linear-gradient(160deg, #1a1a24, #12121a);
border: 1px solid var(--line); border-radius: 20px; padding: 16px; margin-bottom: 12px;
}
.spark-card .label {
display: inline-flex; align-items: center; gap: 6px;
font-size: 11px; color: var(--accent); letter-spacing: .08em; text-transform: uppercase; margin-bottom: 10px;
}
.spark-card .who { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; }
.avatar {
width: 40px; height: 40px; border-radius: 14px;
background: linear-gradient(135deg, #ff7a93, #7c6cff); display:grid; place-items:center; font-weight:700; font-size:14px;
}
.avatar.b { background: linear-gradient(135deg, #56d4ff, #7c6cff); }
.spark-card h3 { margin: 0; font-size: 15px; }
.spark-card .meta { font-size: 12px; color: var(--muted); }
.spark-text { font-size: 15px; line-height: 1.55; margin: 0 0 14px; }
.photo {
height: 160px; border-radius: 16px; margin-bottom: 12px;
background: #222; position: relative; overflow: hidden;
display:flex; align-items:flex-end; padding: 12px; color: white; font-size: 12px;
}
.photo.p1 { background: linear-gradient(160deg,#3a2a1a,#1a120c), url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="300" height="160"><rect fill="%233a2a1a" width="300" height="160"/><circle cx="220" cy="50" r="28" fill="%23ffb84d"/><rect x="0" y="100" width="300" height="60" fill="%23221810"/></svg>'); background-size: cover; }
.photo.p2 { background: linear-gradient(160deg,#1a2a3a,#0c121a); }
.photo.p3 { background: linear-gradient(160deg,#2a1a3a,#120c1a); }
.photo.grid { display:grid; grid-template-columns:1fr 1fr; gap:6px; height:140px; padding:6px; align-items:stretch; }
.photo.grid > div { border-radius:12px; display:flex; align-items:flex-end; padding:8px; font-size:11px; }
.chip-row { display:flex; flex-wrap:wrap; gap:8px; margin-bottom: 12px; }
.chip {
border: 1px solid var(--line); background: var(--safe); color: var(--text);
border-radius: 999px; padding: 8px 12px; font-size: 13px; cursor: pointer;
}
.chip:active, .chip.selected { border-color: var(--accent); background: #2a1520; }
.cta {
width: 100%; border: 0; border-radius: 14px; padding: 14px 16px; font-size: 15px; font-weight: 650;
cursor: pointer; background: linear-gradient(135deg, var(--accent), #ff7d55); color: white;
}
.cta.secondary { background: var(--safe); border: 1px solid var(--line); color: var(--text); margin-top: 8px; }
.cta:disabled { opacity: .45; cursor: default; }
.composer {
display: none; gap: 8px; margin-top: 8px;
}
.composer.show { display: flex; }
.composer input {
flex: 1; border-radius: 12px; border: 1px solid var(--line); background: #0a0a10;
color: var(--text); padding: 12px; font-size: 14px;
}
.toast {
position: absolute; left: 16px; right: 16px; bottom: 24px;
background: rgba(20,20,28,.96); border: 1px solid var(--line); border-radius: 14px;
padding: 12px 14px; font-size: 13px; display:none; z-index: 6;
}
.toast.show { display:block; animation: up .25s ease; }
@keyframes up { from { transform: translateY(8px); opacity: 0;} to { transform:none; opacity:1;} }
.panel {
background: var(--card); border: 1px solid var(--line); border-radius: 20px; padding: 18px;
}
.panel h2 { margin: 0 0 8px; font-size: 18px; }
.panel .hyp { color: var(--muted); font-size: 13px; line-height: 1.55; margin-bottom: 14px; }
.metric {
display:grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 14px;
}
.metric div {
background: var(--safe); border-radius: 12px; padding: 10px; text-align:center;
}
.metric b { display:block; font-size: 18px; }
.metric span { font-size: 11px; color: var(--muted); }
.log {
background: #0a0a10; border-radius: 12px; padding: 12px; min-height: 120px;
font-size: 13px; color: var(--muted); line-height: 1.5; white-space: pre-wrap;
}
.tag {
display:inline-block; font-size:11px; padding: 3px 8px; border-radius: 999px; margin-right:6px;
background:#241820; color:#ff9db0; border:1px solid #3a2430;
}
.tag.alt { background:#181828; color:#b7aeff; border-color:#2c2a44; }
.steps { display:flex; flex-direction:column; gap:8px; margin: 12px 0 0; }
.step {
display:flex; gap:10px; align-items:flex-start; font-size:13px; color: var(--muted);
}
.step i {
width: 20px; height: 20px; border-radius: 50%; background: #222; display:grid; place-items:center;
font-style: normal; font-size: 11px; color: var(--text); flex: 0 0 auto;
}
.step.done { color: var(--good); }
.step.done i { background: #163526; }
.hidden { display:none !important; }
.shutter {
width: 64px; height: 64px; border-radius: 50%; border: 4px solid #fff; background: #ff5a7a;
margin: 18px auto 0; display:block; cursor: pointer;
}
.cam-ui { text-align:center; padding-top: 20px; }
.cam-ui .viewfinder {
height: 280px; border-radius: 18px; background: linear-gradient(160deg,#2a2a20,#10100c);
margin-bottom: 8px; display:grid; place-items:center; color: var(--muted); font-size: 13px;
}
</style>
</head>
<body>
<header>
<h1>YouLet Spark — ループ案デモ</h1>
<p>仮説切り分け用。触ると右にイベントが流れる。「写真返信が本命か/物語が薄いか」を体感で比較する。</p>
</header>
<div class="tabs" id="tabs"></div>
<div class="layout">
<div class="phone-wrap">
<div class="phone">
<div class="notch"></div>
<div class="screen" id="screen"></div>
<div class="toast" id="toast"></div>
</div>
</div>
<div class="panel">
<div id="meta"></div>
<div class="metric" id="metrics">
<div><b id="m-open">0</b><span>開封</span></div>
<div><b id="m-act">0</b><span>行動</span></div>
<div><b id="m-loop">0</b><span>ループ完走</span></div>
</div>
<div class="log" id="log">操作するとここに流れます</div>
</div>
</div>
<script>
const demos = [
{
id: 'current',
tab: 'いまのループ',
title: '友達の瞬間 → 写真で返す',
tag: '現行',
tagClass: '',
hyp: '仮説: 開封後の摩擦が高い。カメラを近づければ返信率が上がる。',
measure: '開封 → 写真投稿',
render(root, api) {
root.innerHTML = `
<div class="status"><strong>Spark</strong><span>いま</span></div>
<div class="content">
<div class="spark-card" id="card">
<div class="label">✦ Spark</div>
<div class="who">
<div class="avatar">H</div>
<div><h3>Hinata</h3><div class="meta">Mountain View · 夕方</div></div>
</div>
<div class="photo p1">夕焼けの散歩</div>
<p class="spark-text">Anna、HinataがMountain Viewで友達と夕焼けを歩いてたよ。いい空気感。</p>
<button class="cta" id="open">開く</button>
<button class="cta secondary hidden" id="reply">写真を撮って返す</button>
</div>
<div class="cam-ui hidden" id="cam">
<div class="viewfinder">裏カメラ · 今の景色を1枚</div>
<button class="shutter" id="shot" title="撮影"></button>
<button class="cta secondary" id="cancel">やめる</button>
</div>
</div>`;
const open = root.querySelector('#open');
const reply = root.querySelector('#reply');
const cam = root.querySelector('#cam');
const card = root.querySelector('#card');
open.onclick = () => {
api.open();
open.classList.add('hidden');
reply.classList.remove('hidden');
api.log('開封した(中身を読んだ)');
};
reply.onclick = () => {
card.classList.add('hidden');
cam.classList.remove('hidden');
api.log('カメラ導線へ');
};
root.querySelector('#cancel').onclick = () => {
cam.classList.add('hidden');
card.classList.remove('hidden');
api.log('撮影をやめた → 摩擦で離脱');
};
root.querySelector('#shot').onclick = () => {
api.act('photo');
api.loop();
api.toast('写真を送った。相手に届く想定');
api.log('写真返信 → ループ完走(相手還流は未確認)');
cam.classList.add('hidden');
card.classList.remove('hidden');
reply.classList.add('hidden');
open.classList.remove('hidden');
open.textContent = 'もう一度試す';
};
}
},
{
id: 'react',
tab: 'リアクション完了',
title: '知らせるだけ → 一言/リアクション',
tag: '変種1 · はな型',
tagClass: 'alt',
hyp: '仮説H文脈/ループ: 写真必須が間違い。反応できればループは回る。',
measure: '開封 → リアクション or 一言',
render(root, api) {
root.innerHTML = `
<div class="status"><strong>Spark</strong><span>リアクションで完了</span></div>
<div class="content">
<div class="spark-card">
<div class="label">✦ Spark</div>
<div class="who"><div class="avatar">H</div><div><h3>Hinata</h3><div class="meta">Mountain View</div></div></div>
<div class="photo p1">夕焼けの散歩</div>
<p class="spark-text">Anna、Hinataが夕焼け歩いてたよ。</p>
<button class="cta" id="open">開く</button>
<div id="after" class="hidden">
<div class="chip-row">
<button class="chip" data-r="わかる">わかる</button>
<button class="chip" data-r="いいな">いいな</button>
<button class="chip" data-r="写真見たい">写真見たい</button>
</div>
<div class="composer" id="comp">
<input id="msg" placeholder="一言で返す(任意)" />
<button class="cta" id="send" style="width:auto;padding:12px 14px">送る</button>
</div>
<button class="cta secondary" id="showComp">一言を書く</button>
</div>
</div>
</div>`;
root.querySelector('#open').onclick = (e) => {
api.open();
e.target.classList.add('hidden');
root.querySelector('#after').classList.remove('hidden');
api.log('開封');
};
root.querySelectorAll('.chip').forEach(btn => {
btn.onclick = () => {
api.act('react:' + btn.dataset.r);
api.loop();
api.toast(`「${btn.dataset.r}」で完了。写真なし`);
api.log('リアクション完了 → 写真必須を外したループ');
};
});
root.querySelector('#showComp').onclick = () => {
root.querySelector('#comp').classList.add('show');
api.log('一言入力を開いた');
};
root.querySelector('#send').onclick = () => {
const v = root.querySelector('#msg').value.trim() || '(空の一言)';
api.act('one-liner');
api.loop();
api.toast('一言を送った');
api.log('一言返信: ' + v);
};
}
},
{
id: 'shared',
tab: '共通の話題',
title: '二人の写真から一点を作る',
tag: '変種2',
tagClass: 'alt',
hyp: '仮説H文脈: 片方向の「友達がXしてた」より、二人に共通する種の方が会話が始まる。',
measure: '開封 → 会話シードへの反応',
render(root, api) {
root.innerHTML = `
<div class="status"><strong>Spark</strong><span>共通点</span></div>
<div class="content">
<div class="spark-card">
<div class="label">✦ 二人のあいだ</div>
<div class="photo grid">
<div class="p1">Anna · ピクニック</div>
<div class="p2" style="background:linear-gradient(160deg,#1a2a3a,#0c121a)">Hinata · 夕焼け</div>
</div>
<p class="spark-text">今週、二人とも「外でゆっくりする時間」撮ってる。AnnaのピクニックとHinataの散歩、どっちの空気が好き?</p>
<button class="cta" id="open">開く</button>
<div id="after" class="hidden">
<div class="chip-row">
<button class="chip" data-r="picnic">ピクニック派</button>
<button class="chip" data-r="walk">夕焼け散歩派</button>
<button class="chip" data-r="both">どっちも欲しい</button>
</div>
<button class="cta secondary" id="ask">Hinataに聞く</button>
</div>
</div>
</div>`;
root.querySelector('#open').onclick = (e) => {
api.open(); e.target.classList.add('hidden');
root.querySelector('#after').classList.remove('hidden');
api.log('共通話題Sparkを開封');
};
root.querySelectorAll('.chip').forEach(btn => btn.onclick = () => {
api.act('pick:' + btn.dataset.r);
api.loop();
api.toast('好みを残した。次は相手の画面に種が渡る');
api.log('共通話題に投票 → 会話シード');
});
root.querySelector('#ask').onclick = () => {
api.act('ask-friend');
api.loop();
api.toast('Hinataに質問を投げた');
api.log('相手への問いとして還流開始');
};
}
},
{
id: 'now',
tab: '受信者の今',
title: '相手の話 × あなたの今',
tag: '変種3 · 文脈増強',
tagClass: 'alt',
hyp: '仮説H文脈: 写真の情報次元不足を、受信者側の今(場所・直近投稿・開いてる事実)で補う。',
measure: '開封 → 自分の今に紐づく行動',
render(root, api) {
root.innerHTML = `
<div class="status"><strong>Spark</strong><span>あなたの今</span></div>
<div class="content">
<div class="spark-card">
<div class="label">✦ For you right now</div>
<div class="who"><div class="avatar b">R</div><div><h3>Rosie</h3><div class="meta">SF · ピクニック</div></div></div>
<div class="photo p3" style="background:linear-gradient(160deg,#3a2a40,#141018)">餃子とクレームブリュレ</div>
<p class="spark-text">Anna、いまアプリ開いてるよね。RosieがSFでピクニックしてた。あなたも直近「編集作業の写真」上げてたから、外の空気とどっちが今ほしい?</p>
<div class="chip-row" style="margin-top:-4px">
<span class="chip" style="cursor:default;opacity:.8">コンテキスト: 開いている</span>
<span class="chip" style="cursor:default;opacity:.8">直近: 編集作業</span>
</div>
<button class="cta" id="open">開く</button>
<div id="after" class="hidden">
<button class="cta" id="out">外の写真を1枚足す</button>
<button class="cta secondary" id="desk">作業続きを共有</button>
<button class="cta secondary" id="skip">今は見だけ</button>
</div>
</div>
</div>`;
root.querySelector('#open').onclick = (e) => {
api.open(); e.target.classList.add('hidden');
root.querySelector('#after').classList.remove('hidden');
api.log('受信者コンテキスト付きSparkを開封');
};
root.querySelector('#out').onclick = () => {
api.act('add-outside'); api.loop();
api.toast('自分の今を足した');
api.log('受信者の今に紐づけて投稿');
};
root.querySelector('#desk').onclick = () => {
api.act('share-desk'); api.loop();
api.toast('作業側の今を共有');
api.log('対比で自分の文脈を返した');
};
root.querySelector('#skip').onclick = () => {
api.log('見るだけで終了 → 文脈があっても行動しないケース');
api.toast('未行動で閉じた');
};
}
},
{
id: 'question',
tab: '質問で終わる',
title: '撮る → 答える に変える',
tag: '変種4',
tagClass: 'alt',
hyp: '仮説ループ変更: 行動コストを「撮影」から「答えやすい一文」に落とす。',
measure: '開封 → 回答',
render(root, api) {
root.innerHTML = `
<div class="status"><strong>Spark</strong><span>質問</span></div>
<div class="content">
<div class="spark-card">
<div class="label">✦ 聞いてみた</div>
<div class="who"><div class="avatar">H</div><div><h3>Hinata</h3><div class="meta">夕焼け</div></div></div>
<div class="photo p1">夕焼けの散歩</div>
<p class="spark-text">Anna、Hinataが夕焼け歩いてた。あなたなら今、誰と歩きたい?</p>
<button class="cta" id="open">開く</button>
<div id="after" class="hidden">
<div class="chip-row">
<button class="chip" data-a="ひとりで">ひとりで</button>
<button class="chip" data-a="Hinataと">Hinataと</button>
<button class="chip" data-a="Rosieと">Rosieと</button>
</div>
<div class="composer show">
<input id="msg" placeholder="自由に一言" />
<button class="cta" id="send" style="width:auto;padding:12px 14px">答える</button>
</div>
</div>
</div>
</div>`;
root.querySelector('#open').onclick = (e) => {
api.open(); e.target.classList.add('hidden');
root.querySelector('#after').classList.remove('hidden');
api.log('質問Sparkを開封');
};
const answer = (text) => {
api.act('answer'); api.loop();
api.toast('回答した(撮影なし)');
api.log('回答: ' + text + ' → 低コスト完了');
};
root.querySelectorAll('.chip').forEach(btn => btn.onclick = () => answer(btn.dataset.a));
root.querySelector('#send').onclick = () => answer(root.querySelector('#msg').value.trim() || '(自由記述)');
}
}
];
const state = { open: 0, act: 0, loop: 0 };
const logEl = document.getElementById('log');
const toastEl = document.getElementById('toast');
let toastTimer;
function apiFactory() {
return {
open() { state.open++; syncMetrics(); },
act() { state.act++; syncMetrics(); },
loop() { state.loop++; syncMetrics(); },
log(msg) {
const t = new Date().toLocaleTimeString('ja-JP', { hour12: false });
const prev = logEl.textContent === '操作するとここに流れます' ? '' : logEl.textContent + '\n';
logEl.textContent = prev + `${t} ${msg}`;
logEl.scrollTop = logEl.scrollHeight;
},
toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 2200);
}
};
}
function syncMetrics() {
document.getElementById('m-open').textContent = state.open;
document.getElementById('m-act').textContent = state.act;
document.getElementById('m-loop').textContent = state.loop;
}
function setDemo(id) {
const demo = demos.find(d => d.id === id) || demos[0];
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.id === demo.id));
document.getElementById('meta').innerHTML = `
<span class="tag ${demo.tagClass}">${demo.tag}</span>
<h2>${demo.title}</h2>
<div class="hyp">${demo.hyp}<br><b style="color:var(--text)">見る指標:</b> ${demo.measure}</div>
<div class="steps">
<div class="step"><i>1</i><div>スマホ枠で一連の操作をする</div></div>
<div class="step"><i>2</i><div>「行動した気になるか/返したくなるか」を体感で見る</div></div>
<div class="step"><i>3</i><div>右のカウンタはデモ用。本番は開封→行動→還流で取る</div></div>
</div>`;
const screen = document.getElementById('screen');
screen.innerHTML = '';
demo.render(screen, apiFactory());
}
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