@extenshi/cli 0.5.3 → 0.6.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.
- package/dist/auto-update.d.ts.map +1 -1
- package/dist/auto-update.js +3 -5
- package/dist/auto-update.js.map +1 -1
- package/dist/cli.js +29 -6
- package/dist/cli.js.map +1 -1
- package/dist/icon-preview.d.ts +60 -0
- package/dist/icon-preview.d.ts.map +1 -0
- package/dist/icon-preview.js +918 -0
- package/dist/icon-preview.js.map +1 -0
- package/dist/icon.d.ts +39 -0
- package/dist/icon.d.ts.map +1 -0
- package/dist/icon.js +128 -0
- package/dist/icon.js.map +1 -0
- package/dist/review-checks.d.ts.map +1 -1
- package/dist/review-checks.js +123 -22
- package/dist/review-checks.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,918 @@
|
|
|
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
|
+
'&': '&',
|
|
37
|
+
'<': '<',
|
|
38
|
+
'>': '>',
|
|
39
|
+
'"': '"',
|
|
40
|
+
"'": ''',
|
|
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
|
+
// ── Mockup HTML fragments ───────────────────────────────────────────────────
|
|
62
|
+
/** The pinned-extensions cluster shared by every toolbar mockup. */
|
|
63
|
+
function pinsHtml(iconUrl, name) {
|
|
64
|
+
return `<span class="pin"><img src="${NEIGHBOR_GRID}" alt="" width="16" height="16"></span>
|
|
65
|
+
<span class="pin"><img src="${NEIGHBOR_BOLT}" alt="" width="16" height="16"></span>
|
|
66
|
+
<span class="pin yours" title="${esc(name)}"><img class="icon-self" src="${esc(iconUrl)}" alt="${esc(name)}" width="16" height="16"></span>
|
|
67
|
+
<span class="pin puzzle" title="Extensions">${PUZZLE_ICON}</span>`;
|
|
68
|
+
}
|
|
69
|
+
// macOS window controls — present on every mockup, like a real window.
|
|
70
|
+
const TRAFFIC_LIGHTS = '<span class="lights"><i class="l r"></i><i class="l y"></i><i class="l g"></i></span>';
|
|
71
|
+
// Firefox chrome pieces (see the real macOS header): Firefox View button in the
|
|
72
|
+
// tab strip, a sidebar toggle in the toolbar, a search-first address bar.
|
|
73
|
+
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>';
|
|
74
|
+
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>';
|
|
75
|
+
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>';
|
|
76
|
+
function browserMockup(kind, input) {
|
|
77
|
+
const label = kind === 'chrome' ? 'Chrome' : kind === 'firefox' ? 'Firefox' : 'Edge';
|
|
78
|
+
const menuGlyph = kind === 'firefox' ? '☰' : kind === 'edge' ? '…' : '⋮';
|
|
79
|
+
const urlText = 'extenshi.io';
|
|
80
|
+
const extMenu = kind === 'chrome'
|
|
81
|
+
? `<div class="extmenu">
|
|
82
|
+
<div class="extmenu-title">Extensions</div>
|
|
83
|
+
<div class="extmenu-row you">
|
|
84
|
+
<img class="icon-self" src="${esc(input.dataUrl)}" alt="" width="20" height="20">
|
|
85
|
+
<span class="extmenu-name">${esc(input.name)}</span>
|
|
86
|
+
<span class="extmenu-pin">${PIN_ICON}</span>
|
|
87
|
+
</div>
|
|
88
|
+
<div class="extmenu-row">
|
|
89
|
+
<img src="${NEIGHBOR_GRID}" alt="" width="20" height="20">
|
|
90
|
+
<span class="extmenu-name">Grid Notes</span>
|
|
91
|
+
<span class="extmenu-pin dim">${PIN_ICON}</span>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="extmenu-row">
|
|
94
|
+
<img src="${NEIGHBOR_BOLT}" alt="" width="20" height="20">
|
|
95
|
+
<span class="extmenu-name">Quick Actions</span>
|
|
96
|
+
<span class="extmenu-pin dim">${PIN_ICON}</span>
|
|
97
|
+
</div>
|
|
98
|
+
</div>`
|
|
99
|
+
: '';
|
|
100
|
+
// Firefox has its own header anatomy (see the real macOS window): Firefox
|
|
101
|
+
// View before the tabs, a tab-list chevron at the far right of the strip,
|
|
102
|
+
// a sidebar toggle, a search-first address bar, and ☰ instead of an avatar.
|
|
103
|
+
const tabs = kind === 'firefox'
|
|
104
|
+
? `<div class="tabs">
|
|
105
|
+
${TRAFFIC_LIGHTS}
|
|
106
|
+
<span class="strip-btn" title="Firefox View">${FFVIEW_ICON}</span>
|
|
107
|
+
<div class="tab active"><span class="fav"></span><span class="tab-title">New Tab</span><span class="tab-x">×</span></div>
|
|
108
|
+
<span class="newtab">+</span>
|
|
109
|
+
<span class="strip-spacer"></span>
|
|
110
|
+
<span class="strip-btn chevron" title="List all tabs">⌄</span>
|
|
111
|
+
</div>`
|
|
112
|
+
: `<div class="tabs">
|
|
113
|
+
${TRAFFIC_LIGHTS}
|
|
114
|
+
<div class="tab active"><span class="fav"></span><span class="tab-title">New Tab</span><span class="tab-x">×</span></div>
|
|
115
|
+
<div class="tab"><span class="fav"></span><span class="tab-title">Docs — Extenshi</span></div>
|
|
116
|
+
<span class="newtab">+</span>
|
|
117
|
+
</div>`;
|
|
118
|
+
const toolbar = kind === 'firefox'
|
|
119
|
+
? `<div class="toolbar">
|
|
120
|
+
<span class="navbtn">${SIDEBAR_ICON}</span>
|
|
121
|
+
<span class="navbtn">←</span><span class="navbtn dim">→</span><span class="navbtn">⟳</span>
|
|
122
|
+
<div class="urlbar"><span class="lock">${SEARCH_ICON}</span><span class="url placeholder">Search or enter address</span></div>
|
|
123
|
+
<div class="pins">${pinsHtml(input.dataUrl, input.name)}</div>
|
|
124
|
+
<span class="menu">${menuGlyph}</span>
|
|
125
|
+
</div>`
|
|
126
|
+
: `<div class="toolbar">
|
|
127
|
+
<span class="navbtn">←</span><span class="navbtn dim">→</span><span class="navbtn">⟳</span>
|
|
128
|
+
<div class="urlbar"><span class="lock">${LOCK_ICON}</span><span class="url">${urlText}</span></div>
|
|
129
|
+
<div class="pins">${pinsHtml(input.dataUrl, input.name)}</div>
|
|
130
|
+
<span class="avatar"></span>
|
|
131
|
+
<span class="menu">${menuGlyph}</span>
|
|
132
|
+
</div>`;
|
|
133
|
+
return `<section class="browser ${kind}">
|
|
134
|
+
<h3>${label}</h3>
|
|
135
|
+
<div class="bwin">
|
|
136
|
+
${tabs}
|
|
137
|
+
${toolbar}
|
|
138
|
+
<div class="pagearea"></div>
|
|
139
|
+
</div>
|
|
140
|
+
${extMenu}
|
|
141
|
+
</section>`;
|
|
142
|
+
}
|
|
143
|
+
// ── Page CSS ────────────────────────────────────────────────────────────────
|
|
144
|
+
const CSS = `
|
|
145
|
+
:root {
|
|
146
|
+
--t-toolbar: #ffffff; --t-tabstrip: #dfe1e5; --t-url: #f1f3f4;
|
|
147
|
+
--t-text: #202124; --t-muted: #5f6368; --t-border: rgba(0,0,0,.12);
|
|
148
|
+
--page-bg: #f6f7f9; --page-text: #1c1e21; --card: #ffffff; --card-border: #e4e6ea;
|
|
149
|
+
--accent: #5e5ce6;
|
|
150
|
+
}
|
|
151
|
+
@media (prefers-color-scheme: dark) {
|
|
152
|
+
:root { --page-bg: #131417; --page-text: #e8eaed; --card: #1c1e22; --card-border: #2a2d33; }
|
|
153
|
+
}
|
|
154
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
155
|
+
body {
|
|
156
|
+
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
157
|
+
background: var(--page-bg); color: var(--page-text); padding: 28px 20px 60px;
|
|
158
|
+
}
|
|
159
|
+
main { max-width: 1080px; margin: 0 auto; display: grid; gap: 22px; }
|
|
160
|
+
.page-head .brand { font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--accent); font-weight: 600; }
|
|
161
|
+
.page-head h1 { font-size: 24px; margin-top: 2px; }
|
|
162
|
+
.page-head .meta { color: color-mix(in srgb, var(--page-text) 55%, transparent); font-size: 12.5px; margin-top: 2px; }
|
|
163
|
+
section.card { background: var(--card); border: 1px solid var(--card-border); border-radius: 12px; padding: 18px; }
|
|
164
|
+
section.card > h2 { font-size: 15px; margin-bottom: 12px; }
|
|
165
|
+
h2 .hint { font-weight: 400; font-size: 12.5px; color: color-mix(in srgb, var(--page-text) 55%, transparent); margin-left: 8px; }
|
|
166
|
+
|
|
167
|
+
/* Palette chips */
|
|
168
|
+
.chips { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
169
|
+
.chip {
|
|
170
|
+
display: inline-flex; align-items: center; gap: 7px; padding: 6px 12px; border-radius: 999px;
|
|
171
|
+
border: 1px solid var(--card-border); background: transparent; color: inherit; cursor: pointer; font: inherit; font-size: 13px;
|
|
172
|
+
}
|
|
173
|
+
.chip .sw { width: 14px; height: 14px; border-radius: 50%; border: 1px solid rgba(0,0,0,.2); }
|
|
174
|
+
.chip.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
|
175
|
+
.custom { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; margin-left: 4px; }
|
|
176
|
+
.custom input { width: 30px; height: 30px; border: none; background: none; padding: 0; cursor: pointer; }
|
|
177
|
+
|
|
178
|
+
/* Browser mockups */
|
|
179
|
+
#mockups { display: grid; gap: 18px; }
|
|
180
|
+
.browser h3 { font-size: 13px; color: color-mix(in srgb, var(--page-text) 60%, transparent); margin-bottom: 6px; }
|
|
181
|
+
.bwin { border-radius: 10px; overflow: hidden; border: 1px solid var(--t-border); box-shadow: 0 1px 4px rgba(0,0,0,.12); }
|
|
182
|
+
.tabs { display: flex; align-items: flex-end; gap: 2px; padding: 7px 10px 0; background: var(--t-tabstrip); }
|
|
183
|
+
.tab {
|
|
184
|
+
display: flex; align-items: center; gap: 7px; padding: 6px 12px; font-size: 12px; color: var(--t-muted);
|
|
185
|
+
border-radius: 8px 8px 0 0; max-width: 190px; white-space: nowrap; overflow: hidden;
|
|
186
|
+
}
|
|
187
|
+
.tab.active { background: var(--t-toolbar); color: var(--t-text); }
|
|
188
|
+
.tab .fav { width: 12px; height: 12px; border-radius: 3px; background: color-mix(in srgb, var(--t-muted) 55%, transparent); flex: none; }
|
|
189
|
+
.tab .tab-title { overflow: hidden; text-overflow: ellipsis; }
|
|
190
|
+
.tab .tab-x { margin-left: 4px; opacity: .6; }
|
|
191
|
+
.newtab { align-self: center; padding: 0 10px 4px; color: var(--t-muted); font-size: 15px; }
|
|
192
|
+
.lights { display: inline-flex; align-items: center; gap: 6.5px; align-self: center; padding: 0 10px 4px 6px; }
|
|
193
|
+
.lights .l { width: 11px; height: 11px; border-radius: 50%; }
|
|
194
|
+
.lights .l.r { background: #ff5f57; }
|
|
195
|
+
.lights .l.y { background: #febc2e; }
|
|
196
|
+
.lights .l.g { background: #28c840; }
|
|
197
|
+
.firefox .tabs { padding: 6px 8px; align-items: center; }
|
|
198
|
+
.firefox .tab { border-radius: 6px; margin-bottom: 0; padding: 7px 12px; min-width: 150px; }
|
|
199
|
+
.firefox .tab.active { box-shadow: 0 1px 2px rgba(0,0,0,.25); }
|
|
200
|
+
.firefox .tab .tab-x { margin-left: auto; }
|
|
201
|
+
.firefox .lights, .firefox .newtab { padding-bottom: 0; }
|
|
202
|
+
.strip-btn { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 6px; color: var(--t-muted); }
|
|
203
|
+
.strip-btn.chevron { font-size: 12px; }
|
|
204
|
+
.strip-spacer { flex: 1; }
|
|
205
|
+
.firefox .urlbar .placeholder { opacity: .75; }
|
|
206
|
+
.edge .tab { border-radius: 6px 6px 0 0; }
|
|
207
|
+
.toolbar { display: flex; align-items: center; gap: 6px; padding: 7px 12px; background: var(--t-toolbar); color: var(--t-text); }
|
|
208
|
+
.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; }
|
|
209
|
+
.navbtn.dim { opacity: .45; }
|
|
210
|
+
.urlbar {
|
|
211
|
+
flex: 1; display: flex; align-items: center; gap: 7px; height: 30px; padding: 0 13px;
|
|
212
|
+
background: var(--t-url); color: var(--t-muted); border-radius: 999px; font-size: 12.5px; min-width: 120px;
|
|
213
|
+
}
|
|
214
|
+
.firefox .urlbar, .edge .urlbar { border-radius: 7px; }
|
|
215
|
+
.lock { display: inline-flex; opacity: .8; }
|
|
216
|
+
.pins { display: flex; align-items: center; gap: 2px; padding: 0 2px; }
|
|
217
|
+
.pin { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; flex: none; }
|
|
218
|
+
.pin:hover { background: color-mix(in srgb, var(--t-muted) 18%, transparent); }
|
|
219
|
+
.pin img { display: block; }
|
|
220
|
+
.pin.puzzle { color: var(--t-muted); }
|
|
221
|
+
.pin.yours.hl { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
|
|
222
|
+
.avatar { width: 24px; height: 24px; border-radius: 50%; flex: none; background: linear-gradient(135deg, #b7c4d8, #7e93b1); }
|
|
223
|
+
.menu { color: var(--t-muted); padding: 0 4px; font-size: 15px; }
|
|
224
|
+
.pagearea { height: 44px; background: color-mix(in srgb, var(--t-toolbar) 60%, var(--page-bg)); border-top: 1px solid var(--t-border); }
|
|
225
|
+
|
|
226
|
+
/* Chrome puzzle dropdown */
|
|
227
|
+
.extmenu {
|
|
228
|
+
width: 300px; margin: 10px 0 0 auto; background: var(--t-toolbar); color: var(--t-text);
|
|
229
|
+
border: 1px solid var(--t-border); border-radius: 10px; padding: 8px; box-shadow: 0 4px 14px rgba(0,0,0,.18);
|
|
230
|
+
}
|
|
231
|
+
.extmenu-title { font-size: 12px; font-weight: 600; padding: 4px 8px 8px; color: var(--t-muted); }
|
|
232
|
+
.extmenu-row { display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: 7px; font-size: 13px; }
|
|
233
|
+
.extmenu-row:hover { background: color-mix(in srgb, var(--t-muted) 14%, transparent); }
|
|
234
|
+
.extmenu-row.you { background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
|
235
|
+
.extmenu-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
236
|
+
.extmenu-pin { color: var(--t-muted); display: inline-flex; }
|
|
237
|
+
.extmenu-pin.dim { opacity: .4; }
|
|
238
|
+
.extmenu-row.you .extmenu-pin { color: var(--accent); }
|
|
239
|
+
|
|
240
|
+
/* Contrast grid */
|
|
241
|
+
#contrast-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; }
|
|
242
|
+
.cg-cell { border: 1px solid var(--card-border); border-radius: 9px; overflow: hidden; }
|
|
243
|
+
.cg-strip { display: flex; align-items: center; gap: 2px; padding: 9px 12px; }
|
|
244
|
+
.cg-strip .pin { width: 26px; height: 26px; }
|
|
245
|
+
.cg-meta { display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 6px 10px; font-size: 12px; }
|
|
246
|
+
.badge { font-size: 11px; padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
|
|
247
|
+
.badge.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #15803d; }
|
|
248
|
+
.badge.weak { background: color-mix(in srgb, #f59e0b 20%, transparent); color: #b45309; }
|
|
249
|
+
.badge.bad { background: color-mix(in srgb, #ef4444 20%, transparent); color: #b91c1c; }
|
|
250
|
+
@media (prefers-color-scheme: dark) {
|
|
251
|
+
.badge.ok { color: #4ade80; } .badge.weak { color: #fbbf24; } .badge.bad { color: #f87171; }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* Size matrix + magnifier */
|
|
255
|
+
.size-flex { display: flex; flex-wrap: wrap; gap: 26px; align-items: flex-end; }
|
|
256
|
+
.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); }
|
|
257
|
+
.checker {
|
|
258
|
+
display: inline-flex; align-items: center; justify-content: center; padding: 8px; border-radius: 8px; border: 1px solid var(--card-border);
|
|
259
|
+
background: repeating-conic-gradient(rgba(128,128,128,.16) 0% 25%, transparent 0% 50%); background-size: 14px 14px;
|
|
260
|
+
}
|
|
261
|
+
.mag-wrap { display: flex; gap: 18px; align-items: center; margin-top: 16px; flex-wrap: wrap; }
|
|
262
|
+
#mag { image-rendering: pixelated; width: 128px; height: 128px; border-radius: 8px; }
|
|
263
|
+
.mag-box { display: inline-flex; padding: 10px; border-radius: 10px; background: var(--t-toolbar); border: 1px solid var(--t-border); }
|
|
264
|
+
.mag-note { font-size: 12.5px; color: color-mix(in srgb, var(--page-text) 60%, transparent); max-width: 380px; }
|
|
265
|
+
.fill-note { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 12.5px; margin-top: 14px; color: color-mix(in srgb, var(--page-text) 72%, transparent); }
|
|
266
|
+
label.toggle.trim { margin: 10px 0 0; display: flex; }
|
|
267
|
+
.note-warn {
|
|
268
|
+
font-size: 12.5px; line-height: 1.45; padding: 9px 12px; border-radius: 8px; margin-bottom: 12px;
|
|
269
|
+
background: color-mix(in srgb, #f59e0b 14%, transparent); color: #b45309;
|
|
270
|
+
}
|
|
271
|
+
@media (prefers-color-scheme: dark) { .note-warn { color: #fbbf24; } }
|
|
272
|
+
|
|
273
|
+
/* Export */
|
|
274
|
+
.export-row { display: flex; flex-wrap: wrap; gap: 9px; margin-bottom: 13px; }
|
|
275
|
+
button.btn {
|
|
276
|
+
font: inherit; font-size: 13px; padding: 7px 14px; border-radius: 8px; cursor: pointer;
|
|
277
|
+
border: 1px solid var(--card-border); background: transparent; color: inherit;
|
|
278
|
+
}
|
|
279
|
+
button.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
280
|
+
button.btn:hover { filter: brightness(1.06); }
|
|
281
|
+
pre.snippet { background: color-mix(in srgb, var(--page-text) 7%, transparent); border-radius: 8px; padding: 12px; font-size: 12px; overflow-x: auto; }
|
|
282
|
+
.foot { text-align: center; font-size: 12px; color: color-mix(in srgb, var(--page-text) 45%, transparent); }
|
|
283
|
+
.foot a { color: var(--accent); }
|
|
284
|
+
.raster-note { font-size: 12.5px; color: #b45309; margin-bottom: 10px; }
|
|
285
|
+
label.toggle { display: inline-flex; gap: 7px; align-items: center; font-size: 13px; cursor: pointer; margin-left: auto; }
|
|
286
|
+
.themes-head { display: flex; align-items: center; }
|
|
287
|
+
`;
|
|
288
|
+
// ── Embedded page JS ────────────────────────────────────────────────────────
|
|
289
|
+
// Written in plain ES2020 with string concatenation (no template literals) so
|
|
290
|
+
// the outer TS template literal never needs escaped interpolations.
|
|
291
|
+
const PAGE_JS = String.raw `
|
|
292
|
+
'use strict';
|
|
293
|
+
var CFG = window.__ICON_PREVIEW__;
|
|
294
|
+
|
|
295
|
+
// ---- palettes ----
|
|
296
|
+
var THEMES = [
|
|
297
|
+
{ id: 'light', label: 'Light', toolbar: '#ffffff', tabstrip: '#dfe1e5' },
|
|
298
|
+
{ id: 'tinted', label: 'Light tinted',toolbar: '#e9eef6', tabstrip: '#d4dceb' },
|
|
299
|
+
{ id: 'dark', label: 'Dark', toolbar: '#35363a', tabstrip: '#202124' },
|
|
300
|
+
{ id: 'black', label: 'Black', toolbar: '#1b1b1f', tabstrip: '#0e0e10' },
|
|
301
|
+
{ id: 'ocean', label: 'Saturated', toolbar: '#174ea6', tabstrip: '#0d3a80' }
|
|
302
|
+
];
|
|
303
|
+
|
|
304
|
+
function hexToRgb(hex) {
|
|
305
|
+
var h = hex.replace('#', '');
|
|
306
|
+
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
|
307
|
+
var n = parseInt(h, 16);
|
|
308
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
309
|
+
}
|
|
310
|
+
function rgbToHex(rgb) {
|
|
311
|
+
return '#' + rgb.map(function (v) {
|
|
312
|
+
var s = Math.max(0, Math.min(255, Math.round(v))).toString(16);
|
|
313
|
+
return s.length === 1 ? '0' + s : s;
|
|
314
|
+
}).join('');
|
|
315
|
+
}
|
|
316
|
+
function mix(a, b, t) {
|
|
317
|
+
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
|
318
|
+
}
|
|
319
|
+
// WCAG relative luminance
|
|
320
|
+
function relLum(rgb) {
|
|
321
|
+
var c = rgb.map(function (v) {
|
|
322
|
+
v /= 255;
|
|
323
|
+
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
324
|
+
});
|
|
325
|
+
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
|
|
326
|
+
}
|
|
327
|
+
function contrastRatio(a, b) {
|
|
328
|
+
var l1 = relLum(a), l2 = relLum(b);
|
|
329
|
+
var hi = Math.max(l1, l2), lo = Math.min(l1, l2);
|
|
330
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
331
|
+
}
|
|
332
|
+
function deriveTheme(toolbarHex) {
|
|
333
|
+
var t = hexToRgb(toolbarHex);
|
|
334
|
+
var darkText = relLum(t) > 0.4;
|
|
335
|
+
var text = darkText ? [32, 33, 36] : [235, 237, 240];
|
|
336
|
+
return {
|
|
337
|
+
id: 'custom', label: 'Custom',
|
|
338
|
+
toolbar: toolbarHex,
|
|
339
|
+
tabstrip: rgbToHex(mix(t, darkText ? [0, 0, 0] : [0, 0, 0], darkText ? 0.14 : 0.4)),
|
|
340
|
+
text: rgbToHex(text),
|
|
341
|
+
muted: rgbToHex(mix(t, text, 0.62)),
|
|
342
|
+
url: rgbToHex(mix(t, text, darkText ? 0.06 : 0.1))
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
function materialize(theme) {
|
|
346
|
+
if (theme.text) return theme;
|
|
347
|
+
var full = deriveTheme(theme.toolbar);
|
|
348
|
+
return {
|
|
349
|
+
id: theme.id, label: theme.label,
|
|
350
|
+
toolbar: theme.toolbar, tabstrip: theme.tabstrip,
|
|
351
|
+
text: full.text, muted: full.muted, url: full.url
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
var currentTheme = materialize(THEMES[0]);
|
|
356
|
+
function applyTheme(theme) {
|
|
357
|
+
currentTheme = materialize(theme);
|
|
358
|
+
var r = document.documentElement.style;
|
|
359
|
+
r.setProperty('--t-toolbar', currentTheme.toolbar);
|
|
360
|
+
r.setProperty('--t-tabstrip', currentTheme.tabstrip);
|
|
361
|
+
r.setProperty('--t-url', currentTheme.url);
|
|
362
|
+
r.setProperty('--t-text', currentTheme.text);
|
|
363
|
+
r.setProperty('--t-muted', currentTheme.muted);
|
|
364
|
+
r.setProperty('--t-border', relLum(hexToRgb(currentTheme.toolbar)) > 0.4 ? 'rgba(0,0,0,.14)' : 'rgba(255,255,255,.14)');
|
|
365
|
+
document.querySelectorAll('.chip[data-theme]').forEach(function (c) {
|
|
366
|
+
c.classList.toggle('active', c.getAttribute('data-theme') === currentTheme.id);
|
|
367
|
+
});
|
|
368
|
+
drawMagnifier();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ---- icon raster helpers (SVG/PNG → canvas) ----
|
|
372
|
+
var iconImg = null;
|
|
373
|
+
function loadIcon() {
|
|
374
|
+
return new Promise(function (resolve, reject) {
|
|
375
|
+
if (iconImg) { resolve(iconImg); return; }
|
|
376
|
+
var img = new Image();
|
|
377
|
+
img.onload = function () { iconImg = img; resolve(img); };
|
|
378
|
+
img.onerror = function () { reject(new Error('Failed to load the icon image')); };
|
|
379
|
+
img.src = CFG.dataUrl;
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
// ---- trim-to-edges (canvas-usage) state ----
|
|
383
|
+
// When the artwork leaves wide transparent margins inside its viewBox, the
|
|
384
|
+
// 16px toolbar render wastes those pixels. "trimmed" holds an edge-to-edge
|
|
385
|
+
// variant (vector viewBox re-crop for SVG, canvas crop for PNG) that the trim
|
|
386
|
+
// toggle swaps into every preview AND every export.
|
|
387
|
+
var trimOn = false;
|
|
388
|
+
var trimmed = null; // { svgSource?, displayUrl, img? }
|
|
389
|
+
|
|
390
|
+
function activeSvgSource() {
|
|
391
|
+
return trimOn && trimmed && trimmed.svgSource ? trimmed.svgSource : CFG.svgSource;
|
|
392
|
+
}
|
|
393
|
+
function displayIconUrl() {
|
|
394
|
+
return trimOn && trimmed ? trimmed.displayUrl : CFG.dataUrl;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function parseSvg(source) {
|
|
398
|
+
var doc = new DOMParser().parseFromString(source, 'image/svg+xml');
|
|
399
|
+
var el = doc.querySelector('svg');
|
|
400
|
+
if (el && !el.getAttribute('viewBox')) {
|
|
401
|
+
var w = parseFloat(el.getAttribute('width') || '') || 24;
|
|
402
|
+
var h = parseFloat(el.getAttribute('height') || '') || 24;
|
|
403
|
+
el.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
|
|
404
|
+
}
|
|
405
|
+
return el;
|
|
406
|
+
}
|
|
407
|
+
// For SVG sources, re-encode with explicit pixel dimensions so every browser
|
|
408
|
+
// rasterizes at the exact target size (Firefox refuses dimension-less SVGs).
|
|
409
|
+
function sizedSvgUrl(size) {
|
|
410
|
+
var el = parseSvg(activeSvgSource());
|
|
411
|
+
if (!el) return CFG.dataUrl;
|
|
412
|
+
el.setAttribute('width', String(size));
|
|
413
|
+
el.setAttribute('height', String(size));
|
|
414
|
+
var xml = new XMLSerializer().serializeToString(el);
|
|
415
|
+
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(xml);
|
|
416
|
+
}
|
|
417
|
+
function renderToCanvas(size) {
|
|
418
|
+
return new Promise(function (resolve, reject) {
|
|
419
|
+
var finish = function (img) {
|
|
420
|
+
var canvas = document.createElement('canvas');
|
|
421
|
+
canvas.width = size; canvas.height = size;
|
|
422
|
+
var ctx = canvas.getContext('2d');
|
|
423
|
+
ctx.imageSmoothingEnabled = true;
|
|
424
|
+
ctx.imageSmoothingQuality = 'high';
|
|
425
|
+
ctx.drawImage(img, 0, 0, size, size);
|
|
426
|
+
resolve(canvas);
|
|
427
|
+
};
|
|
428
|
+
if (CFG.isSvg && CFG.svgSource) {
|
|
429
|
+
var img = new Image();
|
|
430
|
+
img.onload = function () { finish(img); };
|
|
431
|
+
img.onerror = function () { reject(new Error('SVG rasterization failed')); };
|
|
432
|
+
img.src = sizedSvgUrl(size);
|
|
433
|
+
} else if (trimOn && trimmed && trimmed.img) {
|
|
434
|
+
finish(trimmed.img);
|
|
435
|
+
} else {
|
|
436
|
+
loadIcon().then(finish, reject);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
function canvasToBlob(canvas) {
|
|
441
|
+
return new Promise(function (resolve, reject) {
|
|
442
|
+
canvas.toBlob(function (b) { b ? resolve(b) : reject(new Error('canvas.toBlob returned null')); }, 'image/png');
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
function downloadBlob(name, blob) {
|
|
446
|
+
var url = URL.createObjectURL(blob);
|
|
447
|
+
var a = document.createElement('a');
|
|
448
|
+
a.href = url; a.download = name;
|
|
449
|
+
document.body.appendChild(a);
|
|
450
|
+
a.click();
|
|
451
|
+
a.remove();
|
|
452
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---- icon analysis ----
|
|
456
|
+
// One 128px pass over the UNTRIMMED source: alpha-weighted average color,
|
|
457
|
+
// coverage, average chroma (for the mid-gray heuristic), and the opaque-pixel
|
|
458
|
+
// bounding box (for the canvas-usage metric + trim).
|
|
459
|
+
var ANALYZE_SIZE = 128;
|
|
460
|
+
var iconStats = null;
|
|
461
|
+
function analyzeIcon() {
|
|
462
|
+
return renderToCanvas(ANALYZE_SIZE).then(function (canvas) {
|
|
463
|
+
var n = ANALYZE_SIZE;
|
|
464
|
+
var d = canvas.getContext('2d').getImageData(0, 0, n, n).data;
|
|
465
|
+
var r = 0, g = 0, b = 0, wsum = 0, opaque = 0, chroma = 0;
|
|
466
|
+
var x0 = n, y0 = n, x1 = -1, y1 = -1;
|
|
467
|
+
for (var y = 0; y < n; y++) {
|
|
468
|
+
for (var x = 0; x < n; x++) {
|
|
469
|
+
var i = (y * n + x) * 4;
|
|
470
|
+
var a = d[i + 3] / 255;
|
|
471
|
+
if (a > 0.05) {
|
|
472
|
+
opaque++;
|
|
473
|
+
if (x < x0) x0 = x;
|
|
474
|
+
if (x > x1) x1 = x;
|
|
475
|
+
if (y < y0) y0 = y;
|
|
476
|
+
if (y > y1) y1 = y;
|
|
477
|
+
}
|
|
478
|
+
r += d[i] * a; g += d[i + 1] * a; b += d[i + 2] * a; wsum += a;
|
|
479
|
+
chroma += (Math.max(d[i], d[i + 1], d[i + 2]) - Math.min(d[i], d[i + 1], d[i + 2])) * a;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (wsum > 0 && x1 >= 0) {
|
|
483
|
+
iconStats = {
|
|
484
|
+
avg: [r / wsum, g / wsum, b / wsum],
|
|
485
|
+
coverage: opaque / (n * n),
|
|
486
|
+
chroma: chroma / wsum,
|
|
487
|
+
bbox: { x0: x0, y0: y0, x1: x1, y1: y1 },
|
|
488
|
+
fill: Math.max(x1 - x0 + 1, y1 - y0 + 1) / n
|
|
489
|
+
};
|
|
490
|
+
} else {
|
|
491
|
+
iconStats = { avg: [128, 128, 128], coverage: 0, chroma: 0, bbox: null, fill: 0 };
|
|
492
|
+
}
|
|
493
|
+
return iconStats;
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
function contrastBadge(theme) {
|
|
497
|
+
var ratio = contrastRatio(iconStats.avg, hexToRgb(theme.toolbar));
|
|
498
|
+
if (ratio < 1.45) return { cls: 'bad', label: '⚠ melts into background', ratio: ratio };
|
|
499
|
+
if (ratio < 2.2) return { cls: 'weak', label: '△ low contrast', ratio: ratio };
|
|
500
|
+
return { cls: 'ok', label: '✓ reads well', ratio: ratio };
|
|
501
|
+
}
|
|
502
|
+
// Mid-gray heuristic: a desaturated icon whose luminance sits in the middle
|
|
503
|
+
// clears the WCAG ratio against EVERY toolbar (≈3–4:1 everywhere) yet stands
|
|
504
|
+
// out on none of them — the ratio badges alone can't catch that.
|
|
505
|
+
function isMidGray() {
|
|
506
|
+
if (!iconStats || iconStats.coverage < 0.04) return false;
|
|
507
|
+
var lum = relLum(iconStats.avg);
|
|
508
|
+
return iconStats.chroma < 30 && lum > 0.1 && lum < 0.62;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ---- canvas-usage metric + trim toggle ----
|
|
512
|
+
function computeTrimmedVariant() {
|
|
513
|
+
if (!iconStats || !iconStats.bbox || iconStats.fill >= 0.98) { trimmed = null; return Promise.resolve(); }
|
|
514
|
+
var n = ANALYZE_SIZE;
|
|
515
|
+
var bb = iconStats.bbox;
|
|
516
|
+
var pad = 0.02; // 2% breathing room so strokes don't touch the very edge
|
|
517
|
+
if (CFG.isSvg && CFG.svgSource) {
|
|
518
|
+
var el = parseSvg(CFG.svgSource);
|
|
519
|
+
if (!el) { trimmed = null; return Promise.resolve(); }
|
|
520
|
+
var vb = (el.getAttribute('viewBox') || '0 0 24 24').trim().split(/[\s,]+/).map(Number);
|
|
521
|
+
var bw = ((bb.x1 - bb.x0 + 1) / n) * vb[2];
|
|
522
|
+
var bh = ((bb.y1 - bb.y0 + 1) / n) * vb[3];
|
|
523
|
+
var cx = vb[0] + ((bb.x0 + bb.x1 + 1) / 2 / n) * vb[2];
|
|
524
|
+
var cy = vb[1] + ((bb.y0 + bb.y1 + 1) / 2 / n) * vb[3];
|
|
525
|
+
var side = Math.max(bw, bh) * (1 + pad * 2);
|
|
526
|
+
el.setAttribute('viewBox', (cx - side / 2) + ' ' + (cy - side / 2) + ' ' + side + ' ' + side);
|
|
527
|
+
el.removeAttribute('width');
|
|
528
|
+
el.removeAttribute('height');
|
|
529
|
+
var xml = new XMLSerializer().serializeToString(el);
|
|
530
|
+
trimmed = { svgSource: xml, displayUrl: 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(xml) };
|
|
531
|
+
return Promise.resolve();
|
|
532
|
+
}
|
|
533
|
+
// PNG: crop the natural-resolution image around the (scaled) bounding box.
|
|
534
|
+
return loadIcon().then(function (img) {
|
|
535
|
+
var nw = img.naturalWidth || n, nh = img.naturalHeight || n;
|
|
536
|
+
var sx0 = Math.floor((bb.x0 / n) * nw), sx1 = Math.ceil(((bb.x1 + 1) / n) * nw);
|
|
537
|
+
var sy0 = Math.floor((bb.y0 / n) * nh), sy1 = Math.ceil(((bb.y1 + 1) / n) * nh);
|
|
538
|
+
var bwPx = sx1 - sx0, bhPx = sy1 - sy0;
|
|
539
|
+
var side = Math.ceil(Math.max(bwPx, bhPx) * (1 + pad * 2));
|
|
540
|
+
var canvas = document.createElement('canvas');
|
|
541
|
+
canvas.width = side; canvas.height = side;
|
|
542
|
+
var ctx = canvas.getContext('2d');
|
|
543
|
+
ctx.drawImage(img, sx0, sy0, bwPx, bhPx, Math.round((side - bwPx) / 2), Math.round((side - bhPx) / 2), bwPx, bhPx);
|
|
544
|
+
var url = canvas.toDataURL('image/png');
|
|
545
|
+
return new Promise(function (resolve) {
|
|
546
|
+
var timg = new Image();
|
|
547
|
+
timg.onload = function () { trimmed = { displayUrl: url, img: timg }; resolve(); };
|
|
548
|
+
timg.onerror = function () { trimmed = null; resolve(); };
|
|
549
|
+
timg.src = url;
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function refreshIconEverywhere() {
|
|
555
|
+
var url = displayIconUrl();
|
|
556
|
+
document.querySelectorAll('img.icon-self').forEach(function (img) { img.src = url; });
|
|
557
|
+
drawMagnifier();
|
|
558
|
+
renderContrastGrid();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function renderFillNote() {
|
|
562
|
+
var note = document.getElementById('fill-note');
|
|
563
|
+
if (!note || !iconStats) return;
|
|
564
|
+
var pct = Math.round(iconStats.fill * 100);
|
|
565
|
+
note.textContent = '';
|
|
566
|
+
var badge = document.createElement('span');
|
|
567
|
+
var ok = iconStats.fill >= 0.88;
|
|
568
|
+
badge.className = 'badge ' + (ok ? 'ok' : 'weak');
|
|
569
|
+
badge.textContent = ok ? '✓ fills the canvas well' : '△ scale the artwork to the edges';
|
|
570
|
+
var text = document.createElement('span');
|
|
571
|
+
text.textContent = 'Artwork fills ' + pct + '% of its canvas — the rest is transparent margin that shrinks the icon in the toolbar. ';
|
|
572
|
+
note.appendChild(text);
|
|
573
|
+
note.appendChild(badge);
|
|
574
|
+
var trimRow = document.getElementById('trim-row');
|
|
575
|
+
if (trimRow && iconStats.fill < 0.98 && iconStats.fill > 0) trimRow.hidden = false;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ---- contrast grid ----
|
|
579
|
+
function pinStrip(theme) {
|
|
580
|
+
var wrap = document.createElement('div');
|
|
581
|
+
wrap.className = 'cg-strip';
|
|
582
|
+
wrap.style.background = theme.toolbar;
|
|
583
|
+
var srcs = [CFG.neighbors[0], CFG.neighbors[1], displayIconUrl()];
|
|
584
|
+
srcs.forEach(function (src, i) {
|
|
585
|
+
var pin = document.createElement('span');
|
|
586
|
+
pin.className = 'pin' + (i === 2 ? ' yours' : '');
|
|
587
|
+
var img = document.createElement('img');
|
|
588
|
+
img.src = src; img.width = 16; img.height = 16; img.alt = '';
|
|
589
|
+
pin.appendChild(img);
|
|
590
|
+
wrap.appendChild(pin);
|
|
591
|
+
});
|
|
592
|
+
return wrap;
|
|
593
|
+
}
|
|
594
|
+
function renderContrastGrid() {
|
|
595
|
+
var grid = document.getElementById('contrast-grid');
|
|
596
|
+
grid.textContent = '';
|
|
597
|
+
var themes = THEMES.map(materialize);
|
|
598
|
+
if (currentTheme.id === 'custom') themes.push(currentTheme);
|
|
599
|
+
themes.forEach(function (theme) {
|
|
600
|
+
var cell = document.createElement('div');
|
|
601
|
+
cell.className = 'cg-cell';
|
|
602
|
+
cell.appendChild(pinStrip(theme));
|
|
603
|
+
var meta = document.createElement('div');
|
|
604
|
+
meta.className = 'cg-meta';
|
|
605
|
+
var name = document.createElement('span');
|
|
606
|
+
name.textContent = theme.label;
|
|
607
|
+
var badge = document.createElement('span');
|
|
608
|
+
var v = contrastBadge(theme);
|
|
609
|
+
badge.className = 'badge ' + v.cls;
|
|
610
|
+
badge.title = 'contrast ratio ' + v.ratio.toFixed(2) + ':1 (icon average color vs toolbar)';
|
|
611
|
+
badge.textContent = v.label;
|
|
612
|
+
meta.appendChild(name);
|
|
613
|
+
meta.appendChild(badge);
|
|
614
|
+
cell.appendChild(meta);
|
|
615
|
+
grid.appendChild(cell);
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ---- magnifier ----
|
|
620
|
+
function drawMagnifier() {
|
|
621
|
+
var mag = document.getElementById('mag');
|
|
622
|
+
if (!mag) return;
|
|
623
|
+
renderToCanvas(16).then(function (src) {
|
|
624
|
+
var ctx = mag.getContext('2d');
|
|
625
|
+
ctx.clearRect(0, 0, 16, 16);
|
|
626
|
+
ctx.imageSmoothingEnabled = false;
|
|
627
|
+
ctx.drawImage(src, 0, 0);
|
|
628
|
+
});
|
|
629
|
+
var box = document.getElementById('mag-box');
|
|
630
|
+
if (box) box.style.background = currentTheme.toolbar;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ---- exports ----
|
|
634
|
+
var SIZES = [16, 32, 48, 128];
|
|
635
|
+
function exportPng(size) {
|
|
636
|
+
renderToCanvas(size).then(canvasToBlob).then(function (blob) {
|
|
637
|
+
downloadBlob(size + '.png', blob);
|
|
638
|
+
}).catch(function (e) { alert(e.message); });
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// STORE-method ZIP writer (PNG members are already compressed).
|
|
642
|
+
var CRC_TABLE = (function () {
|
|
643
|
+
var t = new Uint32Array(256);
|
|
644
|
+
for (var n = 0; n < 256; n++) {
|
|
645
|
+
var c = n;
|
|
646
|
+
for (var k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
647
|
+
t[n] = c >>> 0;
|
|
648
|
+
}
|
|
649
|
+
return t;
|
|
650
|
+
})();
|
|
651
|
+
function crc32(data) {
|
|
652
|
+
var crc = 0xffffffff;
|
|
653
|
+
for (var i = 0; i < data.length; i++) crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
|
|
654
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
655
|
+
}
|
|
656
|
+
function ZipBuilder() { this.entries = []; this.chunks = []; this.offset = 0; }
|
|
657
|
+
ZipBuilder.prototype.push = function (chunk) { this.chunks.push(chunk); this.offset += chunk.length; };
|
|
658
|
+
ZipBuilder.prototype.addFile = function (name, content) {
|
|
659
|
+
var data = typeof content === 'string' ? new TextEncoder().encode(content) : content;
|
|
660
|
+
var nameBytes = new TextEncoder().encode(name);
|
|
661
|
+
var crc = crc32(data);
|
|
662
|
+
var d = new Date();
|
|
663
|
+
var dosTime = (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2);
|
|
664
|
+
var dosDate = ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
|
|
665
|
+
var h = new DataView(new ArrayBuffer(30));
|
|
666
|
+
h.setUint32(0, 0x04034b50, true);
|
|
667
|
+
h.setUint16(4, 20, true);
|
|
668
|
+
h.setUint16(6, 0x0800, true);
|
|
669
|
+
h.setUint16(8, 0, true);
|
|
670
|
+
h.setUint16(10, dosTime, true);
|
|
671
|
+
h.setUint16(12, dosDate, true);
|
|
672
|
+
h.setUint32(14, crc, true);
|
|
673
|
+
h.setUint32(18, data.length, true);
|
|
674
|
+
h.setUint32(22, data.length, true);
|
|
675
|
+
h.setUint16(26, nameBytes.length, true);
|
|
676
|
+
h.setUint16(28, 0, true);
|
|
677
|
+
this.entries.push({ name: name, size: data.length, crc: crc, offset: this.offset, dosTime: dosTime, dosDate: dosDate });
|
|
678
|
+
this.push(new Uint8Array(h.buffer));
|
|
679
|
+
this.push(nameBytes);
|
|
680
|
+
this.push(data);
|
|
681
|
+
};
|
|
682
|
+
ZipBuilder.prototype.toBlob = function () {
|
|
683
|
+
var central = [];
|
|
684
|
+
var centralSize = 0;
|
|
685
|
+
this.entries.forEach(function (e) {
|
|
686
|
+
var nameBytes = new TextEncoder().encode(e.name);
|
|
687
|
+
var r = new DataView(new ArrayBuffer(46));
|
|
688
|
+
r.setUint32(0, 0x02014b50, true);
|
|
689
|
+
r.setUint16(4, 20, true);
|
|
690
|
+
r.setUint16(6, 20, true);
|
|
691
|
+
r.setUint16(8, 0x0800, true);
|
|
692
|
+
r.setUint16(10, 0, true);
|
|
693
|
+
r.setUint16(12, e.dosTime, true);
|
|
694
|
+
r.setUint16(14, e.dosDate, true);
|
|
695
|
+
r.setUint32(16, e.crc, true);
|
|
696
|
+
r.setUint32(20, e.size, true);
|
|
697
|
+
r.setUint32(24, e.size, true);
|
|
698
|
+
r.setUint16(28, nameBytes.length, true);
|
|
699
|
+
r.setUint32(42, e.offset, true);
|
|
700
|
+
central.push(new Uint8Array(r.buffer), nameBytes);
|
|
701
|
+
centralSize += 46 + nameBytes.length;
|
|
702
|
+
});
|
|
703
|
+
var end = new DataView(new ArrayBuffer(22));
|
|
704
|
+
end.setUint32(0, 0x06054b50, true);
|
|
705
|
+
end.setUint16(8, this.entries.length, true);
|
|
706
|
+
end.setUint16(10, this.entries.length, true);
|
|
707
|
+
end.setUint32(12, centralSize, true);
|
|
708
|
+
end.setUint32(16, this.offset, true);
|
|
709
|
+
return new Blob(this.chunks.concat(central, [new Uint8Array(end.buffer)]), { type: 'application/zip' });
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
function exportZip() {
|
|
713
|
+
var btn = document.getElementById('zip-btn');
|
|
714
|
+
btn.disabled = true;
|
|
715
|
+
var zip = new ZipBuilder();
|
|
716
|
+
var work = SIZES.reduce(function (p, size) {
|
|
717
|
+
return p.then(function () {
|
|
718
|
+
return renderToCanvas(size).then(canvasToBlob).then(function (blob) {
|
|
719
|
+
return blob.arrayBuffer().then(function (buf) {
|
|
720
|
+
zip.addFile('icons/' + size + '.png', new Uint8Array(buf));
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
}, Promise.resolve());
|
|
725
|
+
work.then(function () {
|
|
726
|
+
if (CFG.isSvg && CFG.svgSource) zip.addFile('icon.svg', activeSvgSource());
|
|
727
|
+
zip.addFile('manifest-icons.json', CFG.manifestSnippet + '\n');
|
|
728
|
+
downloadBlob(CFG.safeName + '-icons.zip', zip.toBlob());
|
|
729
|
+
}).catch(function (e) { alert(e.message); }).then(function () { btn.disabled = false; });
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function copySnippet() {
|
|
733
|
+
var btn = document.getElementById('copy-btn');
|
|
734
|
+
navigator.clipboard.writeText(CFG.manifestSnippet).then(function () {
|
|
735
|
+
var prev = btn.textContent;
|
|
736
|
+
btn.textContent = '✓ Copied';
|
|
737
|
+
setTimeout(function () { btn.textContent = prev; }, 1800);
|
|
738
|
+
}, function () { alert('Clipboard unavailable — copy the snippet below manually.'); });
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ---- boot ----
|
|
742
|
+
function boot() {
|
|
743
|
+
var chips = document.getElementById('theme-chips');
|
|
744
|
+
THEMES.forEach(function (theme, i) {
|
|
745
|
+
var chip = document.createElement('button');
|
|
746
|
+
chip.type = 'button';
|
|
747
|
+
chip.className = 'chip' + (i === 0 ? ' active' : '');
|
|
748
|
+
chip.setAttribute('data-theme', theme.id);
|
|
749
|
+
var sw = document.createElement('span');
|
|
750
|
+
sw.className = 'sw';
|
|
751
|
+
sw.style.background = theme.toolbar;
|
|
752
|
+
chip.appendChild(sw);
|
|
753
|
+
chip.appendChild(document.createTextNode(theme.label));
|
|
754
|
+
chip.addEventListener('click', function () { applyTheme(theme); renderContrastGrid(); });
|
|
755
|
+
chips.appendChild(chip);
|
|
756
|
+
});
|
|
757
|
+
document.getElementById('custom-color').addEventListener('input', function (e) {
|
|
758
|
+
applyTheme(deriveTheme(e.target.value));
|
|
759
|
+
renderContrastGrid();
|
|
760
|
+
});
|
|
761
|
+
document.getElementById('hl-toggle').addEventListener('change', function (e) {
|
|
762
|
+
document.querySelectorAll('.pin.yours').forEach(function (p) {
|
|
763
|
+
p.classList.toggle('hl', e.target.checked);
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
SIZES.forEach(function (size) {
|
|
767
|
+
var el = document.getElementById('png-' + size);
|
|
768
|
+
if (el) el.addEventListener('click', function () { exportPng(size); });
|
|
769
|
+
});
|
|
770
|
+
// Export controls are absent in embedded mode — the host UI has its own.
|
|
771
|
+
var zipBtn = document.getElementById('zip-btn');
|
|
772
|
+
if (zipBtn) zipBtn.addEventListener('click', exportZip);
|
|
773
|
+
var copyBtn = document.getElementById('copy-btn');
|
|
774
|
+
if (copyBtn) copyBtn.addEventListener('click', copySnippet);
|
|
775
|
+
|
|
776
|
+
var trimToggle = document.getElementById('trim-toggle');
|
|
777
|
+
if (trimToggle) trimToggle.addEventListener('change', function (e) {
|
|
778
|
+
trimOn = e.target.checked;
|
|
779
|
+
refreshIconEverywhere();
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
analyzeIcon().then(function () {
|
|
783
|
+
renderContrastGrid();
|
|
784
|
+
drawMagnifier();
|
|
785
|
+
renderFillNote();
|
|
786
|
+
var mg = document.getElementById('midgray-note');
|
|
787
|
+
if (mg && isMidGray()) mg.hidden = false;
|
|
788
|
+
return computeTrimmedVariant();
|
|
789
|
+
}).catch(function (e) {
|
|
790
|
+
document.getElementById('contrast-grid').textContent = 'Contrast analysis unavailable: ' + e.message;
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', boot) : boot();
|
|
794
|
+
`;
|
|
795
|
+
// ── Manifest snippet (kept in sync with the dojo tool) ──────────────────────
|
|
796
|
+
export const MANIFEST_SNIPPET = `{
|
|
797
|
+
"icons": {
|
|
798
|
+
"16": "icons/16.png",
|
|
799
|
+
"32": "icons/32.png",
|
|
800
|
+
"48": "icons/48.png",
|
|
801
|
+
"128": "icons/128.png"
|
|
802
|
+
},
|
|
803
|
+
"action": {
|
|
804
|
+
"default_icon": {
|
|
805
|
+
"16": "icons/16.png",
|
|
806
|
+
"32": "icons/32.png"
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}`;
|
|
810
|
+
/** Filesystem-safe base name for downloads derived from the extension name. */
|
|
811
|
+
export function safeDownloadName(name) {
|
|
812
|
+
const cleaned = name
|
|
813
|
+
.toLowerCase()
|
|
814
|
+
.replace(/[^a-z0-9]+/gi, '-')
|
|
815
|
+
.replace(/^-+|-+$/g, '')
|
|
816
|
+
.slice(0, 48);
|
|
817
|
+
return cleaned || 'icon';
|
|
818
|
+
}
|
|
819
|
+
// ── Renderer ────────────────────────────────────────────────────────────────
|
|
820
|
+
export function renderIconPreviewHtml(input) {
|
|
821
|
+
const config = {
|
|
822
|
+
name: input.name,
|
|
823
|
+
dataUrl: input.dataUrl,
|
|
824
|
+
isSvg: input.isSvg,
|
|
825
|
+
svgSource: input.isSvg ? (input.svgSource ?? null) : null,
|
|
826
|
+
manifestSnippet: MANIFEST_SNIPPET,
|
|
827
|
+
safeName: safeDownloadName(input.name),
|
|
828
|
+
neighbors: [NEIGHBOR_GRID, NEIGHBOR_BOLT],
|
|
829
|
+
};
|
|
830
|
+
const generated = input.generatedAt.toISOString().slice(0, 16).replace('T', ' ');
|
|
831
|
+
const sizeCells = [16, 32, 48, 128]
|
|
832
|
+
.map((size) => `<div class="size-cell">
|
|
833
|
+
<span class="checker"><img class="icon-self" src="${esc(input.dataUrl)}" width="${size}" height="${size}" alt="${size}px preview"></span>
|
|
834
|
+
<span>${size}px</span>
|
|
835
|
+
</div>`)
|
|
836
|
+
.join('\n');
|
|
837
|
+
const rasterNote = input.isSvg
|
|
838
|
+
? ''
|
|
839
|
+
: '<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>';
|
|
840
|
+
const header = input.embedded
|
|
841
|
+
? ''
|
|
842
|
+
: `<header class="page-head">
|
|
843
|
+
<div class="brand">Extenshi · Icon preview</div>
|
|
844
|
+
<h1>${esc(input.name)}</h1>
|
|
845
|
+
<p class="meta">${esc(input.fileName)} · generated ${esc(generated)} UTC · @extenshi/cli v${esc(input.cliVersion)}</p>
|
|
846
|
+
</header>
|
|
847
|
+
|
|
848
|
+
`;
|
|
849
|
+
const exportSection = input.embedded
|
|
850
|
+
? ''
|
|
851
|
+
: `<section class="card">
|
|
852
|
+
<h2>Export</h2>
|
|
853
|
+
<div class="export-row">
|
|
854
|
+
<button class="btn" type="button" id="png-16">PNG 16</button>
|
|
855
|
+
<button class="btn" type="button" id="png-32">PNG 32</button>
|
|
856
|
+
<button class="btn" type="button" id="png-48">PNG 48</button>
|
|
857
|
+
<button class="btn" type="button" id="png-128">PNG 128</button>
|
|
858
|
+
<button class="btn primary" type="button" id="zip-btn">Download ZIP (PNG set${input.isSvg ? ' + SVG' : ''} + manifest)</button>
|
|
859
|
+
<button class="btn" type="button" id="copy-btn">Copy manifest snippet</button>
|
|
860
|
+
</div>
|
|
861
|
+
<pre class="snippet"><code>${esc(MANIFEST_SNIPPET)}</code></pre>
|
|
862
|
+
</section>
|
|
863
|
+
|
|
864
|
+
<p class="foot">Generated locally by <a href="https://extenshi.io">@extenshi/cli</a> — no data left your machine.</p>`;
|
|
865
|
+
return `<!doctype html>
|
|
866
|
+
<html lang="en">
|
|
867
|
+
<head>
|
|
868
|
+
<meta charset="utf-8">
|
|
869
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
870
|
+
<title>${esc(input.name)} — icon preview · Extenshi</title>
|
|
871
|
+
<style>${CSS}</style>
|
|
872
|
+
</head>
|
|
873
|
+
<body>
|
|
874
|
+
<main>
|
|
875
|
+
${header}<section class="card">
|
|
876
|
+
<div class="themes-head">
|
|
877
|
+
<h2>Palette<span class="hint">how the toolbar around your icon is colored</span></h2>
|
|
878
|
+
<label class="toggle"><input type="checkbox" id="hl-toggle"> highlight my icon</label>
|
|
879
|
+
</div>
|
|
880
|
+
<div class="chips" id="theme-chips"></div>
|
|
881
|
+
<label class="custom">Custom toolbar color <input type="color" id="custom-color" value="#8b5cf6"></label>
|
|
882
|
+
</section>
|
|
883
|
+
|
|
884
|
+
<div id="mockups">
|
|
885
|
+
${browserMockup('chrome', input)}
|
|
886
|
+
${browserMockup('firefox', input)}
|
|
887
|
+
${browserMockup('edge', input)}
|
|
888
|
+
</div>
|
|
889
|
+
|
|
890
|
+
<section class="card">
|
|
891
|
+
<h2>Contrast across palettes<span class="hint">does the icon survive every background?</span></h2>
|
|
892
|
+
<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>
|
|
893
|
+
<div id="contrast-grid"></div>
|
|
894
|
+
</section>
|
|
895
|
+
|
|
896
|
+
<section class="card">
|
|
897
|
+
<h2>Store sizes</h2>
|
|
898
|
+
${rasterNote}
|
|
899
|
+
<div class="size-flex">
|
|
900
|
+
${sizeCells}
|
|
901
|
+
</div>
|
|
902
|
+
<p class="fill-note" id="fill-note"></p>
|
|
903
|
+
<label class="toggle trim" id="trim-row" hidden><input type="checkbox" id="trim-toggle"> Trim empty margins — rescale the artwork edge-to-edge in every preview and export</label>
|
|
904
|
+
<div class="mag-wrap">
|
|
905
|
+
<span class="mag-box" id="mag-box"><canvas id="mag" width="16" height="16"></canvas></span>
|
|
906
|
+
<p class="mag-note">The 16 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>
|
|
907
|
+
</div>
|
|
908
|
+
</section>
|
|
909
|
+
|
|
910
|
+
${exportSection}
|
|
911
|
+
</main>
|
|
912
|
+
<script>window.__ICON_PREVIEW__ = ${jsonForScript(config)};</script>
|
|
913
|
+
<script>${PAGE_JS}</script>
|
|
914
|
+
</body>
|
|
915
|
+
</html>
|
|
916
|
+
`;
|
|
917
|
+
}
|
|
918
|
+
//# sourceMappingURL=icon-preview.js.map
|