PageSourceSearch

https://claudetuner.com/shared/announcement.js?v=20260920

js claudetuner.com collected 2026-10-01 14:02:38 UTC 55,960 bytes, 1,043 lines download raw bytes

1// === ClaudeTuner Announcement System ===
2// 대시보드 4개 페이지에서 공유
3
4var _ctAnnouncementList = [];
5var _ctAnnouncementContainerId = null;
6var _ctAnnouncementLang = null;
7// Per-page-load campaign→variant picks. In-memory only (not persisted), so each page load
8// re-randomizes which variant shows, while staying stable across re-renders within one load.
9var _ctVariantChoices = {};
10
11var _ctNoticeStylesLight = {
12  info:     { bg: '#EFF6FF', border: '#BFDBFE', icon: '\u2139\uFE0F', color: '#1E40AF' },
13  warning:  { bg: '#FFFBEB', border: '#FDE68A', icon: '\u26A0\uFE0F', color: '#92400E' },
14  critical: { bg: '#FEF2F2', border: '#FECACA', icon: '\uD83D\uDEA8', color: '#991B1B' },
15  promo:    { bg: '#F5F3FF', border: '#DDD6FE', icon: '\u2728', color: '#5B21B6' }
16};
17var _ctNoticeStylesDark = {
18  info:     { bg: '#1e3a5f', border: '#2d4a6f', icon: '\u2139\uFE0F', color: '#93c5fd' },
19  warning:  { bg: '#422006', border: '#5a3010', icon: '\u26A0\uFE0F', color: '#fde68a' },
20  critical: { bg: '#450a0a', border: '#5a1a1a', icon: '\uD83D\uDEA8', color: '#fca5a5' },
21  promo:    { bg: '#2e1065', border: '#4c1d95', icon: '\u2728', color: '#c4b5fd' }
22};
23function _ctGetNoticeStyles() {
24  var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
25  return isDark ? _ctNoticeStylesDark : _ctNoticeStylesLight;
26}
27
28// Allow only http(s) or same-origin (/path) URLs; block javascript:/data: etc.
29// Returns '' for unsafe values so callers can skip rendering the link/image.
30function _ctSafeUrl(u) {
31  if (!u) return '';
32  var s = String(u).trim();
33  return (/^https?:\/\//i.test(s) || s.charAt(0) === '/') ? s : '';
34}
35
36// Show a banner only to users in the target IANA timezone (e.g. 'Asia/Seoul').
37// null/empty target = show to everyone. Catches Korea-resident users regardless of UI language.
38function _ctMatchesTz(tzTarget) {
39  if (!tzTarget) return true;
40  try {
41    return Intl.DateTimeFormat().resolvedOptions().timeZone === tzTarget;
42  } catch (e) {
43    return true; // fail open if the browser can't report a timezone
44  }
45}
46
47function _escNoticeHtml(s) {
48  return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
49}
50
51function sanitizeNoticeHtml(html) {
52  if (!html) return '';
53  // DOM 기반 sanitizer: a, br 태그만 허용, 나머지 제거
54  var doc = new DOMParser().parseFromString(html, 'text/html');
55  function walk(node) {
56    var out = '';
57    for (var i = 0; i < node.childNodes.length; i++) {
58      var child = node.childNodes[i];
59      if (child.nodeType === 3) { // TEXT_NODE
60        out += _escNoticeHtml(child.textContent);
61      } else if (child.nodeType === 1) { // ELEMENT_NODE
62        var tag = child.tagName.toLowerCase();
63        if (tag === 'br') {
64          out += '<br>';
65        } else if (tag === 'a') {
66          var href = child.getAttribute('href') || '#';
67          if (href.toLowerCase().indexOf('javascript:') === 0) href = '#';
68          out += '<a href="' + _escNoticeHtml(href) + '" target="_blank" rel="noopener">' + walk(child) + '</a>';
69        } else {
70          out += walk(child);
71        }
72      }
73    }
74    return out;
75  }
76  return walk(doc.body);
77}
78
79function formatNoticeDate(dateStr) {
80  if (!dateStr) return '';
81  try {
82    var d = new Date(dateStr);
83    return (d.getMonth() + 1) + '/' + d.getDate();
84  } catch(e) { return ''; }
85}
86
87function getDismissedNotices() {
88  try {
89    return JSON.parse(localStorage.getItem('ct_dismissed_notices') || '[]');
90  } catch(e) { return []; }
91}
92
93function setDismissedNotices(arr) {
94  localStorage.setItem('ct_dismissed_notices', JSON.stringify(arr));
95}
96
97// Dismissals for A/B campaigns are tracked by campaign key (not variant id), so dismissing
98// any variant hides the whole campaign — it can't reappear as a different variant.
99function getDismissedCampaigns() {
100  try { return JSON.parse(localStorage.getItem('ct_dismissed_campaigns') || '[]'); } catch (e) { return []; }
101}
102function setDismissedCampaigns(arr) {
103  localStorage.setItem('ct_dismissed_campaigns', JSON.stringify(arr));
104}
105
106function dismissNotice(id) {
107  var n = _ctAnnouncementList.filter(function(x) { return x.id === id; })[0];
108  if (n && n.campaign) {
109    var camps = getDismissedCampaigns();
110    if (camps.indexOf(n.campaign) === -1) camps.push(n.campaign);
111    setDismissedCampaigns(camps);
112  } else {
113    var arr = getDismissedNotices();
114    if (arr.indexOf(id) === -1) arr.push(id);
115    setDismissedNotices(arr);
116  }
117  // 배너 DOM 제거
118  var el = document.getElementById('notice-' + id);
119  if (el) el.remove();
120  // 푸터 업데이트
121  updateNoticeFooter();
122}
123
124function showAllNotices() {
125  setDismissedNotices([]);
126  setDismissedCampaigns([]);
127  renderAnnouncements(_ctAnnouncementContainerId, _ctAnnouncementList);
128}
129
130function updateNoticeFooter() {
131  var footer = document.getElementById('ct-notice-footer');
132  if (!footer) return;
133  var dismissed = getDismissedNotices();
134  var dismissedCamps = getDismissedCampaigns();
135  var lang = _ctAnnouncementLang;
136  var seenCamp = {};
137  var hiddenCount = 0;
138  _ctAnnouncementList.forEach(function(n) {
139    if (!n.active) return;
140    if (n.lang && lang && n.lang !== lang) return;
141    if (!_ctMatchesTz(n.tz)) return; // don't count banners not targeted at this user
142    if (n.campaign) {
143      // count each dismissed campaign once
144      if (dismissedCamps.indexOf(n.campaign) !== -1 && !seenCamp[n.campaign]) {
145        seenCamp[n.campaign] = 1;
146        hiddenCount++;
147      }
148    } else if (dismissed.indexOf(n.id) !== -1) {
149      hiddenCount++;
150    }
151  });
152  if (hiddenCount > 0) {
153    var footerText = _ctAnnouncementLang === 'ko'
154      ? '\uACF5\uC9C0\uC0AC\uD56D ' + hiddenCount + '\uAC74'
155      : hiddenCount + ' hidden notice' + (hiddenCount > 1 ? 's' : '');
156    footer.innerHTML = '<a href="#" style="color:#6B7280;font-size:12px;text-decoration:none;display:flex;align-items:center;justify-content:center;gap:4px;padding:8px 0">'
157      + '<span>\uD83D\uDCE2</span> ' + footerText + '</a>';
158    footer.querySelector('a').addEventListener('click', function(e) { e.preventDefault(); showAllNotices(); });
159    footer.style.display = 'block';
160  } else {
161    footer.style.display = 'none';
162  }
163}
164
165var _CT_API_BASE = 'https://api.claudetuner.com';
166
167// Fire-and-forget impression/click beacon for CTR. Both kinds deduped per page session.
168function _ctTrackEvent(id, kind) {
169  if (!id) return;
170  var key = 'ct_evt_' + kind + '_' + id;
171  try { if (sessionStorage.getItem(key)) return; sessionStorage.setItem(key, '1'); } catch (e) {}
172  try {
173    fetch(_CT_API_BASE + '/api/announcements/event', {
174      method: 'POST',
175      headers: { 'Content-Type': 'application/json' },
176      body: JSON.stringify({ id: id, kind: kind }),
177      keepalive: true
178    }).catch(function () {});
179  } catch (e) {}
180}
181
182// Variant selection: within each campaign, show one variant chosen at random.
183// choices: { campaign -> chosen id } for this page load (in-memory; keeps the pick stable across
184// re-renders within a load). dismissed: array of dismissed ids. Returns { selected, updated }.
185function _ctSelectVariants(eligible, choices, dismissed, dismissedCampaigns) {
186  var byCampaign = {}, singles = [], updated = null;
187  eligible.forEach(function (n) {
188    if (n.campaign) (byCampaign[n.campaign] = byCampaign[n.campaign] || []).push(n);
189    else singles.push(n);
190  });
191  var out = singles.filter(function (n) { return dismissed.indexOf(n.id) === -1; });
192  Object.keys(byCampaign).forEach(function (camp) {
193    if (dismissedCampaigns.indexOf(camp) !== -1) return; // whole campaign dismissed
194    var grp = byCampaign[camp];
195    var ids = grp.map(function (g) { return g.id; }).sort();
196    var chosen = choices[camp];
197    if (!chosen || ids.indexOf(chosen) === -1) {
198      chosen = ids[Math.floor(Math.random() * ids.length)];
199      updated = updated || {};
200      updated[camp] = chosen;
201    }
202    for (var i = 0; i < grp.length; i++) { if (grp[i].id === chosen) { out.push(grp[i]); break; } }
203  });
204  return { selected: out, updated: updated };
205}
206
207// ════════════════════════════════════════════════════════════════════════════
208// In-house ad server — Phase 1 SERVING (design docs/DESIGN-sidebar-ad-server.md)
209// ════════════════════════════════════════════════════════════════════════════
210// The dashboard is a SEPARATE Cloudflare Pages deploy and can't import the
211// extension's canonical ad core (usage-shared.js). This is a mechanical SYNC COPY
212// of that logic, adapted to localStorage (no chrome.storage). The PLACEMENTS map
213// and banner markup are kept identical to the extension's, guarded by
214// test/ads-dry-guard.mjs (single-source-of-truth rule).
215
216// SYNC COPY of __ctUsageCore.PLACEMENTS — keep values identical to usage-shared.js.
217var _CT_PLACEMENTS = {
218  CLAUDE_SIDEBAR: 'claude_sidebar',
219  CHATGPT_SIDEBAR: 'chatgpt_sidebar',
220  GEMINI_SIDEBAR: 'gemini_sidebar',
221  POPUP: 'popup',
222  DASHBOARD: 'dashboard'
223};
224
225var _CT_ADS_URL = 'https://cdn.claudetuner.com/ads.json';
226var _CT_ADS_CACHE_KEY = 'ct_ads_cache';       // { at, list } (localStorage)
227var _CT_ADS_TTL_MS = 5 * 60 * 1000;           // 5min — match CDN max-age=300 so a
228                                              // pulled/edited creative propagates fast
229var _CT_AD_STICKY_KEY = 'ct_ad_sticky';       // { [campaign_id]: content_id }
230var _CT_AD_CAP_KEY = 'ct_ad_cap';             // { "campaign|placement": {day,count} }
231var _CT_AD_INQUIRY_URL = 'https://tally.so/r/q4dyQk?source=ad_inquiry'; // advertiser-inquiry Tally form (opened from the "Ad" label)
232var _ctAds = [];                              // selected ad banners for this page load
233var _ctAdServedSession = {};                  // per-load dedup for the daily cap counter
234var _ctAdsInFlight = false;                   // an ads.json selection is being awaited (dedup only)
235var _ctAdsSettled = false;                    // picks for THIS account state are rendered (or none servable)
236
237// === Premium ad gate (#1530) — a subscriber (`/api/me billing.plan === 'pro'`) sees no ads. ===
238// The pages that fetch /api/me hand the WHOLE payload over via _ctCachePlan(meData) (next to
239// _ctCacheCountry). loadAnnouncements runs BEFORE that fetch resolves (dashboard onLogin calls it
240// first), so the decision at ad-fetch time is the localStorage copy of the LAST answer FOR THIS
241// ACCOUNT, and the live answer then corrects it in either direction, any number of times:
242//   cached free / nothing → ads fetched+rendered → /api/me says pro  → banners removed (re-render)
243//   cached pro            → ad fetch skipped     → /api/me says free → ads fetched now
244// Identity (Codex 1R #1-#3): the cache is keyed by the signed-in email (window._ctUserEmail, set by
245// every dashboard page's onLogin before loadAnnouncements) and a payload is accepted ONLY when its
246// user.email is that principal — so a demo fixture (no user/billing), an admin `?as=` view (the
247// viewed user's billing), an error body, or a delayed response from the previous account can never
248// write this account's plan. No answer for this account = free = ads (fail open, as before the gate).
249// Pages that never fetch /api/me (insights, admin) resolve it ONCE per account per browser via
250// _ctResolvePlanFallback. ads.json carries no plan targeting (privacy notice §9) — client-side only.
251var _CT_PLAN_KEY = 'ct_billing_plan';         // { email, plan:'pro'|'free' } — last accepted /api/me answer
252var _ctAdFreeLive = null;                     // this page load's confirmed answer; null until /api/me
253var _ctAdFreeLiveFor = null;                  // …and the principal it was confirmed for (same-tab account switch)
254var _CT_PLAN_FALLBACK_MS = 4000;              // grace for the page's own /api/me before the fallback asks
255
256// The signed-in principal, lower-cased. Preferred source is the dashboard session token's `email`
257// claim: the server resolves a verified Google alias to the primary account there (routes/auth.ts)
258// and /api/me answers with that primary email, whereas window._ctUserEmail (set from the Google
259// ID token) is the alias — comparing against it would reject the subscriber's own answer (Codex
260// 2R #2). Fallback to window._ctUserEmail (demo pages, no session yet). Read-only: never deletes.
261function _ctPrincipalEmail() {
262  var tok = null;
263  try { tok = localStorage.getItem('ct_session_token'); } catch (e) {}
264  if (tok) {
265    try {
266      var p = JSON.parse(atob(tok.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
267      if (p && p.iss === 'claudetuner' && typeof p.email === 'string' && p.email) return p.email.toLowerCase();
268    } catch (e) {}
269  }
270  var e = typeof window !== 'undefined' && window._ctUserEmail;
271  return (typeof e === 'string' && e) ? e.toLowerCase() : null;
272}
273// The cached answer for the CURRENT principal only: 'pro' | 'free' | null (unknown / other account).
274function _ctCachedPlan() {
275  var me = _ctPrincipalEmail();
276  var c = _ctLsGet(_CT_PLAN_KEY);
277  if (!me || !c || typeof c !== 'object' || c.email !== me) return null;
278  return c.plan === 'pro' ? 'pro' : (c.plan === 'free' ? 'free' : null);
279}
280// The live answer counts only for the principal it was given for — a same-tab logout/login to
281// another account (no reload) must not inherit the previous account's verdict (Codex 2R #1).
282function _ctLiveAdFree() {
283  if (_ctAdFreeLive === null) return null;
284  var me = _ctPrincipalEmail();
285  return (me && _ctAdFreeLiveFor === me) ? _ctAdFreeLive : null;
286}
287function _ctIsAdFree() {
288  var live = _ctLiveAdFree();
289  if (live !== null) return live;
290  return _ctCachedPlan() === 'pro';
291}
292
293// Call from every /api/me consumer with the full payload. Returns true when the answer was accepted.
294// Accepted = { user: { email: <the signed-in principal> }, billing: { plan } }; anything else is not an
295// answer about this account and changes nothing (neither the cache nor this load's decision).
296function _ctCachePlan(me) {
297  var principal = _ctPrincipalEmail();
298  if (!principal || !me || typeof me !== 'object') return false;
299  var email = me.user && typeof me.user.email === 'string' ? me.user.email.toLowerCase() : null;
300  var billing = me.billing;
301  if (email !== principal || !billing || typeof billing !== 'object') return false;
302  var adFree = billing.plan === 'pro';
303  _ctLsSet(_CT_PLAN_KEY, { email: principal, plan: adFree ? 'pro' : 'free' });
304  _ctAdFreeLive = adFree;
305  _ctAdFreeLiveFor = principal;
306  if (!_ctAnnouncementContainerId) return true; // announcements not rendered yet — _ctRequestAds reads the live answer
307  if (adFree) {
308    // Drop whatever is painted; a later free answer (account state flips within the load) re-requests.
309    _ctAdsSettled = false;
310    var painted = _ctAds.length > 0 || _ctAnnouncementList.some(function (n) { return n && n._ad; });
311    _ctAds = [];
312    if (painted) renderAnnouncements(_ctAnnouncementContainerId, _ctAnnouncementList); // banners + promo-feed rows go
313  } else {
314    _ctRequestAds(_ctAnnouncementContainerId);
315  }
316  return true;
317}
318
319// The ONLY entry to the dashboard ad fetch — gated on _ctIsAdFree at request time AND at
320// resolve time (the live /api/me answer can land while ads.json is in flight). In-flight dedup
321// and "already settled" are separate flags so free → pro → free recovers (Codex 1R #4).
322function _ctRequestAds(containerId) {
323  if (_ctAdsInFlight || _ctAdsSettled || _ctIsAdFree()) return;
324  _ctAdsInFlight = true;
325  _ctSelectDashboardAds(_ctAnnouncementLang).then(function (ads) {
326    _ctAdsInFlight = false;
327    if (_ctIsAdFree()) return; // Premium confirmed while fetching → drop the picks unrendered
328    _ctAdsSettled = true;
329    _ctAds = ads || [];
330    // Re-render when there is anything ad-shaped to (re)paint: banners, or partner-promo rows that a
331    // previous pro verdict hid — an empty banner pick must not leave those hidden (Codex 2R #3).
332    var adRows = _ctAnnouncementList.some(function (n) { return n && n._ad; });
333    if (_ctAds.length || adRows) renderAnnouncements(containerId, _ctAnnouncementList);
334  }, function () { _ctAdsInFlight = false; });
335}
336
337// Pages without their own /api/me (insights, admin): one fetch per account per browser, only when
338// nothing is cached for this account and the page's own fetch (if any) has not answered within the
339// grace period. Needs the dashboard session helper (shared/auth.js) — absent = stay unknown = ads.
340function _ctResolvePlanFallback() {
341  if (_ctLiveAdFree() !== null || _ctCachedPlan() !== null || !_ctPrincipalEmail()) return;
342  if (typeof authHeaders !== 'function') return;
343  var tok = null;
344  try { tok = localStorage.getItem('ct_session_token'); } catch (e) {}
345  if (!tok) return;
346  fetch(_CT_API_BASE + '/api/me', { headers: authHeaders() })
347    .then(function (r) { return r.ok ? r.json() : null; })
348    .then(function (d) { if (d) _ctCachePlan(d); })
349    .catch(function () {});
350}
351
352function _ctLsGet(key) {
353  try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch (e) { return null; }
354}
355function _ctLsSet(key, val) {
356  try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) {}
357}
358
359// Fetch the nested ads.json with a 5min localStorage cache (mirrors the extension's
360// chrome.storage cache). Returns a Promise of the raw campaign array ([] on error).
361function _ctFetchAds() {
362  var cached = _ctLsGet(_CT_ADS_CACHE_KEY);
363  if (cached && Array.isArray(cached.list) && (Date.now() - (cached.at || 0)) < _CT_ADS_TTL_MS) {
364    return Promise.resolve(cached.list);
365  }
366  return fetch(_CT_ADS_URL)
367    .then(function (r) { return r.ok ? r.json() : []; })
368    .then(function (list) {
369      if (!Array.isArray(list)) return [];
370      _ctLsSet(_CT_ADS_CACHE_KEY, { at: Date.now(), list: list });
371      return list;
372    })
373    .catch(function () { return []; });
374}
375
376// Absent/empty/null list = match all; a null signal value also passes (unknown →
377// don't suppress). But a PRESENT non-array list is malformed data → fail closed
378// (NO match) so a bad upload can never leak to every user.
379function _ctAdInList(list, val) {
380  if (list == null) return true;
381  if (!Array.isArray(list)) return false;
382  if (list.length === 0) return true;
383  if (val == null) return true;
384  return list.indexOf(val) !== -1;
385}
386
387function _ctAdCampaignMatches(c, ctx) {
388  if (!c || !c.campaign_id) return false;
389  var t = c.targeting || {};
390  var s = c.schedule || {};
391  if (s.start_at && ctx.now < s.start_at) return false;
392  if (s.end_at && ctx.now > s.end_at) return false;
393  if (!_ctAdInList(t.placements, ctx.placement)) return false;
394  if (!_ctAdInList(t.lang, ctx.lang)) return false;
395  if (!_ctAdInList(t.country, ctx.country)) return false;
396  // Targeting is limited to placement + language + country, matching the privacy notice
397  // (site/privacy §9: "targeted only by ... country and interface language"). Legacy
398  // plan/provider keys in stored campaign JSON are ignored here, as in the extension core.
399  return true;
400}
401
402function _ctWeightedPickContent(contents) {
403  var total = 0, i;
404  for (i = 0; i < contents.length; i++) total += (Number(contents[i].weight) > 0 ? Number(contents[i].weight) : 1);
405  var r = Math.random() * total;
406  for (i = 0; i < contents.length; i++) {
407    r -= (Number(contents[i].weight) > 0 ? Number(contents[i].weight) : 1);
408    if (r <= 0) return contents[i];
409  }
410  return contents[contents.length - 1];
411}
412
413// Pick ONE content (sticky default persists (user,campaign)→content for fair A/B;
414// weighted re-rolls per load). Returns { content, assigned } (assigned = sticky id to persist).
415function _ctSelectAdContent(campaign, sticky) {
416  var contents = (campaign.contents || []).filter(function (c) {
417    return c && c.content_id && (c.active == null || c.active);
418  });
419  if (!contents.length) return { content: null, assigned: null };
420  if (campaign.rotation === 'weighted') return { content: _ctWeightedPickContent(contents), assigned: null };
421  var prev = sticky[campaign.campaign_id];
422  var found = prev && contents.filter(function (c) { return c.content_id === prev; })[0];
423  if (found) return { content: found, assigned: null };
424  var chosen = _ctWeightedPickContent(contents);
425  return { content: chosen, assigned: chosen.content_id };
426}
427
428function _ctAdDayKey(now) {
429  var d = new Date(now);
430  return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate();
431}
432function _ctAdCapId(campaignId, placement) { return campaignId + '|' + placement; }
433function _ctAdCapReached(capStore, campaignId, placement, perDay, today) {
434  if (!perDay || perDay <= 0) return false;
435  var e = capStore[_ctAdCapId(campaignId, placement)];
436  return !!(e && e.day === today && e.count >= perDay);
437}
438
439// Cache the country the worker piggybacks on the /api/me response (symmetric to the
440// extension, which caches cf.country from snapshot POST responses). This is the only
441// country signal the dashboard has, so country-targeted campaigns fail open until it
442// lands. Call from the /api/me consumer with meData.country.
443function _ctCacheCountry(country) {
444  if (typeof country === 'string' && country) {
445    try { localStorage.setItem('ct_country', country.toUpperCase()); } catch (e) {}
446  }
447}
448
449// Fetch → target-filter → cap-check → pick ONE campaign at random → content-select for
450// the dashboard placement. Returns a Promise of an array holding a single
451// { campaign, content, placement } (or []) — one ad slot per fetch, chosen uniformly at
452// random among servable campaigns so the banner rotates across advertisers instead of
453// stacking them. Cap is only CHECKED here.
454function _ctSelectDashboardAds(lang) {
455  return _ctFetchAds().then(function (campaigns) {
456    if (!Array.isArray(campaigns) || !campaigns.length) return [];
457    var now = Date.now();
458    var country = null;
459    var stored = _ctLsGet('ct_country');
460    if (typeof stored === 'string' && stored) country = stored.toUpperCase();
461    var ctx = { placement: _CT_PLACEMENTS.DASHBOARD, lang: lang || null, country: country, now: now };
462    var eligible = campaigns.filter(function (c) { return _ctAdCampaignMatches(c, ctx); });
463    if (!eligible.length) return [];
464    var sticky = _ctLsGet(_CT_AD_STICKY_KEY) || {};
465    var cap = _ctLsGet(_CT_AD_CAP_KEY) || {};
466    var today = _ctAdDayKey(now);
467    // Keep only campaigns whose daily cap is not yet reached.
468    var servable = eligible.filter(function (c) {
469      var perDay = c.cap && Number(c.cap.per_day);
470      return !_ctAdCapReached(cap, c.campaign_id, ctx.placement, perDay, today);
471    });
472    if (!servable.length) return [];
473    // TWO slots on the dashboard (the sidebars keep one). The dashboard container is ~912px
474    // wide, so a single full-width banner stretches a 34px logo and one headline across the
475    // whole page and reads as mostly empty. Two side by side land at ~450px each.
476    //
477    // Distinct campaigns only — drawing the same advertiser into both slots would look like
478    // a bug and burn the frequency cap twice on one impression pair. Sampling WITHOUT
479    // replacement also fixes a latent hole in the single-slot version: it picked one campaign
480    // and gave up entirely (`return []`) if that campaign had no usable content, so one bad
481    // campaign could blank the whole slot. Here a campaign with no content is skipped and the
482    // next one is drawn.
483    var SLOTS = 2;
484    var pool = servable.slice();
485    var picks = [];
486    var assignedAny = false;
487    while (pool.length && picks.length < SLOTS) {
488      var c = pool.splice(Math.floor(Math.random() * pool.length), 1)[0];
489      var picked = _ctSelectAdContent(c, sticky);
490      if (!picked.content) continue; // no usable creative → try another advertiser
491      if (picked.assigned) { sticky[c.campaign_id] = picked.assigned; assignedAny = true; }
492      picks.push({ campaign: c, content: picked.content, placement: ctx.placement });
493    }
494    if (assignedAny) _ctLsSet(_CT_AD_STICKY_KEY, sticky);
495    return picks;
496  });
497}
498
499// Record a serve toward the daily frequency cap (serving-side, deduped per page load).
500function _ctNoteAdServed(campaignId, placement) {
501  if (!campaignId || !placement) return;
502  var key = _ctAdCapId(campaignId, placement);
503  if (_ctAdServedSession[key]) return;
504  _ctAdServedSession[key] = 1;
505  var cap = _ctLsGet(_CT_AD_CAP_KEY) || {};
506  var today = _ctAdDayKey(Date.now());
507  var e = cap[key];
508  if (e && e.day === today) e.count += 1; else cap[key] = { day: today, count: 1 };
509  _ctLsSet(_CT_AD_CAP_KEY, cap);
510}
511
512// ════════════════════════════════════════════════════════════════════════════
513// Ad measurement (Phase 2, design §5) — DASHBOARD twin of the extension core
514// ════════════════════════════════════════════════════════════════════════════
515// SYNC COPY of the extension's viewability/click/flush logic
516// (claude-tuner-extension/usage-shared.js trackAdViewability/trackAdClick +
517// background.js _adEnqueue/incrementAdCounter/flushAdCounters). Kept behavior-
518// identical so the two runtimes stay parity-consistent, guarded by
519// test/ads-dry-guard.mjs. The difference: there is NO service worker here — a web
520// page owns BOTH the increments AND the flush, and self-flushes on a fixed timer +
521// tab-hide/pagehide (no server-tunable alarm cadence). Counters live in
522// localStorage instead of chrome.storage.local.
523var _CT_AD_COUNTERS_KEY = 'ct_ad_counters'; // { "campaign|content|placement": {imp,clk} }
524var AD_IMPRESSION_DEDUP_MS = 10 * 60 * 1000; // ≤1 impression per creative / 10min
525var AD_CLICK_DEDUP_MS = 1000;                // collapse click bursts / double-fires
526// The worker /api/event caps the body at 4096 bytes AND 50 rows. campaign/content IDs
527// can be up to 128 chars each, so a fixed row count can exceed the byte cap → the worker
528// drops the body but returns 204 while the client would subtract = silent count loss.
529// The flush builds its batch by BYTE BUDGET (AD_FLUSH_MAX_BYTES, headroom under 4096)
530// with a 50-row hard cap; excess keys drain over subsequent flush ticks.
531var AD_FLUSH_MAX_ROWS = 50;    // hard row cap (matches the worker's row slice)
532var AD_FLUSH_MAX_BYTES = 3600; // serialized-body byte budget (headroom under the worker's 4096)
533
534// Byte-accurate size of a string: Blob is exact for any encoding; str.length is an
535// ASCII-only fallback (ad IDs are ASCII, so char ≈ byte) when Blob is unavailable.
536function _ctAdByteSize(str) {
537  try { return new Blob([str]).size; } catch (e) { return str.length; }
538}
539// Fixed self-flush cadence. Unlike the extension (server-tunable via getCadence), a
540// page has no alarm/cadence channel; 2min is a sane fixed default that also drains
541// promptly given the tab-hide/pagehide tail flushes below.
542var DASH_FLUSH_MS = 2 * 60 * 1000;
543
544// Per-key impression/click dedup windows (Date.now() ms), keyed by creative identity
545// (campaign|content|placement) so a re-render (new element every announcements refresh)
546// can't re-count the SAME creative. Scope is one page load (module-level Map).
547var _ctAdImpressionSeen = {}; // key → lastFiredMs
548var _ctAdClickSeen = {};      // key → lastFiredMs
549function _ctAdKey(ad) {
550  return ad.campaign.campaign_id + '|' + ad.content.content_id + '|' + ad.placement;
551}
552
553// Single async op-chain (mutex): every counter read-modify-write — increment AND
554// flush — is appended here so they can never interleave within this page. Failures
555// are swallowed so one bad op never wedges the chain. The in-page chain only orders
556// THIS page's ops; the counters live in shared localStorage, so two dashboard tabs
557// could otherwise flush the same batch (duplicate AE counts) or clobber each other's
558// read-modify-write (undercount). Each op therefore also acquires a CROSS-TAB lock via
559// the Web Locks API ('ct_ad_ctr') so counter mutations are serialized across tabs too.
560// Fallback: browsers without navigator.locks run the fn directly (prior behavior) —
561// never throw. The extension SW is the single owner and needs no such lock.
562var _ctAdOpChain = Promise.resolve();
563
564// Cross-tab fallback for browsers WITHOUT navigator.locks. The dangerous op is the flush:
565// two tabs that snapshot the same counters both POST the same rows and the server counts
566// BOTH → OVER-count, i.e. impressions/clicks that never happened. So the flush — and only
567// the flush — takes a lease. Increments stay lock-free: they are a synchronous
568// read-modify-write, so the worst ordinary outcome is a DROPPED count (under-count), and a
569// missed impression is not corruption.
570// The lease is a localStorage claim with write-then-read-back: writes are serialized per
571// origin, so when two claims interleave as write/write/read/read both tabs read back the
572// same last-written token and only one matches. The loser skips this tick — its counters
573// stay in localStorage and go out on the next flush. The lease expires so a tab killed
574// mid-flush cannot wedge flushing forever.
575//
576// This NARROWS the over-count window, it does not close it. Two known residual races,
577// both requiring an exact cross-process interleaving of adjacent localStorage ops:
578//   1. lease claim: A.claim → A.read-back → B.claim → B.read-back lets BOTH tabs win.
579//   2. increment vs flush: the flush re-reads and SUBTRACTS what it sent, so an increment
580//      that read the pre-subtract value and writes after it can resurrect already-sent
581//      counts → over-count. (Bounded: the increment's read and write are adjacent
582//      synchronous statements, so the window is a single localStorage op wide.)
583// Closing either properly needs a real mutex — which is exactly what navigator.locks is,
584// and every browser that can run the extension has it. This whole path is a last-resort
585// fallback for a browser we do not expect to see; a rare miscount there beats the certain
586// double-count of having no lease at all. If it ever needs to be correct, serialize the
587// increment under the same lease rather than bolting more heuristics onto this one.
588var _CT_AD_LOCK_KEY = 'ct_ad_ctr_lock';  // { token, exp }
589var AD_LOCK_TTL_MS = 30 * 1000;          // > worst-case flush POST; self-heals a dead holder
590
591function _ctAdTryLease() {
592  var now = Date.now();
593  var cur = _ctLsGet(_CT_AD_LOCK_KEY);
594  if (cur && Number(cur.exp) > now) return null; // another tab is mid-flush
595  var token = String(now) + '-' + Math.random().toString(36).slice(2);
596  _ctLsSet(_CT_AD_LOCK_KEY, { token: token, exp: now + AD_LOCK_TTL_MS });
597  var back = _ctLsGet(_CT_AD_LOCK_KEY);         // last writer wins → only one token survives
598  return (back && back.token === token) ? token : null;
599}
600function _ctAdReleaseLease(token) {
601  var cur = _ctLsGet(_CT_AD_LOCK_KEY);
602  if (cur && cur.token === token) {
603    try { localStorage.removeItem(_CT_AD_LOCK_KEY); } catch (e) {}
604  }
605}
606
607// exclusive=true → must not run concurrently in ANOTHER TAB (the flush). Everything else
608// is only ordered within this page, by the op-chain.
609function _ctAdEnqueue(fn, exclusive) {
610  var run = function () {
611    if (navigator.locks && navigator.locks.request) return navigator.locks.request('ct_ad_ctr', fn);
612    if (!exclusive) return fn();
613    var token = _ctAdTryLease();
614    if (!token) return; // lease held elsewhere → skip; counters survive for the next tick
615    var release = function () { _ctAdReleaseLease(token); };
616    return Promise.resolve().then(fn).then(release, release);
617  };
618  _ctAdOpChain = _ctAdOpChain.then(run).catch(function () {});
619  return _ctAdOpChain;
620}
621
622// Increment one counter cell. Mirrors incrementAdCounter (SW side).
623function _ctIncrementAdCounter(kind, ad) {
624  var campaign = ad.campaign.campaign_id, content = ad.content.content_id, placement = ad.placement;
625  if (!campaign || !content || !placement) return;
626  if (kind !== 'impression' && kind !== 'click') return;
627  _ctAdEnqueue(function () {
628    var store = _ctLsGet(_CT_AD_COUNTERS_KEY) || {};
629    var key = campaign + '|' + content + '|' + placement;
630    var e = store[key] || { imp: 0, clk: 0 };
631    if (kind === 'impression') e.imp++; else e.clk++;
632    store[key] = e;
633    _ctLsSet(_CT_AD_COUNTERS_KEY, store);
634  });
635}
636
637// Viewability-gated impression (design §5.2) — EXACT mirror of trackAdViewability:
638// count ONE impression per creative when it stays >=50% visible in a visible tab for
639// 1s. Deduped per element (__ctViz) AND per creative key (10-min window). The 1s
640// timer's fire path REVALIDATES visibility+intersection at fire time and only
641// disconnects/dedups on a SUCCESSFUL fire, otherwise it re-arms when viewable again.
642// On a successful fire it INCREMENTS the localStorage counter (no SW to message).
643function _ctTrackAdViewability(el, ad) {
644  if (!el || el.__ctViz || !ad || !ad.campaign || !ad.content) return;
645  if (typeof IntersectionObserver !== 'function') return;
646  _ctAdMeasureInit(); // lazily arm the flush timer + tail-flush listeners (once)
647  var key = _ctAdKey(ad);
648  var seen = _ctAdImpressionSeen[key];
649  if (seen && (Date.now() - seen) < AD_IMPRESSION_DEDUP_MS) return; // already counted this window — don't even observe
650  el.__ctViz = true;
651  var intersecting = false; // live viewability, updated on every IO callback
652  var timer = null;
653  var obs;
654  var clear = function () { if (timer) { clearTimeout(timer); timer = null; } };
655  // Clear a pending timer the moment the tab is hidden mid-wait so it can't fire
656  // against a hidden tab; removed on disconnect to avoid a listener leak.
657  var onVis = function () { if (document.visibilityState !== 'visible') clear(); };
658  var disconnect = function () {
659    clear();
660    obs.disconnect();
661    document.removeEventListener('visibilitychange', onVis);
662  };
663  var fire = function () {
664    timer = null;
665    // Revalidate at fire time: only a still-viewable ad in a visible tab counts.
666    if (document.visibilityState !== 'visible' || intersecting !== true) return; // not viewable now → keep observing, re-arm on re-entry
667    disconnect();
668    _ctAdImpressionSeen[key] = Date.now();
669    _ctIncrementAdCounter('impression', ad);
670  };
671  obs = new IntersectionObserver(function (entries) {
672    var entry = entries[entries.length - 1];
673    intersecting = !!(entry && entry.isIntersecting && entry.intersectionRatio >= 0.5);
674    var viewable = intersecting && document.visibilityState === 'visible';
675    if (viewable) { if (!timer) timer = setTimeout(fire, 1000); }
676    else { clear(); } // left viewport / tab hidden before 1s → restart on re-entry
677  }, { threshold: [0, 0.5] });
678  document.addEventListener('visibilitychange', onVis);
679  obs.observe(el);
680}
681
682// Click counter (design §5.3) — EXACT mirror of trackAdClick. Ignores synthetic
683// clicks (isTrusted === false) and collapses same-creative bursts within
684// AD_CLICK_DEDUP_MS (double-fire / click fraud). A genuine repeat click after the
685// window still counts. Increments the localStorage counter (no SW to message).
686function _ctTrackAdClick(ad, e) {
687  if (!ad || !ad.campaign || !ad.content) return;
688  if (e && e.isTrusted === false) return; // synthetic click → ignore
689  var key = _ctAdKey(ad);
690  var seen = _ctAdClickSeen[key];
691  var now = Date.now();
692  if (seen && (now - seen) < AD_CLICK_DEDUP_MS) return; // dup within window
693  _ctAdClickSeen[key] = now;
694  _ctIncrementAdCounter('click', ad);
695}
696
697// Flush batched counters to /api/event — mirror of flushAdCounters. Runs through the
698// op-chain so it never races an increment. Sends a snapshot of the current non-empty
699// cells (≤30 rows), then SUBTRACTS exactly what was sent from a FRESH re-read (never
700// zeroes) so increments that arrived during the in-flight POST are preserved. No-op
701// when nothing to send; on POST failure the counters are left intact for next flush.
702function _ctFlushAdCounters() {
703  // exclusive: a concurrent flush in another tab would re-send the same rows (over-count).
704  return _ctAdEnqueue(function () {
705    var store = _ctLsGet(_CT_AD_COUNTERS_KEY) || {};
706    var keys = Object.keys(store).filter(function (k) { return store[k].imp > 0 || store[k].clk > 0; });
707    if (!keys.length) return;
708    // Accumulate rows by BYTE BUDGET: add one at a time and stop before the serialized
709    // body would exceed AD_FLUSH_MAX_BYTES OR AD_FLUSH_MAX_ROWS is reached (whichever
710    // first). Keys not included stay in the store and drain next flush (only the keys in
711    // `batch` are subtracted below). A single row that alone exceeds the budget (only
712    // possible with corrupted keys — a valid 128-char-ID row is ~375B) is SKIPPED, never
713    // force-sent, so the worker can't drop it and cause subtract-on-204 to lose counts.
714    var batch = [];
715    var rows = [];
716    for (var bi = 0; bi < keys.length; bi++) {
717      var bk = keys[bi];
718      var parts = bk.split('|');
719      var row = { campaign: parts[0], content: parts[1], placement: parts[2], imp: store[bk].imp, clk: store[bk].clk };
720      var candidate = JSON.stringify({ type: 'banner_batch', ver: 'dash', rows: rows.concat([row]) });
721      if (_ctAdByteSize(candidate) > AD_FLUSH_MAX_BYTES) {
722        if (rows.length) break; // batch full — send the rest next flush
723        continue;               // lone oversized row — skip (don't force-send + lose it)
724      }
725      rows.push(row);
726      batch.push(bk);
727      if (batch.length >= AD_FLUSH_MAX_ROWS) break; // hard row cap
728    }
729    if (!rows.length) return; // nothing sendable this tick
730    // text/plain keeps this a CORS simple request (no preflight). The server always returns
731    // 204. ver = 'dash' (no dashboard manifest version).
732    //
733    // `_auth` carries the session token ONLY so the server can count DISTINCT users reached
734    // (privacy §9: hashed with a server secret, held <=24h as a de-duplication key, never
735    // written to analytics). The dashboard MUST send it — it is currently the only ad surface
736    // live to real users, so extension-only reach would report zero until the next CWS
737    // release. Signed out → no token → anonymous beacon; impressions still count, no reach.
738    var _payload = { type: 'banner_batch', ver: 'dash', rows: rows };
739    try {
740      var _tok = localStorage.getItem('ct_session_token');
741      if (_tok) _payload._auth = _tok;
742    } catch (e) { /* storage blocked → stay anonymous */ }
743    return fetch(_CT_API_BASE + '/api/event', {
744      method: 'POST',
745      headers: { 'Content-Type': 'text/plain' },
746      body: JSON.stringify(_payload),
747      keepalive: true
748    }).then(function (r) {
749      if (!(r.ok || r.status === 204)) return; // leave counters intact; retry on next flush
750      // Re-read and SUBTRACT what was actually sent (preserve concurrent increments).
751      var cur = _ctLsGet(_CT_AD_COUNTERS_KEY) || {};
752      for (var i = 0; i < batch.length; i++) {
753        var k = batch[i];
754        if (!cur[k]) continue;
755        cur[k].imp -= store[k].imp;
756        cur[k].clk -= store[k].clk;
757        if (cur[k].imp <= 0 && cur[k].clk <= 0) delete cur[k];
758      }
759      _ctLsSet(_CT_AD_COUNTERS_KEY, cur);
760    }).catch(function () {}); // network failure → leave counters intact
761  }, true);
762}
763
764// Arm the flush triggers ONCE per page (a page has no SW/alarm). Guarded by a module
765// flag so repeated renders / viewability calls don't stack intervals or listeners.
766var _ctAdMeasureInited = false;
767function _ctAdMeasureInit() {
768  if (_ctAdMeasureInited) return;
769  _ctAdMeasureInited = true;
770  try { setInterval(_ctFlushAdCounters, DASH_FLUSH_MS); } catch (e) {}
771  // Tail flush so short sessions don't wait for the periodic tick.
772  document.addEventListener('visibilitychange', function () {
773    if (document.visibilityState === 'hidden') _ctFlushAdCounters();
774  });
775  window.addEventListener('pagehide', function () { _ctFlushAdCounters(); });
776}
777
778// Build one ad banner — SYNC COPY of __ctUsageCore.buildAdBannerHtml (theme-neutral
779// inline styles, "Ad"/"광고" source label for brand safety, design §12).
780function _ctBuildAdBannerHtml(ad, lang) {
781  var c = ad.content || {};
782  var img = _ctSafeUrl(c.image_url);
783  var url = _ctSafeUrl(c.url);
784  var key = _escNoticeHtml(ad.campaign.campaign_id + '|' + c.content_id + '|' + ad.placement);
785  var tooltip = lang === 'ko'
786    ? 'Claude Tuner가 게재하는 광고입니다. 광고 문의하려면 클릭하세요.'
787    : 'Ad shown by Claude Tuner. Click to advertise with us.';
788  // "Corner badge" layout: the disclosure mark is taken OUT OF FLOW (absolute, pinned to
789  // the banner's top-left) instead of occupying its own leading line. The leading-line
790  // version cost a full row of vertical space in a 230px-wide sidebar for a 9px chip.
791  // Out of flow, the logo + headline row starts at the top and the banner is ~14px shorter.
792  var h = '<div class="ct-ad-banner" data-ad-key="' + key + '"'
793    + (url ? ' data-ad-url="' + _escNoticeHtml(url) + '"' : '')
794    + ' style="position:relative;width:100%;box-sizing:border-box;'
795    + 'border:1px solid rgba(128,128,128,0.28);border-radius:10px;padding:8px 10px;margin:6px 0;'
796    + 'box-shadow:0 1px 2px rgba(0,0,0,0.04);font-family:inherit;'
797    + (url ? 'cursor:pointer;' : '') + '">';
798  // Disclosure badge: out of flow, pinned to the banner's top-RIGHT corner. Top-left
799  // would land on the creative's logo (34px, vertically centred) and read as dirty,
800  // especially over a light logo tile on a dark theme. Right-aligned it collides with
801  // nothing — only the headline's first line, which reserves room for it below.
802  // Deliberately low-contrast (outline only, no fill) so it recedes — but the outline
803  // STAYS: it is what reads the mark as a disclosure rather than part of the ad copy.
804  // Don't fade it further; an ad label has to remain plainly recognisable.
805  // It stays an advertiser-inquiry link: hover discloses it's an ad, click opens the
806  // inquiry form. The banner click handler ignores clicks inside .ct-ad-label so the
807  // ad URL isn't opened too.
808  h += '<a class="ct-ad-label" href="' + _escNoticeHtml(_CT_AD_INQUIRY_URL) + '" target="_blank" rel="noopener noreferrer"'
809    + ' title="' + _escNoticeHtml(tooltip) + '"'
810    + ' style="position:absolute;top:2px;right:3px;z-index:1;'
811    + 'font-size:7.5px;font-weight:600;letter-spacing:0.03em;line-height:11px;padding:0 2px;'
812    + 'border:1px solid rgba(128,128,128,0.22);border-radius:3px;'
813    + 'background:none;opacity:0.45;'
814    + 'text-decoration:none;color:inherit;cursor:pointer;white-space:nowrap">AD</a>';
815  // Content row: logo (only if present) + full-width headline/subtitle. No leading chip
816  // above it any more — the badge floats over this row's top-right corner.
817  h += '<div style="display:flex;align-items:center;gap:8px">';
818  if (img) {
819    h += '<img src="' + _escNoticeHtml(img) + '" alt="" style="width:34px;height:34px;'
820      + 'border-radius:9px;object-fit:cover;flex-shrink:0" />';
821  }
822  h += '<div style="flex:1;min-width:0">';
823  // padding-right reserves exactly the strip the badge floats over, so the headline can
824  // never run underneath it. 12px is measured, not guessed: the badge is 17px wide and
825  // overhangs the text column by ~10px, and anything wider (a 20px badge, or 16px+ of
826  // padding) pushes this headline onto a SECOND line and gives back all the height we
827  // just saved. Only the headline needs it — the body sits below the badge.
828  // No bold: the headline inherits the surface's normal weight so the banner reads as
829  // part of the panel rather than shouting over it.
830  h += '<div style="font-size:13px;line-height:1.25;padding-right:12px">'
831    + _escNoticeHtml(c.title || '') + '</div>';
832  if (c.body) {
833    h += '<div style="font-size:10.5px;opacity:0.6;margin-top:1px;line-height:1.3;'
834      + 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + _escNoticeHtml(c.body) + '</div>';
835  }
836  h += '</div>';
837  h += '</div>';
838  h += '</div>';
839  return h;
840}
841
842function renderAnnouncements(containerId, list) {
843  var container = document.getElementById(containerId);
844  if (!container) return;
845
846  _ctAnnouncementContainerId = containerId;
847  _ctAnnouncementList = list;
848  if (_ctIsAdFree()) { _ctAds = []; _ctAdsSettled = false; } // Premium: nothing below may paint a banner (#1530)
849
850  var lang = _ctAnnouncementLang;
851  var dismissed = getDismissedNotices();
852  // Eligible = passes version/lang/tz (NOT dismiss): keeps A/B assignment stable across dismiss.
853  var adFree = _ctIsAdFree();
854  var eligible = list.filter(function(n) {
855    if (!n.active) return false;
856    if (n._ad && adFree) return false; // Premium: partner promos (/api/promos) are ads too (#1530)
857    // lang 필터: null/undefined → 모든 언어에 표시, 아니면 일치하는 언어만
858    if (n.lang && lang && n.lang !== lang) return false;
859    // tz 필터: null → 전체, 아니면 해당 타임존 사용자에게만 (예: 한국 거주자)
860    if (!_ctMatchesTz(n.tz)) return false;
861    return true;
862  });
863
864  // Collapse each campaign to one variant (random per page load), then drop dismissed.
865  var choices = _ctVariantChoices;
866  var picked = _ctSelectVariants(eligible, choices, dismissed, getDismissedCampaigns());
867  if (picked.updated) {
868    for (var k in picked.updated) choices[k] = picked.updated[k];
869  }
870  var active = picked.selected;
871
872  // In-house ad banners render above the notices (design §5, first placement = all).
873  var adHtml = '';
874  for (var ai = 0; ai < _ctAds.length; ai++) adHtml += _ctBuildAdBannerHtml(_ctAds[ai], lang);
875  // Side-by-side slots. auto-fit + minmax collapses to ONE column once a slot would be
876  // narrower than 320px, so a narrow window / phone stacks them with no media query. The
877  // banners keep their own inline width:100%, so they just fill whatever column they land in.
878  if (_ctAds.length > 1) {
879    // column-gap only: the vertical gap when slots collapse to a single stacked
880    // column is supplied by each banner's own margin:6px 0 (grid item margins do
881    // NOT collapse, so 6+6 = 12px), keeping the stacked gap equal to the 12px
882    // side-by-side gap instead of doubling to 24px. align-items:start prevents a
883    // banner whose title wraps to two lines from stretching its shorter sibling.
884    adHtml = '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));'
885      + 'column-gap:12px;row-gap:0;align-items:start">' + adHtml + '</div>';
886  }
887
888  if (active.length === 0 && !adHtml) {
889    container.innerHTML = '';
890    updateNoticeFooter();
891    return;
892  }
893
894  // 최신순 정렬
895  active.sort(function(a, b) { return (b.date || '').localeCompare(a.date || ''); });
896
897  var html = adHtml;
898  for (var i = 0; i < active.length; i++) {
899    var n = active[i];
900    var _styles = _ctGetNoticeStyles();
901    var s = _styles[n.type] || _styles.info;
902    var dateStr = formatNoticeDate(n.date);
903
904    var safeId = _escNoticeHtml(n.id);
905
906    // Badge promo (e.g. Product Hunt launch): render the official Product Hunt embed badge
907    // instead of the logo+copy+CTA layout. Keyed on campaign so the same promo row also
908    // degrades cleanly on older extension popups (which ignore campaign and show logo+title).
909    // The embed shows the live vote count; theme picks the light/dark variant and the `t`
910    // cache-buster keeps the count fresh. Must stay in sync with the extension popup
911    // (claude-tuner-extension/ui/notices.js) — same post_id + themed embed.
912    if (n.type === 'promo' && n.campaign === 'producthunt') {
913      var bRaw = _ctSafeUrl(n.url);
914      var bUrl = bRaw ? (bRaw + (bRaw.indexOf('?') >= 0 ? '&' : '?') + 'utm_source=dashboard') : '';
915      var bDark = document.documentElement.getAttribute('data-theme') === 'dark';
916      var PH_POST_ID = '1186963';
917      var bSrc = 'https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=' + PH_POST_ID + '&theme=' + (bDark ? 'dark' : 'light') + '&t=' + Date.now();
918      var bImg = '<img src="' + bSrc + '" width="250" height="54" alt="' + _escNoticeHtml(n.title || 'Product Hunt') + '" style="display:inline-block;max-width:100%;height:auto;vertical-align:middle" />';
919      html += '<div id="notice-' + safeId + '" style="margin-bottom:8px;text-align:center">';
920      html += bUrl
921        ? '<a href="' + _escNoticeHtml(bUrl) + '" target="_blank" rel="noopener" data-track-click="' + safeId + '">' + bImg + '</a>'
922        : bImg;
923      html += '</div>';
924      continue;
925    }
926
927    // Promo banner: logo + copy + CTA button (reusable for any partner project).
928    // Ads are non-dismissible by design — no close button.
929    if (n.type === 'promo') {
930      var pRaw = _ctSafeUrl(n.url);
931      var pUrl = pRaw ? (pRaw + (pRaw.indexOf('?') >= 0 ? '&' : '?') + 'utm_source=dashboard') : '';
932      var pImg = _ctSafeUrl(n.image_url);
933      var ctaLabel = _ctAnnouncementLang === 'en' ? 'Open →' : '바로가기 →';
934      html += '<div id="notice-' + safeId + '" style="background:' + s.bg + ';border:1px solid ' + s.border + ';border-radius:10px;padding:12px 14px;margin-bottom:8px;position:relative">';
935      html += '<div style="display:flex;align-items:center;gap:12px">';
936      if (pImg) {
937        html += '<img src="' + _escNoticeHtml(pImg) + '" alt="" style="width:40px;height:40px;border-radius:9px;flex-shrink:0;object-fit:cover" />';
938      } else {
939        html += '<span style="font-size:24px;flex-shrink:0">' + s.icon + '</span>';
940      }
941      html += '<div style="flex:1;min-width:0;padding-right:14px">';
942      html += '<div style="font-size:13px;font-weight:700;color:' + s.color + ';line-height:18px">' + _escNoticeHtml(n.title || '') + '</div>';
943      if (n.body) html += '<div style="font-size:12px;color:var(--gray-500,#4B5563);margin-top:2px;line-height:17px">' + sanitizeNoticeHtml(n.body) + '</div>';
944      html += '</div>';
945      if (pUrl) {
946        html += '<a href="' + _escNoticeHtml(pUrl) + '" target="_blank" rel="noopener" data-track-click="' + safeId + '" style="flex-shrink:0;align-self:center;background:' + s.color + ';color:#fff;font-size:12px;font-weight:600;padding:7px 12px;border-radius:8px;text-decoration:none;white-space:nowrap">' + ctaLabel + '</a>';
947      }
948      html += '</div></div>';
949      continue;
950    }
951
952    html += '<div id="notice-' + safeId + '" style="background:' + s.bg + ';border:1px solid ' + s.border + ';border-radius:10px;padding:12px 16px;margin-bottom:8px;position:relative">';
953    html += '<div style="display:flex;align-items:flex-start;gap:8px">';
954    html += '<span style="font-size:14px;flex-shrink:0;line-height:20px">' + s.icon + '</span>';
955    html += '<div style="flex:1;min-width:0">';
956    var nSafeUrl = _ctSafeUrl(n.url);
957    if (nSafeUrl) {
958      var utmUrl = nSafeUrl + (nSafeUrl.indexOf('?') >= 0 ? '&' : '?') + 'utm_source=dashboard';
959      html += '<a href="' + _escNoticeHtml(utmUrl) + '" target="_blank" rel="noopener" data-track-click="' + safeId + '" style="font-size:13px;font-weight:600;color:' + s.color + ';line-height:20px;text-decoration:none">
959' + _escNoticeHtml(n.title || '') + ' <span style="font-size:11px;opacity:0.6">→</span></a>';
960    } else {
961      html += '<div style="font-size:13px;font-weight:600;color:' + s.color + ';line-height:20px">' + _escNoticeHtml(n.title || '') + '</div>';
962      if (n.body) {
963        html += '<div style="font-size:12px;color:var(--gray-500,#4B5563);margin-top:4px;line-height:18px">' + sanitizeNoticeHtml(n.body) + '</div>';
964      }
965    }
966    html += '</div>';
967    html += '<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">';
968    if (dateStr) html += '<span style="font-size:11px;color:#9CA3AF">' + dateStr + '</span>';
969    html += '<button data-dismiss="' + safeId + '" style="background:none;border:none;cursor:pointer;font-size:16px;color:#9CA3AF;padding:0;line-height:1" title="' + (_ctAnnouncementLang === 'en' ? 'Close' : '\uB2EB\uAE30') + '">\u00D7</button>';
970    html += '</div>';
971    html += '</div></div>';
972  }
973
974  container.innerHTML = html;
975  // Wire ad banners: count the serve (frequency cap, serving-side) + arm the
976  // viewability-gated impression counter (Phase 2), and open the URL on click.
977  container.querySelectorAll('.ct-ad-banner').forEach(function (el, i) {
978    var ad = _ctAds[i];
979    if (!ad) return;
980    _ctNoteAdServed(ad.campaign.campaign_id, ad.placement);
981    _ctTrackAdViewability(el, ad);
982    var url = el.getAttribute('data-ad-url');
983    if (url) el.addEventListener('click', function (e) {
984      // Label chip is an advertiser-inquiry link (its own target=_blank nav) — not an ad click.
985      if (e.target.closest && e.target.closest('.ct-ad-label')) return;
986      _ctTrackAdClick(ad, e);
987      window.open(url + (url.indexOf('?') >= 0 ? '&' : '?') + 'utm_source=dashboard', '_blank');
988    });
989  });
990  container.querySelectorAll('button[data-dismiss]').forEach(function(btn) {
991    btn.addEventListener('click', function() { dismissNotice(btn.getAttribute('data-dismiss')); });
992  });
993  // Click tracking (link follows default navigation; beacon is fire-and-forget).
994  // auxclick catches middle-click "open in new tab".
995  container.querySelectorAll('[data-track-click]').forEach(function(el) {
996    var onClick = function() { _ctTrackEvent(el.getAttribute('data-track-click'), 'click'); };
997    el.addEventListener('click', onClick);
998    el.addEventListener('auxclick', function(e) { if (e.button === 1) onClick(); });
999  });
1000  // Impression tracking for the variants actually shown (deduped per session)
1001  for (var j = 0; j < active.length; j++) _ctTrackEvent(active[j].id, 'impression');
1002  updateNoticeFooter();
1003}
1004
1005function loadAnnouncements(containerId, lang) {
1006  _ctAnnouncementLang = lang || null;
1007  var cacheBuster = Math.floor(Date.now() / 3600000);
1008  // 공지(/api/announcements)와 광고(/api/promos)는 별도 엔드포인트 → 둘 다 받아 병합 렌더.
1009  // (광고를 공지 피드에 합치면 사이드바·랜딩 등 다른 소비자에 새므로 분리 유지)
1010  // Absolute URLs (the static CDN copy of announcements) are fetched as-is; relative
1011  // paths (e.g. /api/promos, still Worker-served) get the API base + cache-buster.
1012  function _fetchJson(path) {
1013    var url = /^https?:/.test(path) ? path : (_CT_API_BASE + path + '?t=' + cacheBuster);
1014    return fetch(url)
1015      .then(function(r) { return r.json(); })
1016      .then(function(list) { return Array.isArray(list) ? list : []; })
1017      .catch(function() { return []; });
1018  }
1019  // Announcements are served straight from the CDN (cdn.claudetuner.com) — no Worker
1020  // invocation. Promos stay on the Worker route (own table, low volume).
1021  Promise.all([_fetchJson('https://cdn.claudetuner.com/announcements.json'), _fetchJson('/api/promos')])
1022    .then(function(res) {
1023      // /api/promos is the partner-ad feed (worker: "광고 배너") — tag its rows so the Premium gate can
1024      // drop them at render time while ordinary notices (announcements.json) stay (Codex 1R #5).
1025      res[1].forEach(function(n) { if (n && typeof n === 'object') n._ad = true; });
1026      var list = res[0].concat(res[1]);
1027      list.forEach(function(n) { n.active = true; });
1028      renderAnnouncements(containerId, list);
1029      // In-house ads (design §3.2) — independent of announcements; fetched by the
1030      // synced ad core and re-rendered into the same container above the notices.
1031      // Skipped entirely for Premium (_ctRequestAds → _ctIsAdFree).
1032      _ctRequestAds(containerId);
1033      setTimeout(_ctResolvePlanFallback, _CT_PLAN_FALLBACK_MS);
1034    })
1035    .catch(function() {});
1036}
1037
1038// Re-render with current theme colors (call on theme change)
1039function refreshAnnouncementTheme() {
1040  if (_ctAnnouncementContainerId && _ctAnnouncementList.length) {
1041    renderAnnouncements(_ctAnnouncementContainerId, _ctAnnouncementList);
1042  }
1043}

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.