@stage5/lumine 0.2.5 → 0.2.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/README.md +26 -0
- package/lib/api.js +88 -1
- package/lib/assets.js +164 -1
- package/lib/commands.js +354 -3
- package/lib/constants.js +69 -0
- package/lib/thumbnail.js +330 -0
- package/lib/util.js +7 -0
- package/lib/workspace.js +56 -0
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +19 -2
package/lib/thumbnail.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ASSET_GENERATE_TIMEOUT_MS,
|
|
6
|
+
THUMBNAIL_CAPTURE_TIMEOUT_MS,
|
|
7
|
+
THUMBNAIL_CONTENT_TYPE_BY_EXTENSION,
|
|
8
|
+
THUMBNAIL_MAX_FILE_SIZE_BYTES,
|
|
9
|
+
} from "./constants.js";
|
|
10
|
+
import {
|
|
11
|
+
captureBuildThumbnailPreview,
|
|
12
|
+
generateBuildThumbnailRequest,
|
|
13
|
+
loadBuildFiles,
|
|
14
|
+
mintBuildApiToken,
|
|
15
|
+
requestThumbnailSignedUpload,
|
|
16
|
+
updateBuildThumbnailUrl,
|
|
17
|
+
} from "./api.js";
|
|
18
|
+
import { assertAuthScope, ensureAuth } from "./auth.js";
|
|
19
|
+
import { resolveSdkBuildId } from "./sdk.js";
|
|
20
|
+
import {
|
|
21
|
+
confirmPrompt,
|
|
22
|
+
describeGenerateHttpError,
|
|
23
|
+
formatBatteryPercent,
|
|
24
|
+
putAssetPartWithRetry,
|
|
25
|
+
resolveGenerateModel,
|
|
26
|
+
} from "./assets.js";
|
|
27
|
+
import { formatBytes } from "./util.js";
|
|
28
|
+
import { findLocalProjectMetadata } from "./workspace.js";
|
|
29
|
+
|
|
30
|
+
export async function thumbnailCommand(options) {
|
|
31
|
+
const subcommand = String(options.positional[0] || "");
|
|
32
|
+
if (subcommand === "set") {
|
|
33
|
+
await thumbnailSet(options);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (subcommand === "capture") {
|
|
37
|
+
await thumbnailCapture(options);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (subcommand === "generate") {
|
|
41
|
+
await thumbnailGenerate(options);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
throw new Error(
|
|
45
|
+
'Usage: lumine thumbnail set <file> | lumine thumbnail capture [--out <file>] | lumine thumbnail generate "<prompt>" --model <gpt-image-2|nano-banana>',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function refuseMainCheckout(options) {
|
|
50
|
+
const local = await findLocalProjectMetadata(
|
|
51
|
+
options.dir ? path.resolve(options.dir) : process.cwd(),
|
|
52
|
+
);
|
|
53
|
+
if (local?.metadata?.mainCheckout === true && !options.buildIdFlag) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
"This is a read-only main checkout. Set the thumbnail from your contribution-branch workspace (or pass --build for a build you own).",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function resolveThumbnailContext(options) {
|
|
61
|
+
await refuseMainCheckout(options);
|
|
62
|
+
const buildId = await resolveSdkBuildId(options);
|
|
63
|
+
const auth = await ensureAuth(options);
|
|
64
|
+
await assertAuthScope({ options, auth, scope: "build:write" });
|
|
65
|
+
const metadata = await loadBuildFiles({
|
|
66
|
+
options,
|
|
67
|
+
auth,
|
|
68
|
+
buildId,
|
|
69
|
+
includeContent: false,
|
|
70
|
+
});
|
|
71
|
+
const build = metadata?.build || {};
|
|
72
|
+
if (build.canWrite === false) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`You cannot write to Build #${buildId}. Thumbnails can only be set on builds/branches you can save.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return { buildId, auth, build };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function confirmThumbnailOverwrite({ options, build, buildId }) {
|
|
81
|
+
if (options.assumeYes) return true;
|
|
82
|
+
if (!String(build?.thumbnailUrl || "").trim()) return true;
|
|
83
|
+
const confirmed = await confirmPrompt(
|
|
84
|
+
`Build #${buildId} already has a thumbnail. Replace it? [y/N] `,
|
|
85
|
+
);
|
|
86
|
+
if (confirmed === null) {
|
|
87
|
+
console.log("Not a TTY — re-run with --yes to replace the thumbnail.");
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (!confirmed) {
|
|
91
|
+
console.log("Aborted. Thumbnail unchanged.");
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function commitThumbnailImage({
|
|
98
|
+
options,
|
|
99
|
+
auth,
|
|
100
|
+
buildId,
|
|
101
|
+
buffer,
|
|
102
|
+
contentType,
|
|
103
|
+
}) {
|
|
104
|
+
const { signedRequest, thumbnailUrl } = await requestThumbnailSignedUpload({
|
|
105
|
+
options,
|
|
106
|
+
auth,
|
|
107
|
+
buildId,
|
|
108
|
+
fileSize: buffer.length,
|
|
109
|
+
contentType,
|
|
110
|
+
});
|
|
111
|
+
if (!signedRequest || !thumbnailUrl) {
|
|
112
|
+
throw new Error("The server did not return a thumbnail upload URL.");
|
|
113
|
+
}
|
|
114
|
+
await putAssetPartWithRetry({
|
|
115
|
+
url: signedRequest,
|
|
116
|
+
chunk: buffer,
|
|
117
|
+
mimeType: contentType,
|
|
118
|
+
partLabel: "thumbnail",
|
|
119
|
+
});
|
|
120
|
+
const result = await updateBuildThumbnailUrl({
|
|
121
|
+
options,
|
|
122
|
+
auth,
|
|
123
|
+
buildId,
|
|
124
|
+
thumbnailUrl,
|
|
125
|
+
});
|
|
126
|
+
if (result?.success !== true) {
|
|
127
|
+
throw new Error(result?.error || "Failed to save the thumbnail.");
|
|
128
|
+
}
|
|
129
|
+
return { thumbnailUrl, build: result.build || null };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function thumbnailSet(options) {
|
|
133
|
+
const filePath = String(options.positional[1] || "").trim();
|
|
134
|
+
if (!filePath) {
|
|
135
|
+
throw new Error("Usage: lumine thumbnail set <file> (jpg, png, or webp)");
|
|
136
|
+
}
|
|
137
|
+
const absolutePath = path.resolve(filePath);
|
|
138
|
+
const extension = path.extname(absolutePath).toLowerCase();
|
|
139
|
+
const contentType = THUMBNAIL_CONTENT_TYPE_BY_EXTENSION[extension];
|
|
140
|
+
if (!contentType) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Unsupported thumbnail type for ${path.basename(absolutePath)}. Thumbnails support ${Object.keys(THUMBNAIL_CONTENT_TYPE_BY_EXTENSION).join(", ")}.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
let buffer;
|
|
146
|
+
try {
|
|
147
|
+
buffer = await fs.readFile(absolutePath);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error.code === "ENOENT") {
|
|
150
|
+
throw new Error(`Thumbnail file does not exist: ${filePath}`);
|
|
151
|
+
}
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
if (!buffer.length) {
|
|
155
|
+
throw new Error(`Thumbnail file is empty: ${filePath}`);
|
|
156
|
+
}
|
|
157
|
+
if (buffer.length > THUMBNAIL_MAX_FILE_SIZE_BYTES) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`Thumbnail file is too big (${formatBytes(buffer.length)}; max ${formatBytes(THUMBNAIL_MAX_FILE_SIZE_BYTES)}).`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const { buildId, auth, build } = await resolveThumbnailContext(options);
|
|
164
|
+
const proceed = await confirmThumbnailOverwrite({ options, build, buildId });
|
|
165
|
+
if (!proceed) return;
|
|
166
|
+
|
|
167
|
+
const { thumbnailUrl } = await commitThumbnailImage({
|
|
168
|
+
options,
|
|
169
|
+
auth,
|
|
170
|
+
buildId,
|
|
171
|
+
buffer,
|
|
172
|
+
contentType,
|
|
173
|
+
});
|
|
174
|
+
console.log(`Thumbnail set for Build #${buildId}:`);
|
|
175
|
+
console.log(` ${thumbnailUrl}`);
|
|
176
|
+
console.log("Tip: thumbnails display ~16:9; the image was uploaded uncropped.");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function decodeImageDataUrl(dataUrl) {
|
|
180
|
+
const match = String(dataUrl || "").match(
|
|
181
|
+
/^data:(image\/[a-z+.-]+);base64,(.+)$/i,
|
|
182
|
+
);
|
|
183
|
+
if (!match) return null;
|
|
184
|
+
return {
|
|
185
|
+
contentType: match[1].toLowerCase(),
|
|
186
|
+
buffer: Buffer.from(match[2], "base64"),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function thumbnailCapture(options) {
|
|
191
|
+
const { buildId, auth, build } = await resolveThumbnailContext(options);
|
|
192
|
+
await assertAuthScope({ options, auth, scope: "build:sdk" });
|
|
193
|
+
const proceed = await confirmThumbnailOverwrite({ options, build, buildId });
|
|
194
|
+
if (!proceed) return;
|
|
195
|
+
|
|
196
|
+
const { token: previewToken } = await mintBuildApiToken({
|
|
197
|
+
options,
|
|
198
|
+
auth,
|
|
199
|
+
buildId,
|
|
200
|
+
scopes: ["preview:read"],
|
|
201
|
+
});
|
|
202
|
+
const previewPath = `/build/preview/build/${buildId}/current?rev=${Number(build.updatedAt || 0)}&buildApiToken=${encodeURIComponent(previewToken)}`;
|
|
203
|
+
|
|
204
|
+
console.log("Capturing the app preview server-side... this can take ~30s.");
|
|
205
|
+
const captureResult = await captureBuildThumbnailPreview({
|
|
206
|
+
options,
|
|
207
|
+
auth,
|
|
208
|
+
buildId,
|
|
209
|
+
previewPath,
|
|
210
|
+
timeoutMs: THUMBNAIL_CAPTURE_TIMEOUT_MS,
|
|
211
|
+
});
|
|
212
|
+
const decoded = decodeImageDataUrl(captureResult?.imageUrl);
|
|
213
|
+
if (!decoded) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
captureResult?.error || "Preview capture returned no image.",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (decoded.buffer.length > THUMBNAIL_MAX_FILE_SIZE_BYTES) {
|
|
219
|
+
throw new Error("Captured image exceeds the 8MB thumbnail limit.");
|
|
220
|
+
}
|
|
221
|
+
if (options.out) {
|
|
222
|
+
const outPath = path.resolve(String(options.out));
|
|
223
|
+
await fs.writeFile(outPath, decoded.buffer);
|
|
224
|
+
console.log(`Saved capture to ${outPath}`);
|
|
225
|
+
}
|
|
226
|
+
const normalizedContentType = THUMBNAIL_CONTENT_TYPE_BY_EXTENSION[
|
|
227
|
+
`.${decoded.contentType.split("/")[1] || "png"}`
|
|
228
|
+
]
|
|
229
|
+
? decoded.contentType
|
|
230
|
+
: "image/png";
|
|
231
|
+
const { thumbnailUrl } = await commitThumbnailImage({
|
|
232
|
+
options,
|
|
233
|
+
auth,
|
|
234
|
+
buildId,
|
|
235
|
+
buffer: decoded.buffer,
|
|
236
|
+
contentType: normalizedContentType,
|
|
237
|
+
});
|
|
238
|
+
console.log(`Thumbnail captured and set for Build #${buildId}:`);
|
|
239
|
+
console.log(` ${thumbnailUrl}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function thumbnailGenerate(options) {
|
|
243
|
+
const prompt = String(options.positional[1] || "").trim();
|
|
244
|
+
const { model, quality } = resolveGenerateModel(options);
|
|
245
|
+
const { buildId, auth, build } = await resolveThumbnailContext(options);
|
|
246
|
+
|
|
247
|
+
const requestBody = {
|
|
248
|
+
model,
|
|
249
|
+
...(quality ? { quality } : {}),
|
|
250
|
+
...(prompt ? { prompt } : {}),
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
if (!options.assumeYes) {
|
|
254
|
+
let estimate = null;
|
|
255
|
+
try {
|
|
256
|
+
const estimateResult = await generateBuildThumbnailRequest({
|
|
257
|
+
options,
|
|
258
|
+
auth,
|
|
259
|
+
buildId,
|
|
260
|
+
body: { ...requestBody, estimateOnly: true },
|
|
261
|
+
});
|
|
262
|
+
estimate = estimateResult?.estimate || null;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
const friendly = describeGenerateHttpError(error);
|
|
265
|
+
if (friendly) throw new Error(friendly);
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
const selectedOption = (estimate?.options || []).find(
|
|
269
|
+
(option) => option.model === model,
|
|
270
|
+
);
|
|
271
|
+
console.log(
|
|
272
|
+
`Generate a thumbnail with ${model}${quality ? ` (quality ${quality})` : ""}${prompt ? "" : " (prompt auto-composed from the build title/description)"}?`,
|
|
273
|
+
);
|
|
274
|
+
if (String(build?.thumbnailUrl || "").trim()) {
|
|
275
|
+
console.log(" This will REPLACE the existing thumbnail.");
|
|
276
|
+
}
|
|
277
|
+
if (selectedOption) {
|
|
278
|
+
console.log(
|
|
279
|
+
` Estimated battery cost: ${formatBatteryPercent(selectedOption.energyUnits, estimate?.fullBatteryUnits)} of a full AI battery (~$${Number(selectedOption.estimatedUsd || 0).toFixed(2)})`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
if (estimate) {
|
|
283
|
+
console.log(
|
|
284
|
+
` Battery remaining now: ${formatBatteryPercent(estimate.energyRemaining, estimate.fullBatteryUnits)}`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const confirmed = await confirmPrompt("Proceed? [y/N] ");
|
|
288
|
+
if (confirmed === null) {
|
|
289
|
+
console.log("Not a TTY — re-run with --yes to generate.");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (!confirmed) {
|
|
293
|
+
console.log("Aborted. Nothing generated.");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
console.log("Generating the thumbnail... this can take a minute.");
|
|
299
|
+
let result;
|
|
300
|
+
try {
|
|
301
|
+
result = await generateBuildThumbnailRequest({
|
|
302
|
+
options,
|
|
303
|
+
auth,
|
|
304
|
+
buildId,
|
|
305
|
+
body: requestBody,
|
|
306
|
+
timeoutMs: ASSET_GENERATE_TIMEOUT_MS,
|
|
307
|
+
});
|
|
308
|
+
} catch (error) {
|
|
309
|
+
const friendly = describeGenerateHttpError(error);
|
|
310
|
+
if (friendly) throw new Error(friendly);
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
if (result?.success !== true || !result?.thumbnailUrl) {
|
|
314
|
+
throw new Error(result?.error || "Thumbnail generation failed.");
|
|
315
|
+
}
|
|
316
|
+
console.log(`Thumbnail generated and set for Build #${buildId}:`);
|
|
317
|
+
console.log(` ${result.thumbnailUrl}`);
|
|
318
|
+
if (result?.asset?.url) {
|
|
319
|
+
console.log(
|
|
320
|
+
`The generated image is also stored as reusable asset #${result.asset.id}:`,
|
|
321
|
+
);
|
|
322
|
+
console.log(` ${result.asset.url}`);
|
|
323
|
+
}
|
|
324
|
+
const remaining = result?.aiUsagePolicy?.energyRemaining;
|
|
325
|
+
if (Number.isFinite(Number(remaining))) {
|
|
326
|
+
console.log(
|
|
327
|
+
`Battery remaining: ${formatBatteryPercent(Number(remaining), result?.aiUsagePolicy?.fullBatteryUnits)}`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
package/lib/util.js
CHANGED
|
@@ -86,6 +86,13 @@ export function defaultMainCheckoutDir(build) {
|
|
|
86
86
|
return `twinkle-main-${titleSlug || "build"}-${buildId}`;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
export function defaultVersionCheckoutDir(build, versionNumber) {
|
|
90
|
+
const titleSlug = slugify(build?.title || "");
|
|
91
|
+
const buildId = Number(build?.id || 0) || "build";
|
|
92
|
+
const version = Number(versionNumber || 0) || "version";
|
|
93
|
+
return `twinkle-v${version}-${titleSlug || "build"}-${buildId}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
89
96
|
export function resolveRequiredBuildId(value) {
|
|
90
97
|
const buildId = resolveBuildReference(value).buildId;
|
|
91
98
|
if (buildId > 0) return buildId;
|
package/lib/workspace.js
CHANGED
|
@@ -491,6 +491,54 @@ export async function writeMainCheckoutMetadata({
|
|
|
491
491
|
);
|
|
492
492
|
}
|
|
493
493
|
|
|
494
|
+
// Metadata for a read-only `pull --version <n>` checkout of one previous save:
|
|
495
|
+
// versionCheckout marks it so save can point back at the restore flow.
|
|
496
|
+
export async function writeVersionCheckoutMetadata({
|
|
497
|
+
dir,
|
|
498
|
+
options,
|
|
499
|
+
build,
|
|
500
|
+
manifest,
|
|
501
|
+
version,
|
|
502
|
+
pulledAt,
|
|
503
|
+
}) {
|
|
504
|
+
const metadataDir = path.join(dir, PROJECT_METADATA_DIR);
|
|
505
|
+
await fs.mkdir(metadataDir, { recursive: true });
|
|
506
|
+
const buildId = Number(build?.id || 0) || null;
|
|
507
|
+
await fs.writeFile(
|
|
508
|
+
path.join(metadataDir, PROJECT_METADATA_FILE),
|
|
509
|
+
JSON.stringify(
|
|
510
|
+
{
|
|
511
|
+
schemaVersion: 1,
|
|
512
|
+
buildId,
|
|
513
|
+
readOnly: true,
|
|
514
|
+
versionCheckout: true,
|
|
515
|
+
checkoutVersion: Number(version?.version || 0) || null,
|
|
516
|
+
checkoutVersionSummary: version?.summary || null,
|
|
517
|
+
checkoutVersionCreatedAt: Number(version?.createdAt || 0) || null,
|
|
518
|
+
build: {
|
|
519
|
+
id: buildId,
|
|
520
|
+
title: build?.title || (buildId ? `Build ${buildId}` : ""),
|
|
521
|
+
role: build?.role || "collaborator",
|
|
522
|
+
ownerUsername: build?.ownerUsername || null,
|
|
523
|
+
contributionStatus: "none",
|
|
524
|
+
contributionRootBuildId:
|
|
525
|
+
Number(build?.contributionRootBuildId || 0) || null,
|
|
526
|
+
canWrite: false,
|
|
527
|
+
canPublish: false,
|
|
528
|
+
},
|
|
529
|
+
apiUrl: options.apiUrl,
|
|
530
|
+
siteUrl: options.siteUrl,
|
|
531
|
+
lumineCli: serializeLumineCliMetadata(options),
|
|
532
|
+
manifest,
|
|
533
|
+
pulledAt,
|
|
534
|
+
},
|
|
535
|
+
null,
|
|
536
|
+
2,
|
|
537
|
+
),
|
|
538
|
+
"utf8",
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
494
542
|
export async function findLocalProjectMetadata(startDir) {
|
|
495
543
|
let current = path.resolve(startDir || process.cwd());
|
|
496
544
|
while (true) {
|
|
@@ -527,6 +575,14 @@ export function assertLocalProjectCanBeSaved(localProject) {
|
|
|
527
575
|
`This is a read-only checkout of main${rootBuildId ? ` for Build ${rootBuildId}` : ""}. Make edits in your branch workspace (\`lumine pull${rootBuildId ? ` ${rootBuildId}` : ""}\`), and run \`lumine update-from-main\` there to bring main's changes into it.`,
|
|
528
576
|
);
|
|
529
577
|
}
|
|
578
|
+
if (metadata.versionCheckout === true) {
|
|
579
|
+
const checkoutBuildId =
|
|
580
|
+
Number(metadata.buildId || 0) || Number(metadata.build?.id || 0) || 0;
|
|
581
|
+
const checkoutVersion = Number(metadata.checkoutVersion || 0) || 0;
|
|
582
|
+
throw new Error(
|
|
583
|
+
`This is a read-only checkout of a previous save${checkoutVersion ? ` (v${checkoutVersion})` : ""}${checkoutBuildId ? ` for Build ${checkoutBuildId}` : ""}. To bring this save back, run \`lumine restore${checkoutVersion ? ` ${checkoutVersion}` : " <n>"}\` from the editable workspace, then \`lumine save\`.`,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
530
586
|
if (isReadOnlyReferenceMetadata(metadata)) {
|
|
531
587
|
const sourceBuildId =
|
|
532
588
|
Number(metadata.reference?.sourceBuildId || 0) ||
|
package/package.json
CHANGED
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Build SDK Index
|
|
2
2
|
|
|
3
3
|
Version: 1.26.2
|
|
4
|
-
Updated: 2026-
|
|
5
|
-
Generated: 2026-
|
|
4
|
+
Updated: 2026-07-05
|
|
5
|
+
Generated: 2026-07-05T06:47:32.349Z
|
|
6
6
|
|
|
7
7
|
## Notes
|
|
8
8
|
- This SDK is injected into Build iframes via the Build preview/runtime.
|
|
@@ -21,6 +21,7 @@ Generated: 2026-06-18T12:26:29.648Z
|
|
|
21
21
|
- Use Twinkle.world for realtime multiplayer rooms, avatar presence, movement, emotes, and lightweight actions; world sessions are disposable and durable MMO state belongs in sharedDb/privateDb.
|
|
22
22
|
- Use Twinkle.characters.chat for real Zero/Ciel NPC dialogue with shared room context and AI Energy-aware thinking modes.
|
|
23
23
|
- Twinkle.ai.chat history entries must use { role, content }; map local message.text fields to content before passing history.
|
|
24
|
+
- Interface text must not be selectable on touch devices: apply user-select: none plus -webkit-user-select: none and -webkit-touch-callout: none to interface text (HUD, buttons, labels, menus, scores, game controls) so mobile long-press does not highlight UI. Keep text inputs and genuinely user-copyable content selectable.
|
|
24
25
|
|
|
25
26
|
## Token Scopes
|
|
26
27
|
files:read, user:read, users:read, dailyReflections:read, content:read, sharedDb:read, sharedDb:write, privateDb:read, privateDb:write, files:write, chat:read, chat:write, notifications:read, notifications:write, notifications:emit, reminders:read, reminders:write
|
|
@@ -49,6 +50,22 @@ files:read, user:read, users:read, dailyReflections:read, content:read, sharedDb
|
|
|
49
50
|
- Returns: Viewer info
|
|
50
51
|
- Forces a fresh fetch from the parent.
|
|
51
52
|
|
|
53
|
+
### Twinkle.app
|
|
54
|
+
- async getInfo() | scopes: none
|
|
55
|
+
- Returns: App info object from the parent (includes appUrl) or null
|
|
56
|
+
- Cached after the first call for the iframe session.
|
|
57
|
+
- async getShareUrl(pathSegment) | scopes: none
|
|
58
|
+
- Returns: Canonical shareable deep-link URL string, or null when app info is unavailable
|
|
59
|
+
- Builds a canonical shareable deep link into this app, e.g. https://www.twin-kle.com/app/884/432-the-great-gatsby.
|
|
60
|
+
- Example: await Twinkle.app.getShareUrl('432-the-great-gatsby');
|
|
61
|
+
- async navigate(target) | scopes: none
|
|
62
|
+
- Returns: { success, src }
|
|
63
|
+
- Navigate to another Build preview route through the parent bridge without dropping Twinkle SDK access.
|
|
64
|
+
- Use this for in-app Build preview/world switches instead of window.location.assign, location.replace, or setting location.href.
|
|
65
|
+
- The parent validates that the target is still a Build preview URL before navigating.
|
|
66
|
+
- External URLs are rejected and do not receive the Build bridge nonce.
|
|
67
|
+
- Example: await Twinkle.app.navigate('./arena.html');
|
|
68
|
+
|
|
52
69
|
### Twinkle.preview
|
|
53
70
|
- getLayout() | scopes: none
|
|
54
71
|
- Returns: { mode, viewport, stage, safeInsets, playfield }
|