PageSourceSearch

https://doyoungpark.netlify.app/

html doyoungpark.netlify.app collected 2026-10-03 10:39:49 UTC 163,895 bytes, 2,298 lines download raw bytes

1<!doctype html>
2<html lang="en">
3<head>
4<meta charset="UTF-8">
5<meta name="viewport" content="width=device-width, initial-scale=1.0">
6<title>Doyoung Park — Portfolio 2026</title>
7<link rel="icon" href="./favicon.webp">
8<link rel="preconnect" href="https://fonts.googleapis.com">
9<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700;800&family=Noto+Sans+KR:wght@400;500;600;700;800&display=swap" rel="stylesheet">
11<style>
12/* =========================================================
13   TOKENS
14   ========================================================= */
15:root{
16  --paper:#ffffff;
17  --ink:#000000;
18  --grey:#7d7d7d;
19  --rule:#0f0f0f;
20  --blue:#1400ff;
21  --lavender:#ecebff;
22  --font:'Inter Tight','Noto Sans KR','Helvetica Neue',Helvetica,Arial,sans-serif;
23
24  --gutter:clamp(28px,4.2vw,64px);
25  --top:clamp(28px,5.5vh,64px);
26
27  --ease-out:cubic-bezier(.16,1,.3,1);
28  --ease-in-out:cubic-bezier(.83,0,.17,1);
29  --dur:1.05s;
30}
31*{box-sizing:border-box}
32html,body{margin:0;height:100%;background:var(--paper);color:var(--ink);font-family:var(--font);-webkit-font-smoothing:antialiased;overflow:hidden}
33/* 1.21 is Inter Tight's own "normal"; pinning it means lines with Korean
34   (Noto Sans KR, taller default) keep the same height, so nothing shifts
35   vertically when the language toggle switches to KO */
36body{line-height:1.21}
37a{color:inherit}
38button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}
39::selection{background:var(--blue);color:#fff}
40
41/* =========================================================
42   TYPE SCALE
43   Display type is the design. Tight tracking, tight leading.
44   ========================================================= */
45.h-display{font-weight:700;letter-spacing:-.055em;line-height:.86;font-size:clamp(64px,8.6vw,150px);margin:0}
46.h-huge{font-weight:700;letter-spacing:-.05em;line-height:.9;font-size:clamp(56px,7.4vw,124px);margin:0}
47.h-chapter{font-weight:700;letter-spacing:-.055em;line-height:.86;font-size:clamp(88px,12.5vw,230px);margin:0}
48.h-stat{font-weight:700;letter-spacing:-.06em;line-height:.85;font-size:clamp(72px,9.5vw,170px);margin:0;color:var(--blue)}
49.h-caps{font-weight:700;letter-spacing:-.045em;line-height:.9;font-size:clamp(40px,5.2vw,88px);text-transform:uppercase;color:var(--blue);margin:0}
50/* Hangul syllable blocks sit much taller in their own em-box than Latin
51   letters, so the sub-1 line-height tuned for Inter Tight makes the case
52   page's claim heading overlap once its Korean translation wraps to more
53   than one line. Scoped to .casepage.ko (set only when a case is actually
54   showing Korean text) rather than :lang(ko) on <html> — that matched every
55   .h-huge/.h-display on the page, including ones that never translate, like
56   the static "Data Analysis." heading, and pushed their layout around too. */
57.casepage.ko .h-display,.planpage.ko .phead h1{line-height:1.2;word-break:keep-all}
58.body{font-weight:400;font-size:clamp(15px,1.15vw,19px);line-height:1.45;letter-spacing:-.005em;max-width:56ch;text-align:justify;-webkit-hyphens:none;hyphens:none}
59.body p:last-child,.body p:only-child{text-align-last:left}
60.body p{margin:0 0 1.2em}
61.body p:last-child{margin-bottom:0}
62.small{font-size:clamp(13px,.95vw,16px);line-height:1.4;letter-spacing:-.005em}
63.grey{color:var(--grey)}
64.b{font-weight:600}
65
66/* =========================================================
67   DECK (scroll snap)
68   ========================================================= */
69.deck{height:100vh;height:100dvh;overflow-y:auto;scroll-snap-type:y mandatory;scroll-behavior:smooth;scrollbar-width:none}
70.deck::-webkit-scrollbar{display:none}
71.slide{position:relative;height:100vh;height:100dvh;scroll-snap-align:start;scroll-snap-stop:always;padding:var(--top) var(--gutter);display:flex;flex-direction:column;overflow:hidden}
72.slide.dark{background:var(--ink);color:var(--paper)}
73.slide.lav{background:var(--lavender);color:var(--ink)}
74
75/* fixed chrome: running head + counter. Blend so it survives dark slides */
76.chrome{position:fixed;inset:0;pointer-events:none;z-index:50;mix-blend-mode:difference;color:#fff}
77.chrome .head{position:absolute;top:var(--top);left:var(--gutter);right:var(--gutter);display:flex;justify-content:space-between;font-size:clamp(13px,1vw,17px);letter-spacing:-.01em;transform:translateY(-6px)}
78.chrome .counter{position:absolute;
78left:50%;transform:translateX(-50%);bottom:calc(var(--top)*.55);font-size:clamp(12px,.9vw,15px);font-variant-numeric:tabular-nums;letter-spacing:-.01em;display:flex;align-items:center;gap:14px}
79.chrome .bar{width:72px;height:1px;background:rgba(255,255,255,.35);position:relative;pointer-events:auto;cursor:pointer}
80.chrome .bar::before{content:"";position:absolute;inset:-10px 0}
81.chrome .bar i{position:absolute;inset:0;background:#fff;transform-origin:left;transform:scaleX(0);transition:transform .6s var(--ease-out)}
82.chrome.hide-head .head{opacity:0}
83
84/* =========================================================
85   REVEAL PRIMITIVES
86   .mask > .line : text rises out of a clipped box
87   .kw : blur-in with stagger
88   ========================================================= */
89.mask{display:block;overflow:hidden;padding:.22em 0;margin:-.22em 0}
90.mask>.line{display:block;transform:translateY(135%);transition:transform var(--dur) var(--ease-out);transition-delay:calc(var(--i,0)*90ms)}
91.in .mask>.line{transform:translateY(0)}
92
93.fade{opacity:0;transform:translateY(14px);transition:opacity .9s var(--ease-out),transform .9s var(--ease-out);transition-delay:calc(var(--i,0)*80ms + .35s)}
94.in .fade{opacity:1;transform:none}
95
96.kw{opacity:0;filter:blur(10px);transform:translateY(10px);transition:opacity .8s var(--ease-out),filter .8s var(--ease-out),transform .8s var(--ease-out);transition-delay:calc(var(--i,0)*55ms + .5s)}
97.in .kw{opacity:1;filter:blur(0);transform:none}
98
99/* typing */
100.type{white-space:pre-wrap}
101.type .cur{display:inline-block;width:.5em;height:.82em;background:currentColor;vertical-align:-.04em;margin-left:.06em;animation:blink 1s steps(1) infinite}
102.type.done .cur{animation:blink 1s steps(1) 3;opacity:0;transition:opacity .2s 3s}
103@keyframes blink{50%{opacity:0}}
104
105/* =========================================================
106   01 COVER
107   ========================================================= */
108.cover{justify-content:space-between}
109.cover .top{display:flex;justify-content:space-between;align-items:flex-start}
110.cover .top .langtoggle{margin-top:calc(var(--top)*1.2)}
111.cover .meta{margin-top:calc(var(--top)*1.2)}
112.cover .mark{width:44px;height:22px;margin-bottom:22px}
113.cover .meta .l1{font-size:clamp(14px,1.05vw,18px);letter-spacing:-.01em}
114.cover .meta .l2{font-size:clamp(20px,1.7vw,30px);font-weight:700;letter-spacing:-.03em;line-height:1.2}
115.cover .meta .l3{font-size:clamp(14px,1.05vw,18px);letter-spacing:-.01em}
116.cover .foot{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:clamp(8px,1.2vh,18px);font-size:clamp(13px,1.05vw,18px);letter-spacing:-.01em}
117.cover .roles span{display:inline-block}
118.cover .roles .sl{margin:0 .9em;color:var(--grey)}
119.cover .word{white-space:nowrap;font-weight:700;line-height:.78;margin:0 0 -.06em -.04em;display:flex}
120.cover .word .l{display:inline-block;overflow:hidden;letter-spacing:normal;padding:.12em 0;margin:-.12em -.031em}
121.cover .word .l i{display:inline-block;font-style:normal;transform:translateY(110%);transition:transform 1.25s var(--ease-out);transition-delay:calc(var(--i)*48ms + .15s)}
122.in.cover .word .l i{transform:translateY(0)}
123
124/* =========================================================
125   02 HELLO
126   ========================================================= */
127.hello{display:grid;grid-template-columns:1fr 1.25fr;gap:var(--gutter);align-items:center;padding-top:calc(var(--top)*2);padding-bottom:calc(var(--top)*2.4)}
128.hello .pic{aspect-ratio:1/1;max-height:70vh;width:100%;background:var(--lavender);overflow:hidden;clip-path:inset(0 100% 0 0);transition:clip-path 1.3s var(--ease-in-out) .1s}
129.in .hello .pic,.hello.in .pic{clip-path:inset(0 0 0 0)}
130.hello .pic img{width:100%;height:100%;object-fit:cover;display:block;transform:scale(1.08);transition:transform 1.6s var(--ease-out) .1s}
131.in.hello .pic img{transform:scale(1)}
132.hello .txt .h-huge{font-size:clamp(48px,6.2vw,108px)}
133.hello .txt .body{margin-top:clamp(36px,5.5vh,68px);font-size:clamp(14px,1.02vw,17px);max-width:88ch}
134/* Two equal columns, not 1fr + max-content: the Korean language list is far
135   shorter than the English one, so a content-sized second column let the
136   first column swallow the slack and pushed Languages way right in Korean
137   only. Equal halves start Languages at the same x in both languages, and
138   the cap is set so the longest education line still fits on one row. */
139.hello .info{display:grid;grid-template-columns:1fr 1fr;gap:var(--gutter);margin-top:clamp(44px,7vh,84px);max-width:72ch}
140.hello .info h3{font-weight:700;font-size:clamp(12px,.9vw,15px);letter-spacing:-.01em;text-transform:uppercase;color:var(--grey);margin:0 0 .7em}
141.hello .info ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:.55em}
142.hello .info li{font-size:clamp(13px,1vw,16px);line-height:1.35;letter-spacing:-.005em}
143.hello .info li b{display:block;font-weight:600}
144.hello .info li span{color:var(--grey)}
145.hello .info ul.langs li{white-space:nowrap}
146.hello .info ul.langs li b{display:inline;font-weight:600}
147
148/* =========================================================
149   03 WHAT I DO / 04 TOOLS / 05 WORKED
150   ========================================================= */
151.whatido{padding-top:calc(var(--top)*2.4);justify-content:space-between}
152.whatido .body{margin-top:clamp(28px,4vh,52px);margin-left:clamp(0px,2vw,36px);max-width:clamp(320px,52vw,900px)}
153.whatido .grid{display:grid;grid-template-columns:repeat(4,1fr);gap:clamp(22px,4vh,48px) 24px;margin:0 clamp(0px,6vw,120px) clamp(8px,4vh,40px);font-weight:700;font-size:clamp(17px,1.55vw,28px);letter-spacing:-.03em;text-align:center}
154.whatido .grid>span:nth-child(4n+1){text-align:left}
155.whatido .grid>span:nth-child(4n){text-align:right}
156
157.tools{padding-top:calc(var(--top)*2.4)}
158.tools .body{margin-top:clamp(40px,5.5vh,72px);margin-left:clamp(0px,2vw,36px);max-width:64ch;font-size:clamp(16px,1.35vw,23px);line-height:1.4;color:var(--grey)}
159.tools .body b{color:var(--ink);font-weight:700}
160.tools .body p{white-space:nowrap}
161
162.worked{display:grid;grid-template-columns:1fr 1fr;padding-top:calc(var(--top)*2.4);align-items:start}
163.worked .tl{align-self:center;margin-left:clamp(20px,4.5vw,72px);padding-top:.4em;display:grid;grid-template-columns:auto 1fr;gap:clamp(22px,3.5vh,40px) 40px;align-items:baseline;font-size:clamp(15px,1.25vw,22px);letter-spacing:-.02em}
164.worked .tl .y{color:var(--grey);font-size:.85em;letter-spacing:-.01em;font-variant-numeric:tabular-nums;margin-left:clamp(8px,1.8vw,28px)}
165.worked .tl .r b{display:block;font-weight:700}
166.worked .tl .r span{color:var(--grey)}
167
168/* =========================================================
169   06 CONTENTS
170   ========================================================= */
171.contents{padding-top:calc(var(--top)*2.4);justify-content:space-between}
172.contents ol{list-style:none;margin:0 0 clamp(8px,3vh,32px) 30%;padding:0}
173.contents li{position:relative;display:flex;justify-content:space-between;align-items:baseline;padding:clamp(14px,2.6vh,30px) 0 clamp(10px,1.6vh,18px)}
174.contents li::after{content:"";position:absolute;left:0;right:0;bottom:0;height:1.5px;background:currentColor;transform:scaleX(0);transform-origin:left;transition:transform 1.1s var(--ease-in-out);transition-delay:calc(var(--i)*140ms + .3s)}
175.in .contents li::after,.contents.in li::after{transform:scaleX(1)}
176.contents li .n{font-weight:700;font-size:clamp(18px,1.6vw,28px);letter-spacing:-.03em}
177.contents li a{text-decoration:none;font-weight:500;font-size:clamp(24px,2.6vw,46px);letter-spacing:-.045em;line-height:1;transition:letter-spacing .5s var(--ease-out),color .3s}
178.contents li a:hover{letter-spacing:-.02em;color:var(--blue)}
179
180/* =========================================================
181   CHAPTER DIVIDER (dark)
182   ========================================================= */
183.chapter{justify-content:flex-end}
184.chapter .h-chapter{margin-left:-.03em;margin-bottom:-.02em}
185.chapter .arrow{position:absolute;right:var(--gutter);top:50%;transform:translateY(-50%);width:clamp(120px,15vw,240px);pointer-events:auto}
186.chapter .arrow path{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:square;stroke-dasharray:600;stroke-dashoffset:600;transition:stroke-dashoffset 1.4s var(--ease-in-out) .55s}
187.in .chapter .arrow path,.chapter.in .arrow path{stroke-dashoffset:0}
188.chapter .arrow:hover{animation:nudge 1.1s var(--ease-in-out) infinite}
189@keyframes nudge{50%{transform:translate(12px,-50%)}}
190
191/* =========================================================
192   STATS (lavender) + CHART
193   ========================================================= */
194.stats{padding-top:calc(var(--top)*2.4);justify-content:space-between}
195.stats .lead{display:grid;grid-template-columns:1fr 1fr;gap:var(--gutter);align-items:end}
196/* right column: the language toggle rides at the top of the row, the copy
197   stays pinned to the bottom next to the headline */
198.stats .leadright{align-self:stretch;display:flex;flex-direction:column;justify-content:space-between;align-items:flex-end;gap:clamp(14px,2.6vh,26px)}
199.stats .leadright .body{align-self:stretch}
200.stats .h-caps{color:var(--ink);font-size:clamp(48px,6.4vw,108px);position:relative;top:calc(-1 * clamp(20px,4.5vh,52px))}
201/* copy sits a little below the headline's baseline; nudged visually so the row
202   height (and the headline position) doesn't change */
203.stats .lead .body{color:#2a2a2a;max-width:60ch;margin-bottom:.3em;position:relative;top:clamp(50px,10.5vh,124px);left:calc(-1 * clamp(20px,3.2vw,60px));text-align:justify;text-justify:inter-word}
204/* Korean has far fewer spaces per line, so justifying it opens big gaps, and
205   the default break rule splits words mid-phrase */
206html[lang="ko"] .stats .lead .body,html[lang="ko"] .stats .num .k{text-align:left;word-break:keep-all}
207html[lang="ko"] .stats .h-caps{font-weight:800}
208.stats .nums{display:grid;grid-template-columns:repeat(4,1fr);gap:24px;margin-bottom:clamp(8px,3vh,32px)}
209.stats .nums.three{grid-template-columns:repeat(3,1fr);gap:var(--gutter)}
210.stats .nums.three .num{display:flex;flex-direction:column}
211.stats .nums.three .num .k{max-width:40ch}
212/* grey label sits under the figure with some air (clear of the comma's
213   descender in 23,000), the copy follows */
214.stats .nums.three .num .v{font-size:clamp(51px,6.56vw,115px);color:var(--ink)}
215.stats .nums.three .num .src{margin-top:2.2em}
216.stats .num .v{font-weight:700;letter-spacing:-.06em;line-height:.85;font-size:clamp(56px,6.8vw,120px);color:var(--blue);font-variant-numeric:tabular-nums}
217.stats .num .v small{font-size:.55em;letter-spacing:-.04em}
218.stats .num .k{margin-top:.6em;max-width:22ch;font-size:clamp(13px,1vw,17px);line-height:1.35;letter-spacing:-.01em}
219.stats .num .src{color:var(--grey);font-size:clamp(12px,.85vw,14px);margin-top:.35em}
220
221.chart{padding-top:calc(var(--top)*2.4);justify-content:space-between}
222.chart .top{display:flex;justify-content:space-between;align-items:flex-end;
222gap:var(--gutter)}
223.chart .toggle{display:inline-flex;border:1.5px solid var(--ink);border-radius:999px;padding:3px;gap:2px}
224.chart .toggle button{padding:.55em 1.3em;border-radius:999px;font-weight:600;font-size:clamp(13px,1vw,16px);letter-spacing:-.01em;transition:background .35s,color .35s}
225.chart .toggle button[aria-pressed="true"]{background:var(--ink);color:#fff}
226.chart .note{color:var(--grey);font-size:clamp(12px,.9vw,15px);max-width:44ch;line-height:1.4}
227.chart .plot{display:grid;grid-template-columns:repeat(7,1fr);gap:clamp(12px,1.6vw,28px);align-items:end;height:min(46vh,480px);margin-bottom:clamp(8px,3vh,28px);border-bottom:1.5px solid var(--ink);padding-bottom:0}
228.chart .col{display:flex;flex-direction:column;justify-content:flex-end;height:100%;position:relative}
229.chart .col .bar{position:relative;width:100%;background:var(--ink);transform-origin:bottom;transform:scaleY(0);transition:transform 1.2s var(--ease-in-out) calc(var(--i)*70ms),height 1.1s var(--ease-in-out),background .5s}
230.in .chart .col .bar,.chart.in .col .bar{transform:scaleY(1)}
231.chart .col .bar.after{background:var(--blue)}
232.chart .col .val{position:absolute;left:0;bottom:calc(100% + 8px);font-weight:700;letter-spacing:-.03em;font-size:clamp(18px,1.6vw,28px);font-variant-numeric:tabular-nums;transition:bottom 1.2s var(--ease-in-out)}
233.chart .col .lab{position:absolute;top:calc(100% + 10px);left:0;font-size:clamp(11px,.85vw,14px);line-height:1.3;letter-spacing:-.01em;max-width:100%}
234.chart .col .lab b{display:block;font-weight:600}
235.chart .col .lab span{color:var(--grey)}
236.chart .plot{margin-bottom:clamp(58px,9vh,90px)}
237
238/* =========================================================
239   PROJECT LIST
240   ========================================================= */
241.projects{padding-top:calc(var(--top)*2.4);justify-content:flex-start}
242.projects .h-huge{font-size:clamp(40px,5vw,84px)}
243.projects .list{margin-top:clamp(22px,3.5vh,40px);display:grid;grid-template-columns:1fr 1fr;column-gap:var(--gutter);overflow:auto;scrollbar-width:none;padding-bottom:var(--top)}
244.projects .list::-webkit-scrollbar{display:none}
245.projects .list.single{grid-template-columns:1fr;max-width:70%}
246.projects .item{display:grid;grid-template-columns:2.2em 1fr auto;gap:0 14px;align-items:baseline;padding:clamp(9px,1.3vh,14px) 0;border-bottom:1px solid rgba(0,0,0,.18);text-align:left;width:100%;transition:padding-left .45s var(--ease-out)}
247.projects .item:hover{padding-left:10px}
248.projects .item .n{color:var(--grey);font-variant-numeric:tabular-nums;font-size:clamp(12px,.9vw,15px)}
249.projects .item .t{font-weight:600;font-size:clamp(14px,1.15vw,20px);letter-spacing:-.025em;line-height:1.15}
250.projects .item .c{color:var(--grey);font-size:clamp(11px,.85vw,14px);white-space:nowrap}
251.projects .item:hover .t{color:var(--blue)}
252
253/* AI slide */
254.ai{padding-top:calc(var(--top)*2.4);display:grid;grid-template-columns:1.1fr 1fr;gap:var(--gutter);align-items:stretch}
255.ai .q{margin-top:clamp(20px,3.2vh,40px);font-weight:600;font-size:clamp(20px,2vw,34px);letter-spacing:-.035em;line-height:1.1;max-width:none;white-space:nowrap}
256.ai .ins{margin-top:1em;color:var(--grey);max-width:78ch;font-size:clamp(15px,1.15vw,19px);line-height:1.45}
257.ai .right{padding-top:.4em;display:flex;flex-direction:column;align-items:flex-end}
258.ai .right>*{width:100%}
259.ai .aiuse{margin-top:clamp(70px,14vh,150px)}
260.ai .open{align-self:flex-end;width:auto}
261.ai .right .k{font-weight:600;font-size:clamp(13px,1vw,17px)}
262.ai .right .v{color:var(--grey);margin-top:.2em;line-height:1.4;font-size:clamp(13px,1.05vw,18px)}
263.ai .right>div{margin-bottom:clamp(14px,2.4vh,28px)}
264.ai .open{margin-top:clamp(20px,3vh,36px);display:inline-flex;align-items:center;gap:16px;font-weight:600;font-size:clamp(20px,1.54vw,27px);border-bottom:1.5px solid currentColor;padding-bottom:4px}
265.ai .open svg{width:28px;height:28px;transition:transform .5s var(--ease-out)}
266.ai .open:hover svg{transform:translateX(6px)}
267.ai .lab{color:var(--grey);font-size:clamp(12px,.95vw,15px);margin-top:clamp(14px,2.2vh,24px)}
268.ai .right .topright{margin-bottom:clamp(18px,3vh,38px)}
269.ai .aiuse{margin-bottom:clamp(22px,4vh,44px)}
270/* the AI-notes heading is set like the concept line (.q) opposite it, and its
271   list starts where .ins does (1em of the body size below the heading) */
272.ai .aiuse h4{margin:0;font-weight:600;font-size:clamp(20px,2vw,34px);letter-spacing:-.035em;line-height:1.1}
273.ai .aiuse ul{list-style:none;margin:clamp(12px,.92vw,15px) 0 0;padding:0;display:flex;flex-direction:column;gap:.7em}
274/* desktop: both columns share one grid so the AI-notes heading sits on the
275   same row as the concept line, whatever the title above it measures */
276@media (min-width:901px){
277  .ai{grid-template-areas:"h2 top" "lab ." "q aih" "ins ail" ". open";align-content:start;align-items:start;row-gap:0}
278  .ai .left,.ai .right{display:contents}
279  .ai h2{grid-area:h2}
280  .ai .lab{grid-area:lab}
281  .ai .q{grid-area:q}
282  .ai .ins{grid-area:ins}
283  .ai .right .topright{grid-area:top;justify-self:end;width:auto;margin:0}
284  /* heading on the concept line's row, list on the .ins row; the box is
285     dissolved, so its children take the .fade state from it via inherit */
286  .ai .aiuse{display:contents}
287  .ai .aiuse>h4{grid-area:aih;margin-top:clamp(20px,3.2vh,40px);opacity:inherit;transform:inherit}
288  .ai .aiuse>ul{grid-area:ail;opacity:inherit;transform:inherit}
289  .ai .open{grid-area:open;justify-self:end}
290}
291.ai .aiuse li{position:relative;padding-left:1.1em;color:var(--grey);font-size:clamp(15px,1.15vw,19px);line-height:1.45}
292.ai .aiuse li::before{content:"";position:absolute;
292left:0;top:.6em;width:5px;height:5px;border-radius:50%;background:var(--ink)}
293html[lang="ko"] .ai .ins,html[lang="ko"] .ai .aiuse li{word-break:keep-all}
294
295/* =========================================================
296   CONTACT (dark)
297   ========================================================= */
298.contact{display:grid;grid-template-columns:1fr 1fr;gap:var(--gutter);padding-right:0;padding-bottom:0;padding-top:calc(var(--top)*2.4)}
299.contact .left{display:flex;flex-direction:column;justify-content:space-between;padding-bottom:var(--top)}
300.contact .links{margin-top:clamp(28px,6vh,72px);display:flex;flex-direction:column;gap:clamp(14px,2.5vh,28px);font-size:clamp(22px,2.6vw,46px);letter-spacing:-.04em;font-weight:400}
301.contact .links a{text-decoration:none;position:relative;width:max-content;padding-bottom:.08em}
302.contact .links a::after{content:"";position:absolute;left:0;right:0;bottom:0;height:2px;background:currentColor;transform:scaleX(0);transform-origin:left;transition:transform .9s var(--ease-in-out);transition-delay:calc(var(--i)*150ms + .6s)}
303.in .contact .links a::after,.contact.in .links a::after{transform:scaleX(1)}
304.contact .social{font-size:clamp(13px,1vw,17px)}
305.contact .social b{display:block;margin-bottom:.9em;font-weight:700}
306.contact .social a{margin-right:2.4em}
307.contact .right{background:var(--ink);position:relative;overflow:hidden;clip-path:inset(0 0 0 100%);transition:clip-path 1.4s var(--ease-in-out) .2s}
308.in.contact .right{clip-path:inset(0)}
309.contact .right img{width:100%;height:100%;object-fit:cover;display:block;filter:grayscale(1)}
310
311/* =========================================================
312   DETAIL OVERLAY
313   ========================================================= */
314.detail{position:fixed;inset:0;z-index:100;background:var(--paper);color:var(--ink);transform:translateY(100%);transition:transform .9s var(--ease-in-out);overflow:hidden;visibility:hidden}
315.detail.open{transform:none;visibility:visible}
316.detail .scroll{height:100%;overflow-y:auto;padding:var(--top) var(--gutter) calc(var(--top)*2)}
317.detail .bar{display:flex;justify-content:space-between;align-items:center;font-size:clamp(13px,1vw,17px);margin-bottom:clamp(36px,7vh,80px)}
318.detail .close{display:inline-flex;align-items:center;gap:10px;font-weight:600}
319.detail .close svg{width:18px;height:18px}
320.detail article{max-width:1480px;margin:0 auto}
321.detail .h-huge{font-size:clamp(40px,5.4vw,92px);margin-bottom:.4em;max-width:18ch}
322.detail .metarow{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:clamp(16px,2vw,32px);border-top:1.5px solid var(--ink);border-bottom:1px solid rgba(0,0,0,.15);padding:clamp(14px,2vh,22px) 0;font-size:clamp(13px,.98vw,16px);line-height:1.4;letter-spacing:-.01em}
323.detail .metarow .k{color:var(--grey);display:block;margin-bottom:.3em;font-size:.9em}
324.detail .sec{display:grid;grid-template-columns:minmax(160px,1fr) minmax(0,3.2fr);gap:var(--gutter);padding:clamp(28px,4.5vh,52px) 0;border-bottom:1px solid rgba(0,0,0,.15)}
325.detail .sec:last-child{border-bottom:0}
326.detail h3{font-weight:700;letter-spacing:-.03em;font-size:clamp(20px,1.7vw,30px);margin:0;line-height:1.05}
327.detail .body{max-width:88ch;font-size:clamp(15px,1.12vw,19px);line-height:1.5}
328.detail .carousel{margin:clamp(24px,4vh,44px) 0 clamp(8px,1.5vh,16px);position:relative}
329.detail .fandeck{display:flex;align-items:flex-end;overflow-x:auto;overflow-y:hidden;overflow-anchor:none;scrollbar-width:none;-ms-overflow-style:none;padding:clamp(46px,8vh,84px) clamp(16px,5vw,72px) clamp(30px,5vh,52px);cursor:grab;touch-action:pan-y;scroll-behavior:smooth;background:transparent}
330.detail .fandeck::-webkit-scrollbar{display:none}
331.detail .fandeck.drag{cursor:grabbing;scroll-behavior:auto}
332.detail .card{flex:0 0 auto;position:relative;width:clamp(180px,23vw,300px);height:min(60vh,420px);margin-left:clamp(-136px,-10vw,-64px);border-radius:16px;overflow:hidden;background:#fff;box-shadow:0 16px 36px rgba(0,0,0,.16),0 2px 6px rgba(0,0,0,.10);transform-origin:50% 100%;transform:rotate(var(--r,0deg)) translateY(var(--y,0px));transition:transform .55s var(--ease-out),box-shadow .55s var(--ease-out),width .55s var(--ease-out);z-index:var(--z,1);will-change:transform}
333.detail .card:first-child{margin-left:0}
334.detail .card img{width:100%;height:100%;object-fit:cover;display:block;pointer-events:none;user-select:none}
335.detail .card iframe{position:absolute;inset:0;width:100%;height:100%;border:0;display:block}
336.detail .card.vid{background:var(--ink)}
337.detail .card.vid .play{position:absolute;
337left:50%;top:50%;transform:translate(-50%,-50%);width:52px;height:52px;border-radius:50%;background:rgba(255,255,255,.92);display:flex;align-items:center;justify-content:center;pointer-events:none;transition:transform .5s var(--ease-out)}
338.detail .card.vid .play svg{width:18px;height:18px;margin-left:2px;fill:var(--ink)}
339.detail .card:hover{width:clamp(260px,36vw,520px);transform:rotate(0deg) translateY(-30px) scale(1.03);box-shadow:0 44px 84px rgba(0,0,0,.30),0 4px 12px rgba(0,0,0,.12);z-index:30}
340.detail .card:hover img{object-fit:contain}
341.detail .card:hover.vid .play{transform:translate(-50%,-50%) scale(1.12)}
342.detail .card.playing,.detail .card.playing:hover{width:clamp(300px,46vw,620px);transform:rotate(0deg) translateY(-30px);z-index:40;cursor:default}
343.detail .card.playing img,.detail .card.playing .play{display:none}
344.detail .fandeck.drag .card{pointer-events:none}
345@media (hover:none){.detail .card{width:clamp(200px,52vw,300px);margin-left:clamp(-90px,-14vw,-40px)}}
346.detail .cap{margin:.6em 0 0;color:var(--grey);font-size:clamp(12px,.9vw,14px);min-height:1.2em}
347.detail .qs{margin:1.2em 0 0;padding:0;list-style:none}
348.detail .qs li{padding:1em 0;border-top:1px solid rgba(0,0,0,.15)}
349.detail .qs li b{display:block;font-weight:600;font-size:1.1em;letter-spacing:-.02em;margin-bottom:.3em}
350.detail .qs li span{color:var(--grey)}
351/* stagger content inside detail */
352.detail .rev{opacity:0;transform:translateY(24px);transition:opacity .9s var(--ease-out),transform .9s var(--ease-out)}
353.detail.open .rev{opacity:1;transform:none}
354.detail.open .rev:nth-child(1){transition-delay:.55s}
355.detail.open .rev:nth-child(2){transition-delay:.65s}
356.detail.open .rev:nth-child(3){transition-delay:.75s}
357.detail.open #dBody .rev{transition-delay:.85s}
358
359/* =========================================================
360   VIZ SLIDES (v2): User behaviour + Campaign effectiveness
361   ========================================================= */
362.viz{padding-top:calc(var(--top)*2.2);display:grid;grid-template-rows:auto 1fr auto;gap:clamp(14px,2.4vh,28px)}
363.viz .top{display:flex;justify-content:space-between;align-items:flex-end;gap:var(--gutter);min-height:12.15em}
364.viz .top .h-huge{font-size:clamp(38px,4.6vw,78px)}
365.viz .sub{margin-top:1.4em;font-size:clamp(13px,1vw,17px);color:var(--grey);letter-spacing:-.01em;max-width:60ch;line-height:1.4}
366.viz .sub b{color:var(--ink);font-weight:600}
367.viz .formula{margin:0;font-size:clamp(10px,.8vw,12px);color:var(--grey);letter-spacing:-.01em}
368.viz .formula i{font-style:normal;color:var(--grey)}
369.chartcol{display:flex;flex-direction:column;min-height:0;gap:clamp(10px,1.6vh,18px)}
370.chartcol .groups{flex:1;min-height:0}
371.viz .foot .footnote{display:flex;flex-direction:column;gap:.4em}
372.viz .seg{display:inline-flex;border:1.5px solid var(--ink);border-radius:999px;padding:3px;gap:2px;flex-wrap:wrap}
373.viz .seg button{padding:.5em 1.1em;border-radius:999px;font-weight:600;font-size:clamp(12px,.95vw,15px);letter-spacing:-.01em;transition:background .35s,color .35s;white-space:nowrap}
374.viz .seg button[aria-pressed="true"]{background:var(--ink);color:#fff}
375.viz .ctrls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
376.viz .stage{display:grid;grid-template-columns:1.35fr 1fr;gap:var(--gutter);min-height:0}
377.viz .foot{display:flex;justify-content:space-between;align-items:flex-end;gap:var(--gutter);font-size:clamp(11px,.85vw,14px);color:var(--grey);line-height:1.35;min-height:6.3em}
378.viz .foot .disc{max-width:52ch}
379.viz .foot .disc b{color:var(--ink);font-weight:600}
380.viz .foot .disc .nowrap{white-space:nowrap}
381
382/* horizontal bars (eROAS) */
383.hbars{display:flex;flex-direction:column;justify-content:center;gap:clamp(10px,2vh,22px)}
384.hbar{display:grid;grid-template-columns:minmax(120px,.34fr) 1fr 4.2em;align-items:center;gap:14px;text-align:left;padding:2px 0;border-radius:4px}
385.hbar .l{font-weight:600;font-size:clamp(13px,1.05vw,17px);letter-spacing:-.02em;line-height:1.15}
386.hbar .l span{display:block;color:var(--grey);font-weight:400;font-size:.8em;margin-top:.15em}
387.hbar .t{position:relative;height:clamp(28px,4.6vh,48px);background:rgba(0,0,0,.06)}
388.hbar .t i{position:absolute;
388left:0;top:0;bottom:0;background:var(--ink);transform-origin:left;transform:scaleX(0);transition:transform 1s var(--ease-in-out),background .35s;transition-delay:calc(var(--i)*60ms)}
389.in .hbar .t i{transform:scaleX(1)}
390.hbar .t em{position:absolute;left:0;right:0;top:0;bottom:0;border-left:1.5px dashed var(--ink);width:0;pointer-events:none}
391.hbar .v{font-weight:700;font-variant-numeric:tabular-nums;font-size:clamp(15px,1.3vw,22px);letter-spacing:-.03em;text-align:right}
392.hbar.sel .t i,.hbar:hover .t i{background:var(--blue)}
393.hbar.dim .t i{background:rgba(0,0,0,.28)}
394.hbar.sel .v{color:var(--blue)}
395.hbars .axis{display:flex;justify-content:space-between;color:var(--grey);font-size:clamp(11px,.8vw,13px);margin-left:calc(minmax(120px,.34fr) + 14px);padding-left:0}
396.hbars .axis span:first-child{margin-left:auto;margin-right:auto}
397
398/* right panel: allocation */
399.alloc{display:flex;flex-direction:column;justify-content:center;gap:clamp(12px,2vh,22px)}
400.alloc h4{margin:0;font-weight:600;font-size:clamp(13px,1vw,17px);letter-spacing:-.02em}
401.alloc h4 span{color:var(--grey);font-weight:400}
402.alloc .row{display:grid;grid-template-columns:4.6em 1fr;gap:12px;align-items:center;font-size:clamp(12px,.9vw,14px);color:var(--grey)}
403.alloc .stack{display:flex;height:clamp(24px,3.6vh,38px);width:100%;overflow:hidden}
404.alloc .stack i{display:block;height:100%;background:var(--ink);transition:flex-basis 1s var(--ease-in-out),background .35s;flex:0 0 0%;position:relative;box-shadow:inset -1px 0 0 #fff}
405.alloc .stack i.sel{background:var(--blue)}
406.alloc .stack i.o1{background:rgba(0,0,0,.72)}.alloc .stack i.o2{background:rgba(0,0,0,.5)}.alloc .stack i.o3{background:rgba(0,0,0,.32)}.alloc .stack i.o4{background:rgba(0,0,0,.18)}
407.alloc .delta{font-weight:700;font-size:clamp(26px,3vw,52px);letter-spacing:-.05em;line-height:.95;color:var(--blue);font-variant-numeric:tabular-nums}
408.alloc .delta small{display:block;font-size:clamp(12px,.9vw,15px);letter-spacing:-.01em;color:var(--ink);font-weight:400;margin-top:.5em;max-width:34ch;line-height:1.35}
409.alloc .cards{display:grid;grid-template-columns:1fr 1fr;gap:16px;border-top:1.5px solid var(--ink);padding-top:14px}
410.alloc .cards div{font-size:clamp(12px,.9vw,14px);line-height:1.4}
411.alloc .cards b{display:block;font-weight:600;letter-spacing:-.02em;font-size:1.05em;margin-bottom:.25em}
412.alloc .cards span{color:var(--grey)}
413
414/* LTV chart */
415.ltv{position:relative;min-height:0}
416.ltv svg{width:100%;height:100%;display:block;overflow:visible}
417.ltv .grid line{stroke:rgba(0,0,0,.12);stroke-width:1}
418.ltv .be{stroke:var(--ink);stroke-width:1.5;stroke-dasharray:4 5}
419.ltv .p-total{fill:none;stroke:var(--blue);stroke-width:3;stroke-linejoin:round}
420.ltv .p-iap{fill:none;stroke:var(--ink);stroke-width:1.5;stroke-linejoin:round}
421.ltv .p-iaa{fill:none;stroke:rgba(0,0,0,.4);stroke-width:1.5;stroke-linejoin:round}
422.ltv .p-total,.ltv .p-iap,.ltv .p-iaa{stroke-dasharray:2000;stroke-dashoffset:2000;transition:stroke-dashoffset 1.6s var(--ease-in-out) .3s,d .6s var(--ease-out)}
423.in .ltv .p-total,.in .ltv .p-iap,.in .ltv .p-iaa{stroke-dashoffset:0}
424.ltv .win{stroke:var(--blue);stroke-width:1.5}
425.ltv .wdot{fill:var(--blue)}
426.ltv text{font-family:var(--font);font-size:12px;fill:var(--grey);letter-spacing:-.01em}
427.ltv text.lbl{fill:var(--ink);font-weight:600}
428.ltv .legend{position:absolute;left:0;top:0;display:flex;gap:18px;font-size:clamp(11px,.85vw,14px);color:var(--grey)}
429.ltv .legend i{display:inline-block;width:18px;height:3px;vertical-align:middle;margin-right:6px;background:var(--blue)}
430.ltv .legend i.k{background:var(--ink);height:1.5px}.ltv .legend i.g{background:rgba(0,0,0,.4);height:1.5px}
431
432/* readout table (mirrors the KPI sheet) */
433.readout{display:flex;flex-direction:column;justify-content:center;gap:clamp(10px,1.8vh,18px)}
434.readout .slider{display:grid;grid-template-columns:1fr auto;gap:12px;align-items:center;font-size:clamp(12px,.95vw,15px)}
435.readout .slider b{font-variant-numeric:tabular-nums;font-weight:700;font-size:1.2em;letter-spacing:-.03em}
436.readout input[type=range]{width:100%;-webkit-appearance:none;appearance:none;height:2px;background:var(--ink);outline:none;margin:10px 0}
437.readout input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:18px;height:18px;border-radius:50%;background:var(--blue);border:2px solid #fff;box-shadow:0 0 0 1.5px var(--blue);cursor:pointer}
438.readout input[type=range]::-moz-range-thumb{width:18px;height:18px;border-radius:50%;background:var(--blue);border:2px solid #fff;box-shadow:0 0 0 1.5px var(--blue);cursor:pointer}
439.readout table{width:100%;border-collapse:collapse;font-size:clamp(12px,.95vw,15px);letter-spacing:-.01em}
440.readout th,.readout td{text-align:left;padding:.55em 0;border-bottom:1px solid rgba(0,0,0,.14);vertical-align:baseline}
441.readout th{font-weight:400;color:var(--grey)}
442.readout td{text-align:right;font-weight:600;font-variant-numeric:tabular-nums}
443.readout td.big{font-size:1.5em;letter-spacing:-.04em;font-weight:700;color:var(--blue)}
444.readout tr.goal td{color:var(--ink)}
445.readout .verdict{font-size:clamp(12px,.95vw,15px);line-height:1.4}
446.readout .verdict b{color:var(--blue)}
447.viz .results{display:flex;gap:clamp(24px,4vw,64px)}
448.viz .results .num{display:flex;align-items:baseline;gap:10px}
449.viz .results .v{font-weight:700;letter-spacing:-.05em;font-size:clamp(28px,3.2vw,56px);line-height:1;font-variant-numeric:tabular-nums}
450.viz .results .k{font-size:clamp(11px,.85vw,14px);color:var(--grey);max-width:16ch;line-height:1.25}
451
452/* consolidated case list */
453.projects .list.cases{grid-template-columns:1fr;max-width:none;margin-top:clamp(18px,3vh,36px)}
454.projects .list.cases .item{grid-template-columns:2.4em 1fr auto;align-items:center;padding:clamp(14px,2.6vh,28px) 0;border-bottom:1.5px solid var(--ink)}
455.projects .list.cases .item .t{font-weight:600;font-size:clamp(20px,2vw,34px);letter-spacing:-.04em;line-height:1.05}
456.projects .list.cases .item .t small{display:block;font-weight:400;color:var(--grey);font-size:clamp(12px,.95vw,16px);letter-spacing:-.01em;margin-top:.35em;line-height:1.3}
457.projects .list.cases .item .m{text-align:right}
458.projects .list.cases .item .m b{display:block;font-weight:700;font-size:clamp(34px,4.2vw,76px);letter-spacing:-.06em;line-height:.9;color:var(--blue);font-variant-numeric:tabular-nums}
459.projects .list.cases .item .m span{display:block;color:var(--grey);font-size:clamp(11px,.85vw,14px);max-width:28ch;margin-top:.45em;line-height:1.3;margin-left:auto}
460
461@media (max-width:900px){
462  .viz .stage{grid-template-columns:1fr}
463  .viz .top{flex-direction:column;align-items:flex-start}
464  .projects .list.cases .item .m{display:none}
465}
466
467/* v2.1: grouped eROAS + insights, draggable LTV point */
468.viz .hint{font-size:clamp(12px,.95vw,15px);color:var(--grey);white-space:nowrap;margin:0 0 .3em}
469.groups{display:grid;grid-template-columns:1fr 1fr;gap:clamp(20px,3vw,48px);align-content:center}
470.group h4{margin:0 0 clamp(10px,1.6vh,18px);font-weight:700;font-size:clamp(16px,1.4vw,24px);letter-spacing:-.03em;line-height:1.1;display:flex;justify-content:space-between;align-items:baseline;border-bottom:1.5px solid var(--ink);padding-bottom:.45em}
471.group h4 span{font-weight:400;color:var(--grey);font-size:.7em;letter-spacing:-.01em}
472.group .hbar{grid-template-columns:minmax(96px,.42fr) 1fr 3.6em}
473.group .hbars{gap:clamp(10px,1.8vh,20px)}
474.group .hbar.best .t i{background:var(--blue)}
475.group .hbar.best .v{color:var(--blue)}
476.insights{display:flex;flex-direction:column;justify-content:center;gap:clamp(16px,3vh,32px)}
477.insights ol{margin:0;padding:0;list-style:none;counter-reset:ins}
478.insights li{counter-increment:ins;display:grid;grid-template-columns:2em 1fr;gap:10px;padding:clamp(8px,1.3vh,14px) 0;border-top:1px solid rgba(0,0,0,.14);font-size:clamp(13px,1.02vw,17px);line-height:1.4;letter-spacing:-.01em}
479.insights li::before{content:counter(ins,decimal-leading-zero);color:var(--grey);font-variant-numeric:tabular-nums;font-size:.85em;padding-top:.15em}
480.insights li b{font-weight:600}
481.viz .big b{display:block;font-weight:700;font-size:clamp(56px,6.8vw,120px);letter-spacing:-.06em;line-height:.85;color:var(--blue);font-variant-numeric:tabular-nums}
482.viz .big>span{display:block;margin-top:.7em;font-size:clamp(12px,.95vw,15px);line-height:1.35;max-width:30ch;letter-spacing:-.01em}
483.readout .bigs{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:clamp(8px,1.6vh,16px)}
484.readout .bigs .big b{font-size:clamp(40px,4.4vw,80px)}
485.ltv svg{cursor:ew-resize;touch-action:none}
486.ltv .wdot{fill:var(--blue);stroke:#fff;stroke-width:3;cursor:grab}
487.ltv .wdot.drag{cursor:grabbing}
488.ltv .hit{fill:transparent}
489
490/* =========================================================
491   v3: Data → Insight → Result stepper slides
492   ========================================================= */
493.stepslide .top{align-items:flex-start}
494.topright{display:flex;flex-direction:column;align-items:flex-end;gap:28px;flex:0 0 auto}
495.readcase{display:inline-flex;align-items:center;gap:10px;font-weight:600;font-size:clamp(13px,1vw,16px);letter-spacing:-.01em;border-bottom:1.5px solid currentColor;padding-bottom:3px}
496.readcase svg{width:18px;height:18px;transition:transform .5s var(--ease-out)}
497.readcase:hover svg{transform:translateX(5px)}
498.stepper{display:inline-flex;border:1.5px solid var(--ink);border-radius:999px;padding:3px;gap:2px}
499.stepper button{display:inline-flex;align-items:baseline;gap:8px;padding:.5em 1.15em;border-radius:999px;font-weight:600;font-size:clamp(12px,.95vw,15px);letter-spacing:-.01em;transition:background .35s,color .35s}
500.stepper button i{font-style:normal;font-weight:400;font-size:.8em;opacity:.55;font-variant-numeric:tabular-nums}
501.stepper button[aria-selected="true"]{background:var(--ink);color:#fff}
502.stepslide .steps{position:relative;min-height:0;cursor:pointer}
503.stepslide .step{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;opacity:0;visibility:hidden;transform:translateY(22px);transition:opacity .55s var(--ease-out),transform .55s var(--ease-out),visibility 0s .55s}
504.stepslide .step.on{opacity:1;visibility:visible;transform:none;transition:opacity .7s var(--ease-out) .12s,transform .7s var(--ease-out) .12s,visibility 0s}
505.stepslide .step .mask>.line{transform:translateY(135%)}
506.stepslide .step.on .mask>.line{transform:none}
507.stepslide .step .fade{opacity:0;transform:translateY(14px)}
508.stepslide .step.on .fade{opacity:1;transform:none}
509.stepslide .step .hbar .t i{transform:scaleX(0)}
510.stepslide .step.on .hbar .t i{transform:scaleX(1)}
511.stepslide .step .p-total,.stepslide .step .p-iap,.stepslide .step .p-iaa{stroke-dashoffset:2000}
512.stepslide .step.on .p-total,.stepslide .step.on .p-iap,.stepslide .step.on .p-iaa{stroke-dashoffset:0}
513.stepslide .chartcol{height:100%;justify-content:center}
514.stepslide .chartcol .ctrls{justify-content:flex-start}
515.stephint{color:var(--grey);font-size:clamp(11px,.85vw,14px);margin:0;white-space:nowrap}
516.stepslide .foot{align-items:flex-end}
517
518/* insight step */
519.insight-grid{display:grid;grid-template-columns:1.25fr 1fr;gap:calc(var(--gutter)*1.2);align-items:center;height:100%;min-height:0}
520.insight-grid.ce{grid-template-columns:1.25fr 1fr;align-items:center}
521.insight-grid.ce .ltv{height:100%;min-height:0;display:flex;flex-direction:column}
522.insight-grid.ce .ltv svg{flex:1;min-height:0}
523.ltv .legend{position:static;margin-top:8px;justify-content:center}
524.ltv .legend .drag{color:var(--grey)}
525.readout .lead{margin:0 0 .6em;font-size:clamp(13px,1.02vw,17px);line-height:1.4;letter-spacing:-.01em}
526
527/* result step */
528.result{display:flex;flex-direction:column;justify-content:center;height:100%}
529.result .giant{font-weight:700;letter-spacing:-.07em;line-height:.82;font-size:clamp(120px,15vw,280px);color:var(--blue);font-variant-numeric:tabular-nums;margin-left:-.04em}
530.result .giant .mask{padding:.1em 0;margin:-.1em 0}
531.result .rline{margin:.4em 0 0;font-weight:600;font-size:clamp(18px,1.8vw,32px);letter-spacing:-.035em;line-height:1.1}
532.result .rsub{margin:1.2em 0 0;max-width:54ch;font-size:clamp(13px,1.05vw,18px);line-height:1.45;color:var(--grey);letter-spacing:-.01em}
533.result.two{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:auto auto;gap:0 var(--gutter);align-content:center}
534.result.two .giant{font-size:clamp(96px,11.5vw,220px)}
535.result.two .rsub{grid-column:1/-1}
536
537/* daily board (data step, campaign effectiveness) */
538.board{display:flex;flex-direction:column;gap:clamp(10px,1.8vh,20px);height:100%;justify-content:center;min-height:0}
539.chips{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
540.chips .lab{font-size:clamp(11px,.85vw,13px);color:var(--grey);margin-right:6px;white-space:nowrap}
541.chips .lab:not(:first-child){margin-left:14px}
542.chips span.c{font-size:clamp(10px,.8vw,12px);font-weight:600;letter-spacing:-.01em;padding:.25em .6em;border:1px solid rgba(0,0,0,.25);border-radius:4px;white-space:nowrap;opacity:0;transform:translateY(6px);transition:opacity .5s var(--ease-out),transform .5s var(--ease-out);transition-delay:calc(var(--i)*22ms + .2s)}
543.stepslide .step.on .chips span.c{opacity:1;transform:none}
544.boardgrid{display:grid;grid-template-columns:1fr;min-height:0;flex:1}
545.daily{min-height:0;display:flex;flex-direction:column;gap:8px}
546.daily .legend{display:flex;gap:18px;font-size:clamp(11px,.85vw,14px);color:var(--grey);align-items:center;justify-content:center}
547.daily .legend i{display:inline-block;width:14px;height:11px;vertical-align:middle;margin-right:6px;border-radius:2px}
548.daily .legend i.pillar{background:rgba(0,0,0,.14)}
549.daily .legend i.line{height:2px;background:var(--ink)}
550.daily svg{width:100%;height:100%;flex:1;min-height:0;overflow:visible;cursor:crosshair}
551.daily text{font-family:var(--font);font-size:12px;fill:var(--grey)}
552.daily .bar{fill:rgba(0,0,0,.14);transform-origin:bottom;transform:scaleY(0);transition:transform .9s var(--ease-in-out),fill .3s;transition-delay:calc(var(--i)*70ms + .25s)}
553.stepslide .step.on .daily .bar{transform:scaleY(1)}
554.daily .bar.hot{fill:rgba(0,0,0,.3)}
555.daily .ln{fill:none;stroke-width:2;stroke-linejoin:round;stroke-dasharray:1200;stroke-dashoffset:1200;transition:stroke-dashoffset 1.5s var(--ease-in-out) .5s}
556.stepslide .step.on .daily .ln{stroke-dashoffset:0}
557.daily .goal{stroke:var(--ink);stroke-width:1.5;stroke-dasharray:4 5}
558.daily .cursor{stroke:var(--blue);stroke-width:1.5;opacity:0}
558
559.daily .cursor.show{opacity:1}
560.daily .dot{fill:#fff;stroke-width:2.5;opacity:0}
561.daily .dot.show{opacity:1}
562@media (max-width:900px){.insight-grid,.insight-grid.ce,.boardgrid,.result.two{grid-template-columns:1fr}.topright{align-items:flex-start}}
563
564/* =========================================================
565   Retention-curve chart (asset anatomy), used by the
566   From User Behaviour to Creative case
567   ========================================================= */
568.anatomy{display:flex;flex-direction:column;gap:clamp(8px,1.4vh,14px);height:100%;min-height:0;justify-content:center}
569.anhead{display:flex;justify-content:space-between;align-items:center;gap:16px}
570.anhead .t{font-size:clamp(12px,.95vw,15px);color:var(--grey)}
571.anatlegend{display:flex;gap:18px;font-size:clamp(11px,.85vw,14px);color:var(--grey);align-items:center;justify-content:center;margin-top:clamp(8px,1.4vh,14px)}
572.anatlegend i{display:inline-block;width:18px;vertical-align:middle;margin-right:6px}
573.anatlegend i.dash{border-top:1.5px dashed rgba(0,0,0,.4)}
574.anatlegend i.solid{border-top:3px solid var(--blue)}
575.anatomy svg{width:100%;overflow:visible}
576.anatomy text{font-family:var(--font);font-size:12px;fill:var(--grey)}
577.anatomy text.k{fill:var(--ink);font-weight:600}
578.anatomy .gl{stroke:rgba(0,0,0,.1)}
579/* dasharray tracks the real path length (~719 user units) so the draw fills
580   the whole transition instead of finishing in the first third */
581.anatomy .curve{fill:none;stroke:var(--blue);stroke-width:3;stroke-linejoin:round;stroke-dasharray:760;stroke-dashoffset:760;transition:stroke-dashoffset 1.6s var(--ease-in-out) .3s,d .8s var(--ease-in-out)}
582.anatomy .ghost{fill:none;stroke:rgba(0,0,0,.25);stroke-width:1.5;stroke-dasharray:3 4}
583.anatomy .beat{fill:rgba(0,0,0,.06);stroke:#fff;stroke-width:2}
584.anatomy .beat.hook{fill:rgba(20,0,255,.12)}
585.anatomy .drop{fill:none;stroke:var(--ink);stroke-width:1.5}
586.anatomy .drop.b{stroke-dasharray:3 4}
587/* reveal, same pattern as the LTV and daily charts: the blue curve draws
588   itself in while the dashed reference, the beat bars and the labels fade up */
589.anatomy .ghost,.anatomy .beat,.anatomy .drop,.anatomy text{opacity:0;transition:opacity .7s var(--ease-out) .25s}
590.stepslide .step.on .anatomy .curve{stroke-dashoffset:0}
591.stepslide .step.on .anatomy .ghost,.stepslide .step.on .anatomy .beat,.stepslide .step.on .anatomy .drop,.stepslide .step.on .anatomy text{opacity:1}
592.result.three{display:grid;grid-template-columns:1fr 1fr 1fr;grid-template-rows:auto auto;gap:0 var(--gutter);align-content:center}
593.result.three .giant{font-size:clamp(72px,8.6vw,170px)}
594.result.three .rline{font-size:clamp(15px,1.4vw,24px)}
595.result.three .rsub{grid-column:1/-1;max-width:none;white-space:nowrap}
596.result.three .rsub+.rsub{margin-top:0}
597@media (max-width:900px){.libgrid,.insight-grid.cr,.result.three{grid-template-columns:1fr}}
598
599/* =========================================================
600   v7: reports desk (slide 9) + integrated case page
601   ========================================================= */
602.desk{padding-top:calc(var(--top)*2.2);display:grid;grid-template-rows:auto 1fr;gap:clamp(12px,2vh,24px)}
603.desk .top{display:flex;justify-content:space-between;align-items:flex-start;gap:var(--gutter)}
604.desk .top .h-huge{font-size:clamp(38px,4.6vw,78px)}
605.desk .top .hint{margin:.7em 0 0;color:var(--grey);font-size:clamp(12px,.95vw,15px);letter-spacing:-.01em}
606.langtoggle{display:inline-flex;border:1.5px solid var(--ink);border-radius:999px;padding:3px;gap:2px}
607.langtoggle button{padding:.45em 1.1em;border-radius:999px;font-weight:600;font-size:clamp(12px,.95vw,15px);letter-spacing:-.01em;transition:background .35s,color .35s}
608.langtoggle button[aria-pressed="true"]{background:var(--ink);color:#fff}
609/* text + animation rows, identical language to the Contents slide: only the
610   per-row underline is a drawn line (no static borders), number + title fade
611   in together, hover just tightens tracking and tints blue */
612/* --dstep is the staircase indent per row; sized so three rows still reach
613   roughly the same depth the four-row list used to */
614.deskline{list-style:none;margin:0;padding:0;height:100%;display:flex;flex-direction:column;justify-content:center;--dstep:clamp(150px,20vw,340px)}
615/* staircase: each row steps in from the left, narrowing so the right edge
616   (arrow column) still lines up. Title/number are static (no entrance
617   animation) — only the underline draw and the subtext sweep animate in,
618   each staggered later per row via --i. */
619.deskline-item{position:relative;margin-left:calc(var(--i) * var(--dstep));width:calc(100% - var(--i) * var(--dstep))}
620.deskline-item:nth-child(2){margin-left:calc(var(--dstep) - 30px);width:calc(100% - var(--dstep) + 30px)}
621/* 300ms step against a 650ms draw: with the ease-in-out curve that starts the
622   next line when the previous one is about 30% drawn */
623.deskline-item::after{content:"";position:absolute;
623left:0;right:0;bottom:0;height:1.5px;background:var(--ink);transform:scaleX(0);transform-origin:left;transition:transform .65s var(--ease-in-out);transition-delay:calc(var(--i)*300ms + .55s)}
624.in .deskline-item::after{transform:scaleX(1)}
625.deskline-item button{all:unset;box-sizing:border-box;cursor:pointer;display:flex;flex-wrap:wrap;align-items:baseline;column-gap:clamp(16px,3vw,48px);row-gap:8px;width:100%;padding:clamp(22px,4vh,44px) 0}
626.deskline-item .n{flex:0 0 auto;font-weight:700;font-size:clamp(16px,1.3vw,22px);letter-spacing:-.02em}
627.deskline-item .ti{flex:1 1 0%;min-width:0}
628.deskline-item .h{display:block;font-weight:600;font-size:clamp(28px,3.6vw,68px);letter-spacing:-.045em;line-height:1.05;transition:letter-spacing .5s var(--ease-out),color .3s}
629.deskline.ko .deskline-item .h{font-weight:800}
630.deskline-item .d{display:block;margin-top:.4em;color:var(--grey);font-size:clamp(12px,.9vw,15px);line-height:1.4;white-space:nowrap;opacity:0;clip-path:inset(0 100% 0 0);transition:opacity .9s var(--ease-out),clip-path .9s var(--ease-out);transition-delay:calc(var(--i)*280ms + .75s)}
631.in .deskline-item .d{opacity:1;clip-path:inset(0 0 0 0)}
632.deskline-item .go{flex:0 0 auto;display:inline-flex;align-items:center;opacity:.5;transform:translateX(0);transition:opacity .3s,transform .4s var(--ease-out)}
633.deskline-item .go svg{width:34px;height:17px}
634.deskline-item button:hover .h,.deskline-item button:focus-visible .h{letter-spacing:-.02em;color:var(--blue)}
635.deskline-item button:hover .go,.deskline-item button:focus-visible .go{opacity:1;transform:translateX(4px)}
636
637/* case page overlay */
638/* visibility must switch a beat late on close (stay visible through the slide-down)
639   but instantly on open (visible before the slide-up starts), or the close looks
640   like an instant cut instead of the reverse of the open animation */
641.casepage{position:fixed;inset:0;z-index:110;background:var(--paper);color:var(--ink);transform:translateY(100%);transition:transform .9s var(--ease-in-out),visibility 0s .9s;visibility:hidden}
642.casepage.open{transform:none;visibility:visible;transition:transform .9s var(--ease-in-out),visibility 0s}
643.casepage .cscroll{height:100%;overflow-y:auto;padding:var(--top) var(--gutter) calc(var(--top)*2)}
644.casepage .bar{display:flex;justify-content:space-between;align-items:center;font-size:clamp(13px,1vw,17px);margin-bottom:0}
645.casepage .close{display:inline-flex;align-items:center;gap:10px;font-weight:600}
646.casepage .close svg{width:18px;height:18px}
647.casepage .rv{opacity:0;transform:translateY(22px);transition:opacity .9s var(--ease-out),transform .9s var(--ease-out);transition-delay:calc(var(--i,0)*120ms)}
648.casepage .rv.on{opacity:1;transform:none}
649/* content-driven height, not min-height + centering: any leftover slack from
650   centering would land entirely after the last child (.whygrid), throwing off
651   its own top/bottom symmetry no matter how its own padding is tuned */
652.cshero{max-width:1480px;margin:0 auto;padding-top:clamp(60px,10vh,120px);display:flex;flex-direction:column;gap:clamp(40px,7vh,88px)}
653.cshero .lab{font-size:clamp(12px,.95vw,15px);color:var(--grey)}
654.cshero .h-display{font-size:clamp(48px,7vw,132px);max-width:14ch;margin:0}
655.whygrid{display:grid;grid-template-columns:1fr 1fr;gap:calc(var(--gutter) * 2);align-items:start;border-top:1.5px solid var(--ink);padding:clamp(48px,6.75vh,72px) 0}
656.whygrid h4{margin:0 0 .6em;font-weight:600;font-size:clamp(13px,1.05vw,17px);color:var(--grey);letter-spacing:-.01em}
657.whygrid p{margin:0;font-size:clamp(13px,.98vw,16px);line-height:1.55;letter-spacing:-.005em;font-weight:400;text-align:justify;text-justify:inter-word}
658.cssec{max-width:1480px;margin:0 auto;display:grid;grid-template-columns:minmax(150px,1fr) minmax(0,3.4fr);gap:var(--gutter);padding:clamp(40px,7vh,88px) 0;border-top:1.5px solid var(--ink)}
659.cssec .sh h3{position:sticky;top:0;margin:0;font-weight:700;letter-spacing:-.03em;font-size:clamp(20px,1.7vw,30px);line-height:1.05}
660.cssec .sb{min-width:0}
661.cssec .body{max-width:none;font-size:clamp(13px,.98vw,16px);line-height:1.55;text-align:justify;text-justify:inter-word}
662.cssec .body p,.cssec .sb>p.tx{max-width:none;margin:0 0 1.8em;font-size:clamp(13px,.98vw,16px);line-height:1.55;letter-spacing:-.005em;font-weight:400;text-align:justify;text-justify:inter-word}
663.cssec .sb p.tx i{display:block;font-style:normal;color:var(--grey);font-size:.82em;margin-top:.45em}
664.cssec .sb p.tx b{font-weight:600}
665/* segment sub-heads inside a long-form paragraph (e.g. "[PUBG MOBILE JP]:"
666   markers in a multi-project case) read as a small label line above the
667   copy instead of inline bracket text — b.seglabel is still phrasing
668   content, so display:block here is valid, it just breaks its own line. */
669.cssec .sb p.tx b.seglabel{display:block;margin:0 0 .5em;font-weight:600;font-size:1.15em;letter-spacing:-.005em}
670/* extra breathing room before a paragraph that starts a new segment (i.e.
671   the previous paragraph's copy ends and a seglabel begins) — bigger than
672   the ordinary paragraph-to-paragraph gap above, so a new segment reads as
673   a real break. One shared rule, so the gap stays the same size on every
674   case detail page rather than being tuned per case. Skipped on the very
675   first paragraph of a section so it doesn't add space under the heading
676   metarow when a case's first paragraph happens to open with a segment. */
677.cssec .sb p.tx:has(>
677b.seglabel):not(:first-child){margin-top:3em}
678.cssec .sb p.tx a{color:var(--blue);text-decoration:underline;text-underline-offset:2px}
679.cssec .sb p.tx a:hover{opacity:.75}
680/* Korean body copy: the Latin tracking/leading tuned for Inter Tight reads
681   dense in Hangul, so when a case page is actually showing Korean text,
682   loosen to the letter-spacing/line-height/ink Google uses for CJK body
683   text (0 tracking, ~1.7 leading, and a softened near-black rather than
684   pure #000) — font itself is untouched. */
685.casepage.ko .cssec .body,.casepage.ko .cssec .body p,.casepage.ko .cssec .sb>p.tx{letter-spacing:0;line-height:1.75;color:rgba(0,0,0,.87)}
686/* why/what stays on the English letter-spacing/line-height in Korean too,
687   per the site owner's call — only the long-form case body (above) gets
688   the loosened CJK treatment. */
689.cssec .metarow{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:clamp(14px,2vw,28px);border-bottom:1px solid rgba(0,0,0,.15);padding:0 0 clamp(14px,2vh,22px);margin-bottom:clamp(40px,7vh,88px);font-size:clamp(13px,.98vw,16px);line-height:1.4}
690.cssec .metarow .k{color:var(--grey);display:block;margin-bottom:.3em;font-size:.9em}
691.cssec .chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:.4em}
692.cssec .chips span{font-size:.8em;font-weight:600;padding:.25em .65em;border:1px solid rgba(0,0,0,.3);border-radius:4px}
693/* media stack: one card centred and frontmost, the next waiting behind it;
694   scrolling the wheel over the stack (or a vertical touch drag) swaps which
695   card is in front instead of the page scrolling */
696.cssec .media{position:relative;height:clamp(340px,54vh,620px);margin-top:clamp(40px,7vh,88px);touch-action:pan-x}
697.cssec .media:empty,.cssec .mnav:empty{display:none}
698/* stack sitting inline under a segment label rather than under the whole
699   section: tighter above so it stays bonded to its label, clear space below
700   before the copy resumes */
701.cssec .body>.media{height:clamp(240px,34vh,400px);margin-top:clamp(16px,2.4vh,26px)}
702.cssec .body>.mnav{margin-bottom:clamp(30px,4.5vh,48px)}
703/* inline figure in a long-form section: same card width as the media stack,
704   so a still and an embed sit at one shared measure */
705.cssec .csfig{width:min(600px,86vw);margin:clamp(22px,3.2vh,36px) auto clamp(26px,3.8vh,42px)}
706.cssec .csfig img{width:100%;height:auto;display:block}
707.cssec .csfig .video{aspect-ratio:16/9;background:var(--ink)}
708.cssec .csfig .video iframe{width:100%;height:100%;border:0;display:block}
709.cssec .media .mcard{position:absolute;left:50%;top:50%;width:min(600px,86vw);max-height:100%;overflow:hidden;background:var(--lavender);box-shadow:0 22px 46px -22px rgba(0,0,0,.28),0 4px 14px -6px rgba(0,0,0,.12);opacity:0;pointer-events:none;transform:translate(-50%,-50%);transition:transform .55s var(--ease-out),opacity .5s var(--ease-out)}
710.cssec .media .mcard.front{opacity:1;pointer-events:auto;z-index:3;transform:translate(-50%,-50%)}
711.cssec .media .mcard.next{opacity:.55;z-index:2;transform:translate(-42%,-58%) scale(.92)}
712.cssec .media img{width:100%;height:auto;display:block;pointer-events:none;user-select:none}
713.cssec .media .video{aspect-ratio:16/9;background:var(--ink)}
714.cssec .media .video iframe{width:100%;height:100%;border:0;display:block}
715.cssec .mnav{display:flex;justify-content:center;margin-top:clamp(12px,2vh,20px)}
716.cssec .mnav span{width:22px;height:22px;display:flex;align-items:center;justify-content:center;cursor:pointer}
717.cssec .mnav span::before{content:"";width:6px;height:6px;border-radius:50%;background:rgba(0,0,0,.18);transition:background .3s,transform .3s}
718.cssec .mnav span.on::before{background:var(--ink);transform:scale(1.3)}
719.casepage .disc{max-width:1480px;margin:clamp(24px,4vh,48px) auto 0;color:var(--grey);font-size:clamp(12px,.9vw,14px);line-height:1.4;text-align:center}
720.casepage .disc b{color:inherit;font-weight:inherit}
721/* visualisation blocks inside the case page: static flow instead of the slide's stacked steps */
722.casepage .vzw{display:block;height:auto;min-height:0;margin:clamp(40px,7vh,88px) 0;padding:0}
723.casepage .vzw .step{position:relative;inset:auto;height:auto;display:block;visibility:visible;opacity:0;transform:translateY(22px);transition:opacity .7s var(--ease-out),transform .7s var(--ease-out)}
724.casepage .vzw .step.on{opacity:1;transform:none}
725.casepage .vzw .chartcol,.casepage .vzw .insight-grid,.casepage .vzw .board,.casepage .vzw .lib,.casepage .vzw .result,.casepage .vzw .anatomy,.casepage .vzw .ltv{height:auto;min-height:0}
726.casepage .vzw .scatter svg,.casepage .vzw .daily svg,.casepage .vzw .ltv svg,.casepage .vzw .anatomy svg{height:auto;width:100%}
727/* cr's statements were moved below their chart, so it reads as a single
728   column; ce keeps its original chart-beside-readout split (.insight-grid.ce
729   further down sets its own 2-column ratio) */
730#vz-ub-3 .insight-grid{grid-template-columns:1fr;align-items:start}
731.casepage .vzw .statements{display:flex;flex-direction:column;gap:clamp(14px,2.6vh,30px);margin-top:clamp(20px,3vh,32px)}
732.casepage .vzw .result{padding:clamp(12px,2vh,24px) 0}
733.casepage .vzw .result .giant{font-size:clamp(80px,9vw,180px)}
734.casepage .vzw .result.two .giant,.casepage .vzw .result.three .giant{font-size:clamp(56px,6.4vw,124px)}
735.casepage .vzw .groups{align-content:start}
736.casepage .vzw .chartcol .ctrls{justify-content:center;margin-top:clamp(12px,2vh,20px)}
737.casepage .cssec .sb>.vzw:first-child{margin-top:0}
738/* The proposal overlay reuses every .casepage style; only the direction of
739   the open/close move differs, so it reads as a sibling of the case pages. */
740.planpage{transform:translateX(100%);transition:transform .9s var(--ease-in-out),visibility 0s .9s}
741.planpage.open{transform:none;transition:transform .9s var(--ease-in-out),visibility 0s}
742.planpage .pvenue{max-width:1480px;margin:0 auto;padding-top:clamp(26px,4vh,52px)}
743.planpage .phead{max-width:1480px;margin:0 auto;padding-top:clamp(34px,6vh,72px)}
744.planpage .phead .lab{color:var(--grey);font-size:clamp(12px,.95vw,15px);margin-bottom:clamp(10px,1.6vh,18px)}
745.planpage .phead h1{font-size:clamp(48px,7vw,132px);font-weight:700;letter-spacing:-.055em;line-height:.9;margin:0 0 clamp(20px,3.5vh,44px);max-width:18ch}
746.cssec .sb ul.plist{margin:0 0 1.8em;padding:0;list-style:none;display:flex;flex-direction:column;gap:.85em}
747.cssec .sb ul.plist li{position:relative;padding-left:1.2em;font-size:clamp(13px,.98vw,16px);line-height:1.55;letter-spacing:-.005em}
748.cssec .sb ul.plist li::before{content:"";position:absolute;left:0;top:.62em;width:5px;height:5px;border-radius:50%;background:var(--ink)}
749.cssec .sb ul.plist li b{font-weight:600}
750/* bullet with its own bold heading: heading on one line, detail on the next */
751.cssec .sb ul.plist li b.lih{display:block;margin-bottom:.15em}
752.casepage.ko .cssec .sb ul.plist li{letter-spacing:0;line-height:1.75;color:rgba(0,0,0,.87);word-break:keep-all}
753/* crowd calculation block in the proposal: conditions table, formulas, result */
754.cssec .sb .calc{margin:0 0 1.8em;font-size:clamp(13px,.98vw,16px);line-height:1.5}
755.cssec .sb .calc-h{font-weight:600;font-size:clamp(13px,.98vw,16px);margin:2.4em 0 .9em}
756.cssec .sb .calc-h:first-child{margin-top:.4em}
757.cssec .sb .calc a{color:var(--blue);text-decoration:underline;text-underline-offset:2px}
758.cssec .sb .calc a:hover{opacity:.75}
759.cssec .sb .calc .tag{display:inline-block;margin-left:.4em;padding:0 .4em;border:1px solid rgba(0,0,0,.3);border-radius:4px;font-size:.8em;font-weight:600;color:var(--grey);vertical-align:.1em}
760.cssec .sb .ctab{border-top:1.5px solid var(--ink)}
761.cssec .sb .ctab .tr{display:grid;grid-template-columns:1fr 1.1fr 1.3fr 2fr;gap:0 1.2em;padding:.75em 0;border-bottom:1px solid rgba(0,0,0,.12)}
762.cssec .sb .ctab .th{color:var(--grey);font-weight:600;padding:.6em 0}
763.cssec .sb .ctab .k,.cssec .sb .ctab .v{font-weight:600}
764.cssec .sb .ctab .use,.cssec .sb .ctab .src{color:rgba(0,0,0,.72)}
765.cssec .sb .cf{display:flex;flex-direction:column;gap:1em;border-left:2px solid var(--ink);padding:.2em 0 .2em 1.1em}
766.cssec .sb .cf .row{display:grid;grid-template-columns:9em 1fr;gap:0 1.2em}
767.cssec .sb .cf .k{color:var(--grey);font-weight:600}
768.cssec .sb .cf .e{font-variant-numeric:tabular-nums;font-size:1.06em}
769.cssec .sb .cf .n{color:var(--grey);font-size:.9em;margin-top:.15em}
770.cssec .sb .calc p.cap{color:var(--grey);font-size:clamp(12px,.86vw,14px);margin:4px 0 1.6em}
771.cssec .sb .cres{display:grid;grid-template-columns:repeat(4,1fr);gap:1em;border-top:1.5px solid var(--ink);padding-top:1em;margin-top:.6em}
772.cssec .sb .cres b{display:block;font-size:clamp(22px,2vw,34px);font-weight:700;letter-spacing:-.03em;line-height:1.1;font-variant-numeric:tabular-nums}
773.cssec .sb .cres span{display:block;color:var(--grey);margin-top:.35em}
774.casepage.ko .cssec .sb .calc{word-break:keep-all}
775@media (max-width:900px){
776  .cssec .sb .ctab .th{display:none}
777  .cssec .sb .ctab .tr{grid-template-columns:1fr;gap:.2em}
778  .cssec .sb .ctab .src{font-size:.92em}
779  .cssec .sb .cf .row{grid-template-columns:1fr;gap:.2em}
780  .cssec .sb .cres{grid-template-columns:repeat(2,1fr);row-gap:1.2em}
781}
782/* proposal page: venue mockup gallery under the title, the same card stack as
783   a case page's media (it borrows .cssec so those rules apply unchanged) */
784.planpage .pgal{display:block;border-top:0;padding:0;margin-bottom:clamp(40px,7vh,88px)}
785.planpage .pgal .media{margin-top:0}
786.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
787/* heatmaps A/B (user-behaviour case): plain table + CSS, no chart library */
788.hm-head{margin-bottom:clamp(14px,2.2vh,22px)}
789.heatmap{text-align:center}
790.hm-title{margin:0 0 .3em;font-weight:500;font-size:clamp(16px,1.3vw,22px);letter-spacing:-.02em;line-height:1.2}
791.hm-sub{margin:0;color:var(--grey);font-size:clamp(12px,.9vw,14px);line-height:1.35}
792.hm-scroll{overflow-x:auto;padding-bottom:4px}
793.hm-table{width:100%;border-collapse:separate;border-spacing:3px;table-layout:fixed}
794.hm-table th,.hm-table td{border-radius:4px;font-variant-numeric:tabular-nums}
795.hm-table thead th{font-weight:400;color:var(--grey);text-align:center;padding-bottom:2px}
796.hm-table thead th.hm-launch{font-weight:500;color:var(--ink);border-left:1px dashed var(--rule)}
797.hm-table tbody td.hm-launch{border-left:1px dashed var(--rule)}
798.hm-table .hm-rowhead{text-align:left}
799.hm-table .hm-axis-row th{font-size:10px;font-weight:400;color:var(--grey);letter-spacing:.02em;padding-bottom:6px}
800.hm-table th[scope="row"]{text-align:left;font-weight:500;padding-right:8px;white-space:nowrap}
801.hm-table td{text-align:center;font-weight:500;cursor:default}
802.hm-a td,.hm-a th{font-size:12px}
803.hm-a td{height:30px}
804.hm-b td,.hm-b th{font-size:13px}
805.hm-b td{height:38px}
806.hm-table tr.hm-gap td{height:6px;padding:0;border:0;background:transparent}
807@media (max-width:700px){.hm-table th[scope="row"]{white-space:normal}}
808.hm-legend{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:clamp(12px,1.8vh,18px);font-size:12px;color:var(--grey)}
809.hm-legend-bar{display:flex}
810.hm-legend-bar i{display:block;width:22px;height:10px}
811.hm-legend-bar i:first-child{border-radius:4px 0 0 4px}
812.hm-legend-bar i:last-child{border-radius:0 4px 4px 0}
813.hm-legend.wrap{flex-wrap:wrap;row-gap:8px}
814.hm-chip{display:inline-flex;align-items:center;gap:6px}
815.hm-chip i{display:block;width:12px;height:12px;border-radius:3px}
816.hm-info{margin:10px 0 0;font-size:clamp(12px,.9vw,14px);min-height:1.4em}
817@media (max-width:900px){.whygrid{grid-template-columns:1fr}.cssec{grid-template-columns:1fr}.deskline-item,.deskline-item:nth-child(2){margin-left:0;width:100%}}
818
819/* =========================================================
820   RESPONSIVE (PC first; tablet/mobile are graceful, not pixel-perfect)
821   ========================================================= */
822@media (max-width:900px){
823  .hello,.worked,.ai,.contact{grid-template-columns:1fr}
824  .hello .pic{max-height:34vh;aspect-ratio:auto;height:34vh}
825  .whatido .grid{grid-template-columns:1fr 1fr}
826  .whatido .grid>span{text-align:left!important}
827  .contents ol{margin-left:0}
828  .tools .body p{white-space:normal}
829  .stats .lead,.stats .nums{grid-template-columns:1fr 1fr}
830  /* one column on a phone: the toggle rides at the top right, then the copy,
831     then the AI notes. The meta list is dropped — every line of it is in the
832     proposal itself, which is one tap away. */
833  .ai{display:flex;flex-direction:column;gap:0}
834  .ai .right{display:contents}
835  .ai .right .topright{order:-1;align-self:flex-end;align-items:flex-end;width:auto;margin:0 0 clamp(6px,1.2vh,12px)}
836  .ai .left{order:0}
837  .ai .aiuse{order:1;margin-top:clamp(14px,2.4vh,24px);margin-bottom:clamp(14px,2.4vh,24px)}
838  .ai .open{order:2;align-self:flex-end}
839  .ai h2{font-size:clamp(30px,8.6vw,40px)!important}
840  /* the concept line never wraps, so on the narrowest phones it sets its own size */
841  .ai .q{margin-top:clamp(12px,2vh,20px);font-size:clamp(14px,4.2vw,22px)}
842  .ai .ins{margin-top:.7em;font-size:clamp(11px,3vw,13px);line-height:1.4}
843  .ai .open{margin-top:clamp(12px,2vh,20px)}
844  .ai .aiuse h4{font-size:clamp(14px,4.2vw,22px)}
845  .ai .aiuse ul{margin-top:.5em}
846  .ai .aiuse ul{gap:.45em}
847  .ai .aiuse li{font-size:clamp(11px,3vw,13px);line-height:1.4}
848  .chart .plot{grid-template-columns:repeat(7,1fr);height:34vh}
849  .projects .list{grid-template-columns:1fr}
850  .projects .list.single{max-width:100%}
851  .detail .sec{grid-template-columns:1fr}
852  .contact{padding-right:var(--gutter)}
853  .contact .right{display:none}
854  .cover .contact{display:none}
855}
856/* phones: the headline and its paragraph stop sharing a row (side by side they
857   squeeze the copy into a sliver and the slide overflows), and each figure sits
858   beside its own line of copy */
859@media (max-width:760px){
860  .stats{padding-top:calc(var(--top)*1.35)}
861  .stats .h-caps{top:0}
862  .stats .lead{grid-template-columns:1fr;gap:clamp(12px,2.4vh,20px);align-items:start}
863  .stats .leadright{gap:clamp(10px,1.6vh,16px)}
864  .stats .lead .body{max-width:none;margin-bottom:clamp(8px,1.6vh,14px);top:0;left:0}
865  .stats .nums.three{grid-template-columns:1fr;gap:clamp(10px,1.8vh,16px)}
866  .stats .nums.three .num{display:grid;grid-template-columns:auto 1fr;column-gap:14px;align-items:baseline}
867  .stats .nums.three .num .v{font-size:clamp(32px,9vw,44px);line-height:1}
868  .stats .nums.three .num .src{grid-column:2;margin-top:0}
869  .stats .nums.three .num .k{grid-column:2;max-width:none;margin-top:.2em}
870}
871@media (prefers-reduced-motion:reduce){
872  *,*::before,*::after{transition-duration:.01ms!important;animation:none!important;transition-delay:0s!important}
873  .mask>.line,.cover .word .l i,.chart .col .bar{transform:none!important}
874  .fade,.kw{opacity:1!important;filter:none!important}
875  .deck{scroll-behavior:auto}
876}
877:focus-visible{outline:2px solid var(--blue);outline-offset:4px}
878
879/* =========================================================
880   PRINT / PDF
881   One slide per 16:9 page, every reveal in its finished state. When a case
882   page or the proposal is open, only that overlay prints, as a flowing
883   document. The JS half (typing, counters, chart steps) is preparePrint().
884   The reference export is tools/pdf/export.js, which renders at exactly
885   1920x1080 so the JS-measured layout (cover word, page-10 alignment)
886   matches the page; Cmd+P from another window size is close but not exact.
887   ========================================================= */
888@page{size:1920px 1080px;margin:0}
889/* overlays flow across pages, so they get top/bottom room the slides don't */
890@page doc{size:1920px 1080px;margin:64px 0 56px}
891.print-yt,.print-head,.print-break{display:none}
892@media print{
893  *,*::before,*::after{transition:none!important;animation:none!important}
894  html,body{height:auto!important;overflow:visible!important}
895  .mask>.line,.cover .word .l i,.chart .col .bar{transform:none!important}
896  .fade,.kw,.rv,.casepage .vzw .step{opacity:1!important;filter:none!important;transform:none!important}
897  /* screen-only controls and click prompts; hidden but still taking their
898     space, so nothing around them shifts */
899  .langtoggle,.casepage .close,#deskHint,#gpBtn{visibility:hidden!important}
900  /* the fixed header relies on mix-blend-mode to stay legible on light and
901     dark slides, which doesn't survive into PDF; each slide gets its own
902     copy instead (added by preparePrint), coloured for that slide */
903  .chrome{display:none!important}
904  .slide>.print-head{display:flex;position:absolute;top:var(--top);left:var(--gutter);right:var(--gutter);justify-content:space-between;font-size:clamp(13px,1vw,17px);letter-spacing:-.01em;transform:translateY(-6px);color:var(--ink);z-index:5}
905  .slide.dark>.print-head{color:#fff}
906  .deck{height:auto!important;overflow:visible!important;scroll-snap-type:none!important}
907  .slide{break-after:page;break-inside:avoid}
908
909  /* an open overlay prints on its own */
910  body:has(.casepage.open) .deck,body:has(.casepage.open) .chrome,
911  body:has(.detail.open) .deck,body:has(.detail.open) .chrome{display:none!important}
912  .casepage:not(.open),.detail:not(.open),#vizStore{display:none!important}
913  .casepage.open{position:static!important;transform:none!important;height:auto!important;inset:auto!important;page:doc}
914  .casepage .cscroll{height:auto!important;overflow:visible!important;padding-bottom:0!important}
915  /* the closing note stays with the last section instead of taking a page alone */
916  .casepage .disc{margin-top:20px!important;break-before:avoid}
917  .cssec .sh h3{position:static!important}
918  /* sections print as plain block flow with the heading pinned in the left
919     column, rather than as a two-column grid: Chrome splits block flow
920     across pages reliably, whereas a grid row can be pushed whole to the
921     next page and leave an empty frame behind. Same look, same widths
922     (1fr : 3.4fr with a gutter between). */
923  .cssec{display:block!important;position:relative;padding-left:calc((100% - var(--gutter)) / 4.4 + var(--gutter))!important}
924  .cssec>.sh{position:absolute;left:0;width:calc((100% - var(--gutter)) / 4.4)}
925  /* page breaks: never through a paragraph, list item, table row, figure or
926     chart; a heading or segment label never ends a page on its own */
927  .cssec .sh h3,.cssec .sb p.tx b.seglabel,.hm-head,.cssec .sb .calc-h{break-after:avoid}
928  .cssec .sb p.tx:has(>b.seglabel:last-child){break-after:avoid}
929  .cssec .sb p.tx,.cssec .sb ul.plist li,.cssec .body p,.cssec .sb .ctab .tr,.cssec .sb .cf .row,.cssec .sb .cres,
930  .whygrid>div,.csfig,.vzw .step,.cssec .media .mcard,.hm-table,.disc{break-inside:avoid}
931  .cssec p,.cssec li{orphans:3;widows:3}
932  /* a paragraph long enough to leave a big gap if moved whole may split,
933     with at least three lines either side (flagged by preparePrint) */
934  .cssec .print-long{break-inside:auto!important}
935  /* every section opens a page, so its rule and heading always sit at the
936     top instead of being left behind at the foot of the previous one. The
937     break sits on an empty marker before the section (added by
938     preparePrint): forcing it on the two-column section itself can leave
939     the section's top rule alone on a page. */
940  .print-break{display:block;break-before:page;height:0}
941
942  /* card stacks: every card laid out, not just the front one */
943  .cssec .media{height:auto!important;display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;align-items:start}
944  .cssec .media .mcard{position:static!important;transform:none!important;opacity:1!important;width:auto!important;max-height:none!important;box-shadow:none!important}
945  .cssec .mnav{display:none!important}
946  /* the proposal's mockups already sit inside their own sections below */
947  .planpage .pgal{display:none!important}
948
949  /* a video can't play on paper: thumbnail + link instead */
950  .video{display:none!important}
951  .print-yt{display:block;position:relative;color:var(--ink);text-decoration:none}
952  .print-yt img{width:100%;display:block;aspect-ratio:16/9;object-fit:cover}
953  /* a video stand-in only needs to show what the video is, not fill the column */
954  .csfig:has(.print-yt){width:min(480px,70vw)!important}
955
956  /* Hand-built appendix pages (paginatePrint, used by tools/pdf/export.js):
957     each page is one block the height of the printable area, its content
958     centred vertically, with the section name repeated in the left column
959     so a reader always knows where they are. A page holding more than one
960     page's worth (rare) is allowed to run on. */
961  /* the section name sits at the same spot on every page (a fixed band at
962     the top, under the section's opening rule on its first page); only the
963     content below it is centred */
964  .cssec.print-page{display:flex!important;flex-direction:column;justify-content:center;position:relative;min-height:var(--pp-h,0px);padding:var(--pp-band,0px) 0 0!important;border:0!important;break-before:page;break-inside:avoid;box-sizing:border-box}
965  .cssec.print-page.first{border-top:1.5px solid var(--ink)!important}
966  .cssec.print-page.tall{break-inside:auto;justify-content:flex-start}
967  .print-page>.pp-in{display:grid;grid-template-columns:calc((100% - var(--gutter)) / 4.4) minmax(0,1fr);column-gap:var(--gutter);align-items:start}
968  .print-page>.pp-in>.sh{position:absolute!important;top:var(--pp-band,0px);left:0;width:calc((100% - var(--gutter)) / 4.4)}
969  .print-page>.pp-in>.sb{grid-column:2}
970  .print-page .sb>:last-child,.print-page .sb>:last-child>:last-child{margin-bottom:0!important}
971  .casepage .vzw{margin:20px 0!important}
972  .print-yt span{display:block;margin-top:.5em;color:var(--grey);font-size:14px}
973}
974</style>
975</head>
976<body>
977
978<!-- fixed chrome -->
979<div class="chrome" id="chrome">
980  <div class="head"><span>Portfolio 2026</span><span id="headDate"></span></div>
981  <div class="counter"><span id="cIdx">01</span><span class="bar"><i id="cBar"></i></span><span id="cTot">00</span></div>
982</div>
983
984<main class="deck" id="deck">
985
986<!-- 01 COVER ============================================ -->
987<section class="slide cover" id="cover" data-name="Cover">
988  <div class="top">
989    <div class="meta">
990      <svg class="mark" viewBox="0 0 44 22" fill="none" stroke="currentColor" stroke-width="1.2"><ellipse cx="22" cy="11" rx="21" ry="10"/><ellipse cx="22" cy="11" rx="10" ry="10"/><ellipse cx="22" cy="11" rx="4" ry="10"/><line x1="1" y1="11" x2="43" y2="11"/><line x1="22" y1="1" x2="22" y2="21"/></svg>
991      <div class="l2 type" data-type="Portfolio 2026"></div>
992      <div class="l3 type" id="presentedBy" data-type="Presented by: Doyoung Park"></div>
993    </div>
994    <div class="langtoggle" role="group" aria-label="Language"><button data-lang="en" aria-pressed="true">EN</button><button data-lang="ko" aria-pressed="false">KO</button></div>
995  </div>
996  <div>
997    <div class="foot">
998      <div class="roles">
999        <span class="fade" id="role1" style="--i:0">Marketer</span><span class="sl fade" style="--i:1">/</span><span class="fade" id="role2" style="--i:2">Communicator</span><span class="sl fade" style="--i:3">/</span><span class="fade" id="role3" style="--i:4">Creative Manager</span>
1000      </div>
1001      <div class="email fade" style="--i:3">[email protected]</div>
1002    </div>
1003    <h1 class="word" id="bigword" aria-label="Portfolio"></h1>
1004  </div>
1005</section>
1006
1007<!-- 02 HELLO ============================================ -->
1008<section class="slide hello" id="hello" data-name="Hello">
1009  <div class="pic"><img src="assets/dp.webp" alt="Doyoung Park" onerror="this.style.display='none'"></div>
1010  <div class="txt">
1011    <h2 class="h-huge">
1012      <span class="mask"><span class="line" id="helloLine0" style="--i:0">Hello!</span></span>
1013      <span class="mask"><span class="line" id="helloLine1" style="--i:1">I'm Doyoung Park</span></span>
1014    </h2>
1015    <div class="body">
1016      <p class="fade" id="bioText" style="--i:0">I’m a gamer, and a marketer who acts on data.<br>I’ve spent over five years running publishing pipelines across Korea, Japan and Europe, and building KPI frameworks from scratch.
1017Beyond that, I’ve led product, investment and budget decisions grounded in market and performance data.</p>
1018    </div>
1019    <div class="info fade" style="--i:2">
1020      <div class="col">
1021        <h3 id="eduHeader">Education</h3>
1022        <ul>
1023          <li><b>Goldsmiths, University of London</b><span id="eduDegree1">BA (Hons) Fine Arts — First-Class Honours</span></li>
1024          <li><b>Kenkoku High School, Osaka</b><span>High School Diploma</span></li>
1025        </ul>
1026      </div>
1027      <div class="col">
1028        <h3 id="langHeader">Languages</h3>
1029        <ul class="langs">
1030          <li><b id="langName1">Korean</b> - <span id="langLevel1">Native</span></li>
1031          <li><b id="langName2">Japanese</b> - <span id="langLevel2">Native</span></li>
1032          <li><b id="langName3">English</b> - <span id="langLevel3">Professional working proficiency</span></li>
1033          <li><b id="langName4">German</b> - <span id="langLevel4">Basic communication</span></li>
1034          <li><b id="langName5">Chinese</b> - <span id="langLevel5">Basic communication</span></li>
1035        </ul>
1036      </div>
1037    </div>
1038  </div>
1039</section>
1040
1041<!-- 03 WHAT I DO ======================================== -->
1042<section class="slide whatido" id="whatido" data-name="What I do">
1043  <div>
1044    <h2 class="h-display">
1045      <span class="mask"><span class="line" id="whatidoLine0" style="--i:0">What I do?</span></span>
1046      <span class="mask"><span class="line" id="whatidoLine1" style="--i:1">(and love doing)</span></span>
1047    </h2>
1048    <div class="body fade" style="--i:1">
1049      <p id="whatidoBody">My core strength is understanding users and content through data, and using those insights to improve and manage key metrics. Throughout my career, my main role has been analysing user behaviour and content characteristics to draw out insights, then testing and validating them to build insights and pipelines that genuinely move the metrics.</p>
1050    </div>
1051  </div>
1052  <div class="grid">
1053    <span class="kw" id="kw0" style="--i:0">Performance Marketing</span>
1054    <span class="kw" id="kw1" style="--i:1">Creative Strategy</span>
1055    <span class="kw" id="kw2" style="--i:2">Brand Marketing</span>
1056    <span class="kw" id="kw3" style="--i:3">Market Validation</span>
1057    <span class="kw" id="kw4" style="--i:4">Store Optimisation</span>
1058    <span class="kw" id="kw5" style="--i:5">Budget Planning</span>
1059    <span class="kw" id="kw6" style="--i:6">Competitor Intelligence</span>
1060    <span class="kw" id="kw7" style="--i:7">Business Development</span>
1061    <span class="kw" id="kw8" style="--i:8">IP Partnerships</span>
1062    <span class="kw" id="kw9" style="--i:9">Community Activation</span>
1063    <span class="kw" id="kw10" style="--i:10">Dashboard Automation</span>
1064    <span class="kw" id="kw11" style="--i:11">AI-assisted Analysis</span>
1065  </div>
1066</section>
1067
1068<!-- 04 TOOLS ============================================ -->
1069<section class="slide tools" id="tools" data-name="Tools">
1070  <h2 class="h-display">
1071    <span class="mask"><span class="line" id="toolsLine0" style="--i:0">Tools</span></span>
1072    <span class="mask"><span class="line" id="toolsLine1" style="--i:1">I'm fluent in.</span></span>
1073  </h2>
1074  <div class="body">
1075    <p class="fade" id="toolsP0" style="--i:0"><b>Tableau, Excel, MySQL, Google Analytics, Data.ai, Adjust</b> for reading the data.</p>
1076    <p class="fade" id="toolsP1" style="--i:1"><b>Google Ads, Meta, TikTok, Moloco, Apple Search Ads, X Ads</b> for spending it well.</p>
1077    <p class="fade" id="toolsP2" style="--i:2"><b>Photoshop, After Effects, InDesign</b> for making the things people see.</p>
1078    <p class="fade" id="toolsP3" style="--i:3"><b>Notion, Slack, Jira</b> for keeping studios, agencies and partners aligned.</p>
1079    <p class="fade" id="toolsP4" style="--i:4"><b>Claude Code, Codex, Python, Whisper, PySceneDetect, FLUX, NovelAI, Seedance, Grok</b> for exploring what AI can add to analysis and creative work.</p>
1080  </div>
1081</section>
1082
1083<!-- 05 WORKED =========================================== -->
1084<section class="slide worked" id="worked" data-name="Where I've worked">
1085  <h2 class="h-display">
1086    <span class="mask"><span class="line" id="workedLine0" style="--i:0">Where</span></span>
1087    <span class="mask"><span class="line" id="workedLine1" style="--i:1">I've worked.</span></span>
1088  </h2>
1089  <div class="tl">
1090    <span class="y fade" style="--i:0">2024 / 2026</span><div class="r fade" style="--i:0"><b>Marketer &amp; Publishing Manager</b><span>SANDY FLOOR</span></div>
1091    <span class="y fade" style="--i:1">2024 / 2024</span><div class="r fade" style="--i:1"><b>PUBG MOBILE Japan Marketer</b><span>KRAFTON</span></div>
1092    <span class="y fade" style="--i:2">2020 / 2023</span><div class="r fade" style="--i:2"><b>Creative Director &amp; Marketer</b><span>GAMEDUO</span></div>
1093  </div>
1094</section>
1095
1096<!-- 06 CONTENTS ========================================= -->
1097<section class="slide contents" id="contents" data-name="Contents">
1098  <h2 class="h-display">
1099    <span class="mask"><span class="line" id="contentsLine0" style="--i:0">What you will find</span></span>
1100    <span class="mask"><span class="line" id="contentsLine1" style="--i:1">inside?</span></span>
1101  </h2>
1102  <ol>
1103    <li style="--i:0"><span class="n fade" style="--i:1">1</span><a class="fade" style="--i:1" href="#ch-data">Data-Driven Marketing</a></li>
1104    <li style="--i:1"><span class="n fade" style="--i:2">2</span><a class="fade" style="--i:2" href="#ch-bd">Business Development</a></li>
1105    <li style="--i:2"><span class="n fade" style="--i:3">3</span><a class="fade" style="--i:3" href="#ch-ai">AI Exploration</a></li>
1106  </ol>
1107</section>
1108
1109<!-- 07 CHAPTER: DATA ==================================== -->
1110<section class="slide dark chapter" id="ch-data" data-name="Data-Driven Marketing">
1111  <button class="arrow" aria-label="Next" data-next><svg viewBox="0 0 120 120"><path d="M8 60 H110 M74 20 C78 40 92 54 110 60 C92 66 78 80 74 100"/></svg></button>
1112  <h2 class="h-chapter type" data-type="Data-Driven Marketing"></h2>
1113</section>
1114
1115<!-- 08 CHAPTER: BD ====================================== -->
1116<section class="slide desk" id="reports" data-name="Data-Driven Marketing · Reports">
1117  <div class="top">
1118    <div>
1119      <h2 class="h-huge"><span class="mask"><span class="line" style="--i:0">Data-Driven Marketing.</span></span></h2>
1120      <p class="hint fade" id="deskHint" style="--i:1">Click to see the detail.</p>
1121    </div>
1122    <div class="topright fade" style="--i:1">
1123      <div class="langtoggle" role="group" aria-label="Language"><button data-lang="en" aria-pressed="true">EN</button><button data-lang="ko" aria-pressed="false">KO</button></div>
1124    </div>
1125  </div>
1126  <ol class="deskline" id="deskCards"></ol>
1127</section>
1128
1129<section class="slide dark chapter" id="ch-bd" data-name="Business Development">
1130  <button class="arrow" aria-label="Next" data-next><svg viewBox="0 0 120 120"><path d="M8 60 H110 M74 20 C78 40 92 54 110 60 C92 66 78 80 74 100"/></svg></button>
1131  <h2 class="h-chapter type" data-type="Business Development"></h2>
1132</section>
1133
1134<section class="slide stats" id="bd-stats" data-name="Business Development · Numbers">
1135  <div class="lead">
1136    <h2 class="h-caps">
1137      <span class="mask"><span class="line" id="bdLine0" style="--i:0">Beyond the marketing.</span></span>
1138    </h2>
1139    <div class="leadright">
1140      <div class="topright fade" style="--i:1">
1141        <div class="langtoggle" role="group" aria-label="Language"><button data-lang="en" aria-pressed="true">EN</button><button data-lang="ko" aria-pressed="false">KO</button></div>
1142      </div>
1143      <p class="body fade" id="bdBody" style="--i:2">I sourced global partners and, through repeated meetings, game shows and negotiation, signed a China publishing deal.<br><br>At SANDY FLOOR I set up and ran the market test for a new title, Dark Bouncer, from A to Z. I then used marketing and in-game metrics to propose a business-model pivot that was actually adopted, which secured a separate marketing investment for the domestic market.</p>
1144    </div>
1145  </div>
1146  <div class="nums three">
1147    <div class="num fade" style="--i:1"><div class="v">23,000</div><div class="src" id="bdSrc2">Wishlist earned from China region</div><div class="k" id="bdK2">Closing the China publishing contract lifted PengPong's wishlist count in the Chinese market.</div></div>
1148    <div class="num fade" style="--i:2"><div class="v">$0.50</div><div class="src" id="bdSrc1">Market test</div><div class="k" id="bdK1">CPI held for a full month on Dark Bouncer's market test, the data behind a recommended IAA to IAP pivot.</div></div>
1149    <div class="num fade" style="--i:3"><div class="v">2</div><div class="src" id="bdSrc0">Awards from Gameshow</div><div class="k" id="bdK0">Took part in a range of international game shows and won G-STAR 2025 Player's Voice Choice and Clemmy's 2026.</div></div>
1150  </div>
1151</section>
1152
1153<!-- 09 CHAPTER: AI ====================================== -->
1154<section class="slide dark chapter" id="ch-ai" data-name="AI Exploration">
1155  <button class="arrow" aria-label="Next" data-next><svg viewBox="0 0 120 120"><path d="M8 60 H110 M74 20 C78 40 92 54 110 60 C92 66 78 80 74 100"/></svg></button>
1156  <h2 class="h-chapter type" data-type="AI Exploration"></h2>
1157</section>
1158
1159<section class="slide ai" id="ai-project" data-name="AI Exploration · Genshin popup proposal">
1160  <div class="left">
1161    <h2 class="h-huge" style="font-size:clamp(48px,6.2vw,108px)">
1162      <span class="mask"><span class="line" id="gpLine0" style="--i:0"></span></span>
1163      <span class="mask"><span class="line" id="gpLine1" style="--i:1"></span></span>
1164    </h2>
1165    <div class="lab fade" id="gpLab" style="--i:1"></div>
1166    <div class="q fade" style="--i:2" id="gpQ"></div>
1167    <div class="ins body fade" style="--i:3" id="gpIns"></div>
1168  </div>
1169  <div class="right">
1170    <div class="topright fade" style="--i:1">
1171      <div class="langtoggle" role="group" aria-label="Language"><button data-lang="en" aria-pressed="true">EN</button><button data-lang="ko" aria-pressed="false">KO</button></div>
1172    </div>
1173    <div class="aiuse fade" style="--i:2" id="gpAi"></div>
1174    <button class="open fade" style="--i:4" data-plan id="gpBtn"><span></span> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h18M13 4l8 8-8 8"/></svg></button>
1175  </div>
1176</section>
1177
1178<!-- 10 CONTACT ========================================== -->
1179<section class="slide dark contact" id="contact" data-name="Contact">
1180  <div class="left">
1181    <div>
1182      <h2 class="h-display">
1183        <span class="mask"><span class="line" style="--i:0">Let's start</span></span>
1184        <span class="mask"><span class="line" style="--i:1">something</span></span>
1185      </h2>
1186      <div class="links">
1187        <a class="fade" style="--i:0" href="mailto:[email protected]">[email protected]</a>
1188        <a class="fade" style="--i:1" href="https://www.linkedin.com/in/doyoung-park-721566248/" target="_blank" rel="noopener">linkedin.com/in/doyoung-park</a>
1189      </div>
1190    </div>
1191    <div class="social fade" style="--i:3">
1192      <b>Elsewhere</b>
1193      <a href="https://www.linkedin.com/in/doyoung-park-721566248/" target="_blank" rel="noopener">LinkedIn</a>
1194      <a href="https://www.doyoungpark.com" target="_blank" rel="noopener">doyoungpark.com</a>
1195    </div>
1196  </div>
1197  <div class="right"><img src="assets/dp.webp" alt="Doyoung Park" onerror="this.remove()"></div>
1198</section>
1199
1200</main>
1201
1202<!-- DETAIL OVERLAY ====================================== -->
1203<div id="vizStore" hidden>
1204<div class="viz stepslide vzw" id="vz-bc-2">
1205    <div class="step" data-step="2">
1206      <div class="result two">
1207        <div>
1208          <div class="giant"><span class="mask"><span class="line"><span data-count="8">0</span></span></span></div>
1209          <p class="rline"><span class="mask"><span class="line" style="--i:1">IP collaborations coordinated at once</span></span></p>
1210        </div>
1211        <div>
1212          <div class="giant"><span class="mask"><span class="line" style="--i:1">#<span data-count="8">0</span></span></span></div>
1213          <p class="rline"><span class="mask"><span class="line" style="--i:2">real-time trending rank on Japanese X, Dragon Ball Super microsite</span></span></p>
1214        </div>
1215      </div>
1216    </div>
1217</div>
1218<div class="viz stepslide vzw" id="vz-ub-0">
1219    <div class="step" data-step="0">
1220      <div class="heatmap" id="heatmapA"></div>
1221    </div>
1222</div>
1223<div class="viz stepslide vzw" id="vz-ub-1">
1224    <div class="step" data-step="1">
1225      <div class="heatmap" id="heatmapB"></div>
1226    </div>
1227</div>
1228<div class="viz stepslide vzw" id="vz-ub-2">
1229    <div class="step" data-step="2">
1230      <div class="result">
1231        <div class="giant"><span class="mask"><span class="line"><span data-count="130">0</span>%</span></span></div>
1232        <p class="rline"><span class="mask"><span class="line" style="--i:1">growth in managed media budget, year on year</span></span></p>
1233      </div>
1234    </div>
1235</div>
1236<div class="viz stepslide vzw" id="vz-ce-1">
1237    <div class="step" data-step="1">
1238      <div class="board">
1239        <div class="boardgrid">
1240          <div class="daily">
1241            <svg id="ceBoard" viewBox="0 0 760 320" preserveAspectRatio="xMidYMid meet" aria-label="Daily cost and ROI by campaign"></svg>
1242            <div class="legend"><span><i class="pillar"></i>COST</span><span><i class="line"></i>ROI</span></div>
1243          </div>
1244        </div>
1245      </div>
1246    </div>
1247</div>
1248<div class="viz stepslide vzw" id="vz-ce-0">
1249    <div class="step" data-step="0">
1250      <div class="insight-grid ce">
1251        <div class="ltv">
1252          <svg id="ceSvg" viewBox="0 0 760 340" preserveAspectRatio="xMidYMid meet" aria-label="Cumulative ROI by day since install. Drag to set the LTV window."></svg>
1253          <div class="legend"><span><i></i>Cumulative ROI</span><span><i class="k"></i>IAP</span><span><i class="g"></i>IAA</span><span class="drag">drag the point to set the LTV window</span></div>
1254        </div>
1255        <div class="readout">
1256          <p class="lead">Set the LTV window per title from its retention and payment timing, and the D1 ROI goal every campaign has to clear falls out of it.</p>
1257          <table>
1258            <tr><th>LTV window</th><td id="rWin"></td></tr>
1259            <tr class="goal"><th>D1 ROI goal, break-even at window</th><td class="big" id="rGoal"></td></tr>
1260            <tr><th>D1 ROI, live campaign</th><td id="rD1"></td></tr>
1261            <tr><th>Projected net UA revenue per $100K spent</th><td id="rNet"></td></tr>
1262          </table>
1263          <p class="verdict" id="rVerdict"></p>
1264        </div>
1265      </div>
1266    </div>
1267</div>
1268<div class="viz stepslide vzw" id="vz-ce-2">
1269    <div class="step" data-step="2">
1270      <div class="result two">
1271        <div>
1272          <div class="giant"><span class="mask"><span class="line">+<span data-count="90">0</span>%</span></span></div>
1273          <p class="rline"><span class="mask"><span class="line" style="--i:1">net UA revenue over the year</span></span></p>
1274        </div>
1275        <div>
1276          <div class="giant"><span class="mask"><span class="line" style="--i:1">−<span data-count="35">0</span>%</span></span></div>
1277          <p class="rline"><span class="mask"><span class="line" style="--i:2">average CPI across key countries and OS</span></span></p>
1278        </div>
1279      </div>
1280    </div>
1281</div>
1282<div class="viz stepslide vzw" id="vz-ub-3">
1283    <div class="step" data-step="3">
1284      <div class="insight-grid">
1285        <div class="anatomy">
1286          <div class="anhead"><span class="t">Top asset, retention by second</span></div>
1287          <svg id="crAnat" viewBox="0 0 760 300" preserveAspectRatio="xMidYMid meet" aria-label="Retention curve over the video timeline with sequence tags"></svg>
1288          <div class="legend anatlegend"><span><i class="dash"></i>Before</span><span><i class="solid"></i>After the sequence change</span></div>
1289        </div>
1290      </div>
1291    </div>
1292</div>
1293</div>
1294<div class="casepage" id="casepage" role="dialog" aria-modal="true" aria-labelledby="csClaim">
1295  <div class="cscroll" id="csScroll">
1296    <div class="bar"><button class="close" data-cclose><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h16M12 4l-8 8 8 8"/></svg><span data-i18n="back">Back to list</span></button><span class="grey" id="csLabel"></span></div>
1297    <header class="cshero">
1298      <h1 class="h-display rv" id="csClaim"></h1>
1299      <div class="whygrid">
1300        <div class="rv"><h4 data-i18n="why">Why it mattered</h4><p id="csWhy"></p></div>
1301        <div class="rv"><h4 data-i18n="enabled">What it made possible</h4><p id="csEnabled"></p></div>
1302      </div>
1303    </header>
1304    <section class="cssec"><div class="sh"><h3 data-i18n="background">
1304Background</h3></div><div class="sb"><div class="metarow" id="csMeta"></div><div class="body" id="csBg"></div><div class="media" id="csMedia"></div><div class="mnav" id="csMediaNav"></div></div></section>
1305    <section class="cssec"><div class="sh"><h3 data-i18n="plan">Planning &amp; Execution</h3></div><div class="sb" id="csPlan"></div></section>
1306    <section class="cssec"><div class="sh"><h3 data-i18n="result">Result</h3></div><div class="sb" id="csResult"></div></section>
1307    <p class="disc" id="csDisc"></p>
1308  </div>
1309</div>
1310<div class="detail" id="detail" role="dialog" aria-modal="true" aria-labelledby="dTitle">
1311  <div class="scroll" id="dScroll">
1312    <div class="bar">
1313      <button class="close" data-close><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h16M12 4l-8 8 8 8"/></svg>Back to deck</button>
1314      <span id="dChapter" class="grey"></span>
1315    </div>
1316    <article>
1317      <h2 class="h-huge rev" id="dTitle"></h2>
1318      <div class="metarow rev" id="dMeta"></div>
1319      <div class="carousel rev" id="dCar" hidden>
1320        <div class="fandeck" id="dTrack" role="list"></div>
1321        <p class="cap" id="dCap"></p>
1322      </div>
1323      <div id="dBody"></div>
1324    </article>
1325  </div>
1326</div>
1327
1328<div class="casepage planpage" id="planpage" role="dialog" aria-modal="true" aria-labelledby="gpTitle">
1329  <div class="cscroll" id="gpScroll">
1330    <div class="bar"><button class="close" data-pclose><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h16M12 4l-8 8 8 8"/></svg><span data-i18n="back">Back to list</span></button><span class="grey">AI Exploration</span></div>
1331    <header class="phead">
1332      <div class="lab rv" id="gpPlanLab"></div>
1333      <h1 class="rv" id="gpTitle"></h1>
1334    </header>
1335    <div class="pvenue">
1336      <div class="cssec pgal"><div class="media rv" id="gpMedia"></div><div class="mnav" id="gpMediaNav"></div></div>
1337    </div>
1338    <div id="gpBody"></div>
1339  </div>
1340</div>
1341
1342<script src="data.js"></script>
vendor: 1 bytes, line 1342
1342
1343<script src="data_analysis_cases_ko.js"></script>
vendor: 1 bytes, line 1343
1343
1344<script src="heatmap_data.js"></script>
vendor: 1 bytes, line 1344
1344
1345<script src="hero_ko.js"></script>
vendor: 1 bytes, line 1345
1345
1346<script src="genshin_popup.js"></script>
vendor: 1 bytes, line 1346
1346
1347<script>
1348/* =========================================================
1349   CONTENT CONFIG
1350   Everything editorial lives here or in data.js.
1351   ========================================================= */
1352const CHAPTERS = {
1353  data: { label:'Data-Driven Marketing', keys:['da-brand-community','da-user-behaviour','da-campaign-effectiveness'] },
1354  bd:   { label:'Business Development', keys:['sf-gts-launch','sf-darkbouncer','gamirror','darkbouncer-bd','gameshows','tokatonton-pr','sf-influencer-community','ip-collab','dbs-microsite','dentsu','gd-platform-am'] },
1355  ai:   { label:'AI Exploration', keys:['hoyoverse-pv'] }
1356};
1357
1358const ALL = Object.assign({}, typeof marketerProjects!=='undefined'?marketerProjects:{}, typeof communicatorProjects!=='undefined'?communicatorProjects:{}, typeof researcher!=='undefined'?researcher:{}, typeof dataAnalysisCases!=='undefined'?dataAnalysisCases:{});
1359const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
1360
1361/* =========================================================
1362   COVER: giant word, fitted to width, letter by letter
1363   ========================================================= */
1364const bigword = document.getElementById('bigword');
1365'Portfolio'.split('').forEach((ch,i)=>{ const s=document.createElement('span'); s.className='l'; s.innerHTML=`<i style="--i:${i}">${ch}</i>`; bigword.appendChild(s); });
1366function fitWord(){ bigword.style.fontSize='100px'; bigword.style.width='max-content'; const w=bigword.offsetWidth; bigword.style.width=''; const cw=bigword.parentElement.clientWidth; bigword.style.fontSize=(100*cw/w)+'px'; }
1367fitWord(); addEventListener('resize',fitWord); if(document.fonts) document.fonts.ready.then(fitWord);
1368document.getElementById('headDate').textContent='Doyoung Park';
1369
1370/* =========================================================
1371   TYPING
1372   ========================================================= */
1373const typers = new Map();
1374function typeLineBreaks(el,text){
1375  const breaks = new Set(); const words = text.split(' ');
1376  if (words.length<2) return breaks;
1377  el.textContent = text;
1378  const textNode = el.firstChild, range = document.createRange();
1379  let idx=0, prevTop=null;
1380  words.forEach((w,wi)=>{
1381    if (w.length){
1382      range.setStart(textNode,idx); range.setEnd(textNode,idx+w.length);
1383      const top = range.getBoundingClientRect().top;
1384      if (wi>0 && top!==prevTop) breaks.add(idx-1);
1385      prevTop = top;
1386    }
1387    idx += w.length+1;
1388  });
1389  return breaks;
1390}
1391function typeIn(el){
1392  if (typers.get(el)) return;
1393  const text = el.dataset.type; el.classList.remove('done');
1394  const breaks = typeLineBreaks(el,text);
1395  el.textContent='';
1396  const cur=document.createElement('span'); cur.className='cur'; el.appendChild(cur);
1397  if (reduce){
1398    let last=0;
1399    breaks.forEach(b=>{ el.insertBefore(document.createTextNode(text.slice(last,b)),cur); el.insertBefore(document.createElement('br'),cur); last=b+1; });
1400    el.insertBefore(document.createTextNode(text.slice(last)),cur);
1401    el.classList.add('done'); typers.set(el,true); return;
1402  }
1403  let i=0; const speed = text.length>16 ? 46 : 62;
1404  const t=setInterval(()=>{
1405    if (breaks.has(i)){ el.insertBefore(document.createElement('br'),cur); i++; }
1406    else { el.insertBefore(document.createTextNode(text[i]),cur); i++; }
1407    if(i>=text.length){clearInterval(t); el.classList.add('done');}
1408  }, speed);
1409  typers.set(el,t);
1410}
1411function typeReset(el){ const t=typers.get(el); if(t&&t!==true) clearInterval(t); typers.delete(el); el.textContent=''; }
1412
1413/* =========================================================
1414   COUNTERS
1415   ========================================================= */
1416function count(el,instant){
1417  const to=parseFloat(el.dataset.count), dec=+(el.dataset.dec||0), dur=1600, t0=performance.now();
1418  const fmt=v=>dec?v.toFixed(dec):Math.round(v).toLocaleString('en-GB');
1419  if(reduce||instant){ el.textContent=fmt(to); return; }
1420  (function f(now){ const p=Math.min(1,(now-t0)/dur); const e=1-Math.pow(1-p,4); el.textContent=fmt(to*e); if(p<1) requestAnimationFrame(f); })(t0);
1421}
1422
1423/* =========================================================
1424   PROJECT LISTS
1425   ========================================================= */
1426Object.entries(CHAPTERS).forEach(([id,ch])=>{
1427  const list=document.querySelector(`.list[data-chapter="${id}"]`); if(!list) return;
1428  const cases=ch.keys.every(k=>ALL[k]&&ALL[k].metric);
1429  if(cases) list.classList.add('cases'); else if(ch.keys.length<=8) list.classList.add('single');
1430  ch.keys.forEach((k,i)=>{
1431    const p=ALL[k]; if(!p) return;
1432    const b=document.createElement('button'); b.className='item fade'; b.style.setProperty('--i',i); b.dataset.open=k;
1433    b.innerHTML = cases
1434      ? `<span class="n">${String(i+1).padStart(2,'0')}</span><span class="t">${p.name}<small>${p.company}${p.title?' · '+p.title:''}</small></span><span class="m"><b>${p.metric}</b><span>${p.metricLabel||''}</span></span>`
1435      : `<span class="n">${String(i+1).padStart(2,'0')}</span><span class="t">${p.name}</span><span class="c">${p.company||p.year||''}</span>`;
1436    list.appendChild(b);
1437  });
1438});
1439
1440/* AI slide + full proposal overlay (Genshin popup) */
1441const GP=typeof genshinPopup!=='undefined'?genshinPopup:{ko:{},en:{}};
1442/* Korean is the master copy: an English key only wins once it exists. */
1443const gp=k=>{ const o=GP[LANG]||{}; const v=o[k]; return (v===undefined||v===null||v.length===0)?(GP.ko||{})[k]:v; };
1444function renderPopupSlide(){
1445  const set=(id,v)=>{ const el=document.getElementById(id); if(el) el.textContent=v||''; };
1446  set('gpLab',gp('lab')); set('gpLine0',gp('line0')); set('gpLine1',gp('line1'));
1447  set('gpQ',gp('q')); set('gpIns',gp('ins'));
1448  document.querySelector('#gpBtn span').textContent=gp('btn')||'';
1449  document.getElementById('gpAi').innerHTML=`<h4>${gp('aiLab')||''}</h4><ul>${(gp('ai')||[]).map(x=>`<li>${x}</li>`).join('')}</ul>`;
1450}
1451
1452const planpage=document.getElementById('planpage'), gpScroll=document.getElementById('gpScroll');
1453let planIO=null, planOpen=false;
1454/* a card stack like a case page's media (front card, next one waiting behind;
1455   dots, a horizontal wheel or a swipe change which is in front), wired to its
1456   own stage and dots so it can live outside the case page */
1457function mediaStack(stage,nav){
1458  let idx=0, lock=0, x0=null;
1459  const cards=()=>[...stage.querySelectorAll('.mcard')];
1460  const render=()=>{
1461    const cs=cards();
1462    cs.forEach((c,i)=>{ c.classList.remove('front','next'); const rel=i-idx; if(rel===0) c.classList.add('front'); else if(rel===1) c.classList.add('next'); });
1463    nav.innerHTML=cs.length>1?cs.map((_,i)=>`<span class="${i===idx?'on':''}"></span>`).join(''):'';
1464  };
1465  const step=d=>{ const next=idx+d; if(next<0||next>=cards().length) return false; idx=next; render(); return true; };
1466  nav.addEventListener('click',e=>{
1467    const dot=e.target.closest('span'); if(!dot) return;
1468    const i=[...nav.children].indexOf(dot); if(i<0||i===idx) return;
1469    idx=i; render();
1470  });
1471  stage.addEventListener('wheel',e=>{
1472    if(cards().length<2) return;
1473    if(Math.abs(e.deltaX)<8 || Math.abs(e.deltaX)<Math.abs(e.deltaY)) return;
1474    const now=Date.now(); if(now-lock<550){ e.preventDefault(); return; }
1475    if(step(e.deltaX>0?1:-1)){ lock=now; e.preventDefault(); }
1476  },{passive:false});
1477  stage.addEventListener('pointerdown',e=>{ x0=e.clientX; });
1478  stage.addEventListener('pointerup',e=>{ if(x0===null) return; const dx=e.clientX-x0; x0=null; if(Math.abs(dx)>30) step(dx<0?1:-1); });
1479  stage.addEventListener('pointercancel',()=>{ x0=null; });
1480  return { set(html){ stage.innerHTML=html; idx=0; render(); } };
1481}
1482const gpGallery=mediaStack(document.getElementById('gpMedia'),document.getElementById('gpMediaNav'));
1483function renderPlan(){
1484  document.getElementById('gpPlanLab').textContent=gp('lab')||'';
1485  document.getElementById('gpTitle').textContent=`${gp('line0')||''} ${gp('line1')||''}`.trim();
1486  gpGallery.set((gp('gallery')||[]).map(g=>`<div class="mcard"><img src="${g.src}" alt="${g.alt}" loading="lazy" onerror="this.closest('.mcard').remove()"></div>`).join(''));
1487  document.getElementById('gpBody').innerHTML=(gp('sections')||[]).map(sec=>
1488    `<section class="cssec"><div class="sh"><h3>${sec.h}</h3></div><div class="sb">${sec.html.replace(/<(p|ul) class="/g,'<$1 class="rv ')}</div></section>`).join('');
1489}
1490function openPlan(){
1491  planOpen=true;
1492  planpage.classList.toggle('ko',LANG==='ko');
1493  applyI18n(); renderPlan();
1494  if(planIO) planIO.disconnect();
1495  planIO=new IntersectionObserver(es=>es.forEach(e=>{ if(e.isIntersecting){ e.target.classList.add('on'); planIO.unobserve(e.target); } }),{root:gpScroll,threshold:.1});
1496  planpage.querySelectorAll('.rv').forEach(el=>{ el.classList.remove('on'); planIO.observe(el); });
1497  gpScroll.scrollTop=0;
1498  planpage.classList.add('open');
1499  document.getElementById('chrome').classList.add('hide-head');
1500  planpage.querySelector('.close').focus();
1501}
1502function closePlan(){
1503  if(!planOpen) return;
1504  planOpen=false;
1505  planpage.classList.remove('open');
1506  document.getElementById('chrome').classList.remove('hide-head');
1507}
1508document.addEventListener('click',e=>{
1509  if(e.target.closest('[data-plan]')){ openPlan(); return; }
1510  if(e.target.closest('[data-pclose]')) closePlan();
1511});
1512addEventListener('keydown',e=>{ if(e.key==='Escape'&&planOpen) closePlan(); },true);
1513
1514/* =========================================================
1515   DETAIL OVERLAY
1516   ========================================================= */
1517/* =========================================================
1518   STEPPER (v3): Data → Insight → Result on the two viz slides
1519   ========================================================= */
1520/* =========================================================
1521   USER BEHAVIOUR: heatmaps A (UC top-up timing) and B (eROAS by
1522   target/platform/appeal). Data lives in heatmap_data.js; these just
1523   render whatever rows/cols/groups it holds, in the current LANG.
1524   ========================================================= */
1525function esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
1526function heatmapColor(v,brackets){ return brackets.find(b=>v>=b.min)||brackets[brackets.length-1]; }
1527/* sub is optional — chart B carries its scale in the legend instead */
1528function hmHead(d){ return `<div class="hm-head"><h5 class="hm-title">${d.title}</h5>${d.sub?`<p class="hm-sub">${d.sub}</p>`:''}</div>`; }
1529function hmCaption(d){ return d.sub?`${d.title} — ${d.sub}`:d.title; }
1530function renderHeatmapA(container){
1531  if(!container) return;
1532  const d=heatmapAData;
1533  const theadCols=d.cols.map(c=>`<th scope="col"${c===d.launchCol?' class="hm-launch"':''}>${c}</th>`).join('');
1534  const bodyRows=d.rows.map(r=>{
1535    const cells=r.values.map((v,i)=>{
1536      const col=d.cols[i], {bg,fg}=heatmapColor(v,HEATMAP_A_BRACKETS);
1537      const label=`${r.label}, ${col}, ${v}%`;
1538      return `<td${col===d.launchCol?' class="hm-launch"':''} tabindex="0" style="background:${bg};color:${fg}" data-row="${esc(r.label)}" data-col="${esc(col)}" data-val="${v}" aria-label="${esc(label)}">${v}%</td>`;
1539    }).join('');
1540    return `<tr><th scope="row">${r.label}</th>${cells}</tr>`;
1541  }).join('');
1542  const rowPct=16, colPct=((100-rowPct)/d.cols.length).toFixed(3);
1543  container.innerHTML=`
1544    ${hmHead(d)}
1545    <div class="hm-scroll"><table class="hm-table hm-a">
1546      <caption class="sr-only">${esc(hmCaption(d))}</caption>
1547      <colgroup><col style="width:${rowPct}%">${d.cols.map(()=>`<col style="width:${colPct}%">`).join('')}</colgroup>
1548      <thead>
1549        <tr class="hm-axis-row" aria-hidden="true"><th class="hm-rowhead"></th><th colspan="${d.cols.length}">${esc(d.colAxis)}</th></tr>
1550        <tr><th scope="col" class="hm-rowhead">${esc(d.rowAxis)}</th>${theadCols}</tr>
1551      </thead>
1552      <tbody>${bodyRows}</tbody>
1553    </table></div>
1554    <div class="hm-legend"><span class="hm-legend-label">${d.legend[0]}</span><span class="hm-legend-bar">${HEATMAP_A_BRACKETS.slice().reverse().map(b=>`<i style="background:${b.bg}"></i>`).join('')}</span><span class="hm-legend-label">${d.legend[1]}</span></div>
1555    <p class="hm-info" id="hmAInfo" aria-live="polite">&nbsp;</p>`;
1556  const info=container.querySelector('#hmAInfo');
1557  container.querySelectorAll('td[data-val]').forEach(td=>{
1558    const show=()=>{ info.textContent=`${td.dataset.row} · ${td.dataset.col}: ${td.dataset.val}%`; };
1559    td.addEventListener('mouseenter',show); td.addEventListener('focus',show); td.addEventListener('click',show);
1560  });
1561}
1562function renderHeatmapB(container){
1563  if(!container) return;
1564  const d=heatmapBData;
1565  const headCols=d.cols.map(c=>`<th scope="col">${c}</th>`).join('');
1566  let bodyRows='';
1567  d.groups.forEach((g,gi)=>{
1568    g.rows.forEach(r=>{
1569      const rowLabel=`${g.label} · ${r.platform}`;
1570      const cells=r.eroas.map((v,i)=>{
1571        const cost=r.cost[i], revenue=Math.round(cost*v/100), colLabel=d.cols[i];
1572        const {bg,fg}=heatmapColor(v,HEATMAP_B_BRACKETS);
1573        const label=`${rowLabel}, ${colLabel}, eROAS ${v}%`;
1574        return `<td tabindex="0" style="background:${bg};color:${fg}" data-row="${esc(rowLabel)}" data-col="${esc(colLabel)}" data-eroas="${v}" data-cost="${cost}" data-rev="${revenue}" aria-label="${esc(label)}">${v}%</td>`;
1575      }).join('');
1576      bodyRows+=`<tr><th scope="row">${rowLabel}</th>${cells}</tr>`;
1577    });
1578    if(gi<d.groups.length-1) bodyRows+=`<tr class="hm-gap" aria-hidden="true"><td colspan="${d.cols.length+1}"></td></tr>`;
1579  });
1580  const legendChips=d.legendSteps.map((s,i)=>`<span class="hm-chip"><i style="background:${d.legendColors[i]}"></i>${s}</span>`).join('');
1581  const rowPct=22, colPct=((100-rowPct)/d.cols.length).toFixed(3);
1582  container.innerHTML=`
1583    ${hmHead(d)}
1584    <div class="hm-scroll"><table class="hm-table hm-b">
1585      <caption class="sr-only">${esc(hmCaption(d))}</caption>
1586      <colgroup><col style="width:${rowPct}%">${d.cols.map(()=>`<col style="width:${colPct}%">`).join('')}</colgroup>
1587      <thead>
1588        <tr class="hm-axis-row" aria-hidden="true"><th class="hm-rowhead"></th><th colspan="${d.cols.length}">${esc(d.colAxis)}</th></tr>
1589        <tr><th scope="col" class="hm-rowhead">${esc(d.rowAxis)}</th>${headCols}</tr>
1590      </thead>
1591      <tbody>${bodyRows}</tbody>
1592    </table></div>
1593    <div class="hm-legend wrap">${legendChips}</div>
1594    <p class="hm-info" id="hmBInfo" aria-live="polite">${d.defaultInfo}</p>`;
1595  const info=container.querySelector('#hmBInfo');
1596  container.querySelectorAll('td[data-eroas]').forEach(td=>{
1597    const show=()=>{ info.textContent=`${td.dataset.row} / ${td.dataset.col}: eROAS ${td.dataset.eroas}%, spend $${td.dataset.cost}K, projected revenue $${td.dataset.rev}K`; };
1598    td.addEventListener('mouseenter',show); td.addEventListener('focus',show); td.addEventListener('click',show);
1599  });
1600}
1601
1602/* =========================================================
1603   CAMPAIGN EFFECTIVENESS, data step: daily tracking board (illustrative)
1604   Mirrors the KPI sheet: cost per day + ROI per campaign against the D1 goal.
1605   ========================================================= */
1606const CEB = {
1607  days:['Mon 07','Tue 08','Wed 09','Thu 10','Fri 11','Sat 12','Sun 13'],
1608  cost:[42,61,58,36,86,92,71],  // total daily cost, index
1609  goal:.25,
1610  camps:[
1611    {name:'KR · Android · Purchase', color:'var(--blue)',        roi:[.27,.28,.27,.30,.31,.33,.34]},
1612    {name:'JP · iOS · AEO',          color:'var(--ink)',         roi:[.19,.24,.23,.26,.27,.27,.26]},
1613    {name:'US · Android · tROAS',    color:'rgba(0,0,0,.45)',    roi:[.14,.21,.22,.24,.24,.25,.24]},
1614    {name:'WW · Android · Install',  color:'rgba(0,0,0,.22)',    roi:[.09,.10,.11,.08,.08,.06,.07]}
1615  ],
1616  chips:{ 'Channel':['FB','UAC','TikTok','Moloco','Liftoff','Unity Ads','Pangle','Snapchat','Twitter','ASA'],
1617          'Event':['Install','AEO','VO','IAP','Purchase','Retention','tROAS','MAI','ARO'],
1618          'Country':['KR','JP','US','TW','WW','Tier 1','Tier 2','Tier 3','HK','SG','UK','AU','FRDE','SEA'] }
1619};
1620const ceBoard=document.getElementById('ceBoard');
1621const CBG={W:760,H:320,pl:40,pr:44,pt:20,pb:30};
1622let cbHot=-1;
1623function ceBoardBuild(){
1624  const {W,H,pl,pr,pt,pb}=CBG, n=CEB.days.length, cw=(W-pl-pr)/n, bw=cw*.62;
1625  const cmax=Math.max(...CEB.cost)*1.15, rmax=Math.max(...CEB.camps.flatMap(c=>c.roi))*1.25;
1626  const xc=i=>pl+cw*(i+.5), yc=v=>pt+(1-v/cmax)*(H-pt-pb), yr=v=>pt+(1-v/rmax)*(H-pt-pb);
1627  let g='';
1628  [.25,.5,.75,1].forEach(f=>{ g+=`<line x1="${pl}" x2="${W-pr}" y1="${yr(rmax*f)}" y2="${yr(rmax*f)}" stroke="rgba(0,0,0,.1)"/><text x="${W-pr+8}" y="${yr(rmax*f)+4}">${(rmax*f*100).toFixed(0)}%</text>`; });
1629  let ct=[.25,.5,.75,1].map(f=>`<text x="${pl-8}" y="${yc(cmax*f)+4}" text-anchor="end">${Math.round(cmax*f)}</text>`).join('');
1630  let bars=CEB.cost.map((c,i)=>`<rect class="bar" style="--i:${i}" x="${xc(i)-bw/2}" y="${yc(c)}" width="${bw}" height="${H-pb-yc(c)}"/>`).join('');
1631  let lines=CEB.camps.map(c=>`<path class="ln" stroke="${c.color}" d="${c.roi.map((v,i)=>(i?'L':'M')+xc(i).toFixed(1)+' '+yr(v).toFixed(1)).join(' ')}"/>`).join('');
1632  let dots=CEB.camps.map((c,k)=>`<circle class="dot" data-k="${k}" r="4.5" stroke="${c.color}"/>`).join('');
1633  let xt=CEB.days.map((d,i)=>`<text x="${xc(i)}" y="${H-8}" text-anchor="middle">${d}</text>`).join('');
1634  ceBoard.innerHTML=`${g}${ct}${bars}<line class="goal" x1="${pl}" x2="${W-pr}" y1="${yr(CEB.goal)}" y2="${yr(CEB.goal)}"/><text x="${pl}" y="${yr(CEB.goal)-6}" fill="var(--ink)" font-weight="600">D1 ROI goal ${(CEB.goal*100).toFixed(0)}%</text>${lines}<line class="cursor" y1="${pt}" y2="${H-pb}"/>${dots}${xt}`;
1635  ceBoard._geo={xc,yr,cw};
1636}
1637function ceBoardHover(i){
1638  cbHot=i; const {xc,yr}=ceBoard._geo;
1639  ceBoard.querySelectorAll('.bar').forEach((b,k)=>b.classList.toggle('hot',k===i));
1640  const cur=ceBoard.querySelector('.cursor'); cur.classList.toggle('show',i>=0); if(i>=0){ cur.setAttribute('x1',xc(i)); cur.setAttribute('x2',xc(i)); }
1641  ceBoard.querySelectorAll('.dot').forEach(d=>{ const k=+d.dataset.k; d.classList.toggle('show',i>=0); if(i>=0){ d.setAttribute('cx',xc(i)); d.setAttribute('cy',yr(CEB.camps[k].roi[i])); } });
1642}
1643ceBoard.addEventListener('pointermove',e=>{ const r=ceBoard.getBoundingClientRect(); const x=(e.clientX-r.left)/r.width*CBG.W; const i=Math.floor((x-CBG.pl)/ceBoard._geo.cw); if(i>=0&&i<CEB.days.length&&i!==cbHot) ceBoardHover(i); });
1644ceBoard.addEventListener('pointerleave',()=>ceBoardHover(-1));
1645ceBoardBuild(); ceBoardHover(-1);
1646
1647/* =========================================================
1648   CAMPAIGN EFFECTIVENESS, insight step: LTV window → D1 ROI goal (illustrative)
1649   ROI(d) = D1 × [ IAPshare × mIAP(d) + IAAshare × mIAA(d) ]
1650   Goal   = 1 / [ IAPshare × mIAP(W) + IAAshare × mIAA(W) ]
1651   ========================================================= */
1652const CE = { iap:.64, d1:.25, A:{iap:5.5,iaa:1.8}, tau:{iap:28,iaa:14} };
1653let ceW=45, ceDrag=false;
1654const ceSvg=document.getElementById('ceSvg');
1655const CEG={W:760,H:340,pl:44,pr:16,pt:28,pb:34,days:90};
1656function mult(d,k){ return 1+CE.A[k]*(1-Math.exp(-d/CE.tau[k])); }
1657const fT=d=>CE.d1*(CE.iap*mult(d,'iap')+(1-CE.iap)*mult(d,'iaa')), fI=d=>CE.d1*CE.iap*mult(d,'iap'), fA=d=>CE.d1*(1-CE.iap)*mult(d,'iaa');
1658const ymax=Math.max(1.4,fT(90)*1.08);
1659const cx=d=>CEG.pl+(d-1)/(CEG.days-1)*(CEG.W-CEG.pl-CEG.pr), cy=v=>CEG.pt+(1-v/ymax)*(CEG.H-CEG.pt-CEG.pb);
1660function ceStatic(){
1661  const {W,H,pl,pr,pt,pb,days}=CEG;
1662  const path=f=>{ let s=''; for(let d=1;d<=days;d++) s+=(d===1?'M':'L')+cx(d).toFixed(1)+' '+cy(f(d)).toFixed(1)+' '; return s; };
1663  let grid=''; for(let v=0.5; v<ymax; v+=0.5) grid+=`<line x1="${pl}" x2="${W-pr}" y1="${cy(v)}" y2="${cy(v)}"/><text x="${pl-8}" y="${cy(v)+4}" text-anchor="end">${(v*100).toFixed(0)}%</text>`;
1664  let xt=''; [1,15,30,45,60,75,90].forEach(d=>{ xt+=`<text x="${cx(d)}" y="${H-10}" text-anchor="middle">D${d}</text>`; });
1665  let be=null; for(let d=1;d<=days;d++) if(fT(d)>=1){ be=d; break; }
1666  ceSvg.innerHTML=`<rect class="hit" x="0" y="0" width="${W}" height="${H}"/><g class="grid">${grid}</g>${xt}
1667    <line class="be" x1="${pl}" x2="${W-pr}" y1="${cy(1)}" y2="${cy(1)}"/><text class="lbl" x="${W-pr}" y="${cy(1)-6}" text-anchor="end">break-even</text>
1668    <path class="p-iaa" d="${path(fA)}"/><path class="p-iap" d="${path(fI)}"/><path class="p-total" d="${path(fT)}"/>
1669    ${be?`<circle cx="${cx(be)}" cy="${cy(1)}" r="4" fill="var(--ink)"/><text x="${cx(be)}" y="${cy(1)+18}" text-anchor="middle">break-even D${be}</text>`:''}
1670    <line class="win" id="ceWinLine" x1="0" x2="0" y1="${pt}" y2="${H-pb}"/>
1671    <text class="lbl" id="ceWinLbl" x="0" y="0"></text>
1672    <circle class="wdot" id="ceDot" r="7" cx="0" cy="0"/>`;
1673}
1674function ceRender(){
1675  const iap=CE.iap, iaa=1-iap, d1=CE.d1;
1676  /* net is a per-unit-spend ratio, so the base is just a display choice:
1677     read against $100K of spend it stays the same number, in $K */
1678  const M=iap*mult(ceW,'iap')+iaa*mult(ceW,'iaa'), goal=1/M, roi=d1*M, net=(roi-1)*100;
1679  document.getElementById('rWin').textContent='D'+ceW;
1680  document.getElementById('rGoal').textContent=(goal*100).toFixed(1)+'%';
1681  document.getElementById('rD1').textContent=(d1*100).toFixed(1)+'%';
1682  document.getElementById('rNet').textContent=(net>=0?'+':'−')+'$'+Math.abs(net).toFixed(1)+'K';
1683  document.getElementById('rVerdict').innerHTML = d1>=goal
1684    ? `Live D1 ROI clears the goal at a ${ceW}-day window: <b>scale</b> the campaign and hold the window.`
1685    : `Live D1 ROI is <b>${((goal-d1)*100).toFixed(1)} pts short</b> of the goal at a ${ceW}-day window: cut bids or fix creative before adding budget.`;
1686  const X=cx(ceW), Y=cy(fT(ceW));
1687  const l=document.getElementById('ceWinLine'); l.setAttribute('x1',X); l.setAttribute('x2',X);
1688  const dot=document.getElementById('ceDot'); dot.setAttribute('cx',X); dot.setAttribute('cy',Y);
1689  const lb=document.getElementById('ceWinLbl'); lb.setAttribute('x',X+(ceW>70?-10:10)); lb.setAttribute('y',Y-12); lb.setAttribute('text-anchor',ceW>70?'end':'start'); lb.textContent=`${(fT(ceW)*100).toFixed(0)}% at D${ceW}`;
1690}
1691function ceFromEvent(e){ const r=ceSvg.getBoundingClientRect(); const xs=(e.clientX-r.left)/r.width*CEG.W; const d=Math.round(1+(xs-CEG.pl)/(CEG.W-CEG.pl-CEG.pr)*(CEG.days-1)); return Math.min(CEG.days,Math.max(7,d)); }
1692ceSvg.addEventListener('pointerdown',e=>{ ceDrag=true; ceSvg.setPointerCapture(e.pointerId); document.getElementById('ceDot').classList.add('drag'); ceW=ceFromEvent(e); ceRender(); });
1693ceSvg.addEventListener('pointermove',e=>{ if(!ceDrag) return; ceW=ceFromEvent(e); ceRender(); });
1694['pointerup','pointercancel'].forEach(ev=>ceSvg.addEventListener(ev,()=>{ ceDrag=false; document.getElementById('ceDot').classList.remove('drag'); }));
1695ceSvg.addEventListener('keydown',e=>{ if(e.key==='ArrowRight'){ceW=Math.min(90,ceW+1);ceRender();} if(e.key==='ArrowLeft'){ceW=Math.max(7,ceW-1);ceRender();} });
1696ceSvg.setAttribute('tabindex','0');
1697ceStatic(); ceRender();
1698
1699/* =========================================================
1700   Retention chart: one top asset read second by second (illustrative)
1701   ========================================================= */
1702const CRA = {
1703  beats:[['Hook',0,3],['Character',3,10],['Gameplay',10,22],['CTA',22,30]],
1704  before:[[0,100],[1,92],[3,78],[5,70],[8,64],[10,58],[13,47],[16,40],[20,35],[25,30],[30,25]],
1705  after: [[0,100],[1,95],[3,86],[5,80],[8,75],[10,71],[13,66],[16,61],[20,56],[25,50],[30,44]]
1706};
1707/* the chart always shows the after-the-sequence-change curve, with the
1708   before curve underneath as a dashed reference (see .anatlegend) */
1709function crAnatRender(){
1710  const svg=document.getElementById('crAnat'), W=760,H=300,pl=40,pr=16,pt=16,pb=58;
1711  const x=t=>pl+t/30*(W-pl-pr), y=v=>pt+(1-v/100)*(H-pt-pb);
1712  const path=pts=>pts.map((p,i)=>(i?'L':'M')+x(p[0]).toFixed(1)+' '+y(p[1]).toFixed(1)).join(' ');
1713  const cur=CRA.after;
1714  let g=''; [25,50,75,100].forEach(v=>{ g+=`<line class="gl" x1="${pl}" x2="${W-pr}" y1="${y(v)}" y2="${y(v)}"/><text x="${pl-6}" y="${y(v)+4}" text-anchor="end">${v}%</text>`; });
1715  const beats=CRA.beats.map(b=>`<rect class="beat ${b[0].toLowerCase()}" x="${x(b[1])}" y="${H-pb+14}" width="${x(b[2])-x(b[1])}" height="22"/><text class="k" x="${x(b[1])+6}" y="${H-pb+29}">${b[0]}</text><text x="${x(b[1])}" y="${H-pb+52}">${b[1]}s</text>`).join('');
1716  const v3=cur.find(p=>p[0]===3)[1], v13=cur.find(p=>p[0]===13)[1];
1717  const marks=`<line class="drop" x1="${x(3)}" x2="${x(3)}" y1="${y(100)}" y2="${H-pb+14}"/><text class="k" x="${x(3)+6}" y="${y(100)+4}">3 s: ${100-v3} pts lost</text>
1718    <line class="drop b" x1="${x(13)}" x2="${x(13)}" y1="${y(v13)-30}" y2="${H-pb+14}"/><text x="${x(13)+6}" y="${y(v13)-34}">gameplay segment replaced with a different appeal</text>`;
1719  svg.innerHTML=`${g}${beats}<path class="ghost" d="${path(CRA.before)}"/><path class="curve" d="${path(cur)}"/>${marks}<text x="${x(30)}" y="${y(cur[cur.length-1][1])-10}" text-anchor="end" class="k">${cur[cur.length-1][1]}% reach the end</text>`;
1720}
1721crAnatRender();
1722
1723/* =========================================================
1724   v7: REPORTS DESK + INTEGRATED CASE PAGE (EN / KO)
1725   EN text comes from data.js (dataAnalysisCases),
1726   KO text from data_analysis_cases_ko.js. Hero copy lives here.
1727   ========================================================= */
1728let LANG='en';
1729document.documentElement.lang='en';
1730const KO = typeof dataAnalysisCasesKo!=='undefined' ? dataAnalysisCasesKo : {};
1731const I18N = {
1732  en:{ back:'Back to list', why:'Why it mattered', enabled:'What it made possible', background:'Background', plan:'Planning & Execution', result:'Result', report:'Data report', company:'Company', year:'Year', title:'Title', skills:'Skills', capData:'Data', capInsight:'Insight', capResult:'Result' },
1733  ko:{ back:'목록으로 돌아가기', why:'Why it mattered', enabled:'What it made possible', background:'Background', plan:'Planning & Execution', result:'Result', report:'데이터 리포트', company:'Company', year:'연도', title:'Title', skills:'스킬', capData:'데이터', capInsight:'인사이트', capResult:'결과' }
1734};
1735const CASES = [
1736  { id:'bc', key:'da-brand-community', num:'01',
1737    title:{en:'Brand & Community',ko:'브랜드 & 커뮤니티'},
1738    sub:{en:'Brand Marketing through IP Collaboration and Community Strategy',ko:'IP콜라보 브랜드 마케팅과 커뮤니티 시책'},
1739    claim:{en:'Ads aren\'t always the answer.',ko:'때로는 광고만이 능사는 아니죠.'},
1740    why:{en:'Paid UA brings players in, but it\'s brand and community that make them care, and stay. At PUBG MOBILE Japan, timelines across multiple IP holders, the Seoul and Tokyo offices and our agency all overlapped at once. At SANDY FLOOR, the task was to reach more players on a small budget. Both called for the same thing: reading the situation quickly, understanding where others stood, and moving the project forward without a set playbook.',ko:'Paid UA를 통한 모객도 중요하지만, 유저가 게임을 좋아하고 머물게 만드는 건 브랜드와 커뮤니티의 몫입니다. PUBG MOBILE 일본에서는 여러 IP 홀더와 서울·도쿄 오피스, 대행사의 일정이 한꺼번에 얽혀 있었고, SANDY FLOOR에서는 적은 예산으로 유저와의 접점을 넓혀야 했습니다. 두 곳 모두 정해진 답보다 상황을 빠르게 읽고, 상대의 입장을 이해하며 프로젝트를 이끌어 나가는 힘이 필요했습니다.'},
1741    enabled:{en:'Our Dragon Ball Super microsite campaign, designed to go viral, reached No. 8 on Japan\'s real-time X trends. An esports tournament with eight popular streamers lifted DAU in Korea. And coverage in more than eight Japanese gaming outlets more than doubled monthly Steam visitors.',ko:'특설 사이트를 중심으로 바이럴을 설계한 Dragon Ball Super 콜라보는 일본 X 실시간 트렌드 8위에 올랐습니다. 인기 스트리머 8인과 함께한 e스포츠 대회로 게임을 알려 한국 DAU를 끌어올렸고, 일본 게임 매체 8곳 이상에 기사가 실리며 스팀 월간 방문자 수를 전월 대비 2배 이상 늘렸습니다.'},
1742    disc:{en:'',ko:''} },
1743  { id:'ub', key:'da-user-behaviour', num:'02',
1744    title:{en:'From User Behaviour to Creative',ko:'유저 행동에서 크리에이티브까지'},
1745    sub:{en:'Optimising marketing budget and Creative through eROAS analysis',ko:'eROAS 분석을 통한 마케팅 예산 최
1745적화 및 크리에이티브 고도화'},
1746    claim:{en:'Finding solutions from user behaviour.',ko:'유저의 행동에서 답을 찾습니다.'},
1747    why:{en:"Among paid items with different prices and business models, I set out to find which ones truly generated high revenue relative to their marketing spend.",ko:'서로 다른 가격과 BM을 가진 유료 상품 중, 실제로 마케팅 비용 대비 높은 매출을 만들어내는 상품이 무엇인지 파악하고자 접근했습니다.'},
1748    enabled:{en:'I proposed a UA budget allocation by skin selling point, and the methodology was reflected in the actual marketing budget. At the same time, I rebuilt the creative operations framework so these insights could lead to better ad efficiency. As a result, I identified the most effective asset formats and hooks for a specific skin business model, meeting the monthly revenue KPI while growing managed ad spend by more than 130% year on year.',ko:'스킨 소구별 UA 예산 배분안을 제안했고, 해당 방법론이 실제 마케팅 예산에 반영될 수 있었습니다. 동시에 이러한 인사이트가 광고 효율 개선으로 이어질 수 있도록 크리에이티브 운영 체계도 재정비하였습니다. 그 결과 특정 스킨 BM에서 효과적인 소재 규격과 후킹 포인트를 발굴하는데 성공하여, 월 매출 KPI를 달성하는 동시에 운용 광고비 규모를 전년 대비 130% 이상 성장시켰습니다.'},
1749    disc:{en:'<b>Figures are illustrative placeholders, not actual data.</b> The segmentation, the formula and the direction of the findings reflect the real method.',ko:'<b>수치는 실제 데이터가 아닌 예시입니다.</b> 세그먼테이션, 산출 공식, 결과의 방향성은 실제 방법론을 반영합니다.'} },
1750  { id:'ce', key:'da-campaign-effectiveness', num:'03',
1751    title:{en:'Campaign Effectiveness',ko:'캠페인 효과 측정'},
1752    sub:{en:'LTV-based KPI setting. And efficient campaign management to reach the goal.',ko:'LTV기반의 KPI 세팅. 그리고 목표를 위한 효율적인 캠페인 관리'},
1753    claim:{en:'To reach the right goal in the shortest time.',ko:'올바른 목표에 최대한 빨리 도달하기 위해.'},
1754    why:{en:"Five live titles with a different IAP and IAA mix in every country and OS, so a single KPI could not tell me which campaigns were actually working. On iOS in particular, SKAN postbacks were too unstable and too delayed to move budget on.",ko:'라이브 중인 5개 타이틀은 국가와 OS마다 IAP·IAA 비중이 제각각이었기에, 하나의 KPI로는 어떤 캠페인이 실제로 효율을 내고 있는지 판단할 수 없었습니다. 특히 iOS는 SKAN 포스트백 데이터가 불안정하고 지연이 커서, 그 수치만 보고 예산을 움직이기 어려운 상황이었습니다.'},
1755    enabled:{en:"I built a KPI framework derived from how each title's players actually pay, turning the monthly target from a guess into a figure I could recalculate every month. Adding an internal modelled-ROI system that worked around SKAN's limits let me evaluate iOS campaigns reliably and feed them into budget allocation, and net UA revenue grew 90% over the year while average CPI across key countries and operating systems fell 35%.",ko:'타이틀마다 유저가 결제하는 방식에서 역산한 KPI 체계를 만들어, 월간 목표를 감이 아니라 매달 다시 계산할 수 있는 수치로 바꿨습니다. 여기에 SKAN의 한계를 우회하는 자체 Modeled ROI 체계를 더해 iOS 캠페인까지 신뢰도 있게 평가하고 예산 배분에 반영할 수 있었고, 그 결과 UA 순수익은 연초 대비 90% 성장하고 주요 국가·OS의 평균 CPI는 35% 하락했습니다.'},
1756    disc:{en:'<b>Figures are illustrative placeholders, not actual data.</b>The tracking structure, the LTV window logic and the D1 ROI goal derivation mirror the real KPI sheet.',ko:'<b>수치는 실제 데이터가 아닌 예시입니다.</b> 추적 구조, LTV 윈도우 로직, D1 ROI 목표 산출 방식은 실제 KPI 시트를 반영합니다.'} }
1757];
1758const T=(k)=>I18N[LANG][k];
1759function applyI18n(){ document.querySelectorAll('[data-i18n]').forEach(el=>{ el.textContent=T(el.dataset.i18n); }); }
1760
1761/* desk list: text rows (number, title, one-line result), contents-page style */
1762const deskCards=document.getElementById('deskCards');
1763const deskEls=[];
1764CASES.forEach((c,i)=>{
1765  const li=document.createElement('li'); li.className='deskline-item'; li.style.setProperty('--i',i);
1766  const b=document.createElement('button'); b.dataset.case=c.id; b.setAttribute('aria-label',c.title.en);
1767  b.innerHTML=`<span class="n">${+c.num}</span><span class="ti"><span class="h"></span><span class="d"></span></span><span class="go"><svg viewBox="0 0 40 20" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 10h30M23 3l9 7-9 7"/></svg></span>`;
1768  b.addEventListener('click',()=>openCase(c.id));
1769  li.appendChild(b); deskCards.appendChild(li);
1770  deskEls.push({c,h:b.querySelector('.h'),d:b.querySelector('.d')});
1771});
1772function renderDeskText(){ deskEls.forEach(({c,h,d})=>{ h.textContent=c.title[LANG]; d.textContent=c.sub[LANG]; }); deskCards.classList.toggle('ko',LANG==='ko'); }
1773renderDeskText();
1774renderPopupSlide();   // needs LANG, which is declared above this point
1775
1776/* Simple-copy pages (cover, hello, what I do, tools, worked, contents) copy
1777   swap: English text is the HTML's own default (cached here before anything
1778   touches it), Korean text comes only from hero_ko.js (heroKo). Toggling to
1779   EN always restores the cached original, so hero_ko.js can never leave the
1780   English page altered. */
1781const HERO_IDS=['presentedBy','role1','role2','role3','helloLine0','helloLine1','bioText','eduHeader','eduDegree1','langHeader','langName1','langLevel1','langName2','langLevel2','langName3','langLevel3','langName4','langLevel4','langName5','langLevel5',
1782  'whatidoLine0','whatidoLine1','whatidoBody','kw0','kw1','kw2','kw3','kw4','kw5','kw6','kw7','kw8','kw9','kw10','kw11',
1783  'toolsLine0','toolsLine1','toolsP0','toolsP1','toolsP2','toolsP3','toolsP4',
1784  'workedLine0','workedLine1',
1785  'contentsLine0','contentsLine1',
1786  'bdLine0','bdBody','bdK0','bdK1','bdK2','bdSrc0','bdSrc1','bdSrc2',
1787  'deskHint'];
1788const HERO_HTML_IDS=['bioText','whatidoBody','toolsP0','toolsP1','toolsP2','toolsP3','toolsP4','bdLine0','bdBody','bdK0']; // ids whose EN/KO text needs embedded markup (<br>, <b>), so they're read/written as innerHTML rather than textContent
1789const heroEn={};
1790HERO_IDS.forEach(id=>{ const el=document.getElementById(id); if(!el) return; heroEn[id]=el.dataset.type!==undefined?el.dataset.type:(HERO_HTML_IDS.includes(id)?el.innerHTML:el.textContent); });
1791function applyHeroLang(){
1792  const ko=typeof heroKo!=='undefined'?heroKo:{};
1793  HERO_IDS.forEach(id=>{
1794    const el=document.getElementById(id); if(!el) return;
1795    const text=(LANG==='ko'&&ko[id])?ko[id]:heroEn[id];
1796    if(el.classList.contains('type')){ el.dataset.type=text; typeReset(el); typeIn(el); }
1797    else if(HERO_HTML_IDS.includes(id)) el.innerHTML=text;
1798    else el.textContent=text.trim()?text:'\u00a0'; // a blank line keeps its height, so the layout below doesn't jump
1799  });
1800}
1801/* Blocks whose EN and KO copy run to a different number of lines, inside a
1802   vertically centred layout: reserve the taller version's height so switching
1803   language doesn't slide the heading and the rows below it up or down. */
1804const HERO_HOLD_IDS=['bioText'];
1805function holdHeroHeights(){
1806  const ko=typeof heroKo!=='undefined'?heroKo:{};
1807  HERO_HOLD_IDS.forEach(id=>{
1808    const el=document.getElementById(id); if(!el) return;
1809    const cur=el.innerHTML; el.style.minHeight='';
1810    const h=[heroEn[id],ko[id]||heroEn[id]].map(t=>{ el.innerHTML=t; return el.offsetHeight; });
1811    el.innerHTML=cur; el.style.minHeight=Math.max(...h)+'px';
1812  });
1813}
1814holdHeroHeights(); addEventListener('resize',holdHeroHeights); if(document.fonts) document.fonts.ready.then(holdHeroHeights);
1815/* Page 10: the copy's first line lines up with the top of "MARKETING." (the
1816   English title's second line). The target is computed from the title's
1817   bottom edge and line pitch, so in Korean, where the title is one line, the
1818   copy stays exactly where it sits in English. */
1819function alignBdBody(){
1820  const body=document.getElementById('bdBody'), h=document.querySelector('#bd-stats .h-caps');
1821  if(!body||!h) return;
1822  if(matchMedia('(max-width:760px)').matches){ body.style.top=''; return; }
1823  body.style.top='0px';
1824  const fs=parseFloat(getComputedStyle(h).fontSize);
1825  // cap top of the second title line: bottom edge, up one .9em line, down the
1826  // half-leading (.9-1.21)/2 and the ascender-to-cap gap (.243em) = -.812em
1827  const target=h.getBoundingClientRect().bottom-.812*fs;
1828  const bs=getComputedStyle(body), bf=parseFloat(bs.fontSize), lh=parseFloat(bs.lineHeight);
1829  const ty=bs.transform==='none'?0:new DOMMatrix(bs.transform).m42;     // ignore the fade-in's slide, if it's mid-flight
1830  const ink=body.getBoundingClientRect().top-ty+(lh-1.21*bf)/2+.243*bf; // cap top of the copy's first line
1831  body.style.top=Math.round(target-ink)+'px';
1832}
1833alignBdBody(); addEventListener('resize',alignBdBody); if(document.fonts) document.fonts.ready.then(alignBdBody);
1834/* Every .langtoggle instance on the page (cover, reports, ...) drives the
1835   same LANG state — aria-pressed is matched by data-lang, not by which
1836   specific button was clicked, so every instance stays in sync regardless
1837   of which one triggered the change. */
1838document.querySelectorAll('.langtoggle button').forEach(bt=>bt.addEventListener('click',()=>{
1839  if(bt.dataset.lang===LANG) return;
1840  LANG=bt.dataset.lang;
1841  document.documentElement.lang=LANG;
1842  document.querySelectorAll('.langtoggle button').forEach(x=>x.setAttribute('aria-pressed',String(x.dataset.lang===LANG)));
1843  applyI18n();
1844  renderDeskText();
1845  applyHeroLang();
1846  holdHeroHeights();
1847  alignBdBody();
1848  renderPopupSlide();
1849  if(planOpen){ planpage.classList.toggle('ko',LANG==='ko'); renderPlan(); planpage.querySelectorAll('.rv').forEach(el=>el.classList.add('on')); }
1850}));
1851
1852/* case page */
1853const casepage=document.getElementById('casepage'), csScroll=document.getElementById('csScroll'), vizStore=document.getElementById('vizStore');
1854let csIO=null, openCaseId=null;
1855/* Closing a case returns its prebuilt viz blocks to #vizStore, but only after
1856   the close transition so they don't visibly jump. If another case is opened
1857   inside that window, openCase() flushes the pending teardown first: otherwise
1858   the timer would either sweep the new case's blocks away (same id reopened)
1859   or the new page's plan.innerHTML='' would destroy the old case's blocks for
1860   good, leaving that case chartless for the rest of the session. */
1861let csPending=null, csPendingTimer=null;
1862function returnViz(id){
1863  if(!id) return;
1864  /* sweep by what is actually in the page, not by id convention: a case can
1865     borrow another case's viz block (see planMedia viz entries), and a block
1866     left behind would be destroyed by the next open's innerHTML reset */
1867  casepage.querySelectorAll('.vzw').forEach(w=>{ w.querySelectorAll('.step').forEach(st=>st.classList.remove('on')); vizStore.appendChild(w); });
1868  document.getElementById('csMedia').innerHTML='';
1869}
1870function flushCaseTeardown(){
1871  if(csPendingTimer){ clearTimeout(csPendingTimer); csPendingTimer=null; }
1872  if(csPending){ returnViz(csPending); csPending=null; }
1873}
1874/* media stack: front card centred, next one waiting behind; wheel or a
1875   vertical drag over the stack swaps which is in front. At either end the
1876   page takes over the scroll, same rule the old hidden fandeck used. */
1877const mediaStage=document.getElementById('csMedia'), mediaNav=document.getElementById('csMediaNav');
1878let mediaIdx=0;
1879function mediaRender(){
1880  const cards=[...mediaStage.querySelectorAll('.mcard')];
1881  cards.forEach((c,i)=>{ c.classList.remove('front','next'); const rel=i-mediaIdx; if(rel===0) c.classList.add('front'); else if(rel===1) c.classList.add('next'); });
1882  mediaNav.innerHTML=cards.length>1?cards.map((_,i)=>`<span class="${i===mediaIdx?'on':''}"></span>`).join(''):'';
1883}
1884function mediaStep(d){
1885  const n=mediaStage.querySelectorAll('.mcard').length, next=mediaIdx+d;
1886  if(next<0||next>=n) return false;
1887  mediaIdx=next; mediaRender(); return true;
1888}
1889mediaNav.addEventListener('click',e=>{
1890  const dot=e.target.closest('span'); if(!dot) return;
1891  const i=[...mediaNav.children].indexOf(dot); if(i<0||i===mediaIdx) return;
1892  mediaIdx=i; mediaRender();
1893});
1894let mediaWheelLock=0;
1895mediaStage.addEventListener('wheel',e=>{
1896  if(mediaStage.querySelectorAll('.mcard').length<2) return;
1897  if(Math.abs(e.deltaX)<8 || Math.abs(e.deltaX)<Math.abs(e.deltaY)) return;
1898  const now=Date.now(); if(now-mediaWheelLock<550) { e.preventDefault(); return; }
1899  if(mediaStep(e.deltaX>0?1:-1)){ mediaWheelLock=now; e.preventDefault(); }
1900},{passive:false});
1901let mediaX0=null;
1902mediaStage.addEventListener('pointerdown',e=>{ if(e.target.closest('iframe')) return; mediaX0=e.clientX; });
1903mediaStage.addEventListener('pointerup',e=>{ if(mediaX0===null) return; const dx=e.clientX-mediaX0; mediaX0=null; if(Math.abs(dx)>30) mediaStep(dx<0?1:-1); });
1904mediaStage.addEventListener('pointercancel',()=>{ mediaX0=null; });
1905function pick(c){ const en=ALL[c.key]||{}; const ko=KO[c.key]||{}; return LANG==='ko' && ko.background ? ko : en; }
1906function paras(html,cls){ return (html||'').split(/<br\s*\/?>\s*<br\s*\/?>/i).map(t=>t.trim()).filter(Boolean).map((t,i)=>`<p class="${cls} rv" style="--i:${i%3}">${t}</p>`).join(''); }
1907/* a case only gets the viz blocks it actually has — bc, for instance, has a
1908   result block but no data/insight ones, so a missing id is skipped rather
1909   than throwing and leaving the rest of the page unrendered */
1910function moveViz(id,k,slot,cap){ const w=document.getElementById(`vz-${id}-${k}`); if(!w) return; w.dataset.cap=cap; slot.appendChild(w); }
1911/* a single inline figure — image or YouTube embed — for the planMedia slots */
1912function figureHtml(m){
1913  if(!m) return '';
1914  if(m.video){ const v=ytId(m.video); return v?`<div class="csfig rv">${ytPrint(v)}<div class="video"><iframe loading="lazy" src="https://www.youtube.com/embed/${v}" title="Video" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div></div>`:''; }
1915  return m.img?`<div class="csfig rv"><img src="${m.img}" alt="" loading="lazy" onerror="this.closest('.csfig').remove()"></div>`:'';
1916}
1917/* viz blocks a case can borrow from another case, with whatever render call
1918   that block needs once it is in the page */
1919const VIZ_INIT={'vz-ub-3':()=>crAnatRender()};
1920function moveVizById(elId,slot,cap){
1921  const w=document.getElementById(elId); if(!w) return;
1922  w.dataset.cap=cap; slot.appendChild(w);
1923  if(VIZ_INIT[elId]) VIZ_INIT[elId]();
1924}
1925function openCase(id){
1926  const c=CASES.find(x=>x.id===id); if(!c) return; flushCaseTeardown(); openCaseId=id;
1927  const p=pick(c);
1928  applyI18n();
1929  casepage.dataset.case=id;
1930  casepage.classList.toggle('ko',LANG==='ko');
1931  document.getElementById('csLabel').textContent=`${chapterOf(c.key)} · ${c.title[LANG]}`;
1932  document.getElementById('csClaim').textContent=(LANG==='ko' && p.claim) ? p.claim : c.claim[LANG];
1933  document.getElementById('csWhy').textContent=c.why[LANG];
1934  document.getElementById('csEnabled').textContent=c.enabled[LANG];
1935  const meta=[['company',p.company],['year',p.year],['title',p.title]].filter(x=>x[1]).map(([k,v])=>`<div><span class="k">${T(k)}</span>${v}</div>`).join('')
1936    + (p.skills&&p.skills.length ? `<div><span class="k">${T('skills')}</span><div class="chips">${p.skills.map(x=>`<span>${x}</span>`).join('')}</div></div>` : '');
1937  document.getElementById('csMeta').innerHTML=meta;
1938  document.getElementById('csBg').innerHTML=paras(p.background,'tx');
1939  // media: images and videos from the EN entry (paths are language-neutral)
1940  const en=ALL[c.key]||{}; let media='';
1941  [].concat(en.heroImage||[],en.heroImages||[],en.heroImageGrid||[],en.peImages||[],en.resultsImage||[]).forEac
1941h(src=>media+=`<div class="mcard"><img src="${src}" alt="" loading="lazy" onerror="this.closest('.mcard').remove()"></div>`);
1942  [].concat(en.peVideo||[],en.peVideos||[]).forEach(u=>{ const v=ytId(u); if(v) media+=`<div class="mcard"><div class="video"><iframe loading="lazy" src="https://www.youtube.com/embed/${v}" title="Video" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div></div>`; });
1943  mediaStage.innerHTML=media; mediaIdx=0; mediaRender();
1944  /* The stack normally sits under the whole Background section. When the copy
1945     opens with a segment label the images belong to that segment rather than
1946     the section, so the label is lifted onto its own line and the stack slots
1947     in between label and copy. Re-inserted on every open either way, since
1948     setting csBg's innerHTML above detaches whatever was placed inside it. */
1949  const bgEl=document.getElementById('csBg');
1950  const leadP=bgEl.firstElementChild, leadLabel=leadP&&leadP.querySelector(':scope>b.seglabel');
1951  if(media&&leadLabel){
1952    const labelP=document.createElement('p'); labelP.className='tx rv';
1953    labelP.appendChild(leadLabel);
1954    bgEl.insertBefore(labelP,leadP);
1955    bgEl.insertBefore(mediaStage,leadP); bgEl.insertBefore(mediaNav,leadP);
1956  } else {
1957    bgEl.after(mediaStage,mediaNav);
1958  }
1959  // planning: each of the first two text blocks is followed by the viz that
1960  // illustrates it, then whatever blocks are left
1961  const plan=document.getElementById('csPlan'); plan.innerHTML='';
1962  const blocks=(p.planningExecution||'').split(/<br\s*\/?>\s*<br\s*\/?>/i).map(t=>t.trim()).filter(Boolean);
1963  const planMedia=en.planMedia||[];
1964  const addBlock=(t,at,stagger)=>{
1965    if(!t) return;
1966    const m=planMedia.find(x=>x.at===at);
1967    if(m&&m.viz) moveVizById(m.viz,plan,T(m.cap||'capInsight'));
1968    const fig=figureHtml(m);
1969    // a block opening with a segment label keeps that label above the figure,
1970    // so the figure reads as part of its own segment, not the previous one
1971    const lab=fig&&t.match(/^\s*<b class=['"]seglabel['"]>[\s\S]*?<\/b>/i);
1972    plan.insertAdjacentHTML('beforeend', lab
1973      ? `<p class="tx rv">${lab[0]}</p>${fig}<p class="tx rv" style="--i:${stagger}">${t.slice(lab[0].length)}</p>`
1974      : `${fig}<p class="tx rv" style="--i:${stagger}">${t}</p>`);
1975  };
1976  addBlock(blocks[0],0,0);
1977  moveViz(id,0,plan,T('capData'));
1978  addBlock(blocks[1],1,1);
1979  moveViz(id,1,plan,T('capInsight'));
1980  blocks.slice(2).forEach((t,i)=>addBlock(t,i+2,i%3));
1981  // result: [result viz] + text
1982  const res=document.getElementById('csResult'); res.innerHTML='';
1983  moveViz(id,2,res,T('capResult'));
1984  res.insertAdjacentHTML('beforeend',paras(p.results,'tx'));
1985  document.getElementById('csDisc').innerHTML=`<b>${c.disc[LANG]}</b>`;
1986  // prepare viz that need a render before showing
1987  if(id==='ub'){ renderHeatmapA(document.getElementById('heatmapA')); renderHeatmapB(document.getElementById('heatmapB')); }
1988  if(id==='ce'){ ceBoardHover(-1); ceRender(); }
1989  // reveal on scroll inside the page
1990  if(csIO) csIO.disconnect();
1991  csIO=new IntersectionObserver(entries=>entries.forEach(en=>{ if(en.isIntersecting){ en.target.classList.add('on'); en.target.querySelectorAll('[data-count]').forEach(count); if(en.target.matches&&en.target.matches('[data-count]')) count(en.target); csIO.unobserve(en.target); } }),{root:csScroll,threshold:.25});
1992  casepage.querySelectorAll('.rv, .vzw .step').forEach(el=>{ el.classList.remove('on'); csIO.observe(el); });
1993  csScroll.scrollTop=0;
1994  casepage.classList.add('open');
1995  document.getElementById('chrome').classList.add('hide-head');
1996  casepage.querySelector('.close').focus();
1997}
1998function closeCase(){
1999  casepage.classList.remove('open'); document.getElementById('chrome').classList.remove('hide-head');
2000  if(!openCaseId) return;
2001  csPending=openCaseId; openCaseId=null;
2002  csPendingTimer=setTimeout(()=>{ csPendingTimer=null; flushCaseTeardown(); },900);
2003}
2004document.addEventListener('click',e=>{ if(e.target.closest('[data-cclose]')) closeCase(); });
2005addEventListener('keydown',e=>
2005{ if(e.key==='Escape'&&casepage.classList.contains('open')) closeCase(); },true);
2006applyI18n();
2007
2008const detail=document.getElementById('detail'), dScroll=document.getElementById('dScroll');
2009/* print-only stand-in for a YouTube embed (hidden on screen, see PRINT CSS).
2010   Not lazy-loaded, so the thumbnail is already there when printing starts. */
2011function ytPrint(v){ return `<a class="print-yt" href="https://youtu.be/${v}"><img src="https://i.ytimg.com/vi/${v}/hqdefault.jpg" alt=""><span>▶ youtu.be/${v}</span></a>`; }
2012function ytId(u){ const m=(u||'').match(/(?:youtu\.be\/|shorts\/|v=|embed\/)([A-Za-z0-9_-]{11})/); return m?m[1]:null; }
2013function video(u){ const id=ytId(u); return id?`<div class="video"><iframe loading="lazy" src="https://www.youtube.com/embed/${id}" title="Video" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>`:''; }
2014function imgs(arr,cls){ const a=[].concat(arr||[]).filter(Boolean); if(!a.length) return ''; return `<div class="media ${cls||''}">${a.map(s=>`<img src="${s}" alt="" loading="lazy" onerror="this.parentElement.style.display='none'">`).join('')}</div>`; }
2015function chapterOf(key){ for(const [id,ch] of Object.entries(CHAPTERS)) if(ch.keys.includes(key)) return ch.label; return ''; }
2016
2017function openDetail(key){
2018  const p=ALL[key]; if(!p) return;
2019  document.getElementById('dChapter').textContent=chapterOf(key);
2020  document.getElementById('dTitle').textContent=p.name;
2021  document.getElementById('dMeta').innerHTML=[['Company',p.company],['Year',p.year],['Title',p.title],['Role',p.role]].filter(x=>x[1]).map(([k,v])=>`<div><span class="k">${k}</span>${v}</div>`).join('');
2022  const media=[];
2023  [].concat(p.heroImage||[],p.heroImages||[],p.heroImageGrid||[]).forEach(src=>media.push({t:'img',src,cap:'Overview'}));
2024  [].concat(p.peVideo||[],p.peVideos||[]).forEach(u=>{ const id=ytId(u); if(id) media.push({t:'vid',id,cap:'Video'}); });
2025  [].concat(p.peImages||[]).forEach(src=>media.push({t:'img',src,cap:'Planning & Execution'}));
2026  [].concat(p.resultsImage||[]).forEach(src=>media.push({t:'img',src,cap:'Result'}));
2027  carBuild(media);
2028  let html='';
2029  if(p.questions) html+=`<div class="sec rev"><h3>Questions</h3><ul class="qs">${p.questions.map(q=>`<li><b>${q.q}</b><span>${q.insight}</span></li>`).join('')}</ul></div>`;
2030  if(p.background) html+=`<div class="sec rev"><h3>Background</h3><div class="body"><p>${p.background}</p></div></div>`;
2031  if(p.planningExecution) html+=`<div class="sec rev"><h3>Planning &amp; Execution</h3><div class="body"><p>${p.planningExecution}</p></div></div>`;
2032  if(p.results) html+=`<div class="sec rev"><h3>Result</h3><div class="body"><p>${p.results}</p></div></div>`;
2033  document.getElementById('dBody').innerHTML=html;
2034  dScroll.scrollTop=0;
2035  detail.classList.add('open');
2036  document.getElementById('chrome').classList.add('hide-head');
2037  detail.querySelector('.close').focus();
2038}
2039let carMedia=[];
2040/* Fanned card deck: assets sit collapsed and overlapping; hover pulls one
2041   forward. Horizontal movement comes from scrolling or dragging, no buttons.
2042   Feature-flagged off for now (disrupted the reading flow) — flip to true to
2043   bring it back without rebuilding it. */
2044const MEDIA_DECK_ENABLED=false;
2045const CARD_TILT=[-7,4,-3,6,-5,3,-6,5,-2,7];
2046function carBuild(media){
2047  const car=document.getElementById('dCar'), deck=document.getElementById('dTrack'), cap=document.getElementById('dCap');
2048  carMedia=media;
2049  if(!MEDIA_DECK_ENABLED||!media.length){ car.hidden=true; deck.innerHTML=''; cap.textContent=''; return; }
2050  car.hidden=false;
2051  deck.innerHTML=media.map((m,i)=>{
2052    const st=`--r:${CARD_TILT[i%CARD_TILT.length]}deg;--y:${i%2?10:0}px;--z:${i+1}`;
2053    return m.t==='img'
2054      ? `<div class="card" role="listitem" style="${st}" data-i="${i}"><img src="${m.src}" alt="" loading="lazy" onerror="this.closest('.card').style.display='none'"></div>`
2055      : `<div class="card vid" role="listitem" style="${st}" data-i="${i}" data-vid="${m.id}" tabindex="0"><img src="https://i.ytimg.com/vi/${m.id}/hqdefault.jpg" alt="" loading="lazy"><span class="play"><svg viewBox="0 0 24 24">
2055<path d="M8 5v14l11-7z"/></svg></span></div>`;
2056  }).join('');
2057  cap.textContent=media[0].cap;
2058  requestAnimationFrame(()=>{ deck.scrollLeft=(deck.scrollWidth-deck.clientWidth)/2; });
2059}
2060(function(){
2061  const deck=document.getElementById('dTrack'), cap=document.getElementById('dCap');
2062  const step=()=>{ const c=deck.querySelector('.card'); return c?Math.max(120,c.getBoundingClientRect().width*.55):200; };
2063
2064  deck.addEventListener('mouseover',e=>{ const c=e.target.closest('.card'); if(c&&carMedia[c.dataset.i]) cap.textContent=carMedia[c.dataset.i].cap; });
2065
2066  // Vertical wheel scrolls the deck sideways; at either end the page takes over.
2067  deck.addEventListener('wheel',e=>{
2068    const d=(Math.abs(e.deltaX)>Math.abs(e.deltaY)?e.deltaX:e.deltaY)*14.6;
2069    const max=deck.scrollWidth-deck.clientWidth;
2070    if(max<=0) return;
2071    if((d<0&&deck.scrollLeft<=0)||(d>0&&deck.scrollLeft>=max-1)) return;
2072    e.preventDefault(); deck.scrollLeft+=d;
2073  },{passive:false});
2074
2075  // Drag to pan.
2076  let x0=null, sl0=0, moved=0;
2077  deck.addEventListener('pointerdown',e=>{ if(e.target.closest('iframe')) return; x0=e.clientX; sl0=deck.scrollLeft; moved=0; });
2078  deck.addEventListener('pointermove',e=>{ if(x0===null) return; const dx=e.clientX-x0; if(Math.abs(dx)>4){ moved=Math.abs(dx); deck.classList.add('drag'); deck.scrollLeft=sl0-dx; } });
2079  const endDrag=()=>{ x0=null; deck.classList.remove('drag'); };
2080  deck.addEventListener('pointerup',endDrag); deck.addEventListener('pointercancel',endDrag); deck.addEventListener('pointerleave',endDrag);
2081
2082  // Videos load their player only once asked for.
2083  function playCard(c){
2084    if(!c||c.classList.contains('playing')) return;
2085    c.classList.add('playing');
2086    const f=document.createElement('iframe');
2087    f.src=`https://www.youtube.com/embed/${c.dataset.vid}?autoplay=1`;
2088    f.title='Video'; f.loading='lazy'; f.allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'; f.allowFullscreen=true;
2089    c.appendChild(f);
2090    c.scrollIntoView({behavior:'smooth',block:'nearest',inline:'nearest'});
2091  }
2092  deck.addEventListener('click',e=>{ if(moved>4){ moved=0; return; } playCard(e.target.closest('.card.vid')); });
2093  deck.addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ const c=e.target.closest('.card.vid'); if(c){ e.preventDefault(); playCard(c); } } });
2094
2095  window.carNudge=d=>deck.scrollBy({left:d*step(),behavior:'smooth'});
2096})();
2097function closeDetail(){ detail.classList.remove('open'); document.getElementById('chrome').classList.remove('hide-head'); setTimeout(()=>{ detail.querySelectorAll('iframe').forEach(f=>f.remove()); detail.querySelectorAll('.card.playing').forEach(c=>c.classList.remove('playing')); },900); }
2098document.addEventListener('click',e=>{
2099  const o=e.target.closest('[data-open]'); if(o){ openDetail(o.dataset.open); return; }
2100  if(e.target.closest('[data-close]')) closeDetail();
2101  if(e.target.closest('[data-next]')){ const s=e.target.closest('.slide').nextElementSibling; s&&s.scrollIntoView({behavior:'smooth'}); }
2102});
2103addEventListener('keydown',e=>{
2104  if(e.key==='Escape'&&detail.classList.contains('open')) closeDetail();
2105  if(casepage.classList.contains('open')||planOpen) return;
2106  if(detail.classList.contains('open')){ if(e.key==='ArrowRight') carNudge(1); if(e.key==='ArrowLeft') carNudge(-1); return; }
2107  if(['ArrowDown','PageDown',' '].includes(e.key)){ e.preventDefault(); go(1); }
2108  if(['ArrowUp','PageUp'].includes(e.key)){ e.preventDefault(); go(-1); }
2109});
2110
2111/* =========================================================
2112   SLIDE OBSERVER: reveals, typing, counters, counter chrome
2113   ========================================================= */
2114const slides=[...document.querySelectorAll('.slide')];
2115document.getElementById('cTot').textContent=String(slides.length).padStart(2,'0');
2116let current=0;
2117function go(d){ const n=Math.min(slides.length-1,Math.max(0,current+d)); slides[n].scrollIntoView({behavior:'smooth'}); }
2118const io=new IntersectionObserver(entries=>{
2119  entries.forEach(en=>{
2120    const s=en.target;
2121    if(en.isIntersecting){
2122      s.classList.add('in');
2123      current=slides.indexOf(s);
2124      document.getElementById('cIdx').textContent=String(current+1).padStart(2,'0');
2125      document.getElementById('cBar').style.transform=`scaleX(${(current+1)/slides.length})`;
2126      s.querySelectorAll('.type').forEach(typeIn);
2127      s.querySelectorAll('[data-count]').forEach(count);
2128    } else {
2129      s.classList.remove('in');
2130      s.querySelectorAll('.type').forEach(typeReset);
2131      s.querySelectorAll('[data-count]').forEach(el=>el.textContent='0');
2132    }
2133  });
2134},{root:document.getElementById('deck'),threshold:.55});
2135slides.forEach(s=>io.observe(s));
2136
2137const barEl=document.getElementById('cBar').parentElement;
2138barEl.addEventListener('click',e=>{
2139  const rect=barEl.getBoundingClientRect();
2140  const frac=Math.min(1,Math.max(0,(e.clientX-rect.left)/rect.width));
2141  slides[Math.round(frac*(slides.length-1))].scrollIntoView({behavior:'smooth'});
2142});
2143
2144/* =========================================================
2145   PRINT: everything that only finishes through JS (typing, counters, the
2146   per-slide .in state, chart steps, lazy images) is put in its final state
2147   before printing, and the slides that are off screen are put back after,
2148   so the deck carries on animating as normal.
2149   ========================================================= */
2150function preparePrint(){
2151  slides.forEach(s=>s.classList.add('in'));
2152  document.querySelectorAll('.type').forEach(el=>{
2153    const t=typers.get(el); if(t&&t!==true) clearInterval(t);
2154    typers.set(el,true); el.textContent=el.dataset.type||''; el.classList.add('done');
2155  });
2156  document.querySelectorAll('[data-count]').forEach(el=>count(el,true));
2157  document.querySelectorAll('.rv,.vzw .step').forEach(el=>el.classList.add('on'));
2158  document.querySelectorAll('img[loading="lazy"]').forEach(img=>img.loading='eager');
2159  document.querySelectorAll('.casepage.open .cssec p,.casepage.open .cssec li').forEach(el=>el.classList.toggle('print-long',el.offsetHeight>innerHeight*.3));
2160  // a page break before every section of the open overlay, except the
2161  // proposal's one-line concept, which stays on the page with its title
2162  document.querySelectorAll('.casepage.open .cssec').forEach(sec=>{
2163    // (hand-built .print-page blocks carry their own break; Chrome fires
2164    // beforeprint again while making the PDF, so they must be skipped here)
2165    if(sec.matches('#gpBody>.cssec:first-child,.pgal,.print-page')||sec.previousElementSibling?.classList.contains('print-break')) return;
2166    const m=document.createElement('div'); m.className='print-break'; sec.before(m);
2167  });
2168  const head=document.querySelector('#chrome .head');
2169  slides.forEach(s=>{
2170    if(s.querySelector(':scope>.print-head')) return;
2171    const h=head.cloneNode(true);
2172    h.className='print-head'; h.querySelectorAll('[id]').forEach(el=>el.removeAttribute('id'));
2173    s.appendChild(h);
2174  });
2175}
2176function restorePrint(){
2177  document.querySelectorAll('.slide>.print-head,.print-break').forEach(h=>h.remove());
2178  slides.forEach((s,i)=>{
2179    if(i===current) return;
2180    s.classList.remove('in');
2181    s.querySelectorAll('.type').forEach(typeReset);
2182    s.querySelectorAll('[data-count]').forEach(el=>el.textContent='0');
2183  });
2184}
2185/* PDF export only: lays the open overlay's sections out as explicit pages of
2186   height H (layout px). Called by tools/pdf/export.js with print styles
2187   emulated; the page is reloaded after printing, so nothing is put back.
2188   - content is cut into chunks: a segment label, a calc sub-heading, or a
2189     figure/chart starts a new chunk (a figure stays with the label or
2190     sub-heading right above it), so a picture keeps its caption text;
2191   - chunks are packed onto pages; a chunk up to 15% over a page stays
2192     whole on its own (squeezed), a bigger one is packed block by block;
2193   - a chunk may squeeze onto the current page (up to 15% over) when it is
2194     the section's last, or when the page would otherwise be left mostly
2195     empty, instead of opening a near-empty page;
2196   - a heading (segment label, calc sub-heading, table header) never ends a
2197     page: it moves on with what it introduces;
2198   - containers (lists, tables, calc blocks) are rebuilt on each page as
2199     empty copies, so their styling still applies, and a split table gets
2200     its header row again. */
2201function paginatePrint(H){
2202  const root=document.querySelector('.casepage.open .cscroll'); if(!root) return;
2203  document.querySelectorAll('.print-break').forEach(m=>m.remove());
2204  root.style.setProperty('--pp-h',(H+4)+'px');
2205  const BAND=26; root.style.setProperty('--pp-band',BAND+'px');   // room under the opening rule; the name sits at its foot
2206  H-=BAND;
2207  const CONT='.body,.calc,.ctab,.cf,ul.plist';
2208  const FIG='.csfig,.vzw,.media,svg,.heatmap,.hm-wrap';
2209  const shown=el=>getComputedStyle(el).display!=='none'&&el.getBoundingClientRect().height>0;
2210  const labelOnly=el=>el.matches('p.tx')&&el.querySelector(':scope>b.seglabel')&&el.textContent.trim()===el.querySelector(':scope>b.seglabel').textContent.trim();
2211  const secs=[...root.querySelectorAll('.cssec')].filter(sec=>!sec.matches('.pgal,#gpBody>.cssec:first-child')&&shown(sec));
2212  const disc=root.querySelector(':scope>.disc');
2213  const thOf=new Map([...root.querySelectorAll('.ctab')].map(t=>[t,t.querySelector(':scope>.th')?.cloneNode(true)]));
2214  const isHead=el=>labelOnly(el)||el.matches('.calc-h,.th');
2215  secs.forEach((sec,si)=>{
2216    const sb=sec.querySelector(':scope>.sb'), title=sec.querySelector(':scope>.sh h3')?.textContent||'';
2217    const leaves=[];
2218    (function walk(box){ [...box.children].forEach(el=>{ if(!shown(el)) return; if(el.matches(CONT)) walk(el); else leaves.push(el); }); })(sb);
2219    if(si===secs.length-1&&disc&&disc.textContent.trim()&&shown(disc)) leaves.push(disc);
2220    if(!leaves.length) return;
2221    // vertical room each block takes in the normal flow (gap to the next one)
2222    const tops=leaves.map(el=>el.getBoundingClientRect().top);
2223    const span=leaves.map((el,i)=>{
2224      if(i<leaves.length-1&&tops[i+1]>=tops[i]) return tops[i+1]-tops[i];
2225      const r=el.getBoundingClientRect(); return r.height+parseFloat(getComputedStyle(el).marginBottom||0);
2226    });
2227    // chunks
2228    const chunks=[]; let cur=null;
2229    leaves.forEach((el,i)=>{
2230      const prev=leaves[i-1];
2231      const glued=prev&&(labelOnly(prev)||prev.matches('.calc-h'));   // a heading stays with what follows it
2232      const starts=!glued&&((el.matches('p.tx')&&el.querySelector(':scope>b.seglabel'))||el.matches('.calc-h')||el.matches(FIG));
2233      if(!cur||starts){ cur={items:[],h:0}; chunks.push(cur); }
2234      cur.items.push(i); cur.h+=span[i];
2235    });
2236    // pack
2237    const pages=[]; let pg={items:[],h:0};
2238    const flush=()=>{ if(pg.items.length) pages.push(pg); pg={items:[],h:0}; };
2239    chunks.forEach((c,ci)=>{
2240      const last=ci===chunks.length-1, sparse=pg.h<H*.45;
2241      if(pg.h+c.h<=H||((last||sparse)&&pg.items.length&&pg.h+c.h<=H*1.15)){ pg.items.push(...c.items); pg.h+=c.h; return; }
2242      // a near-fit stays whole on its own page (squeezed), unless the current
2243      // page is mostly empty, in which case it is better to start filling it
2244      if(c.h<=H||(c.h<=H*1.15&&!(sparse&&pg.items.length))){ flush(); pg.items.push(...c.items); pg.h=c.h; return; }
2245      c.items.forEach(i=>{
2246        if(pg.h+span[i]>H&&pg.items.length){
2247          const carry=[];   // trailing headings go over with what they introduce
2248          while(pg.items.length>1&&isHead(leaves[pg.items[pg.items.length-1]])){ const j=pg.items.pop(); pg.h-=span[j]; carry.unshift(j); }
2249          flush(); carry.forEach(j=>{ pg.items.push(j); pg.h+=span[j]; });
2250        }
2251        pg.items.push(i); pg.h+=span[i];
2252      });
2253    });
2254    flush();
2255    // build
2256    const made=pages.map((p,pi)=>{
2257      const page=document.createElement('section'); page.className='cssec print-page'+(pi===0?' first':'');
2258      page.innerHTML=`<div class="pp-in"><div class="sh"><h3></h3></div><div class="sb"></div></div>`;
2259      page.querySelector('h3').textContent=title;
2260      const pageSb=page.querySelector('.sb'); let stack=[];
2261      p.items.forEach(i=>{
2262        const el=leaves[i], chain=[];
2263        for(let a=el.parentElement;a&&a!==sb&&a!==root;a=a.parentElement) chain.unshift(a);
2264        if(el===disc) chain.length=0;
2265        let parent=pageSb;
2266        chain.forEach((orig,d)=>{
2267          if(stack[d]&&stack[d].orig===orig){ parent=stack[d].copy; return; }
2268          const copy=orig.cloneNode(false); copy.removeAttribute('id'); parent.appendChild(copy);
2269          if(orig.matches('.ctab')&&thOf.get(orig)&&!el.matches('.th')) copy.appendChild(thOf.get(orig).cloneNode(true));
2270          stack=stack.slice(0,d); stack[d]={orig,copy}; parent=copy;
2271        });
2272        stack=stack.slice(0,chain.length);
2273        parent.appendChild(el);
2274      });
2275      return page;
2276    });
2277    sec.replaceWith(...made);
2278  });
2279  // check each page against the real layout: squeeze a slight overshoot,
2280  // let anything genuinely longer than a page run on
2281  // (the content column is what gets squeezed, so the section name keeps
2282  // its size and position)
2283  root.querySelectorAll('.print-page').forEach(page=>{
2284    const inner=page.querySelector('.pp-in>.sb'), h=inner.getBoundingClientRect().height;
2285    if(h<=H) return;
2286    if(h<=H*1.18) inner.style.zoom=(H/h).toFixed(3); else page.classList.add('tall');
2287  });
2288}
2289window.paginatePrint=paginatePrint;
2290addEventListener('beforeprint',preparePrint);
2291addEventListener('afterprint',restorePrint);
2292window.preparePrint=preparePrint;
2293
2294/* contents anchors: smooth scroll inside the deck */
2295document.querySelectorAll('.contents a').forEach(a=>a.addEventListener('click',e=>{ e.preventDefault(); document.querySelector(a.getAttribute('href')).scrollIntoView({behavior:'smooth'}); }));
2296</script>
2296
2297</body>
2298</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.