@pygmalionjs/pygmalion 0.2.39 → 0.2.40
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 +7 -0
- package/dist-lib/{App-CJz8NrxS.js → App-CiVUoaTo.js} +3808 -3711
- package/dist-lib/pygmalion.js +1089 -1017
- package/dist-lib/testing.js +1 -1
- package/node/preview-artifact-plugin.mjs +14 -0
- package/node/preview-artifact-store.mjs +141 -18
- package/node/route-preview-artifact-v3.mjs +42 -15
- package/package.json +1 -1
- package/storyboard.d.ts +1 -1
- package/types.d.ts +4 -0
package/dist-lib/testing.js
CHANGED
|
@@ -85,6 +85,11 @@ function generationAllowed(url) {
|
|
|
85
85
|
return !['0', 'false', 'no'].includes(raw.trim().toLowerCase());
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
function resolutionOnly(url) {
|
|
89
|
+
const raw = url.searchParams.get('resolve');
|
|
90
|
+
return raw != null && ['1', 'true', 'yes'].includes(raw.trim().toLowerCase());
|
|
91
|
+
}
|
|
92
|
+
|
|
88
93
|
function requestedFrames(url) {
|
|
89
94
|
const raw = url.searchParams.get('frames');
|
|
90
95
|
if (raw == null) return undefined;
|
|
@@ -373,6 +378,15 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
373
378
|
return;
|
|
374
379
|
}
|
|
375
380
|
if (wanted !== undefined) {
|
|
381
|
+
if (resolutionOnly(url)) {
|
|
382
|
+
const resolution = await artifactStore.resolveFrameSelection(
|
|
383
|
+
namespace,
|
|
384
|
+
sourceRevision,
|
|
385
|
+
wanted,
|
|
386
|
+
);
|
|
387
|
+
sendJson(response, 200, { ok: true, ...resolution });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
376
390
|
let picked = await artifactStore.readFrameSelection(
|
|
377
391
|
namespace,
|
|
378
392
|
sourceRevision,
|
|
@@ -18,6 +18,7 @@ const LOCK_TIMEOUT_MS = 30_000;
|
|
|
18
18
|
const LOCK_STALE_MS = 120_000;
|
|
19
19
|
const LOCK_POLL_MS = 40;
|
|
20
20
|
const OBJECT_HASH = /^[a-f0-9]{64}$/u;
|
|
21
|
+
const OBJECT_CACHE_MAX_BYTES = 64 * 1024 * 1024;
|
|
21
22
|
|
|
22
23
|
function sha256(value) {
|
|
23
24
|
return createHash('sha256').update(value).digest('hex');
|
|
@@ -182,8 +183,7 @@ function upsertFrame(entries, next) {
|
|
|
182
183
|
(entry) =>
|
|
183
184
|
entry.id !== next.id ||
|
|
184
185
|
entry.fingerprint !== next.fingerprint ||
|
|
185
|
-
|
|
186
|
-
entry.sourceRevision !== next.sourceRevision),
|
|
186
|
+
entry.sourceRevision !== next.sourceRevision,
|
|
187
187
|
),
|
|
188
188
|
next,
|
|
189
189
|
];
|
|
@@ -216,6 +216,34 @@ export function createPreviewArtifactStore({
|
|
|
216
216
|
const legacyFile = path.resolve(artifactFile);
|
|
217
217
|
const root = path.resolve(storeDirectory);
|
|
218
218
|
let legacyCache = null;
|
|
219
|
+
const objectCache = new Map();
|
|
220
|
+
let objectCacheBytes = 0;
|
|
221
|
+
|
|
222
|
+
const rememberObject = (hash, artifact, bytes) => {
|
|
223
|
+
const previous = objectCache.get(hash);
|
|
224
|
+
if (previous) objectCacheBytes -= previous.bytes;
|
|
225
|
+
objectCache.delete(hash);
|
|
226
|
+
objectCache.set(hash, { artifact, bytes });
|
|
227
|
+
objectCacheBytes += bytes;
|
|
228
|
+
while (objectCacheBytes > OBJECT_CACHE_MAX_BYTES && objectCache.size > 1) {
|
|
229
|
+
const oldestHash = objectCache.keys().next().value;
|
|
230
|
+
const oldest = objectCache.get(oldestHash);
|
|
231
|
+
objectCache.delete(oldestHash);
|
|
232
|
+
objectCacheBytes -= oldest?.bytes ?? 0;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const forgetObject = (hash) => {
|
|
237
|
+
const cached = objectCache.get(hash);
|
|
238
|
+
if (!cached) return;
|
|
239
|
+
objectCache.delete(hash);
|
|
240
|
+
objectCacheBytes -= cached.bytes;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const legacyFileSignature = async () => {
|
|
244
|
+
const stat = await fs.stat(legacyFile);
|
|
245
|
+
return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(':');
|
|
246
|
+
};
|
|
219
247
|
|
|
220
248
|
const namespaceHash = (namespace) => sha256(namespace);
|
|
221
249
|
const manifestPath = (namespace) =>
|
|
@@ -242,6 +270,7 @@ export function createPreviewArtifactStore({
|
|
|
242
270
|
const serialized = JSON.stringify(artifact);
|
|
243
271
|
const hash = sha256(serialized);
|
|
244
272
|
const target = objectPath(hash);
|
|
273
|
+
rememberObject(hash, artifact, Buffer.byteLength(serialized));
|
|
245
274
|
try {
|
|
246
275
|
await fs.access(target);
|
|
247
276
|
return hash;
|
|
@@ -253,11 +282,20 @@ export function createPreviewArtifactStore({
|
|
|
253
282
|
|
|
254
283
|
const readObject = async (hash) => {
|
|
255
284
|
if (!OBJECT_HASH.test(hash)) return null;
|
|
285
|
+
const cached = objectCache.get(hash);
|
|
286
|
+
if (cached) {
|
|
287
|
+
objectCache.delete(hash);
|
|
288
|
+
objectCache.set(hash, cached);
|
|
289
|
+
return cached.artifact;
|
|
290
|
+
}
|
|
256
291
|
try {
|
|
257
292
|
const raw = await fs.readFile(objectPath(hash), 'utf8');
|
|
258
293
|
const artifact = JSON.parse(raw);
|
|
259
|
-
|
|
260
|
-
|
|
294
|
+
const serialized = JSON.stringify(artifact);
|
|
295
|
+
if (sha256(serialized) !== hash) return null;
|
|
296
|
+
if (!validateRoutePreviewArtifactBundle(artifact).valid) return null;
|
|
297
|
+
rememberObject(hash, artifact, Buffer.byteLength(serialized));
|
|
298
|
+
return artifact;
|
|
261
299
|
} catch {
|
|
262
300
|
return null;
|
|
263
301
|
}
|
|
@@ -392,7 +430,10 @@ export function createPreviewArtifactStore({
|
|
|
392
430
|
await Promise.all(
|
|
393
431
|
[...previousObjectReferences]
|
|
394
432
|
.filter((hash) => !retainedObjectReferences.has(hash))
|
|
395
|
-
.map((hash) =>
|
|
433
|
+
.map(async (hash) => {
|
|
434
|
+
await fs.rm(objectPath(hash), { force: true });
|
|
435
|
+
forgetObject(hash);
|
|
436
|
+
}),
|
|
396
437
|
);
|
|
397
438
|
|
|
398
439
|
if (materializeLegacy) {
|
|
@@ -417,6 +458,7 @@ export function createPreviewArtifactStore({
|
|
|
417
458
|
if (materialized) {
|
|
418
459
|
await writeJsonAtomic(legacyFile, materialized);
|
|
419
460
|
legacyCache = {
|
|
461
|
+
signature: await legacyFileSignature(),
|
|
420
462
|
hash: sha256(JSON.stringify(materialized)),
|
|
421
463
|
status: 'valid',
|
|
422
464
|
artifact: materialized,
|
|
@@ -430,6 +472,21 @@ export function createPreviewArtifactStore({
|
|
|
430
472
|
};
|
|
431
473
|
|
|
432
474
|
const importLegacyArtifact = async () => {
|
|
475
|
+
let signature;
|
|
476
|
+
try {
|
|
477
|
+
signature = await legacyFileSignature();
|
|
478
|
+
} catch (error) {
|
|
479
|
+
if (error?.code === 'ENOENT') {
|
|
480
|
+
legacyCache = {
|
|
481
|
+
signature: 'missing',
|
|
482
|
+
status: 'missing',
|
|
483
|
+
artifact: null,
|
|
484
|
+
};
|
|
485
|
+
return legacyCache;
|
|
486
|
+
}
|
|
487
|
+
throw error;
|
|
488
|
+
}
|
|
489
|
+
if (legacyCache?.signature === signature) return legacyCache;
|
|
433
490
|
let raw;
|
|
434
491
|
try {
|
|
435
492
|
raw = await fs.readFile(legacyFile, 'utf8');
|
|
@@ -441,12 +498,17 @@ export function createPreviewArtifactStore({
|
|
|
441
498
|
try {
|
|
442
499
|
artifact = JSON.parse(raw);
|
|
443
500
|
} catch {
|
|
444
|
-
|
|
501
|
+
legacyCache = { signature, status: 'invalid', artifact: null };
|
|
502
|
+
return legacyCache;
|
|
445
503
|
}
|
|
446
504
|
const hash = sha256(JSON.stringify(artifact));
|
|
447
|
-
if (legacyCache?.hash === hash) return legacyCache;
|
|
448
505
|
if (!validateRoutePreviewArtifactBundle(artifact).valid) {
|
|
449
|
-
legacyCache = {
|
|
506
|
+
legacyCache = {
|
|
507
|
+
signature,
|
|
508
|
+
hash,
|
|
509
|
+
status: 'invalid',
|
|
510
|
+
artifact: null,
|
|
511
|
+
};
|
|
450
512
|
return legacyCache;
|
|
451
513
|
}
|
|
452
514
|
// The compatibility contract treats this file as one exact snapshot. Frame
|
|
@@ -454,7 +516,7 @@ export function createPreviewArtifactStore({
|
|
|
454
516
|
// idempotent entries below ensure restarting Vite cannot promote this seed
|
|
455
517
|
// above a newer partial capture merely by importing it again.
|
|
456
518
|
await publishArtifact(artifact, { materializeLegacy: false });
|
|
457
|
-
legacyCache = { hash, status: 'valid', artifact };
|
|
519
|
+
legacyCache = { signature, hash, status: 'valid', artifact };
|
|
458
520
|
return legacyCache;
|
|
459
521
|
};
|
|
460
522
|
|
|
@@ -484,20 +546,64 @@ export function createPreviewArtifactStore({
|
|
|
484
546
|
return materializeManifest(manifest);
|
|
485
547
|
};
|
|
486
548
|
|
|
549
|
+
const indexFrameCandidates = (manifest, wanted) => {
|
|
550
|
+
const wantedIds = new Set(wanted.map((request) => request.id));
|
|
551
|
+
const candidatesById = new Map();
|
|
552
|
+
for (const entry of manifest?.frames ?? []) {
|
|
553
|
+
if (!wantedIds.has(entry.id)) continue;
|
|
554
|
+
const candidates = candidatesById.get(entry.id) ?? [];
|
|
555
|
+
candidates.push(entry);
|
|
556
|
+
candidatesById.set(entry.id, candidates);
|
|
557
|
+
}
|
|
558
|
+
for (const candidates of candidatesById.values()) {
|
|
559
|
+
candidates.sort((left, right) => right.generation - left.generation);
|
|
560
|
+
}
|
|
561
|
+
return candidatesById;
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
/** Resolves freshness from the compact manifest without loading frame payloads. */
|
|
565
|
+
const resolveFrameSelection = async (namespace, sourceRevision, wanted) => {
|
|
566
|
+
const manifest = await readManifest(namespace);
|
|
567
|
+
const candidatesById = indexFrameCandidates(manifest, wanted);
|
|
568
|
+
const exact = [];
|
|
569
|
+
const missing = [];
|
|
570
|
+
const stale = [];
|
|
571
|
+
for (const request of wanted) {
|
|
572
|
+
const candidates = candidatesById.get(request.id) ?? [];
|
|
573
|
+
if (!candidates.length) {
|
|
574
|
+
missing.push(request.id);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
const current = candidates.some(
|
|
578
|
+
(entry) =>
|
|
579
|
+
entry.sourceRevision === sourceRevision &&
|
|
580
|
+
(request.fingerprint == null ||
|
|
581
|
+
entry.fingerprint === request.fingerprint),
|
|
582
|
+
);
|
|
583
|
+
(current ? exact : stale).push(request.id);
|
|
584
|
+
}
|
|
585
|
+
return { exact, missing, stale };
|
|
586
|
+
};
|
|
587
|
+
|
|
487
588
|
const readFrameSelection = async (namespace, sourceRevision, wanted) => {
|
|
488
589
|
const manifest = await readManifest(namespace);
|
|
489
590
|
let aggregate = null;
|
|
490
591
|
if (manifest) {
|
|
592
|
+
// Index once per read. The earlier path filtered the complete manifest for
|
|
593
|
+
// every wanted frame, which made a storyboard probe O(wanted × entries).
|
|
594
|
+
const candidatesById = indexFrameCandidates(manifest, wanted);
|
|
491
595
|
for (const request of wanted) {
|
|
492
|
-
const candidates =
|
|
493
|
-
.filter((entry) => entry.id === request.id)
|
|
494
|
-
.sort((a, b) => b.generation - a.generation);
|
|
596
|
+
const candidates = candidatesById.get(request.id) ?? [];
|
|
495
597
|
const exact = candidates.filter((entry) =>
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
598
|
+
entry.sourceRevision === sourceRevision &&
|
|
599
|
+
(request.fingerprint == null ||
|
|
600
|
+
entry.fingerprint === request.fingerprint),
|
|
499
601
|
);
|
|
500
|
-
const
|
|
602
|
+
const exactSet = new Set(exact);
|
|
603
|
+
const ordered = [
|
|
604
|
+
...exact,
|
|
605
|
+
...candidates.filter((entry) => !exactSet.has(entry)),
|
|
606
|
+
];
|
|
501
607
|
for (const entry of ordered) {
|
|
502
608
|
const artifact = await readObject(entry.object);
|
|
503
609
|
if (
|
|
@@ -505,7 +611,23 @@ export function createPreviewArtifactStore({
|
|
|
505
611
|
artifact.namespace === namespace &&
|
|
506
612
|
artifact.frames[request.id]
|
|
507
613
|
) {
|
|
508
|
-
|
|
614
|
+
const frame = artifact.frames[request.id];
|
|
615
|
+
const withProvenance = {
|
|
616
|
+
...artifact,
|
|
617
|
+
frames: {
|
|
618
|
+
...artifact.frames,
|
|
619
|
+
[request.id]: {
|
|
620
|
+
...frame,
|
|
621
|
+
...(entry.sourceRevision == null
|
|
622
|
+
? {}
|
|
623
|
+
: { sourceRevision: entry.sourceRevision }),
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
};
|
|
627
|
+
aggregate = mergeRoutePreviewArtifactV3(
|
|
628
|
+
aggregate,
|
|
629
|
+
withProvenance,
|
|
630
|
+
);
|
|
509
631
|
break;
|
|
510
632
|
}
|
|
511
633
|
}
|
|
@@ -514,7 +636,7 @@ export function createPreviewArtifactStore({
|
|
|
514
636
|
return selectRoutePreviewArtifactFrames(
|
|
515
637
|
aggregate ?? emptyBundle(namespace, sourceRevision),
|
|
516
638
|
wanted,
|
|
517
|
-
{ includeStale: true },
|
|
639
|
+
{ includeStale: true, sourceRevision },
|
|
518
640
|
);
|
|
519
641
|
};
|
|
520
642
|
|
|
@@ -526,5 +648,6 @@ export function createPreviewArtifactStore({
|
|
|
526
648
|
readExactArtifact,
|
|
527
649
|
readFrameSelection,
|
|
528
650
|
readLatestArtifact,
|
|
651
|
+
resolveFrameSelection,
|
|
529
652
|
};
|
|
530
653
|
}
|
|
@@ -398,9 +398,9 @@ export function createRoutePreviewArtifactV3(input) {
|
|
|
398
398
|
{
|
|
399
399
|
status: rawCapture.status,
|
|
400
400
|
viewport: copyViewport(rawCapture.viewport),
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
401
|
+
// Content identity within the source revision that captured this frame.
|
|
402
|
+
// Store selections preserve the revision as per-frame provenance when
|
|
403
|
+
// frames from separate revisions share one response bundle.
|
|
404
404
|
...(rawCapture.fingerprint == null
|
|
405
405
|
? {}
|
|
406
406
|
: { fingerprint: String(rawCapture.fingerprint) }),
|
|
@@ -490,6 +490,10 @@ export function validateRoutePreviewArtifactV3(bundle) {
|
|
|
490
490
|
errors.push(`Frame "${frameId}" fingerprint is invalid.`);
|
|
491
491
|
continue;
|
|
492
492
|
}
|
|
493
|
+
if (!validOptionalIdentifier(rawFrame.sourceRevision)) {
|
|
494
|
+
errors.push(`Frame "${frameId}" source revision is invalid.`);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
493
497
|
const diagnostics = rawFrame.diagnostics;
|
|
494
498
|
if (
|
|
495
499
|
!Array.isArray(diagnostics) ||
|
|
@@ -622,9 +626,9 @@ function withFrames(bundle, frames) {
|
|
|
622
626
|
/**
|
|
623
627
|
* Picks the frames a consumer asked for and reports what it must still capture.
|
|
624
628
|
*
|
|
625
|
-
* A frame is usable when it exists and its stored fingerprint
|
|
626
|
-
* requested
|
|
627
|
-
*
|
|
629
|
+
* A frame is usable when it exists and its stored revision and fingerprint match
|
|
630
|
+
* the requested provenance. Callers that omit either constraint retain the
|
|
631
|
+
* looser compatibility behavior of earlier versions.
|
|
628
632
|
*/
|
|
629
633
|
export function selectRoutePreviewArtifactFrames(bundle, wanted, options = {}) {
|
|
630
634
|
if (!isPlainRecord(bundle) || !isPlainRecord(bundle.frames)) {
|
|
@@ -647,7 +651,12 @@ export function selectRoutePreviewArtifactFrames(bundle, wanted, options = {}) {
|
|
|
647
651
|
continue;
|
|
648
652
|
}
|
|
649
653
|
const fingerprint = isPlainRecord(request) ? request.fingerprint : undefined;
|
|
650
|
-
|
|
654
|
+
const frameSourceRevision = frame.sourceRevision ?? bundle.sourceRevision;
|
|
655
|
+
if (
|
|
656
|
+
(options.sourceRevision != null &&
|
|
657
|
+
frameSourceRevision !== options.sourceRevision) ||
|
|
658
|
+
(fingerprint != null && frame.fingerprint !== fingerprint)
|
|
659
|
+
) {
|
|
651
660
|
stale.push(id);
|
|
652
661
|
if (options.includeStale === true) frames[id] = frame;
|
|
653
662
|
continue;
|
|
@@ -674,7 +683,23 @@ export function mergeRoutePreviewArtifactV3(base, incoming) {
|
|
|
674
683
|
if (base.namespace !== incoming.namespace) {
|
|
675
684
|
throw new TypeError('Route preview artifacts belong to different namespaces.');
|
|
676
685
|
}
|
|
677
|
-
const
|
|
686
|
+
const mixedRevisions = base.sourceRevision !== incoming.sourceRevision;
|
|
687
|
+
const preserveRevision = (frames, sourceRevision) =>
|
|
688
|
+
Object.fromEntries(
|
|
689
|
+
Object.entries(frames).map(([id, frame]) => [
|
|
690
|
+
id,
|
|
691
|
+
mixedRevisions &&
|
|
692
|
+
isPlainRecord(frame) &&
|
|
693
|
+
frame.sourceRevision == null &&
|
|
694
|
+
sourceRevision != null
|
|
695
|
+
? { ...frame, sourceRevision }
|
|
696
|
+
: frame,
|
|
697
|
+
]),
|
|
698
|
+
);
|
|
699
|
+
const frames = {
|
|
700
|
+
...preserveRevision(base.frames, base.sourceRevision),
|
|
701
|
+
...preserveRevision(incoming.frames, incoming.sourceRevision),
|
|
702
|
+
};
|
|
678
703
|
if (Object.keys(frames).length > ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.frameCount) {
|
|
679
704
|
throw new RangeError('Route preview artifact has too many frames.');
|
|
680
705
|
}
|
|
@@ -692,13 +717,15 @@ export function mergeRoutePreviewArtifactV3(base, incoming) {
|
|
|
692
717
|
return withFrames(
|
|
693
718
|
{
|
|
694
719
|
namespace: incoming.namespace,
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
...(
|
|
698
|
-
?
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
720
|
+
// A mixed bundle has no honest bundle-wide revision. Its frames carry
|
|
721
|
+
// their own provenance instead, including known revisions on both sides.
|
|
722
|
+
...(mixedRevisions
|
|
723
|
+
? {}
|
|
724
|
+
: incoming.sourceRevision == null
|
|
725
|
+
? base.sourceRevision == null
|
|
726
|
+
? {}
|
|
727
|
+
: { sourceRevision: base.sourceRevision }
|
|
728
|
+
: { sourceRevision: incoming.sourceRevision }),
|
|
702
729
|
assets,
|
|
703
730
|
},
|
|
704
731
|
frames,
|
package/package.json
CHANGED
package/storyboard.d.ts
CHANGED
|
@@ -318,7 +318,7 @@ export interface RoutePreviewArtifactFrameRequest {
|
|
|
318
318
|
export declare function selectRoutePreviewArtifactFrames(
|
|
319
319
|
bundle: unknown,
|
|
320
320
|
wanted: readonly (RoutePreviewArtifactFrameRequest | string)[],
|
|
321
|
-
options?: { includeStale?: boolean },
|
|
321
|
+
options?: { includeStale?: boolean; sourceRevision?: string },
|
|
322
322
|
): {
|
|
323
323
|
bundle: RoutePreviewArtifactBundleV3;
|
|
324
324
|
missing: string[];
|
package/types.d.ts
CHANGED
|
@@ -994,6 +994,8 @@ export interface RoutePreviewArtifactV3ScreenshotAsset {
|
|
|
994
994
|
export interface RoutePreviewArtifactV3Frame {
|
|
995
995
|
readonly status: RoutePreviewArtifactV3Status;
|
|
996
996
|
readonly viewport: RoutePreviewArtifactV3Viewport;
|
|
997
|
+
readonly sourceRevision?: string;
|
|
998
|
+
readonly fingerprint?: string;
|
|
997
999
|
readonly snapshot?: RoutePreviewArtifactBundleV2['frames'][string];
|
|
998
1000
|
readonly screenshot?: RoutePreviewArtifactV3ScreenshotReference;
|
|
999
1001
|
readonly diagnostics: readonly RoutePreviewArtifactV3Diagnostic[];
|
|
@@ -1945,6 +1947,8 @@ export declare function PygmalionEditor(props: {
|
|
|
1945
1947
|
registry?: ComponentRegistry;
|
|
1946
1948
|
tokens?: TokenDef[];
|
|
1947
1949
|
initialPages?: InitialPageDef[];
|
|
1950
|
+
/** Canvas shown on first mount. Useful when an earlier catalog canvas is expensive to render. */
|
|
1951
|
+
initialCanvas?: string;
|
|
1948
1952
|
/** Explicit external-design to registered-code component mappings. */
|
|
1949
1953
|
componentConnections?: readonly DesignComponentConnectionDef[];
|
|
1950
1954
|
/** Named frame widths the application supports — rendered as one-click buttons beside the W/H boxes. */
|