@extenshi/cli 0.5.4 → 0.7.0

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,1094 @@
1
+ /**
2
+ * Self-contained HTML renderer for `extenshi icon preview`.
3
+ *
4
+ * Pure (no I/O, no Node APIs) so it is snapshot-testable and bundleable into
5
+ * browser surfaces (the dojo icon-generator embeds the same page via iframe
6
+ * srcDoc). Returns a complete single-file HTML document with inline CSS and
7
+ * inline JS: no network access, no external assets — opens from file:// and
8
+ * works fully offline.
9
+ *
10
+ * What the page shows
11
+ * -------------------
12
+ * Toolbar mockups for Chrome, Firefox and Edge with the developer's icon
13
+ * pinned among neutral neighbor extensions, switchable color palettes
14
+ * (light / dark / black / saturated / light-tinted + a custom color picker),
15
+ * a contrast strip that renders the icon on EVERY palette at once with an
16
+ * automatic "icon melts into the background" warning, a store-size matrix
17
+ * (16/32/48/128) with an 8× pixel magnifier, and canvas-based export: per-size
18
+ * PNG downloads and a ZIP (icons + source SVG + manifest snippet).
19
+ *
20
+ * Security: the icon is embedded exclusively as a data: URI inside <img>
21
+ * elements — SVG loaded through <img> never executes scripts. The CLI
22
+ * additionally strips scripting constructs from the SVG source before it ever
23
+ * reaches this renderer (see icon.ts), because the raw source is also shipped
24
+ * in the export ZIP.
25
+ *
26
+ * SINGLE SOURCE, TWO COPIES: this file exists byte-identical at
27
+ * tools/extenshi-cli/src/icon-preview.ts (canonical — edit here)
28
+ * dojo/src/lib/tools/icon-preview.ts (synced copy for the dojo iframe)
29
+ * because the published npm CLI cannot depend on unpublished workspace
30
+ * packages, and dojo's Docker build does not build the CLI package. A test in
31
+ * tools/extenshi-cli/src/icon.test.ts fails CI when the copies drift; after
32
+ * editing, run: cp tools/extenshi-cli/src/icon-preview.ts dojo/src/lib/tools/icon-preview.ts
33
+ */
34
+ // ── HTML escaping ───────────────────────────────────────────────────────────
35
+ const ESCAPE_MAP = {
36
+ '&': '&amp;',
37
+ '<': '&lt;',
38
+ '>': '&gt;',
39
+ '"': '&quot;',
40
+ "'": '&#39;',
41
+ };
42
+ function esc(s) {
43
+ return s.replace(/[&<>"']/g, (c) => ESCAPE_MAP[c] ?? c);
44
+ }
45
+ /** JSON for inline <script> — <-escape so `</script>` can never break out. */
46
+ function jsonForScript(value) {
47
+ return JSON.stringify(value).replace(/</g, '\\u003c');
48
+ }
49
+ // ── Neutral neighbor icons (inline SVG → data URI) ──────────────────────────
50
+ // Deliberately gray and generic: they give the user's icon realistic company
51
+ // in the pinned area without any brand resemblance, and stay visible on both
52
+ // light and dark toolbars.
53
+ function svgDataUrl(svg) {
54
+ return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
55
+ }
56
+ const NEIGHBOR_GRID = svgDataUrl('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3.5" fill="#9aa0a6"/><circle cx="5.5" cy="5.5" r="1.6" fill="#fff"/><circle cx="10.5" cy="5.5" r="1.6" fill="#fff"/><circle cx="5.5" cy="10.5" r="1.6" fill="#fff"/><circle cx="10.5" cy="10.5" r="1.6" fill="#fff"/></svg>');
57
+ const NEIGHBOR_BOLT = svgDataUrl('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><circle cx="8" cy="8" r="7" fill="#80868b"/><path d="M8.8 3.5 5.5 8.6h2.2l-.6 3.9 3.4-5.1H8.3l.5-3.9z" fill="#fff"/></svg>');
58
+ const PUZZLE_ICON = '<svg viewBox="0 0 20 20" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M8.5 3.5a1.75 1.75 0 1 1 3.5 0V5h2.25c.69 0 1.25.56 1.25 1.25V8.5h-1a1.75 1.75 0 1 0 0 3.5h1v2.25c0 .69-.56 1.25-1.25 1.25H12v-1a1.75 1.75 0 1 0-3.5 0v1H6.25C5.56 15.5 5 14.94 5 14.25V12H4a1.75 1.75 0 1 1 0-3.5h1V6.25C5 5.56 5.56 5 6.25 5H8.5V3.5z"/></svg>';
59
+ const PIN_ICON = '<svg viewBox="0 0 16 16" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M9.5 1.5 14 6l-2.7.7-2 2V13l-1.5 1.5-2-3.5L2.3 7.5 3.8 6h4.3l2-2L9.5 1.5z"/></svg>';
60
+ const LOCK_ICON = '<svg viewBox="0 0 12 12" width="11" height="11" fill="currentColor" aria-hidden="true"><path d="M3.5 5V3.8a2.5 2.5 0 0 1 5 0V5h.4c.6 0 1.1.5 1.1 1.1v3.8c0 .6-.5 1.1-1.1 1.1H3.1c-.6 0-1.1-.5-1.1-1.1V6.1C2 5.5 2.5 5 3.1 5h.4zm1.2 0h2.6V3.8a1.3 1.3 0 0 0-2.6 0V5z"/></svg>';
61
+ // The Extenshi mark, lifted verbatim from the scan report (catalog-frontend
62
+ // logo). `fill="currentColor"` picks up --primary in either theme.
63
+ const LOGO_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="28" height="28" aria-hidden="true">' +
64
+ '<g transform="translate(200, 200) scale(1.1) translate(-219, -169)">' +
65
+ '<path fill="currentColor" fill-rule="evenodd" d="M 43 29.961 C 43 32.246, 47.125 48.247, 49.888 56.680 C 55.762 74.610, 62.198 83.944, 72.096 88.890 C 78.191 91.936, 78.614 92, 92.660 92 L 107 92 106.994 96.250 C 106.991 98.588, 106.697 104.233, 106.342 108.795 L 105.697 117.091 92.098 116.996 C 84.619 116.943, 77.938 116.923, 77.250 116.950 C 76.300 116.988, 76 121.193, 76 134.500 L 76 152 90.487 152 L 104.974 152 104.449 159.250 C 104.161 163.237, 103.503 174.150, 102.987 183.500 C 102.472 192.850, 101.387 211.300, 100.578 224.500 C 98.475 258.794, 96.671 304.700, 97.356 306.487 C 97.875 307.839, 111.424 307.980, 219.220 307.754 L 340.500 307.500 340.255 287.609 C 340.096 274.661, 339.617 267.243, 338.883 266.359 C 338.004 265.299, 333.491 265, 318.397 265 L 299.038 265 298.411 267.500 C 298.066 268.875, 298.066 271.125, 298.411 272.500 L 299.038 275 222.519 275 L 146 275 146 213.500 L 146 152 218.300 152 C 273.200 152, 290.889 152.289, 291.800 153.200 C 293.565 154.965, 293.269 163.776, 291.257 169.391 C 289.026 175.614, 283.255 182.192, 277.770 184.762 C 265.607 190.461, 266.274 190.383, 224.500 190.961 L 185.500 191.500 185.229 211.226 L 184.958 230.951 187.533 231.598 C 191.687 232.640, 262.673 231.320, 272.308 230.021 C 306.918 225.355, 331.983 202.503, 332.006 175.593 C 332.009 171.692, 332.300 164.799, 332.652 160.277 L 333.292 152.053 347.396 151.777 L 361.500 151.500 361.500 134.500 L 361.500 117.500 347.219 117.222 L 332.939 116.945 333.219 104.722 L 333.500 92.500 347.500 91.947 C 357.308 91.560, 362.626 90.900, 365.261 89.744 C 371.234 87.122, 379.834 78.279, 383.489 71 C 388.795 60.434, 397.405 31.072, 395.699 29.365 C 395.464 29.130, 391.723 30.083, 387.386 31.483 C 379.737 33.952, 356.811 38.101, 340.500 39.969 C 320.691 42.238, 283.099 43.185, 214.975 43.131 C 145.730 43.077, 109.789 42.106, 94.500 39.878 C 90.650 39.316, 84.575 38.430, 81 37.907 C 65.459 35.634, 56.790 33.817, 50.373 31.490 C 42.436 28.611, 43 28.728, 43 29.961 M 146.673 92.660 C 146.303 93.030, 146 98.658, 146 105.167 L 146 117 219.531 117 L 293.062 117 292.781 104.750 L 292.500 92.500 219.923 92.244 C 180.006 92.102, 147.043 92.290, 146.673 92.660"/>' +
66
+ '</g></svg>';
67
+ // ── Next-steps CTA icons ─────────────────────────────────────────────────────
68
+ const SHIP_ICON = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2.5c3.4 1.7 5 4.8 5 8.5 0 1.4-.3 2.8-.8 4l-2 3.5H9.8l-2-3.5A9 9 0 0 1 7 11c0-3.7 1.6-6.8 5-8.5z"/><circle cx="12" cy="9.5" r="1.9"/><path d="M8 18l-2 3.5M16 18l2 3.5"/></svg>';
69
+ const RISK_ICON = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3.5 4.5 6.5v5c0 4.4 3 8 7.5 9.5 4.5-1.5 7.5-5.1 7.5-9.5v-5L12 3.5z"/><path d="M9 11.5l2 2 4-4.5"/></svg>';
70
+ const SCAN_ICON = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="6.5"/><path d="M20.5 20.5 16 16"/></svg>';
71
+ const BOX_ICON = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2.5 20.5 7v10L12 21.5 3.5 17V7L12 2.5z"/><path d="M3.5 7 12 11.5 20.5 7M12 11.5v10"/></svg>';
72
+ const CROP_ICON = '<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 2v14a2 2 0 0 0 2 2h14"/><path d="M2 6h14a2 2 0 0 1 2 2v14"/></svg>';
73
+ // ── Mockup HTML fragments ───────────────────────────────────────────────────
74
+ /** The pinned-extensions cluster shared by every toolbar mockup. */
75
+ function pinsHtml(iconUrl, name) {
76
+ return `<span class="pin"><img src="${NEIGHBOR_GRID}" alt="" width="16" height="16"></span>
77
+ <span class="pin"><img src="${NEIGHBOR_BOLT}" alt="" width="16" height="16"></span>
78
+ <span class="pin yours" title="${esc(name)}"><img class="icon-self" src="${esc(iconUrl)}" alt="${esc(name)}" width="16" height="16"></span>
79
+ <span class="pin puzzle" title="Extensions">${PUZZLE_ICON}</span>`;
80
+ }
81
+ // macOS window controls — present on every mockup, like a real window.
82
+ const TRAFFIC_LIGHTS = '<span class="lights"><i class="l r"></i><i class="l y"></i><i class="l g"></i></span>';
83
+ // Firefox chrome pieces (see the real macOS header): Firefox View button in the
84
+ // tab strip, a sidebar toggle in the toolbar, a search-first address bar.
85
+ const FFVIEW_ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><rect x="1.5" y="3.5" width="13" height="10" rx="2"/><path d="M1.5 6.5h13"/></svg>';
86
+ const SIDEBAR_ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><rect x="1.5" y="2.5" width="13" height="11" rx="2"/><path d="M6 2.5v11"/></svg>';
87
+ const SEARCH_ICON = '<svg viewBox="0 0 14 14" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="6" cy="6" r="4"/><path d="m9 9 3.4 3.4"/></svg>';
88
+ function browserMockup(kind, input) {
89
+ const label = kind === 'chrome' ? 'Chrome' : kind === 'firefox' ? 'Firefox' : 'Edge';
90
+ const menuGlyph = kind === 'firefox' ? '☰' : kind === 'edge' ? '…' : '⋮';
91
+ const urlText = 'extenshi.io';
92
+ const extMenu = kind === 'chrome'
93
+ ? `<div class="extmenu">
94
+ <div class="extmenu-title">Extensions</div>
95
+ <div class="extmenu-row you">
96
+ <img class="icon-self" src="${esc(input.dataUrl)}" alt="" width="20" height="20">
97
+ <span class="extmenu-name">${esc(input.name)}</span>
98
+ <span class="extmenu-pin">${PIN_ICON}</span>
99
+ </div>
100
+ <div class="extmenu-row">
101
+ <img src="${NEIGHBOR_GRID}" alt="" width="20" height="20">
102
+ <span class="extmenu-name">Grid Notes</span>
103
+ <span class="extmenu-pin dim">${PIN_ICON}</span>
104
+ </div>
105
+ <div class="extmenu-row">
106
+ <img src="${NEIGHBOR_BOLT}" alt="" width="20" height="20">
107
+ <span class="extmenu-name">Quick Actions</span>
108
+ <span class="extmenu-pin dim">${PIN_ICON}</span>
109
+ </div>
110
+ </div>`
111
+ : '';
112
+ // Firefox has its own header anatomy (see the real macOS window): Firefox
113
+ // View before the tabs, a tab-list chevron at the far right of the strip,
114
+ // a sidebar toggle, a search-first address bar, and ☰ instead of an avatar.
115
+ const tabs = kind === 'firefox'
116
+ ? `<div class="tabs">
117
+ ${TRAFFIC_LIGHTS}
118
+ <span class="strip-btn" title="Firefox View">${FFVIEW_ICON}</span>
119
+ <div class="tab active"><span class="fav"></span><span class="tab-title">New Tab</span><span class="tab-x">×</span></div>
120
+ <span class="newtab">+</span>
121
+ <span class="strip-spacer"></span>
122
+ <span class="strip-btn chevron" title="List all tabs">⌄</span>
123
+ </div>`
124
+ : `<div class="tabs">
125
+ ${TRAFFIC_LIGHTS}
126
+ <div class="tab active"><span class="fav"></span><span class="tab-title">New Tab</span><span class="tab-x">×</span></div>
127
+ <div class="tab"><span class="fav"></span><span class="tab-title">Docs — Extenshi</span></div>
128
+ <span class="newtab">+</span>
129
+ </div>`;
130
+ const toolbar = kind === 'firefox'
131
+ ? `<div class="toolbar">
132
+ <span class="navbtn">${SIDEBAR_ICON}</span>
133
+ <span class="navbtn">←</span><span class="navbtn dim">→</span><span class="navbtn">⟳</span>
134
+ <div class="urlbar"><span class="lock">${SEARCH_ICON}</span><span class="url placeholder">Search or enter address</span></div>
135
+ <div class="pins">${pinsHtml(input.dataUrl, input.name)}</div>
136
+ <span class="menu">${menuGlyph}</span>
137
+ </div>`
138
+ : `<div class="toolbar">
139
+ <span class="navbtn">←</span><span class="navbtn dim">→</span><span class="navbtn">⟳</span>
140
+ <div class="urlbar"><span class="lock">${LOCK_ICON}</span><span class="url">${urlText}</span></div>
141
+ <div class="pins">${pinsHtml(input.dataUrl, input.name)}</div>
142
+ <span class="avatar"></span>
143
+ <span class="menu">${menuGlyph}</span>
144
+ </div>`;
145
+ return `<section class="browser ${kind}">
146
+ <h3>${label}</h3>
147
+ <div class="bwin">
148
+ ${tabs}
149
+ ${toolbar}
150
+ <div class="pagearea"></div>
151
+ </div>
152
+ ${extMenu}
153
+ </section>`;
154
+ }
155
+ // ── Page CSS ────────────────────────────────────────────────────────────────
156
+ const CSS = `
157
+ /* Report palette — the real extenshi.io tokens, shared with the scan report:
158
+ warm terracotta on paper (light), green on near-black (dark). The --t-*
159
+ variables are the SIMULATED browser toolbar colors and are independent of
160
+ the report theme (they're driven by the palette chips). */
161
+ :root {
162
+ --bg: oklch(0.9711 0.0074 80.7211);
163
+ --surface: oklch(0.99 0.005 80);
164
+ --surface-2: oklch(0.937 0.0142 74.4218);
165
+ --fg: oklch(0.3 0.0358 30.2042);
166
+ --muted-fg: oklch(0.4495 0.0486 39.211);
167
+ --border: oklch(0.8805 0.0208 74.6428);
168
+ --primary: oklch(0.7234 0.1783 41.1468);
169
+ --primary-fg: oklch(1 0 0);
170
+ --shadow: 0 1px 3px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
171
+ --font-sans: 'Space Grotesk', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
172
+ --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
173
+ /* Back-compat aliases so the rest of the sheet keeps its names. */
174
+ --page-bg: var(--bg); --page-text: var(--fg); --card: var(--surface); --card-border: var(--border); --accent: var(--primary);
175
+ /* Simulated browser toolbar (chip-controlled, theme-independent). */
176
+ --t-toolbar: #ffffff; --t-tabstrip: #dfe1e5; --t-url: #f1f3f4;
177
+ --t-text: #202124; --t-muted: #5f6368; --t-border: rgba(0,0,0,.12);
178
+ }
179
+ html.dark {
180
+ --bg: oklch(0.1377 0.0167 150.7681);
181
+ --surface: oklch(0.1961 0.0163 146.9867);
182
+ --surface-2: oklch(0.2335 0.0184 146.9867);
183
+ --fg: oklch(0.9423 0.0097 72.6595);
184
+ --muted-fg: oklch(0.79 0.0174 76.0955);
185
+ --border: oklch(0.2644 0.0159 142.9926);
186
+ --primary: oklch(0.6731 0.1624 144.2083);
187
+ --primary-fg: oklch(0.2157 0.0453 145.7256);
188
+ --shadow: 0 1px 3px hsl(0 0% 0% / 0.4), 0 1px 2px -1px hsl(0 0% 0% / 0.4);
189
+ }
190
+ * { box-sizing: border-box; margin: 0; padding: 0; }
191
+ body {
192
+ font-family: var(--font-sans); font-size: 14px; line-height: 1.5;
193
+ font-feature-settings: "ss01", "ss02", "cv11"; -webkit-font-smoothing: antialiased;
194
+ background: var(--bg); color: var(--fg);
195
+ }
196
+ code, .mono { font-family: var(--font-mono); }
197
+ main { max-width: 1080px; margin: 0 auto; display: grid; gap: 22px; padding: 28px 20px 72px; }
198
+
199
+ /* Sticky topbar — mirrors the scan report chrome. */
200
+ .topbar {
201
+ position: sticky; top: 0; z-index: 20;
202
+ display: flex; align-items: center; gap: 12px; padding: 14px 24px;
203
+ background: color-mix(in oklab, var(--bg) 85%, transparent);
204
+ backdrop-filter: blur(8px); border-bottom: 1px solid var(--border);
205
+ }
206
+ .topbar .logo { color: var(--primary); display: inline-flex; }
207
+ .topbar .brand { font-weight: 700; font-size: 1.05rem; letter-spacing: -0.01em; }
208
+ .topbar .sep { color: var(--muted-fg); }
209
+ .topbar .subtitle { color: var(--muted-fg); font-size: 0.9rem; }
210
+ .topbar .spacer { flex: 1; }
211
+ .tg {
212
+ display: inline-flex; align-items: center; gap: 6px;
213
+ border: 1px solid var(--border); background: var(--surface); color: var(--fg);
214
+ border-radius: 999px; padding: 6px 12px; font: inherit; font-size: 0.85rem; cursor: pointer;
215
+ }
216
+ .tg:hover { border-color: var(--primary); }
217
+
218
+ .page-head .brand { font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--primary); font-weight: 600; }
219
+ .page-head h1 { font-size: 24px; margin-top: 2px; letter-spacing: -0.01em; }
220
+ .page-head .meta { color: var(--muted-fg); font-size: 12.5px; margin-top: 2px; }
221
+ section.card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 20px 22px; box-shadow: var(--shadow); }
222
+ section.card > h2 { font-size: 15px; margin-bottom: 12px; }
223
+ h2 .hint { font-weight: 400; font-size: 12.5px; color: var(--muted-fg); margin-left: 8px; }
224
+
225
+ /* Palette chips */
226
+ .chips { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
227
+ .chip {
228
+ display: inline-flex; align-items: center; gap: 7px; padding: 6px 12px; border-radius: 999px;
229
+ border: 1px solid var(--card-border); background: transparent; color: inherit; cursor: pointer; font: inherit; font-size: 13px;
230
+ }
231
+ .chip .sw { width: 14px; height: 14px; border-radius: 50%; border: 1px solid rgba(0,0,0,.2); }
232
+ .chip.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
233
+ .custom { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; margin-left: 4px; }
234
+ .custom input { width: 30px; height: 30px; border: none; background: none; padding: 0; cursor: pointer; }
235
+
236
+ /* Browser mockups */
237
+ #mockups { display: grid; gap: 18px; }
238
+ .browser h3 { font-size: 13px; color: color-mix(in srgb, var(--page-text) 60%, transparent); margin-bottom: 6px; }
239
+ .bwin { border-radius: 10px; overflow: hidden; border: 1px solid var(--t-border); box-shadow: 0 1px 4px rgba(0,0,0,.12); }
240
+ .tabs { display: flex; align-items: flex-end; gap: 2px; padding: 7px 10px 0; background: var(--t-tabstrip); }
241
+ .tab {
242
+ display: flex; align-items: center; gap: 7px; padding: 6px 12px; font-size: 12px; color: var(--t-muted);
243
+ border-radius: 8px 8px 0 0; max-width: 190px; white-space: nowrap; overflow: hidden;
244
+ }
245
+ .tab.active { background: var(--t-toolbar); color: var(--t-text); }
246
+ .tab .fav { width: 12px; height: 12px; border-radius: 3px; background: color-mix(in srgb, var(--t-muted) 55%, transparent); flex: none; }
247
+ .tab .tab-title { overflow: hidden; text-overflow: ellipsis; }
248
+ .tab .tab-x { margin-left: 4px; opacity: .6; }
249
+ .newtab { align-self: center; padding: 0 10px 4px; color: var(--t-muted); font-size: 15px; }
250
+ .lights { display: inline-flex; align-items: center; gap: 6.5px; align-self: center; padding: 0 10px 4px 6px; }
251
+ .lights .l { width: 11px; height: 11px; border-radius: 50%; }
252
+ .lights .l.r { background: #ff5f57; }
253
+ .lights .l.y { background: #febc2e; }
254
+ .lights .l.g { background: #28c840; }
255
+ .firefox .tabs { padding: 6px 8px; align-items: center; }
256
+ .firefox .tab { border-radius: 6px; margin-bottom: 0; padding: 7px 12px; min-width: 150px; }
257
+ .firefox .tab.active { box-shadow: 0 1px 2px rgba(0,0,0,.25); }
258
+ .firefox .tab .tab-x { margin-left: auto; }
259
+ .firefox .lights, .firefox .newtab { padding-bottom: 0; }
260
+ .strip-btn { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 6px; color: var(--t-muted); }
261
+ .strip-btn.chevron { font-size: 12px; }
262
+ .strip-spacer { flex: 1; }
263
+ .firefox .urlbar .placeholder { opacity: .75; }
264
+ .edge .tab { border-radius: 6px 6px 0 0; }
265
+ .toolbar { display: flex; align-items: center; gap: 6px; padding: 7px 12px; background: var(--t-toolbar); color: var(--t-text); }
266
+ .navbtn { width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; color: var(--t-muted); font-size: 14px; flex: none; }
267
+ .navbtn.dim { opacity: .45; }
268
+ .urlbar {
269
+ flex: 1; display: flex; align-items: center; gap: 7px; height: 30px; padding: 0 13px;
270
+ background: var(--t-url); color: var(--t-muted); border-radius: 999px; font-size: 12.5px; min-width: 120px;
271
+ }
272
+ .firefox .urlbar, .edge .urlbar { border-radius: 7px; }
273
+ .lock { display: inline-flex; opacity: .8; }
274
+ .pins { display: flex; align-items: center; gap: 2px; padding: 0 2px; }
275
+ .pin { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; flex: none; }
276
+ .pin:hover { background: color-mix(in srgb, var(--t-muted) 18%, transparent); }
277
+ .pin img { display: block; }
278
+ .pin.puzzle { color: var(--t-muted); }
279
+ .pin.yours.hl { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
280
+ .avatar { width: 24px; height: 24px; border-radius: 50%; flex: none; background: linear-gradient(135deg, #b7c4d8, #7e93b1); }
281
+ .menu { color: var(--t-muted); padding: 0 4px; font-size: 15px; }
282
+ .pagearea { height: 44px; background: color-mix(in srgb, var(--t-toolbar) 60%, var(--page-bg)); border-top: 1px solid var(--t-border); }
283
+
284
+ /* Chrome puzzle dropdown */
285
+ .extmenu {
286
+ width: 300px; margin: 10px 0 0 auto; background: var(--t-toolbar); color: var(--t-text);
287
+ border: 1px solid var(--t-border); border-radius: 10px; padding: 8px; box-shadow: 0 4px 14px rgba(0,0,0,.18);
288
+ }
289
+ .extmenu-title { font-size: 12px; font-weight: 600; padding: 4px 8px 8px; color: var(--t-muted); }
290
+ .extmenu-row { display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: 7px; font-size: 13px; }
291
+ .extmenu-row:hover { background: color-mix(in srgb, var(--t-muted) 14%, transparent); }
292
+ .extmenu-row.you { background: color-mix(in srgb, var(--accent) 12%, transparent); }
293
+ .extmenu-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
294
+ .extmenu-pin { color: var(--t-muted); display: inline-flex; }
295
+ .extmenu-pin.dim { opacity: .4; }
296
+ .extmenu-row.you .extmenu-pin { color: var(--accent); }
297
+
298
+ /* Contrast grid */
299
+ #contrast-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; }
300
+ .cg-cell { border: 1px solid var(--card-border); border-radius: 9px; overflow: hidden; }
301
+ .cg-strip { display: flex; align-items: center; gap: 2px; padding: 9px 12px; }
302
+ .cg-strip .pin { width: 26px; height: 26px; }
303
+ .cg-meta { display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 6px 10px; font-size: 12px; }
304
+ .badge { font-size: 11px; padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
305
+ .badge.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #15803d; }
306
+ .badge.weak { background: color-mix(in srgb, #f59e0b 20%, transparent); color: #b45309; }
307
+ .badge.bad { background: color-mix(in srgb, #ef4444 20%, transparent); color: #b91c1c; }
308
+ html.dark .badge.ok { color: #4ade80; }
309
+ html.dark .badge.weak { color: #fbbf24; }
310
+ html.dark .badge.bad { color: #f87171; }
311
+
312
+ /* Size matrix + magnifier */
313
+ .size-flex { display: flex; flex-wrap: wrap; gap: 26px; align-items: flex-end; }
314
+ .size-cell { display: flex; flex-direction: column; align-items: center; gap: 6px; font-size: 11.5px; color: color-mix(in srgb, var(--page-text) 60%, transparent); }
315
+ .checker {
316
+ display: inline-flex; align-items: center; justify-content: center; padding: 8px; border-radius: 8px; border: 1px solid var(--card-border);
317
+ background: repeating-conic-gradient(rgba(128,128,128,.16) 0% 25%, transparent 0% 50%); background-size: 14px 14px;
318
+ }
319
+ .mag-wrap { display: flex; gap: 18px; align-items: center; margin-top: 16px; flex-wrap: wrap; }
320
+ #mag { image-rendering: pixelated; width: 128px; height: 128px; border-radius: 8px; }
321
+ .mag-box { display: inline-flex; padding: 10px; border-radius: 10px; background: var(--t-toolbar); border: 1px solid var(--t-border); }
322
+ .mag-note { font-size: 12.5px; color: color-mix(in srgb, var(--page-text) 60%, transparent); max-width: 380px; }
323
+ .fill-note { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 12.5px; margin-top: 14px; color: var(--muted-fg); }
324
+ /* Trim callout — a highlighted, hard-to-miss action tied to the fill warning. */
325
+ .trim-callout {
326
+ display: flex; align-items: center; gap: 13px; margin-top: 12px; padding: 13px 15px; border-radius: 12px;
327
+ border: 1.5px solid color-mix(in oklab, var(--primary) 55%, var(--border));
328
+ background: color-mix(in oklab, var(--primary) 9%, var(--surface));
329
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 10%, transparent);
330
+ }
331
+ .trim-ico { flex: none; width: 34px; height: 34px; border-radius: 9px; display: inline-flex; align-items: center; justify-content: center; color: var(--primary-fg); background: var(--primary); }
332
+ .trim-text { flex: 1; min-width: 0; }
333
+ .trim-text strong { font-size: 13.5px; }
334
+ .trim-text p { font-size: 12.5px; color: var(--muted-fg); margin-top: 1px; }
335
+ /* iOS-style switch so the control reads as an actionable toggle, not a stray checkbox. */
336
+ .switch { flex: none; position: relative; display: inline-flex; cursor: pointer; }
337
+ .switch input { position: absolute; opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; }
338
+ .switch-track { width: 42px; height: 24px; border-radius: 999px; background: color-mix(in oklab, var(--muted-fg) 45%, transparent); transition: background .18s; display: inline-flex; align-items: center; padding: 0 3px; }
339
+ .switch-thumb { width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.35); transition: transform .18s; }
340
+ .switch input:checked + .switch-track { background: var(--primary); }
341
+ .switch input:checked + .switch-track .switch-thumb { transform: translateX(18px); }
342
+ .switch input:focus-visible + .switch-track { outline: 2px solid var(--primary); outline-offset: 2px; }
343
+ .note-warn {
344
+ font-size: 12.5px; line-height: 1.45; padding: 9px 12px; border-radius: 8px; margin-bottom: 12px;
345
+ background: color-mix(in srgb, #f59e0b 14%, transparent); color: #b45309;
346
+ }
347
+ html.dark .note-warn { color: #fbbf24; }
348
+
349
+ /* Export */
350
+ .export-row { display: flex; flex-wrap: wrap; gap: 9px; margin-bottom: 13px; }
351
+ button.btn {
352
+ font: inherit; font-size: 13px; padding: 7px 14px; border-radius: 8px; cursor: pointer;
353
+ border: 1px solid var(--card-border); background: transparent; color: inherit;
354
+ }
355
+ button.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
356
+ button.btn:hover { filter: brightness(1.06); }
357
+ pre.snippet { background: color-mix(in srgb, var(--page-text) 7%, transparent); border-radius: 8px; padding: 12px; font-size: 12px; overflow-x: auto; }
358
+ .next-steps {
359
+ border-color: color-mix(in srgb, var(--accent) 40%, var(--card-border));
360
+ background:
361
+ linear-gradient(180deg, color-mix(in srgb, var(--accent) 9%, transparent), transparent 60%),
362
+ var(--card);
363
+ }
364
+ .next-head { display: flex; align-items: center; gap: 13px; margin-bottom: 16px; }
365
+ .next-badge {
366
+ flex: none; width: 40px; height: 40px; border-radius: 11px; display: inline-flex; align-items: center; justify-content: center;
367
+ color: var(--primary-fg); background: linear-gradient(135deg, var(--primary), color-mix(in oklab, var(--primary) 68%, #000));
368
+ box-shadow: 0 4px 12px color-mix(in oklab, var(--primary) 35%, transparent);
369
+ }
370
+ .next-head h2 { font-size: 16px; margin: 0; }
371
+ .next-sub { font-size: 12.5px; color: color-mix(in srgb, var(--page-text) 62%, transparent); margin-top: 2px; }
372
+ .next-grid { display: grid; gap: 9px; }
373
+ .next-item {
374
+ display: flex; gap: 11px; align-items: flex-start; padding: 11px 13px; border-radius: 10px;
375
+ background: color-mix(in srgb, var(--page-text) 4%, transparent);
376
+ border: 1px solid color-mix(in srgb, var(--accent) 12%, transparent);
377
+ transition: border-color .15s, background .15s;
378
+ }
379
+ .next-item:hover { border-color: color-mix(in srgb, var(--accent) 45%, transparent); background: color-mix(in srgb, var(--accent) 7%, transparent); }
380
+ .next-ico { flex: none; margin-top: 1px; color: var(--accent); display: inline-flex; }
381
+ .next-body { display: grid; gap: 3px; min-width: 0; }
382
+ .next-body code { font-size: 12.5px; background: color-mix(in srgb, var(--page-text) 8%, transparent); border-radius: 5px; padding: 2px 7px; justify-self: start; }
383
+ .next-body a { font-size: 13.5px; font-weight: 600; color: var(--accent); text-decoration: none; }
384
+ .next-body a:hover { text-decoration: underline; }
385
+ .next-body p { font-size: 12.5px; color: color-mix(in srgb, var(--page-text) 68%, transparent); }
386
+ .foot { text-align: center; font-size: 12px; color: color-mix(in srgb, var(--page-text) 45%, transparent); }
387
+ .foot a { color: var(--accent); }
388
+ .raster-note { font-size: 12.5px; color: #b45309; margin-bottom: 10px; }
389
+ label.toggle { display: inline-flex; gap: 7px; align-items: center; font-size: 13px; cursor: pointer; margin-left: auto; }
390
+ .themes-head { display: flex; align-items: center; }
391
+ `;
392
+ // ── Embedded page JS ────────────────────────────────────────────────────────
393
+ // Written in plain ES2020 with string concatenation (no template literals) so
394
+ // the outer TS template literal never needs escaped interpolations.
395
+ const PAGE_JS = String.raw `
396
+ 'use strict';
397
+ var CFG = window.__ICON_PREVIEW__;
398
+
399
+ // ---- palettes ----
400
+ var THEMES = [
401
+ { id: 'light', label: 'Light', toolbar: '#ffffff', tabstrip: '#dfe1e5' },
402
+ { id: 'tinted', label: 'Light tinted',toolbar: '#e9eef6', tabstrip: '#d4dceb' },
403
+ { id: 'dark', label: 'Dark', toolbar: '#35363a', tabstrip: '#202124' },
404
+ { id: 'black', label: 'Black', toolbar: '#1b1b1f', tabstrip: '#0e0e10' },
405
+ { id: 'ocean', label: 'Saturated', toolbar: '#174ea6', tabstrip: '#0d3a80' }
406
+ ];
407
+
408
+ function hexToRgb(hex) {
409
+ var h = hex.replace('#', '');
410
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
411
+ var n = parseInt(h, 16);
412
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
413
+ }
414
+ function rgbToHex(rgb) {
415
+ return '#' + rgb.map(function (v) {
416
+ var s = Math.max(0, Math.min(255, Math.round(v))).toString(16);
417
+ return s.length === 1 ? '0' + s : s;
418
+ }).join('');
419
+ }
420
+ function mix(a, b, t) {
421
+ return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
422
+ }
423
+ // WCAG relative luminance
424
+ function relLum(rgb) {
425
+ var c = rgb.map(function (v) {
426
+ v /= 255;
427
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
428
+ });
429
+ return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
430
+ }
431
+ function contrastRatio(a, b) {
432
+ var l1 = relLum(a), l2 = relLum(b);
433
+ var hi = Math.max(l1, l2), lo = Math.min(l1, l2);
434
+ return (hi + 0.05) / (lo + 0.05);
435
+ }
436
+ function deriveTheme(toolbarHex) {
437
+ var t = hexToRgb(toolbarHex);
438
+ var darkText = relLum(t) > 0.4;
439
+ var text = darkText ? [32, 33, 36] : [235, 237, 240];
440
+ return {
441
+ id: 'custom', label: 'Custom',
442
+ toolbar: toolbarHex,
443
+ tabstrip: rgbToHex(mix(t, darkText ? [0, 0, 0] : [0, 0, 0], darkText ? 0.14 : 0.4)),
444
+ text: rgbToHex(text),
445
+ muted: rgbToHex(mix(t, text, 0.62)),
446
+ url: rgbToHex(mix(t, text, darkText ? 0.06 : 0.1))
447
+ };
448
+ }
449
+ function materialize(theme) {
450
+ if (theme.text) return theme;
451
+ var full = deriveTheme(theme.toolbar);
452
+ return {
453
+ id: theme.id, label: theme.label,
454
+ toolbar: theme.toolbar, tabstrip: theme.tabstrip,
455
+ text: full.text, muted: full.muted, url: full.url
456
+ };
457
+ }
458
+
459
+ var currentTheme = materialize(THEMES[0]);
460
+ function applyTheme(theme) {
461
+ currentTheme = materialize(theme);
462
+ var r = document.documentElement.style;
463
+ r.setProperty('--t-toolbar', currentTheme.toolbar);
464
+ r.setProperty('--t-tabstrip', currentTheme.tabstrip);
465
+ r.setProperty('--t-url', currentTheme.url);
466
+ r.setProperty('--t-text', currentTheme.text);
467
+ r.setProperty('--t-muted', currentTheme.muted);
468
+ r.setProperty('--t-border', relLum(hexToRgb(currentTheme.toolbar)) > 0.4 ? 'rgba(0,0,0,.14)' : 'rgba(255,255,255,.14)');
469
+ document.querySelectorAll('.chip[data-theme]').forEach(function (c) {
470
+ c.classList.toggle('active', c.getAttribute('data-theme') === currentTheme.id);
471
+ });
472
+ drawMagnifier();
473
+ }
474
+
475
+ // ---- icon raster helpers (SVG/PNG → canvas) ----
476
+ var iconImg = null;
477
+ function loadIcon() {
478
+ return new Promise(function (resolve, reject) {
479
+ if (iconImg) { resolve(iconImg); return; }
480
+ var img = new Image();
481
+ img.onload = function () { iconImg = img; resolve(img); };
482
+ img.onerror = function () { reject(new Error('Failed to load the icon image')); };
483
+ img.src = CFG.dataUrl;
484
+ });
485
+ }
486
+ // ---- trim-to-edges (canvas-usage) state ----
487
+ // When the artwork leaves wide transparent margins inside its viewBox, the
488
+ // 16px toolbar render wastes those pixels. "trimmed" holds an edge-to-edge
489
+ // variant (vector viewBox re-crop for SVG, canvas crop for PNG) that the trim
490
+ // toggle swaps into every preview AND every export.
491
+ var trimOn = false;
492
+ var trimmed = null; // { svgSource?, displayUrl, img? }
493
+
494
+ function activeSvgSource() {
495
+ return trimOn && trimmed && trimmed.svgSource ? trimmed.svgSource : CFG.svgSource;
496
+ }
497
+ function displayIconUrl() {
498
+ return trimOn && trimmed ? trimmed.displayUrl : CFG.dataUrl;
499
+ }
500
+
501
+ function parseSvg(source) {
502
+ var doc = new DOMParser().parseFromString(source, 'image/svg+xml');
503
+ var el = doc.querySelector('svg');
504
+ if (el && !el.getAttribute('viewBox')) {
505
+ var w = parseFloat(el.getAttribute('width') || '') || 24;
506
+ var h = parseFloat(el.getAttribute('height') || '') || 24;
507
+ el.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
508
+ }
509
+ return el;
510
+ }
511
+ // For SVG sources, re-encode with explicit pixel dimensions so every browser
512
+ // rasterizes at the exact target size (Firefox refuses dimension-less SVGs).
513
+ function sizedSvgUrl(size) {
514
+ var el = parseSvg(activeSvgSource());
515
+ if (!el) return CFG.dataUrl;
516
+ el.setAttribute('width', String(size));
517
+ el.setAttribute('height', String(size));
518
+ var xml = new XMLSerializer().serializeToString(el);
519
+ return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(xml);
520
+ }
521
+ function renderToCanvas(size) {
522
+ return new Promise(function (resolve, reject) {
523
+ var finish = function (img) {
524
+ var canvas = document.createElement('canvas');
525
+ canvas.width = size; canvas.height = size;
526
+ var ctx = canvas.getContext('2d');
527
+ ctx.imageSmoothingEnabled = true;
528
+ ctx.imageSmoothingQuality = 'high';
529
+ ctx.drawImage(img, 0, 0, size, size);
530
+ resolve(canvas);
531
+ };
532
+ if (CFG.isSvg && CFG.svgSource) {
533
+ var img = new Image();
534
+ img.onload = function () { finish(img); };
535
+ img.onerror = function () { reject(new Error('SVG rasterization failed')); };
536
+ img.src = sizedSvgUrl(size);
537
+ } else if (trimOn && trimmed && trimmed.img) {
538
+ finish(trimmed.img);
539
+ } else {
540
+ loadIcon().then(finish, reject);
541
+ }
542
+ });
543
+ }
544
+ function canvasToBlob(canvas) {
545
+ return new Promise(function (resolve, reject) {
546
+ canvas.toBlob(function (b) { b ? resolve(b) : reject(new Error('canvas.toBlob returned null')); }, 'image/png');
547
+ });
548
+ }
549
+ function downloadBlob(name, blob) {
550
+ var url = URL.createObjectURL(blob);
551
+ var a = document.createElement('a');
552
+ a.href = url; a.download = name;
553
+ document.body.appendChild(a);
554
+ a.click();
555
+ a.remove();
556
+ setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
557
+ }
558
+
559
+ // ---- icon analysis ----
560
+ // One 128px pass over the UNTRIMMED source: alpha-weighted average color,
561
+ // coverage, average chroma (for the mid-gray heuristic), and the opaque-pixel
562
+ // bounding box (for the canvas-usage metric + trim).
563
+ var ANALYZE_SIZE = 128;
564
+ var iconStats = null;
565
+ function analyzeIcon() {
566
+ return renderToCanvas(ANALYZE_SIZE).then(function (canvas) {
567
+ var n = ANALYZE_SIZE;
568
+ var d = canvas.getContext('2d').getImageData(0, 0, n, n).data;
569
+ var r = 0, g = 0, b = 0, wsum = 0, opaque = 0, chroma = 0;
570
+ var x0 = n, y0 = n, x1 = -1, y1 = -1;
571
+ for (var y = 0; y < n; y++) {
572
+ for (var x = 0; x < n; x++) {
573
+ var i = (y * n + x) * 4;
574
+ var a = d[i + 3] / 255;
575
+ if (a > 0.05) {
576
+ opaque++;
577
+ if (x < x0) x0 = x;
578
+ if (x > x1) x1 = x;
579
+ if (y < y0) y0 = y;
580
+ if (y > y1) y1 = y;
581
+ }
582
+ r += d[i] * a; g += d[i + 1] * a; b += d[i + 2] * a; wsum += a;
583
+ chroma += (Math.max(d[i], d[i + 1], d[i + 2]) - Math.min(d[i], d[i + 1], d[i + 2])) * a;
584
+ }
585
+ }
586
+ if (wsum > 0 && x1 >= 0) {
587
+ iconStats = {
588
+ avg: [r / wsum, g / wsum, b / wsum],
589
+ coverage: opaque / (n * n),
590
+ chroma: chroma / wsum,
591
+ bbox: { x0: x0, y0: y0, x1: x1, y1: y1 },
592
+ fill: Math.max(x1 - x0 + 1, y1 - y0 + 1) / n
593
+ };
594
+ } else {
595
+ iconStats = { avg: [128, 128, 128], coverage: 0, chroma: 0, bbox: null, fill: 0 };
596
+ }
597
+ return iconStats;
598
+ });
599
+ }
600
+ function contrastBadge(theme) {
601
+ var ratio = contrastRatio(iconStats.avg, hexToRgb(theme.toolbar));
602
+ if (ratio < 1.45) return { cls: 'bad', label: '⚠ melts into background', ratio: ratio };
603
+ if (ratio < 2.2) return { cls: 'weak', label: '△ low contrast', ratio: ratio };
604
+ return { cls: 'ok', label: '✓ reads well', ratio: ratio };
605
+ }
606
+ // Mid-gray heuristic: a desaturated icon whose luminance sits in the middle
607
+ // clears the WCAG ratio against EVERY toolbar (≈3–4:1 everywhere) yet stands
608
+ // out on none of them — the ratio badges alone can't catch that.
609
+ function isMidGray() {
610
+ if (!iconStats || iconStats.coverage < 0.04) return false;
611
+ var lum = relLum(iconStats.avg);
612
+ return iconStats.chroma < 30 && lum > 0.1 && lum < 0.62;
613
+ }
614
+
615
+ // ---- canvas-usage metric + trim toggle ----
616
+ function computeTrimmedVariant() {
617
+ if (!iconStats || !iconStats.bbox || iconStats.fill >= 0.98) { trimmed = null; return Promise.resolve(); }
618
+ var n = ANALYZE_SIZE;
619
+ var bb = iconStats.bbox;
620
+ var pad = 0.02; // 2% breathing room so strokes don't touch the very edge
621
+ if (CFG.isSvg && CFG.svgSource) {
622
+ var el = parseSvg(CFG.svgSource);
623
+ if (!el) { trimmed = null; return Promise.resolve(); }
624
+ var vb = (el.getAttribute('viewBox') || '0 0 24 24').trim().split(/[\s,]+/).map(Number);
625
+ var bw = ((bb.x1 - bb.x0 + 1) / n) * vb[2];
626
+ var bh = ((bb.y1 - bb.y0 + 1) / n) * vb[3];
627
+ var cx = vb[0] + ((bb.x0 + bb.x1 + 1) / 2 / n) * vb[2];
628
+ var cy = vb[1] + ((bb.y0 + bb.y1 + 1) / 2 / n) * vb[3];
629
+ var side = Math.max(bw, bh) * (1 + pad * 2);
630
+ el.setAttribute('viewBox', (cx - side / 2) + ' ' + (cy - side / 2) + ' ' + side + ' ' + side);
631
+ el.removeAttribute('width');
632
+ el.removeAttribute('height');
633
+ var xml = new XMLSerializer().serializeToString(el);
634
+ trimmed = { svgSource: xml, displayUrl: 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(xml) };
635
+ return Promise.resolve();
636
+ }
637
+ // PNG: crop the natural-resolution image around the (scaled) bounding box.
638
+ return loadIcon().then(function (img) {
639
+ var nw = img.naturalWidth || n, nh = img.naturalHeight || n;
640
+ var sx0 = Math.floor((bb.x0 / n) * nw), sx1 = Math.ceil(((bb.x1 + 1) / n) * nw);
641
+ var sy0 = Math.floor((bb.y0 / n) * nh), sy1 = Math.ceil(((bb.y1 + 1) / n) * nh);
642
+ var bwPx = sx1 - sx0, bhPx = sy1 - sy0;
643
+ var side = Math.ceil(Math.max(bwPx, bhPx) * (1 + pad * 2));
644
+ var canvas = document.createElement('canvas');
645
+ canvas.width = side; canvas.height = side;
646
+ var ctx = canvas.getContext('2d');
647
+ ctx.drawImage(img, sx0, sy0, bwPx, bhPx, Math.round((side - bwPx) / 2), Math.round((side - bhPx) / 2), bwPx, bhPx);
648
+ var url = canvas.toDataURL('image/png');
649
+ return new Promise(function (resolve) {
650
+ var timg = new Image();
651
+ timg.onload = function () { trimmed = { displayUrl: url, img: timg }; resolve(); };
652
+ timg.onerror = function () { trimmed = null; resolve(); };
653
+ timg.src = url;
654
+ });
655
+ });
656
+ }
657
+
658
+ function refreshIconEverywhere() {
659
+ var url = displayIconUrl();
660
+ document.querySelectorAll('img.icon-self').forEach(function (img) { img.src = url; });
661
+ drawMagnifier();
662
+ renderContrastGrid();
663
+ }
664
+
665
+ function renderFillNote() {
666
+ var note = document.getElementById('fill-note');
667
+ if (!note || !iconStats) return;
668
+ var pct = Math.round(iconStats.fill * 100);
669
+ note.textContent = '';
670
+ var badge = document.createElement('span');
671
+ var ok = iconStats.fill >= 0.88;
672
+ badge.className = 'badge ' + (ok ? 'ok' : 'weak');
673
+ badge.textContent = ok ? '✓ fills the canvas well' : '△ scale the artwork to the edges';
674
+ var text = document.createElement('span');
675
+ text.textContent = 'Artwork fills ' + pct + '% of its canvas — the rest is transparent margin that shrinks the icon in the toolbar. ';
676
+ note.appendChild(text);
677
+ note.appendChild(badge);
678
+ var trimRow = document.getElementById('trim-row');
679
+ if (trimRow && iconStats.fill < 0.98 && iconStats.fill > 0) trimRow.hidden = false;
680
+ }
681
+
682
+ // ---- contrast grid ----
683
+ function pinStrip(theme) {
684
+ var wrap = document.createElement('div');
685
+ wrap.className = 'cg-strip';
686
+ wrap.style.background = theme.toolbar;
687
+ var srcs = [CFG.neighbors[0], CFG.neighbors[1], displayIconUrl()];
688
+ srcs.forEach(function (src, i) {
689
+ var pin = document.createElement('span');
690
+ pin.className = 'pin' + (i === 2 ? ' yours' : '');
691
+ var img = document.createElement('img');
692
+ img.src = src; img.width = 16; img.height = 16; img.alt = '';
693
+ pin.appendChild(img);
694
+ wrap.appendChild(pin);
695
+ });
696
+ return wrap;
697
+ }
698
+ function renderContrastGrid() {
699
+ var grid = document.getElementById('contrast-grid');
700
+ grid.textContent = '';
701
+ var themes = THEMES.map(materialize);
702
+ if (currentTheme.id === 'custom') themes.push(currentTheme);
703
+ themes.forEach(function (theme) {
704
+ var cell = document.createElement('div');
705
+ cell.className = 'cg-cell';
706
+ cell.appendChild(pinStrip(theme));
707
+ var meta = document.createElement('div');
708
+ meta.className = 'cg-meta';
709
+ var name = document.createElement('span');
710
+ name.textContent = theme.label;
711
+ var badge = document.createElement('span');
712
+ var v = contrastBadge(theme);
713
+ badge.className = 'badge ' + v.cls;
714
+ badge.title = 'contrast ratio ' + v.ratio.toFixed(2) + ':1 (icon average color vs toolbar)';
715
+ badge.textContent = v.label;
716
+ meta.appendChild(name);
717
+ meta.appendChild(badge);
718
+ cell.appendChild(meta);
719
+ grid.appendChild(cell);
720
+ });
721
+ }
722
+
723
+ // ---- magnifier ----
724
+ function drawMagnifier() {
725
+ var mag = document.getElementById('mag');
726
+ if (!mag) return;
727
+ renderToCanvas(16).then(function (src) {
728
+ var ctx = mag.getContext('2d');
729
+ ctx.clearRect(0, 0, 16, 16);
730
+ ctx.imageSmoothingEnabled = false;
731
+ ctx.drawImage(src, 0, 0);
732
+ });
733
+ var box = document.getElementById('mag-box');
734
+ if (box) box.style.background = currentTheme.toolbar;
735
+ }
736
+
737
+ // ---- exports ----
738
+ var SIZES = [16, 32, 48, 128];
739
+ function exportPng(size) {
740
+ renderToCanvas(size).then(canvasToBlob).then(function (blob) {
741
+ downloadBlob(size + '.png', blob);
742
+ }).catch(function (e) { alert(e.message); });
743
+ }
744
+
745
+ // STORE-method ZIP writer (PNG members are already compressed).
746
+ var CRC_TABLE = (function () {
747
+ var t = new Uint32Array(256);
748
+ for (var n = 0; n < 256; n++) {
749
+ var c = n;
750
+ for (var k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
751
+ t[n] = c >>> 0;
752
+ }
753
+ return t;
754
+ })();
755
+ function crc32(data) {
756
+ var crc = 0xffffffff;
757
+ for (var i = 0; i < data.length; i++) crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
758
+ return (crc ^ 0xffffffff) >>> 0;
759
+ }
760
+ function ZipBuilder() { this.entries = []; this.chunks = []; this.offset = 0; }
761
+ ZipBuilder.prototype.push = function (chunk) { this.chunks.push(chunk); this.offset += chunk.length; };
762
+ ZipBuilder.prototype.addFile = function (name, content) {
763
+ var data = typeof content === 'string' ? new TextEncoder().encode(content) : content;
764
+ var nameBytes = new TextEncoder().encode(name);
765
+ var crc = crc32(data);
766
+ var d = new Date();
767
+ var dosTime = (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2);
768
+ var dosDate = ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
769
+ var h = new DataView(new ArrayBuffer(30));
770
+ h.setUint32(0, 0x04034b50, true);
771
+ h.setUint16(4, 20, true);
772
+ h.setUint16(6, 0x0800, true);
773
+ h.setUint16(8, 0, true);
774
+ h.setUint16(10, dosTime, true);
775
+ h.setUint16(12, dosDate, true);
776
+ h.setUint32(14, crc, true);
777
+ h.setUint32(18, data.length, true);
778
+ h.setUint32(22, data.length, true);
779
+ h.setUint16(26, nameBytes.length, true);
780
+ h.setUint16(28, 0, true);
781
+ this.entries.push({ name: name, size: data.length, crc: crc, offset: this.offset, dosTime: dosTime, dosDate: dosDate });
782
+ this.push(new Uint8Array(h.buffer));
783
+ this.push(nameBytes);
784
+ this.push(data);
785
+ };
786
+ ZipBuilder.prototype.toBlob = function () {
787
+ var central = [];
788
+ var centralSize = 0;
789
+ this.entries.forEach(function (e) {
790
+ var nameBytes = new TextEncoder().encode(e.name);
791
+ var r = new DataView(new ArrayBuffer(46));
792
+ r.setUint32(0, 0x02014b50, true);
793
+ r.setUint16(4, 20, true);
794
+ r.setUint16(6, 20, true);
795
+ r.setUint16(8, 0x0800, true);
796
+ r.setUint16(10, 0, true);
797
+ r.setUint16(12, e.dosTime, true);
798
+ r.setUint16(14, e.dosDate, true);
799
+ r.setUint32(16, e.crc, true);
800
+ r.setUint32(20, e.size, true);
801
+ r.setUint32(24, e.size, true);
802
+ r.setUint16(28, nameBytes.length, true);
803
+ r.setUint32(42, e.offset, true);
804
+ central.push(new Uint8Array(r.buffer), nameBytes);
805
+ centralSize += 46 + nameBytes.length;
806
+ });
807
+ var end = new DataView(new ArrayBuffer(22));
808
+ end.setUint32(0, 0x06054b50, true);
809
+ end.setUint16(8, this.entries.length, true);
810
+ end.setUint16(10, this.entries.length, true);
811
+ end.setUint32(12, centralSize, true);
812
+ end.setUint32(16, this.offset, true);
813
+ return new Blob(this.chunks.concat(central, [new Uint8Array(end.buffer)]), { type: 'application/zip' });
814
+ };
815
+
816
+ function exportZip() {
817
+ var btn = document.getElementById('zip-btn');
818
+ btn.disabled = true;
819
+ var zip = new ZipBuilder();
820
+ var work = SIZES.reduce(function (p, size) {
821
+ return p.then(function () {
822
+ return renderToCanvas(size).then(canvasToBlob).then(function (blob) {
823
+ return blob.arrayBuffer().then(function (buf) {
824
+ zip.addFile('icons/' + size + '.png', new Uint8Array(buf));
825
+ });
826
+ });
827
+ });
828
+ }, Promise.resolve());
829
+ work.then(function () {
830
+ if (CFG.isSvg && CFG.svgSource) zip.addFile('icon.svg', activeSvgSource());
831
+ zip.addFile('manifest-icons.json', CFG.manifestSnippet + '\n');
832
+ downloadBlob(CFG.safeName + '-icons.zip', zip.toBlob());
833
+ }).catch(function (e) { alert(e.message); }).then(function () { btn.disabled = false; });
834
+ }
835
+
836
+ function copySnippet() {
837
+ var btn = document.getElementById('copy-btn');
838
+ navigator.clipboard.writeText(CFG.manifestSnippet).then(function () {
839
+ var prev = btn.textContent;
840
+ btn.textContent = '✓ Copied';
841
+ setTimeout(function () { btn.textContent = prev; }, 1800);
842
+ }, function () { alert('Clipboard unavailable — copy the snippet below manually.'); });
843
+ }
844
+
845
+ // ---- report light/dark theme (matches the scan report) ----
846
+ function initReportTheme() {
847
+ var root = document.documentElement;
848
+ var btn = document.getElementById('theme-toggle');
849
+ if (!btn) return; // embedded mode has no topbar
850
+ function syncLabel() { btn.textContent = root.classList.contains('dark') ? '☀ Light' : '☾ Dark'; }
851
+ syncLabel();
852
+ btn.addEventListener('click', function () {
853
+ var dark = root.classList.toggle('dark');
854
+ try { localStorage.setItem('extenshi-report-theme', dark ? 'dark' : 'light'); } catch (e) {}
855
+ syncLabel();
856
+ });
857
+ }
858
+
859
+ // ---- boot ----
860
+ function boot() {
861
+ initReportTheme();
862
+ var chips = document.getElementById('theme-chips');
863
+ THEMES.forEach(function (theme, i) {
864
+ var chip = document.createElement('button');
865
+ chip.type = 'button';
866
+ chip.className = 'chip' + (i === 0 ? ' active' : '');
867
+ chip.setAttribute('data-theme', theme.id);
868
+ var sw = document.createElement('span');
869
+ sw.className = 'sw';
870
+ sw.style.background = theme.toolbar;
871
+ chip.appendChild(sw);
872
+ chip.appendChild(document.createTextNode(theme.label));
873
+ chip.addEventListener('click', function () { applyTheme(theme); renderContrastGrid(); });
874
+ chips.appendChild(chip);
875
+ });
876
+ document.getElementById('custom-color').addEventListener('input', function (e) {
877
+ applyTheme(deriveTheme(e.target.value));
878
+ renderContrastGrid();
879
+ });
880
+ document.getElementById('hl-toggle').addEventListener('change', function (e) {
881
+ document.querySelectorAll('.pin.yours').forEach(function (p) {
882
+ p.classList.toggle('hl', e.target.checked);
883
+ });
884
+ });
885
+ SIZES.forEach(function (size) {
886
+ var el = document.getElementById('png-' + size);
887
+ if (el) el.addEventListener('click', function () { exportPng(size); });
888
+ });
889
+ // Export controls are absent in embedded mode — the host UI has its own.
890
+ var zipBtn = document.getElementById('zip-btn');
891
+ if (zipBtn) zipBtn.addEventListener('click', exportZip);
892
+ var copyBtn = document.getElementById('copy-btn');
893
+ if (copyBtn) copyBtn.addEventListener('click', copySnippet);
894
+
895
+ var trimToggle = document.getElementById('trim-toggle');
896
+ if (trimToggle) trimToggle.addEventListener('change', function (e) {
897
+ trimOn = e.target.checked;
898
+ refreshIconEverywhere();
899
+ });
900
+
901
+ analyzeIcon().then(function () {
902
+ renderContrastGrid();
903
+ drawMagnifier();
904
+ renderFillNote();
905
+ var mg = document.getElementById('midgray-note');
906
+ if (mg && isMidGray()) mg.hidden = false;
907
+ return computeTrimmedVariant();
908
+ }).catch(function (e) {
909
+ document.getElementById('contrast-grid').textContent = 'Contrast analysis unavailable: ' + e.message;
910
+ });
911
+ }
912
+ document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', boot) : boot();
913
+ `;
914
+ // ── Manifest snippet (kept in sync with the dojo tool) ──────────────────────
915
+ export const MANIFEST_SNIPPET = `{
916
+ "icons": {
917
+ "16": "icons/16.png",
918
+ "32": "icons/32.png",
919
+ "48": "icons/48.png",
920
+ "128": "icons/128.png"
921
+ },
922
+ "action": {
923
+ "default_icon": {
924
+ "16": "icons/16.png",
925
+ "32": "icons/32.png"
926
+ }
927
+ }
928
+ }`;
929
+ /** Filesystem-safe base name for downloads derived from the extension name. */
930
+ export function safeDownloadName(name) {
931
+ const cleaned = name
932
+ .toLowerCase()
933
+ .replace(/[^a-z0-9]+/gi, '-')
934
+ .replace(/^-+|-+$/g, '')
935
+ .slice(0, 48);
936
+ return cleaned || 'icon';
937
+ }
938
+ // ── Renderer ────────────────────────────────────────────────────────────────
939
+ export function renderIconPreviewHtml(input) {
940
+ const config = {
941
+ name: input.name,
942
+ dataUrl: input.dataUrl,
943
+ isSvg: input.isSvg,
944
+ svgSource: input.isSvg ? (input.svgSource ?? null) : null,
945
+ manifestSnippet: MANIFEST_SNIPPET,
946
+ safeName: safeDownloadName(input.name),
947
+ neighbors: [NEIGHBOR_GRID, NEIGHBOR_BOLT],
948
+ };
949
+ const generated = input.generatedAt.toISOString().slice(0, 16).replace('T', ' ');
950
+ const sizeCells = [16, 32, 48, 128]
951
+ .map((size) => `<div class="size-cell">
952
+ <span class="checker"><img class="icon-self" src="${esc(input.dataUrl)}" width="${size}" height="${size}" alt="${size}px preview"></span>
953
+ <span>${size}px</span>
954
+ </div>`)
955
+ .join('\n');
956
+ const rasterNote = input.isSvg
957
+ ? ''
958
+ : '<p class="raster-note">Source is a raster PNG — exports are resized from it; sizes above its native resolution will look soft. Prefer an SVG source when possible.</p>';
959
+ // Full page gets the sticky Extenshi topbar with the theme toggle; the dojo
960
+ // embed drops it (the host page supplies its own chrome and theme control).
961
+ const topbar = input.embedded
962
+ ? ''
963
+ : `<header class="topbar">
964
+ <span class="logo">${LOGO_SVG}</span>
965
+ <span class="brand">extenshi</span>
966
+ <span class="sep">·</span>
967
+ <span class="subtitle">Icon preview</span>
968
+ <span class="spacer"></span>
969
+ <button class="tg" id="theme-toggle" type="button" aria-label="Toggle colour theme"></button>
970
+ </header>
971
+ `;
972
+ const header = input.embedded
973
+ ? ''
974
+ : `<header class="page-head">
975
+ <div class="brand">Extenshi · Icon preview</div>
976
+ <h1>${esc(input.name)}</h1>
977
+ <p class="meta">${esc(input.fileName)} · generated ${esc(generated)} UTC · @extenshi/cli v${esc(input.cliVersion)}</p>
978
+ </header>
979
+
980
+ `;
981
+ const exportSection = input.embedded
982
+ ? ''
983
+ : `<section class="card">
984
+ <h2>Export</h2>
985
+ <div class="export-row">
986
+ <button class="btn" type="button" id="png-16">PNG 16</button>
987
+ <button class="btn" type="button" id="png-32">PNG 32</button>
988
+ <button class="btn" type="button" id="png-48">PNG 48</button>
989
+ <button class="btn" type="button" id="png-128">PNG 128</button>
990
+ <button class="btn primary" type="button" id="zip-btn">Download ZIP (PNG set${input.isSvg ? ' + SVG' : ''} + manifest)</button>
991
+ <button class="btn" type="button" id="copy-btn">Copy manifest snippet</button>
992
+ </div>
993
+ <pre class="snippet"><code>${esc(MANIFEST_SNIPPET)}</code></pre>
994
+ </section>
995
+
996
+ <section class="card next-steps">
997
+ <div class="next-head">
998
+ <span class="next-badge">${SHIP_ICON}</span>
999
+ <div>
1000
+ <h2>Ship the rest with Extenshi</h2>
1001
+ <p class="next-sub">The same free CLI takes you from icon to a clean store submission.</p>
1002
+ </div>
1003
+ </div>
1004
+ <div class="next-grid">
1005
+ <div class="next-item">
1006
+ <span class="next-ico">${RISK_ICON}</span>
1007
+ <div class="next-body">
1008
+ <code>npx @extenshi/cli review-risk dist.zip</code>
1009
+ <p>Predict the Chrome Web Store review outcome before you submit — offline, no account.</p>
1010
+ </div>
1011
+ </div>
1012
+ <div class="next-item">
1013
+ <span class="next-ico">${SCAN_ICON}</span>
1014
+ <div class="next-body">
1015
+ <code>npx @extenshi/cli scan dist.zip</code>
1016
+ <p>Pre-publish security scan of the built artifact, with a free monthly allowance.</p>
1017
+ </div>
1018
+ </div>
1019
+ <div class="next-item">
1020
+ <span class="next-ico">${BOX_ICON}</span>
1021
+ <div class="next-body">
1022
+ <a href="https://catalog.extenshi.io/developers#tools">The full developer toolbox →</a>
1023
+ <p>Manifest generator, privacy-policy generator, and more — free at extenshi.io.</p>
1024
+ </div>
1025
+ </div>
1026
+ </div>
1027
+ </section>
1028
+
1029
+ <p class="foot">Generated locally by <a href="https://extenshi.io">@extenshi/cli</a> — no data left your machine.</p>`;
1030
+ return `<!doctype html>
1031
+ <html lang="en">
1032
+ <head>
1033
+ <meta charset="utf-8">
1034
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1035
+ <title>${esc(input.name)} — icon preview · Extenshi</title>
1036
+ <script>(function(){try{var t=localStorage.getItem('extenshi-report-theme');if(!t)t=(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';if(t==='dark')document.documentElement.classList.add('dark');}catch(e){}})();</script>
1037
+ <style>${CSS}</style>
1038
+ </head>
1039
+ <body>
1040
+ ${topbar}<main>
1041
+ ${header}<section class="card">
1042
+ <div class="themes-head">
1043
+ <h2>Palette<span class="hint">how the toolbar around your icon is colored</span></h2>
1044
+ <label class="toggle"><input type="checkbox" id="hl-toggle"> highlight my icon</label>
1045
+ </div>
1046
+ <div class="chips" id="theme-chips"></div>
1047
+ <label class="custom">Custom toolbar color <input type="color" id="custom-color" value="#8b5cf6"></label>
1048
+ </section>
1049
+
1050
+ <div id="mockups">
1051
+ ${browserMockup('chrome', input)}
1052
+ ${browserMockup('firefox', input)}
1053
+ ${browserMockup('edge', input)}
1054
+ </div>
1055
+
1056
+ <section class="card">
1057
+ <h2>Contrast across palettes<span class="hint">does the icon survive every background?</span></h2>
1058
+ <p class="note-warn" id="midgray-note" hidden>△ Low-saturation mid-gray icon: it clears the contrast ratio on every palette, yet stands out on none of them — mid-gray reads as "part of the toolbar" everywhere. Consider a saturated brand color, or add a darker/lighter outline or backdrop shape.</p>
1059
+ <div id="contrast-grid"></div>
1060
+ </section>
1061
+
1062
+ <section class="card">
1063
+ <h2>Store sizes</h2>
1064
+ ${rasterNote}
1065
+ <div class="size-flex">
1066
+ ${sizeCells}
1067
+ </div>
1068
+ <p class="fill-note" id="fill-note"></p>
1069
+ <div class="trim-callout" id="trim-row" hidden>
1070
+ <span class="trim-ico">${CROP_ICON}</span>
1071
+ <div class="trim-text">
1072
+ <strong>Trim empty margins</strong>
1073
+ <p>Rescale the artwork edge-to-edge — applied to every preview, the size matrix, and all exports.</p>
1074
+ </div>
1075
+ <label class="switch" title="Toggle edge-to-edge trim">
1076
+ <input type="checkbox" id="trim-toggle">
1077
+ <span class="switch-track"><span class="switch-thumb"></span></span>
1078
+ </label>
1079
+ </div>
1080
+ <div class="mag-wrap">
1081
+ <span class="mag-box" id="mag-box"><canvas id="mag" width="16" height="16"></canvas></span>
1082
+ <p class="mag-note">The 16&nbsp;px render at 8× magnification on the selected toolbar color — this is the version most users will actually see. Check that the silhouette stays readable and edges don't dissolve.</p>
1083
+ </div>
1084
+ </section>
1085
+
1086
+ ${exportSection}
1087
+ </main>
1088
+ <script>window.__ICON_PREVIEW__ = ${jsonForScript(config)};</script>
1089
+ <script>${PAGE_JS}</script>
1090
+ </body>
1091
+ </html>
1092
+ `;
1093
+ }
1094
+ //# sourceMappingURL=icon-preview.js.map