@nooticr/mcp 1.26.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2856 @@
1
+ /**
2
+ * MCP Apps HTML UI template for Nooticr tools.
3
+ *
4
+ * Features:
5
+ * - Content renders when a tool result arrives (no loading screen)
6
+ * - Idle/resource-preview state shows a branded ready view
7
+ * - Smooth fade-in transitions when data arrives
8
+ * - Expandable analysis sections (click to expand/collapse)
9
+ * - Click-to-copy on hashtags, hooks, quotes
10
+ * - Hover micro-interactions on cards
11
+ * - Dark/light theme auto-detection
12
+ * - Responsive layout
13
+ */
14
+ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
15
+ <html lang="en">
16
+ <head>
17
+ <meta charset="UTF-8"/>
18
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
19
+ <title>Nooticr</title>
20
+ <style>
21
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--leading-snug:1.375;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--shadow-sm:0 1px 3px 0 #0000001a, 0 1px 2px -1px #0000001a;--shadow-md:0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a;--shadow-lg:0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html{overflow:hidden auto}[hidden]{display:none!important}a{color:inherit;text-decoration:none}body{color:var(--fg);background:0 0;max-width:100vw;padding:10px;font-family:system-ui,-apple-system,Segoe UI,sans-serif;line-height:1.5;overflow-x:hidden}}@layer components{.load-bar{background:var(--tag);border-radius:3.40282e38px;height:3px;margin-bottom:14px;position:relative;overflow:hidden}.load-bar:after{content:"";background:var(--brand);border-radius:3.40282e38px;width:42%;animation:1.35s cubic-bezier(.45,0,.55,1) infinite sweep;position:absolute;top:0;bottom:0}@keyframes sweep{0%{left:-45%}50%{left:58%}to{left:-45%}}.load-head{align-items:center;gap:calc(var(--spacing) * 2);color:var(--muted);margin-bottom:11px;font-size:12.5px;font-weight:600;display:flex}.load-head .dot{background:var(--brand);border-radius:50%;flex-shrink:0;width:7px;height:7px;animation:1.2s ease-in-out infinite pulse}@keyframes pulse{0%,to{opacity:.35}50%{opacity:1}}.sk{border-radius:var(--radius-md);background:var(--tag);animation:1.5s ease-in-out infinite skpulse;position:relative;overflow:hidden}@keyframes skpulse{0%,to{opacity:1}50%{opacity:.55}}.sk-media{aspect-ratio:9/16;width:100%;max-height:var(--stage-max,420px);border-radius:0}.sk-media.h{aspect-ratio:16/9}.sk-line{height:11px;margin-top:8px}.sk-line.w40{width:40%}.sk-line.w60{width:60%}.sk-line.w80{width:80%}.sk-pill{border-radius:999px;width:76px;height:22px;margin:0 5px 5px 0;display:inline-block}.sk-card{border-radius:var(--radius-xl);border:1px solid var(--border);background:var(--card);flex-shrink:0;width:min(340px,86vw);min-width:min(340px,86vw);overflow:hidden}.sk-body{padding:13px 14px}.sk-row{align-items:center;gap:calc(var(--spacing) * 3);border-bottom:1px solid var(--border);padding:11px 0;display:flex}.sk-avatar{border-radius:10px;flex-shrink:0;width:44px;height:44px}.sk-strip{gap:calc(var(--spacing) * 3);padding:2px 0 8px;display:flex;overflow:hidden}@media (prefers-reduced-motion:reduce){.load-bar:after,.sk,.load-head .dot{animation:none}.load-bar:after{opacity:.5;width:100%;left:0}}.fade-in{animation:.4s ease-out fadeIn}@keyframes fadeIn{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.stagger>*{opacity:0;animation:.4s ease-out forwards fadeIn}.stagger>:first-child{animation-delay:50ms}.stagger>:nth-child(2){animation-delay:.1s}.stagger>:nth-child(3){animation-delay:.15s}.stagger>:nth-child(4){animation-delay:.2s}.stagger>:nth-child(5){animation-delay:.25s}.stagger>:nth-child(6){animation-delay:.3s}.stagger>:nth-child(7){animation-delay:.35s}.stagger>:nth-child(8){animation-delay:.4s}.gallery-wrap{max-width:100%;position:relative;overflow:hidden}.gallery{gap:calc(var(--spacing) * 3);scroll-snap-type:x mandatory;scroll-behavior:smooth;-webkit-overflow-scrolling:touch;scrollbar-width:none;width:100%;max-width:100%;padding:2px 0 8px;display:flex;overflow-x:auto}.gallery::-webkit-scrollbar{display:none}.gallery-dots{justify-content:center;gap:calc(var(--spacing) * 1.5);flex-wrap:wrap;margin-top:10px;display:flex}.gallery-dots .dot{background:var(--border);cursor:pointer;width:8px;height:8px;transition:var(--transition);border:none;border-radius:50%;padding:0}.gallery-dots .dot.active{background:var(--accent);border-radius:999px;width:20px}.gallery-nav{z-index:2;cursor:pointer;background:var(--card);border:1px solid var(--border);width:32px;height:32px;color:var(--fg);transition:var(--transition);opacity:0;border-radius:50%;justify-content:center;align-items:center;font-size:14px;display:flex;position:absolute;top:50%;transform:translateY(-50%)}.gallery-wrap:hover .gallery-nav{opacity:1}.gallery-nav:hover{background:var(--accent);color:var(--card)}.gallery-nav.prev{left:2px}.gallery-nav.next{right:2px}.card{border-radius:var(--radius-xl);border:1px solid var(--border);background:var(--card);width:min(380px,100%);min-width:min(380px,100%);max-width:100%;transition:var(--transition);cursor:default;scroll-snap-align:center;flex-shrink:0;display:block}.card.card-media{--ar-w:9;--ar-h:16;width:min(100%, calc(var(--stage-max,520px) * var(--ar-w) / var(--ar-h)));background:0 0;border-color:#0000;min-width:0;margin:0 auto}.card.card-media.h{--ar-w:16;--ar-h:9}.card.card-media.sq{--ar-w:1;--ar-h:1}.card-media .card-foot{background:0 0;justify-content:flex-end;padding:8px 4px 0;display:flex}.card-media .btn-sm{margin-top:0}.card:hover{border-color:var(--muted)}.card-media:hover{box-shadow:var(--shadow-md);transform:translateY(-2px)}.card-wide{width:100%}.card img:not(.mp-slide){object-fit:cover;width:100%;height:260px;display:block}.card-wide img:not(.mp-slide){max-height:340px}.card video{object-fit:contain;background:#000;border:none;outline:none;width:100%;min-height:220px;max-height:520px;display:block}.card-wide video{max-height:520px}.card-body{padding:14px 16px}.badge{align-items:center;gap:var(--spacing);text-transform:capitalize;transition:var(--transition);border-radius:3.40282e38px;padding:3px 10px;font-size:11px;font-weight:700;display:inline-flex}.handle{color:var(--muted);margin-left:auto;font-size:12px}.tag{align-items:center;gap:var(--spacing);transition:var(--transition);cursor:default;border-radius:3.40282e38px;margin:2px;padding:4px 10px;font-size:12px;display:inline-flex}.tag.clickable{cursor:pointer;-webkit-user-select:none;user-select:none}.tag.clickable:hover{background:var(--tag)}.tag.copied{background:var(--green)!important;color:#fff!important}.title{margin:6px 0;font-size:14px;font-weight:600;line-height:1.35}.card-wide .title{font-size:16px;font-weight:700}.caption{color:var(--muted);margin:6px 0 0;font-size:13px;line-height:1.5}.stats{align-items:center;gap:calc(var(--spacing) * 1.5);flex-wrap:wrap;margin-top:8px;display:flex}.stat-pill{align-items:center;gap:var(--spacing);color:var(--fg);background:var(--tag);transition:var(--transition);border-radius:3.40282e38px;padding:5px 12px;font-size:12px;display:inline-flex}.stat-pill:hover{background:var(--border)}.btn{cursor:pointer;align-items:center;gap:calc(var(--spacing) * 1.5);border-radius:var(--radius-lg);color:#fff;background:var(--brand);transition:var(--transition);border:none;margin-top:12px;padding:8px 18px;font-size:13px;font-weight:600;text-decoration-line:none;display:inline-flex}.btn:hover{background:var(--brand-2)}.btn:active{opacity:.9}.btn-sm{margin-top:8px;padding:5px 12px;font-size:11px}.section{border-bottom:1px solid var(--border);margin-bottom:6px;overflow:hidden}.section-header{cursor:pointer;-webkit-user-select:none;user-select:none;transition:var(--transition);justify-content:space-between;align-items:center;padding:10px 2px;display:flex}.section-header:hover .section-label{color:var(--fg)}.section-label{color:var(--muted);text-transform:uppercase;letter-spacing:.06em;font-size:11px;font-weight:700}.section-chevron{color:var(--muted);font-size:12px;transition:transform .2s}.section-header.open .section-chevron{transform:rotate(180deg)}.section-content{max-height:0;padding:0 2px;transition:max-height .28s,padding .28s;overflow:hidden}.section-content.open{max-height:2400px;padding:4px 2px 14px}.section-text{color:var(--fg);font-size:13px;line-height:1.6}.ai-actions{gap:calc(var(--spacing) * 2);flex-wrap:wrap;margin-top:14px;display:flex}.ai-btn{cursor:pointer;align-items:center;gap:calc(var(--spacing) * 2);border:1px solid var(--border);background:var(--card);color:var(--fg);font:inherit;transition:var(--transition);border-radius:3.40282e38px;padding:8px 13px;font-size:13px;font-weight:600;display:inline-flex}.ai-btn:hover:not(:disabled){border-color:var(--brand);background:var(--brand-soft)}.ai-btn:disabled{opacity:.55;cursor:not-allowed}.ai-btn .spark{color:var(--brand);flex-shrink:0;display:inline-flex}.ai-btn .price{white-space:nowrap;color:var(--brand);background:var(--brand-soft);border-radius:3.40282e38px;align-items:center;padding:2px 7px;font-size:11px;font-weight:700;display:inline-flex}.ai-btn.busy .spark{animation:1.1s linear infinite spin}.ai-note{color:var(--muted);margin-top:8px;font-size:11.5px}.an-title{align-items:center;gap:calc(var(--spacing) * 2);margin-bottom:14px;font-size:16px;font-weight:700;display:flex}.verdict{gap:calc(var(--spacing) * 2.5);grid-template-columns:repeat(1,minmax(0,1fr));margin-bottom:12px;display:grid}@media (min-width:420px){.verdict{grid-template-columns:1fr 1fr}}.meter{border-radius:var(--radius-md);background:var(--tag);padding:9px 11px}.meter-top{justify-content:space-between;align-items:baseline;gap:calc(var(--spacing) * 2);letter-spacing:.05em;text-transform:uppercase;color:var(--muted);font-size:11px;font-weight:700;display:flex}.meter-top b{letter-spacing:0;font-size:14px}.meter-track{background:var(--border);border-radius:3.40282e38px;height:5px;margin-top:6px;overflow:hidden}.meter-track>i{border-radius:3.40282e38px;height:100%;display:block}.chiprow{gap:calc(var(--spacing) * 1.5);flex-wrap:wrap;display:flex}.mhead{z-index:3;background:var(--bg);border-bottom:1px solid var(--border);padding:2px 0 10px;position:sticky;top:0}.mhead-top{justify-content:space-between;align-items:flex-end;gap:calc(var(--spacing) * 2.5);flex-wrap:wrap;display:flex}.mention-term{color:var(--muted);margin-bottom:2px;font-size:13px}.mention-term b{color:var(--fg)}.mention-total{color:var(--fg);letter-spacing:-.01em;font-size:20px;font-weight:650}.mention-down{color:var(--muted);margin-top:8px;font-size:12px}.mention-window{color:var(--muted);border:1px solid var(--border);border-radius:3.40282e38px;margin-left:6px;padding:1px 8px;font-size:11px;display:inline-block}.mention-summary{color:var(--muted);max-width:60ch;margin-top:6px;font-size:13px;line-height:1.45}.msort{background:var(--tag);border-radius:3.40282e38px;flex:none;align-items:center;gap:2px;padding:3px;display:inline-flex}.msort-btn{color:var(--muted);cursor:pointer;transition:var(--transition);white-space:nowrap;background:0 0;border:0;border-radius:999px;padding:5px 14px;font-size:11.5px;font-weight:600}.msort-btn:hover{color:var(--fg)}.msort-btn.on{color:var(--card);background:var(--accent)}.mchips{gap:calc(var(--spacing) * 1.5);flex-wrap:wrap;margin-top:10px;display:flex}.mchip{cursor:pointer;align-items:center;gap:calc(var(--spacing) * 2);color:var(--muted);border:1px solid var(--border);transition:var(--transition);white-space:nowrap;background:0 0;border-radius:3.40282e38px;padding:5px 12px;font-size:12px;font-weight:600;display:inline-flex}.mchip:hover{color:var(--fg);border-color:var(--muted)}.mchip b{color:var(--fg);background:var(--tag);font-variant-numeric:tabular-nums;border-radius:3.40282e38px;padding:1px 7px;font-weight:700}.mchip.on{color:var(--card);background:var(--accent);border-color:#0000}.mchip.on b{color:inherit;background:0 0;padding:1px 0}.mchip.on .mchip-mark{color:inherit}.mchip.quiet{opacity:.5;cursor:default}.mchip.quiet:hover{color:var(--muted);border-color:var(--border)}.mchip-mark{color:var(--brand);display:inline-flex}.mchip-mark svg{fill:currentColor;width:13px;height:13px}.mgroups{gap:calc(var(--spacing) * 2.5);flex-direction:column;margin-top:12px;display:flex}.mgroup{border-radius:var(--radius-xl);border:1px solid var(--border);background:var(--card);padding:12px 14px 6px}.mgroup-head{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;margin-bottom:6px;display:flex}.mgroup-plat{align-items:center;gap:calc(var(--spacing) * 1.5);color:var(--brand);font-size:11px;font-weight:600;display:inline-flex}.mgroup-plat svg{fill:currentColor;width:12px;height:12px}.mgroup-about{text-transform:uppercase;letter-spacing:.04em;color:var(--muted);border:1px solid var(--border);border-radius:3.40282e38px;padding:1px 7px;font-size:10.5px}.mgroup-reach{color:var(--muted);margin-left:auto;font-size:11px}.mgroup-title{color:var(--fg);-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:13px;font-weight:600;line-height:1.35;display:-webkit-box;overflow:hidden}.mgroup-rest{color:var(--muted);cursor:pointer;transition:var(--transition);background:0 0;border:0;align-self:flex-start;margin-top:2px;padding:2px 0;font-size:11.5px;font-weight:600}.mgroup-rest:hover{color:var(--fg)}.mgroup-actions{align-items:center;gap:calc(var(--spacing) * 3);margin-top:5px;display:flex}.mgroup-link{color:var(--muted);font-size:11.5px;text-decoration:none}.mgroup-link:hover{color:var(--fg);text-decoration:underline}.mgroup-all{color:var(--muted);cursor:pointer;transition:var(--transition);background:0 0;border:0;padding:0;font-size:11.5px;font-weight:600}.mgroup-all:hover{color:var(--fg)}.mgroup-none{color:var(--muted);margin-top:8px;font-size:12px}.mgroup-media{margin-top:10px}.mentions{flex-direction:column;margin-top:8px;display:flex}.mention{cursor:pointer;align-items:flex-start;gap:calc(var(--spacing) * 3);transition:var(--transition);padding:11px 2px;display:flex}.mention+.mention{border-top:1px solid var(--border)}.mention:hover{background:var(--card-hover)}.mention.picked{background:var(--tag)}.mention-pick{width:15px;height:15px;accent-color:var(--accent);cursor:pointer;flex:none;margin-top:11px}.mention-av{background:var(--tag);border:1px solid var(--border);border-radius:3.40282e38px;flex:none;width:36px;height:36px;position:relative;overflow:visible}.mention-av-init{color:var(--muted);border-radius:3.40282e38px;justify-content:center;align-items:center;font-size:14px;font-weight:700;display:flex;position:absolute;inset:0}.mention-av-img{object-fit:cover;border-radius:3.40282e38px;width:100%;height:100%;position:absolute;inset:0}.mention-av-badge{background:var(--card);width:16px;height:16px;color:var(--brand);border:1px solid var(--border);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex;position:absolute;bottom:-3px;right:-3px}.mention-av-badge svg{fill:currentColor;width:9.5px;height:9.5px}.mention-body{gap:calc(var(--spacing) * .5);flex-direction:column;flex:1;min-width:0;display:flex}.mention-head{align-items:baseline;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.mention-who{color:var(--fg);font-size:13px;font-weight:650}.mention-when{color:var(--muted);font-size:11.5px}.mention-when:before{content:"ยท";color:var(--border);margin-right:7px}.mention-hits{color:var(--fg);background:var(--tag);border:1px solid var(--border);border-radius:3.40282e38px;margin-left:auto;padding:1px 7px;font-size:10.5px;font-weight:700}.mention-text{color:var(--fg);word-break:break-word;margin-top:4px;font-size:13.5px;line-height:1.5}.mention-text mark{color:inherit;background:#f43f5e26;border-radius:4px;padding:1px 3px;font-weight:600}.mention-note{border-left:2px solid var(--brand,#f43f5e);background:var(--tag);color:var(--fg);white-space:pre-wrap;word-break:break-word;border-radius:0 6px 6px 0;margin-top:6px;padding:6px 9px;font-size:12.5px;line-height:1.5;display:block}.mention-meta{align-items:center;gap:calc(var(--spacing) * 3.5);margin-top:6px;display:flex}.mention-stat{align-items:center;gap:var(--spacing);color:var(--muted);font-size:11px;display:inline-flex}.mention-stat svg{fill:none;stroke:currentColor;stroke-width:1.8px;stroke-linecap:round;stroke-linejoin:round;width:12px;height:12px}.chip{text-transform:capitalize;letter-spacing:.01em;border:1px solid var(--border);color:var(--muted);border-radius:3.40282e38px;padding:1px 7px;font-size:10px;font-weight:600}.chip-positive{color:#15803d;background:#15803d14;border-color:#15803d55}.chip-negative{color:#b91c1c;background:#b91c1c14;border-color:#b91c1c55}.chip-mixed{color:#a16207;background:#a1620714;border-color:#a1620755}.chip-neutral{color:var(--muted)}.chip-cat{background:var(--tag)}.mention-more{cursor:pointer;border-radius:var(--radius-xl);width:100%;color:var(--fg);background:var(--tag);border:1px solid var(--border);transition:var(--transition);margin-top:12px;padding:10px;font-size:13px;font-weight:600;display:block}.mention-more:hover:not(:disabled){border-color:var(--muted)}.mention-more:disabled{opacity:.6;cursor:default}.sm-thumb{border-radius:var(--radius-lg);object-fit:cover;background:var(--tag);border:1px solid var(--border);flex:none;width:56px;height:56px}.tag.copyable{cursor:pointer;border:1px solid var(--border);font:inherit;align-items:center;gap:5px;font-size:12px;display:inline-flex}.tag.copyable:hover{border-color:var(--accent)}.tag.warn{background:var(--red)}@supports (color:color-mix(in lab, red, red)){.tag.warn{background:color-mix(in srgb, var(--red) 12%, transparent)}}.tag.warn{color:var(--red);border:1px solid var(--red)}@supports (color:color-mix(in lab, red, red)){.tag.warn{border:1px solid color-mix(in srgb, var(--red) 35%, transparent)}}.lede-box{background:var(--tag);border-radius:var(--radius-sm);margin-bottom:12px;padding:11px 13px;font-size:13.5px;line-height:1.6}.steps-list{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.step-row{align-items:flex-start;gap:calc(var(--spacing) * 2.5);grid-template-columns:78px 1fr;display:grid}.step-k{letter-spacing:.05em;text-transform:uppercase;color:var(--muted);padding-top:2px;font-size:10.5px;font-weight:700}.step-v{font-size:13px;line-height:1.5}.an-list{margin:0;padding-left:18px;font-size:13px;line-height:1.55}.an-list li{margin:4px 0}.clamp6{-webkit-line-clamp:6;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.hook-bar-track{align-items:center;gap:calc(var(--spacing) * 2.5);margin-top:6px;display:flex}.hook-bar{background:var(--tag);border-radius:3.40282e38px;flex:1;height:10px;overflow:hidden}.hook-bar-fill{border-radius:3.40282e38px;height:100%;transition:width .8s cubic-bezier(.4,0,.2,1)}.hook-score{text-align:right;min-width:40px;font-size:15px;font-weight:800}.quote-box{border-radius:var(--radius-md);background:var(--tag);border-left:3px solid var(--blue);margin:6px 0;padding:10px 14px;font-size:13px;font-weight:600;line-height:1.45;position:relative}.quote-box .copy-btn{cursor:pointer;border-radius:var(--radius-md);background:var(--card);border:1px solid var(--border);color:var(--muted);transition:var(--transition);opacity:0;padding:3px 8px;font-size:10px;position:absolute;top:8px;right:8px}.quote-box:hover .copy-btn{opacity:1}.copy-btn:hover{background:var(--accent);color:var(--card)}.theme-chip{align-items:center;gap:calc(var(--spacing) * 1.5);background:var(--tag);border:1px solid var(--border);color:var(--fg);border-radius:3.40282e38px;margin:0 4px 5px 0;padding:4px 10px;font-size:12px;display:inline-flex}.theme-chip b{color:var(--brand,var(--blue));font-variant-numeric:tabular-nums}.sec-label{letter-spacing:.05em;text-transform:uppercase;color:var(--muted);margin-bottom:7px;font-size:11px;font-weight:700}.cbadge{white-space:nowrap;border-radius:4px;padding:1px 5px;font-size:10px;font-weight:700}.cbadge.pin{color:#b45309;background:#fef3c7}.cbadge.liked{color:#be185d;background:#fce7f3}.cmeta{align-items:center;gap:calc(var(--spacing) * 2);color:var(--muted);flex-wrap:wrap;margin-top:5px;font-size:11px;display:flex}.ctext{color:var(--fg);font-size:13px;line-height:1.45}.ctext.clamp{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.cmore{color:var(--blue);cursor:pointer;background:0 0;border:none;padding:3px 0 0;font-family:inherit;font-size:11.5px;font-weight:600}.comment-row{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-md);border-bottom:1px solid var(--border);transition:var(--transition);padding:10px 0;display:flex}.comment-row:last-child{border-bottom:none}.comment-row:hover{background:var(--tag)}.creator-card{border-radius:var(--radius-xl);border:1px solid var(--border);background:var(--card);width:min(240px,80vw);min-width:min(240px,80vw);transition:var(--transition);scroll-snap-align:start;flex-shrink:0;padding:16px;display:block}.creator-card:hover{border-color:var(--muted)}.avatar{object-fit:cover;border:2px solid var(--border);border-radius:3.40282e38px;width:52px;height:52px}.avatar-placeholder{border-radius:3.40282e38px;justify-content:center;align-items:center;width:52px;height:52px;font-size:22px;display:flex}.verified{color:#fff;border-radius:3.40282e38px;justify-content:center;align-items:center;width:18px;height:18px;margin-left:4px;font-size:10px;display:inline-flex}.sound-card{border-radius:var(--radius-xl);border:1px solid var(--border);background:var(--card);width:min(300px,88vw);min-width:min(300px,88vw);transition:var(--transition);scroll-snap-align:start;flex-shrink:0;padding:14px;display:block}.sound-card:hover{border-color:var(--muted)}.sound-cover{border-radius:var(--radius-md);object-fit:cover;width:68px;height:68px}.mp{--ar-w:9;--ar-h:16;width:min(100%, calc(var(--stage-max,520px) * var(--ar-w) / var(--ar-h)));background:#000;border:2px solid #0000;border-radius:18px;margin-inline:auto;position:relative;overflow:hidden}.mp.playing{border-color:var(--brand)}.card-media>.mp{width:100%}.mp.h{--ar-w:16;--ar-h:9}.mp.sq{--ar-w:1;--ar-h:1}.mp-stage{width:100%;aspect-ratio:var(--ar-w) / var(--ar-h);max-height:var(--stage-max,520px);background:#000;position:relative;overflow:hidden}.mp .mp-stage>video{object-fit:contain;background:#000;width:100%;height:100%;max-height:none;display:block}.mp .mp-slide{object-fit:contain;opacity:0;background:#000;width:100%;height:100%;max-height:none;transition:opacity .45s;position:absolute;inset:0;transform:none}.mp-slide.active{opacity:1}.mp-ov{pointer-events:none;z-index:2;position:absolute;inset:0}.mp-ov:after{content:"";pointer-events:none;background:linear-gradient(#0000,#00000047 38%,#000000b8);height:46%;position:absolute;bottom:0;left:0;right:0}.mp-ov:before{content:"";pointer-events:none;background:linear-gradient(#00000073,#0000);height:16%;position:absolute;top:0;left:0;right:0}.mp-badge,.mp-chip,.mp-rail,.mp-meta,.mp-tap,.mp-nav{z-index:1}.mp-ov>*{pointer-events:auto}.mp-badge{text-transform:capitalize;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:#fff;background:#0000008c;border-radius:3.40282e38px;align-items:center;gap:5px;padding:4px 9px;font-size:11px;font-weight:700;display:inline-flex;position:absolute;top:10px;left:10px}.mp-pick{z-index:3;cursor:pointer;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:#0000;width:26px;height:26px;transition:var(--transition);background:#0000006b;border:2px solid #ffffffd9;border-radius:3.40282e38px;justify-content:center;align-items:center;padding:0;display:flex;position:absolute;top:9px;right:9px}.mp-pick:hover{background:#000000a8;transform:scale(1.08)}.mp-open{z-index:3;cursor:pointer;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:#fff;width:26px;height:26px;transition:var(--transition);background:#0000006b;border:2px solid #ffffffd9;border-radius:3.40282e38px;justify-content:center;align-items:center;padding:0;text-decoration-line:none;display:flex;position:absolute;top:9px;right:9px}.mp-open:hover{background:#000000a8;transform:scale(1.08)}.mp-open.with-pick{right:43px}.mp-pick[aria-pressed=true]{background:var(--brand,#ff4d23);color:#fff;border-color:#fff}.mp.picked{outline:2px solid var(--brand,#ff4d23);outline-offset:-2px}.mp-pick~.mp-chip,.mp[data-mp=slides] .mp-chip{top:42px}.pickbar{z-index:20;align-items:center;gap:calc(var(--spacing) * 2.5);background:var(--card);border:1px solid var(--border);box-shadow:var(--shadow-lg);border-radius:3.40282e38px;margin:12px 0 0;padding:10px 12px;animation:.2s ease-out fadeIn;display:flex;position:sticky;bottom:0}.pickbar .n{white-space:nowrap;font-size:13px;font-weight:700}.pickbar .sp{flex:1}.pickbar button{cursor:pointer;white-space:nowrap;border:1px solid var(--border);background:var(--card);color:var(--fg);font:inherit;transition:var(--transition);border-radius:3.40282e38px;padding:6px 13px;font-size:12.5px;font-weight:600}.pickbar button.primary{background:var(--brand,#ff4d23);color:#fff;border-color:#0000}.pickbar button:hover{filter:brightness(1.08)}.pickbar button:disabled{opacity:.5;cursor:not-allowed}.pickbar .hint{color:var(--muted);font-size:11.5px}.mp-chip{-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:#fff;font-variant-numeric:tabular-nums;background:#0000008c;border-radius:3.40282e38px;padding:3px 9px;font-size:11px;font-weight:700;position:absolute;top:10px;right:10px}.mp-rail{align-items:center;gap:calc(var(--spacing) * 3.5);flex-direction:column;display:flex;position:absolute;bottom:76px;right:8px}.mp-act{cursor:default;background:0 0;border:none;flex-direction:column;align-items:center;gap:3px;padding:0;display:flex}.mp-act .ic{-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:#fff;width:38px;height:38px;transition:var(--transition);background:#0006;border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.mp-act:hover .ic{background:#0000009e;transform:scale(1.06)}.mp-act .n{color:#fff;text-shadow:0 1px 3px #000c;font-variant-numeric:tabular-nums;font-size:11px;font-weight:700}.mp-meta{color:#fff;text-shadow:0 1px 3px #000000d9;position:absolute;bottom:60px;left:12px;right:64px}.mp-handle{margin-bottom:3px;font-size:13px;font-weight:800}.mp-cap{-webkit-line-clamp:2;opacity:.95;-webkit-box-orient:vertical;font-size:12px;line-height:1.4;display:-webkit-box;overflow:hidden}.mp-song{white-space:nowrap;text-overflow:ellipsis;align-items:center;gap:5px;margin-top:6px;font-size:11px;font-weight:600;display:flex;overflow:hidden}.mp-song .note{flex:none;display:inline-flex}.mp.playing .mp-song .note{animation:3s linear infinite spin}.mp-song-t{white-space:nowrap;text-overflow:ellipsis;overflow:hidden}@keyframes spin{to{transform:rotate(360deg)}}.mp-tap{cursor:pointer;background:0 0;border:none;justify-content:center;align-items:center;display:flex;position:absolute;inset:0}.mp-tap span{-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);color:#fff;width:64px;height:64px;transition:var(--transition);opacity:1;background:#00000073;border-radius:3.40282e38px;justify-content:center;align-items:center;font-size:26px;display:flex}.mp.playing .mp-tap span{opacity:0}.mp.playing:hover .mp-tap span{opacity:.65}.mp-nav{cursor:pointer;color:#fff;opacity:0;width:34px;height:34px;transition:var(--transition);background:#0000008c;border:none;border-radius:3.40282e38px;justify-content:center;align-items:center;font-size:18px;display:flex;position:absolute;top:50%;transform:translateY(-50%)}.mp:hover .mp-nav,.mp:focus-within .mp-nav{opacity:1}.mp-nav.prev{left:8px}.mp-nav.next{right:8px}.mp-nav:hover{background:#000c}.mp-ctl{z-index:3;background:linear-gradient(#0000,#000000d9);align-items:center;gap:9px;padding:9px 11px;display:flex;position:absolute;bottom:0;left:0;right:0}.mp-btn{cursor:pointer;color:#111;width:30px;height:30px;transition:var(--transition);background:#fffffff2;border:none;border-radius:3.40282e38px;flex:none;justify-content:center;align-items:center;font-size:12px;display:flex}.mp-btn:hover{transform:scale(1.09)}.mp-time{color:#fff;font-variant-numeric:tabular-nums;text-shadow:0 1px 2px #000c;flex-shrink:0;font-size:10px;font-weight:700}.mp-seek{cursor:pointer;flex:1;align-items:center;min-width:0;height:14px;display:flex;position:relative}.mp-segs{gap:calc(var(--spacing) * .5);border-radius:3.40282e38px;width:100%;height:3px;transition:height .15s;display:flex;overflow:hidden}.mp-seek:hover .mp-segs{height:5px}.mp-seg{background:#ffffff52;border-radius:3.40282e38px;flex:1;height:100%;overflow:hidden}.mp-seg>i{background:#fff;border-radius:3.40282e38px;width:0;height:100%;display:block}.mp-seg.done>i{width:100%}.mp-icon{color:#fff;cursor:pointer;opacity:.9;background:0 0;border:none;flex-shrink:0;padding:0;font-size:14px;line-height:1}.mp-icon:hover{opacity:1}.mp-spin{pointer-events:none;z-index:2;justify-content:center;align-items:center;display:flex;position:absolute;inset:0}.mp-spin>i{border:3px solid #ffffff40;border-top-color:#fff;border-radius:3.40282e38px;width:34px;height:34px;animation:.8s linear infinite spin}.mp-err{pointer-events:none;z-index:4;justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);text-align:center;color:#fff;background:#000000d1;flex-direction:column;padding:16px;font-size:12px;font-weight:600;display:flex;position:absolute;inset:0}.mp:focus-visible{outline-offset:-2px;outline:2px solid #fff}.mp-btn:focus-visible,.mp-icon:focus-visible,.mp-nav:focus-visible,.mp-tap:focus-visible,.au-play:focus-visible,.gallery-nav:focus-visible,.dot:focus-visible{outline-offset:2px;outline:2px solid #fff}@media (prefers-reduced-motion:reduce){.mp-song .note{animation:none}.mp-slide{transition:none}.mp-spin>i{animation-duration:2s}*{scroll-behavior:auto!important}}.au{align-items:center;gap:calc(var(--spacing) * 2.5);margin-top:10px;display:flex}.au-play{cursor:pointer;color:#fff;background:var(--brand);width:34px;height:34px;transition:var(--transition);border:none;border-radius:3.40282e38px;flex:none;justify-content:center;align-items:center;display:flex}.au-body{flex:1;min-width:0}.au-seek{cursor:pointer;background:var(--fg);border-radius:3.40282e38px;width:100%;height:5px;position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.au-seek{background:color-mix(in srgb, var(--fg) 22%, transparent)}}.au-seek>i{background:var(--accent);border-radius:3.40282e38px;width:0;height:100%;display:block}.au-time{color:var(--muted);font-variant-numeric:tabular-nums;justify-content:space-between;margin-top:3px;font-size:10px;display:flex}.au-missing{align-items:center;gap:calc(var(--spacing) * 1.5);color:var(--muted);margin-top:8px;font-size:11px;display:flex}.credits-card{border-radius:var(--radius-2xl);border:1px solid var(--border);background:var(--card);max-width:380px;margin:8px 0;padding:24px}.pack{cursor:pointer;border-radius:var(--radius-xl);text-align:center;border:1px solid var(--border);transition:var(--transition);padding:16px}.pack:hover{border-color:var(--muted)}.pack-featured{border-radius:var(--radius-xl);text-align:center;border:2px solid var(--accent);padding:16px}.tier-badge{color:#1e40af;background:#dbeafe;border-radius:3.40282e38px;align-items:center;padding:4px 12px;font-size:12px;font-weight:700;display:inline-flex}.free-tag{align-items:center;gap:var(--spacing);color:#065f46;transition:var(--transition);background:#d1fae5;border-radius:3.40282e38px;margin:2px;padding:4px 10px;font-size:11px;font-weight:600;display:inline-flex}.json-block{border-radius:var(--radius-md);background:var(--tag);border:1px solid var(--border);white-space:pre-wrap;word-break:break-all;max-height:320px;margin-top:10px;padding:14px;font-family:SF Mono,Monaco,monospace;font-size:12px;overflow-y:auto}.empty-state{text-align:center;color:var(--muted);padding:48px 24px}.empty-state .icon{margin-bottom:12px;font-size:48px}.empty-state .text{font-size:14px}}@layer utilities{.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.bottom-3{bottom:calc(var(--spacing) * 3)}.left-3{left:calc(var(--spacing) * 3)}.isolate{isolation:isolate}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-0{margin:0}.mx-auto{margin-inline:auto}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.h-2{height:calc(var(--spacing) * 2)}.h-full{height:100%}.w-full{width:100%}.max-w-sm{max-width:var(--container-sm)}.shrink{flex-shrink:1}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-center{justify-content:center}.gap-1{gap:var(--spacing)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-line{border-color:var(--border)}.bg-black{background-color:var(--color-black)}.bg-soft{background-color:var(--tag)}.bg-surface{background-color:var(--card)}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.p-3{padding:calc(var(--spacing) * 3)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-1{padding-block:var(--spacing)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-10{padding-block:calc(var(--spacing) * 10)}.pl-5{padding-left:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.text-wrap{text-wrap:wrap}.text-ink{color:var(--fg)}.text-muted{color:var(--muted)}.text-white{color:var(--color-white)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.select-all{-webkit-user-select:all;user-select:all}}:root{color-scheme:light dark;--bg:#fafbfc;--fg:#111827;--muted:#5f6773;--border:#e5e7eb;--card:#fff;--card-hover:#f3f4f6;--tag:#f3f4f6;--accent:#14151a;--brand:#ff4d23;--brand-2:#ff8a3d;--brand-soft:#ff4d2321;--radius:10px;--radius-sm:6px;--transition:all .25s cubic-bezier(.4, 0, .2, 1);--shadow-md:0 4px 12px #00000014;--shadow-lg:0 8px 24px #0000001f;--green:#047857;--amber:#b45309;--red:#dc2626;--blue:#2563eb}@media (prefers-color-scheme:dark){:root{--bg:#0f172a;--fg:#f1f5f9;--muted:#a8b8cc;--border:#334155;--green:#34d399;--amber:#fbbf24;--red:#f87171;--blue:#60a5fa;--card:#1e293b;--card-hover:#273548;--tag:#1e293b;--accent:#e2e8f0;--brand:#ff5c33;--brand-2:#ff9351;--brand-soft:#ff5c3329;--shadow-md:0 4px 12px #0000004d;--shadow-lg:0 8px 24px #0006}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}
22
+ </style>
23
+ </head>
24
+ <body>
25
+ <div id="app"></div>
26
+ <script>
27
+ (function(){
28
+ var nextId=1;var pending=new Map();
29
+ var currentTool="";
30
+
31
+ // โ”€โ”€โ”€ Tool-specific idle view โ”€โ”€โ”€
32
+ // Read ?tool=<name> from the resource URI to show a tailored idle screen.
33
+ var TOOL_NAMES={
34
+ analyze_post:"Analyze Post",
35
+ discover_social_posts:"Discover Social Posts",
36
+ get_user_posts:"Get User Posts",
37
+ get_social_media:"Get Social Media",
38
+ analyze_creator_profile:"Analyze Creator Profile",
39
+ get_post_comments:"Get Post Comments",
40
+ search_creators:"Search Creators",
41
+ get_similar_creators:"Similar Creators",
42
+ discover_sounds:"Discover Sounds",
43
+ understand_social_post:"Understand Social Post",
44
+ check_nooticr_credits:"Check Credits",
45
+ buy_nooticr_credits:"Buy Credits",
46
+ compose_sequence:"Compose Sequence",
47
+ overlay_bake:"Overlay Bake",
48
+ spawn_variants:"Spawn Variants",
49
+ bake_job_status:"Bake Job Status",
50
+ enqueue_publish_job:"Enqueue Publish",
51
+ schedule_post:"Schedule Post"
52
+ };
53
+ var TOOL_DESCS={
54
+ analyze_post:"Frames from the post plus its transcript, for you to read yourself.",
55
+ discover_social_posts:"Trending posts from any platform for a given niche.",
56
+ get_user_posts:"Recent posts from a specific creator handle.",
57
+ get_social_media:"Get post data from any social media URL.",
58
+ analyze_creator_profile:"Full creator profile analysis โ€” engagement, audience, content style.",
59
+ get_post_comments:"Top comments on a post โ€” sentiment, themes, viral threads.",
60
+ search_creators:"Find creators in a niche by engagement, followers, and content.",
61
+ get_similar_creators:"Find creators similar to a given handle.",
62
+ discover_sounds:"Trending sounds and music on TikTok/Instagram.",
63
+ understand_social_post:"The same frames and transcript, for a description of what happens on screen.",
64
+ check_nooticr_credits:"View your Nooticr credit balance and usage.",
65
+ buy_nooticr_credits:"Purchase additional Nooticr credits.",
66
+ compose_sequence:"AI-powered content composition for social posts.",
67
+ overlay_bake:"Bake text/image overlays onto video or image.",
68
+ spawn_variants:"Generate multiple content variants from a single seed.",
69
+ bake_job_status:"Check the status of a rendering/baking job.",
70
+ enqueue_publish_job:"Queue a post for publishing to a connected platform.",
71
+ schedule_post:"Schedule a post for future publishing."
72
+ };
73
+ /* How many results a tool comes back with, and in what shape โ€” so the
74
+ skeleton matches the answer instead of being a generic grey box. */
75
+ var LOADING_SHAPE={
76
+ get_social_media: {kind:"post", n:1, label:"Fetching the post"},
77
+ get_post_transcript: {kind:"text", n:1, label:"Reading the caption track"},
78
+ analyze_post: {kind:"post", n:1, label:"Analysing the post"},
79
+ understand_social_post: {kind:"post", n:1, label:"Watching the video"},
80
+ analyze_creator_profile: {kind:"list", n:4, label:"Reading the profile"},
81
+ compare_posts: {kind:"strip", n:2, label:"Comparing posts"},
82
+ discover_social_posts: {kind:"strip", n:3, label:"Searching posts"},
83
+ get_user_posts: {kind:"strip", n:3, label:"Loading their posts"},
84
+ search_creators: {kind:"list", n:5, label:"Finding creators"},
85
+ get_similar_creators: {kind:"list", n:5, label:"Finding similar creators"},
86
+ discover_sounds: {kind:"list", n:4, label:"Finding trending sounds"},
87
+ discover_hashtags: {kind:"list", n:6, label:"Reading the trend board"},
88
+ get_post_comments: {kind:"list", n:6, label:"Loading comments"},
89
+ analyze_comments: {kind:"text", n:1, label:"Reading the comment section"},
90
+ check_nooticr_credits: {kind:"text", n:1, label:"Checking your balance"},
91
+ buy_nooticr_credits: {kind:"text", n:1, label:"Opening checkout"}
92
+ };
93
+
94
+ function skPostCard(){
95
+ return '<div class="sk-card"><div class="sk sk-media"></div><div class="sk-body">'
96
+ +'<span class="sk sk-pill" style="width:64px"></span>'
97
+ +'<div class="sk sk-line w80"></div><div class="sk sk-line w60"></div></div></div>';
98
+ }
99
+ function skRow(){
100
+ return '<div class="sk-row"><div class="sk sk-avatar"></div>'
101
+ +'<div style="flex:1;min-width:0"><div class="sk sk-line w40"></div>'
102
+ +'<div class="sk sk-line w80"></div></div></div>';
103
+ }
104
+ function skText(){
105
+ return '<div class="sk sk-line w60"></div><div class="sk sk-line"></div>'
106
+ +'<div class="sk sk-line"></div><div class="sk sk-line w80"></div>'
107
+ +'<div style="margin-top:14px"><span class="sk sk-pill"></span>'
108
+ +'<span class="sk sk-pill" style="width:58px"></span>'
109
+ +'<span class="sk sk-pill" style="width:92px"></span></div>';
110
+ }
111
+
112
+ /** Skeleton that mirrors the shape of the result the tool will return. */
113
+ // The tool-input notifications carry only the arguments, never the tool
114
+ // name, so when the host does not add one the arguments decide the shape.
115
+ // Guessing the skeleton wrong costs nothing - it is a placeholder - while
116
+ // always falling back to a bare text block made every load look the same.
117
+ function shapeFromArgs(a){
118
+ if(!a||typeof a!=="object")return null;
119
+ if(Array.isArray(a.urls))
120
+ return {kind:"strip",n:Math.min(5,Math.max(2,a.urls.length)),label:"Comparing posts"};
121
+ if(a.draft)return {kind:"text",n:1,label:"Scoring the draft"};
122
+ if(a.username)return {kind:"strip",n:3,label:"Loading their posts"};
123
+ if(a.country||a.days)return {kind:"list",n:6,label:"Reading the trend board"};
124
+ if(a.niche||a.keyword)return {kind:"strip",n:3,label:"Searching"};
125
+ if(a.url)return {kind:"post",n:1,label:"Working on the post"};
126
+ if(a.topic)return {kind:"text",n:1,label:"Writing"};
127
+ return null;
128
+ }
129
+
130
+ function renderCancelled(reason){
131
+ var app=document.getElementById("app");
132
+ if(!app||toolResultReceived)return;
133
+ app.innerHTML='<div class="empty-state fade-in"><div class="icon">x</div><div class="text">'
134
+ +esc(reason?("Cancelled: "+reason):"The tool call was cancelled")+"</div></div>";
135
+ setTimeout(reportSize,50);
136
+ }
137
+
138
+ // A ChatGPT-shaped host sends no tool-input notification, so the poll below
139
+ // is what drives the skeleton there - which means this runs over and over
140
+ // within one call: every 250ms while polling, plus once per set_globals,
141
+ // and that fires for theme, display mode and height changes as well as for
142
+ // results. Rewriting innerHTML on each of them restarted .fade-in (opacity
143
+ // 0, shifted 8px down), the load-bar sweep and every skeleton shine four
144
+ // times a second, and posted a size-changed at the same rate for the host
145
+ // to re-lay-out on. That is the blinking. The skeleton is a pure function
146
+ // of its shape, so paint it once and let the CSS animations run: repaint
147
+ // only when the shape changes, or when it is no longer on screen because a
148
+ // result replaced it - which is the second call in a reused view.
149
+ var loadingSig=null;
150
+ function renderLoading(tool,args){
151
+ var app=document.getElementById("app");
152
+ if(!app||toolResultReceived)return;
153
+ var shape=LOADING_SHAPE[tool]||shapeFromArgs(args)||{kind:"text",n:1,label:"Working"};
154
+ var sig=shape.kind+"|"+shape.n+"|"+shape.label;
155
+ if(sig===loadingSig&&app.querySelector(".load-bar"))return;
156
+ loadingSig=sig;
157
+ var inner="";
158
+ if(shape.kind==="post"){
159
+ inner='<div style="display:flex;justify-content:center">'+skPostCard()+"</div>";
160
+ }else if(shape.kind==="strip"){
161
+ var cards="";
162
+ for(var i=0;i<shape.n;i++)cards+=skPostCard();
163
+ inner='<div class="sk-strip">'+cards+"</div>";
164
+ }else if(shape.kind==="list"){
165
+ var rows="";
166
+ for(var j=0;j<shape.n;j++)rows+=skRow();
167
+ inner='<div class="card card-wide"><div class="card-body">'+rows+"</div></div>";
168
+ }else{
169
+ inner='<div class="card card-wide"><div class="card-body">'+skText()+"</div></div>";
170
+ }
171
+ app.innerHTML='<div class="fade-in"><div class="load-bar"></div>'
172
+ +'<div class="load-head"><span class="dot"></span><span>'
173
+ +esc(shape.label)+"โ€ฆ</span></div>"+inner+"</div>";
174
+ setTimeout(reportSize,50);
175
+ }
176
+
177
+ // The host serves this HTML from its own URL, so on ChatGPT there is no
178
+ // ui:// path to read a tool name out of - which is why its view said
179
+ // "Interactive View" and the generic placeholder while Claude, whose URL
180
+ // does carry it, named the tool. The server knows which view it is serving,
181
+ // so it substitutes it here.
182
+ var BAKED_TOOL="__NOOTICR_TOOL__";
183
+ function bakedTool(){
184
+ return BAKED_TOOL.indexOf("NOOTICR_TOOL")>=0?"":BAKED_TOOL;
185
+ }
186
+
187
+ function renderIdle(){
188
+ // Extract tool name from URI path segment (e.g. /ui://nooticr/analyze_post โ†’ analyze_post)
189
+ // or from ?tool= param if the host passes it.
190
+ var tool=bakedTool();
191
+ var params=new URLSearchParams(window.location.search);
192
+ if(!tool)tool=params.get("tool")||"";
193
+ if(!tool){
194
+ var path=window.location.pathname;
195
+ var idx=path.indexOf("ui://nooticr/");
196
+ if(idx>=0){var rest=path.substring(idx+12);var end=rest.length;for(var ci=0;ci<rest.length;ci++){var ch=rest.charCodeAt(ci);if(ch===63||ch===35||ch===47){end=ci;break;}}tool=rest.substring(0,end);}
197
+
198
+ }
199
+ if(!tool){
200
+ var hash=window.location.hash;
201
+ var idx2=hash.indexOf("ui://nooticr/");
202
+ if(idx2>=0){var rest2=hash.substring(idx2+12);var end2=rest2.length;for(var ci2=0;ci2<rest2.length;ci2++){var ch2=rest2.charCodeAt(ci2);if(ch2===63||ch2===35||ch2===47){end2=ci2;break;}}tool=rest2.substring(0,end2);}
203
+
204
+ }
205
+ var label=TOOL_NAMES[tool]||"Interactive View";
206
+ var desc=TOOL_DESCS[tool]||"Results will appear here as soon as a tool returns.";
207
+ var app=document.getElementById("app");
208
+ if(!app)return;
209
+ app.innerHTML='<div class="flex flex-col items-center justify-center text-center px-6 py-10" style="min-height:220px">'
210
+ +'<svg width="73" height="44" viewBox="0 0 334 200" fill="currentColor" class="text-ink"><path d="M167,25.764A100,100 0 1,1 167,174.236A100,100 0 0,0 186.29,150.537A69.5,69.5 0 1,0 186.29,49.463A100,100 0 0,0 167,25.764ZM298.427,73.934A69.5,69.5 0 0,0 215.393,33.037A48.5,48.5 0 1,0 298.427,73.934ZM232.5,66.5a18,18 0 1,1 36,0a18,18 0 1,1 -36,0ZM0,100a100,100 0 1,1 200,0a100,100 0 1,1 -200,0ZM30.5,100a69.5,69.5 0 1,0 139,0a69.5,69.5 0 1,0 -139,0ZM164.427,73.934A69.5,69.5 0 0,0 81.393,33.037A48.5,48.5 0 1,0 164.427,73.934ZM98.5,66.5a18,18 0 1,1 36,0a18,18 0 1,1 -36,0Z"/></svg>'
211
+ +'<div class="mt-4 text-lg font-bold text-ink">'+label+'</div>'
212
+ +'<div class="mt-2 text-sm leading-relaxed text-muted max-w-sm">'+desc+'</div>'
213
+ +'</div>';
214
+ }
215
+ renderIdle();
216
+
217
+ function send(method,params){
218
+ var id=nextId++;
219
+ return new Promise(function(resolve,reject){
220
+ pending.set(id,{resolve:resolve,reject:reject});
221
+ window.parent.postMessage({jsonrpc:"2.0",id:id,method:method,params:params},"*");
222
+ });
223
+ }
224
+
225
+ var toolResultReceived=false;
226
+
227
+ // ChatGPT hands the result over as a global rather than a message. Its docs
228
+ // call the postMessage bridge above the portable path and this the
229
+ // compatibility alias, but a host that only sets the global would leave the
230
+ // view sitting on its placeholder forever - so read it too. Claude never
231
+ // defines window.openai, so nothing here runs there.
232
+ var lastOutRef=null,lastOutSig="";
233
+ function readHostGlobals(){
234
+ var api=window.openai;
235
+ if(!api)return;
236
+ var out=api.toolOutput;
237
+ if(out){
238
+ // set_globals fires for theme, display mode and height changes as well
239
+ // as for results. Rendering on every one of them rebuilt every <video>
240
+ // and <audio> in the view each time, which is the flashing - and Chrome
241
+ // logged 982 "too many WebMediaPlayers already in existence"
242
+ // interventions as the discarded players piled up. Only a genuinely new
243
+ // result is worth a render.
244
+ if(out===lastOutRef)return;
245
+ var sig="";
246
+ try{sig=JSON.stringify(out);}catch(e){sig="";}
247
+ if(sig&&sig===lastOutSig){lastOutRef=out;return;}
248
+ lastOutRef=out;lastOutSig=sig;
249
+ toolResultReceived=true;
250
+ // The bridge passes {structuredContent}; the alias passes the structured
251
+ // content itself. Accept either rather than guessing.
252
+ render(out.structuredContent?out:{structuredContent:out});
253
+ setTimeout(reportSize,50);
254
+ }else if(!toolResultReceived&&!lastOutRef){
255
+ // A ChatGPT-shaped host mounts this view as part of a tool call, so
256
+ // "window.openai exists and has no output yet" *is* the loading state.
257
+ // Requiring toolInput first meant the host that sets it late - or never
258
+ // - kept the idle placeholder up for the whole call, which is the
259
+ // "Results will appear here" that showed while Claude and Cursor
260
+ // shimmered correctly. The give-up path below restores the placeholder
261
+ // if nothing ever arrives.
262
+ renderLoading(currentTool||bakedTool(),api.toolInput||{});
263
+ }
264
+ }
265
+ // window.openai is injected asynchronously โ€” OpenAI's own guidance is to
266
+ // "wait for window.openai to be available and retry once or twice if data
267
+ // arrives late". A single read at parse time therefore finds nothing and
268
+ // never looks again, which is a view sitting on its placeholder while the
269
+ // host has held the result the whole time.
270
+ //
271
+ // So poll, stop the moment anything lands, and give up rather than spin
272
+ // forever. Claude never defines the global and delivers over postMessage
273
+ // instead, so it stops at the first result and costs a few no-op ticks.
274
+ var pollStart=Date.now();
275
+ function pollHostGlobals(){
276
+ readHostGlobals();
277
+ if(lastOutRef||toolResultReceived)return;
278
+ var waited=Date.now()-pollStart;
279
+ // The budget was 40 ticks - ten seconds - and every tool here that fetches
280
+ // a feed or watches a video runs longer than that. So the shimmer was
281
+ // taken down mid-call and replaced by "Results will appear here", and
282
+ // because this poll is the only way a host that merely sets the global is
283
+ // ever read, the result that landed afterwards was never picked up at all.
284
+ // Wait as long as the server itself waits on a job (POLL_TIMEOUT_MS).
285
+ if(waited>=300000){
286
+ // Nothing ever came. A view left shimmering forever is worse than one
287
+ // that says plainly it is waiting, so put the placeholder back.
288
+ if(window.openai)renderIdle();
289
+ return;
290
+ }
291
+ // Quick while the global is still expected to land, slower once we are
292
+ // plainly just waiting on the tool.
293
+ setTimeout(pollHostGlobals,waited<3000?250:1000);
294
+ }
295
+
296
+ window.addEventListener("openai:set_globals",function(ev){
297
+ var g=ev&&ev.detail?ev.detail.globals:null;
298
+ if(g){
299
+ window.openai=window.openai||{};
300
+ if(g.toolOutput!==undefined)window.openai.toolOutput=g.toolOutput;
301
+ if(g.toolInput!==undefined)window.openai.toolInput=g.toolInput;
302
+ }
303
+ readHostGlobals();
304
+ },{passive:true});
305
+ pollHostGlobals();
306
+
307
+ window.addEventListener("message",function(ev){
308
+ var d=ev.data;if(!d||typeof d!=="object")return;
309
+ if(d.id&&pending.has(d.id)){
310
+ var p=pending.get(d.id);pending.delete(d.id);
311
+ if(d.error)p.reject(new Error(d.error.message||JSON.stringify(d.error)));
312
+ else p.resolve(d.result);
313
+ return;
314
+ }
315
+ if(d.method==="ui/notifications/tool-result"){
316
+ toolResultReceived=true;
317
+ render(d.params);
318
+ setTimeout(reportSize,50);
319
+ }
320
+ // tool-input is the one the spec requires and sends exactly once;
321
+ // tool-input-partial is optional streaming on top of it. Listening only
322
+ // for the partial meant a host that does not stream left the view sitting
323
+ // on its idle placeholder until the result landed, with no shimmer at all.
324
+ if(d.method==="ui/notifications/tool-input"
325
+ ||d.method==="ui/notifications/tool-input-partial"){
326
+ var pp=d.params||{};
327
+ // The payload is just {arguments}; a host may add the name, so use it
328
+ // when offered and shape the skeleton from the arguments when not.
329
+ var n=pp.name||pp.toolName||"";
330
+ if(n)currentTool=n;
331
+ // A view is reused across calls, and this flag was set on the first
332
+ // result and never cleared - so every later call skipped the shimmer
333
+ // and left the previous tool's cards on screen. Asking for Instagram
334
+ // posts and then TikTok showed the Instagram ones again. A new input
335
+ // notification means a new call, so the old result no longer stands.
336
+ toolResultReceived=false;
337
+ renderLoading(currentTool,pp.arguments);
338
+ }
339
+ if(d.method==="ui/notifications/tool-cancelled"){
340
+ renderCancelled(d.params&&d.params.reason);
341
+ }
342
+ });
343
+
344
+ // MCP Apps handshake.
345
+ // Claude's McpUiInitializeRequestSchema requires appInfo, appCapabilities
346
+ // and protocolVersion (legacy hosts read capabilities/clientInfo), so send
347
+ // both shapes. Critically, ui/notifications/initialized must be sent
348
+ // UNCONDITIONALLY โ€” Claude keeps the widget iframe reserved-but-hidden
349
+ // until it receives that notification, so a reply we don't recognize must
350
+ // never deadlock the handshake.
351
+ var initializedSent=false;
352
+ function sendInitialized(){
353
+ if(initializedSent)return;
354
+ initializedSent=true;
355
+ window.parent.postMessage({jsonrpc:"2.0",method:"ui/notifications/initialized",params:{}},"*");
356
+ }
357
+ send("ui/initialize",{
358
+ protocolVersion:"2026-01-26",
359
+ appInfo:{name:"nooticr-view",version:"2.0.0"},
360
+ appCapabilities:{availableDisplayModes:["inline"]},
361
+ capabilities:{},
362
+ clientInfo:{name:"nooticr-view",version:"2.0.0"}
363
+ }).then(function(){ bridgeAlive=true; sendInitialized(); }).catch(sendInitialized);
364
+ // Unconditional fallback: never wait on a reply shape we don't recognize.
365
+ setTimeout(sendInitialized, 500);
366
+
367
+ // Report content size so flexible hosts (Claude) size the iframe correctly.
368
+ // Params MUST be real numbers โ€” claude.ai throws on null/missing width.
369
+ function reportSize(){
370
+ try{
371
+ var h=document.documentElement.scrollHeight||document.body.scrollHeight||400;
372
+ var w=document.documentElement.scrollWidth||document.body.scrollWidth||400;
373
+ window.parent.postMessage({jsonrpc:"2.0",method:"ui/notifications/size-changed",params:{height:h,width:w}},"*");
374
+ }catch(e){}
375
+ }
376
+ window.addEventListener("resize",reportSize);
377
+ setTimeout(reportSize,600);
378
+
379
+ // โ”€โ”€โ”€ Helpers โ”€โ”€โ”€
380
+ function esc(s){return String(s||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");}
381
+ /** "3d" / "2h" for an ISO timestamp โ€” compact enough for a chat panel. */
382
+ function relTime(iso){
383
+ var t=Date.parse(iso);
384
+ if(isNaN(t))return "";
385
+ var s=Math.floor((Date.now()-t)/1000);
386
+ if(s<0)return "";
387
+ if(s<3600)return Math.max(1,Math.floor(s/60))+"m";
388
+ if(s<86400)return Math.floor(s/3600)+"h";
389
+ if(s<2592000)return Math.floor(s/86400)+"d";
390
+ if(s<31536000)return Math.floor(s/2592000)+"mo";
391
+ return Math.floor(s/31536000)+"y";
392
+ }
393
+ function fmtTime(sec){sec=Math.max(0,Math.floor(Number(sec)||0));return Math.floor(sec/60)+":"+("0"+(sec%60)).slice(-2);}
394
+
395
+ // โ”€โ”€โ”€ Audio engine โ”€โ”€โ”€
396
+ // Every <audio> in the view registers here so only one track is ever
397
+ // audible: starting a sound preview pauses a playing slideshow and vice
398
+ // versa. Elements are re-collected after each render.
399
+ var audioReg=[];
400
+ function registerAudio(el){
401
+ if(!el||audioReg.indexOf(el)>-1)return;
402
+ audioReg.push(el);
403
+ el.addEventListener("play",function(){
404
+ for(var i=0;i<audioReg.length;i++){
405
+ var a=audioReg[i];
406
+ if(a!==el&&!a.paused){try{a.pause();}catch(e){}}
407
+ }
408
+ });
409
+ }
410
+ function resetAudio(){
411
+ for(var i=0;i<audioReg.length;i++){try{audioReg[i].pause();}catch(e){}}
412
+ audioReg=[];
413
+ }
414
+
415
+ // โ”€โ”€โ”€ Media player controller โ”€โ”€โ”€
416
+ // A transport hides the difference between the two media kinds behind one
417
+ // interface (duration/time/seek/play/pause). The control bar is wired to
418
+ // the transport, never to a <video> or an <audio> directly, so the same
419
+ // handlers drive every player and the controls behave identically.
420
+ var SLIDE_MS=3000;
421
+
422
+ function videoTransport(box){
423
+ var v=box.querySelector("video");
424
+ return {
425
+ kind:"video",
426
+ el:v,
427
+ // Until metadata loads the element reports NaN for its duration. With
428
+ // preload="none" that is the whole time before the first press, and a
429
+ // scrubber reading 0:00 of 0:00 looks like a card that has failed
430
+ // rather than one waiting. The listing already told us how long the
431
+ // post is, so fall back to that.
432
+ duration:function(){
433
+ if(isFinite(v.duration)&&v.duration>0)return v.duration;
434
+ var d=Number(box.getAttribute("data-mp-dur"))||0;
435
+ return d>0?d:0;
436
+ },
437
+ time:function(){return v.currentTime||0;},
438
+ paused:function(){return v.paused;},
439
+ play:function(){var r=v.play();if(r&&r.catch)r.catch(function(){});},
440
+ pause:function(){try{v.pause();}catch(e){}},
441
+ seek:function(t){try{v.currentTime=t;}catch(e){}},
442
+ muted:function(m){if(m===undefined)return v.muted;v.muted=m;return m;},
443
+ step:function(dir){this.seek(Math.max(0,this.time()+dir*5));},
444
+ // Video paints one continuous segment.
445
+ segment:function(){return 0;},
446
+ audio:null,
447
+ on:function(ev,cb){v.addEventListener(ev,cb);}
448
+ };
449
+ }
450
+
451
+ function slidesTransport(box){
452
+ var slides=box.querySelectorAll(".mp-slide");
453
+ var audio=box.querySelector("audio");
454
+ var total=slides.length||1;
455
+ var idx=0,base=0,startedAt=0,running=false,seekBroken=false;
456
+ // With a track, the track is the clock and each slide owns an equal share
457
+ // of it. Without one, slides run on a fixed beat.
458
+ // A track we cannot seek (a host that serves the audio without range
459
+ // support) would strand the clock at zero while the slides moved on, so
460
+ // we fall back to the wall clock the first time a seek refuses to take.
461
+ function driven(){
462
+ return !seekBroken&&!!audio&&isFinite(audio.duration)&&audio.duration>0;
463
+ }
464
+ function per(){return driven()?audio.duration/total:SLIDE_MS/1000;}
465
+ function paint(){
466
+ for(var k=0;k<total;k++)slides[k].classList.toggle("active",k===idx);
467
+ var chip=box.querySelector(".mp-idx");
468
+ if(chip)chip.textContent=idx+1;
469
+ }
470
+ var t={
471
+ kind:"slides",
472
+ el:box,
473
+ total:total,
474
+ audio:audio,
475
+ duration:function(){return driven()?audio.duration:total*(SLIDE_MS/1000);},
476
+ time:function(){
477
+ if(driven())return audio.currentTime||0;
478
+ return base+(running?(Date.now()-startedAt)/1000:0);
479
+ },
480
+ paused:function(){return !running;},
481
+ play:function(){
482
+ running=true;startedAt=Date.now();
483
+ if(audio){var r=audio.play();if(r&&r.catch)r.catch(function(){});}
484
+ },
485
+ pause:function(){
486
+ if(running)base=t.time();
487
+ running=false;
488
+ if(audio){try{audio.pause();}catch(e){}}
489
+ },
490
+ seek:function(sec){
491
+ sec=Math.max(0,Math.min(sec,t.duration()));
492
+ base=sec;startedAt=Date.now();
493
+ if(audio){
494
+ try{
495
+ audio.currentTime=sec;
496
+ if(sec>0.5&&Math.abs(audio.currentTime-sec)>1)seekBroken=true;
497
+ }catch(e){seekBroken=true;}
498
+ }
499
+ idx=Math.max(0,Math.min(total-1,Math.floor(sec/per())));
500
+ paint();
501
+ },
502
+ muted:function(m){
503
+ if(!audio)return true;
504
+ if(m===undefined)return audio.muted;
505
+ audio.muted=m;return m;
506
+ },
507
+ // Slideshows step a whole slide at a time, not five seconds.
508
+ step:function(dir){t.seek((idx+dir)*per());},
509
+ segment:function(){return idx;},
510
+ sync:function(){
511
+ var want=Math.max(0,Math.min(total-1,Math.floor(t.time()/per())));
512
+ if(want!==idx){idx=want;paint();}
513
+ return t.time()>=t.duration();
514
+ },
515
+ on:function(ev,cb){if(audio)audio.addEventListener(ev,cb);}
516
+ };
517
+ paint();
518
+ return t;
519
+ }
520
+
521
+ // โ”€โ”€โ”€ Follow-up actions โ”€โ”€โ”€
522
+ // A button that spends credits must show that it will: sparkle to mark it as
523
+ // a follow-up the model will reason over, and the price on the face. The
524
+ // click asks the host to run the tool, and falls back to copying a ready
525
+ // prompt where the host cannot run one from a view.
526
+ //
527
+ // These are the fetch prices, which is all any of these tools cost now. The
528
+ // two that fan out to a second call are the two that are dearer than their
529
+ // headline fetch: analyze_post and understand_social_post pay 2 for the
530
+ // frames and 1 more for the transcript. score_draft fetches nothing, so it
531
+ // has no badge โ€” the || 0 below already hides it.
532
+ var AI_PRICE={analyze_post_fast:2,write_hooks:2,create_variants:2,
533
+ repurpose_post:2,niche_report:2,find_hook_pattern:2,analyze_post:3,
534
+ analyze_comments:2,compare_posts:1,understand_social_post:3,analyze_creator_profile:2};
535
+
536
+ // ChatGPT's Apps SDK does not answer the MCP Apps postMessage bridge. A
537
+ // widget there reaches its server through window.openai.callTool, and opens
538
+ // a link through window.openai.openExternal. This view only ever posted
539
+ // tools/call and ui/open-link, so on ChatGPT every in-view button posted
540
+ // into the void, waited out the 1500ms timeout and fell through to the
541
+ // clipboard - and a sandboxed widget iframe carries no clipboard-write
542
+ // grant either, so writeText rejected and the user was told "Copy failed"
543
+ // while nothing had happened. Prefer the host's own API where it exists and
544
+ // keep the bridge for the MCP Apps hosts that do answer it.
545
+ // A tool call is not a UI event. Fetching a feed or watching a video takes
546
+ // ten seconds and more - the discover call this view is built around
547
+ // measures 10.3s - so the 1500ms this used to wait declared a host that was
548
+ // running the tool correctly a failure, and showed "Copy failed" over a call
549
+ // that then succeeded. Wait as long as the result poll does, and keep the
550
+ // short deadline only for a host that answered neither the handshake nor
551
+ // offers an API of its own, which is the one case where nothing is coming.
552
+ var TOOL_CALL_BUDGET_MS=300000;
553
+ var bridgeAlive=false;
554
+
555
+ /** Ask the host to run a tool: onDone(result) when it does, onFail() only
556
+ * when the host cannot run it at all. */
557
+ function invokeTool(tool,args,onDone,onFail){
558
+ var settled=false;
559
+ var fail=function(){ if(settled)return; settled=true; onFail(); };
560
+ var api=window.openai;
561
+ var native=!!(api&&typeof api.callTool==="function");
562
+ var p=null;
563
+ if(native){
564
+ try{ p=api.callTool(tool,args||{}); if(p&&!p.then)p=Promise.resolve(p); }catch(e){ p=null; }
565
+ }
566
+ if(!p){ try{ p=send("tools/call",{name:tool,arguments:args||{}}); }catch(e2){ p=null; } }
567
+ if(!p||!p.then){ fail(); return; }
568
+ p.then(function(r){ if(settled)return; settled=true; onDone(r); },fail);
569
+ setTimeout(fail,(native||bridgeAlive)?TOOL_CALL_BUDGET_MS:1500);
570
+ }
571
+
572
+ /** Show what the call came back with. A host that answers in-band hands the
573
+ * result straight to us; one that also pushes a tool-result notification
574
+ * would render the same payload again, so keep the globals bookkeeping in
575
+ * step and let that second pass be skipped. */
576
+ function renderToolResult(r){
577
+ if(!r||typeof r!=="object")return false;
578
+ var sc=r.structuredContent;
579
+ if(!sc||typeof sc!=="object")return false;
580
+ lastOutRef=sc;
581
+ try{ lastOutSig=JSON.stringify(sc); }catch(e){ lastOutSig=""; }
582
+ toolResultReceived=true;
583
+ render({structuredContent:sc});
584
+ setTimeout(reportSize,50);
585
+ return true;
586
+ }
587
+
588
+ /** True once the host has been asked to open the link. */
589
+ function openHostLink(href){
590
+ var api=window.openai;
591
+ if(api&&typeof api.openExternal==="function"){
592
+ try{ api.openExternal({href:href}); return true; }catch(e){}
593
+ }
594
+ return false;
595
+ }
596
+
597
+ function aiBtn(tool,label,argsJson){
598
+ var cost=AI_PRICE[tool]||0;
599
+ return '<button class="ai-btn" type="button" data-ai="'+esc(tool)+'" data-args="'+esc(argsJson)+'">'
600
+ +'<span class="spark">'+mpIcon("sparkle",13)+"</span><span>"+esc(label)+"</span>"
601
+ +(cost?'<span class="price">'+cost+" cr</span>":"")+"</button>";
602
+ }
603
+
604
+ /** Buttons offered under a single post. */
605
+ function postAiActions(p){
606
+ var url=p&&(p.externalUrl||p.url);
607
+ if(!url)return "";
608
+ var a=JSON.stringify({url:url});
609
+ return '<div class="ai-actions">'
610
+ +aiBtn("create_variants","Create variants",a)
611
+ +aiBtn("write_hooks","Write hooks",a)
612
+ +aiBtn("repurpose_post","Repurpose",a)
613
+ +aiBtn("analyze_comments","Read comments",a)
614
+ +"</div>";
615
+ }
616
+
617
+ function runAiTool(btn){
618
+ var tool=btn.getAttribute("data-ai");
619
+ var args={};
620
+ try{args=JSON.parse(btn.getAttribute("data-args")||"{}");}catch(e){}
621
+ var label=btn.querySelector("span:nth-child(2)");
622
+ var original=label?label.textContent:"";
623
+ btn.disabled=true;btn.classList.add("busy");
624
+ if(label)label.textContent="Workingโ€ฆ";
625
+ var restore=function(text){
626
+ btn.classList.remove("busy");
627
+ if(label)label.textContent=text||original;
628
+ setTimeout(function(){btn.disabled=false;if(label)label.textContent=original;},1600);
629
+ };
630
+ var settled=false;
631
+ var fallback=function(){
632
+ if(settled)return;settled=true;
633
+ var ask="Run "+tool+" on "+(args.url||JSON.stringify(args));
634
+ if(navigator.clipboard&&navigator.clipboard.writeText){
635
+ navigator.clipboard.writeText(ask).then(function(){restore("Copied โœ“");})
636
+ .catch(function(){restore("Try in chat");});
637
+ }else restore("Try in chat");
638
+ };
639
+ invokeTool(tool,args,function(r){
640
+ settled=true;
641
+ restore("Sent โœ“");
642
+ renderToolResult(r);
643
+ },fallback);
644
+ }
645
+
646
+ document.addEventListener("click",function(e){
647
+ var b=e.target.closest&&e.target.closest(".ai-btn[data-ai]");
648
+ if(b&&!b.disabled)runAiTool(b);
649
+ });
650
+
651
+ // A sandboxed view cannot navigate the top-level window, so a plain anchor
652
+ // does nothing in most hosts - the "Open on ..." buttons and the trending
653
+ // hashtag rows were all inert. MCP Apps exposes ui/open-link for this.
654
+ // window.open stays as the fallback for hosts that do allow it.
655
+ document.addEventListener("click",function(e){
656
+ var a=e.target.closest&&e.target.closest("a[href]");
657
+ if(!a)return;
658
+ var href=a.getAttribute("href")||"";
659
+ if(href.indexOf("http")!==0)return;
660
+ e.preventDefault();
661
+ var popOut=function(){try{window.open(href,"_blank","noopener");}catch(err){}};
662
+ if(openHostLink(href))return;
663
+ var pr=null;
664
+ try{pr=send("ui/open-link",{url:href});}catch(err2){}
665
+ if(pr&&pr.then)pr.catch(popOut); else popOut();
666
+ });
667
+
668
+ // โ”€โ”€โ”€ Post selection & comparison โ”€โ”€โ”€
669
+ // Comparing posts is the one action that needs more than one card, so the
670
+ // cards themselves carry the control: a checkbox on the media, and a menu
671
+ // that only exists once something is picked.
672
+ var picked = [];
673
+
674
+ function pickBarHtml(){
675
+ return '<div class="pickbar" id="pickbar" hidden>'
676
+ +'<span class="n"><b id="pickn">0</b> selected</span>'
677
+ +'<span class="hint" id="pickhint">Pick 2 or more to compare</span>'
678
+ +'<span class="sp"></span>'
679
+ +'<button type="button" id="pickclear">Clear</button>'
680
+ +'<button type="button" id="pickgo" class="primary" disabled>Compare</button>'
681
+ +"</div>";
682
+ }
683
+
684
+ function syncPickBar(){
685
+ var bar=document.getElementById("pickbar");
686
+ if(!bar)return;
687
+ bar.hidden = picked.length===0;
688
+ var n=document.getElementById("pickn");
689
+ if(n)n.textContent=picked.length;
690
+ var urls=pickedUrls();
691
+ var go=document.getElementById("pickgo");
692
+ if(go)go.disabled = urls.length<2 || urls.length>5;
693
+ var hint=document.getElementById("pickhint");
694
+ if(hint){
695
+ hint.textContent = picked.length>5 ? "Compare takes at most 5"
696
+ : urls.length<2 ? (picked.length>=2 ? "Those are the same post" : "Pick 2 or more to compare")
697
+ : "Ready to compare";
698
+ }
699
+ setTimeout(reportSize,60);
700
+ }
701
+
702
+ function initPicks(){
703
+ picked=[];
704
+ document.addEventListener("click",onPickClick);
705
+ var clear=document.getElementById("pickclear");
706
+ if(clear)clear.addEventListener("click",function(){
707
+ picked=[];
708
+ document.querySelectorAll('.mp-pick[aria-pressed="true"]').forEach(function(b){
709
+ b.setAttribute("aria-pressed","false");
710
+ var mp=b.closest(".mp"); if(mp)mp.classList.remove("picked");
711
+ });
712
+ syncPickBar();
713
+ });
714
+ var go=document.getElementById("pickgo");
715
+ if(go)go.addEventListener("click",runCompare);
716
+ syncPickBar();
717
+ }
718
+
719
+ function onPickClick(e){
720
+ var b=e.target.closest&&e.target.closest(".mp-pick[data-pick]");
721
+ if(!b)return;
722
+ e.stopPropagation(); // never let the tap reach the player underneath
723
+ var id=b.getAttribute("data-pick-id");
724
+ var i=picked.findIndex(function(x){return x.id===id;});
725
+ var on=i<0;
726
+ if(on)picked.push({id:id,url:b.getAttribute("data-pick")});
727
+ else picked.splice(i,1);
728
+ b.setAttribute("aria-pressed",on?"true":"false");
729
+ var mp=b.closest(".mp");
730
+ if(mp)mp.classList.toggle("picked",on);
731
+ syncPickBar();
732
+ }
733
+
734
+ /** Distinct URLs from the current selection โ€” the same post picked twice
735
+ * is still one post to compare. */
736
+ function pickedUrls(){
737
+ var seen=[],out=[];
738
+ picked.forEach(function(x){
739
+ if(x.url&&seen.indexOf(x.url)<0){seen.push(x.url);out.push(x.url);}
740
+ });
741
+ return out;
742
+ }
743
+
744
+ /** Ask the host to run compare_posts; fall back to copying the URLs.
745
+ * The method is "tools/call" - MCP Apps reuses the core MCP method for
746
+ * tool invocation rather than defining a prefixed one of its own. This
747
+ * used to send a ui-prefixed name that no host answers, so every in-view
748
+ * action button silently timed out into the clipboard fallback. */
749
+ function runCompare(){
750
+ var go=document.getElementById("pickgo");
751
+ var urls=pickedUrls().slice(0,5);
752
+ if(!go||urls.length<2)return;
753
+ go.disabled=true; go.textContent="Comparingโ€ฆ";
754
+ var done=function(label){
755
+ go.textContent=label;
756
+ setTimeout(function(){ go.textContent="Compare"; syncPickBar(); },1800);
757
+ };
758
+ // Preferred path: the host runs the tool for us.
759
+ var settled=false;
760
+ var fallback=function(){
761
+ if(settled)return; settled=true;
762
+ // Hosts that do not support tool-calls from a view still let the user
763
+ // paste โ€” so hand them something ready to send.
764
+ // Build the newline from a char code rather than an escape sequence.
765
+ // This template is embedded both in a Rust raw string (escapes stay
766
+ // literal) and a TS template literal (escapes are resolved), so an
767
+ // escaped newline here becomes a real one in the TS build and breaks
768
+ // the surrounding string literal. Same reason this comment avoids one.
769
+ var NL=String.fromCharCode(10);
770
+ var text="Compare these posts:"+NL+urls.join(NL);
771
+ if(navigator.clipboard&&navigator.clipboard.writeText){
772
+ navigator.clipboard.writeText(text).then(function(){done("Copied โœ“");})
773
+ .catch(function(){done("Copy failed");});
774
+ } else done("Copy failed");
775
+ };
776
+ invokeTool("compare_posts",{urls:urls},function(r){
777
+ settled=true;
778
+ done("Sent โœ“");
779
+ renderToolResult(r);
780
+ },fallback);
781
+ }
782
+
783
+ // Images cannot bubble an error, so this listens in the capture phase: any
784
+ // <img data-fallback> that fails swaps to its fallback once. Thumbnails and
785
+ // sound covers expire the same way videos do - a TikTok poster lasts about
786
+ // five hours - and a dead cover is the most visible kind of broken card.
787
+ document.addEventListener("error",function(e){
788
+ var el=e.target;
789
+ if(!el||el.tagName!=="IMG")return;
790
+ var alt=el.getAttribute("data-fallback")||"";
791
+ if(!alt||el.getAttribute("src")===alt)return;
792
+ el.removeAttribute("data-fallback");
793
+ el.setAttribute("src",alt);
794
+ },true);
795
+
796
+ // An avatar with nothing left to try hides, uncovering the initial drawn
797
+ // underneath it. A broken-image glyph in a column of faces reads as a fault;
798
+ // an initial reads as a person whose picture did not load.
799
+ document.addEventListener("error",function(e){
800
+ var el=e.target;
801
+ if(!el||el.tagName!=="IMG"||!el.hasAttribute("data-avatar"))return;
802
+ if(el.getAttribute("data-fallback"))return;
803
+ el.style.display="none";
804
+ },true);
805
+
806
+ // A <video> poster cannot report failure: there is no error event for it, so
807
+ // a dead cover leaves a black stage and nothing notices. TikTok cover
808
+ // signatures expire in hours, so this is the ordinary case, not the edge -
809
+ // measured on a two-hour-old payload whose cover already answered 403 while
810
+ // its video still played. Probing the URL with an Image gives us the error
811
+ // event the video will not, and the resolver hands back a fresh cover.
812
+ function healPosters(root){
813
+ var vids=(root||document).querySelectorAll("video[data-poster-fallback]");
814
+ for(var i=0;i<vids.length;i++)(function(v){
815
+ var poster=v.getAttribute("poster")||"",alt=v.getAttribute("data-poster-fallback")||"";
816
+ v.removeAttribute("data-poster-fallback");
817
+ if(!poster||!alt||poster===alt)return;
818
+ var probe=new Image();
819
+ probe.onerror=function(){v.setAttribute("poster",alt);};
820
+ probe.src=poster;
821
+ })(vids[i]);
822
+ }
823
+
824
+ // โ”€โ”€โ”€ Player registry โ”€โ”€โ”€
825
+ // Exclusivity cannot ride on the audio elements alone: a silent slideshow
826
+ // owns no <audio>, so it would happily keep advancing underneath a video
827
+ // that just started. Every player registers its own stop() instead.
828
+ var players=[];
829
+ function registerPlayer(rec){players.push(rec);}
830
+ function stopOtherPlayers(except){
831
+ for(var i=0;i<players.length;i++){
832
+ if(players[i]!==except&&players[i].isPlaying())players[i].stop(false);
833
+ }
834
+ }
835
+ function stopAllPlayers(){stopOtherPlayers(null);}
836
+ function resetPlayers(){stopAllPlayers();players=[];}
837
+
838
+ // Mute is a session preference, not a per-card one โ€” having to silence
839
+ // every card in a feed one at a time is the kind of thing that makes people
840
+ // close the tab.
841
+ var mutePref=false;
842
+
843
+ function initMediaPlayers(root){
844
+ var nodes=(root||document).querySelectorAll(".mp[data-mp]");
845
+ for(var n=0;n<nodes.length;n++)initMediaPlayer(nodes[n]);
846
+ observeVisibility();
847
+ }
848
+
849
+ // โ”€โ”€โ”€ Pause on scroll-away โ”€โ”€โ”€
850
+ // Paging to the next card in a gallery must stop the one you left, or two
851
+ // soundtracks end up playing over each other.
852
+ var mpObserver=null;
853
+ function observeVisibility(){
854
+ if(!("IntersectionObserver" in window))return;
855
+ if(mpObserver)mpObserver.disconnect();
856
+ mpObserver=new IntersectionObserver(function(entries){
857
+ for(var i=0;i<entries.length;i++){
858
+ var e=entries[i],rec=e.target.__mpRec;
859
+ if(!rec)continue;
860
+ if(e.intersectionRatio>=0.6){rec.wasVisible=true;warmMedia(e.target);continue;}
861
+ // Only a card that was genuinely on screen and has scrolled away
862
+ // stops. Without the transition check, a smooth-scroll that is still
863
+ // settling reads as "off screen" and kills playback the instant the
864
+ // user presses play on a partly-visible card.
865
+ if(e.intersectionRatio<0.25&&rec.wasVisible){
866
+ rec.wasVisible=false;
867
+ if(rec.isPlaying())rec.stop(false);
868
+ }
869
+ }
870
+ },{threshold:[0,0.25,0.6,1]});
871
+ var nodes=document.querySelectorAll(".mp[data-mp]");
872
+ for(var n=0;n<nodes.length;n++)mpObserver.observe(nodes[n]);
873
+ }
874
+
875
+ // Leaving the tab (or the host collapsing the view) must not leave audio
876
+ // playing out of sight.
877
+ document.addEventListener("visibilitychange",function(){
878
+ if(document.hidden)stopAllPlayers();
879
+ });
880
+ window.addEventListener("pagehide",stopAllPlayers);
881
+
882
+ // Resolver-backed video costs eight to twenty-five seconds before a URL
883
+ // comes back. That is the provider's own latency, measured across repeated
884
+ // calls for the same video, and they do not cache it - so nothing we do on
885
+ // our side makes the *first* one quick. Left until the play button is
886
+ // pressed, all of it is spent staring at a spinner, which is what "loads
887
+ // indefinitely" was.
888
+ //
889
+ // A card that is genuinely on screen therefore warms itself, once. Metadata
890
+ // only - a small range request, not the video - and it primes the resolve
891
+ // cache so the press that follows is instant. Cards nobody scrolls to cost
892
+ // nothing, which is the whole reason preload is "none" to begin with.
893
+ function warmMedia(box){
894
+ if(box.__mpWarm)return;
895
+ var v=box.querySelector("video");
896
+ if(!v)return;
897
+ var src=v.getAttribute("src")||"";
898
+ if(src.indexOf("/media/resolve")<0||v.preload!=="none")return;
899
+ box.__mpWarm=true;
900
+ v.preload="metadata";
901
+ try{v.load();}catch(e){}
902
+ }
903
+
904
+ function initMediaPlayer(box){
905
+ if(box.__mpInit)return;
906
+ box.__mpInit=true;
907
+ var t=box.getAttribute("data-mp")==="video"?videoTransport(box):slidesTransport(box);
908
+ if(!t.el)return;
909
+ if(t.audio)registerAudio(t.audio);
910
+ if(t.kind==="video")registerAudio(t.el);
911
+
912
+ var playBtn=box.querySelector(".mp-play");
913
+ var tapBtn=box.querySelector(".mp-tap");
914
+ var muteBtn=box.querySelector(".mp-mute");
915
+ var seekBar=box.querySelector(".mp-seek");
916
+ var segs=box.querySelectorAll(".mp-seg");
917
+ var curEl=box.querySelector(".mp-cur");
918
+ var durEl=box.querySelector(".mp-dur");
919
+ // Driven by an interval, not requestAnimationFrame: hosts render these
920
+ // cards in iframes that often are not compositing, where rAF never fires.
921
+ var ticker=null;
922
+
923
+ function paintSeek(){
924
+ var d=t.duration(),now=t.time();
925
+ if(segs.length<=1){
926
+ if(segs[0])segs[0].firstChild.style.width=(d>0?Math.min(100,(now/d)*100):0)+"%";
927
+ }else{
928
+ var per=d/segs.length,active=t.segment();
929
+ for(var k=0;k<segs.length;k++){
930
+ segs[k].classList.toggle("done",k<active);
931
+ segs[k].firstChild.style.width=
932
+ k<active?"100%":k>active?"0":Math.max(0,Math.min(100,((now-k*per)/per)*100))+"%";
933
+ }
934
+ }
935
+ if(curEl)curEl.textContent=fmtTime(now);
936
+ if(durEl)durEl.textContent=fmtTime(d);
937
+ }
938
+ function setPlaying(on){
939
+ box.classList.toggle("playing",on);
940
+ if(playBtn){
941
+ playBtn.innerHTML=mpIcon(on?"pause":"play",16);
942
+ playBtn.setAttribute("aria-label",on?"Pause":"Play");
943
+ }
944
+ if(tapBtn){
945
+ tapBtn.innerHTML="<span>"+mpIcon(on?"pause":"play",28)+"</span>";
946
+ tapBtn.setAttribute("aria-label",on?"Pause":"Play");
947
+ }
948
+ }
949
+ function applyMute(){
950
+ t.muted(mutePref);
951
+ if(muteBtn){
952
+ muteBtn.innerHTML=mpIcon(mutePref?"muted":"volume",17);
953
+ muteBtn.setAttribute("aria-label",mutePref?"Unmute":"Mute");
954
+ muteBtn.setAttribute("aria-pressed",mutePref?"true":"false");
955
+ }
956
+ }
957
+ function tick(){
958
+ paintSeek();
959
+ if(t.kind==="slides"&&t.sync&&!t.paused()&&t.sync())stop(true);
960
+ }
961
+ function start(){
962
+ // Only one thing plays at a time, across every card in the view.
963
+ stopOtherPlayers(rec);
964
+ rec.wasVisible=true;
965
+ t.play();setPlaying(true);
966
+ clearInterval(ticker);ticker=setInterval(tick,100);
967
+ tick();
968
+ }
969
+ function stop(reset){
970
+ t.pause();setPlaying(false);
971
+ clearInterval(ticker);ticker=null;
972
+ if(reset)t.seek(0);
973
+ paintSeek();
974
+ }
975
+ function toggle(){t.paused()?start():stop(false);}
976
+
977
+ // Registered before any wiring so start() can reach it.
978
+ var rec={wasVisible:false,
979
+ isPlaying:function(){return box.classList.contains("playing");},
980
+ stop:function(reset){stop(reset);}};
981
+ box.__mpRec=rec;
982
+ registerPlayer(rec);
983
+
984
+ if(playBtn)playBtn.addEventListener("click",toggle);
985
+ if(tapBtn)tapBtn.addEventListener("click",toggle);
986
+ if(muteBtn)muteBtn.addEventListener("click",function(){
987
+ mutePref=!mutePref;
988
+ // Apply to every player, so the choice sticks across the whole feed.
989
+ var all=document.querySelectorAll(".mp[data-mp]");
990
+ for(var i=0;i<all.length;i++)if(all[i].__mpApplyMute)all[i].__mpApplyMute();
991
+ });
992
+ box.__mpApplyMute=applyMute;
993
+
994
+ // Keyboard: the same keys people already expect from a video player.
995
+ box.addEventListener("keydown",function(e){
996
+ var k=e.key;
997
+ if(k===" "||k==="Spacebar"||k==="k"){e.preventDefault();toggle();}
998
+ else if(k==="ArrowRight"){e.preventDefault();t.step(1);paintSeek();}
999
+ else if(k==="ArrowLeft"){e.preventDefault();t.step(-1);paintSeek();}
1000
+ else if(k==="m"||k==="M"){e.preventDefault();if(muteBtn)muteBtn.click();}
1001
+ else if(k==="Home"){e.preventDefault();t.seek(0);paintSeek();}
1002
+ });
1003
+ if(seekBar)seekBar.addEventListener("click",function(e){
1004
+ var d=t.duration();
1005
+ if(d<=0)return;
1006
+ var r=seekBar.getBoundingClientRect();
1007
+ t.seek(((e.clientX-r.left)/r.width)*d);
1008
+ paintSeek();
1009
+ });
1010
+ box.addEventListener("click",function(e){
1011
+ var nav=e.target.closest?e.target.closest(".mp-nav"):null;
1012
+ if(!nav)return;
1013
+ t.step(Number(nav.getAttribute("data-dir"))||1);
1014
+ paintSeek();
1015
+ });
1016
+ // Media the user paused from elsewhere (another player claimed the
1017
+ // audio, or the native video UI) must not leave the button lying.
1018
+ t.on("ended",function(){stop(true);});
1019
+ t.on("pause",function(){if(!box.classList.contains("playing"))return;setPlaying(false);clearInterval(ticker);});
1020
+ t.on("play",function(){if(!box.classList.contains("playing"))start();});
1021
+ t.on("loadedmetadata",paintSeek);
1022
+ if(t.kind==="slides")t.on("timeupdate",tick);
1023
+
1024
+ // Buffering and failure are states the user can see, not a frozen frame.
1025
+ var spin=box.querySelector(".mp-spin"),errBox=box.querySelector(".mp-err");
1026
+ function busy(on){if(spin)spin.hidden=!on;}
1027
+ if(t.kind==="video"){
1028
+ t.on("waiting",function(){busy(true);});
1029
+ t.on("stalled",function(){busy(true);});
1030
+ t.on("playing",function(){busy(false);});
1031
+ t.on("canplay",function(){busy(false);});
1032
+ }
1033
+ var media=t.kind==="video"?t.el:t.audio;
1034
+ if(media)media.addEventListener("error",function(){
1035
+ // One retry through the proxy before admitting defeat: the direct CDN
1036
+ // link is tried first, but some hosts refuse a cross-origin request
1037
+ // from this frame and only the proxied copy will play.
1038
+ var alt=media.getAttribute("data-fallback")||box.getAttribute("data-mp-fallback")||"";
1039
+ if(alt&&media.getAttribute("src")!==alt){
1040
+ media.removeAttribute("data-fallback");
1041
+ box.removeAttribute("data-mp-fallback");
1042
+ media.setAttribute("src",alt);
1043
+ try{media.load();}catch(e){}
1044
+ return;
1045
+ }
1046
+ busy(false);
1047
+ if(errBox)errBox.hidden=false;
1048
+ stop(false);
1049
+ });
1050
+
1051
+ setPlaying(false);
1052
+ applyMute();
1053
+ paintSeek();
1054
+ }
1055
+
1056
+ // โ”€โ”€โ”€ Sound card players โ”€โ”€โ”€
1057
+ function initSoundPlayers(root){
1058
+ var nodes=(root||document).querySelectorAll(".au[data-au]");
1059
+ for(var n=0;n<nodes.length;n++){
1060
+ (function(wrap){
1061
+ if(wrap.__auInit)return;
1062
+ wrap.__auInit=true;
1063
+ var audio=wrap.querySelector("audio");
1064
+ var btn=wrap.querySelector(".au-play");
1065
+ var seek=wrap.querySelector(".au-seek");
1066
+ var fill=seek?seek.firstChild:null;
1067
+ var cur=wrap.querySelector(".au-cur");
1068
+ var dur=wrap.querySelector(".au-dur");
1069
+ if(!audio||!btn)return;
1070
+ registerAudio(audio);
1071
+ btn.addEventListener("click",function(){
1072
+ if(audio.paused){var pr=audio.play();if(pr&&pr.catch)pr.catch(function(){
1073
+ wrap.setAttribute("data-error","1");
1074
+ if(cur)cur.textContent="unavailable";
1075
+ });}
1076
+ else audio.pause();
1077
+ });
1078
+ function mark(on){
1079
+ btn.innerHTML=mpIcon(on?"pause":"play",14);
1080
+ btn.setAttribute("aria-label",on?"Pause sound":"Play sound");
1081
+ }
1082
+ // A sound preview must also silence any playing card, not just other
1083
+ // previews โ€” they are all one audio surface to the user.
1084
+ audio.addEventListener("play",function(){mark(true);stopAllPlayers();});
1085
+ audio.addEventListener("pause",function(){mark(false);});
1086
+ audio.addEventListener("ended",function(){mark(false);if(fill)fill.style.width="0";});
1087
+ audio.addEventListener("loadedmetadata",function(){
1088
+ if(dur&&isFinite(audio.duration))dur.textContent=fmtTime(audio.duration);
1089
+ });
1090
+ audio.addEventListener("timeupdate",function(){
1091
+ if(cur)cur.textContent=fmtTime(audio.currentTime);
1092
+ if(fill&&isFinite(audio.duration)&&audio.duration>0)
1093
+ fill.style.width=((audio.currentTime/audio.duration)*100)+"%";
1094
+ });
1095
+ if(seek)seek.addEventListener("click",function(e){
1096
+ if(!isFinite(audio.duration)||audio.duration<=0)return;
1097
+ var r=seek.getBoundingClientRect();
1098
+ try{audio.currentTime=((e.clientX-r.left)/r.width)*audio.duration;}catch(err){}
1099
+ });
1100
+ })(nodes[n]);
1101
+ }
1102
+ }
1103
+
1104
+ // Wire every player in the freshly rendered view.
1105
+ function initPlayers(){
1106
+ resetPlayers();
1107
+ resetAudio();
1108
+ setStageMax();
1109
+ initMediaPlayers(document);
1110
+ healPosters(document);
1111
+ initSoundPlayers(document);
1112
+ }
1113
+
1114
+ // โ”€โ”€โ”€ Stage size โ”€โ”€โ”€
1115
+ // Ceiling on the stage height, so a portrait post cannot run the card off
1116
+ // the screen. It is the only thing capping the stage now.
1117
+ var HARD_MAX=560;
1118
+ function availableWidth(){
1119
+ var vv=window.visualViewport;
1120
+ return (vv&&vv.width)||document.documentElement.clientWidth||window.innerWidth||0;
1121
+ }
1122
+ // The stage takes its size from the width of the frame and the aspect ratio
1123
+ // of the post. It must not take it from the frame's *height*.
1124
+ //
1125
+ // That height is provisional: the host sets a placeholder, waits for us to
1126
+ // report how tall we are, then resizes. Deriving our width from it (the CSS
1127
+ // takes .mp width from --stage-max via the ratio) made the card a function
1128
+ // of that placeholder. Measured with the same card and the same content,
1129
+ // varying only the height the host started with:
1130
+ //
1131
+ // starts 300px -> 492x277 starts 560px -> 738x415
1132
+ // starts 420px -> 706x397 starts 700px -> 738x415
1133
+ //
1134
+ // Deterministic, but the placeholder differs every time a virtualising host
1135
+ // unmounts a card on scroll and mounts it again - which is the "videos get
1136
+ // resized randomly when I scroll or they reload" report.
1137
+ //
1138
+ // So the cap is now fixed. Width comes from the frame, height follows from
1139
+ // the ratio, the cap only stops a portrait post running away, and we report
1140
+ // the height that produces. The host accommodates us rather than the other
1141
+ // way round, which is the only direction that terminates.
1142
+ function setStageMax(){
1143
+ document.documentElement.style.setProperty("--stage-max",HARD_MAX+"px");
1144
+ }
1145
+ // A width change is a real layout change and worth re-reporting; a height
1146
+ // change is the host answering us, and must not feed back.
1147
+ var lastWidth=availableWidth();
1148
+ function onViewportChange(){
1149
+ var w=availableWidth();
1150
+ if(w===lastWidth)return;
1151
+ lastWidth=w;
1152
+ reportSize();
1153
+ }
1154
+ if(window.visualViewport)window.visualViewport.addEventListener("resize",onViewportChange);
1155
+ window.addEventListener("orientationchange",function(){setTimeout(function(){
1156
+ lastWidth=availableWidth();reportSize();
1157
+ },120);});
1158
+
1159
+ function fmtNum(n){n=Number(n)||0;return n>=1e6?(n/1e6).toFixed(1)+"M":n>=1e3?(n/1e3).toFixed(1)+"K":String(n);}
1160
+ function pColor(p){return{tiktok:"#000",douyin:"#000",instagram:"#E4405F",youtube:"#FF0000",xiaohongshu:"#FF2442",x:"#000",twitter:"#000",bilibili:"#00A1D6",linkedin:"#0A66C2",reddit:"#FF4500",weibo:"#E6162D"}[p]||"#6B7280";}
1161
+ /** Display name for a platform key โ€” "xiaohongshu" is not a label. */
1162
+ function pTitle(p){
1163
+ var m={tiktok:"TikTok",douyin:"Douyin",instagram:"Instagram",youtube:"YouTube",
1164
+ xiaohongshu:"Xiaohongshu",twitter:"X",x:"X",bilibili:"Bilibili",
1165
+ linkedin:"LinkedIn",reddit:"Reddit",weibo:"Weibo"};
1166
+ return m[p]||(p?String(p).charAt(0).toUpperCase()+String(p).slice(1):"");
1167
+ }
1168
+ // Brand colours are chosen against a white page. TikTok, Douyin and X are
1169
+ // pure black, so used as label text on the dark card they were invisible.
1170
+ // Keep the brand colour for fills, but lift a too-dark one toward the card
1171
+ // foreground when it has to be read as text in dark mode.
1172
+ function pColorText(p){
1173
+ var c=pColor(p);
1174
+ var dq=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)");
1175
+ if(!dq||!dq.matches)return c;
1176
+ var h=c.replace("#","");
1177
+ if(h.length===3)h=h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
1178
+ var r=parseInt(h.slice(0,2),16),g=parseInt(h.slice(2,4),16),b=parseInt(h.slice(4,6),16);
1179
+ if(isNaN(r)||isNaN(g)||isNaN(b))return c;
1180
+ if((0.2126*r+0.7152*g+0.0722*b)/255>0.38)return c;
1181
+ var t=0.66;
1182
+ return "rgb("+Math.round(r+(255-r)*t)+","+Math.round(g+(255-g)*t)+","+Math.round(b+(255-b)*t)+")";
1183
+ }
1184
+ // Official brand mark (simple-icons path) as an inline SVG that inherits
1185
+ // the current text color via fill="currentColor" โ€” matches badge accents.
1186
+ // โ”€โ”€โ”€ UI icon set โ”€โ”€โ”€
1187
+ // Real vector icons rather than emoji: emoji render differently on every
1188
+ // OS (and some platforms colour them), so the control bar and engagement
1189
+ // rail would not look the same from one machine to the next. These inherit
1190
+ // currentColor and size, so one icon serves every context.
1191
+ var MP_ICONS={
1192
+ heart:"M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z",
1193
+ comment:"M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-6H6V6h12v2z",
1194
+ share:"M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z",
1195
+ eye:"M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z",
1196
+ play:"M8 5v14l11-7z",
1197
+ pause:"M6 19h4V5H6v14zm8-14v14h4V5h-4z",
1198
+ volume:"M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z",
1199
+ muted:"M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z",
1200
+ note:"M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z",
1201
+ prev:"M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z",
1202
+ next:"M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z",
1203
+ open:"M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z",
1204
+ warn:"M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z",
1205
+ check:"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z",
1206
+ sparkle:"M12 2l1.9 5.7L19.6 9.6l-5.7 1.9L12 17.2l-1.9-5.7L4.4 9.6l5.7-1.9L12 2zm6.5 10.5l.9 2.6 2.6.9-2.6.9-.9 2.6-.9-2.6-2.6-.9 2.6-.9.9-2.6zM5 14l.7 2 2 .7-2 .7L5 19.4l-.7-2-2-.7 2-.7L5 14z"
1207
+ };
1208
+ function mpIcon(name,size){
1209
+ var s=size||18,d=MP_ICONS[name]||"";
1210
+ return '<svg viewBox="0 0 24 24" width="'+s+'" height="'+s+'" fill="currentColor" style="display:block;flex-shrink:0" aria-hidden="true" focusable="false"><path d="'+d+'"/></svg>';
1211
+ }
1212
+
1213
+ /**
1214
+ * The monitoring screen, kept in a variable so filtering and sorting redraw
1215
+ * without another paid call. A sandboxed view cannot re-query; anything the
1216
+ * user narrows has to be narrowed from what is already here.
1217
+ */
1218
+ var monitorState=null;
1219
+ var monitorTerm="";
1220
+
1221
+ /**
1222
+ * search_spoken_mentions's own state, kept separately from monitorState
1223
+ * rather than shoehorned into it: the unit here is a video with excerpts,
1224
+ * not a comment, and the two payloads share only the idiom, not the shape.
1225
+ */
1226
+ var spokenState=null;
1227
+
1228
+ /**
1229
+ * Sentiment and category, when something has classified the comment.
1230
+ *
1231
+ * These are absent on a brand sweep and present on a show_comment_review,
1232
+ * because the sweep has no basis for them โ€” nothing in the payload says
1233
+ * whether a comment is angry, and colouring it from a keyword guess would be
1234
+ * confidently wrong about the exact thing being scanned for. When a model
1235
+ * has actually read the words, the label is worth something, so it is drawn.
1236
+ */
1237
+ function labelChips(m){
1238
+ var out="";
1239
+ var s=String(m.sentiment||"");
1240
+ if(s)out+='<span class="chip chip-'+esc(s)+'">'+esc(s)+"</span>";
1241
+ var c=String(m.category||"");
1242
+ if(c)out+='<span class="chip chip-cat">'+esc(c.split("_").join(" "))+"</span>";
1243
+ return out;
1244
+ }
1245
+
1246
+ /** The counts a review carries, as the same filter chips the sweep uses. */
1247
+ function reviewChips(st){
1248
+ var counts=st.byCategory||{};
1249
+ var keys=Object.keys(counts).sort(function(a,b){return counts[b]-counts[a];});
1250
+ if(!keys.length)return "";
1251
+ return '<div class="mchips">'
1252
+ +['<button type="button" class="mchip'+(st.filter?"":" on")+'" data-filter="">All <b>'
1253
+ +esc(String(st.total))+"</b></button>"]
1254
+ .concat(keys.map(function(k){
1255
+ return '<button type="button" class="mchip'+(st.filter===k?" on":"")
1256
+ +'" data-filter="'+esc(k)+'">'+esc(k.split("_").join(" "))
1257
+ +" <b>"+esc(String(counts[k]))+"</b></button>";
1258
+ })).join("")
1259
+ +"</div>";
1260
+ }
1261
+
1262
+ /** Identifies a group across a redraw. The permalink is the stable part. */
1263
+ function groupKey(t){
1264
+ var post=t.post||{};
1265
+ return String(post.externalUrl||(post.platform||"")+":"+(post.title||""));
1266
+ }
1267
+ /** The group a key names, or null if the page has moved on. */
1268
+ function groupByKey(k){
1269
+ var found=null;
1270
+ (monitorState?monitorState.threads:[]).forEach(function(t){
1271
+ if(!found&&groupKey(t)===k)found=t;
1272
+ });
1273
+ return found;
1274
+ }
1275
+
1276
+ /**
1277
+ * The comments "select all" means, for one group.
1278
+ *
1279
+ * Collapsing and filtering look alike โ€” both leave rows off the screen โ€” but
1280
+ * they mean opposite things. A collapsed comment is hidden to save space and
1281
+ * the reader still meant it; a filtered-out one they deliberately excluded.
1282
+ * So this ignores the fold and honours the filter.
1283
+ */
1284
+ function selectableMentions(t){
1285
+ var all=t.mentions||[];
1286
+ if(!monitorState||!monitorState.filter||!monitorState.byCategory)return all;
1287
+ var want=monitorState.filter;
1288
+ return all.filter(function(m){return String(m.category||"")===want;});
1289
+ }
1290
+
1291
+ /** How many comments a group shows before it asks. */
1292
+ var GROUP_PEEK=4;
1293
+ /** Groups the reader has opened, by post URL, kept across a filter or sort. */
1294
+ var expandedGroups={};
1295
+
1296
+ /** Reach of the post a comment sits under โ€” how many people saw it. */
1297
+ function postReach(post){
1298
+ var bits=[];
1299
+ if(post.views)bits.push(fmtNum(post.views)+" views");
1300
+ if(post.likes)bits.push(fmtNum(post.likes)+" likes");
1301
+ if(post.comments)bits.push(fmtNum(post.comments)+" comments");
1302
+ return bits.slice(0,2).join(" ยท ");
1303
+ }
1304
+
1305
+ /** Newest comment in a group, for the "recent first" ordering. */
1306
+ function groupNewest(t){
1307
+ var best=0;
1308
+ (t.mentions||[]).forEach(function(m){
1309
+ var d=new Date(String(m.postedAt||""));
1310
+ if(!isNaN(d.getTime())&&d.getTime()>best)best=d.getTime();
1311
+ });
1312
+ return best;
1313
+ }
1314
+
1315
+ function renderMonitor(){
1316
+ var app=document.getElementById("app");
1317
+ if(!app||!monitorState)return;
1318
+ var st=monitorState;
1319
+
1320
+ var keep=st.threads.filter(function(t){
1321
+ // A post with no matching comment earns its space only when the post
1322
+ // itself names the term; otherwise it is a card that says nothing.
1323
+ return Number(t.mentionCount||0)>0||!!t.postIsAboutTerm;
1324
+ });
1325
+ // Which platforms have something on screen โ€” not the same as which have a
1326
+ // comment. A post that names the term with no matching replies still shows.
1327
+ var onScreen={};
1328
+ keep.forEach(function(t){onScreen[String((t.post||{}).platform||"")]=true;});
1329
+ // A brand sweep filters by network, because it spans many. A review is one
1330
+ // post, so the network is a constant and the useful cut is the label the
1331
+ // model put on each comment โ€” which means filtering inside a group rather
1332
+ // than dropping whole ones.
1333
+ var shown=!st.filter?keep
1334
+ :st.byCategory
1335
+ ?keep.map(function(t){
1336
+ var kept=(t.mentions||[]).filter(function(m){return String(m.category||"")===st.filter;});
1337
+ return kept.length?{post:t.post,postIsAboutTerm:t.postIsAboutTerm,
1338
+ mentionCount:kept.length,mentions:kept}:null;
1339
+ }).filter(Boolean)
1340
+ :keep.filter(function(t){
1341
+ return String((t.post||{}).platform||"")===st.filter;
1342
+ });
1343
+ if(st.sort==="new"){
1344
+ shown=shown.slice().sort(function(a,b){return groupNewest(b)-groupNewest(a);});
1345
+ }
1346
+
1347
+ var order=Object.keys(st.counts).sort(function(a,b){return (st.counts[b]||0)-(st.counts[a]||0);});
1348
+ var live=order.filter(function(k){return Number(st.counts[k]||0)>0;});
1349
+ // A platform that returned nothing still gets a chip โ€” "we looked there and
1350
+ // it was quiet" is an answer โ€” but it is not a filter, because filtering to
1351
+ // it is a dead end.
1352
+ var chips=['<button type="button" class="mchip'+(st.filter?"":" on")
1353
+ +'" data-filter="">All <b>'+esc(String(st.total))+"</b></button>"]
1354
+ .concat(order.map(function(k){
1355
+ var n=Number(st.counts[k]||0),has=!!onScreen[k];
1356
+ return '<button type="button" class="mchip'+(st.filter===k?" on":"")+(has?"":" quiet")
1357
+ +'" data-filter="'+esc(k)+'"'+(has?"":" disabled")+' style="--brand:'+pColor(k)+'">'
1358
+ +'<span class="mchip-mark">'+pSvg(k,13)+"</span>"
1359
+ +esc(pTitle(k))+" <b>"+esc(String(n))+"</b></button>";
1360
+ })).join("");
1361
+
1362
+ var down=st.unavailable.length
1363
+ ? '<div class="mention-down">Could not reach '
1364
+ +st.unavailable.map(function(u){return esc(pTitle(u&&u.platform||"?"));}).join(", ")
1365
+ +" โ€” those networks may have more.</div>"
1366
+ : "";
1367
+
1368
+ var head='<div class="mhead">'
1369
+ +'<div class="mhead-top">'
1370
+ +'<div><div class="mention-term">'+(st.byCategory?"Comment review โ€” ":"Mentions of ")
1371
+ +"<b>"+esc(st.term)+"</b> "
1372
+ +(st.since?'<span class="mention-window">since '+esc(st.since)
1373
+ +(st.sinceApplied?"":" ยท not applied")
1374
+ +(st.undatedMentions>0?" ยท "+esc(String(st.undatedMentions))+" undated":"")
1375
+ +"</span>":"")+"</div>"
1376
+ +'<div class="mention-total">'+esc(String(st.total))+" comment"+(st.total===1?"":"s")
1377
+ +(st.byCategory
1378
+ ?(st.summary?"":"")
1379
+ :(live.length?" across "+esc(String(live.length))+" network"+(live.length===1?"":"s"):""))
1380
+ +"</div>"
1381
+ +(st.summary?'<div class="mention-summary">'+esc(st.summary)+"</div>":"")
1382
+ +"</div>"
1383
+ +'<div class="msort"><button type="button" class="msort-btn'+(st.sort==="loud"?" on":"")
1384
+ +'" data-sort="loud">Loudest</button>'
1385
+ +'<button type="button" class="msort-btn'+(st.sort==="new"?" on":"")
1386
+ +'" data-sort="new">Newest</button></div>'
1387
+ +"</div>"
1388
+ +(st.byCategory?reviewChips(st):'<div class="mchips">'+chips+"</div>")+down+"</div>";
1389
+
1390
+ if(!shown.length){
1391
+ app.innerHTML=head+'<div class="empty-state fade-in"><div class="icon">๐Ÿ”</div>'
1392
+ +'<div class="text">'+(st.filter?"Nothing from "+esc(pTitle(st.filter)):"Nobody has mentioned that yet")
1393
+ +"</div></div>";
1394
+ setTimeout(reportSize,50);
1395
+ return;
1396
+ }
1397
+
1398
+ var groups=shown.map(function(t){
1399
+ var post=t.post||{},plat=String(post.platform||""),about=!!t.postIsAboutTerm;
1400
+ var reach=postReach(post),link=String(post.externalUrl||"");
1401
+ var n=Number(t.mentionCount||0);
1402
+ var all=t.mentions||[];
1403
+ // A coordinated burst โ€” seven near-identical fan posts under one Weibo
1404
+ // thread is a real shape in this data โ€” should read as one loud thing,
1405
+ // not push everything else off the screen. Nothing is dropped: the rest
1406
+ // are one click away, and the click costs nothing.
1407
+ var key=groupKey(t);
1408
+ var open=expandedGroups[key],cut=open?all.length:Math.min(all.length,GROUP_PEEK);
1409
+ var mentions=all.slice(0,cut).map(function(m){
1410
+ // No invented handle. "@someone" reads as an account you could go and
1411
+ // look at, and there is none โ€” the platform withheld it, or the
1412
+ // commenter is deleted. An empty handle renders as a stated absence
1413
+ // below instead of a fabricated name. (No backticks in here: this
1414
+ // whole view is one TypeScript template literal, and one would end it.)
1415
+ var hits=Number(m.hits||1),id=String(m.id||"");
1416
+ var who=m.username==null?"":String(m.username).trim();
1417
+ var pic=String(m.avatarProxyUrl||m.avatarUrl||"");
1418
+ var picAlt=String(m.avatarUrl||"");
1419
+ // A face and a platform mark, then the words. Reading a feed of
1420
+ // strangers is mostly working out who is talking; a column of handles
1421
+ // makes you read every one of them to find out.
1422
+ var avatar='<span class="mention-av" style="--brand:'+pColor(plat)+'">'
1423
+ +'<span class="mention-av-init">'+esc(initialOf(who))+"</span>"
1424
+ +(pic?'<img class="mention-av-img" data-avatar src="'+esc(pic)+'"'
1425
+ +(picAlt&&picAlt!==pic?' data-fallback="'+esc(picAlt)+'"':"")
1426
+ +' alt="" loading="lazy"/>':"")
1427
+ +'<span class="mention-av-badge">'+pSvg(plat,9)+"</span></span>";
1428
+ return '<label class="mention" data-mention-id="'+esc(id)+'">'
1429
+ +'<input type="checkbox" class="mention-pick" data-mid="'+esc(id)+'"/>'
1430
+ +avatar
1431
+ +'<span class="mention-body">'
1432
+ +'<span class="mention-head">'
1433
+ +(who?'<span class="mention-who">@'+esc(who)+"</span>"
1434
+ :'<span class="mention-who mention-who-unknown">no handle</span>')
1435
+ +(m.postedAt?'<span class="mention-when">'+esc(friendlyTime(m.postedAt))+"</span>":"")
1436
+ +(hits>1?'<span class="mention-hits" title="names it '+hits+' times">ร—'+hits+"</span>":"")
1437
+ +"</span>"
1438
+ +'<span class="mention-text">'+highlightTerm(String(m.text||""),st.term)+"</span>"
1439
+ +(m.note?'<span class="mention-note">'+esc(String(m.note))+"</span>":"")
1440
+ +'<span class="mention-meta">'
1441
+ +'<span class="mention-stat">'+heartIcon()+esc(fmtNum(m.likes||0))+"</span>"
1442
+ +'<span class="mention-stat">'+replyIcon()+esc(fmtNum(m.replies||0))+"</span>"
1443
+ +labelChips(m)
1444
+ +"</span></span></label>";
1445
+ }).join("");
1446
+ return '<div class="mgroup" data-platform="'+esc(plat)+'">'
1447
+ +'<div class="mgroup-head">'
1448
+ +'<span class="mgroup-plat" style="--brand:'+pColor(plat)+'">'+pSvg(plat,12)+" "+esc(pTitle(plat))+"</span>"
1449
+ +(about?'<span class="mgroup-about">about '+esc(st.term)+"</span>":"")
1450
+ +(reach?'<span class="mgroup-reach">'+esc(reach)+"</span>":"")
1451
+ +"</div>"
1452
+ +'<div class="mgroup-title">'+esc(String(post.title||post.caption||"").slice(0,140))+"</div>"
1453
+ +'<div class="mgroup-actions">'
1454
+ +(link?'<a class="mgroup-link" href="'+esc(link)+'" target="_blank" rel="noopener">Open โ†—</a>':"")
1455
+ // A post can name the term while none of its comments do; there is
1456
+ // nothing to select there, so do not offer to select it.
1457
+ +(n?'<button type="button" class="mgroup-all" data-group-all="'+esc(key)+'">Select all '+esc(String(n))+"</button>":"")
1458
+ +"</div>"
1459
+ +(n?'<div class="mentions">'+mentions
1460
+ +(all.length>cut||open&&all.length>GROUP_PEEK
1461
+ ?'<button type="button" class="mgroup-rest" data-expand="'+esc(key)+'">'
1462
+ +(open?"Show fewer":"Show "+(all.length-cut)+" more from this post")+"</button>"
1463
+ :"")
1464
+ +"</div>"
1465
+ :'<div class="mgroup-none">The post names '+esc(st.term)+", but none of its comments do.</div>")
1466
+ +"</div>";
1467
+ }).join("");
1468
+
1469
+ var more=st.hasMore&&!st.filter
1470
+ ? '<button class="mention-more" type="button" data-next-offset="'+esc(String(st.nextOffset))
1471
+ +'" data-term="'+esc(st.term)+'">Load more mentions</button>'
1472
+ : "";
1473
+ app.innerHTML=head+'<div class="mgroups fade-in">'+groups+"</div>"+more+pickBarHtml();
1474
+ initMentionPicks();
1475
+ // Selections survive a filter or sort: narrowing the view is not unpicking.
1476
+ applyMentionPicks();
1477
+ setTimeout(reportSize,60);
1478
+ }
1479
+
1480
+ // Filter and sort redraw from memory rather than re-querying โ€” the view has
1481
+ // no way to call the tool again for free, and the user did not ask to spend.
1482
+ document.addEventListener("click",function(e){
1483
+ var chip=e.target.closest&&e.target.closest(".mchip");
1484
+ if(chip&&monitorState){
1485
+ monitorState.filter=chip.getAttribute("data-filter")||"";
1486
+ renderMonitor();
1487
+ return;
1488
+ }
1489
+ var sort=e.target.closest&&e.target.closest(".msort-btn");
1490
+ if(sort&&monitorState){
1491
+ monitorState.sort=sort.getAttribute("data-sort")||"loud";
1492
+ renderMonitor();
1493
+ return;
1494
+ }
1495
+ var rest=e.target.closest&&e.target.closest("[data-expand]");
1496
+ if(rest&&monitorState){
1497
+ var k=rest.getAttribute("data-expand")||"";
1498
+ expandedGroups[k]=!expandedGroups[k];
1499
+ renderMonitor();
1500
+ return;
1501
+ }
1502
+ var all=e.target.closest&&e.target.closest("[data-group-all]");
1503
+ if(all&&monitorState){
1504
+ // Over the group's comments, not its rendered rows: a collapsed comment
1505
+ // is still one the reader asked for when they said "all".
1506
+ var t=groupByKey(all.getAttribute("data-group-all")||"");
1507
+ if(!t)return;
1508
+ var ids=selectableMentions(t).map(function(m){return String(m.id||"");}).filter(Boolean);
1509
+ var turnOn=ids.some(function(id){return pickedMentions.indexOf(id)<0;});
1510
+ ids.forEach(function(id){
1511
+ var i=pickedMentions.indexOf(id);
1512
+ if(turnOn&&i<0)pickedMentions.push(id);
1513
+ if(!turnOn&&i>=0)pickedMentions.splice(i,1);
1514
+ });
1515
+ applyMentionPicks();
1516
+ }
1517
+ });
1518
+
1519
+ /**
1520
+ * search_spoken_mentions โ€” the mirror of the brand-monitoring screen above,
1521
+ * for a spoken line rather than a typed comment.
1522
+ *
1523
+ * The excerpt is the star: it is a direct quote of what was actually said,
1524
+ * with the term marked inside it exactly the way a comment's term match is
1525
+ * marked above (highlightTerm, reused verbatim โ€” same rose highlight, same
1526
+ * escaping discipline, because the text underneath is still a stranger's
1527
+ * words and must not be trusted with markup of its own). Each hit is one
1528
+ * video, identified the way a card identifies one everywhere else in this
1529
+ * template: platform, author, a thumbnail, its reach. And because the
1530
+ * expensive part of this tool is deciding how far to look rather than what
1531
+ * it found, the funnel โ€” candidates seen, transcribed, captioned, matched โ€”
1532
+ * is drawn every time, including on an empty result, so a person can tell
1533
+ * "nothing happened" apart from "nothing was said".
1534
+ */
1535
+ function renderSpoken(){
1536
+ var app=document.getElementById("app");
1537
+ if(!app||!spokenState)return;
1538
+ var st=spokenState;
1539
+
1540
+ // Tallied from the hits actually on screen, not asked for a second
1541
+ // field the backend does not send โ€” the same principle as onScreen in
1542
+ // renderMonitor.
1543
+ var counts={};
1544
+ st.hits.forEach(function(h){
1545
+ var p=String((h.post||{}).platform||"");
1546
+ counts[p]=(counts[p]||0)+1;
1547
+ });
1548
+ var order=st.platforms.slice().sort(function(a,b){return (counts[b]||0)-(counts[a]||0);});
1549
+ var chips=['<button type="button" class="mchip'+(st.filter?"":" on")
1550
+ +'" data-splatform="">All <b>'+esc(String(st.hits.length))+"</b></button>"]
1551
+ .concat(order.map(function(k){
1552
+ var n=Number(counts[k]||0);
1553
+ return '<button type="button" class="mchip'+(st.filter===k?" on":"")+(n?"":" quiet")
1554
+ +'" data-splatform="'+esc(k)+'"'+(n?"":" disabled")+' style="--brand:'+pColor(k)+'">'
1555
+ +'<span class="mchip-mark">'+pSvg(k,13)+"</span>"
1556
+ +esc(pTitle(k))+" <b>"+esc(String(n))+"</b></button>";
1557
+ })).join("");
1558
+
1559
+ var shown=!st.filter?st.hits.slice()
1560
+ :st.hits.filter(function(h){return String((h.post||{}).platform||"")===st.filter;});
1561
+ shown=shown.slice().sort(function(a,b){
1562
+ return st.sort==="views"
1563
+ ? (Number((b.post||{}).views)||0)-(Number((a.post||{}).views)||0)
1564
+ : (Number(b.matchCount)||0)-(Number(a.matchCount)||0)
1565
+ || (Number((b.post||{}).views)||0)-(Number((a.post||{}).views)||0);
1566
+ });
1567
+
1568
+ var funnel='<div class="stats" style="margin-top:10px">'
1569
+ +'<span class="stat-pill">'+mpIcon("eye",13)+" "+esc(String(st.considered))+" candidate"+(st.considered===1?"":"s")+" considered</span>"
1570
+ +'<span class="stat-pill">'+mpIcon("note",13)+" "+esc(String(st.transcribed))+" transcribed</span>"
1571
+ +'<span class="stat-pill">'+mpIcon("volume",13)+" "+esc(String(st.transcriptsAvailable))+" had captions</span>"
1572
+ +'<span class="stat-pill">'+mpIcon("check",13)+" "+esc(String(st.matched))+" matched</span>"
1573
+ +"</div>";
1574
+ var ceiling=st.ceilingReached
1575
+ ? '<div class="mention-down">Only the '+esc(String(st.maxTranscripts))+' most-viewed candidates were '
1576
+ +'transcribed โ€” '+esc(String(st.considered-st.maxTranscripts))+' more were found and never checked. '
1577
+ +'Raise maxTranscripts to look further.</div>'
1578
+ : "";
1579
+
1580
+ var head='<div class="mhead">'
1581
+ +'<div class="mhead-top">'
1582
+ +'<div><div class="mention-term">Spoken mentions of <b>'+esc(st.term)+"</b>"
1583
+ +(st.platforms.length?'<span class="mention-window">'+esc(st.platforms.map(pTitle).join(" + "))+"</span>":"")
1584
+ +"</div>"
1585
+ +'<div class="mention-total">'+esc(String(st.matched))+" video"+(st.matched===1?"":"s")+" say it out loud</div>"
1586
+ +"</div>"
1587
+ +'<div class="msort"><button type="button" class="msort-btn'+(st.sort==="loud"?" on":"")
1588
+ +'" data-ssort="loud">Loudest</button>'
1589
+ +'<button type="button" class="msort-btn'+(st.sort==="views"?" on":"")
1590
+ +'" data-ssort="views">Most viewed</button></div>'
1591
+ +"</div>"
1592
+ +funnel+ceiling
1593
+ +(order.length>1?'<div class="mchips">'+chips+"</div>":"")
1594
+ +"</div>";
1595
+
1596
+ if(!shown.length){
1597
+ var emptyText=st.filter?"Nothing from "+esc(pTitle(st.filter))
1598
+ :st.transcribed===0?"No candidate posts were found to transcribe"
1599
+ :"Nobody said it on camera in what was checked";
1600
+ app.innerHTML=head+'<div class="empty-state fade-in"><div class="icon">๐Ÿ”</div>'
1601
+ +'<div class="text">'+emptyText+"</div></div>";
1602
+ setTimeout(reportSize,50);
1603
+ return;
1604
+ }
1605
+
1606
+ var groups=shown.map(function(h){
1607
+ var post=h.post||{},plat=String(post.platform||"");
1608
+ var reach=postReach(post),link=String(post.externalUrl||"");
1609
+ var handle=String(post.creatorHandle||post.author||post.username||"").replace(/^@/,"");
1610
+ var title=String(post.title||post.caption||"").slice(0,160);
1611
+ var thumb=String(post.thumbnailUrl||post.thumbnailProxyUrl||"");
1612
+ var thumbFb=String((post.thumbnailUrl&&(post.thumbnailFallbackUrl||post.thumbnailProxyUrl))||"");
1613
+ var count=Number(h.matchCount||0);
1614
+ var excerpts=(h.excerpts||[]).map(function(ex){
1615
+ var text=String(ex.text||"");
1616
+ return '<div class="quote-box" style="margin:0">'
1617
+ +'<span class="mention-text" style="margin-top:0">'+highlightTerm(text,st.term)+"</span>"
1618
+ +'<button class="copy-btn" data-copy="'+esc(text)+'">Copy</button></div>';
1619
+ }).join("");
1620
+ var shownExcerpts=(h.excerpts||[]).length;
1621
+ var moreMatches=count-shownExcerpts;
1622
+ var meta=[];
1623
+ if(h.wordCount)meta.push('<span class="chip">'+esc(fmtNum(h.wordCount))+" words</span>");
1624
+ if(h.language)meta.push('<span class="chip">'+esc(String(h.language))+"</span>");
1625
+ if(h.autoGenerated)meta.push('<span class="chip">auto-generated</span>');
1626
+ return '<div class="mgroup" data-platform="'+esc(plat)+'">'
1627
+ +'<div class="mgroup-head">'
1628
+ +'<span class="mgroup-plat" style="--brand:'+pColor(plat)+'">'+pSvg(plat,12)+" "+esc(pTitle(plat))+"</span>"
1629
+ +(reach?'<span class="mgroup-reach">'+esc(reach)+"</span>":"")
1630
+ +"</div>"
1631
+ +'<div style="display:flex;gap:10px;align-items:flex-start">'
1632
+ +(thumb?'<img class="sm-thumb" src="'+esc(thumb)+'"'
1633
+ +(thumbFb&&thumbFb!==thumb?' data-fallback="'+esc(thumbFb)+'"':"")
1634
+ +' alt="" loading="lazy"/>':"")
1635
+ +'<div style="flex:1;min-width:0"><div class="mgroup-title">'
1636
+ +(handle?'<span style="color:var(--muted);font-weight:500">@'+esc(handle)+"</span> โ€” ":"")
1637
+ +esc(title)+"</div></div></div>"
1638
+ +'<div class="mgroup-actions">'
1639
+ +(link?'<a class="mgroup-link" href="'+esc(link)+'" target="_blank" rel="noopener">Open โ†—</a>':"")
1640
+ +'<span class="mention-hits" title="names it '+esc(String(count))+' times">ร—'+esc(String(count))+"</span>"
1641
+ +meta.join("")
1642
+ +"</div>"
1643
+ +'<div style="display:flex;flex-direction:column;gap:8px;margin-top:10px">'+excerpts
1644
+ +(moreMatches>0?'<div class="faint" style="font-size:11px;color:var(--muted)">+'
1645
+ +esc(String(moreMatches))+" more mention"+(moreMatches===1?"":"s")+" later in this video, not shown</div>":"")
1646
+ +"</div></div>";
1647
+ }).join("");
1648
+
1649
+ var unavail=st.unavailableCount
1650
+ ? '<div class="faint" style="font-size:11px;color:var(--muted);margin-top:14px;text-align:center">'
1651
+ +esc(String(st.unavailableCount))+" candidate"+(st.unavailableCount===1?"":"s")
1652
+ +" could not be reached or transcribed.</div>"
1653
+ : "";
1654
+
1655
+ app.innerHTML=head+'<div class="mgroups fade-in">'+groups+"</div>"+unavail;
1656
+ setTimeout(reportSize,60);
1657
+ }
1658
+
1659
+ // Same reasoning as renderMonitor's own delegate: redraw from memory, never
1660
+ // re-fetch. Distinct data attributes (data-splatform/data-ssort rather than
1661
+ // data-filter/data-sort) so this cannot be confused with the monitor's own
1662
+ // clicks even though both reuse the same .mchip/.msort-btn look.
1663
+ document.addEventListener("click",function(e){
1664
+ var chip=e.target.closest&&e.target.closest("[data-splatform]");
1665
+ if(chip&&spokenState){
1666
+ if(chip.hasAttribute("disabled"))return;
1667
+ spokenState.filter=chip.getAttribute("data-splatform")||"";
1668
+ renderSpoken();
1669
+ return;
1670
+ }
1671
+ var sort=e.target.closest&&e.target.closest("[data-ssort]");
1672
+ if(sort&&spokenState){
1673
+ spokenState.sort=sort.getAttribute("data-ssort")||"loud";
1674
+ renderSpoken();
1675
+ return;
1676
+ }
1677
+ });
1678
+
1679
+ /**
1680
+ * Make what is on screen agree with the selection โ€” after a redraw, and
1681
+ * after a select-all that reached comments no row is showing.
1682
+ */
1683
+ function applyMentionPicks(){
1684
+ document.querySelectorAll(".mention-pick").forEach(function(b){
1685
+ var on=pickedMentions.indexOf(b.getAttribute("data-mid")||"")>=0;
1686
+ b.checked=on;
1687
+ var row=b.closest(".mention");
1688
+ if(row)row.classList.toggle("picked",on);
1689
+ });
1690
+ document.querySelectorAll("[data-group-all]").forEach(function(b){
1691
+ var t=groupByKey(b.getAttribute("data-group-all")||"");
1692
+ if(!t)return;
1693
+ var ids=selectableMentions(t).map(function(m){return String(m.id||"");}).filter(Boolean);
1694
+ var allOn=ids.length&&ids.every(function(id){return pickedMentions.indexOf(id)>=0;});
1695
+ b.textContent=allOn?"Clear these":"Select all "+ids.length;
1696
+ });
1697
+ syncMentionBar();
1698
+ }
1699
+
1700
+ /**
1701
+ * Show where the term actually appears in a comment.
1702
+ *
1703
+ * Walked by index rather than matched by regex: this template is embedded in
1704
+ * a Rust raw string as well as a TS literal, and a backslash means different
1705
+ * things in the two โ€” so the file carries none at all. Escaping each slice
1706
+ * separately also means the mark cannot be injected by the comment text,
1707
+ * every word of which came from a stranger.
1708
+ */
1709
+ function highlightTerm(text,term){
1710
+ var src=String(text||""),t=String(term||"").trim();
1711
+ if(!t)return esc(src);
1712
+ var low=src.toLowerCase(),needle=t.toLowerCase(),out="",i=0;
1713
+ for(;;){
1714
+ var at=low.indexOf(needle,i);
1715
+ if(at<0){out+=esc(src.slice(i));break;}
1716
+ out+=esc(src.slice(i,at))+"<mark>"+esc(src.slice(at,at+needle.length))+"</mark>";
1717
+ i=at+needle.length;
1718
+ }
1719
+ return out;
1720
+ }
1721
+
1722
+ /**
1723
+ * The time the way someone would say it. A monitoring feed is read for
1724
+ * recency above all โ€” "today at 7:25 PM" answers the question the reader
1725
+ * actually has, which "2026-09-01T19:25:04Z" does not.
1726
+ */
1727
+ function friendlyTime(v){
1728
+ var s=String(v||"");
1729
+ if(!s)return "";
1730
+ var d=new Date(s);
1731
+ if(isNaN(d.getTime()))return s.slice(0,10);
1732
+ var now=new Date(),day=86400000;
1733
+ var clock=d.toLocaleTimeString([],{hour:"numeric",minute:"2-digit"});
1734
+ if(d.toDateString()===now.toDateString())return "today at "+clock;
1735
+ if(d.toDateString()===new Date(now.getTime()-day).toDateString())return "yesterday at "+clock;
1736
+ var days=Math.floor((now.getTime()-d.getTime())/day);
1737
+ if(days>0&&days<7)return days+" days ago";
1738
+ var date=d.toLocaleDateString([],{month:"short",day:"numeric"});
1739
+ return d.getFullYear()===now.getFullYear()?date:date+", "+d.getFullYear();
1740
+ }
1741
+
1742
+ function heartIcon(){
1743
+ return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-7.5-4.9-9.3-9A5.2 5.2 0 0 1 12 6.2 5.2 5.2 0 0 1 21.3 12c-1.8 4.1-9.3 9-9.3 9z"/></svg>';
1744
+ }
1745
+ function replyIcon(){
1746
+ return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 11.5a8.4 8.4 0 0 1-9 8.4 9.9 9.9 0 0 1-4.2-.9L3 20.5l1.6-4.6A8.2 8.2 0 0 1 3.6 11.5a8.4 8.4 0 0 1 9-8.4 8.4 8.4 0 0 1 8.4 8.4z"/></svg>';
1747
+ }
1748
+
1749
+ /** First letter of a handle, for when the picture will not load. */
1750
+ function initialOf(name){
1751
+ var s=String(name||"").replace("@","").trim();
1752
+ return s?s.charAt(0).toUpperCase():"?";
1753
+ }
1754
+
1755
+ /** "2026-08-18" rather than a full timestamp: the day is the useful part. */
1756
+ function shortDate(v){
1757
+ var s=String(v||"");
1758
+ if(!s)return "";
1759
+ var d=new Date(s);
1760
+ if(!isNaN(d.getTime()))return d.toISOString().slice(0,10);
1761
+ return s.slice(0,10);
1762
+ }
1763
+
1764
+ /**
1765
+ * Selected comments, so an agent can be handed exactly the ones a person
1766
+ * picked โ€” reply to these, escalate those. The ids come straight from the
1767
+ * tool result, so what the host receives addresses the same comments.
1768
+ */
1769
+ var pickedMentions=[];
1770
+ var mentionPicksWired=false;
1771
+ // Idempotent: the monitoring screen redraws on every filter and sort, and a
1772
+ // listener added per redraw would fire a selection several times over.
1773
+ function initMentionPicks(){
1774
+ if(!mentionPicksWired){
1775
+ mentionPicksWired=true;
1776
+ document.addEventListener("change",onMentionPick);
1777
+ document.addEventListener("click",function(e){
1778
+ var c=e.target.closest&&e.target.closest("#pickclear");
1779
+ if(!c||!pickedMentions.length)return;
1780
+ pickedMentions=[];
1781
+ applyMentionPicks();
1782
+ },true);
1783
+ }
1784
+ syncMentionBar();
1785
+ }
1786
+ function onMentionPick(e){
1787
+ var box=e.target;
1788
+ if(!box||!box.classList||!box.classList.contains("mention-pick"))return;
1789
+ var id=box.getAttribute("data-mid")||"";
1790
+ var i=pickedMentions.indexOf(id);
1791
+ if(box.checked&&i<0)pickedMentions.push(id);
1792
+ if(!box.checked&&i>=0)pickedMentions.splice(i,1);
1793
+ var row=box.closest(".mention");
1794
+ if(row)row.classList.toggle("picked",box.checked);
1795
+ syncMentionBar();
1796
+ }
1797
+ function syncMentionBar(){
1798
+ var bar=document.getElementById("pickbar");
1799
+ if(!bar)return;
1800
+ var n=document.getElementById("pickn");
1801
+ if(pickedMentions.length){
1802
+ bar.hidden=false;
1803
+ if(n)n.textContent=pickedMentions.length;
1804
+ var hint=document.getElementById("pickhint");
1805
+ if(hint)hint.textContent=pickedMentions.length===1?"1 comment selected":pickedMentions.length+" comments selected";
1806
+ var go=document.getElementById("pickgo");
1807
+ if(go){go.disabled=false;go.textContent="Analyse these";}
1808
+ }else{
1809
+ bar.hidden=true;
1810
+ }
1811
+ setTimeout(reportSize,60);
1812
+ }
1813
+
1814
+ // Selected comments go to the host as a tool call. analyze_comments's own
1815
+ // inputSchema is {url, limit?}.strict() โ€” one post, not an arbitrary
1816
+ // hand-picked set of texts/ids โ€” so this used to post {comments, ids}
1817
+ // regardless of how many posts the selection spanned, and a real host
1818
+ // executing that call got a schema rejection every time; the "Try in
1819
+ // chat"/timeout fallback was the only thing anyone ever saw work. Now:
1820
+ // find which post(s) the picks belong to, and if they are all the same
1821
+ // one, ask analyze_comments to re-read that post's comments (the nearest
1822
+ // thing to "analyse these" the tool can actually do); otherwise say why
1823
+ // not, rather than sending a call the schema will refuse.
1824
+ document.addEventListener("click",function(e){
1825
+ var go=e.target.closest&&e.target.closest("#pickgo");
1826
+ if(!go||!pickedMentions.length)return;
1827
+ if(!document.querySelector(".mention-pick"))return; // the compare bar owns it
1828
+ e.stopPropagation();
1829
+ var urls={};
1830
+ (monitorState?monitorState.threads:[]).forEach(function(t){
1831
+ var u=t.post&&(t.post.externalUrl||t.post.url);
1832
+ if(!u)return;
1833
+ (t.mentions||[]).forEach(function(m){
1834
+ if(pickedMentions.indexOf(String(m.id||""))>=0)urls[u]=true;
1835
+ });
1836
+ });
1837
+ var distinct=Object.keys(urls);
1838
+ if(distinct.length!==1){
1839
+ var was=go.textContent;
1840
+ go.textContent=distinct.length===0?"Couldn't find that post":"Pick comments from one post only";
1841
+ setTimeout(function(){go.textContent=was;},2200);
1842
+ return;
1843
+ }
1844
+ go.disabled=true;go.textContent="Sendingโ€ฆ";
1845
+ invokeTool("analyze_comments",{url:distinct[0],limit:Math.max(pickedMentions.length,20)},function(){
1846
+ go.textContent="Sent โœ“";
1847
+ setTimeout(function(){go.disabled=false;go.textContent="Analyse these";},1800);
1848
+ },function(){
1849
+ go.textContent="Try in chat";
1850
+ setTimeout(function(){go.disabled=false;go.textContent="Analyse these";},1800);
1851
+ });
1852
+ },true);
1853
+
1854
+ // Load more asks the host to re-run the tool with the next offset, which is
1855
+ // the only way a sandboxed view can page: it has no data of its own.
1856
+ document.addEventListener("click",function(e){
1857
+ var b=e.target.closest&&e.target.closest(".mention-more");
1858
+ if(!b||b.disabled)return;
1859
+ b.disabled=true;
1860
+ var was=b.textContent;
1861
+ b.textContent="Loadingโ€ฆ";
1862
+ invokeTool("search_mentions",{
1863
+ term:b.getAttribute("data-term")||"",
1864
+ offset:Number(b.getAttribute("data-next-offset")||0),
1865
+ },function(){ b.textContent="Loaded โœ“"; },function(){
1866
+ b.disabled=false;b.textContent=was;
1867
+ });
1868
+ });
1869
+
1870
+ function pSvg(p,size){
1871
+ var s=size||14,paths={
1872
+ reddit:"M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286A.72.72 0 001.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0zm4.388 3.199a1.999 1.999 0 11-1.947 2.46v.002a2.37 2.37 0 00-2.032 2.341v.007c1.334.065 2.559.371 3.591.923a1.998 1.998 0 112.086 3.372c.005.061.007.121.007.183 0 2.891-3.611 5.235-8.067 5.235-4.455 0-8.066-2.344-8.066-5.235 0-.062.002-.122.007-.183a1.998 1.998 0 112.086-3.372c1.032-.552 2.257-.858 3.591-.923v-.007a3.77 3.77 0 013.412-3.751 1.999 1.999 0 011.332-1.052zM9.25 12a1.25 1.25 0 100 2.5 1.25 1.25 0 000-2.5zm5.5 0a1.25 1.25 0 100 2.5 1.25 1.25 0 000-2.5zm-5.466 3.99a.327.327 0 00-.231.094.33.33 0 000 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 00.029-.463.33.33 0 00-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 00-.232-.095z",
1873
+ weibo:"M10.098 20.323c-3.977.391-7.414-1.406-7.672-4.02-.259-2.609 2.759-5.047 6.74-5.441 3.979-.394 7.413 1.404 7.671 4.018.259 2.6-2.759 5.049-6.737 5.439l-.002.004zM9.05 17.219c-.384.616-1.208.884-1.829.602-.612-.279-.793-.991-.406-1.593.379-.595 1.176-.861 1.793-.601.622.263.82.972.442 1.592zm1.027-1.32c-.141.237-.449.353-.689.253-.236-.09-.312-.359-.177-.586.138-.227.436-.346.672-.24.239.09.315.36.18.601l.014-.028zm.176-2.719c-1.893-.493-4.033.45-4.857 2.118-.836 1.704-.026 3.591 1.886 4.21 1.983.64 4.318-.341 5.132-2.179.8-1.793-.201-3.642-2.161-4.149zm7.563-1.224c-.346-.105-.57-.18-.405-.615.375-.977.42-1.804 0-2.4-.781-1.112-2.915-1.053-5.364-.03 0 0-.766.331-.571-.271.376-1.217.315-2.224-.27-2.815-1.336-1.335-4.891.045-7.94 3.089C1.009 11.104 0 13.499 0 15.564c0 3.951 5.067 6.36 10.028 6.36 6.504 0 10.834-3.782 10.834-6.78 0-1.809-1.529-2.835-2.897-3.264l-.149-.03v.106zm2.482-6.135c-1.502-1.666-3.716-2.305-5.767-1.869a.914.914 0 00-.705 1.086.916.916 0 001.086.705c1.454-.309 3.03.15 4.096 1.327 1.061 1.18 1.363 2.79.9 4.2a.918.918 0 00.585 1.155.913.913 0 001.155-.585c.66-1.984.238-4.25-1.259-5.909l-.091-.11zm-2.204 1.995c-.732-.81-1.812-1.125-2.81-.915a.789.789 0 10.33 1.545c.489-.105 1.02.045 1.38.45.359.401.464.945.315 1.425a.795.795 0 00.51.996.79.79 0 00.996-.51c.301-.976.09-2.086-.646-2.895l-.075-.096z",
1874
+ tiktok:"M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z",
1875
+ instagram:"M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077",
1876
+ youtube:"M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z",
1877
+ x:"M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z",
1878
+ twitter:"M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z",
1879
+ douyin:"M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z",
1880
+ xiaohongshu:"M22.405 9.879c.002.016.01.02.07.019h.725a.797.797 0 0 0 .78-.972.794.794 0 0 0-.884-.618.795.795 0 0 0-.692.794c0 .101-.002.666.001.777zm-11.509 4.808c-.203.001-1.353.004-1.685.003a2.528 2.528 0 0 1-.766-.126.025.025 0 0 0-.03.014L7.7 16.127a.025.025 0 0 0 .01.032c.111.06.336.124.495.124.66.01 1.32.002 1.981 0 .01 0 .02-.006.023-.015l.712-1.545a.025.025 0 0 0-.024-.036zM.477 9.91c-.071 0-.076.002-.076.01a.834.834 0 0 0-.01.08c-.027.397-.038.495-.234 3.06-.012.24-.034.389-.135.607-.026.057-.033.042.003.112.046.092.681 1.523.787 1.74.008.015.011.02.017.02.008 0 .033-.026.047-.044.147-.187.268-.391.371-.606.306-.635.44-1.325.486-1.706.014-.11.021-.22.03-.33l.204-2.616.022-.293c.003-.029 0-.033-.03-.034zm7.203 3.757a1.427 1.427 0 0 1-.135-.607c-.004-.084-.031-.39-.235-3.06a.443.443 0 0 0-.01-.082c-.004-.011-.052-.008-.076-.008h-1.48c-.03.001-.034.005-.03.034l.021.293c.076.982.153 1.964.233 2.946.05.4.186 1.085.487 1.706.103.215.223.419.37.606.015.018.037.051.048.049.02-.003.742-1.642.804-1.765.036-.07.03-.055.003-.112zm3.861-.913h-.872a.126.126 0 0 1-.116-.178l1.178-2.625a.025.025 0 0 0-.023-.035l-1.318-.003a.148.148 0 0 1-.135-.21l.876-1.954a.025.025 0 0 0-.023-.035h-1.56c-.01 0-.02.006-.024.015l-.926 2.068c-.085.169-.314.634-.399.938a.534.534 0 0 0-.02.191.46.46 0 0 0 .23.378.981.981 0 0 0 .46.119h.59c.041 0-.688 1.482-.834 1.972a.53.53 0 0 0-.023.172.465.465 0 0 0 .23.398c.15.092.342.12.475.12l1.66-.001c.01 0 .02-.006.023-.015l.575-1.28a.025.025 0 0 0-.024-.035zm-6.93-4.937H3.1a.032.032 0 0 0-.034.033c0 1.048-.01 2.795-.01 6.829 0 .288-.269.262-.28.262h-.74c-.04.001-.044.004-.04.047.001.037.465 1.064.555 1.263.01.02.03.033.051.033.157.003.767.009.938-.014.153-.02.3-.06.438-.132.3-.156.49-.419.595-.765.052-.172.075-.353.075-.533.002-2.33 0-4.66-.007-6.991a.032.032 0 0 0-.032-.032zm11.784 6.896c0-.014-.01-.021-.024-.022h-1.465c-.048-.001-.049-.002-.05-.049v-4.66c0-.072-.005-.07.07-.07h.863c.08 0 .075.004.075-.074V8.393c0-.082.006-.076-.08-.076h-3.5c-.064 0-.075-.006-.075.073v1.445c0 .083-.006.077.08.077h.854c.075 0 .07-.004.07.07v4.624c0 .095.008.084-.085.084-.37 0-1.11-.002-1.304 0-.048.001-.06.03-.06.03l-.697 1.519s-.014.025-.008.036c.006.01.013.008.058.008 1.748.003 3.495.002 5.243.002.03-.001.034-.006.035-.033v-1.539zm4.177-3.43c0 .013-.007.023-.02.024-.346.006-.692.004-1.037.004-.014-.002-.022-.01-.022-.024-.005-.434-.007-.869-.01-1.303 0-.072-.006-.071.07-.07l.733-.003c.041 0 .081.002.12.015.093.025.16.107.165.204.006.431.002 1.153.001 1.153zm2.67.244a1.953 1.953 0 0 0-.883-.222h-.18c-.04-.001-.04-.003-.042-.04V10.21c0-.132-.007-.263-.025-.394a1.823 1.823 0 0 0-.153-.53 1.533 1.533 0 0 0-.677-.71 2.167 2.167 0 0 0-1-.258c-.153-.003-.567 0-.72 0-.07 0-.068.004-.068-.065V7.76c0-.031-.01-.041-.046-.039H17.93s-.016 0-.023.007c-.006.006-.008.012-.008.023v.546c-.008.036-.057.015-.082.022h-.95c-.022.002-.028.008-.03.032v1.481c0 .09-.004.082.082.082h.913c.082 0 .072.128.072.128V11.19s.003.117-.06.117h-1.482c-.068 0-.06.082-.06.082v1.445s-.01.068.064.068h1.457c.082 0 .076-.006.076.079v3.225c0 .088-.007.081.082.081h1.43c.09 0 .082.007.082-.08v-3.27c0-.029.006-.035.033-.035l2.323-.003c.098 0 .191.02.28.061a.46.46 0 0 1 .274.407c.008.395.003.79.003 1.185 0 .259-.107.367-.33.367h-1.218c-.023.002-.029.008-.028.033.184.437.374.871.57 1.303a.045.045 0 0 0 .04.026c.17.005.34.002.51.003.15-.002.517.004.666-.01a2.03 2.03 0 0 0 .408-.075c.59-.18.975-.698.976-1.313v-1.981c0-.128-.01-.254-.034-.38 0 .078-.029-.641-.724-.998z",
1881
+ bilibili:"M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z",
1882
+ linkedin:"M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z",
1883
+ unknown:"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"
1884
+ };
1885
+ var d=paths[p]||paths.unknown;
1886
+ return '<svg viewBox="0 0 24 24" width="'+s+'" height="'+s+'" fill="currentColor" style="vertical-align:-2px;flex-shrink:0;display:inline-block" aria-hidden="true"><path d="'+d+'"/></svg>';
1887
+ }
1888
+
1889
+ function copyText(text){
1890
+ navigator.clipboard.writeText(text).catch(function(){});
1891
+ }
1892
+
1893
+ // โ”€โ”€โ”€ Post Card โ”€โ”€โ”€
1894
+ // โ”€โ”€โ”€ Media player markup โ”€โ”€โ”€
1895
+ // Video and slideshow render the SAME shell: same overlay furniture, same
1896
+ // control bar, same order. Only the stage contents differ โ€” a <video>, or a
1897
+ // stack of slides with the post's track behind them. Everything below the
1898
+ // stage is byte-for-byte identical between the two, which is the point:
1899
+ // controls must not move or change shape from one post to the next.
1900
+ var mpId=0;
1901
+ // Aspect class of the most recent player, so the enclosing card can adopt it.
1902
+ var lastPlayerAr="v";
1903
+ var lastVideoFallback="";
1904
+ function mediaPlayerHtml(p,images,video,platform,pickable){
1905
+ var id="mp"+(mpId++);
1906
+ var pickUrl=p.externalUrl||p.url||"";
1907
+ pickable=!!pickable&&!!pickUrl;
1908
+ // A photo post carries its slides AND a server-rendered slideshow video.
1909
+ // Judging by "no video" alone picked that video, so the stage was a black
1910
+ // rectangle while the separate audio element played on - the caller saw a
1911
+ // black screen with music. contentType is what actually says photo post,
1912
+ // and postCard already worked that out before calling this.
1913
+ var ctype=p.contentType||"";
1914
+ var isSlideshow=images.length>1&&(ctype==="slideshow"||ctype==="carousel"||!video);
1915
+ var music=p.musicUrl||p.musicProxyUrl||"";
1916
+ var musicFallback=(p.musicUrl&&(p.musicFallbackUrl||p.musicProxyUrl))||"";
1917
+ var thumbFallback=(p.thumbnailUrl&&(p.thumbnailFallbackUrl||p.thumbnailProxyUrl))||"";
1918
+ var thumb=p.thumbnailUrl||p.thumbnailProxyUrl||"";
1919
+ var songLabel=[p.musicTitle||"",p.musicAuthor||""].filter(Boolean).join(" ยท ");
1920
+
1921
+ // Shape: vertical is the short-form default; long-form and the desktop
1922
+ // networks are wide; carousels on those networks are square.
1923
+ var fmt=p.detectedFormat||"",dur=Number(p.duration)||0,ar="v";
1924
+ var wide=(platform==="youtube"&&!(dur>0&&dur<=60))||platform==="linkedin"||platform==="twitter"||platform==="x";
1925
+ if(fmt==="Long-form")wide=true;
1926
+ if(fmt==="Short-form")wide=false;
1927
+ if(wide)ar=isSlideshow?"sq":"h";
1928
+
1929
+ // Stage
1930
+ var stage="";
1931
+ if(video&&!isSlideshow){
1932
+ // A /media/resolve link is not a CDN URL: the server downloads the
1933
+ // whole video with yt-dlp before it answers. "metadata" preload starts
1934
+ // that for every card on screen, unprompted - eight Bilibili results
1935
+ // meant eight full downloads nobody asked for, which is what gets us
1936
+ // rate limited. Those wait for play; a real CDN URL still preloads,
1937
+ // because there it costs a range request.
1938
+ var pre=video.indexOf("/media/resolve")>=0?"none":"metadata";
1939
+ stage='<video src="'+esc(video)+'" preload="'+pre+'" playsinline'
1940
+ +(thumb?' poster="'+esc(thumb)+'"':"")
1941
+ +(thumbFallback?' data-poster-fallback="'+esc(thumbFallback)+'"':"")+"></video>";
1942
+ }else{
1943
+ // A failed slide had nowhere to go. The thumbnail carries a fallback and
1944
+ // self-heals through the resolver; slides carried none, so one dead
1945
+ // image left a black stage with the music still listed underneath and no
1946
+ // way back - permanently, since nothing retries. The post cover is not
1947
+ // the right slide, but it is the right shape and it heals the same way,
1948
+ // which beats a black card.
1949
+ var slideFb=p.thumbnailFallbackUrl||p.thumbnailProxyUrl||p.thumbnailUrl||"";
1950
+ for(var i=0;i<images.length;i++)
1951
+ stage+='<img class="mp-slide'+(i===0?" active":"")+'" src="'+esc(images[i])
1952
+ +'" alt="slide '+(i+1)+'" loading="'+(i===0?"eager":"lazy")+'"'
1953
+ +(slideFb&&slideFb!==images[i]?' data-fallback="'+esc(slideFb)+'"':"")+"/>";
1954
+ }
1955
+
1956
+ // Overlay: brand badge, slide counter, engagement rail, caption block.
1957
+ var acts=[["heart",p.likes,"likes"],["comment",p.comments,"comments"],
1958
+ ["share",p.shares,"shares"],["eye",p.views,"views"]]
1959
+ .filter(function(a){return Number(a[1])>0;})
1960
+ .map(function(a){
1961
+ return '<div class="mp-act" title="'+fmtNum(a[1])+" "+a[2]+'">'
1962
+ +'<div class="ic">'+mpIcon(a[0],19)+"</div>"
1963
+ +'<div class="n">'+fmtNum(a[1])+"</div></div>";
1964
+ }).join("");
1965
+ var handle=p.creatorHandle?'<div class="mp-handle">@'+esc(p.creatorHandle)+"</div>":"";
1966
+ var capText=p.caption||p.title||"";
1967
+ var cap=capText?'<div class="mp-cap">'+esc(capText)+"</div>":"";
1968
+ var song=songLabel||music
1969
+ ? '<div class="mp-song"><span class="note">'+mpIcon("note",12)+"</span>"
1970
+ +'<span class="mp-song-t">'+esc(songLabel||"original sound")+"</span></div>"
1971
+ : "";
1972
+ var counter=isSlideshow?'<div class="mp-chip"><span class="mp-idx">1</span>/'+images.length+"</div>":"";
1973
+ var navs=isSlideshow
1974
+ ? '<button class="mp-nav prev" type="button" data-dir="-1" aria-label="Previous slide">'+mpIcon("prev",20)+"</button>"
1975
+ +'<button class="mp-nav next" type="button" data-dir="1" aria-label="Next slide">'+mpIcon("next",20)+"</button>"
1976
+ : "";
1977
+
1978
+ // Seek widget: one segment for video, one per slide for a slideshow.
1979
+ var segCount=isSlideshow?images.length:1,segs="";
1980
+ for(var k=0;k<segCount;k++)segs+='<div class="mp-seg"><i></i></div>';
1981
+
1982
+ var label=(p.creatorHandle?"@"+p.creatorHandle+" โ€” ":"")+(isSlideshow?"slideshow":"video");
1983
+ lastPlayerAr=ar;
1984
+ return '<div class="mp '+ar+'" id="'+id+'" data-mp="'+(isSlideshow?"slides":"video")+'"'
1985
+ +(Number(p.duration)>0?' data-mp-dur="'+Math.floor(Number(p.duration))+'"':"")
1986
+ +(lastVideoFallback?' data-mp-fallback="'+esc(lastVideoFallback)+'"':"")
1987
+ +(isSlideshow?' data-slides="'+images.length+'"':"")
1988
+ +' tabindex="0" role="group" aria-label="'+esc(label)+'">'
1989
+ +'<div class="mp-stage">'+stage
1990
+ +'<div class="mp-spin" hidden><i></i></div>'
1991
+ +'<div class="mp-err" hidden>'+mpIcon("warn",22)
1992
+ +"<span>This media could not be loaded</span></div>"
1993
+ +"</div>"
1994
+ +'<div class="mp-ov">'
1995
+ +'<div class="mp-badge">'+pSvg(platform,12)+" "+esc(platform)+"</div>"
1996
+ +(pickable?'<button class="mp-pick" type="button" aria-pressed="false" data-pick-id="'+id+'" data-pick="'+esc(pickUrl)+'" title="Select for comparison">'+mpIcon("check",15)+"</button>":"")
1997
+ +(pickUrl?'<a class="mp-open'+(pickable?" with-pick":"")+'" href="'+esc(pickUrl)+'" target="_blank" rel="noopener" title="Open on '+esc(platform)+'" aria-label="Open on '+esc(platform)+'">'+mpIcon("open",14)+"</a>":"")
1998
+ +counter+navs
1999
+ +'<div class="mp-rail">'+acts+"</div>"
2000
+ +'<div class="mp-meta">'+handle+cap+song+"</div>"
2001
+ +'<button class="mp-tap" type="button" aria-label="Play"><span>'+mpIcon("play",28)+"</span></button>"
2002
+ +"</div>"
2003
+ +'<div class="mp-ctl">'
2004
+ +'<button class="mp-btn mp-play" type="button" aria-label="Play">'+mpIcon("play",16)+"</button>"
2005
+ +'<span class="mp-time mp-cur">0:00</span>'
2006
+ +'<div class="mp-seek" role="slider" tabindex="-1" aria-label="Seek"'
2007
+ +' aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">'
2008
+ +'<div class="mp-segs">'+segs+"</div></div>"
2009
+ +'<span class="mp-time mp-dur">0:00</span>'
2010
+ +'<button class="mp-icon mp-mute" type="button" aria-label="Mute" aria-pressed="false">'
2011
+ +mpIcon("volume",17)+"</button>"
2012
+ +"</div>"
2013
+ +(music?'<audio preload="metadata" src="'+esc(music)+'"'
2014
+ +(musicFallback?' data-fallback="'+esc(musicFallback)+'"':"")+"></audio>":"")
2015
+ +"</div>";
2016
+ }
2017
+
2018
+ /**
2019
+ * The one line a competitor sweep or an underperformance check actually
2020
+ * exists to produce โ€” ratio to the creator's own median, and the verdict
2021
+ * that ratio earns. track_competitor and why_did_this_underperform attach
2022
+ * "standing" to every post they score (see performance.ts), and until this
2023
+ * existed postCard rendered the same view/like/comment pills any gallery
2024
+ * gets, so the computed comparison a person paid for never reached the
2025
+ * screen โ€” only the chat text carried it. Renders nothing when "standing"
2026
+ * is absent, which is every other tool's posts.
2027
+ */
2028
+ function standingBadge(p){
2029
+ var st=p.standing;
2030
+ if(!st||typeof st!=="object"||st.ratio===null||st.ratio===undefined)return "";
2031
+ var ratio=Number(st.ratio),verdict=String(st.verdict||"");
2032
+ var sc=verdict==="breakout"||verdict==="above_baseline"?"var(--green)"
2033
+ :verdict==="below_baseline"||verdict==="flop"?"var(--red)"
2034
+ :verdict==="no_baseline"?"var(--muted)":"var(--amber)";
2035
+ var label={breakout:"Breakout",above_baseline:"Above baseline",typical:"Typical",
2036
+ below_baseline:"Below baseline",flop:"Underperformed",no_baseline:"No baseline"}[verdict]||"";
2037
+ return '<div style="padding:7px 12px;display:flex;align-items:center;gap:7px;'
2038
+ +'font-size:12.5px;border-top:1px solid var(--border)">'
2039
+ +'<span style="font-weight:700;color:'+sc+'">'+ratio+"ร— median</span>"
2040
+ +(label?'<span style="color:var(--muted)">'+esc(label)+"</span>":"")
2041
+ +"</div>";
2042
+ }
2043
+
2044
+ function postCard(p,wide,pickable,hideActions){
2045
+ var platform=p.platform||"unknown",color=pColor(platform),brandSvg=pSvg(platform);
2046
+ var tcolor=pColorText(platform);
2047
+ var title=p.title||p.caption||"",handle=p.creatorHandle||"",url=p.externalUrl||"";
2048
+ var thumb=p.thumbnailUrl||p.thumbnailProxyUrl||"",ct=p.contentType||"post";
2049
+ var views=p.views||0,likes=p.likes||0,comments=p.comments||0,shares=p.shares||0;
2050
+ var cls=wide?"card card-wide":"card";
2051
+ var statsHtml=[[views,"eye"],[likes,"heart"],[comments,"comment"],[shares,"share"]]
2052
+ .filter(function(s){return s[0]>0})
2053
+ .map(function(s){return'<span class="stat-pill">'+mpIcon(s[1],13)+" "+fmtNum(s[0])+"</span>";}).join("");
2054
+ var handleHtml=handle?'<span class="handle">@'+esc(handle)+"</span>":"";
2055
+ var linkHtml=url?'<a href="'+esc(url)+'" target="_blank" class="btn" style="background:'+color+'">View on '+esc(platform)+' โ†’</a>':"";
2056
+ // Prefer video playback over a static thumbnail when the post has one
2057
+ // (videoUrl, or a video mediaItem preview_url). Videos are proxied /
2058
+ // re-hosted to a permanent nooticr URL so they play inside the CSP.
2059
+ var video="",images=[];
2060
+ // Play the platform URL directly. Going through /media/proxy did not fix
2061
+ // playback in practice, and the direct link is one less hop that can fail.
2062
+ // The proxied companion is kept as a fallback: the player retries with it
2063
+ // once if the direct URL errors, which covers the CDNs that refuse a
2064
+ // cross-origin request from this frame.
2065
+ var rawVideo=typeof p.videoUrl==="string"?p.videoUrl:"";
2066
+ if(rawVideo)video=rawVideo;
2067
+ else if(typeof p.videoProxyUrl==="string"&&p.videoProxyUrl)video=p.videoProxyUrl;
2068
+ // Some feeds carry no stream at all - Douyin's search endpoint returns a
2069
+ // permalink and a poster and nothing else - but the post detail does. The
2070
+ // resolver fetches it on demand, so the card plays instead of sitting
2071
+ // there as a still image. Only offered where resolution actually works.
2072
+ else if(typeof p.videoFallbackUrl==="string"&&p.videoFallbackUrl
2073
+ &&(p.contentType==="video"||Number(p.duration)>0))video=p.videoFallbackUrl;
2074
+ // The retry target: the resolver re-fetches the post and hands back a
2075
+ // fresh link, which is the only thing that helps once the signed URL has
2076
+ // expired - a proxy of a dead link is just as dead.
2077
+ lastVideoFallback=(rawVideo&&(p.videoFallbackUrl||p.videoProxyUrl))||"";
2078
+ if(p.mediaItems&&p.mediaItems.length){
2079
+ for(var i=0;i<p.mediaItems.length;i++){
2080
+ var m=p.mediaItems[i];
2081
+ // "proxy_url" is the same asset served through Nooticr โ€” the raw
2082
+ // preview_url is a signed CDN link that this frame can't load.
2083
+ var mu=m.proxy_url||m.preview_url;
2084
+ if(m.kind==="video"&&mu){if(!video)video=mu;}
2085
+ else if(m.kind==="image"&&mu&&images.length<12)images.push(mu);
2086
+ }
2087
+ }
2088
+ var mediaHtml="";
2089
+ var vh=wide?"340px":"200px";
2090
+ var bodyText=p.caption||p.text||"";
2091
+ var isSlideshow=(ct==="slideshow"||ct==="carousel")&&images.length>1;
2092
+ // Anything playable โ€” a video or a slideshow โ€” goes through the one
2093
+ // player, so the controls are the same on every card.
2094
+ if(video||isSlideshow||images.length>1){
2095
+ mediaHtml=mediaPlayerHtml(p,images,video,platform,pickable);
2096
+ // The player already carries the handle, caption and stats as overlay
2097
+ // chrome, so the card body underneath is just the outbound link.
2098
+ return '<div class="'+cls+' card-media '+lastPlayerAr+'">'+mediaHtml
2099
+ +standingBadge(p)
2100
+ +"</div>"
2101
+ // Only on the single-post view โ€” a gallery of these would be noise.
2102
+ +(wide&&!hideActions?postAiActions(p):"");
2103
+ }
2104
+ if(thumb){
2105
+ var tfb=(p.thumbnailUrl&&(p.thumbnailFallbackUrl||p.thumbnailProxyUrl))||"";
2106
+ mediaHtml='<img src="'+esc(thumb)+'" alt="thumbnail" loading="lazy"'
2107
+ +(tfb?' data-fallback="'+esc(tfb)+'"':"")+"/>";
2108
+ }else if(bodyText){
2109
+ // Text-only post (LinkedIn / X) โ€” styled quote block.
2110
+ mediaHtml='<div style="padding:16px 18px;border-bottom:1px solid var(--border);background:var(--card)">'
2111
+ +'<div style="font-size:13px;line-height:1.55;color:var(--fg);white-space:pre-line">'+esc(bodyText.length>400?bodyText.slice(0,400)+"โ€ฆ":bodyText)+'</div></div>';
2112
+ }
2113
+ var ctTag=wide?'<span style="font-size:12px;color:var(--muted);text-transform:capitalize">'+esc(ct)+"</span>":"";
2114
+ return '<div class="'+cls+'">'+mediaHtml+'<div class="card-body">'
2115
+ +'<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">'
2116
+ +'<span class="badge" style="background:'+color+'15;color:'+tcolor+';display:inline-flex;align-items:center;gap:4px">'+brandSvg+" "+esc(platform)+"</span>"
2117
+ +ctTag+handleHtml+"</div>"
2118
+ +'<div class="title">'+esc(title.length>160?title.slice(0,160)+"โ€ฆ":title)+"</div>"
2119
+ +'<div class="stats">'+statsHtml+"</div>"
2120
+ +linkHtml+"</div>"+standingBadge(p)+"</div>";
2121
+ }
2122
+
2123
+ // โ”€โ”€โ”€ Creator Card โ”€โ”€โ”€
2124
+ function creatorCard(c){
2125
+ var platform=c.platform||"tiktok",color=pColor(platform),brandSvg=pSvg(platform,22);
2126
+ var username=c.username||c.uniqueId||"",nickname=c.nickname||c.displayName||username;
2127
+ var followers=c.followers||c.followerCount||0,sig=(c.signature||c.bio||"").slice(0,100);
2128
+ var verified=c.verified||false,avatar=c.avatarUrl||c.avatar_thumb||"";
2129
+ var avatarHtml=avatar
2130
+ ?'<img class="avatar" src="'+esc(avatar)+'" alt="avatar" loading="lazy"/>'
2131
+ :'<div class="avatar-placeholder" style="background:'+color+'20">'+brandSvg+"</div>";
2132
+ var vBadge=verified?'<span class="verified" style="background:'+color+'">โœ“</span>':"";
2133
+ return '<div class="creator-card">'
2134
+ +'<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">'
2135
+ +avatarHtml+"<div>"
2136
+ +'<div style="display:flex;align-items:center"><span style="font-size:14px;font-weight:700">'+esc(nickname)+"</span>"+vBadge+"</div>"
2137
+ +'<div class="handle">@'+esc(username)+"</div></div></div>"
2138
+ +'<div style="font-size:13px;color:var(--fg);font-weight:600;margin-bottom:6px">'+fmtNum(followers)+" followers</div>"
2139
+ // Parenthesised: + binds tighter than the ternary, so without these the whole
2140
+ // concatenation became the condition and the function returned only the
2141
+ // bio โ€” no card, no avatar, no name, no follower count.
2142
+ +(sig?'<div style="font-size:12px;color:var(--muted);line-height:1.4">'+esc(sig)+"</div>":"")
2143
+ +vetting(c)
2144
+ +"</div>";
2145
+ }
2146
+
2147
+ // โ”€โ”€โ”€ Vetting strip โ”€โ”€โ”€
2148
+ //
2149
+ // Only show_collab_shortlist sends these fields, so every other creator card
2150
+ // โ€” search_creators, get_similar_creators, who_should_i_work_with โ€” renders
2151
+ // exactly as it did before. The score is the calling model's, never ours,
2152
+ // and the card says so on its face: a number next to a real person's name
2153
+ // that reads as a nooticr rating would be a claim we have not made and
2154
+ // cannot stand behind.
2155
+ function vetting(c){
2156
+ var hasScore=typeof c.score==="number";
2157
+ if(!hasScore&&!c.verdict&&!c.why&&!(c.links&&c.links.length))return "";
2158
+ var out='<div style="margin-top:10px;padding-top:9px;border-top:1px solid var(--border)">';
2159
+
2160
+ if(hasScore||c.verdict||c.rank){
2161
+ var vColor=c.verdict==="approach"?"var(--green)":c.verdict==="pass"?"var(--muted)":"var(--fg)";
2162
+ out+='<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px">'
2163
+ +(c.rank?'<span style="font-size:11px;font-weight:700;color:var(--muted)">#'+esc(String(c.rank))+"</span>":"")
2164
+ +(hasScore?'<span style="font-size:17px;font-weight:700;line-height:1">'+esc(String(Math.round(c.score)))
2165
+ +'<span style="font-size:11px;font-weight:600;color:var(--muted)">/100</span></span>':"")
2166
+ +(c.verdict?'<span style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:'+vColor
2167
+ +';border:1px solid '+vColor+';border-radius:4px;padding:1px 6px">'+esc(String(c.verdict))+"</span>":"")
2168
+ +"</div>";
2169
+ // Whose number this is, stated wherever a number is shown.
2170
+ if(hasScore){
2171
+ out+='<div style="font-size:10.5px;color:var(--muted);margin-bottom:6px">Scored by '
2172
+ +esc(String(c.scoredBy||"the assistant"))
2173
+ +(c.unverifiedScore?" ยท nothing was opened to reach this":"")+"</div>";
2174
+ }
2175
+ }
2176
+
2177
+ if(c.why)out+='<div style="font-size:12.5px;line-height:1.45;margin-bottom:6px">'+esc(String(c.why))+"</div>";
2178
+
2179
+ if(c.checked&&c.checked.length){
2180
+ out+='<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Read: '
2181
+ +c.checked.slice(0,4).map(function(x){return esc(String(x));}).join(" ยท ")+"</div>";
2182
+ }
2183
+
2184
+ if(c.concerns&&c.concerns.length){
2185
+ out+=c.concerns.slice(0,3).map(function(x){
2186
+ return '<div style="font-size:11.5px;line-height:1.4;color:var(--fg);margin-bottom:3px">โš  '+esc(String(x))+"</div>";
2187
+ }).join("");
2188
+ }
2189
+
2190
+ if(c.links&&c.links.length){
2191
+ // Deliberately not anchors. These hosts came out of a bio a stranger
2192
+ // controls and are not on the platform-link allowlist, so a host that
2193
+ // enforces it would block the navigation silently and a host that did
2194
+ // not would follow an arbitrary URL from inside a widget. Shown as text
2195
+ // so a person can see what is there and decide themselves.
2196
+ out+='<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:7px">'
2197
+ +c.links.slice(0,6).map(function(l){
2198
+ var opaque=!!l.opaque;
2199
+ return '<span title="'+esc(String(l.readable||""))+'" style="font-size:10.5px;padding:1px 6px;border-radius:4px;'
2200
+ +'border:1px '+(opaque?"dashed":"solid")+' var(--border);color:var(--muted)">'
2201
+ +esc(String(l.kind||"link"))+" ยท "+esc(String(l.host||""))+(opaque?" ?":"")+"</span>";
2202
+ }).join("")+"</div>";
2203
+ }
2204
+ return out+"</div>";
2205
+ }
2206
+
2207
+ // โ”€โ”€โ”€ Expandable Section โ”€โ”€โ”€
2208
+ function section(label,contentHtml,openByDefault){
2209
+ var id="sec_"+Math.random().toString(36).slice(2,8);
2210
+ return '<div class="section">'
2211
+ +'<div class="section-header'+(openByDefault?" open":"")+'" onclick="toggleSection(this)">'
2212
+ +'<span class="section-label">'+esc(label)+'</span>'
2213
+ +'<span class="section-chevron">โ–ผ</span></div>'
2214
+ +'<div class="section-content'+(openByDefault?" open":"")+'">'+contentHtml+'</div></div>';
2215
+ }
2216
+
2217
+ // โ”€โ”€โ”€ Analysis Card โ”€โ”€โ”€
2218
+ // โ”€โ”€โ”€ AI analysis โ”€โ”€โ”€
2219
+ // Ordered for someone reading in a chat panel: the verdict in one glance,
2220
+ // then the things they can act on or copy, then reference material folded
2221
+ // away. Every field the model produces is surfaced somewhere โ€” the analysis
2222
+ // is the expensive part of the call, so discarding two thirds of it in the
2223
+ // view (as this previously did) is throwing away what the user paid for.
2224
+ function analysisCard(d){
2225
+ var a=d.analysis||{};
2226
+ var postHtml=postCard(d.post||d,true,false,true);
2227
+ var out="";
2228
+
2229
+ // โ”€โ”€ Verdict strip โ”€โ”€
2230
+ var meters="";
2231
+ if(a.hookStrength!=null)meters+=meter("Hook strength",Number(a.hookStrength));
2232
+ if(a.commentBaitLevel!=null)meters+=meter("Comment bait",Number(a.commentBaitLevel));
2233
+ if(meters)out+='<div class="verdict">'+meters+"</div>";
2234
+
2235
+ var chips=[];
2236
+ if(a.niche)chips.push(chip(a.niche,"eye"));
2237
+ if(a.trendAlignment)chips.push(chip(a.trendAlignment,"share"));
2238
+ if(a.audioTrack)chips.push(chip(a.audioTrack,"note"));
2239
+ if(a.callToAction)chips.push(chip(a.callToAction,"comment"));
2240
+ if(chips.length)out+='<div class="chiprow">'+chips.join("")+"</div>";
2241
+
2242
+ if(a.summary)out+='<div class="lede-box">'+esc(a.summary)+"</div>";
2243
+
2244
+ // โ”€โ”€ Script structure: the most actionable single field โ”€โ”€
2245
+ var sc=a.scriptStructure;
2246
+ if(sc&&(sc.hook||sc.buildUp||sc.payoff||sc.cta)){
2247
+ var steps=[["Hook",sc.hook],["Build-up",sc.buildUp],["Payoff",sc.payoff],["CTA",sc.cta]]
2248
+ .filter(function(x){return x[1];})
2249
+ .map(function(x){
2250
+ return '<div class="step-row"><span class="step-k">'+esc(x[0])+"</span>"
2251
+ +'<span class="step-v">'+esc(x[1])+"</span></div>";
2252
+ }).join("");
2253
+ out+=section("Script structure",'<div class="steps-list">'+steps+"</div>",true);
2254
+ }
2255
+
2256
+ if(a.whyItWorks)out+=section("Why it works",expandable(a.whyItWorks),true);
2257
+
2258
+ // โ”€โ”€ Copyables: what a strategist actually lifts from the card โ”€โ”€
2259
+ if(a.suggestedHook)out+=section("Suggested hook",quote(a.suggestedHook),true);
2260
+ if(a.keyQuotes&&a.keyQuotes.length)
2261
+ out+=section("Quotable lines",a.keyQuotes.slice(0,6).map(quote).join(""));
2262
+ if(a.suggestedHashtags&&a.suggestedHashtags.length){
2263
+ var tags=a.suggestedHashtags.slice(0,12).map(function(t){
2264
+ var c=String(t).replace(/^#/,"");
2265
+ return '<button class="tag copyable" data-copy="#'+esc(c)+'">#'+esc(c)+"</button>";
2266
+ }).join("");
2267
+ out+=section("Hashtags",'<div class="chiprow">'+tags
2268
+ +'<button class="tag copyable" data-copy="'+esc(a.suggestedHashtags.map(function(t){return "#"+String(t).replace(/^#/,"");}).join(" "))+'">Copy all</button></div>',true);
2269
+ }
2270
+
2271
+ // โ”€โ”€ Who it is for โ”€โ”€
2272
+ var ta=a.targetAudience;
2273
+ if(ta&&typeof ta==="object"){
2274
+ var rows=[["Demographics",ta.demographics],["Psychographics",ta.psychographics],["Interests",ta.interests]]
2275
+ .filter(function(x){return x[1];})
2276
+ .map(function(x){return '<div class="step-row"><span class="step-k">'+esc(x[0])+'</span><span class="step-v">'+esc(x[1])+"</span></div>";}).join("");
2277
+ if(rows)out+=section("Target audience",'<div class="steps-list">'+rows+"</div>");
2278
+ }else if(typeof ta==="string"&&ta){
2279
+ out+=section("Target audience",'<div class="section-text">'+esc(ta)+"</div>");
2280
+ }
2281
+
2282
+ // โ”€โ”€ On-screen text: per-scene for slideshows โ”€โ”€
2283
+ if(a.overlayTexts&&a.overlayTexts.length){
2284
+ var ov=a.overlayTexts.slice(0,20).map(function(t,i){
2285
+ return '<div class="step-row"><span class="step-k">'+(i+1)+'</span><span class="step-v">'+esc(t)+"</span></div>";
2286
+ }).join("");
2287
+ out+=section("On-screen text",'<div class="steps-list">'+ov+"</div>");
2288
+ }else if(a.overlayText){
2289
+ out+=section("On-screen text",'<div class="section-text">'+esc(a.overlayText)+"</div>");
2290
+ }
2291
+
2292
+ // โ”€โ”€ Signals โ”€โ”€
2293
+ if(a.viralTriggers&&a.viralTriggers.length)
2294
+ out+=section("Viral triggers",'<div class="chiprow">'
2295
+ +a.viralTriggers.slice(0,10).map(function(t){return '<button class="tag copyable" data-copy="'+esc(t)+'">'+esc(t)+"</button>";}).join("")+"</div>");
2296
+ var es=a.engagementSignals;
2297
+ if(es&&typeof es==="object"&&(es.commentSentiment||es.shareMotivation)){
2298
+ var er=[["Comments","commentSentiment"],["Why shared","shareMotivation"]]
2299
+ .filter(function(x){return es[x[1]];})
2300
+ .map(function(x){return '<div class="step-row"><span class="step-k">'+esc(x[0])+'</span><span class="step-v">'+esc(es[x[1]])+"</span></div>";}).join("");
2301
+ out+=section("Engagement signals",'<div class="steps-list">'+er+"</div>");
2302
+ }
2303
+ if(a.emotionalArc)out+=section("Emotional arc",'<div class="section-text">'+esc(a.emotionalArc)+"</div>");
2304
+
2305
+ // โ”€โ”€ What to fix, what to try โ”€โ”€
2306
+ if(a.negativeSignals&&a.negativeSignals.length)
2307
+ out+=section("Weaknesses",'<div class="chiprow">'
2308
+ +a.negativeSignals.slice(0,8).map(function(n){return '<span class="tag warn">'+esc(n)+"</span>";}).join("")+"</div>",true);
2309
+ if(a.variationIdeas&&a.variationIdeas.length)
2310
+ out+=section("Variations to try",bullets(a.variationIdeas,8));
2311
+
2312
+ // โ”€โ”€ Reference, folded away โ”€โ”€
2313
+ if(a.formatBreakdown)out+=section("Format breakdown",expandable(a.formatBreakdown));
2314
+ if(a.visualStyle)out+=section("Visual style",'<div class="section-text">'+esc(a.visualStyle)+"</div>");
2315
+ if(a.whatHappens)out+=section("What happens",expandable(a.whatHappens));
2316
+ if(a.coreNarrative)out+=section("Core narrative",expandable(a.coreNarrative));
2317
+ if(a.transcript)out+=section("Transcript",
2318
+ '<div class="section-text" style="white-space:pre-wrap">'+esc(a.transcript)+"</div>"
2319
+ +'<button class="tag copyable" style="margin-top:8px" data-copy="'+esc(a.transcript)+'">Copy transcript</button>');
2320
+ if(a.brandMentions&&a.brandMentions.length)
2321
+ out+=section("Brands mentioned",'<div class="chiprow">'+a.brandMentions.map(function(b){return '<span class="tag">'+esc(b)+"</span>";}).join("")+"</div>");
2322
+ if(a.contentSafetyFlags&&a.contentSafetyFlags.length)
2323
+ out+=section("Brand safety",'<div class="chiprow">'+a.contentSafetyFlags.map(function(f){return '<span class="tag warn">'+esc(f)+"</span>";}).join("")+"</div>");
2324
+
2325
+ // Turn the analysis into the next piece of work rather than ending on it.
2326
+ var src=(d.post||d);
2327
+ var srcUrl=src&&(src.externalUrl||src.url);
2328
+ var follow=srcUrl?'<div class="ai-actions">'
2329
+ +aiBtn("create_variants","Create variants",JSON.stringify({url:srcUrl}))
2330
+ +aiBtn("write_hooks","More hooks",JSON.stringify({url:srcUrl}))
2331
+ +aiBtn("repurpose_post","Repurpose",JSON.stringify({url:srcUrl}))
2332
+ +aiBtn("analyze_comments","Read comments",JSON.stringify({url:srcUrl}))
2333
+ +'</div><div class="ai-note">Each action runs a tool and spends the credits shown.</div>':"";
2334
+
2335
+ if(!out)return postHtml;
2336
+ return postHtml+'<div class="card card-wide fade-in" style="margin-top:10px"><div class="card-body">'
2337
+ +'<div class="an-title">'+mpIcon("eye",16)+"<span>AI analysis</span></div>"+out+follow+"</div></div>";
2338
+ }
2339
+
2340
+ /** 0-10 score as a labelled bar. */
2341
+ function meter(label,val){
2342
+ var v=Math.max(0,Math.min(10,Number(val)||0));
2343
+ var c=v>=7?"var(--green)":v>=4?"var(--amber)":"var(--red)";
2344
+ return '<div class="meter"><div class="meter-top"><span>'+esc(label)+"</span>"
2345
+ +'<b style="color:'+c+'">'+v+"/10</b></div>"
2346
+ +'<div class="meter-track"><i style="width:'+(v*10)+"%;background:"+c+'"></i></div></div>';
2347
+ }
2348
+
2349
+ function chip(text,icon){
2350
+ return '<button class="tag copyable" data-copy="'+esc(text)+'">'
2351
+ +(icon?mpIcon(icon,11):"")+"<span>"+esc(text)+"</span></button>";
2352
+ }
2353
+
2354
+ function quote(text){
2355
+ return '<div class="quote-box">'+esc(text)
2356
+ +'<button class="copy-btn" data-copy="'+esc(text)+'">Copy</button></div>';
2357
+ }
2358
+
2359
+ function bullets(arr,cap){
2360
+ return '<ul class="an-list">'+arr.slice(0,cap||6).map(function(x){
2361
+ return "<li>"+esc(x)+"</li>";}).join("")+"</ul>";
2362
+ }
2363
+
2364
+ /** Long prose: clamp with an expander instead of cutting it off. */
2365
+ function expandable(text){
2366
+ var t=String(text||"");
2367
+ if(t.length<=260)return '<div class="section-text">'+esc(t)+"</div>";
2368
+ var id="x"+(mpId++);
2369
+ return '<div class="section-text clamp6" data-x="'+id+'">'+esc(t)+"</div>"
2370
+ +'<button class="cmore" data-xmore="'+id+'">Show more</button>';
2371
+ }
2372
+
2373
+
2374
+ function extractResult(result){
2375
+ if(!result||typeof result!=="object")return result;
2376
+ var sc=result.structuredContent;
2377
+ if(sc&&typeof sc==="object"&&!Array.isArray(sc))return sc;
2378
+ if(Array.isArray(result.content)){
2379
+ var textBlock=null;
2380
+ for(var i=0;i<result.content.length;i++){
2381
+ var c=result.content[i];
2382
+ if(c&&c.type==="text"&&typeof c.text==="string"){textBlock=c.text;break;}
2383
+ }
2384
+ if(typeof textBlock==="string"&&textBlock!==""){
2385
+ // Split "<html> {json}" - if there is a JSON object after the HTML,
2386
+ // parse it; otherwise show the text itself. Deliberately not a regex:
2387
+ // this template is embedded in both a Rust raw string and a TS
2388
+ // template literal, and a backslash survives the first but is eaten by
2389
+ // the second, so the escaped pattern this used to carry matched
2390
+ // nothing in the copy the Worker serves.
2391
+ var NL=String.fromCharCode(10);
2392
+ for(var gap=textBlock.indexOf(NL+NL);gap!==-1;gap=textBlock.indexOf(NL+NL,gap+1)){
2393
+ var tail=textBlock.slice(gap+2).trim();
2394
+ if(tail.charAt(0)!=="{")continue;
2395
+ try{var parsed=JSON.parse(tail);if(parsed&&typeof parsed==="object")return parsed;}catch(e){}
2396
+ }
2397
+ // Single-line HTML (common): keep the whole block as text fallback.
2398
+ if(textBlock.indexOf("<")===0){return { _html: textBlock };}
2399
+ try{var j=JSON.parse(textBlock);if(j&&typeof j==="object")return j;}catch(e2){}
2400
+ }
2401
+ }
2402
+ return result;
2403
+ }
2404
+
2405
+ var galleryId=0;
2406
+ function galleryWrap(cardsHtml,count){
2407
+ var id="gal"+(galleryId++);
2408
+ if(count<=1)return cardsHtml;
2409
+ var dots="";
2410
+ for(var i=0;i<count;i++)dots+='<button class="dot'+(i===0?" active":"")+'" data-g="'+id+'" data-i="'+i+'"></button>';
2411
+ return '<div class="gallery-wrap fade-in">'
2412
+ +'<button class="gallery-nav prev" data-gnav="'+id+'" data-dir="-1">โ€น</button>'
2413
+ +'<div class="gallery" id="'+id+'">'+cardsHtml+'</div>'
2414
+ +'<button class="gallery-nav next" data-gnav="'+id+'" data-dir="1">โ€บ</button>'
2415
+ +'<div class="gallery-dots" id="'+id+'-dots">'+dots+'</div>'
2416
+ +'</div>';
2417
+ }
2418
+ function initGalleryNav(){
2419
+ document.addEventListener('click',function(e){
2420
+ var navBtn=e.target.closest('.gallery-nav[data-gnav]');
2421
+ if(navBtn){
2422
+ var g=document.getElementById(navBtn.getAttribute('data-gnav'));
2423
+ if(!g)return;
2424
+ var dir=Number(navBtn.getAttribute('data-dir'));
2425
+ var cardW=g.querySelector('.card')?.offsetWidth||380;
2426
+ g.scrollBy({left:dir*(cardW+12),behavior:'smooth'});
2427
+ return;
2428
+ }
2429
+ var dotBtn=e.target.closest('.dot[data-g]');
2430
+ if(dotBtn){
2431
+ var gId=dotBtn.getAttribute('data-g');
2432
+ var g=document.getElementById(gId);
2433
+ var dots=document.getElementById(gId+'-dots');
2434
+ if(!g||!dots)return;
2435
+ var idx=Number(dotBtn.getAttribute('data-i'));
2436
+ var cards=g.querySelectorAll('.card');
2437
+ if(cards[idx])cards[idx].scrollIntoView({behavior:'smooth',block:'nearest',inline:'start'});
2438
+ dots.querySelectorAll('.dot').forEach(function(d){d.classList.remove('active')});
2439
+ dotBtn.classList.add('active');
2440
+ }
2441
+ });
2442
+ document.addEventListener('scroll',function(e){
2443
+ if(!e.target.classList||!e.target.classList.contains('gallery'))return;
2444
+ var g=e.target;var gId=g.id;
2445
+ var dots=document.getElementById(gId+'-dots');
2446
+ if(!dots)return;
2447
+ var cards=g.querySelectorAll('.card');
2448
+ if(!cards.length)return;
2449
+ var scrollLeft=g.scrollLeft;var cardW=cards[0].offsetWidth+12;
2450
+ var active=Math.round(scrollLeft/cardW);
2451
+ active=Math.max(0,Math.min(active,cards.length-1));
2452
+ dots.querySelectorAll('.dot').forEach(function(d,i){d.classList.toggle('active',i===active)});
2453
+ },true);
2454
+ }
2455
+ initGalleryNav();
2456
+
2457
+ // Every render replaces the DOM wholesale, so player wiring has to run
2458
+ // after it โ€” one wrapper instead of a call at each of the many early
2459
+ // returns below.
2460
+ function render(result){
2461
+ try{renderView(result);}finally{initPlayers();setTimeout(reportSize,80);}
2462
+ }
2463
+
2464
+ function renderView(result){
2465
+ var app=document.getElementById("app");if(!app)return;
2466
+ var d=extractResult(result);
2467
+ if(!d||typeof d!=="object"){app.innerHTML='<div class="json-block fade-in">'+esc(JSON.stringify(d,null,2))+"</div>";return;}
2468
+ // Raw HTML-only result โ€” inject it directly.
2469
+ if(d._html){app.innerHTML=d._html;setTimeout(reportSize,50);return;}
2470
+
2471
+ // Analysis
2472
+ if(d.analysis&&(d.post||d.url)){app.innerHTML=analysisCard(d);return;}
2473
+ // Post comparison
2474
+ if(d.comparison!==undefined&&d.posts&&Array.isArray(d.posts)){
2475
+ var c=d.comparison||{},win=Number(c.winner)||0;
2476
+ var cards=d.posts.map(function(p,i){
2477
+ var isw=win===i+1;
2478
+ return '<div style="padding:10px 12px;border:1px solid '+(isw?"var(--green)":"var(--border)")+';border-radius:10px;margin-bottom:8px">'
2479
+ +'<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px"><span style="font-size:11px;font-weight:700;color:var(--muted)">#'+(i+1)+"</span>"
2480
+ +(isw?'<span style="font-size:10px;font-weight:700;color:var(--green);border:1px solid var(--green);border-radius:4px;padding:0 5px">BEST</span>':"")+"</div>"
2481
+ +'<div style="font-size:13px;line-height:1.4">'+esc((p.title||p.caption||"").slice(0,140))+"</div>"
2482
+ +'<div style="font-size:11.5px;color:var(--muted);margin-top:4px">'+mpIcon("eye",11)+" "+fmtNum(p.views||0)+" ยท "+mpIcon("heart",11)+" "+fmtNum(p.likes||0)+"</div></div>";
2483
+ }).join("");
2484
+ var diffs=(c.differences||[]).slice(0,5).map(function(x){
2485
+ return '<div style="padding:7px 0;border-bottom:1px solid var(--border)"><span style="font-size:12.5px;font-weight:600">'+esc(x.factor||"")+"</span>"
2486
+ +'<div style="font-size:12.5px;color:var(--muted);margin-top:1px">'+esc(x.detail||"")+"</div></div>";
2487
+ }).join("");
2488
+ var lessons=(c.lessons||[]).length?'<div style="margin-top:14px"><div class="sec-label">Lessons</div><ul style="margin:0;padding-left:18px;font-size:13px;color:var(--muted)">'
2489
+ +c.lessons.slice(0,4).map(function(l){return '<li style="margin:4px 0">'+esc(l)+"</li>";}).join("")+"</ul></div>":"";
2490
+ app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
2491
+ +'<div style="font-size:16px;font-weight:700;margin-bottom:10px">โš–๏ธ Comparing '+d.posts.length+" posts</div>"
2492
+ +cards
2493
+ +(c.winnerReason?'<div style="font-size:13px;margin-top:10px"><b>Why it won:</b> '+esc(c.winnerReason)+"</div>":"")
2494
+ +(diffs?'<div style="margin-top:14px"><div class="sec-label">What differed</div>'+diffs+"</div>":"")
2495
+ +lessons
2496
+ +(c.nextTest?'<div style="margin-top:14px;padding:10px 12px;background:var(--tag);border-radius:10px;font-size:13px"><b>Next test:</b> '+esc(c.nextTest)+"</div>":"")
2497
+ +"</div></div>";
2498
+ return;}
2499
+
2500
+ // Brand monitoring.
2501
+ //
2502
+ // This screen is a triage surface, not a feed. The job is to see what needs
2503
+ // answering, how loud it is, and where โ€” so three things drive the layout.
2504
+ //
2505
+ // The comment leads and its metadata sits under it, because the comment is
2506
+ // what you are reading; who said it matters only once the words have caught
2507
+ // your eye. The post carries its reach, because the same sentence under a
2508
+ // 25K-upvote thread and under one nobody saw are not the same problem, and
2509
+ // nothing else on screen tells you which you are looking at. And the counts
2510
+ // are filters rather than decoration: a nine-platform sweep is unreadable
2511
+ // as one list, and the chips were already the obvious place to narrow it.
2512
+ //
2513
+ // Deliberately not here: a sentiment badge. Nothing in this payload says
2514
+ // whether a comment is angry, and colouring it green or red from a keyword
2515
+ // guess would be confidently wrong about the one thing you are scanning
2516
+ // for. The signals shown are the ones that are real โ€” reach, repetition,
2517
+ // recency, likes โ€” and the reading is left to the model.
2518
+ if(d.term!==undefined&&Array.isArray(d.threads)){
2519
+ monitorState={
2520
+ term:String(d.term||""),
2521
+ threads:d.threads,
2522
+ counts:(d.byPlatform&&typeof d.byPlatform==="object")?d.byPlatform:{},
2523
+ since:d.since?String(d.since):"",
2524
+ // The badge used to print "since <date>" whenever a window was asked
2525
+ // for, which asserted a filter had happened. Most networks do not date
2526
+ // their comments, so it often had not. These two carry the truth.
2527
+ sinceApplied:!!d.sinceApplied,
2528
+ undatedMentions:Number(d.undatedMentions||0),
2529
+ total:Number(d.totalMentions||0),
2530
+ unavailable:Array.isArray(d.unavailable)?d.unavailable:[],
2531
+ hasMore:!!d.hasMore,
2532
+ nextOffset:d.nextOffset,
2533
+ // Present only on a show_comment_review: the model's own labels, and
2534
+ // the counts they roll up to.
2535
+ byCategory:(d.byCategory&&typeof d.byCategory==="object")?d.byCategory:null,
2536
+ summary:d.summary?String(d.summary):"",
2537
+ filter:"",
2538
+ sort:"loud",
2539
+ };
2540
+ // A new term addresses different comments, so a selection made against
2541
+ // the old one is stale rather than carried over.
2542
+ if(monitorTerm!==monitorState.term){monitorTerm=monitorState.term;pickedMentions=[];}
2543
+ renderMonitor();
2544
+ return;
2545
+ }
2546
+
2547
+ // search_spoken_mentions โ€” term + a grouped list, like the brand sweep
2548
+ // above, but of transcript hits ("hits") rather than comment threads
2549
+ // ("threads"), so it draws through its own surface rather than that one.
2550
+ if(d.term!==undefined&&Array.isArray(d.hits)){
2551
+ spokenState={
2552
+ term:String(d.term||""),
2553
+ platforms:Array.isArray(d.platforms)?d.platforms.map(String):[],
2554
+ hits:d.hits,
2555
+ considered:Number(d.candidatesConsidered||0),
2556
+ transcribed:Number(d.transcribed||0),
2557
+ transcriptsAvailable:Number(d.transcriptsAvailable||0),
2558
+ matched:Number(d.matched||0),
2559
+ maxTranscripts:Number(d.maxTranscripts||0),
2560
+ ceilingReached:!!d.ceilingReached,
2561
+ unavailableCount:Array.isArray(d.unavailable)?d.unavailable.length:0,
2562
+ filter:"",
2563
+ sort:"loud",
2564
+ };
2565
+ renderSpoken();
2566
+ return;
2567
+ }
2568
+
2569
+ // Posts gallery
2570
+ if(d.posts&&Array.isArray(d.posts)){
2571
+ if(!d.posts.length){app.innerHTML='<div class="empty-state fade-in"><div class="icon">๐Ÿ”</div><div class="text">No posts found</div></div>';return;}
2572
+ app.innerHTML=galleryWrap(d.posts.map(function(p){return postCard(p,false,true);}).join(""),d.posts.length)+pickBarHtml();initPicks();return;}
2573
+ // Creators
2574
+ if(d.creators&&Array.isArray(d.creators)){
2575
+ if(!d.creators.length){app.innerHTML='<div class="empty-state fade-in"><div class="icon">๐Ÿ‘ค</div><div class="text">No creators found</div></div>';return;}
2576
+ app.innerHTML=galleryWrap(d.creators.map(function(c){return creatorCard(c);}).join(""),d.creators.length);return;}
2577
+ // Transcript
2578
+ if(d.transcript!==undefined||d.available!==undefined&&d.wordCount!==undefined){
2579
+ // A real transcript string is itself evidence of availability โ€”
2580
+ // generate_captions returns {transcript, cues, ...} with no
2581
+ // available flag at all, and used to render "No transcript
2582
+ // available." over real captions because of it.
2583
+ if(!d.available&&!d.transcript){
2584
+ app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
2585
+ +'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700;margin-bottom:6px">'+mpIcon("note",16)+"<span>Transcript</span></div>"
2586
+ +'<div class="muted" style="font-size:13px;color:var(--muted)">'+esc(d.reason||"No transcript available.")+"</div></div></div>";
2587
+ return;}
2588
+ var wc=d.wordCount||(d.transcript?String(d.transcript).trim().split(/\s+/).filter(Boolean).length:0);
2589
+ var meta=[wc+" words"];
2590
+ if(d.language)meta.push(esc(d.language));
2591
+ if(d.autoGenerated)meta.push("auto-generated");
2592
+ app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
2593
+ +'<div style="display:flex;align-items:center;justify-content:space-between;gap:8px">'
2594
+ +'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700">'+mpIcon("note",16)+"<span>Transcript</span></div>"
2595
+ +'<button class="btn btn-sm copyable" style="background:var(--tag);color:var(--fg)" data-copy="'+esc(d.transcript||"")+'">Copy</button></div>'
2596
+ +'<div class="faint" style="font-size:11.5px;color:var(--muted);margin:3px 0 12px">'+meta.join(" ยท ")+"</div>"
2597
+ +'<div style="font-size:13.5px;line-height:1.65;white-space:pre-wrap">'+esc(d.transcript||"")+"</div>"
2598
+ +"</div></div>";
2599
+ return;}
2600
+
2601
+ // Trending hashtags
2602
+ if(d.hashtags&&Array.isArray(d.hashtags)){
2603
+ if(!d.hashtags.length){app.innerHTML='<div class="empty-state fade-in"><div class="icon">#</div><div class="text">No trending hashtags found</div></div>';return;}
2604
+ var hr=d.hashtags.slice(0,30).map(function(t){
2605
+ var dir=t.trend==="rising"?["โ–ฒ","var(--green)"]:t.trend==="cooling"?["โ–ผ","var(--red)"]:["โ–ฌ","var(--muted)"];
2606
+ return '<a href="'+esc(t.url||"#")+'" target="_blank" rel="noopener" class="comment-row" style="text-decoration:none">'
2607
+ +'<div style="flex:1;min-width:0"><div style="font-size:13.5px;font-weight:600">#'+esc(t.hashtag)+"</div>"
2608
+ +'<div style="font-size:11.5px;color:var(--muted)">'+fmtNum(t.posts||0)+" posts ยท "+fmtNum(t.views||0)+" views</div></div>"
2609
+ +'<span style="font-size:11px;font-weight:700;color:'+dir[1]+';white-space:nowrap">'+dir[0]+" "+esc(t.trend||"")+"</span></a>";
2610
+ }).join("");
2611
+ app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
2612
+ +'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700">#<span>Trending hashtags</span></div>'
2613
+ +'<div class="faint" style="font-size:11.5px;color:var(--muted);margin:2px 0 10px">TikTok ยท '+esc(d.country||"US")+" ยท last "+(d.days||7)+" days</div>"
2614
+ +hr+"</div></div>";
2615
+ return;}
2616
+
2617
+ // Comment analysis
2618
+ if(d.report&&(d.commentsAnalyzed!==undefined)){
2619
+ var r=d.report;
2620
+ var col=r.sentiment==="positive"?"var(--green)":r.sentiment==="negative"?"var(--red)":"var(--amber)";
2621
+ var list=function(arr,label){
2622
+ if(!arr||!arr.length)return "";
2623
+ return '<div style="margin-top:14px"><div class="sec-label">'+esc(label)+'</div><ul style="margin:0;padding-left:18px;font-size:13px;color:var(--muted)">'
2624
+ +arr.slice(0,6).map(function(x){return '<li style="margin:4px 0">'+esc(x)+"</li>";}).join("")+"</ul></div>";
2625
+ };
2626
+ var th=(r.topThemes||[]).slice(0,5).map(function(t){
2627
+ return '<div style="padding:8px 0;border-bottom:1px solid var(--border-soft,var(--border))">'
2628
+ +'<div style="font-size:13px;font-weight:600">'+esc(t.theme||"")+"</div>"
2629
+ +'<div style="font-size:12.5px;color:var(--muted);margin-top:2px">'+esc(t.summary||"")+"</div></div>";
2630
+ }).join("");
2631
+ app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
2632
+ +'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700;margin-bottom:8px">'+mpIcon("comment",16)+"<span>Comment analysis</span></div>"
2633
+ +'<span style="display:inline-block;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:'+col+';border:1px solid '+col+';border-radius:999px;padding:2px 10px">'+esc(r.sentiment||"")+"</span>"
2634
+ +'<div style="font-size:13px;margin-top:8px">'+esc(r.sentimentNote||"")+"</div>"
2635
+ +(th?'<div style="margin-top:14px"><div class="sec-label">Themes</div>'+th+"</div>":"")
2636
+ +list(r.questions,"Questions asked")
2637
+ +list(r.objections,"Objections")
2638
+ +list(r.contentRequests,"Content requests")
2639
+ +list(r.nextVideoIdeas,"Next video ideas")
2640
+ +'<div class="faint" style="font-size:11px;color:var(--muted);margin-top:14px">Based on '+(d.commentsAnalyzed||0)+" comments</div>"
2641
+ +"</div></div>";
2642
+ return;}
2643
+
2644
+ // Comments
2645
+ if(d.comments&&Array.isArray(d.comments)){
2646
+ // Themes first: on a post with thousands of comments, "what do people
2647
+ // keep saying" is the answer someone actually wants. The raw list is
2648
+ // the evidence underneath it.
2649
+ var themes="";
2650
+ if(d.themes&&d.themes.length){
2651
+ themes='<div style="margin-bottom:14px"><div class="sec-label">What people keep mentioning</div><div>'
2652
+ +d.themes.slice(0,12).map(function(t){
2653
+ return '<span class="theme-chip">'+esc(t.keyword||t)
2654
+ +(t.count?"<b>"+t.count+"</b>":"")+"</span>";
2655
+ }).join("")+"</div></div>";
2656
+ }
2657
+ var ch=d.comments.slice(0,25).map(function(c,i){
2658
+ var user=c.username||c.user||"";
2659
+ var text=c.text||c.comment||"";
2660
+ var likes=c.likes||c.likeCount||0, replies=c.replies||0;
2661
+ var badges="";
2662
+ if(c.pinned)badges+='<span class="cbadge pin">PINNED</span>';
2663
+ if(c.creatorLiked)badges+='<span class="cbadge liked">CREATOR LIKED</span>';
2664
+ var meta=[];
2665
+ if(likes>0)meta.push(mpIcon("heart",11)+" "+fmtNum(likes));
2666
+ if(replies>0)meta.push(mpIcon("comment",11)+" "+fmtNum(replies));
2667
+ if(c.postedAt)meta.push(esc(relTime(c.postedAt)));
2668
+ // Long comments clamp to three lines with an expander rather than
2669
+ // being cut mid-sentence โ€” in a chat there is nowhere else to read it.
2670
+ var longish=text.length>180;
2671
+ return '<div class="comment-row" style="flex-direction:column;align-items:stretch">'
2672
+ +'<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">'
2673
+ +'<span style="font-size:12px;font-weight:600">@'+esc(user)+"</span>"+badges+"</div>"
2674
+ +'<div class="ctext'+(longish?" clamp":"")+'" data-c="'+i+'">'+esc(text)+"</div>"
2675
+ +(longish?'<button class="cmore" data-more="'+i+'">Show more</button>':"")
2676
+ +(meta.length?'<div class="cmeta">'+meta.join('<span style="opacity:.5">ยท</span>')+"</div>":"")
2677
+ +"</div>";
2678
+ }).join("");
2679
+ var extra=d.comments.length>25?'<div class="faint" style="text-align:center;font-size:12px;padding-top:10px;color:var(--muted)">Showing 25 of '+d.comments.length+"</div>":"";
2680
+ app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
2681
+ +'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700">'+mpIcon("comment",17)+"<span>Comments ("+d.comments.length+")</span></div>"
2682
+ +(d.summary?'<div class="muted" style="font-size:12.5px;margin:5px 0 14px;color:var(--muted)">'+esc(d.summary)+"</div>":'<div style="height:12px"></div>')
2683
+ +themes+ch+extra+"</div></div>";
2684
+ return;}
2685
+ // Sounds
2686
+ if(d.sounds&&Array.isArray(d.sounds)){
2687
+ app.innerHTML=galleryWrap(d.sounds.map(function(s,i){
2688
+ var color=pColor(s.platform||"tiktok");
2689
+ var coverSrc=s.coverUrl||s.coverProxyUrl||"";
2690
+ var cover=coverSrc?'<img class="sound-cover" src="'+esc(coverSrc)+'" alt="cover" loading="lazy"/>'
2691
+ :'<div class="sound-cover" style="background:'+color+'15;display:flex;align-items:center;justify-content:center;font-size:26px">๐ŸŽต</div>';
2692
+ // The raw CDN URL is signed and cross-origin โ€” only the proxied one
2693
+ // actually plays in this sandboxed frame. A bare link never played
2694
+ // anything in-place, which is why sound cards were silent.
2695
+ var play=s.playUrl||s.playProxyUrl||s.url||"";
2696
+ var secs=Number(s.duration)||0;
2697
+ var meta=[s.artist||s.author||"",secs>0?fmtTime(secs):""].filter(Boolean).join(" โ€ข ");
2698
+ var player=play
2699
+ ? '<div class="au" data-au="au'+i+'">'
2700
+ +'<button class="au-play" style="background:'+color+'" aria-label="Play sound">'+mpIcon("play",14)+"</button>"
2701
+ +'<div class="au-body"><div class="au-seek"><i></i></div>'
2702
+ +'<div class="au-time"><span class="au-cur">0:00</span><span class="au-dur">'
2703
+ +(secs>0?fmtTime(secs):"--:--")+"</span></div></div>"
2704
+ +'<audio preload="metadata" src="'+esc(play)+'"></audio></div>'
2705
+ : '<div class="au-missing">'+mpIcon("warn",13)+"<span>No preview for this track</span></div>";
2706
+ return '<div class="sound-card"><div style="display:flex;gap:12px;align-items:center">'
2707
+ +cover+'<div style="flex:1;min-width:0">'
2708
+ +'<div style="font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(s.title||"")+"</div>"
2709
+ +'<div style="font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(meta)+"</div>"
2710
+ +(s.videoCount?'<div style="font-size:11px;color:var(--muted);margin-top:2px">'+fmtNum(s.videoCount)+" videos</div>":"")
2711
+ +"</div></div>"+player+"</div>";
2712
+ }).join(""),d.sounds.length);return;}
2713
+ // Credits
2714
+ if(d.balance!=null||d.tier){
2715
+ var bal=Number(d.balance)||0,tier=d.tier||"",ff=d.firstFreeTools||[];
2716
+ var freeHtml="";
2717
+ if(ff.length){var tags=ff.map(function(t){return'<span class="free-tag">โœจ '+esc(t)+" free</span>";}).join("");
2718
+ freeHtml='<div style="margin-top:12px"><div class="section-label" style="margin-bottom:6px">Free first uses remaining</div><div style="display:flex;flex-wrap:wrap;gap:4px">'+tags+"</div></div>";}
2719
+ app.innerHTML='<div class="credits-card fade-in">'
2720
+ +'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">'
2721
+ +'<div style="font-size:16px;font-weight:700">๐Ÿ’ฐ Your Credits</div>'
2722
+ +(tier?'<span class="tier-badge">'+esc(tier)+"</span>":"")+"</div>"
2723
+ +'<div style="font-size:36px;font-weight:800;margin:10px 0;letter-spacing:-1px">'+bal+"</div>"
2724
+ +'<div style="font-size:13px;color:var(--muted)">credits remaining</div>'+freeHtml+"</div>";return;}
2725
+ // Checkout. Real packs/prices, not the three hardcoded tiers this used
2726
+ // to show regardless of what the backend actually returned โ€” and each
2727
+ // card is a real <a href> now, so the existing generic anchor handler
2728
+ // (search this file for "cannot navigate the top-level window") opens
2729
+ // the real checkoutUrl via ui/open-link. There was no click handler at
2730
+ // all before this: .pack{cursor:pointer} in the CSS promised a click
2731
+ // that did nothing.
2732
+ if(d.checkoutUrl||d.packs){
2733
+ var checkoutUrl=d.checkoutUrl||"#";
2734
+ var packList=Array.isArray(d.packs)?d.packs:[];
2735
+ var packHtml=function(p,featured){
2736
+ var name=(p&&(p.name||p.label||p.title))||"Credits";
2737
+ var price=(p&&(p.price||p.priceLabel||p.cost))||"";
2738
+ var credits=p&&p.credits!=null?fmtNum(p.credits)+" credits":(p&&(p.description||p.credit))||"";
2739
+ return '<a href="'+esc(checkoutUrl)+'" target="_blank" rel="noopener" class="'+(featured?"pack-featured":"pack")+'" style="text-decoration:none;color:inherit;display:block">'
2740
+ +'<div class="text-sm font-semibold">'+esc(name)+'</div>'
2741
+ +(price?'<div class="mt-2 text-2xl font-extrabold">'+esc(price)+"</div>":"")
2742
+ +(credits?'<div class="mt-2 text-xs text-muted">'+esc(credits)+"</div>":"")+"</a>";
2743
+ };
2744
+ var cards=packList.length
2745
+ ? packList.map(function(p,i){return packHtml(p,i===1);}).join("")
2746
+ : packHtml({name:"Buy credits",description:"Opens Stripe Checkout"},true);
2747
+ app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
2748
+ +'<div class="mb-3 text-base font-bold">๐Ÿ›’ Credit Packs</div>'
2749
+ +'<div class="grid grid-cols-3 gap-2">'+cards
2750
+ +"</div></div></div>";return;}
2751
+ // Hooks (show_hooks) โ€” quote() gives each one its own Copy button.
2752
+ if(d.hooks&&Array.isArray(d.hooks)){
2753
+ if(!d.hooks.length){app.innerHTML='<div class="empty-state fade-in"><div class="icon">๐Ÿ’ฌ</div><div class="text">No hooks yet</div></div>';return;}
2754
+ var hooksHtml=d.hooks.map(function(h){
2755
+ var meta=(h.mechanism?chip(h.mechanism,"note"):"")+(h.why?'<div class="section-text" style="margin-top:2px">'+esc(h.why)+"</div>":"");
2756
+ return quote(h.hook||"")+(meta?'<div style="margin:-4px 0 10px">'+meta+"</div>":"");
2757
+ }).join("");
2758
+ app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
2759
+ +'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700;margin-bottom:4px">'+mpIcon("note",16)+"<span>Hooks</span></div>"
2760
+ +(d.topic?'<div class="faint" style="font-size:11.5px;color:var(--muted);margin-bottom:10px">'+esc(d.topic)+"</div>":"")
2761
+ +hooksHtml+"</div></div>";return;}
2762
+
2763
+ // Variants (show_variants)
2764
+ if(d.variants&&Array.isArray(d.variants)){
2765
+ if(!d.variants.length){app.innerHTML='<div class="empty-state fade-in"><div class="icon">๐ŸŽฌ</div><div class="text">No variants yet</div></div>';return;}
2766
+ var srcCard=(d.post&&(d.post.externalUrl||d.post.platform))?postCard(d.post,true,false,true)+'<div style="height:10px"></div>':"";
2767
+ var variantsHtml=d.variants.map(function(v,i){
2768
+ var beats=(v.beats&&v.beats.length)?bullets(v.beats,8):"";
2769
+ var body=(v.hook?quote(v.hook):"")
2770
+ +(v.angle?'<div class="section-text"><b>Angle:</b> '+esc(v.angle)+"</div>":"")
2771
+ +beats
2772
+ +(v.cta?'<div class="section-text"><b>CTA:</b> '+esc(v.cta)+"</div>":"")
2773
+ +(v.whyItCouldWork?'<div class="section-text" style="color:var(--muted)">'+esc(v.whyItCouldWork)+"</div>":"");
2774
+ return section((i+1)+". "+esc(v.title||"Variant"),body,i===0);
2775
+ }).join("");
2776
+ app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
2777
+ +'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700;margin-bottom:10px">'+mpIcon("share",16)+"<span>Variants</span></div>"
2778
+ +srcCard+variantsHtml+"</div></div>";return;}
2779
+
2780
+ // Repurposed copy (show_repurposed_post) โ€” expandable() clamps long
2781
+ // rewrites instead of dumping a whole thread/description onto the
2782
+ // screen at once; the copyable button matches analysisCard's own
2783
+ // "Copy transcript" pattern above rather than inventing a third one.
2784
+ if(d.versions&&Array.isArray(d.versions)){
2785
+ if(!d.versions.length){app.innerHTML='<div class="empty-state fade-in"><div class="icon">โœ๏ธ</div><div class="text">Nothing repurposed yet</div></div>';return;}
2786
+ var versionsHtml=d.versions.map(function(v,i){
2787
+ return section(esc(v.surface||"Version "+(i+1)),
2788
+ expandable(v.content||"")+'<button class="tag copyable" style="margin-top:8px" data-copy="'+esc(v.content||"")+'">Copy</button>',
2789
+ i===0);
2790
+ }).join("");
2791
+ app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
2792
+ +'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700;margin-bottom:10px">'+mpIcon("share",16)+"<span>Repurposed</span></div>"
2793
+ +versionsHtml+"</div></div>";return;}
2794
+
2795
+ // Single post
2796
+ if(d.post||d.platform){app.innerHTML=postCard(d.post||d,true);return;}
2797
+ // Fallback
2798
+ app.innerHTML='<div class="json-block fade-in">'+esc(JSON.stringify(d,null,2))+"</div>";
2799
+ setTimeout(reportSize,50);
2800
+ }
2801
+
2802
+ function updateProgress(params){
2803
+ var p=params||{};
2804
+ var toolName=p.name||p.toolName||"";
2805
+ if(toolName)currentTool=toolName;
2806
+ renderLoading(currentTool,p.arguments);
2807
+ }
2808
+
2809
+ // Global helpers
2810
+ window.copyText=copyText;
2811
+ document.addEventListener('click',function(e){
2812
+ var btn=e.target.closest('.copy-btn[data-copy]');
2813
+ if(btn)copyText(btn.getAttribute('data-copy'));
2814
+ });
2815
+ // Analysis prose expanders and copy chips.
2816
+ document.addEventListener("click",function(e){
2817
+ if(!e.target.closest)return;
2818
+ var x=e.target.closest(".cmore[data-xmore]");
2819
+ if(x){
2820
+ var t=document.querySelector('.section-text[data-x="'+x.getAttribute("data-xmore")+'"]');
2821
+ if(t){
2822
+ var clamped=t.classList.toggle("clamp6");
2823
+ x.textContent=clamped?"Show more":"Show less";
2824
+ setTimeout(reportSize,60);
2825
+ }
2826
+ return;
2827
+ }
2828
+ var c=e.target.closest(".copyable[data-copy]");
2829
+ if(c){
2830
+ copyText(c.getAttribute("data-copy"));
2831
+ var label=c.textContent;
2832
+ c.textContent="Copied";
2833
+ setTimeout(function(){c.textContent=label;},1200);
2834
+ }
2835
+ });
2836
+
2837
+ // Comment expanders.
2838
+ document.addEventListener("click",function(e){
2839
+ var b=e.target.closest&&e.target.closest(".cmore[data-more]");
2840
+ if(!b)return;
2841
+ var t=document.querySelector('.ctext[data-c="'+b.getAttribute("data-more")+'"]');
2842
+ if(!t)return;
2843
+ var open=t.classList.toggle("clamp");
2844
+ b.textContent=open?"Show more":"Show less";
2845
+ setTimeout(reportSize,60);
2846
+ });
2847
+ window.toggleSection=function(header){
2848
+ header.classList.toggle("open");
2849
+ var content=header.nextElementSibling;
2850
+ if(content)content.classList.toggle("open");
2851
+ };
2852
+ })();
2853
+ </script>
2854
+ </body>
2855
+ </html>
2856
+ `;