@tomako/tools-runtime 0.1.6 → 0.1.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomako/tools-runtime",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Shared interactive tools runtime for Tomako-FE workspace dialogs and Tomako-SEO tool pages",
5
5
  "type": "module",
6
6
  "exports": {
@@ -44,7 +44,7 @@ import {
44
44
  buildGeneratedAssetSlots,
45
45
  clampAssetCount,
46
46
  dimensionsForAssetFormat,
47
- widthForAssetFormat,
47
+ gridClassForAssetFormat,
48
48
  } from "./asset-slots";
49
49
  import {
50
50
  LOGO_REFERENCE_MAX_DATA_URL_LENGTH,
@@ -160,9 +160,12 @@ export function AppStoreScreenshotGenerator() {
160
160
  () => downloadableAssets.filter((asset) => downloadSelectionIds.has(asset.id)),
161
161
  [downloadSelectionIds, downloadableAssets],
162
162
  );
163
+ const soleDownloadableAsset = downloadableAssets.length === 1 ? downloadableAssets[0] : null;
163
164
  const downloadButtonLabel = downloadSelectionMode
164
165
  ? `${t("download")} ${selectedDownloadAssets.length}/${downloadableAssets.length}`
165
- : t("download");
166
+ : soleDownloadableAsset
167
+ ? t("downloadPng")
168
+ : t("download");
166
169
 
167
170
  async function submit(event: FormEvent<HTMLFormElement>) {
168
171
  event.preventDefault();
@@ -489,6 +492,14 @@ export function AppStoreScreenshotGenerator() {
489
492
  if (!downloadableAssets.length || downloading) return;
490
493
  setDownloadError(null);
491
494
 
495
+ if (soleDownloadableAsset) {
496
+ await downloadAssets([soleDownloadableAsset], {
497
+ forceZip: false,
498
+ zipName: buildScreenshotsZipName(draft, "selected"),
499
+ });
500
+ return;
501
+ }
502
+
492
503
  if (!downloadSelectionMode) {
493
504
  setDownloadSelectionMode(true);
494
505
  setDownloadSelectionIds(
@@ -521,6 +532,15 @@ export function AppStoreScreenshotGenerator() {
521
532
  });
522
533
  }
523
534
 
535
+ function handleDownloadSvgClick(asset: AppStoreScreenshotAsset) {
536
+ if (!asset.svg || downloading) return;
537
+ setDownloadError(null);
538
+ downloadBlob(
539
+ new Blob([asset.svg], { type: "image/svg+xml;charset=utf-8" }),
540
+ `${assetFileBase(draft, asset)}.svg`,
541
+ );
542
+ }
543
+
524
544
  function cancelDownloadSelection() {
525
545
  setDownloadSelectionMode(false);
526
546
  setDownloadSelectionIds(new Set());
@@ -998,6 +1018,18 @@ export function AppStoreScreenshotGenerator() {
998
1018
  {downloading ? t("downloading") : downloadButtonLabel}
999
1019
  </span>
1000
1020
  </Button>
1021
+ {soleDownloadableAsset?.svg ? (
1022
+ <Button
1023
+ type="button"
1024
+ variant="outline"
1025
+ onClick={() => handleDownloadSvgClick(soleDownloadableAsset)}
1026
+ disabled={downloading}
1027
+ className="h-10 rounded-lg border-[#D8D4CA] bg-white px-4 text-xs font-semibold text-[#111111] shadow-none hover:bg-[#F4F4F4] disabled:pointer-events-none disabled:opacity-55"
1028
+ >
1029
+ <Download aria-hidden className="size-4" />
1030
+ {t("downloadSvg")}
1031
+ </Button>
1032
+ ) : null}
1001
1033
  {downloadSelectionMode ? (
1002
1034
  <Button
1003
1035
  type="button"
@@ -1095,7 +1127,13 @@ export function AppStoreScreenshotGenerator() {
1095
1127
  </div>
1096
1128
  </div>
1097
1129
  ) : null}
1098
- <div className="flex flex-wrap items-start gap-4">
1130
+ <div
1131
+ className={`grid items-start gap-4 ${
1132
+ resultSlots.length === 1
1133
+ ? "grid-cols-1"
1134
+ : "grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"
1135
+ }`}
1136
+ >
1099
1137
  {resultSlots.map(({ asset, format, slot }) => {
1100
1138
  if (!asset) {
1101
1139
  const dimensions = dimensionsForAssetFormat(format);
@@ -1104,11 +1142,8 @@ export function AppStoreScreenshotGenerator() {
1104
1142
  key={`pending-${slot}`}
1105
1143
  aria-label={t("pendingImageLoading", { index: slot })}
1106
1144
  role="status"
1107
- style={{
1108
- aspectRatio: `${dimensions.width} / ${dimensions.height}`,
1109
- width: widthForAssetFormat(format),
1110
- }}
1111
- className="flex max-w-full flex-col items-center justify-center rounded-lg border border-dashed border-[#D8D4CA] bg-white p-4 text-center shadow-[0_8px_22px_rgba(17,17,17,0.04)]"
1145
+ style={{ aspectRatio: `${dimensions.width} / ${dimensions.height}` }}
1146
+ className={`flex w-full max-w-full flex-col items-center justify-center rounded-lg border border-dashed border-[#D8D4CA] bg-white p-4 text-center shadow-[0_8px_22px_rgba(17,17,17,0.04)] ${gridClassForAssetFormat(format, resultSlots.length === 1)}`}
1112
1147
  >
1113
1148
  <span className="flex size-10 items-center justify-center rounded-lg bg-[#F4F4F4] text-[#111111]">
1114
1149
  <Loader2 aria-hidden className="size-4 animate-spin" />
@@ -1130,8 +1165,7 @@ export function AppStoreScreenshotGenerator() {
1130
1165
  return (
1131
1166
  <div
1132
1167
  key={asset.id}
1133
- style={{ width: widthForAssetFormat(asset.format) }}
1134
- className="relative max-w-full"
1168
+ className={`relative w-full max-w-full ${gridClassForAssetFormat(asset.format, resultSlots.length === 1)}`}
1135
1169
  >
1136
1170
  <button
1137
1171
  type="button"
@@ -0,0 +1,52 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+
4
+ import type { AppStoreScreenshotAsset } from "../../../../lib/tools/app-store-screenshot-generator-schema";
5
+
6
+ import {
7
+ buildGeneratedAssetSlots,
8
+ gridClassForAssetFormat,
9
+ } from "./asset-slots";
10
+
11
+ function asset(overrides: Partial<AppStoreScreenshotAsset>): AppStoreScreenshotAsset {
12
+ return {
13
+ id: "asset-1",
14
+ platform: "google",
15
+ format: "googlePhone",
16
+ width: 1080,
17
+ height: 1920,
18
+ imageUrl: "https://example.com/generated.png",
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ describe("app store screenshot result slots", () => {
24
+ it("keeps completed and pending Google assets in the same ordered slot model", () => {
25
+ const slots = buildGeneratedAssetSlots({
26
+ assets: [asset({ id: "phone-2", slot: 2 })],
27
+ expectedCount: 3,
28
+ includePending: true,
29
+ platform: "google",
30
+ totalCount: 3,
31
+ });
32
+
33
+ assert.deepEqual(
34
+ slots.map(({ asset: result, format, slot }) => ({ id: result?.id, format, slot })),
35
+ [
36
+ { id: undefined, format: "featureGraphic", slot: 1 },
37
+ { id: "phone-2", format: "googlePhone", slot: 2 },
38
+ { id: undefined, format: "googlePhone", slot: 3 },
39
+ ],
40
+ );
41
+ });
42
+
43
+ it("lets feature graphics span the responsive result grid", () => {
44
+ assert.equal(gridClassForAssetFormat("featureGraphic"), "sm:col-span-2 xl:col-span-3");
45
+ assert.equal(gridClassForAssetFormat("iphone"), "");
46
+ });
47
+
48
+ it("centers a sole result at an inspectable width without creating implicit grid columns", () => {
49
+ assert.equal(gridClassForAssetFormat("featureGraphic", true), "mx-auto max-w-[340px]");
50
+ assert.equal(gridClassForAssetFormat("iphone", true), "mx-auto max-w-[340px]");
51
+ });
52
+ });
@@ -54,8 +54,9 @@ export function sortGeneratedAssets(assets: AppStoreScreenshotAsset[]) {
54
54
  });
55
55
  }
56
56
 
57
- export function widthForAssetFormat(format: AssetFormat) {
58
- return format === "featureGraphic" ? "clamp(220px, 27vw, 340px)" : "clamp(112px, 12vw, 156px)";
57
+ export function gridClassForAssetFormat(format: AssetFormat, isSoleResult = false) {
58
+ if (isSoleResult) return "mx-auto max-w-[340px]";
59
+ return format === "featureGraphic" ? "sm:col-span-2 xl:col-span-3" : "";
59
60
  }
60
61
 
61
62
  export function dimensionsForAssetFormat(format: AssetFormat) {
@@ -0,0 +1,81 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+
4
+ import type {
5
+ AppStoreScreenshotAsset,
6
+ AppStoreScreenshotGeneratorInput,
7
+ } from "../../../../lib/tools/app-store-screenshot-generator-schema";
8
+
9
+ import { createGeneratedAssetZipEntries } from "./input-media";
10
+
11
+ const input = {
12
+ source: "upload",
13
+ productUrl: "",
14
+ hasScreenshot: true,
15
+ screenshotName: "source.png",
16
+ screenshotDataUrl: "",
17
+ screenshotReferences: [],
18
+ logoName: "",
19
+ logoDataUrl: "",
20
+ productName: "Tomako",
21
+ productBenefits: "",
22
+ assetCount: 1,
23
+ outputLanguage: "en",
24
+ platform: "apple",
25
+ style: "clean",
26
+ palette: "auto",
27
+ layout: "mixed",
28
+ headline: "",
29
+ subheadline: "",
30
+ } satisfies AppStoreScreenshotGeneratorInput;
31
+
32
+ describe("app store screenshot ZIP entries", () => {
33
+ it("includes the PNG and existing SVG wrapper for each generated asset", async () => {
34
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg"><metadata>${"prompt ".repeat(20)}</metadata></svg>`;
35
+ const asset = {
36
+ id: "hero",
37
+ platform: "apple",
38
+ format: "iphone",
39
+ title: "Core benefit",
40
+ width: 1290,
41
+ height: 2796,
42
+ imageUrl: "https://example.com/hero.png",
43
+ svg,
44
+ } satisfies AppStoreScreenshotAsset;
45
+
46
+ const files = await createGeneratedAssetZipEntries(
47
+ [asset],
48
+ input,
49
+ async () => new Blob([new Uint8Array([137, 80, 78, 71])], { type: "image/png" }),
50
+ );
51
+
52
+ assert.deepEqual(
53
+ files.map((file) => file.path),
54
+ [
55
+ "01-tomako-apple-iphone-core-benefit.png",
56
+ "01-tomako-apple-iphone-core-benefit.svg",
57
+ ],
58
+ );
59
+ assert.deepEqual([...files[0]!.data], [137, 80, 78, 71]);
60
+ assert.equal(new TextDecoder().decode(files[1]!.data), svg);
61
+ });
62
+
63
+ it("does not advertise a missing SVG by adding an empty file", async () => {
64
+ const asset = {
65
+ id: "hero",
66
+ platform: "apple",
67
+ format: "iphone",
68
+ width: 1290,
69
+ height: 2796,
70
+ imageUrl: "https://example.com/hero.png",
71
+ } satisfies AppStoreScreenshotAsset;
72
+
73
+ const files = await createGeneratedAssetZipEntries(
74
+ [asset],
75
+ input,
76
+ async () => new Blob([new Uint8Array([1])], { type: "image/png" }),
77
+ );
78
+
79
+ assert.deepEqual(files.map((file) => file.path), ["01-tomako-apple-iphone-hero.png"]);
80
+ });
81
+ });
@@ -5,7 +5,7 @@ import type {
5
5
 
6
6
  import { renderAssetToPngBlob } from "./generation";
7
7
  import type { SourceKey } from "./types";
8
- import { createZipBlob, slugForFileName, type ZipFileInput } from "./zip";
8
+ import { createZipBlob, encodeText, slugForFileName, type ZipFileInput } from "./zip";
9
9
 
10
10
  export function inferSource(input: AppStoreScreenshotGeneratorInput): SourceKey {
11
11
  const hasUrl = input.productUrl.trim().length > 0;
@@ -137,22 +137,34 @@ export async function createGeneratedAssetsZip(
137
137
  assets: readonly AppStoreScreenshotAsset[],
138
138
  input: AppStoreScreenshotGeneratorInput,
139
139
  ): Promise<Blob> {
140
+ return createZipBlob(await createGeneratedAssetZipEntries(assets, input));
141
+ }
142
+
143
+ export async function createGeneratedAssetZipEntries(
144
+ assets: readonly AppStoreScreenshotAsset[],
145
+ input: AppStoreScreenshotGeneratorInput,
146
+ renderPng: (asset: AppStoreScreenshotAsset) => Promise<Blob> = renderAssetToPngBlob,
147
+ ): Promise<ZipFileInput[]> {
140
148
  const files: ZipFileInput[] = [];
141
149
  const usedPaths = new Set<string>();
142
150
 
143
151
  for (const [index, asset] of assets.entries()) {
144
- const blob = await renderAssetToPngBlob(asset);
145
- const path = uniqueZipPath(
146
- usedPaths,
147
- `${String(index + 1).padStart(2, "0")}-${assetFileBase(input, asset)}-${slugForFileName(asset.title ?? asset.id)}.png`,
148
- );
152
+ const fileStem = `${String(index + 1).padStart(2, "0")}-${assetFileBase(input, asset)}-${slugForFileName(asset.title ?? asset.id)}`;
153
+ const blob = await renderPng(asset);
149
154
  files.push({
150
- path,
155
+ path: uniqueZipPath(usedPaths, `${fileStem}.png`),
151
156
  data: new Uint8Array(await blob.arrayBuffer()),
152
157
  });
158
+
159
+ if (asset.svg) {
160
+ files.push({
161
+ path: uniqueZipPath(usedPaths, `${fileStem}.svg`),
162
+ data: encodeText(asset.svg),
163
+ });
164
+ }
153
165
  }
154
166
 
155
- return createZipBlob(files);
167
+ return files;
156
168
  }
157
169
 
158
170
  export function uniqueZipPath(usedPaths: Set<string>, path: string) {