openclacky 1.3.11 → 1.4.1

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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/lib/clacky/agent/session_serializer.rb +11 -4
  4. data/lib/clacky/agent/skill_manager.rb +1 -0
  5. data/lib/clacky/agent.rb +4 -5
  6. data/lib/clacky/brand_config.rb +53 -3
  7. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +202 -3
  8. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +610 -60
  9. data/lib/clacky/default_skills/media-gen/SKILL.md +182 -7
  10. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +5 -3
  11. data/lib/clacky/extension/packager.rb +21 -1
  12. data/lib/clacky/extension/verifier.rb +1 -1
  13. data/lib/clacky/media/generator.rb +41 -0
  14. data/lib/clacky/media/volcengine.rb +394 -0
  15. data/lib/clacky/platform_http_client.rb +9 -7
  16. data/lib/clacky/providers.rb +4 -4
  17. data/lib/clacky/server/http_server.rb +67 -22
  18. data/lib/clacky/tools/grep.rb +6 -1
  19. data/lib/clacky/tools/terminal.rb +24 -8
  20. data/lib/clacky/ui2/layout_manager.rb +4 -0
  21. data/lib/clacky/ui2/screen_buffer.rb +31 -3
  22. data/lib/clacky/utils/environment_detector.rb +16 -9
  23. data/lib/clacky/utils/file_processor.rb +30 -28
  24. data/lib/clacky/utils/model_pricing.rb +49 -0
  25. data/lib/clacky/version.rb +1 -1
  26. data/lib/clacky/web/app.css +229 -31
  27. data/lib/clacky/web/components/onboard.js +14 -4
  28. data/lib/clacky/web/features/backup/view.js +4 -3
  29. data/lib/clacky/web/features/extensions/store.js +2 -2
  30. data/lib/clacky/web/features/extensions/view.js +37 -7
  31. data/lib/clacky/web/features/mcp/view.js +5 -2
  32. data/lib/clacky/web/features/skills/view.js +61 -1
  33. data/lib/clacky/web/features/workspace/store.js +11 -0
  34. data/lib/clacky/web/features/workspace/view.js +10 -4
  35. data/lib/clacky/web/i18n.js +14 -2
  36. data/lib/clacky/web/index.html +29 -13
  37. data/lib/clacky/web/sessions.js +45 -8
  38. data/scripts/uninstall.sh +1 -1
  39. metadata +2 -1
@@ -49,6 +49,13 @@
49
49
  "published.confirm": "Unpublish {{id}} from the marketplace?",
50
50
  "err.generic": "Something went wrong: {{msg}}",
51
51
  "err.version_conflict": "Version {{ver}} has already been published. Please enter a higher version number above and try again.",
52
+ "err.invalid_version": "Invalid version. Use format like 1.0.0.",
53
+ "err.name_taken": "The name \"{{id}}\" is already taken by another user.",
54
+ "rename.label": "Choose a new ID for your extension:",
55
+ "rename.placeholder": "e.g. my-ext-yourname",
56
+ "rename.hint": "Only lowercase letters, digits and hyphens. This will rename the local folder and update ext.yml.",
57
+ "rename.btn": "Rename & Publish",
58
+ "rename.renaming": "Renaming…",
52
59
  "pub.version.label": "Version",
53
60
 
54
61
  "publish.confirm.title": "Publish to the marketplace?",
@@ -88,6 +95,27 @@
88
95
  "pub.success": "Published successfully",
89
96
  "pub.done.close": "Done",
90
97
  "pub.retry": "Try again",
98
+ "pub.readme.placeholder": "## Overview\n\nDescribe what your extension does...",
99
+ "pub.screenshot.upload": "Upload screenshot",
100
+ "pub.screenshot.uploading": "Uploading…",
101
+ "pub.screenshot.section": "Screenshots",
102
+ "pub.screenshot.hint": "PNG / JPG / GIF, up to 5 MB each",
103
+ "pub.screenshot.err": "Upload failed: {{msg}}",
104
+ "pub.screenshot.err.type": "Only PNG / JPG / GIF are allowed",
105
+ "pub.screenshot.err.size": "File size must be under 5 MB",
106
+ "pub.readme.edit.title": "Edit README",
107
+ "readme.pane.editor": "Markdown editor",
108
+ "readme.pane.preview": "Preview",
109
+ "pub.readme.edit": "Edit",
110
+ "pub.readme.section": "Description",
111
+ "pub.readme.empty": "No description yet — click Edit to add one.",
112
+ "pub.readme.save.empty": "Description cannot be empty.",
113
+ "pub.readme.required": "Please add a description (README) before publishing.",
114
+ "pub.readme.required.skip": "Skip, publish anyway",
115
+ "pub.readme.required.edit": "Add description",
116
+ "pub.readme.save": "Save",
117
+ "pub.readme.saving": "Saving…",
118
+ "pub.readme.skip": "Skip",
91
119
  "extlist.section.cloud": "Published Extensions",
92
120
  "extlist.section.cloudHint": "Live on the marketplace",
93
121
  "extlist.section.local": "Local Extensions",
@@ -118,6 +146,25 @@
118
146
  "extlist.newExt.hint": "Opens an AI session that scaffolds and builds it for you.",
119
147
  "extlist.newExt.btn": "Create New Extension",
120
148
  "extlist.newExt.seed": "I want to build a new OpenClacky extension. Help me get started: ask me what the extension should do, then scaffold it and build it out step by step.",
149
+ "unit.panel": "panel",
150
+ "unit.panels": "panels",
151
+ "unit.agent": "agent",
152
+ "unit.agents": "agents",
153
+ "unit.api": "API",
154
+ "unit.apis": "APIs",
155
+ "unit.skill": "skill",
156
+ "unit.skills": "skills",
157
+
158
+ "meta.section": "Extension Info",
159
+ "meta.edit.title": "Edit Extension Info",
160
+ "meta.edit": "Edit",
161
+ "meta.name.label": "Display name",
162
+ "meta.desc.label": "Description",
163
+ "meta.save": "Save",
164
+ "meta.saving": "Saving…",
165
+ "meta.saved": "Saved",
166
+ "meta.save.err": "Save failed: {{msg}}",
167
+
121
168
  "skills.section.cloud": "Cloud Skills",
122
169
  "skills.section.cloudHint": "Published to the platform",
123
170
  "skills.section.local": "Local Skills",
@@ -186,6 +233,13 @@
186
233
  "published.confirm": "确定要从市场下架 {{id}} 吗?",
187
234
  "err.generic": "出错了:{{msg}}",
188
235
  "err.version_conflict": "版本 {{ver}} 已发布过,请在上方输入更高的版本号后重试。",
236
+ "err.invalid_version": "版本号格式不对,请使用如 1.0.0 的格式。",
237
+ "err.name_taken": "扩展名「{{id}}」已被其他用户占用。",
238
+ "rename.label": "请为你的扩展选择一个新 ID:",
239
+ "rename.placeholder": "例如 my-ext-yourname",
240
+ "rename.hint": "只能使用小写字母、数字和连字符。",
241
+ "rename.btn": "重命名并发布",
242
+ "rename.renaming": "重命名中…",
189
243
  "pub.version.label": "版本号",
190
244
 
191
245
  "publish.confirm.title": "确定发布到市场?",
@@ -225,6 +279,27 @@
225
279
  "pub.success": "已发布成功",
226
280
  "pub.done.close": "完成",
227
281
  "pub.retry": "重试",
282
+ "pub.readme.placeholder": "## 简介\n\n描述你的扩展的功能…",
283
+ "pub.screenshot.upload": "上传截图",
284
+ "pub.screenshot.uploading": "上传中…",
285
+ "pub.screenshot.section": "截图",
286
+ "pub.screenshot.hint": "PNG / JPG / GIF,每张最大 5 MB",
287
+ "pub.screenshot.err": "上传失败:{{msg}}",
288
+ "pub.screenshot.err.type": "仅支持 PNG / JPG / GIF 格式",
289
+ "pub.screenshot.err.size": "文件大小不能超过 5 MB",
290
+ "pub.readme.edit.title": "编辑使用说明",
291
+ "readme.pane.editor": "Markdown 编写",
292
+ "readme.pane.preview": "预览",
293
+ "pub.readme.edit": "编辑",
294
+ "pub.readme.section": "使用说明",
295
+ "pub.readme.empty": "暂无说明 — 点击编辑添加。",
296
+ "pub.readme.save.empty": "使用说明不能为空。",
297
+ "pub.readme.required": "发布前请先填写使用说明。",
298
+ "pub.readme.required.skip": "忽略,继续发布",
299
+ "pub.readme.required.edit": "去填写",
300
+ "pub.readme.save": "保存",
301
+ "pub.readme.saving": "保存中…",
302
+ "pub.readme.skip": "跳过",
228
303
  "extlist.section.cloud": "已发布扩展",
229
304
  "extlist.section.cloudHint": "已上架到市场",
230
305
  "extlist.section.local": "本地扩展",
@@ -255,6 +330,25 @@
255
330
  "extlist.newExt.hint": "打开一个 AI 会话,帮你生成并开发它。",
256
331
  "extlist.newExt.btn": "新建扩展",
257
332
  "extlist.newExt.seed": "我想开发一个新的 OpenClacky 扩展。请引导我开始:先问清楚这个扩展要做什么,然后帮我搭好骨架并一步步开发出来。",
333
+ "unit.panel": "个面板",
334
+ "unit.panels": "个面板",
335
+ "unit.agent": "个 Agent",
336
+ "unit.agents": "个 Agent",
337
+ "unit.api": "个 API",
338
+ "unit.apis": "个 API",
339
+ "unit.skill": "个技能",
340
+ "unit.skills": "个技能",
341
+
342
+ "meta.section": "扩展信息",
343
+ "meta.edit.title": "编辑扩展信息",
344
+ "meta.edit": "编辑",
345
+ "meta.name.label": "显示名称",
346
+ "meta.desc.label": "描述",
347
+ "meta.save": "保存",
348
+ "meta.saving": "保存中…",
349
+ "meta.saved": "已保存",
350
+ "meta.save.err": "保存失败:{{msg}}",
351
+
258
352
  "skills.section.cloud": "云端 Skills",
259
353
  "skills.section.cloudHint": "已发布到平台",
260
354
  "skills.section.local": "本地 Skills",
@@ -316,6 +410,22 @@
316
410
  return data;
317
411
  }
318
412
 
413
+ async function patchJson(path, body) {
414
+ const res = await fetch(api(path), {
415
+ method: "PATCH",
416
+ headers: { "Content-Type": "application/json" },
417
+ body: JSON.stringify(body || {}),
418
+ });
419
+ const data = await res.json();
420
+ if (!res.ok) {
421
+ const err = new Error(data.error || `Request failed (${res.status})`);
422
+ err.status = res.status;
423
+ err.data = data;
424
+ throw err;
425
+ }
426
+ return data;
427
+ }
428
+
319
429
  async function downloadPack(ext_id) {
320
430
  const res = await fetch(api("/pack"), {
321
431
  method: "POST",
@@ -383,7 +493,6 @@
383
493
  box.appendChild(bodyEl);
384
494
  box.appendChild(footer);
385
495
  overlay.appendChild(box);
386
- overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
387
496
  document.body.appendChild(overlay);
388
497
  return { overlay, bodyEl, footer, close };
389
498
  }
@@ -477,13 +586,16 @@
477
586
  }
478
587
  }
479
588
 
589
+ function isSemver(v) {
590
+ return /^\d+\.\d+\.\d+$/.test(v);
591
+ }
592
+
480
593
  // Unified publish flow: one modal that walks the creator from a release form
481
- // (version + notes) through progress → device binding (if needed) → done.
482
- // The creator owns the version; if it conflicts with the latest published
483
- // version they can bump it inline — the modal writes it back to ext.yml
484
- // before uploading. `prevVersionOrPromise` may be a value or a Promise.
594
+ // (version + notes) through progress → done.
595
+ // `prevVersionOrPromise` may be a value or a Promise.
485
596
  // Resolves true when a publish succeeded, false otherwise.
486
- function runPublishFlow(ext, prevVersionOrPromise) {
597
+
598
+ function runPublishFlow(ext, prevVersionOrPromise, { onPublishDone } = {}) {
487
599
  return new Promise((resolve) => {
488
600
  let currentVersion = ext.version || "";
489
601
  let isUpdate = false;
@@ -493,8 +605,15 @@
493
605
  const verInput = el("input", { class: "studio-input", type: "text", value: currentVersion, placeholder: "1.0.0" });
494
606
  verInput.addEventListener("input", () => {
495
607
  currentVersion = verInput.value.trim();
496
- publishBtn.disabled = !currentVersion;
497
- publishBtn.textContent = currentVersion ? t("pub.btn.publish", { ver: currentVersion }) : t("pub.btn.publish", { ver: "?" });
608
+ const valid = isSemver(currentVersion);
609
+ verInput.classList.toggle("studio-input-error", !!currentVersion && !valid);
610
+ publishBtn.disabled = !valid;
611
+ publishBtn.textContent = valid ? t("pub.btn.publish", { ver: currentVersion }) : t("pub.btn.publish", { ver: currentVersion || "?" });
612
+ if (currentVersion && !valid) {
613
+ setProgress(t("err.invalid_version"), true);
614
+ } else {
615
+ status.style.display = "none";
616
+ }
498
617
  });
499
618
  verField.appendChild(verInput);
500
619
 
@@ -563,6 +682,13 @@
563
682
  if (done || publishBtn.disabled) return;
564
683
  const ver = currentVersion;
565
684
  if (!ver) return;
685
+ if (!isSemver(ver)) {
686
+ setProgress(t("err.invalid_version", { ver }), true);
687
+ verInput.classList.add("studio-input-error");
688
+ verInput.focus();
689
+ verInput.select();
690
+ return;
691
+ }
566
692
 
567
693
  publishBtn.disabled = true;
568
694
  publishBtn.textContent = t("pub.btn.publishing");
@@ -583,15 +709,20 @@
583
709
  });
584
710
 
585
711
  if (data === null) { resetButtons(); return; }
712
+
713
+ // Name taken by another user — show inline rename form.
714
+ if (!data.ok && data.name_taken) {
715
+ showRenameForm(ext, data.error);
716
+ modal.close();
717
+ resolve(false);
718
+ return;
719
+ }
720
+
586
721
  if (!data.ok) throw new Error(data.error || "Publish failed");
587
722
 
588
723
  done = true;
589
- const successLabel = el("span", { class: "studio-pub-success", text: t("pub.success") });
590
- modal.footer.insertBefore(successLabel, modal.footer.firstChild);
591
- cancelBtn.style.display = "none";
592
- publishBtn.disabled = false;
593
- publishBtn.textContent = t("pub.btn.done");
594
- publishBtn.onclick = () => { modal.close(); resolve(true); };
724
+ modal.close();
725
+ resolve(true);
595
726
  } catch (e) {
596
727
  const msg = e.message || "";
597
728
  const isConflict = /must be greater than/i.test(msg);
@@ -611,7 +742,83 @@
611
742
  });
612
743
  }
613
744
 
614
- // ── Shared: extension list + current selection ─────────────────────────
745
+ // Show an inline modal that lets the creator pick a new extension ID when
746
+ // the current name is already taken by another user on the marketplace.
747
+ // On confirm: calls set_id to rename the local folder + ext.yml, then
748
+ // immediately re-triggers the full publish flow with the new id.
749
+ function showRenameForm(ext, serverError) {
750
+ const slugFormat = /^[a-z0-9][a-z0-9\-]*[a-z0-9]$/;
751
+
752
+ const errorMsg = el("p", { class: "studio-modal-status studio-modal-status-error",
753
+ text: t("err.name_taken", { id: ext.id }) });
754
+
755
+ const field = el("div", { class: "studio-field" });
756
+ field.appendChild(el("label", { class: "studio-label", text: t("rename.label") }));
757
+ const input = el("input", { class: "studio-input", type: "text",
758
+ value: ext.id, placeholder: t("rename.placeholder") });
759
+ const hint = el("p", { class: "studio-field-hint", text: t("rename.hint") });
760
+ field.appendChild(input);
761
+ field.appendChild(hint);
762
+
763
+ const status = el("p", { class: "studio-modal-status" });
764
+ status.style.display = "none";
765
+
766
+ let done = false;
767
+ const modal = openModal({
768
+ title: t("rename.btn"),
769
+ body: el("div", null, [field, errorMsg, status]),
770
+ buttons: [
771
+ { label: t("pub.btn.cancel"), keepOpen: false, onClick: () => {} },
772
+ { label: t("rename.btn"), primary: true, keepOpen: true, onClick: () => submit() },
773
+ ],
774
+ });
775
+
776
+ const confirmBtn = modal.footer.querySelector(".studio-btn-primary");
777
+ const cancelBtn = modal.footer.querySelector(".studio-btn:not(.studio-btn-primary)");
778
+
779
+ input.addEventListener("input", () => {
780
+ const val = input.value.trim();
781
+ const valid = val.length >= 2 && val.length <= 64 && slugFormat.test(val) && val !== ext.id;
782
+ confirmBtn.disabled = !valid;
783
+ input.classList.toggle("studio-input-error", !!val && !slugFormat.test(val));
784
+ status.style.display = "none";
785
+ });
786
+ // Disable confirm until user types a valid different id.
787
+ confirmBtn.disabled = true;
788
+
789
+ async function submit() {
790
+ if (done || confirmBtn.disabled) return;
791
+ const newId = input.value.trim();
792
+ confirmBtn.disabled = true;
793
+ confirmBtn.textContent = t("rename.renaming");
794
+ cancelBtn.disabled = true;
795
+ input.disabled = true;
796
+ status.style.display = "none";
797
+
798
+ try {
799
+ await postJson("/set_id", { ext_id: ext.id, new_id: newId });
800
+ done = true;
801
+ modal.close();
802
+ // Re-trigger the full publish flow with the renamed extension.
803
+ // Reload store first so the new id is visible.
804
+ await store.reload();
805
+ const renamed = store.extensions.find((e) => e.id === newId);
806
+ if (renamed) {
807
+ const ok = await runPublishFlow(renamed, null, { skipEntryCheck: true });
808
+ if (ok) store.reload();
809
+ }
810
+ } catch (e) {
811
+ status.textContent = t("err.generic", { msg: e.message });
812
+ status.style.display = "";
813
+ status.className = "studio-modal-status studio-modal-status-error";
814
+ confirmBtn.disabled = false;
815
+ confirmBtn.textContent = t("rename.btn");
816
+ cancelBtn.disabled = false;
817
+ input.disabled = false;
818
+ }
819
+ }
820
+ }
821
+
615
822
  // Both tabs care about "which local extension am I working on", so we keep a
616
823
  // tiny page-level store and let each tab subscribe to changes.
617
824
  const store = {
@@ -642,7 +849,7 @@
642
849
  }
643
850
  const select = el("select", { class: "studio-select" });
644
851
  store.extensions.forEach((e) => {
645
- const opt = el("option", { value: e.id, text: `${e.name} (${e.id})` });
852
+ const opt = el("option", { value: e.id, text: e.name ? `${e.name} (${e.id})` : e.id });
646
853
  if (e.id === store.selectedId) opt.selected = true;
647
854
  select.appendChild(opt);
648
855
  });
@@ -713,32 +920,6 @@
713
920
  if (box) { box.innerHTML = ""; }
714
921
  }
715
922
 
716
- function renderDetail() {
717
- const detail = container.querySelector(".studio-detail");
718
- if (!detail) return;
719
- detail.innerHTML = "";
720
- const ext = store.selected();
721
- if (!ext) return;
722
-
723
- detail.appendChild(el("h4", { class: "studio-detail-name", text: ext.name }));
724
- if (ext.description) detail.appendChild(el("p", { class: "studio-detail-desc", text: ext.description }));
725
-
726
- const meta = el("dl", { class: "studio-meta" });
727
- const row = (label, value) => { meta.appendChild(el("dt", { text: label })); meta.appendChild(el("dd", { text: value || "—" })); };
728
- row(t("detail.version"), ext.version);
729
- row(t("detail.layer"), ext.layer);
730
- detail.appendChild(meta);
731
-
732
- detail.appendChild(el("div", { class: "studio-label", text: t("detail.units") }));
733
- if (!ext.units.length) {
734
- detail.appendChild(el("p", { class: "studio-empty", text: t("detail.noUnits") }));
735
- } else {
736
- const list = el("div", { class: "studio-units" });
737
- ext.units.forEach((u) => list.appendChild(el("span", { class: "studio-unit-chip", text: `${u.kind} · ${u.id}` })));
738
- detail.appendChild(list);
739
- }
740
- }
741
-
742
923
  function rebuild() {
743
924
  if (!container) return;
744
925
  container.innerHTML = "";
@@ -749,8 +930,6 @@
749
930
  return;
750
931
  }
751
932
 
752
- container.appendChild(el("div", { class: "studio-detail" }));
753
-
754
933
  const bar = el("div", { class: "studio-actions" });
755
934
  bar.appendChild(el("button", { class: "studio-btn studio-btn-primary studio-verify-status", text: t("btn.recheck"), onclick: runVerify }));
756
935
  bar.appendChild(el("span", { class: "studio-verify-summary" }));
@@ -759,7 +938,6 @@
759
938
  container.appendChild(el("div", { class: "studio-verify" }));
760
939
  container.appendChild(el("p", { class: "studio-hint", text: t("hint.reload") }));
761
940
 
762
- renderDetail();
763
941
  runVerify();
764
942
  }
765
943
 
@@ -777,6 +955,306 @@
777
955
  }
778
956
 
779
957
  // ── Publish tab ──────────────────────────────────────────────────────────
958
+
959
+ function buildExtInfoCard(ext, onEdit) {
960
+ const card = el("div", { class: "studio-detail" });
961
+
962
+ const head = el("div", { class: "studio-detail-head" });
963
+ head.appendChild(el("h4", { class: "studio-detail-name", text: ext.name || ext.id }));
964
+ const editBtn = el("button", { class: "studio-btn studio-btn-ghost studio-btn-sm", text: t("meta.edit") });
965
+ editBtn.addEventListener("click", onEdit);
966
+ head.appendChild(editBtn);
967
+ card.appendChild(head);
968
+
969
+ if (ext.description) card.appendChild(el("p", { class: "studio-detail-desc", text: ext.description }));
970
+
971
+ const infoRow = el("div", { class: "studio-info-row" });
972
+ infoRow.appendChild(el("span", { class: "studio-info-label", text: t("detail.version") }));
973
+ infoRow.appendChild(el("span", { class: "studio-info-value", text: ext.version || "—" }));
974
+ card.appendChild(infoRow);
975
+
976
+ card.appendChild(el("div", { class: "studio-label studio-section-label", text: t("detail.units") }));
977
+ if (!(ext.units || []).length) {
978
+ card.appendChild(el("p", { class: "studio-empty", text: t("detail.noUnits") }));
979
+ } else {
980
+ const list = el("div", { class: "studio-units" });
981
+ ext.units.forEach((u) => list.appendChild(el("span", { class: "studio-unit-chip", text: u.kind })));
982
+ card.appendChild(list);
983
+ }
984
+
985
+ return card;
986
+ }
987
+
988
+ function buildReadmeCard(extId, currentReadme, onEdit) {
989
+ const card = el("div", { class: "studio-detail studio-readme-card" });
990
+
991
+ const head = el("div", { class: "studio-detail-head" });
992
+ head.appendChild(el("h4", { class: "studio-detail-name", text: t("pub.readme.section") }));
993
+ const editBtn = el("button", { class: "studio-btn studio-btn-ghost studio-btn-sm", text: t("pub.readme.edit") });
994
+ editBtn.addEventListener("click", onEdit);
995
+ head.appendChild(editBtn);
996
+ card.appendChild(head);
997
+
998
+ if (currentReadme && currentReadme.trim()) {
999
+ const preview = el("div", { class: "studio-readme-preview extension-readme-body studio-readme-card-preview" });
1000
+ preview.innerHTML = typeof marked !== "undefined"
1001
+ ? marked.parse(currentReadme, { breaks: true, gfm: true })
1002
+ : `<pre>${currentReadme}</pre>`;
1003
+ card.appendChild(preview);
1004
+ } else {
1005
+ card.appendChild(el("p", { class: "studio-empty", text: t("pub.readme.empty") }));
1006
+ }
1007
+
1008
+ return card;
1009
+ }
1010
+
1011
+ function openMetaEditModal(ext, onSaved) {
1012
+ const overlay = el("div", { class: "studio-modal-overlay" });
1013
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
1014
+
1015
+ const modal = el("div", { class: "studio-modal" });
1016
+ overlay.appendChild(modal);
1017
+ document.body.appendChild(overlay);
1018
+
1019
+ function close() { document.body.removeChild(overlay); }
1020
+
1021
+ modal.appendChild(el("h3", { class: "studio-modal-title", text: t("meta.edit.title") }));
1022
+
1023
+ const nameField = el("div", { class: "studio-field" });
1024
+ nameField.appendChild(el("label", { class: "studio-label", text: t("meta.name.label") }));
1025
+ const nameInput = el("input", { class: "studio-input", value: ext.name || "" });
1026
+ nameField.appendChild(nameInput);
1027
+ modal.appendChild(nameField);
1028
+
1029
+ const descField = el("div", { class: "studio-field" });
1030
+ descField.appendChild(el("label", { class: "studio-label", text: t("meta.desc.label") }));
1031
+ const descInput = el("textarea", { class: "studio-textarea", rows: "5", text: ext.description || "" });
1032
+ descField.appendChild(descInput);
1033
+ modal.appendChild(descField);
1034
+
1035
+ const statusEl = el("span", { class: "studio-form-status" });
1036
+ const footer = el("div", { class: "studio-modal-footer" });
1037
+ const cancelBtn = el("button", { class: "studio-btn studio-btn-ghost", text: t("pub.btn.cancel"), onclick: close });
1038
+ const saveBtn = el("button", { class: "studio-btn studio-btn-primary", text: t("meta.save") });
1039
+
1040
+ saveBtn.addEventListener("click", async () => {
1041
+ saveBtn.disabled = true;
1042
+ saveBtn.textContent = t("meta.saving");
1043
+ statusEl.textContent = "";
1044
+ try {
1045
+ await postJson("/set_meta", {
1046
+ ext_id: ext.id,
1047
+ name: nameInput.value.trim(),
1048
+ description: descInput.value.trim(),
1049
+ });
1050
+ close();
1051
+ if (onSaved) onSaved();
1052
+ } catch (e) {
1053
+ saveBtn.disabled = false;
1054
+ saveBtn.textContent = t("meta.save");
1055
+ statusEl.textContent = t("meta.save.err", { msg: e.message });
1056
+ }
1057
+ });
1058
+
1059
+ footer.appendChild(statusEl);
1060
+ footer.appendChild(cancelBtn);
1061
+ footer.appendChild(saveBtn);
1062
+ modal.appendChild(footer);
1063
+ }
1064
+
1065
+ // Standalone README + screenshot editor modal, reachable from the published
1066
+ // extension list at any time (not just after first publish).
1067
+
1068
+ // Prompt user to fill in README before publishing.
1069
+ // Returns a Promise that resolves when the user is ready to continue.
1070
+ // If readme is missing, shows a dialog: "Add description" opens the editor,
1071
+ // "Skip" proceeds without filling in.
1072
+ // Options:
1073
+ // onSaved(readme) — called after user saves the readme in the editor,
1074
+ // use this to trigger a UI refresh in the calling panel.
1075
+ function confirmReadme(ext, { onSaved } = {}) {
1076
+ if (ext.readme && ext.readme.trim()) return Promise.resolve();
1077
+ return new Promise((resolve) => {
1078
+ const overlay = el("div", { class: "studio-modal-overlay" });
1079
+ const box = el("div", { class: "studio-modal", style: "max-width:420px;padding:24px 28px 20px" });
1080
+ box.appendChild(el("h3", { class: "studio-modal-title", style: "margin-bottom:10px", text: t("pub.readme.section") }));
1081
+ box.appendChild(el("p", { style: "margin-bottom:20px;color:var(--color-text-secondary);font-size:14px", text: t("pub.readme.required") }));
1082
+ const footer = el("div", { class: "studio-modal-footer" });
1083
+ const skipBtn = el("button", { class: "studio-btn studio-btn-ghost", text: t("pub.readme.required.skip") });
1084
+ const editBtn = el("button", { class: "studio-btn studio-btn-primary", text: t("pub.readme.required.edit") });
1085
+ function close(goEdit) {
1086
+ if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
1087
+ if (goEdit) {
1088
+ openReadmeEditModal(ext.id, ext.readme || "", (saved) => {
1089
+ ext.readme = saved;
1090
+ if (onSaved) onSaved(saved);
1091
+ resolve();
1092
+ });
1093
+ } else {
1094
+ resolve();
1095
+ }
1096
+ }
1097
+ skipBtn.addEventListener("click", () => close(false));
1098
+ editBtn.addEventListener("click", () => close(true));
1099
+ footer.appendChild(skipBtn);
1100
+ footer.appendChild(editBtn);
1101
+ box.appendChild(footer);
1102
+ overlay.appendChild(box);
1103
+ document.body.appendChild(overlay);
1104
+ });
1105
+ }
1106
+
1107
+ function openReadmeEditModal(extId, initialReadme, onSaved) {
1108
+ // Use a wider full-overlay modal with a split editor/preview layout.
1109
+ const overlay = el("div", { class: "studio-modal-overlay studio-readme-overlay" });
1110
+ const box = el("div", { class: "studio-modal studio-readme-modal" });
1111
+ overlay.appendChild(box);
1112
+ document.body.appendChild(overlay);
1113
+ function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
1114
+
1115
+ box.appendChild(el("h3", { class: "studio-modal-title", text: t("pub.readme.edit.title") }));
1116
+
1117
+ // Split pane: left = textarea, right = rendered preview
1118
+ const splitPane = el("div", { class: "studio-readme-split" });
1119
+
1120
+ const editorPane = el("div", { class: "studio-readme-pane" });
1121
+ editorPane.appendChild(el("div", { class: "studio-readme-pane-label", text: t("readme.pane.editor") }));
1122
+ const readmeArea = el("textarea", {
1123
+ class: "studio-textarea studio-readme-textarea",
1124
+ placeholder: t("pub.readme.placeholder"),
1125
+ });
1126
+ // Pre-fill with existing readme content
1127
+ if (initialReadme) {
1128
+ readmeArea.value = initialReadme;
1129
+ }
1130
+ editorPane.appendChild(readmeArea);
1131
+ splitPane.appendChild(editorPane);
1132
+
1133
+ const previewPane = el("div", { class: "studio-readme-pane" });
1134
+ previewPane.appendChild(el("div", { class: "studio-readme-pane-label", text: t("readme.pane.preview") }));
1135
+ const previewBody = el("div", { class: "studio-readme-preview extension-readme-body" });
1136
+ previewPane.appendChild(previewBody);
1137
+ splitPane.appendChild(previewPane);
1138
+
1139
+ box.appendChild(splitPane);
1140
+
1141
+ // Render helper
1142
+ function renderPreview() {
1143
+ const md = readmeArea.value;
1144
+ previewBody.innerHTML = md && typeof marked !== "undefined"
1145
+ ? marked.parse(md, { breaks: true, gfm: true })
1146
+ : (md ? `<pre>${md}</pre>` : "");
1147
+ }
1148
+
1149
+ // Live preview on input
1150
+ readmeArea.addEventListener("input", renderPreview);
1151
+
1152
+ // Initial preview if there's pre-filled content
1153
+ if (readmeArea.value) renderPreview();
1154
+
1155
+ // Screenshots section
1156
+ const screenshotsSection = el("div", { class: "studio-readme-screenshots" });
1157
+
1158
+ const screenshotRow = el("div", { class: "studio-readme-screenshots-row" });
1159
+
1160
+ const fileInput = el("input", { type: "file", accept: "image/png,image/jpeg,image/gif", style: "display:none" });
1161
+ screenshotsSection.appendChild(fileInput);
1162
+
1163
+ const uploadBtn = el("button", { class: "studio-btn studio-btn-ghost studio-btn-sm", text: t("pub.screenshot.upload") });
1164
+ uploadBtn.addEventListener("click", () => fileInput.click());
1165
+ screenshotRow.appendChild(uploadBtn);
1166
+ screenshotRow.appendChild(el("span", { class: "studio-readme-screenshots-hint", text: t("pub.screenshot.hint") }));
1167
+ screenshotsSection.appendChild(screenshotRow);
1168
+
1169
+ const screenshotErr = el("p", { class: "studio-modal-status studio-modal-status-error", style: "display:none;margin-top:6px" });
1170
+ screenshotsSection.appendChild(screenshotErr);
1171
+ fileInput.addEventListener("change", async () => {
1172
+ const file = fileInput.files[0];
1173
+ if (!file) return;
1174
+ fileInput.value = "";
1175
+ screenshotErr.style.display = "none";
1176
+ if (!["image/png", "image/jpeg", "image/gif"].includes(file.type)) {
1177
+ screenshotErr.textContent = t("pub.screenshot.err", { msg: t("pub.screenshot.err.type") });
1178
+ screenshotErr.style.display = "";
1179
+ return;
1180
+ }
1181
+ if (file.size > 5 * 1024 * 1024) {
1182
+ screenshotErr.textContent = t("pub.screenshot.err", { msg: t("pub.screenshot.err.size") });
1183
+ screenshotErr.style.display = "";
1184
+ return;
1185
+ }
1186
+ uploadBtn.disabled = true;
1187
+ uploadBtn.textContent = t("pub.screenshot.uploading");
1188
+ screenshotErr.style.display = "none";
1189
+ try {
1190
+ const form = new FormData();
1191
+ form.append("ext_id", extId);
1192
+ form.append("file", file);
1193
+ const res = await fetch("/api/ext/ext-studio/screenshot", { method: "POST", body: form });
1194
+ const json = await res.json();
1195
+ if (!res.ok || !json.ok) throw new Error(json.error || "upload failed");
1196
+ // Insert ![](url) at cursor position in the editor.
1197
+ // Ensure the image tag is on its own line by adding newlines if needed.
1198
+ const mdImg = `![](${json.url})`;
1199
+ const start = readmeArea.selectionStart;
1200
+ const end = readmeArea.selectionEnd;
1201
+ const before = readmeArea.value.slice(0, start);
1202
+ const after = readmeArea.value.slice(end);
1203
+ const prefix = before.length > 0 && !before.endsWith("\n") ? "\n" : "";
1204
+ const suffix = after.length > 0 && !after.startsWith("\n") ? "\n" : "";
1205
+ const inserted = prefix + mdImg + suffix;
1206
+ readmeArea.value = before + inserted + after;
1207
+ readmeArea.selectionStart = readmeArea.selectionEnd = start + inserted.length;
1208
+ readmeArea.focus();
1209
+ readmeArea.dispatchEvent(new Event("input"));
1210
+ } catch (e) {
1211
+ screenshotErr.textContent = t("pub.screenshot.err", { msg: e.message });
1212
+ screenshotErr.style.display = "";
1213
+ } finally {
1214
+ uploadBtn.disabled = false;
1215
+ uploadBtn.textContent = t("pub.screenshot.upload");
1216
+ }
1217
+ });
1218
+
1219
+ box.appendChild(screenshotsSection);
1220
+
1221
+ const statusEl = el("p", { class: "studio-modal-status", style: "display:none;margin-top:8px" });
1222
+ box.appendChild(statusEl);
1223
+
1224
+ const footer = el("div", { class: "studio-modal-footer" });
1225
+ const cancelBtn = el("button", { class: "studio-btn studio-btn-ghost", text: t("pub.btn.cancel") });
1226
+ cancelBtn.addEventListener("click", close);
1227
+ const saveBtn = el("button", { class: "studio-btn studio-btn-primary", text: t("pub.readme.save") });
1228
+ saveBtn.addEventListener("click", async () => {
1229
+ const readme = readmeArea.value.trim();
1230
+ if (!readme) {
1231
+ statusEl.className = "studio-modal-status studio-modal-status-error";
1232
+ statusEl.textContent = t("pub.readme.save.empty");
1233
+ statusEl.style.display = "";
1234
+ readmeArea.focus();
1235
+ return;
1236
+ }
1237
+ saveBtn.disabled = true;
1238
+ saveBtn.textContent = t("pub.readme.saving");
1239
+ cancelBtn.disabled = true;
1240
+ statusEl.style.display = "none";
1241
+ try {
1242
+ await patchJson("/readme", { ext_id: extId, readme });
1243
+ close();
1244
+ if (onSaved) onSaved(readme);
1245
+ } catch (e) {
1246
+ statusEl.textContent = t("err.generic", { msg: e.message });
1247
+ statusEl.style.display = "";
1248
+ saveBtn.disabled = false;
1249
+ saveBtn.textContent = t("pub.readme.save");
1250
+ cancelBtn.disabled = false;
1251
+ }
1252
+ });
1253
+ footer.appendChild(cancelBtn);
1254
+ footer.appendChild(saveBtn);
1255
+ box.appendChild(footer);
1256
+ }
1257
+
780
1258
  function createPublishPanel() {
781
1259
  let container = null;
782
1260
  let unsub = null;
@@ -789,13 +1267,14 @@
789
1267
  async function doPublish() {
790
1268
  const ext = store.selected();
791
1269
  if (!ext) return;
1270
+ await confirmReadme(ext, { onSaved: () => rebuild() });
792
1271
  const prevVersionPromise = getJson("/published")
793
1272
  .then((data) => {
794
1273
  const match = (data.extensions || []).find((e) => e.id === ext.id);
795
1274
  return match ? match.version : null;
796
1275
  })
797
1276
  .catch(() => null);
798
- const ok = await runPublishFlow(ext, prevVersionPromise);
1277
+ const ok = await runPublishFlow(ext, prevVersionPromise, { onPublishDone: loadPublished });
799
1278
  if (ok) loadPublished();
800
1279
  }
801
1280
 
@@ -814,13 +1293,27 @@
814
1293
  }
815
1294
  }
816
1295
 
1296
+ function renderPublishedSkeleton(box) {
1297
+ const card = el("div", { class: "studio-skill-card studio-skeleton-card" });
1298
+ const head = el("div", { class: "studio-skill-head" });
1299
+ head.appendChild(el("span", { class: "studio-skeleton studio-skeleton-name" }));
1300
+ head.appendChild(el("span", { class: "studio-skeleton studio-skeleton-badge" }));
1301
+ head.appendChild(el("span", { class: "studio-skeleton studio-skeleton-ver" }));
1302
+ head.appendChild(el("span", { class: "studio-skeleton studio-skeleton-btn" }));
1303
+ card.appendChild(head);
1304
+ box.appendChild(card);
1305
+ }
1306
+
817
1307
  async function loadPublished() {
818
1308
  const box = container.querySelector(".studio-published");
819
1309
  if (!box) return;
820
1310
  box.innerHTML = "";
821
1311
  box.appendChild(el("div", { class: "studio-label", text: t("published.title") }));
1312
+ renderPublishedSkeleton(box);
822
1313
  try {
823
1314
  const data = await getJson("/published");
1315
+ box.innerHTML = "";
1316
+ box.appendChild(el("div", { class: "studio-label", text: t("published.title") }));
824
1317
  const exts = data.extensions || [];
825
1318
  if (!exts.length) { box.appendChild(el("p", { class: "studio-empty", text: t("published.empty") })); return; }
826
1319
  exts.forEach((e) => {
@@ -830,7 +1323,7 @@
830
1323
  const statusKind = e.status === "draft" ? "local" : "published";
831
1324
  head.appendChild(el("span", { class: `studio-skill-badge studio-skill-badge-${statusKind}`, text: e.status === "draft" ? t("extlist.badge.draft") : t("extlist.badge.published") }));
832
1325
  if (e.version) head.appendChild(el("span", { style: "font-size:11px;color:var(--color-text-muted);", text: "v" + e.version }));
833
- const unpubBtn = el("button", { class: "studio-btn studio-btn-danger", text: t("btn.unpublish") });
1326
+ const unpubBtn = el("button", { class: "studio-btn studio-btn-danger studio-btn-sm", text: t("btn.unpublish") });
834
1327
  unpubBtn.style.marginLeft = "auto";
835
1328
  unpubBtn.addEventListener("click", async () => {
836
1329
  if (!window.confirm(t("published.confirm", { id: e.id }))) return;
@@ -843,6 +1336,8 @@
843
1336
  box.appendChild(card);
844
1337
  });
845
1338
  } catch (e) {
1339
+ box.innerHTML = "";
1340
+ box.appendChild(el("div", { class: "studio-label", text: t("published.title") }));
846
1341
  box.appendChild(el("p", { class: "studio-empty", text: t("err.generic", { msg: e.message }) }));
847
1342
  }
848
1343
  }
@@ -852,7 +1347,19 @@
852
1347
  container.innerHTML = "";
853
1348
  container.appendChild(renderPicker(rebuild));
854
1349
 
855
- if (!store.selected()) return;
1350
+ const ext = store.selected();
1351
+ if (!ext) return;
1352
+
1353
+ container.appendChild(buildExtInfoCard(
1354
+ ext,
1355
+ () => openMetaEditModal(ext, () => store.reload().then(rebuild))
1356
+ ));
1357
+
1358
+ container.appendChild(buildReadmeCard(
1359
+ ext.id,
1360
+ ext.readme,
1361
+ () => openReadmeEditModal(ext.id, ext.readme, () => store.reload().then(rebuild))
1362
+ ));
856
1363
 
857
1364
  const bar = el("div", { class: "studio-actions" });
858
1365
  bar.appendChild(el("button", { class: "studio-btn studio-btn-primary studio-publish-btn", text: t("btn.publish"), onclick: doPublish }));
@@ -928,6 +1435,15 @@
928
1435
  return el("span", { class: "studio-skill-badge studio-skill-badge-" + kind, text });
929
1436
  }
930
1437
 
1438
+ function formatUnitCounts(counts) {
1439
+ if (!counts || typeof counts !== "object") return "";
1440
+ return Object.keys(counts).map((kind) => {
1441
+ const n = parseInt(counts[kind], 10);
1442
+ if (!n) return null;
1443
+ return `${n} ${t("unit." + kind + (n > 1 ? "s" : ""))}`;
1444
+ }).filter(Boolean).join(" · ");
1445
+ }
1446
+
931
1447
  function cloudCard(ext) {
932
1448
  const card = el("div", { class: "studio-skill-card" });
933
1449
  const head = el("div", { class: "studio-skill-head" });
@@ -969,13 +1485,14 @@
969
1485
 
970
1486
  const meta = el("div", { class: "studio-skill-meta" });
971
1487
  if (ext.version) meta.appendChild(el("span", { text: "v" + ext.version }));
972
- if (ext.units && ext.units.length) meta.appendChild(el("span", { text: ext.units.map((u) => u.kind).join(" · ") }));
1488
+ const unitsText = formatUnitCounts(ext.unit_counts);
1489
+ if (unitsText) meta.appendChild(el("span", { text: unitsText }));
973
1490
  card.appendChild(meta);
974
1491
 
975
1492
  const actions = el("div", { class: "studio-actions" });
976
1493
  const pub = el("button", { class: "studio-btn studio-btn-primary", text: published ? t("extlist.btn.update") : t("extlist.btn.publish") });
977
- pub.disabled = !licensed() || !!ext.error_count;
978
- pub.title = licensed() ? (ext.error_count ? t("extlist.verify.errors", { n: ext.error_count }) : "") : t("extlist.needLicense");
1494
+ pub.disabled = !!ext.error_count;
1495
+ pub.title = ext.error_count ? t("extlist.verify.errors", { n: ext.error_count }) : "";
979
1496
  pub.addEventListener("click", () => doPublish(ext, cloudEntry ? cloudEntry.version : null));
980
1497
  actions.appendChild(pub);
981
1498
 
@@ -1025,7 +1542,9 @@
1025
1542
  }
1026
1543
 
1027
1544
  async function doPublish(ext, prevVersion) {
1028
- const ok = await runPublishFlow(ext, prevVersion);
1545
+ await confirmReadme(ext, { onSaved: () => { reload().then(rebuild); } });
1546
+ const onDone = async () => { await reload(); rebuild(); };
1547
+ const ok = await runPublishFlow(ext, prevVersion, { onPublishDone: onDone });
1029
1548
  if (ok) { await reload(); rebuild(); }
1030
1549
  }
1031
1550
 
@@ -1333,18 +1852,16 @@
1333
1852
  .studio-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
1334
1853
  .studio-label { font-size: 12px; font-weight: 500; color: var(--color-text-secondary); }
1335
1854
  .studio-select, .studio-textarea { width: 100%; box-sizing: border-box; background: var(--color-bg-input); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); padding: 7px 8px; color: var(--color-text-primary); font-size: 13px; font-family: inherit; }
1855
+ .studio-select { appearance: none; -webkit-appearance: none; padding-right: 28px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2 4l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 8px center; }
1336
1856
  .studio-select:focus, .studio-textarea:focus { border-color: var(--color-accent-primary); outline: none; }
1337
1857
  .studio-empty { color: var(--color-text-muted); font-size: 12px; margin: 4px 0; }
1338
1858
  .studio-hint { color: var(--color-text-tertiary); font-size: 12px; line-height: 1.5; margin: 10px 0 0; }
1339
1859
  .studio-detail { border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); padding: 12px; margin-bottom: 14px; background: var(--color-bg-secondary); }
1340
- .studio-detail-name { margin: 0 0 4px; font-size: 14px; color: var(--color-text-primary); }
1341
- .studio-detail-desc { margin: 0 0 10px; font-size: 12px; color: var(--color-text-secondary); line-height: 1.5; }
1342
- .studio-meta { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; margin: 0 0 10px; font-size: 12px; }
1343
- .studio-meta dt { color: var(--color-text-tertiary); }
1344
- .studio-meta dd { margin: 0; color: var(--color-text-primary); }
1860
+ .studio-detail-name { margin: 0; font-size: 14px; color: var(--color-text-primary); }
1861
+ .studio-detail-desc { margin: 4px 0 0; font-size: 12px; color: var(--color-text-secondary); line-height: 1.5; }
1345
1862
  .studio-units { display: flex; flex-wrap: wrap; gap: 6px; }
1346
1863
  .studio-unit-chip { display: inline-flex; background: var(--color-bg-hover); color: var(--color-text-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); padding: 2px 8px; font-size: 12px; }
1347
- .studio-actions { display: flex; align-items: center; gap: 8px; margin: 12px 0; }
1864
+ .studio-actions { display: flex; align-items: center; gap: 8px; margin: 12px 0 0; }
1348
1865
  .studio-skill-promo .studio-actions { margin: 12px 0 0; }
1349
1866
  .studio-btn { padding: 7px 14px; border-radius: var(--radius-sm); border: 1px solid var(--color-border-primary); background: transparent; color: var(--color-text-secondary); cursor: pointer; font-size: 13px; font-weight: 500; }
1350
1867
  .studio-btn:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
@@ -1373,6 +1890,12 @@
1373
1890
  .studio-feedback-error { color: var(--color-error); }
1374
1891
  .studio-feedback-warn { color: var(--color-warning, var(--color-text-secondary)); }
1375
1892
  .studio-published { margin-top: 16px; border-top: 1px solid var(--color-border-primary); padding-top: 12px; }
1893
+ @keyframes studio-shimmer { 0% { background-position: -200px 0; } 100% { background-position: calc(200px + 100%) 0; } }
1894
+ .studio-skeleton { display: inline-block; border-radius: 4px; background: linear-gradient(90deg, var(--color-bg-hover) 25%, var(--color-border-primary) 50%, var(--color-bg-hover) 75%); background-size: 200px 100%; animation: studio-shimmer 1.4s infinite; }
1895
+ .studio-skeleton-name { width: 120px; height: 14px; }
1896
+ .studio-skeleton-badge { width: 44px; height: 16px; border-radius: 10px; }
1897
+ .studio-skeleton-ver { width: 36px; height: 12px; }
1898
+ .studio-skeleton-btn { width: 44px; height: 26px; margin-left: auto; border-radius: 6px; }
1376
1899
  .studio-published-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 0; font-size: 12px; border-bottom: 1px solid var(--color-border-primary); }
1377
1900
  .studio-published-name { display: flex; align-items: center; gap: 6px; color: var(--color-text-primary); }
1378
1901
  .studio-page { width: 100%; }
@@ -1395,8 +1918,8 @@
1395
1918
  .studio-skill-badge-local { background: var(--color-bg-hover); color: var(--color-text-tertiary); }
1396
1919
  .studio-skill-badge-changed { background: var(--color-warning-bg, var(--color-bg-hover)); color: var(--color-warning, var(--color-text-secondary)); }
1397
1920
  .studio-skill-badge-shadow { background: var(--color-bg-hover); color: var(--color-text-secondary); }
1398
- .studio-skill-desc { margin: 6px 0 8px; font-size: 12px; color: var(--color-text-secondary); line-height: 1.5; }
1399
- .studio-skill-meta { display: flex; gap: 12px; font-size: 11px; color: var(--color-text-muted); }
1921
+ .studio-skill-desc { margin: 6px 0 6px; font-size: 12px; color: var(--color-text-secondary); line-height: 1.5; }
1922
+ .studio-skill-meta { display: flex; gap: 12px; font-size: 11px; color: var(--color-text-muted); margin-top: 6px; }
1400
1923
  .studio-skill-promo { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md, 8px); padding: 16px 18px; margin: 0 0 24px; background: var(--color-bg-secondary); }
1401
1924
  .studio-skill-promo-text { margin: 0 0 4px; font-size: 14px; font-weight: 600; color: var(--color-text-primary); }
1402
1925
  .studio-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 9999; animation: studio-overlay-in 0.15s ease; }
@@ -1419,6 +1942,33 @@
1419
1942
  .studio-modal-link { display: inline-block; font-size: 13px; color: var(--color-accent-primary); }
1420
1943
  .studio-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--color-border-primary); }
1421
1944
  .studio-pub-success { margin-right: auto; font-size: 13px; font-weight: 500; color: var(--color-success); }
1945
+ .studio-section { margin-bottom: 20px; }
1946
+ .studio-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
1947
+ .studio-info-row { display: flex; align-items: baseline; gap: 8px; margin: 8px 0 0; }
1948
+ .studio-info-label { font-size: 12px; color: var(--color-text-tertiary); flex-shrink: 0; }
1949
+ .studio-info-value { font-size: 13px; color: var(--color-text-primary); }
1950
+ .studio-section-label { margin-top: 10px; margin-bottom: 6px; }
1951
+ .studio-detail-head .studio-detail-name { margin: 0; }
1952
+ .studio-form-status { font-size: 12px; color: var(--color-text-tertiary); }
1953
+ .studio-btn-sm { padding: 4px 10px; font-size: 12px; }
1954
+ .studio-readme-overlay { align-items: center; justify-content: center; }
1955
+ .studio-readme-modal { width: min(1200px, calc(100vw - 48px)); height: calc(100vh - 80px); max-width: calc(100vw - 48px); border-radius: var(--radius-md, 8px); margin: 0; padding: 24px 28px 20px; display: flex; flex-direction: column; }
1956
+ .studio-readme-modal .studio-modal-title { margin-bottom: 16px; flex-shrink: 0; }
1957
+ .studio-readme-split { display: flex; gap: 14px; flex: 1; min-height: 0; margin-bottom: 18px; }
1958
+ .studio-readme-pane { flex: 1; display: flex; flex-direction: column; min-width: 0; }
1959
+ .studio-readme-pane-label { font-size: 12px; font-weight: 600; color: var(--color-text-muted); letter-spacing: 0.02em; padding-bottom: 6px; border-bottom: 1px solid var(--color-border-primary); margin-bottom: 8px; }
1960
+ .studio-readme-textarea { flex: 1; resize: none; font-family: monospace; font-size: 12.5px; line-height: 1.65; border-radius: var(--radius-sm); }
1961
+ .studio-readme-preview { flex: 1; overflow-y: auto; overflow-x: hidden; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); padding: 12px 16px; background: var(--color-bg-secondary); font-size: 13px; line-height: 1.75; }
1962
+ .studio-readme-preview > *:first-child { margin-top: 0; }
1963
+ .studio-readme-preview img { max-width: 100%; height: auto; display: block; border-radius: var(--radius-sm); }
1964
+ .studio-readme-screenshots { border-top: 1px solid var(--color-border-primary); padding-top: 14px; margin-bottom: 6px; }
1965
+ .studio-readme-screenshots-row { display: flex; align-items: center; gap: 10px; }
1966
+ .studio-readme-screenshots-hint { font-size: 11px; color: var(--color-text-muted); }
1967
+ .studio-readme-card { margin-top: 8px; }
1968
+ .studio-readme-card-preview { max-height: 200px; overflow-y: auto; font-size: 13px; }
1969
+ .extension-readme-body img { max-width: min(800px, 100%); height: auto; }
1970
+ .studio-link-btn { background: none; border: none; padding: 0; font-size: 12px; color: var(--color-accent-primary); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
1971
+ .studio-link-btn:hover { color: var(--color-accent-hover); }
1422
1972
  `;
1423
1973
  document.head.appendChild(style);
1424
1974
  })();