finch-skin-studio 0.1.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,221 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Skin Studio</title>
7
+ <style>
8
+ :root {
9
+ --ss-bg-root: var(--finch-bg-root, #f6f6f8);
10
+ --ss-bg-main: var(--finch-bg-main, #ffffff);
11
+ --ss-bg-elevated: var(--finch-bg-elevated, #ffffff);
12
+ --ss-bg-hover: var(--finch-bg-hover, #f0f1f3);
13
+ --ss-bg-active: var(--finch-bg-active, #e6e8eb);
14
+ --ss-text-primary: var(--finch-text-primary, #1c1c1f);
15
+ --ss-text-secondary: var(--finch-text-secondary, #5b5d66);
16
+ --ss-text-tertiary: var(--finch-text-tertiary, #9a9ca6);
17
+ --ss-accent: var(--finch-accent, #2f6fed);
18
+ --ss-accent-dim: var(--finch-accent-dim, #e2ebfd);
19
+ --ss-border: var(--finch-border, #e4e4e7);
20
+ --ss-radius-sm: var(--finch-radius-sm, 8px);
21
+ --ss-radius-md: var(--finch-radius-md, 12px);
22
+ --ss-radius-lg: var(--finch-radius-lg, 16px);
23
+ }
24
+ @media (prefers-color-scheme: dark) {
25
+ :root {
26
+ --ss-bg-root: var(--finch-bg-root, #17181b);
27
+ --ss-bg-main: var(--finch-bg-main, #1b1c20);
28
+ --ss-bg-elevated: var(--finch-bg-elevated, #222327);
29
+ --ss-bg-hover: var(--finch-bg-hover, #28292e);
30
+ --ss-bg-active: var(--finch-bg-active, #313239);
31
+ --ss-text-primary: var(--finch-text-primary, #f2f2f4);
32
+ --ss-text-secondary: var(--finch-text-secondary, #a7a8b2);
33
+ --ss-text-tertiary: var(--finch-text-tertiary, #6c6d78);
34
+ --ss-accent: var(--finch-accent, #6366f1);
35
+ --ss-accent-dim: var(--finch-accent-dim, #26264a);
36
+ --ss-border: var(--finch-border, #2b2c31);
37
+ }
38
+ }
39
+ * { box-sizing: border-box; }
40
+ html, body { margin: 0; padding: 0; }
41
+ body {
42
+ background: var(--ss-bg-main);
43
+ color: var(--ss-text-primary);
44
+ font-family: var(--finch-font-body, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
45
+ font-size: 13px;
46
+ line-height: 1.5;
47
+ padding: 16px 18px 32px;
48
+ }
49
+ h1, h2 { margin: 0; font-weight: 600; }
50
+ h1 { font-size: 16px; margin-bottom: 14px; }
51
+ h2 { font-size: 13px; color: var(--ss-text-secondary); text-transform: uppercase; letter-spacing: 0.02em; }
52
+ .card {
53
+ background: var(--ss-bg-elevated);
54
+ border: 1px solid var(--ss-border);
55
+ border-radius: var(--ss-radius-lg);
56
+ padding: 14px;
57
+ margin-bottom: 20px;
58
+ }
59
+ .section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
60
+ button {
61
+ font: inherit;
62
+ cursor: pointer;
63
+ border-radius: var(--ss-radius-sm);
64
+ border: 1px solid var(--ss-border);
65
+ background: var(--ss-bg-elevated);
66
+ color: var(--ss-text-primary);
67
+ padding: 6px 12px;
68
+ }
69
+ button:hover { background: var(--ss-bg-hover); }
70
+ button.primary { background: var(--ss-accent); color: #fff; border-color: transparent; }
71
+ button.primary:hover { filter: brightness(1.05); }
72
+ button.ghost { background: transparent; border-color: transparent; color: var(--ss-text-secondary); }
73
+ button:disabled { opacity: 0.5; cursor: default; }
74
+
75
+ /* ── Background section ── */
76
+ .bg-preview {
77
+ height: 120px;
78
+ border-radius: var(--ss-radius-md);
79
+ border: 1px dashed var(--ss-border);
80
+ background: var(--ss-bg-main);
81
+ display: flex;
82
+ align-items: center;
83
+ justify-content: center;
84
+ overflow: hidden;
85
+ margin-bottom: 12px;
86
+ position: relative;
87
+ }
88
+ .bg-preview img { width: 100%; height: 100%; object-fit: cover; }
89
+ .bg-preview.tile img { object-fit: none; background-size: 64px 64px; }
90
+ .bg-preview.drag-over { border-color: var(--ss-accent); background: var(--ss-accent-dim); }
91
+ .bg-placeholder { color: var(--ss-text-tertiary); font-size: 12px; text-align: center; padding: 8px; pointer-events: none; }
92
+ .bg-placeholder .bg-hint { margin-top: 4px; font-size: 11px; opacity: 0.85; }
93
+ .row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
94
+ .row + .row { margin-top: 10px; }
95
+ .seg { display: inline-flex; border: 1px solid var(--ss-border); border-radius: var(--ss-radius-sm); overflow: hidden; }
96
+ .seg button { border: none; border-radius: 0; background: transparent; }
97
+ .seg button + button { border-left: 1px solid var(--ss-border); }
98
+ .seg button.active { background: var(--ss-accent-dim); color: var(--ss-accent); font-weight: 600; }
99
+ .label { color: var(--ss-text-tertiary); font-size: 12px; margin-right: 2px; }
100
+
101
+ /* ── Skin grid ── */
102
+ .grid { display: grid; gap: 12px; }
103
+ .skin-card {
104
+ position: relative;
105
+ border: 1px solid var(--ss-border);
106
+ border-radius: var(--ss-radius-lg);
107
+ background: var(--ss-bg-elevated);
108
+ overflow: hidden;
109
+ cursor: pointer;
110
+ transition: transform 0.12s ease, border-color 0.12s ease;
111
+ }
112
+ .skin-card:hover { transform: translateY(-2px); border-color: var(--ss-accent); }
113
+ .skin-card.active { border-color: var(--ss-accent); box-shadow: 0 0 0 2px var(--ss-accent-dim) inset; }
114
+ .thumb { height: 84px; display: flex; }
115
+ .thumb .side { width: 30%; }
116
+ .thumb .main { flex: 1; padding: 10px 8px; display: flex; flex-direction: column; gap: 5px; }
117
+ .thumb .bar { height: 5px; border-radius: 3px; }
118
+ .thumb .bar.w1 { width: 70%; }
119
+ .thumb .bar.w2 { width: 45%; }
120
+ .thumb .pill { width: 26px; height: 8px; border-radius: 4px; margin-top: 4px; }
121
+ .skin-name {
122
+ padding: 8px 10px;
123
+ font-size: 12px;
124
+ display: flex;
125
+ align-items: center;
126
+ justify-content: space-between;
127
+ gap: 6px;
128
+ }
129
+ .skin-name span.n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
130
+ .check-badge {
131
+ position: absolute; top: 6px; right: 6px;
132
+ width: 18px; height: 18px; border-radius: 50%;
133
+ background: var(--ss-accent); color: #fff;
134
+ display: flex; align-items: center; justify-content: center;
135
+ font-size: 11px; line-height: 1;
136
+ }
137
+ .del-btn {
138
+ border: none; background: rgba(0,0,0,0.55); color: #fff;
139
+ width: 20px; height: 20px; border-radius: 50%;
140
+ display: none; align-items: center; justify-content: center;
141
+ font-size: 12px; padding: 0; flex: none;
142
+ }
143
+ .skin-card:hover .del-btn { display: flex; }
144
+ .add-card {
145
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
146
+ gap: 6px; min-height: 116px;
147
+ color: var(--ss-text-tertiary);
148
+ border: 1px dashed var(--ss-border); border-radius: var(--ss-radius-lg);
149
+ background: transparent; cursor: pointer; padding: 0;
150
+ }
151
+ .add-card .add-icon { font-size: 20px; line-height: 1; }
152
+ .add-card .add-label { font-size: 12px; text-align: center; }
153
+ .add-card:hover { color: var(--ss-accent); border-color: var(--ss-accent); background: var(--ss-bg-hover); }
154
+ .skin-actions { display: flex; gap: 4px; flex: none; }
155
+
156
+ /* ── Skeleton ── */
157
+ .skeleton { border-radius: var(--ss-radius-lg); overflow: hidden; border: 1px solid var(--ss-border); }
158
+ .skeleton .thumb, .skeleton .sk-line { background: linear-gradient(90deg, var(--ss-bg-hover) 25%, var(--ss-bg-active) 37%, var(--ss-bg-hover) 63%); background-size: 400% 100%; animation: shimmer 1.4s ease infinite; }
159
+ .skeleton .sk-line { height: 12px; margin: 10px; border-radius: 4px; width: 60%; }
160
+ @keyframes shimmer { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }
161
+
162
+ .toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: var(--ss-bg-elevated); border: 1px solid var(--ss-border); border-radius: var(--ss-radius-md); padding: 8px 14px; font-size: 12px; color: var(--ss-text-primary); box-shadow: 0 4px 16px rgba(0,0,0,0.18); opacity: 0; pointer-events: none; transition: opacity 0.2s ease; }
163
+ .toast.show { opacity: 1; }
164
+ </style>
165
+ </head>
166
+ <body>
167
+ <h1 id="t-title">Skin Studio</h1>
168
+
169
+ <section class="card" id="bg-card">
170
+ <div class="section-header"><h2 id="t-bg-heading">Home Background</h2></div>
171
+ <div class="bg-preview" id="bg-preview">
172
+ <div class="bg-placeholder" id="bg-placeholder">
173
+ <div id="t-bg-empty">No background image set</div>
174
+ <div class="bg-hint" id="t-bg-empty-hint">Drag &amp; drop an image here, or</div>
175
+ </div>
176
+ <img id="bg-image" style="display:none" alt="" />
177
+ </div>
178
+ <input type="file" id="bg-file-input" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" style="display:none" />
179
+ <div class="row">
180
+ <button class="primary" id="btn-pick" type="button">+ <span id="t-choose">Choose image</span></button>
181
+ <button class="ghost" id="btn-clear" type="button" disabled><span id="t-clear">Clear</span></button>
182
+ </div>
183
+ <div class="row">
184
+ <span class="label" id="t-placement">Placement</span>
185
+ <div class="seg" id="seg-placement">
186
+ <button data-value="fill" id="t-fill">Fill</button>
187
+ <button data-value="tile" id="t-tile">Tile</button>
188
+ </div>
189
+ </div>
190
+ <div class="row">
191
+ <span class="label" id="t-tone">Brightness</span>
192
+ <div class="seg" id="seg-tone">
193
+ <button data-value="brightest">☀️</button>
194
+ <button data-value="bright">🌤️</button>
195
+ <button data-value="balanced">⛅</button>
196
+ <button data-value="dark">🌥️</button>
197
+ <button data-value="darkest">🌑</button>
198
+ </div>
199
+ </div>
200
+ </section>
201
+
202
+ <section id="skins-section">
203
+ <div class="section-header">
204
+ <h2 id="t-skins-heading">Skins</h2>
205
+ </div>
206
+ <div class="row" style="margin-bottom: 12px;">
207
+ <span class="label" id="t-systemTheme">System appearance</span>
208
+ <div class="seg" id="seg-system-theme">
209
+ <button data-value="system" id="t-sys-auto">Auto</button>
210
+ <button data-value="light" id="t-sys-light">Light</button>
211
+ <button data-value="dark" id="t-sys-dark">Dark</button>
212
+ </div>
213
+ </div>
214
+ <div class="grid" id="skins-grid"></div>
215
+ </section>
216
+
217
+ <div class="toast" id="toast"></div>
218
+
219
+ <script src="panel.js"></script>
220
+ </body>
221
+ </html>
package/dist/panel.js ADDED
@@ -0,0 +1,429 @@
1
+ (function () {
2
+ 'use strict';
3
+ var api = window.finch;
4
+
5
+ // ── i18n ──────────────────────────────────────────────────────────────
6
+ var DICT = {
7
+ 'en-US': {
8
+ title: 'Skin Studio', bgHeading: 'Home Background', bgEmpty: 'No background image set',
9
+ bgEmptyHint: 'Drag & drop an image here, or',
10
+ choose: 'Choose image', clear: 'Clear', placement: 'Placement', fill: 'Fill', tile: 'Tile',
11
+ tone: 'Brightness', skinsHeading: 'Skins', addCard: 'Save current skin',
12
+ current: 'Current', deleteConfirmTitle: 'Delete this skin?',
13
+ deleteConfirmMessage: 'This custom skin will be removed. This cannot be undone.',
14
+ deleteConfirm: 'Delete', deleteCancel: 'Cancel', applied: 'Skin applied', removed: 'Skin removed',
15
+ backgroundUpdated: 'Background updated', backgroundCleared: 'Background cleared',
16
+ noCurrentSkin: 'No applied skin to save yet — apply a preset first.',
17
+ dropInvalidType: 'Please choose a supported image file (PNG / JPEG / WebP / GIF / AVIF).',
18
+ dropTooLarge: 'Image is too large (max 15MB).',
19
+ systemTheme: 'System Appearance', sysAuto: 'Auto', sysLight: 'Light', sysDark: 'Dark',
20
+ exportSkin: 'Copy', importCard: 'Import skin',
21
+ importInvalid: 'This file is not a valid skin export.',
22
+ skinCopied: 'Skin copied to clipboard',
23
+ },
24
+ 'zh-CN': {
25
+ title: '换肤工坊', bgHeading: '首页背景', bgEmpty: '尚未设置背景图',
26
+ bgEmptyHint: '拖拽图片到这里,或',
27
+ choose: '选择图片', clear: '清除', placement: '铺放方式', fill: '铺满', tile: '平铺',
28
+ tone: '明暗程度', skinsHeading: '配色皮肤', addCard: '保存当前皮肤',
29
+ current: '使用中', deleteConfirmTitle: '删除这个皮肤?',
30
+ deleteConfirmMessage: '这个自定义皮肤将被移除,此操作无法撤销。',
31
+ deleteConfirm: '删除', deleteCancel: '取消', applied: '已应用皮肤', removed: '已删除皮肤',
32
+ backgroundUpdated: '背景已更新', backgroundCleared: '背景已清除',
33
+ noCurrentSkin: '还没有可保存的当前皮肤,请先应用一个预设。',
34
+ dropInvalidType: '请选择支持的图片格式(PNG / JPEG / WebP / GIF / AVIF)。',
35
+ dropTooLarge: '图片过大(最多 15MB)。',
36
+ systemTheme: '系统外观', sysAuto: '跟随系统', sysLight: '浅色', sysDark: '深色',
37
+ exportSkin: '复制', importCard: '导入皮肤',
38
+ importInvalid: '这不是有效的皮肤导出文件。',
39
+ skinCopied: '皮肤已复制到剪贴板',
40
+ },
41
+ };
42
+ DICT['zh-HK'] = DICT['zh-CN'];
43
+
44
+ var locale = 'en-US';
45
+ function t(key) { var d = DICT[locale] || DICT['en-US']; return d[key] || DICT['en-US'][key] || key; }
46
+
47
+ function applyStaticI18n() {
48
+ document.getElementById('t-title').textContent = t('title');
49
+ document.getElementById('t-bg-heading').textContent = t('bgHeading');
50
+ document.getElementById('t-bg-empty').textContent = t('bgEmpty');
51
+ document.getElementById('t-bg-empty-hint').textContent = t('bgEmptyHint');
52
+ document.getElementById('t-choose').textContent = t('choose');
53
+ document.getElementById('t-clear').textContent = t('clear');
54
+ document.getElementById('t-placement').textContent = t('placement');
55
+ document.getElementById('t-fill').textContent = t('fill');
56
+ document.getElementById('t-tile').textContent = t('tile');
57
+ document.getElementById('t-tone').textContent = t('tone');
58
+ document.getElementById('t-skins-heading').textContent = t('skinsHeading');
59
+ document.getElementById('t-systemTheme').textContent = t('systemTheme');
60
+ document.getElementById('t-sys-auto').textContent = t('sysAuto');
61
+ document.getElementById('t-sys-light').textContent = t('sysLight');
62
+ document.getElementById('t-sys-dark').textContent = t('sysDark');
63
+ }
64
+
65
+ // ── Color helpers ─────────────────────────────────────────────────────
66
+ var BASE_DEFAULTS = {
67
+ light: { bgRoot: '#ffffff', bgMain: '#ffffff', bgSidebar: '#f5f5f7', bgElevated: '#ffffff', textPrimary: '#18181b', textSecondary: '#71717a', textTertiary: '#a1a1aa', accent: '#2563eb', accentDim: '#dbeafe', border: '#e4e4e7' },
68
+ dark: { bgRoot: '#18181b', bgMain: '#1c1c1f', bgSidebar: '#141416', bgElevated: '#232326', textPrimary: '#f4f4f5', textSecondary: '#a1a1aa', textTertiary: '#71717a', accent: '#6366f1', accentDim: '#26264a', border: '#2b2b2f' },
69
+ };
70
+ function mergedColors(skin) {
71
+ var defaults = BASE_DEFAULTS[skin.base] || BASE_DEFAULTS.light;
72
+ return Object.assign({}, defaults, skin.colors || {});
73
+ }
74
+
75
+ function buildThumb(skin) {
76
+ var c = mergedColors(skin);
77
+ var thumb = document.createElement('div');
78
+ thumb.className = 'thumb';
79
+ thumb.style.background = c.bgMain;
80
+ var side = document.createElement('div');
81
+ side.className = 'side';
82
+ side.style.background = c.bgSidebar;
83
+ var main = document.createElement('div');
84
+ main.className = 'main';
85
+ var bar1 = document.createElement('div');
86
+ bar1.className = 'bar w1';
87
+ bar1.style.background = c.textSecondary;
88
+ bar1.style.opacity = '0.55';
89
+ var bar2 = document.createElement('div');
90
+ bar2.className = 'bar w2';
91
+ bar2.style.background = c.textTertiary;
92
+ bar2.style.opacity = '0.55';
93
+ var pill = document.createElement('div');
94
+ pill.className = 'pill';
95
+ pill.style.background = c.accent;
96
+ main.appendChild(bar1);
97
+ main.appendChild(bar2);
98
+ main.appendChild(pill);
99
+ thumb.appendChild(side);
100
+ thumb.appendChild(main);
101
+ return thumb;
102
+ }
103
+
104
+ // ── State ─────────────────────────────────────────────────────────────
105
+ var state = {
106
+ builtin: [], custom: [], background: { placement: 'fill', tone: 'balanced' },
107
+ lastAppliedId: undefined, activeMode: null, loaded: false,
108
+ };
109
+
110
+ function toFinchFileUrl(absolutePath) {
111
+ return 'finch-file://local?path=' + encodeURIComponent(absolutePath);
112
+ }
113
+
114
+ function renderBackground() {
115
+ var preview = document.getElementById('bg-preview');
116
+ var img = document.getElementById('bg-image');
117
+ var placeholder = document.getElementById('bg-placeholder');
118
+ var btnClear = document.getElementById('btn-clear');
119
+ var bg = state.background || {};
120
+ preview.classList.toggle('tile', bg.placement === 'tile');
121
+ if (bg.imagePath) {
122
+ img.src = toFinchFileUrl(bg.imagePath);
123
+ img.style.display = 'block';
124
+ placeholder.style.display = 'none';
125
+ btnClear.disabled = false;
126
+ } else {
127
+ img.style.display = 'none';
128
+ placeholder.style.display = 'block';
129
+ btnClear.disabled = true;
130
+ }
131
+ var segP = document.getElementById('seg-placement');
132
+ Array.prototype.forEach.call(segP.querySelectorAll('button'), function (b) {
133
+ b.classList.toggle('active', b.getAttribute('data-value') === (bg.placement || 'fill'));
134
+ });
135
+ var segT = document.getElementById('seg-tone');
136
+ Array.prototype.forEach.call(segT.querySelectorAll('button'), function (b) {
137
+ b.classList.toggle('active', b.getAttribute('data-value') === (bg.tone || 'balanced'));
138
+ });
139
+ }
140
+
141
+ function renderSystemThemeSeg() {
142
+ var seg = document.getElementById('seg-system-theme');
143
+ var mode = state.activeMode;
144
+ var active = mode && mode.mode === 'system' ? mode.systemTheme : null;
145
+ Array.prototype.forEach.call(seg.querySelectorAll('button'), function (b) {
146
+ b.classList.toggle('active', b.getAttribute('data-value') === active);
147
+ });
148
+ }
149
+
150
+ function computeColumns() {
151
+ var grid = document.getElementById('skins-grid');
152
+ var width = grid.clientWidth || window.innerWidth || 360;
153
+ var cols = Math.floor(width / 168);
154
+ if (cols < 2) cols = 2;
155
+ if (cols > 4) cols = 4;
156
+ grid.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
157
+ }
158
+
159
+ function isSkinActive(skin) {
160
+ var mode = state.activeMode;
161
+ // Before anything has ever been applied through Skin Studio (no
162
+ // activeMode on record yet), fall back to just matching lastAppliedId.
163
+ if (mode && mode.mode !== 'skin') return false;
164
+ return state.lastAppliedId === skin.id;
165
+ }
166
+
167
+ function makeSkinCard(skin, isCustom) {
168
+ var active = isSkinActive(skin);
169
+ var card = document.createElement('div');
170
+ card.className = 'skin-card' + (active ? ' active' : '');
171
+ card.appendChild(buildThumb(skin));
172
+ var nameRow = document.createElement('div');
173
+ nameRow.className = 'skin-name';
174
+ var nameSpan = document.createElement('span');
175
+ nameSpan.className = 'n';
176
+ nameSpan.textContent = skin.name;
177
+ nameRow.appendChild(nameSpan);
178
+ if (isCustom) {
179
+ var actions = document.createElement('div');
180
+ actions.className = 'skin-actions';
181
+ var exp = document.createElement('button');
182
+ exp.className = 'del-btn';
183
+ exp.type = 'button';
184
+ exp.textContent = '⧉';
185
+ exp.title = t('exportSkin');
186
+ exp.addEventListener('click', function (ev) {
187
+ ev.stopPropagation();
188
+ exportSkin(skin);
189
+ });
190
+ var del = document.createElement('button');
191
+ del.className = 'del-btn';
192
+ del.type = 'button';
193
+ del.textContent = '✕';
194
+ del.title = t('deleteConfirm');
195
+ del.addEventListener('click', function (ev) {
196
+ ev.stopPropagation();
197
+ confirmRemove(skin);
198
+ });
199
+ actions.appendChild(exp);
200
+ actions.appendChild(del);
201
+ nameRow.appendChild(actions);
202
+ }
203
+ card.appendChild(nameRow);
204
+ if (active) {
205
+ var badge = document.createElement('div');
206
+ badge.className = 'check-badge';
207
+ badge.textContent = '✓';
208
+ card.appendChild(badge);
209
+ }
210
+ card.addEventListener('click', function () {
211
+ api.postMessage({ type: 'applySkin', id: skin.id });
212
+ });
213
+ return card;
214
+ }
215
+
216
+ function confirmRemove(skin) {
217
+ api.ui.confirm({
218
+ title: t('deleteConfirmTitle'),
219
+ message: t('deleteConfirmMessage'),
220
+ confirmLabel: t('deleteConfirm'),
221
+ cancelLabel: t('deleteCancel'),
222
+ variant: 'danger',
223
+ }).then(function (res) {
224
+ if (res && res.confirmed) {
225
+ api.postMessage({ type: 'removeSkin', id: skin.id });
226
+ }
227
+ });
228
+ }
229
+
230
+ function copyText(text) {
231
+ if (navigator.clipboard && navigator.clipboard.writeText) {
232
+ return navigator.clipboard.writeText(text);
233
+ }
234
+ // Fallback for webviews without the async Clipboard API.
235
+ var ta = document.createElement('textarea');
236
+ ta.value = text;
237
+ ta.style.position = 'fixed';
238
+ ta.style.opacity = '0';
239
+ document.body.appendChild(ta);
240
+ ta.select();
241
+ try { document.execCommand('copy'); } finally { document.body.removeChild(ta); }
242
+ return Promise.resolve();
243
+ }
244
+
245
+ function exportSkin(skin) {
246
+ var payload = { schema: 'finch-skin-studio.skin@1', name: skin.name, base: skin.base, colors: skin.colors || {} };
247
+ copyText(JSON.stringify(payload)).then(function () {
248
+ showToast(t('skinCopied'));
249
+ }, function () {
250
+ showToast(t('importInvalid'));
251
+ });
252
+ }
253
+
254
+ function renderSkins() {
255
+ var grid = document.getElementById('skins-grid');
256
+ grid.innerHTML = '';
257
+ if (!state.loaded) {
258
+ for (var i = 0; i < 4; i++) {
259
+ var sk = document.createElement('div');
260
+ sk.className = 'skeleton';
261
+ var t1 = document.createElement('div');
262
+ t1.className = 'thumb';
263
+ var l1 = document.createElement('div');
264
+ l1.className = 'sk-line';
265
+ sk.appendChild(t1);
266
+ sk.appendChild(l1);
267
+ grid.appendChild(sk);
268
+ }
269
+ computeColumns();
270
+ return;
271
+ }
272
+ state.builtin.forEach(function (skin) { grid.appendChild(makeSkinCard(skin, false)); });
273
+ state.custom.forEach(function (skin) { grid.appendChild(makeSkinCard(skin, true)); });
274
+ var addCard = document.createElement('button');
275
+ addCard.type = 'button';
276
+ addCard.className = 'add-card';
277
+ addCard.innerHTML = '<span class="add-icon">+</span><span class="add-label">' + t('addCard') + '</span>';
278
+ addCard.addEventListener('click', function () {
279
+ api.postMessage({ type: 'requestSaveCurrent' });
280
+ });
281
+ grid.appendChild(addCard);
282
+ var importCard = document.createElement('button');
283
+ importCard.type = 'button';
284
+ importCard.className = 'add-card';
285
+ importCard.innerHTML = '<span class="add-icon">⇪</span><span class="add-label">' + t('importCard') + '</span>';
286
+ importCard.addEventListener('click', function () {
287
+ api.postMessage({ type: 'requestImportSkin' });
288
+ });
289
+ grid.appendChild(importCard);
290
+ computeColumns();
291
+ }
292
+
293
+ // ── Toast ─────────────────────────────────────────────────────────────
294
+ var toastTimer;
295
+ function showToast(msg) {
296
+ var el = document.getElementById('toast');
297
+ el.textContent = msg;
298
+ el.classList.add('show');
299
+ clearTimeout(toastTimer);
300
+ toastTimer = setTimeout(function () { el.classList.remove('show'); }, 2200);
301
+ }
302
+
303
+ // ── Wire up controls ──────────────────────────────────────────────────
304
+ document.getElementById('btn-pick').addEventListener('click', function () {
305
+ // A plain <input type=file> click opens the OS's real native "open
306
+ // file" dialog — standard web platform behavior, not a Finch API —
307
+ // which is the closest thing to a system file picker mini tools can
308
+ // reach. See the upload handling below.
309
+ document.getElementById('bg-file-input').click();
310
+ });
311
+ document.getElementById('btn-clear').addEventListener('click', function () {
312
+ api.postMessage({ type: 'clearBackground' });
313
+ });
314
+ document.getElementById('seg-placement').addEventListener('click', function (ev) {
315
+ var btn = ev.target.closest('button[data-value]');
316
+ if (!btn) return;
317
+ api.postMessage({ type: 'setBackgroundOptions', placement: btn.getAttribute('data-value') });
318
+ });
319
+ document.getElementById('seg-tone').addEventListener('click', function (ev) {
320
+ var btn = ev.target.closest('button[data-value]');
321
+ if (!btn) return;
322
+ api.postMessage({ type: 'setBackgroundOptions', tone: btn.getAttribute('data-value') });
323
+ });
324
+ document.getElementById('seg-system-theme').addEventListener('click', function (ev) {
325
+ var btn = ev.target.closest('button[data-value]');
326
+ if (!btn) return;
327
+ api.postMessage({ type: 'setSystemTheme', theme: btn.getAttribute('data-value') });
328
+ });
329
+
330
+ // ── Background image upload (native file dialog + drag & drop) ────────
331
+ var MAX_DROPPED_IMAGE_BYTES = 15 * 1024 * 1024; // 15 MB, mirrors the backend limit
332
+ var IMAGE_TYPE_RE = /^image\/(png|jpeg|jpg|webp|gif|avif)$/;
333
+ var bgPreview = document.getElementById('bg-preview');
334
+ var dragDepth = 0;
335
+
336
+ function isFileDrag(ev) {
337
+ var types = ev.dataTransfer && ev.dataTransfer.types;
338
+ return !!types && Array.prototype.indexOf.call(types, 'Files') !== -1;
339
+ }
340
+
341
+ function uploadBackgroundFile(file) {
342
+ if (!file) return;
343
+ if (!IMAGE_TYPE_RE.test(file.type)) {
344
+ showToast(t('dropInvalidType'));
345
+ return;
346
+ }
347
+ if (file.size > MAX_DROPPED_IMAGE_BYTES) {
348
+ showToast(t('dropTooLarge'));
349
+ return;
350
+ }
351
+ var reader = new FileReader();
352
+ reader.onload = function () {
353
+ api.postMessage({ type: 'uploadBackgroundImage', dataUrl: String(reader.result || ''), name: file.name });
354
+ };
355
+ reader.onerror = function () {
356
+ showToast(t('dropInvalidType'));
357
+ };
358
+ reader.readAsDataURL(file);
359
+ }
360
+
361
+ document.getElementById('bg-file-input').addEventListener('change', function (ev) {
362
+ var file = ev.target.files && ev.target.files[0];
363
+ ev.target.value = ''; // allow re-picking the same file later
364
+ uploadBackgroundFile(file);
365
+ });
366
+
367
+ bgPreview.addEventListener('dragenter', function (ev) {
368
+ if (!isFileDrag(ev)) return;
369
+ ev.preventDefault();
370
+ dragDepth++;
371
+ bgPreview.classList.add('drag-over');
372
+ });
373
+ bgPreview.addEventListener('dragover', function (ev) {
374
+ if (!isFileDrag(ev)) return;
375
+ ev.preventDefault();
376
+ ev.dataTransfer.dropEffect = 'copy';
377
+ });
378
+ bgPreview.addEventListener('dragleave', function () {
379
+ dragDepth = Math.max(0, dragDepth - 1);
380
+ if (dragDepth === 0) bgPreview.classList.remove('drag-over');
381
+ });
382
+ bgPreview.addEventListener('drop', function (ev) {
383
+ ev.preventDefault();
384
+ dragDepth = 0;
385
+ bgPreview.classList.remove('drag-over');
386
+ var files = ev.dataTransfer && ev.dataTransfer.files;
387
+ uploadBackgroundFile(files && files[0]);
388
+ });
389
+
390
+ // Prevent an errant drop outside the preview box from navigating the whole
391
+ // panel away to the dropped image (default browser behavior).
392
+ document.addEventListener('dragover', function (ev) { if (isFileDrag(ev)) ev.preventDefault(); });
393
+ document.addEventListener('drop', function (ev) { if (isFileDrag(ev)) ev.preventDefault(); });
394
+
395
+ new ResizeObserver(function () { computeColumns(); }).observe(document.getElementById('skins-grid'));
396
+
397
+ // ── Bridge messages ───────────────────────────────────────────────────
398
+ api.onMessage(function (msg) {
399
+ if (!msg || typeof msg !== 'object') return;
400
+ if (msg.type === 'finch:env') {
401
+ if (msg.locale) { locale = msg.locale; applyStaticI18n(); renderBackground(); renderSystemThemeSeg(); renderSkins(); }
402
+ return;
403
+ }
404
+ if (msg.type === 'state') {
405
+ if (msg.env && msg.env.locale) locale = msg.env.locale;
406
+ state.builtin = msg.builtin || [];
407
+ state.custom = msg.custom || [];
408
+ state.background = msg.background || { placement: 'fill', tone: 'balanced' };
409
+ state.lastAppliedId = msg.lastAppliedId;
410
+ state.activeMode = msg.activeMode || null;
411
+ state.loaded = true;
412
+ applyStaticI18n();
413
+ renderBackground();
414
+ renderSystemThemeSeg();
415
+ renderSkins();
416
+ return;
417
+ }
418
+ if (msg.type === 'error' || msg.type === 'toast') {
419
+ showToast(String(msg.message || ''));
420
+ return;
421
+ }
422
+ });
423
+
424
+ applyStaticI18n();
425
+ renderBackground();
426
+ renderSystemThemeSeg();
427
+ renderSkins();
428
+ api.postMessage({ type: 'requestState' });
429
+ })();