shipseal 0.0.7 → 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.
- package/README.md +1 -1
- package/dist/cli.js +1441 -101
- package/dist/cli.js.map +1 -1
- package/dist/studio-client.js +242 -0
- package/dist/studio-client.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
//#region src/studio/client.ts
|
|
2
|
+
const studioMarkup = `
|
|
3
|
+
<style>
|
|
4
|
+
.studio{--ink:#eae9e6;--muted:#a1a1aa;--panel:#18181b;--line:#343438;color:var(--ink);background:#0c0c0f;font:15px/1.5 system-ui,sans-serif;min-height:80vh;padding:clamp(16px,3vw,40px);border-radius:20px}
|
|
5
|
+
.studio *{box-sizing:border-box}.studio h1{font-size:clamp(30px,4vw,48px);line-height:1.1;letter-spacing:-.04em;margin:12px 0}.studio h2{font-size:20px;margin:0 0 12px}.studio p{color:var(--muted);max-width:70ch}.studio .eyebrow{color:#fb756d;text-transform:uppercase;letter-spacing:.16em;font-size:12px}
|
|
6
|
+
.studio .workspace{display:grid;grid-template-columns:280px minmax(0,1fr);gap:24px;margin-top:28px}.studio .controls{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;align-self:start}.studio label{display:block;font-size:13px;margin:14px 0 6px}.studio input,.studio select,.studio textarea{width:100%;background:#101014;border:1px solid #48484f;color:var(--ink);border-radius:8px;padding:10px;font:inherit}.studio input[type=color]{height:44px;padding:4px}.studio textarea{resize:vertical;min-height:90px}.studio button,.studio a.download{display:inline-block;border:1px solid #fb756d;background:#fa6259;color:#170a09;padding:11px 18px;border-radius:8px;font:600 14px system-ui;cursor:pointer;text-decoration:none}.studio button.secondary,.studio a.secondary{background:transparent;color:var(--ink);border-color:var(--line)}
|
|
7
|
+
.studio :focus-visible{outline:3px solid #fbbc78;outline-offset:3px}.studio button:disabled{opacity:.5;cursor:wait}.studio .load-row{display:flex;gap:10px;max-width:850px;align-items:end}.studio .load-row>div{flex:1}.studio .load-row button{white-space:nowrap}.studio .actions{display:flex;gap:10px;flex-wrap:wrap;margin:18px 0}.studio .gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:20px}.studio figure{margin:0;border:1px solid var(--line);border-radius:12px;overflow:hidden;background:var(--panel)}.studio figure img{width:100%;height:auto;display:block}.studio figcaption{padding:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;overflow-wrap:anywhere;font-size:12px}.studio figcaption a{color:#fb9a90}.studio .status{padding:14px 0;min-height:48px}.studio .notice{border-left:3px solid #fbad59;background:#241e18;padding:12px 16px;margin:12px 0;color:#f2d2ae;white-space:pre-wrap}.studio details{margin-top:16px}.studio pre{max-height:360px;overflow:auto;white-space:pre-wrap;font-size:12px}.studio .placeholder{border:1px dashed var(--line);border-radius:14px;padding:70px 24px;text-align:center;color:var(--muted)}.studio [hidden]{display:none!important}.studio .helper{font-size:12px;color:var(--muted)}
|
|
8
|
+
@media(max-width:760px){.studio .workspace{grid-template-columns:1fr}.studio .load-row{flex-direction:column;align-items:stretch}.studio .controls{display:grid;grid-template-columns:1fr 1fr;gap:0 14px}.studio .controls .wide{grid-column:1/-1}}
|
|
9
|
+
</style>
|
|
10
|
+
<section class="studio">
|
|
11
|
+
<div class="eyebrow">Shipseal studio</div><h1>Your next release, ready to share.</h1>
|
|
12
|
+
<p>Choose a release. Make it yours. Download a complete pack with sources attached.</p>
|
|
13
|
+
<form data-load class="load-row"><div><label for="studio-repo">Public GitHub repository</label><input id="studio-repo" name="repo" placeholder="owner/repository" required autocomplete="off" spellcheck="false"></div><button type="submit">Try my repo</button></form>
|
|
14
|
+
<p data-privacy class="helper">No account or API key. GitHub requests go directly from your browser. Images render on your device.</p>
|
|
15
|
+
<div data-status class="status" role="status" aria-live="polite">Load a repository to begin.</div><div data-notes hidden class="notice"></div>
|
|
16
|
+
<div class="workspace"><form data-controls class="controls" hidden>
|
|
17
|
+
<div class="wide"><h2>Make it yours</h2><label for="studio-release">Release</label><select id="studio-release"></select></div>
|
|
18
|
+
<div><label for="studio-pack">Pack</label><select id="studio-pack"><option value="story">Release story</option><option value="release">Standard release</option></select></div>
|
|
19
|
+
<div><label for="studio-style">Story style</label><select id="studio-style"><option value="minimal">Minimal</option><option value="editorial">Editorial</option><option value="terminal">Terminal</option></select></div>
|
|
20
|
+
<div><label for="studio-theme">Theme</label><select id="studio-theme"><option value="dark">Dark</option><option value="light">Light</option></select></div>
|
|
21
|
+
<div><label for="studio-accent">Accent</label><input id="studio-accent" type="color" value="#fb4c4b"></div>
|
|
22
|
+
<div class="wide"><label for="studio-format">Format</label><select id="studio-format"><option value="portrait">Portrait carousel</option><option value="square">Square</option><option value="og">Link preview</option><option value="github-social">GitHub social</option><option value="x">X</option><option value="linkedin">LinkedIn</option><option value="producthunt">Product Hunt</option><option value="all">All supported formats</option></select></div>
|
|
23
|
+
<div class="wide"><label for="studio-headline">Headline</label><input id="studio-headline" list="studio-headlines" maxlength="500" placeholder="Automatic from release notes"><datalist id="studio-headlines"></datalist><label for="studio-upgrade">Upgrade instructions (optional)</label><textarea id="studio-upgrade" maxlength="4000" placeholder="Leave blank to link to the release notes"></textarea></div>
|
|
24
|
+
<div class="wide actions"><button type="submit" data-render>Update preview</button><button type="button" class="secondary" data-save hidden>Save to repo</button></div>
|
|
25
|
+
<p class="wide helper">Story styles apply to the story pack. Standard release templates keep their established layout. The ZIP includes brand and config files for local use.</p>
|
|
26
|
+
</form><div><div data-downloads class="actions" hidden></div><div data-warnings class="notice" hidden></div><div data-gallery class="gallery"><div class="placeholder">Your release images will appear here.<br>Nothing is posted automatically.</div></div><details data-evidence hidden><summary>Sources and rendering details</summary><pre data-manifest></pre></details></div></div>
|
|
27
|
+
</section>`;
|
|
28
|
+
/**
|
|
29
|
+
* Kept in step with selectionSchema by hand. This module must stay free of runtime imports:
|
|
30
|
+
* it is bundled on its own as dist/studio-client.js and served to the browser, so importing
|
|
31
|
+
* the schema would pull zod and the whole render pipeline in with it.
|
|
32
|
+
*/
|
|
33
|
+
const SELECTABLE_FORMATS = [
|
|
34
|
+
"portrait",
|
|
35
|
+
"square",
|
|
36
|
+
"og",
|
|
37
|
+
"github-social",
|
|
38
|
+
"x",
|
|
39
|
+
"linkedin",
|
|
40
|
+
"producthunt",
|
|
41
|
+
"all"
|
|
42
|
+
];
|
|
43
|
+
const isSelectableFormat = (value) => SELECTABLE_FORMATS.some((format) => format === value);
|
|
44
|
+
function mountStudio(root, backend) {
|
|
45
|
+
root.innerHTML = studioMarkup;
|
|
46
|
+
const find = (selector, type) => {
|
|
47
|
+
const element = root.querySelector(selector);
|
|
48
|
+
if (!(element instanceof type)) throw new Error(`Missing studio element: ${selector}`);
|
|
49
|
+
return element;
|
|
50
|
+
};
|
|
51
|
+
const input = (id) => find(`#studio-${id}`, HTMLInputElement);
|
|
52
|
+
const select = (id) => find(`#studio-${id}`, HTMLSelectElement);
|
|
53
|
+
const status = find("[data-status]", HTMLDivElement);
|
|
54
|
+
const controls = find("[data-controls]", HTMLFormElement);
|
|
55
|
+
const gallery = find("[data-gallery]", HTMLDivElement);
|
|
56
|
+
const downloads = find("[data-downloads]", HTMLDivElement);
|
|
57
|
+
const warnings = find("[data-warnings]", HTMLDivElement);
|
|
58
|
+
const evidence = find("[data-evidence]", HTMLDetailsElement);
|
|
59
|
+
const save = find("[data-save]", HTMLButtonElement);
|
|
60
|
+
const upgrade = find("#studio-upgrade", HTMLTextAreaElement);
|
|
61
|
+
let state;
|
|
62
|
+
let revision = 0;
|
|
63
|
+
let urls = [];
|
|
64
|
+
let timer;
|
|
65
|
+
const showError = (error) => {
|
|
66
|
+
status.textContent = error instanceof Error ? error.message : "The preview failed. Retry the request.";
|
|
67
|
+
};
|
|
68
|
+
const clearUrls = () => {
|
|
69
|
+
for (const url of urls) URL.revokeObjectURL(url);
|
|
70
|
+
urls = [];
|
|
71
|
+
};
|
|
72
|
+
const link = (label, name, data, mime) => {
|
|
73
|
+
const bytes = Uint8Array.from(atob(data), (char) => char.charCodeAt(0));
|
|
74
|
+
const url = URL.createObjectURL(new Blob([bytes], { type: mime }));
|
|
75
|
+
urls.push(url);
|
|
76
|
+
const anchor = document.createElement("a");
|
|
77
|
+
anchor.textContent = label;
|
|
78
|
+
anchor.href = url;
|
|
79
|
+
anchor.download = name;
|
|
80
|
+
return anchor;
|
|
81
|
+
};
|
|
82
|
+
const selection = () => {
|
|
83
|
+
const style = select("style").value;
|
|
84
|
+
const format = select("format").value;
|
|
85
|
+
return {
|
|
86
|
+
pack: select("pack").value === "release" ? "release" : "story",
|
|
87
|
+
style: style === "editorial" || style === "terminal" ? style : "minimal",
|
|
88
|
+
theme: select("theme").value === "light" ? "light" : "dark",
|
|
89
|
+
accent: input("accent").value,
|
|
90
|
+
headline: input("headline").value,
|
|
91
|
+
upgrade: upgrade.value,
|
|
92
|
+
format: isSelectableFormat(format) ? format : "portrait"
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
const busy = (value) => {
|
|
96
|
+
for (const button of root.querySelectorAll("button")) button.disabled = value;
|
|
97
|
+
};
|
|
98
|
+
const render = async () => {
|
|
99
|
+
if (state === void 0) return;
|
|
100
|
+
const current = ++revision;
|
|
101
|
+
downloads.hidden = true;
|
|
102
|
+
save.hidden = true;
|
|
103
|
+
warnings.hidden = true;
|
|
104
|
+
status.textContent = "Rendering your pack. The first browser render also loads the renderer.";
|
|
105
|
+
busy(true);
|
|
106
|
+
try {
|
|
107
|
+
const result = await backend.render(selection());
|
|
108
|
+
if (current !== revision) return;
|
|
109
|
+
clearUrls();
|
|
110
|
+
gallery.replaceChildren();
|
|
111
|
+
downloads.replaceChildren();
|
|
112
|
+
for (const file of result.files) {
|
|
113
|
+
const download = link("Download PNG", file.name, file.data, "image/png");
|
|
114
|
+
const figure = document.createElement("figure");
|
|
115
|
+
const img = document.createElement("img");
|
|
116
|
+
img.src = download.href;
|
|
117
|
+
img.alt = `${state.name}: ${file.name}`;
|
|
118
|
+
img.width = file.width;
|
|
119
|
+
img.height = file.height;
|
|
120
|
+
const caption = document.createElement("figcaption");
|
|
121
|
+
const label = document.createElement("span");
|
|
122
|
+
label.textContent = file.name;
|
|
123
|
+
caption.append(label, download);
|
|
124
|
+
figure.append(img, caption);
|
|
125
|
+
gallery.append(figure);
|
|
126
|
+
}
|
|
127
|
+
const zip = link("Download pack", "shipseal-pack.zip", result.archive, "application/zip");
|
|
128
|
+
zip.className = "download";
|
|
129
|
+
downloads.append(zip);
|
|
130
|
+
if (result.pdf !== void 0) {
|
|
131
|
+
const pdf = link("Download carousel PDF", "story.pdf", result.pdf, "application/pdf");
|
|
132
|
+
pdf.className = "download secondary";
|
|
133
|
+
downloads.append(pdf);
|
|
134
|
+
}
|
|
135
|
+
downloads.hidden = false;
|
|
136
|
+
const notices = [...result.manifest.warnings.map((warning) => `${warning.format}: ${warning.slot} was ${warning.action}. Shorten the text or choose a larger format.`), ...result.manifest.missing.map((missing) => `${missing.fact}: ${missing.effect}.`)];
|
|
137
|
+
warnings.textContent = notices.join("\n");
|
|
138
|
+
warnings.hidden = notices.length === 0;
|
|
139
|
+
find("[data-manifest]", HTMLPreElement).textContent = JSON.stringify(result.manifest, null, 2);
|
|
140
|
+
evidence.hidden = false;
|
|
141
|
+
save.hidden = backend.save === void 0;
|
|
142
|
+
status.textContent = `${String(result.files.length)} images ready for ${state.name}. ${notices.length > 0 ? "Review the notices before sharing." : "No fitting warnings."}`;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (current === revision) showError(error);
|
|
145
|
+
} finally {
|
|
146
|
+
if (current === revision) busy(false);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
const load = async (tag) => {
|
|
150
|
+
const current = ++revision;
|
|
151
|
+
state = void 0;
|
|
152
|
+
clearUrls();
|
|
153
|
+
gallery.replaceChildren();
|
|
154
|
+
downloads.hidden = true;
|
|
155
|
+
controls.hidden = true;
|
|
156
|
+
save.hidden = true;
|
|
157
|
+
warnings.hidden = true;
|
|
158
|
+
evidence.hidden = true;
|
|
159
|
+
find("[data-notes]", HTMLDivElement).hidden = true;
|
|
160
|
+
status.textContent = "Reading release facts...";
|
|
161
|
+
busy(true);
|
|
162
|
+
try {
|
|
163
|
+
const next = await backend.load(input("repo").value, tag);
|
|
164
|
+
if (current !== revision) return;
|
|
165
|
+
state = next;
|
|
166
|
+
select("release").replaceChildren(...next.releases.map((release) => {
|
|
167
|
+
const option = document.createElement("option");
|
|
168
|
+
option.value = release.tag;
|
|
169
|
+
option.textContent = release.label;
|
|
170
|
+
return option;
|
|
171
|
+
}));
|
|
172
|
+
select("release").value = next.tag;
|
|
173
|
+
select("style").value = next.style;
|
|
174
|
+
select("theme").value = next.theme;
|
|
175
|
+
input("accent").value = next.accent;
|
|
176
|
+
input("headline").value = next.headline;
|
|
177
|
+
upgrade.value = next.upgrade;
|
|
178
|
+
find("#studio-headlines", HTMLDataListElement).replaceChildren(...next.headlines.map((headline) => {
|
|
179
|
+
const option = document.createElement("option");
|
|
180
|
+
option.value = headline;
|
|
181
|
+
return option;
|
|
182
|
+
}));
|
|
183
|
+
const notes = find("[data-notes]", HTMLDivElement);
|
|
184
|
+
notes.textContent = next.notes.join("\n");
|
|
185
|
+
notes.hidden = next.notes.length === 0;
|
|
186
|
+
controls.hidden = false;
|
|
187
|
+
await render();
|
|
188
|
+
} catch (error) {
|
|
189
|
+
showError(error);
|
|
190
|
+
busy(false);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
find("[data-load]", HTMLFormElement).addEventListener("submit", (event) => {
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
load();
|
|
196
|
+
});
|
|
197
|
+
controls.addEventListener("submit", (event) => {
|
|
198
|
+
event.preventDefault();
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
render();
|
|
201
|
+
});
|
|
202
|
+
select("release").addEventListener("change", () => {
|
|
203
|
+
load(select("release").value);
|
|
204
|
+
});
|
|
205
|
+
controls.addEventListener("input", (event) => {
|
|
206
|
+
if (event.target === select("release")) return;
|
|
207
|
+
if (select("pack").value === "release" && [
|
|
208
|
+
"portrait",
|
|
209
|
+
"square",
|
|
210
|
+
"producthunt"
|
|
211
|
+
].includes(select("format").value)) select("format").value = "all";
|
|
212
|
+
select("style").disabled = select("pack").value === "release";
|
|
213
|
+
downloads.hidden = true;
|
|
214
|
+
save.hidden = true;
|
|
215
|
+
++revision;
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
timer = setTimeout(() => {
|
|
218
|
+
render();
|
|
219
|
+
}, 500);
|
|
220
|
+
});
|
|
221
|
+
save.addEventListener("click", async () => {
|
|
222
|
+
busy(true);
|
|
223
|
+
try {
|
|
224
|
+
await backend.save?.(selection());
|
|
225
|
+
status.textContent = "Saved brand and release settings to .shipseal. Future local runs will use these choices.";
|
|
226
|
+
} catch (error) {
|
|
227
|
+
showError(error);
|
|
228
|
+
} finally {
|
|
229
|
+
busy(false);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
if (backend.mode === "local") {
|
|
233
|
+
find("[data-load]", HTMLFormElement).hidden = true;
|
|
234
|
+
find("[data-privacy]", HTMLParagraphElement).textContent = "Local preview. Files stay on this computer. Save to repo writes only your Shipseal brand and config settings.";
|
|
235
|
+
load();
|
|
236
|
+
}
|
|
237
|
+
window.addEventListener("pagehide", clearUrls, { once: true });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
//#endregion
|
|
241
|
+
export { mountStudio, studioMarkup };
|
|
242
|
+
//# sourceMappingURL=studio-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"studio-client.js","names":[],"sources":["../src/studio/client.ts"],"sourcesContent":["import type { StudioSelection } from \"./pack.js\";\n\nexport interface StudioState {\n name: string;\n style: StudioSelection[\"style\"];\n theme: StudioSelection[\"theme\"];\n accent: string;\n headline: string;\n upgrade: string;\n headlines: string[];\n notes: string[];\n releases: Array<{ tag: string; label: string }>;\n tag: string;\n}\n\nexport interface StudioOutput {\n files: Array<{ name: string; data: string; width: number; height: number }>;\n archive: string;\n pdf?: string;\n manifest: { warnings: Array<{ format: string; slot: string; action: string }>; missing: Array<{ fact: string; effect: string }> };\n}\n\nexport interface StudioBackend {\n mode: \"public\" | \"local\";\n load(repo: string, tag?: string): Promise<StudioState>;\n render(selection: StudioSelection): Promise<StudioOutput>;\n save?(selection: StudioSelection): Promise<void>;\n}\n\nexport const studioMarkup = `\n<style>\n.studio{--ink:#eae9e6;--muted:#a1a1aa;--panel:#18181b;--line:#343438;color:var(--ink);background:#0c0c0f;font:15px/1.5 system-ui,sans-serif;min-height:80vh;padding:clamp(16px,3vw,40px);border-radius:20px}\n.studio *{box-sizing:border-box}.studio h1{font-size:clamp(30px,4vw,48px);line-height:1.1;letter-spacing:-.04em;margin:12px 0}.studio h2{font-size:20px;margin:0 0 12px}.studio p{color:var(--muted);max-width:70ch}.studio .eyebrow{color:#fb756d;text-transform:uppercase;letter-spacing:.16em;font-size:12px}\n.studio .workspace{display:grid;grid-template-columns:280px minmax(0,1fr);gap:24px;margin-top:28px}.studio .controls{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;align-self:start}.studio label{display:block;font-size:13px;margin:14px 0 6px}.studio input,.studio select,.studio textarea{width:100%;background:#101014;border:1px solid #48484f;color:var(--ink);border-radius:8px;padding:10px;font:inherit}.studio input[type=color]{height:44px;padding:4px}.studio textarea{resize:vertical;min-height:90px}.studio button,.studio a.download{display:inline-block;border:1px solid #fb756d;background:#fa6259;color:#170a09;padding:11px 18px;border-radius:8px;font:600 14px system-ui;cursor:pointer;text-decoration:none}.studio button.secondary,.studio a.secondary{background:transparent;color:var(--ink);border-color:var(--line)}\n.studio :focus-visible{outline:3px solid #fbbc78;outline-offset:3px}.studio button:disabled{opacity:.5;cursor:wait}.studio .load-row{display:flex;gap:10px;max-width:850px;align-items:end}.studio .load-row>div{flex:1}.studio .load-row button{white-space:nowrap}.studio .actions{display:flex;gap:10px;flex-wrap:wrap;margin:18px 0}.studio .gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:20px}.studio figure{margin:0;border:1px solid var(--line);border-radius:12px;overflow:hidden;background:var(--panel)}.studio figure img{width:100%;height:auto;display:block}.studio figcaption{padding:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;overflow-wrap:anywhere;font-size:12px}.studio figcaption a{color:#fb9a90}.studio .status{padding:14px 0;min-height:48px}.studio .notice{border-left:3px solid #fbad59;background:#241e18;padding:12px 16px;margin:12px 0;color:#f2d2ae;white-space:pre-wrap}.studio details{margin-top:16px}.studio pre{max-height:360px;overflow:auto;white-space:pre-wrap;font-size:12px}.studio .placeholder{border:1px dashed var(--line);border-radius:14px;padding:70px 24px;text-align:center;color:var(--muted)}.studio [hidden]{display:none!important}.studio .helper{font-size:12px;color:var(--muted)}\n@media(max-width:760px){.studio .workspace{grid-template-columns:1fr}.studio .load-row{flex-direction:column;align-items:stretch}.studio .controls{display:grid;grid-template-columns:1fr 1fr;gap:0 14px}.studio .controls .wide{grid-column:1/-1}}\n</style>\n<section class=\"studio\">\n<div class=\"eyebrow\">Shipseal studio</div><h1>Your next release, ready to share.</h1>\n<p>Choose a release. Make it yours. Download a complete pack with sources attached.</p>\n<form data-load class=\"load-row\"><div><label for=\"studio-repo\">Public GitHub repository</label><input id=\"studio-repo\" name=\"repo\" placeholder=\"owner/repository\" required autocomplete=\"off\" spellcheck=\"false\"></div><button type=\"submit\">Try my repo</button></form>\n<p data-privacy class=\"helper\">No account or API key. GitHub requests go directly from your browser. Images render on your device.</p>\n<div data-status class=\"status\" role=\"status\" aria-live=\"polite\">Load a repository to begin.</div><div data-notes hidden class=\"notice\"></div>\n<div class=\"workspace\"><form data-controls class=\"controls\" hidden>\n<div class=\"wide\"><h2>Make it yours</h2><label for=\"studio-release\">Release</label><select id=\"studio-release\"></select></div>\n<div><label for=\"studio-pack\">Pack</label><select id=\"studio-pack\"><option value=\"story\">Release story</option><option value=\"release\">Standard release</option></select></div>\n<div><label for=\"studio-style\">Story style</label><select id=\"studio-style\"><option value=\"minimal\">Minimal</option><option value=\"editorial\">Editorial</option><option value=\"terminal\">Terminal</option></select></div>\n<div><label for=\"studio-theme\">Theme</label><select id=\"studio-theme\"><option value=\"dark\">Dark</option><option value=\"light\">Light</option></select></div>\n<div><label for=\"studio-accent\">Accent</label><input id=\"studio-accent\" type=\"color\" value=\"#fb4c4b\"></div>\n<div class=\"wide\"><label for=\"studio-format\">Format</label><select id=\"studio-format\"><option value=\"portrait\">Portrait carousel</option><option value=\"square\">Square</option><option value=\"og\">Link preview</option><option value=\"github-social\">GitHub social</option><option value=\"x\">X</option><option value=\"linkedin\">LinkedIn</option><option value=\"producthunt\">Product Hunt</option><option value=\"all\">All supported formats</option></select></div>\n<div class=\"wide\"><label for=\"studio-headline\">Headline</label><input id=\"studio-headline\" list=\"studio-headlines\" maxlength=\"500\" placeholder=\"Automatic from release notes\"><datalist id=\"studio-headlines\"></datalist><label for=\"studio-upgrade\">Upgrade instructions (optional)</label><textarea id=\"studio-upgrade\" maxlength=\"4000\" placeholder=\"Leave blank to link to the release notes\"></textarea></div>\n<div class=\"wide actions\"><button type=\"submit\" data-render>Update preview</button><button type=\"button\" class=\"secondary\" data-save hidden>Save to repo</button></div>\n<p class=\"wide helper\">Story styles apply to the story pack. Standard release templates keep their established layout. The ZIP includes brand and config files for local use.</p>\n</form><div><div data-downloads class=\"actions\" hidden></div><div data-warnings class=\"notice\" hidden></div><div data-gallery class=\"gallery\"><div class=\"placeholder\">Your release images will appear here.<br>Nothing is posted automatically.</div></div><details data-evidence hidden><summary>Sources and rendering details</summary><pre data-manifest></pre></details></div></div>\n</section>`;\n\n/**\n * Kept in step with selectionSchema by hand. This module must stay free of runtime imports:\n * it is bundled on its own as dist/studio-client.js and served to the browser, so importing\n * the schema would pull zod and the whole render pipeline in with it.\n */\nconst SELECTABLE_FORMATS = [\n \"portrait\",\n \"square\",\n \"og\",\n \"github-social\",\n \"x\",\n \"linkedin\",\n \"producthunt\",\n \"all\",\n] as const satisfies readonly StudioSelection[\"format\"][];\n\nconst isSelectableFormat = (value: string): value is StudioSelection[\"format\"] =>\n SELECTABLE_FORMATS.some((format) => format === value);\n\nexport function mountStudio(root: HTMLElement, backend: StudioBackend): void {\n root.innerHTML = studioMarkup;\n\n const find = <T extends Element>(selector: string, type: { new (...args: never[]): T }): T => {\n const element = root.querySelector(selector);\n if (!(element instanceof type)) {\n throw new Error(`Missing studio element: ${selector}`);\n }\n return element;\n };\n const input = (id: string) => find(`#studio-${id}`, HTMLInputElement);\n const select = (id: string) => find(`#studio-${id}`, HTMLSelectElement);\n\n const status = find(\"[data-status]\", HTMLDivElement);\n const controls = find(\"[data-controls]\", HTMLFormElement);\n const gallery = find(\"[data-gallery]\", HTMLDivElement);\n const downloads = find(\"[data-downloads]\", HTMLDivElement);\n const warnings = find(\"[data-warnings]\", HTMLDivElement);\n const evidence = find(\"[data-evidence]\", HTMLDetailsElement);\n const save = find(\"[data-save]\", HTMLButtonElement);\n const upgrade = find(\"#studio-upgrade\", HTMLTextAreaElement);\n\n let state: StudioState | undefined;\n // Every load and render takes the next revision. A result from an older revision is stale\n // and is dropped, so a slow render cannot overwrite a newer one.\n let revision = 0;\n let urls: string[] = [];\n let timer: ReturnType<typeof setTimeout> | undefined;\n\n const showError = (error: unknown) => {\n status.textContent =\n error instanceof Error ? error.message : \"The preview failed. Retry the request.\";\n };\n\n const clearUrls = () => {\n for (const url of urls) {\n URL.revokeObjectURL(url);\n }\n urls = [];\n };\n\n const link = (label: string, name: string, data: string, mime: string): HTMLAnchorElement => {\n const bytes = Uint8Array.from(atob(data), (char) => char.charCodeAt(0));\n const url = URL.createObjectURL(new Blob([bytes], { type: mime }));\n urls.push(url);\n const anchor = document.createElement(\"a\");\n anchor.textContent = label;\n anchor.href = url;\n anchor.download = name;\n return anchor;\n };\n\n const selection = (): StudioSelection => {\n // The selects restrict ordinary browser input; the backend validates regardless.\n const style = select(\"style\").value;\n const format = select(\"format\").value;\n return {\n pack: select(\"pack\").value === \"release\" ? \"release\" : \"story\",\n style: style === \"editorial\" || style === \"terminal\" ? style : \"minimal\",\n theme: select(\"theme\").value === \"light\" ? \"light\" : \"dark\",\n accent: input(\"accent\").value,\n headline: input(\"headline\").value,\n upgrade: upgrade.value,\n format: isSelectableFormat(format) ? format : \"portrait\",\n };\n };\n\n const busy = (value: boolean) => {\n for (const button of root.querySelectorAll(\"button\")) {\n button.disabled = value;\n }\n };\n\n const render = async () => {\n if (state === undefined) {\n return;\n }\n const current = ++revision;\n downloads.hidden = true;\n save.hidden = true;\n warnings.hidden = true;\n status.textContent = \"Rendering your pack. The first browser render also loads the renderer.\";\n busy(true);\n try {\n const result = await backend.render(selection());\n if (current !== revision) {\n return;\n }\n clearUrls();\n gallery.replaceChildren();\n downloads.replaceChildren();\n for (const file of result.files) {\n const download = link(\"Download PNG\", file.name, file.data, \"image/png\");\n const figure = document.createElement(\"figure\");\n const img = document.createElement(\"img\");\n img.src = download.href;\n img.alt = `${state.name}: ${file.name}`;\n img.width = file.width;\n img.height = file.height;\n const caption = document.createElement(\"figcaption\");\n const label = document.createElement(\"span\");\n label.textContent = file.name;\n caption.append(label, download);\n figure.append(img, caption);\n gallery.append(figure);\n }\n const zip = link(\"Download pack\", \"shipseal-pack.zip\", result.archive, \"application/zip\");\n zip.className = \"download\";\n downloads.append(zip);\n if (result.pdf !== undefined) {\n const pdf = link(\"Download carousel PDF\", \"story.pdf\", result.pdf, \"application/pdf\");\n pdf.className = \"download secondary\";\n downloads.append(pdf);\n }\n downloads.hidden = false;\n\n // Fit warnings and missing facts are always shown: shipping overflowing text silently\n // is forbidden (AGENTS.md rule 5).\n const notices = [\n ...result.manifest.warnings.map(\n (warning) =>\n `${warning.format}: ${warning.slot} was ${warning.action}. Shorten the text or choose a larger format.`,\n ),\n ...result.manifest.missing.map((missing) => `${missing.fact}: ${missing.effect}.`),\n ];\n warnings.textContent = notices.join(\"\\n\");\n warnings.hidden = notices.length === 0;\n find(\"[data-manifest]\", HTMLPreElement).textContent = JSON.stringify(\n result.manifest,\n null,\n 2,\n );\n evidence.hidden = false;\n save.hidden = backend.save === undefined;\n status.textContent = `${String(result.files.length)} images ready for ${state.name}. ${\n notices.length > 0 ? \"Review the notices before sharing.\" : \"No fitting warnings.\"\n }`;\n } catch (error) {\n if (current === revision) {\n showError(error);\n }\n } finally {\n if (current === revision) {\n busy(false);\n }\n }\n };\n\n const load = async (tag?: string) => {\n const current = ++revision;\n state = undefined;\n clearUrls();\n gallery.replaceChildren();\n downloads.hidden = true;\n controls.hidden = true;\n save.hidden = true;\n warnings.hidden = true;\n evidence.hidden = true;\n // Notes describe the repository being loaded. Leaving the last one up under a failure\n // tells the visitor about a brand file belonging to a different repository.\n find(\"[data-notes]\", HTMLDivElement).hidden = true;\n status.textContent = \"Reading release facts...\";\n busy(true);\n try {\n const next = await backend.load(input(\"repo\").value, tag);\n if (current !== revision) {\n return;\n }\n state = next;\n select(\"release\").replaceChildren(\n ...next.releases.map((release) => {\n const option = document.createElement(\"option\");\n option.value = release.tag;\n option.textContent = release.label;\n return option;\n }),\n );\n select(\"release\").value = next.tag;\n select(\"style\").value = next.style;\n select(\"theme\").value = next.theme;\n input(\"accent\").value = next.accent;\n input(\"headline\").value = next.headline;\n upgrade.value = next.upgrade;\n find(\"#studio-headlines\", HTMLDataListElement).replaceChildren(\n ...next.headlines.map((headline) => {\n const option = document.createElement(\"option\");\n option.value = headline;\n return option;\n }),\n );\n const notes = find(\"[data-notes]\", HTMLDivElement);\n notes.textContent = next.notes.join(\"\\n\");\n notes.hidden = next.notes.length === 0;\n controls.hidden = false;\n await render();\n } catch (error) {\n showError(error);\n busy(false);\n }\n };\n\n find(\"[data-load]\", HTMLFormElement).addEventListener(\"submit\", (event) => {\n event.preventDefault();\n void load();\n });\n controls.addEventListener(\"submit\", (event) => {\n event.preventDefault();\n clearTimeout(timer);\n void render();\n });\n select(\"release\").addEventListener(\"change\", () => {\n void load(select(\"release\").value);\n });\n controls.addEventListener(\"input\", (event) => {\n if (event.target === select(\"release\")) {\n return;\n }\n // The standard release templates are landscape only, so a portrait choice is corrected\n // rather than sent to the backend to be rejected.\n if (\n select(\"pack\").value === \"release\" &&\n [\"portrait\", \"square\", \"producthunt\"].includes(select(\"format\").value)\n ) {\n select(\"format\").value = \"all\";\n }\n select(\"style\").disabled = select(\"pack\").value === \"release\";\n downloads.hidden = true;\n save.hidden = true;\n ++revision;\n clearTimeout(timer);\n timer = setTimeout(() => {\n void render();\n }, 500);\n });\n save.addEventListener(\"click\", async () => {\n busy(true);\n try {\n await backend.save?.(selection());\n status.textContent =\n \"Saved brand and release settings to .shipseal. Future local runs will use these choices.\";\n } catch (error) {\n showError(error);\n } finally {\n busy(false);\n }\n });\n\n if (backend.mode === \"local\") {\n find(\"[data-load]\", HTMLFormElement).hidden = true;\n find(\"[data-privacy]\", HTMLParagraphElement).textContent =\n \"Local preview. Files stay on this computer. Save to repo writes only your Shipseal brand and config settings.\";\n void load();\n }\n window.addEventListener(\"pagehide\", clearUrls, { once: true });\n}\n"],"mappings":"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"}
|