Trim needless comments and fix the picker width

Remove comments that only restate the code, keeping the section banners
and the notes that carry a reason, a measured fact or an external
contract.

ButtonOptions.width claimed to be a fixed width but only set minWidth,
so the Graph and Texture pickers grew with the name they showed. It is
now what it promises, with a separate minWidth for the buttons that have
two forms. Narrow buttons shrink their padding, otherwise a 24px icon
button had 8px left for its glyph and truncate dropped it entirely.

__gm.labelsDropped() guards that class: the overflow guard only saw text
that was too wide, never a label truncated to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 15:52:05 +02:00
1 parent f16801aef3
commit 4d84231b9f
115 files changed
+707 -1564

No files matched your search

+4 -9
View File
@@ -8,10 +8,7 @@ import {
} from './ProjectStore';
import { deserializeProject, serializeProject } from './serialize';
/**
* Talks to the PHP backend in `api/index.php`. Routing goes through the `r` query
* parameter (`?r=projects/{id}`) because the site's nginx config rejects PATH_INFO.
*/
/** Routing goes through the `r` query parameter because the site's nginx config rejects PATH_INFO. */
export class HttpProjectStore implements ProjectStore {
constructor(private readonly endpoint: string) {}
@@ -49,7 +46,7 @@ export class HttpProjectStore implements ProjectStore {
await this.request<void>('DELETE', `projects/${encodeURIComponent(id)}`);
}
/** One JSON round trip; 409 becomes a `StaleRevisionError`, every other failure an `Error` with the server's message. */
/** 409 becomes a `StaleRevisionError`, every other failure an `Error` with the server's message. */
private async request<T>(method: string, route: string, payload?: unknown): Promise<T> {
const url = `${this.endpoint}?r=${encodeURIComponent(route)}`;
const response = await fetch(url, {
@@ -63,10 +60,8 @@ export class HttpProjectStore implements ProjectStore {
try {
data = text ? JSON.parse(text) : null;
} catch {
// Not every failure comes from our API: nginx rejects an oversized body itself and
// answers with an HTML page. Such a response is explained by its status, and saying
// "invalid JSON" instead of "too large" would send the user looking in the wrong place.
// A *successful* response that is not JSON stays a defect and keeps its old message.
// nginx rejects an oversized body itself and answers with an HTML page, so a failure is
// explained by its status rather than as "invalid JSON".
if (response.ok) throw new Error(`API ${method} ${route}: invalid JSON (${response.status})`);
data = null;
}
+3 -8
View File
@@ -1,11 +1,7 @@
import { statusMessage } from './http';
import type { StoredTexture, TextureStore, UploadedTexture } from './TextureStore';
/**
* Talks to the texture routes of `api/index.php`. Routing goes through the `r` query parameter
* (`?r=textures/{file}`) for the same reason as `HttpProjectStore`: nginx does not pass
* PATH_INFO to PHP.
*/
/** Routing goes through the `r` query parameter because nginx does not pass PATH_INFO to PHP. */
export class HttpTextureStore implements TextureStore {
constructor(private readonly endpoint: string) {}
@@ -21,7 +17,6 @@ export class HttpTextureStore implements TextureStore {
await this.request<void>('DELETE', `textures/${encodeURIComponent(file)}`);
}
/** One round trip; every failure becomes an `Error` carrying the server's message. */
private async request<T>(
method: string,
route: string,
@@ -39,8 +34,8 @@ export class HttpTextureStore implements TextureStore {
try {
data = text ? JSON.parse(text) : null;
} catch {
// An upload is exactly the request nginx refuses before PHP sees it, and its answer is
// an HTML page. Explain such a failure from the status; a successful non-JSON body is a defect.
// An upload is the request nginx refuses before PHP sees it, answering with an HTML page,
// so a failure is explained from the status instead.
if (response.ok) throw new Error(`API ${method} ${route}: invalid JSON (${response.status})`);
data = null;
}
+1 -2
View File
@@ -20,11 +20,10 @@ export class StaleRevisionError extends Error {
}
}
/** Persistence boundary. The editor only talks to this interface. */
export interface ProjectStore {
list(): Promise<ProjectSummary[]>;
load(id: string): Promise<LoadedProject>;
/** Saves the project and returns the new revision. Rejects with StaleRevisionError on conflict. */
/** Returns the new revision; rejects with `StaleRevisionError` on conflict. */
save(project: Project, revision: number): Promise<number>;
create(name: string): Promise<LoadedProject>;
delete(id: string): Promise<void>;
+4 -8
View File
@@ -1,4 +1,3 @@
/** What the server made of an uploaded image; the payload a texture asset is built from. */
export interface UploadedTexture {
/** Content-addressed name in `data/textures/`: `{sha256}.{png|jpg|webp|gif}` (see `isTextureFile`). */
file: string;
@@ -7,7 +6,6 @@ export interface UploadedTexture {
bytes: number;
}
/** One stored image as the server lists it. */
export interface StoredTexture {
file: string;
bytes: number;
@@ -15,15 +13,13 @@ export interface StoredTexture {
}
/**
* Upload boundary for images, the counterpart of `ProjectStore`. The editor only talks to this
* interface. An upload is raw bytes rather than a multipart form: name, size and format are
* derived from the image itself on the server, so there is nothing else to send.
* An upload is raw bytes rather than a multipart form: name, size and format are derived from the
* image itself on the server, so there is nothing else to send.
*/
export interface TextureStore {
/**
* Stores an image and returns its file name and pixel size. `type` only fills the
* Content-Type header – the server trusts the bytes, not the label – and uploading the same
* picture twice yields the same file, because the name is the hash of its content.
* `type` only fills the Content-Type header – the server trusts the bytes, not the label – and
* uploading the same picture twice yields the same file, the hash of its content.
*/
upload(bytes: Blob | ArrayBuffer, type: string): Promise<UploadedTexture>;
list(): Promise<StoredTexture[]>;
+2 -5
View File
@@ -1,10 +1,8 @@
/**
* The bit of HTTP handling both stores share. It exists because a failed response does not
* always come from our API: nginx caps request bodies at 1 MB and answers a larger one with
* its own HTML page, so there is no `{error}` object to read the reason from.
* A failed response does not always come from our API: nginx caps request bodies at 1 MB and
* answers a larger one with its own HTML page, so there is no `{error}` object to read.
*/
/** Reasons for the statuses that can arrive without an API body of their own. */
const STATUS_MESSAGES: Record<number, string> = {
400: 'Bad request',
403: 'Forbidden',
@@ -16,7 +14,6 @@ const STATUS_MESSAGES: Record<number, string> = {
504: 'Server timed out',
};
/** What to tell the user about a failed response whose body is not our JSON – the status is all there is. */
export function statusMessage(status: number, statusText: string): string {
return STATUS_MESSAGES[status] ?? (statusText || `HTTP ${status}`);
}
+12 -16
View File
@@ -1,4 +1,4 @@
// The graph package index registers the built-in nodes; unknown node types are dropped below.
// Importing the graph package index is what registers the built-in nodes.
import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, nodeDef } from '../graph';
import type { Asset, TextureFrame } from '../model/Asset';
import { isAssetType } from '../model/AssetTypes';
@@ -20,16 +20,14 @@ import {
} from '../properties/PropertySchema';
/**
* Turns untrusted JSON (server file, import) into a well-formed Project. Unknown keys are
* dropped, missing ones get defaults, values are coerced against the schema, dangling
* scene objects are removed. Future format migrations live here as well.
* Turns untrusted JSON (server file, import) into a well-formed Project: unknown keys are dropped,
* missing ones get defaults, values are coerced against the schema, dangling objects are removed.
*/
export function deserializeProject(raw: unknown, fallbackId = ''): Project {
const r = asRecord(raw);
const rawAssets = (Array.isArray(r.assets) ? r.assets : []).map(asRecord);
// Pass 1: asset ids and their effective schemas (behaviors declare their own variables),
// needed to coerce scene-object overrides in pass 2.
// Pass 1: asset ids and their effective schemas, needed to coerce overrides in pass 2.
const schemas = new Map<string, PropertySchema>();
const variables = new Map<string, PropertyDef[]>();
for (const a of rawAssets) {
@@ -43,8 +41,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
}
}
// Pass 2: build assets. Frame ids live in the same reference space as asset ids (a sprite's
// `texture` may name either), so they are handed the ids already spoken for.
// Pass 2: build assets. Frame ids share the reference space with asset ids, so `normalizeTexture`
// is handed the ids already spoken for.
const assets: Asset[] = [];
const takenIds = new Set(schemas.keys());
for (const a of rawAssets) {
@@ -106,10 +104,9 @@ function normalizeObjects(raw: unknown, schemas: Map<string, PropertySchema>): S
assetId: o.assetId,
name: typeof o.name === 'string' ? o.name : '',
position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) },
// Editor-only view shift; absent in documents written before it existed.
editorOffset: { x: toNumber(offset.x), y: toNumber(offset.y) },
overrides: normalizeBag(o.overrides, schema, true),
// `ownerId` is the pre-hierarchy name of the same link (documents saved before parenting).
// `ownerId` is the pre-hierarchy name of the same link.
parentId: typeof o.parentId === 'string' ? o.parentId : typeof o.ownerId === 'string' ? o.ownerId : '',
templateId: typeof o.templateId === 'string' ? o.templateId : '',
});
@@ -150,7 +147,7 @@ function normalizeGraph(raw: unknown): Graph {
const n = asRecord(item);
const id = typeof n.id === 'string' ? n.id : '';
const type = typeof n.type === 'string' ? n.type : '';
if (!id || nodeIds.has(id) || !nodeDef(type)) continue; // unknown node types are dropped
if (!id || nodeIds.has(id) || !nodeDef(type)) continue;
nodeIds.add(id);
const params: PropertyBag = {};
for (const [key, value] of Object.entries(asRecord(n.params))) {
@@ -187,9 +184,8 @@ function normalizeGraph(raw: unknown): Graph {
}
/**
* The picture of a texture asset. A file name that is not the server's content-addressed form
* is dropped rather than kept: it can only have been hand-written, and a request built from it
* would 404. Such a texture loads as "no picture", which draws as the sprite's plain colour.
* A file name that is not the server's content-addressed form is dropped: a request built from it
* would 404, and the texture then loads as "no picture".
*/
function normalizeTexture(
raw: Record<string, unknown>,
@@ -204,8 +200,8 @@ function normalizeTexture(
for (const item of Array.isArray(raw.frames) ? raw.frames : []) {
const f = asRecord(item);
const id = typeof f.id === 'string' ? f.id : '';
// A frame id shares the reference space with asset ids (a sprite may name either), so a
// duplicate would make one reference mean two pictures. Drop it rather than resolve it.
// Frame ids share the reference space with asset ids, so a duplicate would make one
// reference mean two pictures.
if (!id || takenIds.has(id)) continue;
takenIds.add(id);
const rect = normalizeFrameRect(