@nano133/nanoterra 0.3.2 → 0.4.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.
@@ -1,14 +1,19 @@
1
- // The Shelf screen in /admin: the game's looks with what each has sold, and a draft you can change in this page. It runs only in
1
+ // The Shelf screen in /admin (the Cosmetics tab): the game's looks with what each has sold, and a draft you can change in this page. It runs only in
2
2
  // the creator's own browser, on the creator's own game. It changes NOTHING on the game: the new looks.json is text to copy and put
3
3
  // in the repository (there is no server call that writes looks, on purpose). Its rules are in the package's looks path (../../looks).
4
+ //
5
+ // Two modes. To see: the looks the game sells NOW (its looks.json), in a grid of cards with a big picture, the price and its step on the ladder, what each
6
+ // has sold and its share of the sales, its slots; filters (All, For sale, Free, Not sold yet) and a sort. To edit: the draft, one card open at a time with its
7
+ // fields in groups (name, price, look, slots), a "New" or "Changed" mark on each card, and a change bar at the bottom of the screen with the copy keys.
4
8
 
5
9
  import { diffLooks, removalProblems } from "../../looks/change.js";
6
10
  import { SLOTS_MAX } from "../../looks/look.js";
7
11
  import { looksJson, slugId } from "../../looks/json.js";
8
12
  import { PRICE_LADDER, priceText, rungOf, rungUsd } from "../../looks/ladder.js";
9
- import { HATS, lookProblems, usdText, type Hat, type Look, type Problem } from "../../looks/look.js";
10
- import { copy, h } from "./dom.js";
11
- import { aiPrompt, changeText, ownedIds, shelfStats, soldText, type ShelfOrder, type ShelfPlayer } from "./shelfData.js";
13
+ import { COLOR_PATTERN, HATS, lookProblems, usdText, type Hat, type Look, type Problem } from "../../looks/look.js";
14
+ import { copy, dollars, h, plural } from "./dom.js";
15
+ import { icon } from "./icons.js";
16
+ import { aiPrompt, changeText, ownedIds, shelfStats, soldText, type LookStats, type ShelfOrder, type ShelfPlayer } from "./shelfData.js";
12
17
 
13
18
  export type ShelfData = { orders: ShelfOrder[]; players: ShelfPlayer[]; repo: string | null };
14
19
 
@@ -22,16 +27,20 @@ const svg = (tag: string, attrs: Record<string, string | number> = {}, ...kids:
22
27
  };
23
28
  const shape = (tag: string, attrs: Record<string, string | number>) => svg(tag, { stroke: INK, "stroke-width": 1.4, "stroke-linejoin": "round", ...attrs });
24
29
 
30
+ /** The shapes of a hat in the flat picture's 64 x 72 box, in the colour `fill`. */
31
+ function hatShapes(hat: Hat, fill: string): Element[] {
32
+ const hats: Record<Hat, () => Element[]> = {
33
+ cap: () => [shape("path", { d: "M19 24 A13 13 0 0 1 45 24 Z", fill }), shape("rect", { x: 30, y: 22, width: 22, height: 4, rx: 2, fill })],
34
+ beanie: () => [shape("path", { d: "M18 23 A14 14 0 0 1 46 23 Z", fill }), shape("rect", { x: 18, y: 21, width: 28, height: 5, rx: 2, fill }), shape("circle", { cx: 32, cy: 9, r: 3.5, fill })],
35
+ crown: () => [shape("polygon", { points: "20,22 20,11 26,17 32,9 38,17 44,11 44,22", fill })],
36
+ tophat: () => [shape("rect", { x: 23, y: 3, width: 18, height: 17, rx: 2, fill }), shape("rect", { x: 17, y: 19, width: 30, height: 4, rx: 2, fill })],
37
+ };
38
+ return hats[hat]();
39
+ }
40
+
25
41
  /** A small flat picture of a look: the body in its colour and the hat in its colour. (The game draws the real character; a game can give its own picture.) */
26
42
  export function lookPicture(l: Look): Element {
27
43
  const hat = l.change.hat;
28
- const hc = l.change.hatColor ?? "#444444";
29
- const hats: Record<Hat, Element[]> = {
30
- cap: [shape("path", { d: "M19 24 A13 13 0 0 1 45 24 Z", fill: hc }), shape("rect", { x: 30, y: 22, width: 22, height: 4, rx: 2, fill: hc })],
31
- beanie: [shape("path", { d: "M18 23 A14 14 0 0 1 46 23 Z", fill: hc }), shape("rect", { x: 18, y: 21, width: 28, height: 5, rx: 2, fill: hc }), shape("circle", { cx: 32, cy: 9, r: 3.5, fill: hc })],
32
- crown: [shape("polygon", { points: "20,22 20,11 26,17 32,9 38,17 44,11 44,22", fill: hc })],
33
- tophat: [shape("rect", { x: 23, y: 3, width: 18, height: 17, rx: 2, fill: hc }), shape("rect", { x: 17, y: 19, width: 30, height: 4, rx: 2, fill: hc })],
34
- };
35
44
  return svg(
36
45
  "svg",
37
46
  { viewBox: "0 0 64 72", width: 56, height: 63, role: "img", "aria-label": `${l.name}: ${l.change.hat ?? "no hat"}` },
@@ -41,10 +50,17 @@ export function lookPicture(l: Look): Element {
41
50
  shape("circle", { cx: 32, cy: 26, r: 13, fill: "#f2c9a0" }),
42
51
  svg("circle", { cx: 27, cy: 27, r: 1.7, fill: INK }),
43
52
  svg("circle", { cx: 37, cy: 27, r: 1.7, fill: INK }),
44
- ...(hat ? hats[hat] : []),
53
+ ...(hat ? hatShapes(hat, l.change.hatColor ?? "#444444") : []),
45
54
  );
46
55
  }
47
56
 
57
+ /** The drawing on a hat choice in the editor: the hat alone (or a dashed head for "No hat"), filled with the text colour, so the choice shows the look's hat colour. */
58
+ function hatChoicePicture(hat: Hat | null): Element {
59
+ const head = shape("circle", { cx: 32, cy: 30, r: 12, fill: "#f2c9a0" });
60
+ const kids = hat ? [head, ...hatShapes(hat, "currentColor")] : [svg("circle", { cx: 32, cy: 30, r: 12, fill: "#f2c9a0", stroke: INK, "stroke-width": 1.4, "stroke-dasharray": "3 3" })];
61
+ return svg("svg", { viewBox: "0 0 64 46", width: 46, height: 33, "aria-hidden": "true", focusable: "false" }, ...kids);
62
+ }
63
+
48
64
  /**
49
65
  * A Shelf picture that shows the look on the real character. `load` gives the drawing function (a look to a PNG address, or null), and is
50
66
  * called once, the first time a picture is asked for: let it import your preview file dynamically, so Three.js loads only when the Shelf
@@ -71,9 +87,6 @@ export function previewPicture(load: () => Promise<(l: Look) => Promise<string |
71
87
 
72
88
  const clone = (ls: readonly Look[]): Look[] => ls.map((l) => ({ id: l.id, name: l.name, priceUsd: l.priceUsd, change: { ...l.change }, ...(l.slots ? { slots: { ...l.slots } } : {}) }));
73
89
 
74
- /** "wings ember, trail #ffcf5a" */
75
- const slotsText = (l: Look): string => Object.entries(l.slots ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
76
-
77
90
  /** The names of the slots that differ between two looks (added, removed or another value). */
78
91
  function slotNamesChanged(before: Look, after: Look): string {
79
92
  const names = new Set([...Object.keys(before.slots ?? {}), ...Object.keys(after.slots ?? {})]);
@@ -92,21 +105,111 @@ function fieldWords(d: ReturnType<typeof diffLooks>["changed"][number]): string
92
105
  return `${d.after.name}: ${parts.join(", ")}`;
93
106
  }
94
107
 
108
+ /** The price as the cards write it: "Free", "25¢ · statement", "$1.10" (a price that is not a step of the ladder has no step name), "No price". */
109
+ function priceWords(usd: number): string {
110
+ if (!Number.isFinite(usd)) return "No price";
111
+ if (usd === 0) return "Free";
112
+ const r = rungOf(usd);
113
+ return `${priceText(usd)}${r ? ` · ${r.name}` : ""}`;
114
+ }
115
+
116
+ /** The colours the body colour offers in one press (any colour is good: the colour field is next to them). */
117
+ export const BODY_COLOURS = ["#4aa8ff", "#3ddc97", "#ffcf5a", "#ff7a3d", "#ff5c8a", "#9b6bff", "#2b2d42", "#f4f1ea"] as const;
118
+
119
+ /** A hat's name as the editor writes it. */
120
+ const HAT_WORDS: Record<Hat, string> = { cap: "Cap", beanie: "Beanie", crown: "Crown", tophat: "Top hat" };
121
+
122
+ export type ShelfFilter = "all" | "sale" | "free" | "unsold";
123
+ export type ShelfSort = "best" | "price-desc" | "price-asc" | "name" | "file";
124
+
125
+ const FILTERS: readonly { id: ShelfFilter; label: string }[] = [
126
+ { id: "all", label: "All" },
127
+ { id: "sale", label: "For sale" },
128
+ { id: "free", label: "Free" },
129
+ { id: "unsold", label: "Not sold yet" },
130
+ ];
131
+ const SORTS: readonly { id: ShelfSort; label: string }[] = [
132
+ { id: "best", label: "Best selling" },
133
+ { id: "price-desc", label: "Price: high to low" },
134
+ { id: "price-asc", label: "Price: low to high" },
135
+ { id: "name", label: "Name" },
136
+ { id: "file", label: "File order" },
137
+ ];
138
+
139
+ /** Whether a look passes a filter. "Not sold yet": a look for sale that no paid order and no owner has. Pure, for the screen and its tests. */
140
+ export function shelfFilter(l: Look, f: ShelfFilter, s: LookStats | undefined): boolean {
141
+ if (f === "sale") return l.priceUsd > 0;
142
+ if (f === "free") return l.priceUsd === 0;
143
+ if (f === "unsold") return l.priceUsd > 0 && !s?.paid && !s?.owners;
144
+ return true;
145
+ }
146
+
147
+ /** The looks in a sort's order (a new list; equal looks keep the file's order). Pure, for the screen and its tests. */
148
+ export function shelfSort(ls: readonly Look[], by: ShelfSort, stats: Map<string, LookStats>): Look[] {
149
+ const place = new Map(ls.map((l, i) => [l, i]));
150
+ const cents = (l: Look) => stats.get(l.id)?.paidCents ?? 0;
151
+ const owners = (l: Look) => stats.get(l.id)?.owners ?? 0;
152
+ const cmp: Record<ShelfSort, (a: Look, b: Look) => number> = {
153
+ best: (a, b) => cents(b) - cents(a) || owners(b) - owners(a),
154
+ "price-desc": (a, b) => b.priceUsd - a.priceUsd,
155
+ "price-asc": (a, b) => a.priceUsd - b.priceUsd,
156
+ name: (a, b) => a.name.localeCompare(b.name, "en", { sensitivity: "base" }),
157
+ file: () => 0,
158
+ };
159
+ return [...ls].sort((a, b) => cmp[by](a, b) || place.get(a)! - place.get(b)!);
160
+ }
161
+
162
+ /** A slot's value as a chip: a colour gets a swatch. */
163
+ function slotChip(name: string, value: string): HTMLElement {
164
+ const sw = COLOR_PATTERN.test(value) ? h("span", { class: "swatch", "aria-hidden": "true" }) : null;
165
+ sw?.style.setProperty("--swatch", value);
166
+ return h("li", { class: "slot-chip" }, sw, h("span", { class: "slot-name" }, name), " ", h("span", { class: "slot-value" }, value));
167
+ }
168
+
95
169
  /**
96
170
  * The screen. `looks` is the game's looks now (its looks.json): the draft starts from them. `update` hands the screen fresh numbers (after the page reloads
97
- * the summary) and keeps the draft. `picture` draws a look (default: the flat picture above). `repoHelp` is what to tell a game that has no repository set.
171
+ * the summary) and keeps the draft, the mode, the open card, the filter and the sort. `picture` draws a look (default: the flat picture above). `repoHelp` is
172
+ * what to tell a game that has no repository set.
98
173
  */
99
174
  export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l: Look) => Element = lookPicture, repoHelp = "To get a link to your looks.json on GitHub, give the admin your repository (for example your-name/your-game)."): { el: HTMLElement; update: (d: ShelfData) => void } {
100
175
  const LOOKS = looks;
101
176
  let data = first;
102
177
  let draft = clone(LOOKS);
103
178
  let editing = false;
179
+ /** The one card that is open in the editor (a look of the draft), or none. */
180
+ let open: Look | null = null;
181
+ /** The open card's marks and its list of problems: the change bar brings them up to date at every key press (the rest of the screen waits for a redraw). */
182
+ let openParts: { marks: HTMLElement; problems: HTMLElement } | null = null;
183
+ let filter: ShelfFilter = "all";
184
+ let sort: ShelfSort = "best";
104
185
  const el = h("section", { class: "admin-card shelf", "aria-labelledby": "shelf-title" });
105
186
 
106
187
  const stats = () => shelfStats(data.orders, data.players);
107
188
  const owned = () => ownedIds(data.players);
108
189
  const isOriginal = (id: string) => LOOKS.some((l) => l.id === id);
109
190
  const freeCount = () => draft.filter((l) => l.priceUsd === 0).length;
191
+ /** Whether the draft differs from the game's looks.json in any way, the order of the looks too (the file's own text is compared). */
192
+ const changedFromGame = () => looksJson(draft) !== looksJson(LOOKS);
193
+
194
+ /**
195
+ * The pictures, kept by the look's text: a redraw puts the same picture back instead of drawing it again (a picture on the real character is drawn once,
196
+ * and the card does not flash its flat picture first). Pictures that the last redraw did not use are let go.
197
+ */
198
+ const pictures = new Map<string, Element>();
199
+ let used = new Set<string>();
200
+ function pictureOf(l: Look): Element {
201
+ const key = JSON.stringify([l.id, l.name, l.priceUsd, l.change, l.slots ?? null]);
202
+ const kept = pictures.get(key);
203
+ if (kept && !used.has(key)) {
204
+ used.add(key);
205
+ return kept;
206
+ }
207
+ const made = picture(l);
208
+ pictures.set(key, made);
209
+ used.add(key);
210
+ return made;
211
+ }
212
+
110
213
  /**
111
214
  * The rows of a look's slot editor, as typed: a name can be typed twice (an object cannot hold it), so the rows are the draft's own truth while it is edited, and the
112
215
  * look's `slots` is made from them. A row with no name and no value is a row not filled in yet: it is not a slot.
@@ -134,12 +237,17 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
134
237
  }
135
238
 
136
239
  function redraw() {
240
+ used = new Set();
241
+ openParts = null;
137
242
  const st = stats();
138
243
  const own = owned();
244
+ const forSale = LOOKS.filter((l) => l.priceUsd > 0).length;
245
+ const earned = [...st.values()].reduce((n, s) => n + s.paidCents, 0);
246
+
139
247
  const head = h(
140
248
  "div",
141
249
  { class: "card-head" },
142
- h("h2", { id: "shelf-title" }, "Shelf"),
250
+ h("div", { class: "card-title" }, h("h2", { id: "shelf-title" }, "Shelf"), h("p", { class: "fine card-sub" }, editing ? "Draft: nothing here changes your game until you push the new looks.json." : "The looks your game sells now. Only you see these numbers.")),
143
251
  h(
144
252
  "button",
145
253
  {
@@ -151,107 +259,325 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
151
259
  redraw();
152
260
  },
153
261
  },
262
+ editing ? icon("close", 18) : icon("pencil", 18),
154
263
  editing ? "Close the editor" : "Edit the shelf",
155
264
  ),
156
265
  );
157
- const intro = h(
158
- "p",
159
- { class: "fine" },
160
- editing
161
- ? "Change a look here to see what it would be. This page changes nothing in your game: when you are happy, copy the new looks.json into your repository and push it. Your game builds again and the new looks go live."
162
- : "The looks your game sells now, with what each has sold in your own database. Nobody else sees these numbers.",
163
- );
164
-
165
- const list = h("div", { class: "shelf-list" });
166
- draft.forEach((l, i) => list.append(card(l, i, st, own)));
167
- const parts: (Node | null)[] = [head, intro, list];
266
+ const parts: (Node | null)[] = [head];
168
267
 
169
- if (editing) {
268
+ if (!editing) {
170
269
  parts.push(
171
270
  h(
172
- "div",
173
- { class: "shelf-add" },
174
- h("button", { class: "btn", type: "button", onclick: () => addLook() }, "Add a look"),
175
- " ",
176
- h("button", { class: "btn", type: "button", disabled: JSON.stringify(draft) === JSON.stringify(LOOKS), onclick: () => ((draft = clone(LOOKS)), redraw()) }, "Start over"),
271
+ "ul",
272
+ { class: "shelf-stats", "aria-label": "The shelf in numbers" },
273
+ h("li", {}, h("strong", {}, String(LOOKS.length)), ` ${LOOKS.length === 1 ? "look" : "looks"}`),
274
+ h("li", {}, h("strong", {}, String(forSale)), " for sale"),
275
+ h("li", {}, h("strong", {}, String(LOOKS.length - forSale)), " free"),
276
+ h("li", {}, h("strong", {}, dollars(earned)), " earned"),
177
277
  ),
178
- foot(),
278
+ );
279
+ if (changedFromGame()) {
280
+ const diff = diffLooks(LOOKS, draft);
281
+ parts.push(
282
+ h(
283
+ "div",
284
+ { class: "draft-note", role: "status" },
285
+ icon("pencil", 18),
286
+ h("span", {}, `You have a draft that is not in your game yet: ${changeText(diff)}.`),
287
+ h("button", { class: "btn btn-small", type: "button", onclick: () => ((editing = true), redraw()) }, "Open the draft"),
288
+ ),
289
+ );
290
+ }
291
+ parts.push(toolbar(st), viewGrid(st));
292
+ } else {
293
+ parts.push(
294
+ h("p", { class: "fine" }, "Change a look here to see what it would be. This page changes nothing in your game: when you are happy, copy the new looks.json into your repository and push it. Your game builds again and the new looks go live."),
295
+ editGrid(st, own),
296
+ ...foot(),
179
297
  );
180
298
  }
181
299
  el.replaceChildren(...parts.filter((p): p is Node => p !== null));
300
+ for (const k of [...pictures.keys()]) if (!used.has(k)) pictures.delete(k);
182
301
  }
183
302
 
184
- function card(l: Look, i: number, st: ReturnType<typeof stats>, own: Set<string>) {
185
- const rung = rungOf(l.priceUsd);
186
- const price = l.priceUsd === 0 ? "Free" : `${priceText(l.priceUsd)}${rung ? ` · ${rung.name}` : ""}`;
187
- const pic = h("div", { class: "shelf-pic" }, picture(l));
188
- const priceLine = h("span", { class: "shelf-price" }, price);
189
- const top = h("div", { class: "shelf-top" }, pic, h("div", { class: "shelf-text" }, h("strong", {}, l.name || "(no name)"), priceLine, h("span", { class: "fine" }, isOriginal(l.id) ? (l.priceUsd === 0 ? "Every player starts with it" : soldText(st.get(l.id))) : "New: not in your game yet"), l.slots && Object.keys(l.slots).length ? h("span", { class: "fine shelf-slots" }, `Slots: ${slotsText(l)}`) : null));
190
- if (!editing) return h("article", { class: "shelf-card" }, top);
303
+ // ---- To see ----
191
304
 
305
+ function toolbar(st: Map<string, LookStats>): HTMLElement {
306
+ const chips = h("div", { class: "seg", role: "group", "aria-label": "Show looks" });
307
+ for (const f of FILTERS) {
308
+ const n = LOOKS.filter((l) => shelfFilter(l, f.id, st.get(l.id))).length;
309
+ chips.append(
310
+ h(
311
+ "button",
312
+ { class: `seg-btn${filter === f.id ? " is-on" : ""}`, type: "button", "aria-pressed": filter === f.id ? "true" : "false", onclick: () => ((filter = f.id), redraw()) },
313
+ f.label,
314
+ h("span", { class: "seg-count" }, String(n)),
315
+ ),
316
+ );
317
+ }
318
+ const select = h("select", { class: "input select", "aria-label": "Sort the looks", onchange: (e: Event) => ((sort = (e.target as HTMLSelectElement).value as ShelfSort), redraw()) }, ...SORTS.map((s) => h("option", { value: s.id }, s.label)));
319
+ select.value = sort;
320
+ return h("div", { class: "toolbar" }, chips, h("label", { class: "sort" }, h("span", {}, "Sort"), select));
321
+ }
322
+
323
+ function viewGrid(st: Map<string, LookStats>): HTMLElement {
324
+ const total = [...st.values()].reduce((n, s) => n + s.paidCents, 0);
325
+ const shown = shelfSort(LOOKS.filter((l) => shelfFilter(l, filter, st.get(l.id))), sort, st);
326
+ const grid = h("div", { class: "shelf-list" });
327
+ if (!shown.length) grid.append(h("p", { class: "empty" }, "No look matches this filter."));
328
+ for (const l of shown) grid.append(viewCard(l, st.get(l.id), total));
329
+ return grid;
330
+ }
331
+
332
+ function stage(l: Look, ...extra: (Node | null)[]): HTMLElement {
333
+ const box = h("div", { class: "shelf-stage" }, h("div", { class: "shelf-pic" }, pictureOf(l)), ...extra);
334
+ if (COLOR_PATTERN.test(l.change.body)) box.style.setProperty("--tint", l.change.body);
335
+ return box;
336
+ }
337
+
338
+ function slotList(l: Look): HTMLElement | null {
339
+ const entries = Object.entries(l.slots ?? {});
340
+ if (!entries.length) return null;
341
+ return h("ul", { class: "slot-chips shelf-slots", "aria-label": `Slots of ${l.name}` }, h("li", { class: "sr-only" }, "Slots:"), ...entries.map(([k, v]) => slotChip(k, v)));
342
+ }
343
+
344
+ function viewCard(l: Look, s: LookStats | undefined, total: number): HTMLElement {
345
+ const share = s && total > 0 && s.paidCents > 0 ? Math.round((s.paidCents / total) * 100) : 0;
346
+ const bar = h("div", { class: "share-bar" });
347
+ bar.style.setProperty("--share", `${share}%`);
348
+ return h(
349
+ "article",
350
+ { class: `shelf-card${l.priceUsd === 0 ? " is-free" : ""}` },
351
+ stage(l, h("span", { class: `price-pill${l.priceUsd === 0 ? " is-free" : ""}` }, priceWords(l.priceUsd))),
352
+ h(
353
+ "div",
354
+ { class: "shelf-body" },
355
+ h("strong", { class: "shelf-name" }, l.name || "(no name)"),
356
+ h("span", { class: "fine" }, l.priceUsd === 0 ? "Every player starts with it" : soldText(s)),
357
+ share ? h("div", { class: "share", title: `${share}% of the money from sales` }, bar, h("span", { class: "fine" }, `${share}% of sales`)) : null,
358
+ slotList(l),
359
+ ),
360
+ );
361
+ }
362
+
363
+ // ---- To edit ----
364
+
365
+ function editGrid(st: Map<string, LookStats>, own: Set<string>): HTMLElement {
366
+ const bad = problems();
367
+ const diff = diffLooks(LOOKS, draft);
368
+ const added = new Set(diff.added.map((l) => l.id));
369
+ const changed = new Set(diff.changed.map((c) => c.id));
370
+ const grid = h("div", { class: "shelf-list is-editing" });
371
+ draft.forEach((l, i) => {
372
+ const mine = bad.filter((p) => p.look === i + 1);
373
+ const mark = added.has(l.id) ? "new" : changed.has(l.id) ? "changed" : null;
374
+ grid.append(l === open ? editCard(l, i, st, own, mine, mark) : compactCard(l, st, own, mine, mark));
375
+ });
376
+ for (const gone of diff.removed) grid.append(ghostCard(gone));
377
+ grid.append(h("button", { class: "shelf-add-tile", type: "button", onclick: () => addLook() }, icon("plus", 22), h("span", {}, "Add a look")));
378
+ return grid;
379
+ }
380
+
381
+ function marks(l: Look, own: Set<string>, mine: Problem[], mark: "new" | "changed" | null): HTMLElement {
382
+ return h("span", { class: "marks" }, ...markList(l, own, mine, mark));
383
+ }
384
+
385
+ function markList(l: Look, own: Set<string>, mine: Problem[], mark: "new" | "changed" | null): (Node | null)[] {
386
+ return [
387
+ mark ? h("span", { class: `mark mark-${mark}` }, mark === "new" ? "New" : "Changed") : null,
388
+ isOriginal(l.id) && own.has(l.id) ? h("span", { class: "mark mark-owned", title: "A player owns this look" }, icon("lock", 14), "Owned") : null,
389
+ mine.length ? h("span", { class: "mark mark-bad" }, plural(mine.length, "problem")) : null,
390
+ ];
391
+ }
392
+
393
+ function compactCard(l: Look, st: Map<string, LookStats>, own: Set<string>, mine: Problem[], mark: "new" | "changed" | null): HTMLElement {
394
+ return h(
395
+ "article",
396
+ { class: `shelf-card is-compact${mark ? ` is-${mark}` : ""}${mine.length ? " has-problem" : ""}` },
397
+ stage(l),
398
+ h(
399
+ "div",
400
+ { class: "shelf-body" },
401
+ h("strong", { class: "shelf-name" }, l.name || "(no name)"),
402
+ h("span", { class: "shelf-price" }, priceWords(l.priceUsd)),
403
+ h("span", { class: "fine" }, isOriginal(l.id) ? (l.priceUsd === 0 ? "Every player starts with it" : soldText(st.get(l.id))) : "New: not in your game yet"),
404
+ marks(l, own, mine, mark),
405
+ ),
406
+ h(
407
+ "button",
408
+ { class: "btn btn-small card-open", type: "button", "aria-expanded": "false", "aria-label": `Edit ${l.name || "this look"}`, onclick: () => openCard(l) },
409
+ icon("pencil", 16),
410
+ "Edit",
411
+ ),
412
+ );
413
+ }
414
+
415
+ function ghostCard(l: Look): HTMLElement {
416
+ return h(
417
+ "article",
418
+ { class: "shelf-ghost" },
419
+ h("div", { class: "shelf-body" }, h("strong", { class: "shelf-name" }, l.name), h("span", { class: "fine" }, "Removed from the draft")),
420
+ h(
421
+ "button",
422
+ {
423
+ class: "btn btn-small",
424
+ type: "button",
425
+ onclick: () => {
426
+ const at = Math.min(LOOKS.findIndex((x) => x.id === l.id), draft.length);
427
+ draft = [...draft.slice(0, at), ...clone([l]), ...draft.slice(at)];
428
+ redraw();
429
+ },
430
+ },
431
+ "Put back",
432
+ ),
433
+ );
434
+ }
435
+
436
+ function openCard(l: Look | null) {
437
+ open = l;
438
+ redraw();
439
+ const card = el.querySelector<HTMLElement>(".shelf-editing");
440
+ if (card) {
441
+ card.focus({ preventScroll: true });
442
+ card.scrollIntoView?.({ block: "nearest" });
443
+ }
444
+ }
445
+
446
+ function editCard(l: Look, i: number, st: Map<string, LookStats>, own: Set<string>, mine: Problem[], mark: "new" | "changed" | null): HTMLElement {
447
+ const pic = h("div", { class: "shelf-pic" }, pictureOf(l));
448
+ const stageBox = h("div", { class: "shelf-stage is-big" }, pic);
449
+ const nameLine = h("strong", { class: "shelf-name" }, l.name || "(no name)");
450
+ const priceLine = h("span", { class: "shelf-price" }, priceWords(l.priceUsd));
451
+ const tint = () => (COLOR_PATTERN.test(l.change.body) ? stageBox.style.setProperty("--tint", l.change.body) : stageBox.style.removeProperty("--tint"));
452
+ tint();
453
+ // The real picture is drawn a moment after the last change: a colour dragged across the picker draws once, not at every step.
454
+ let later: ReturnType<typeof setTimeout> | null = null;
192
455
  const sync = () => {
193
- pic.replaceChildren(picture(l));
194
- const r = rungOf(l.priceUsd);
195
- priceLine.textContent = Number.isFinite(l.priceUsd) ? (l.priceUsd === 0 ? "Free" : `${priceText(l.priceUsd)}${r ? ` · ${r.name}` : ""}`) : "No price";
196
- top.querySelector("strong")!.textContent = l.name || "(no name)";
456
+ nameLine.textContent = l.name || "(no name)";
457
+ priceLine.textContent = priceWords(l.priceUsd);
458
+ tint();
459
+ if (later) clearTimeout(later);
460
+ later = setTimeout(() => pic.replaceChildren(pictureOf(l)), 150);
461
+ ladder.querySelectorAll<HTMLButtonElement>("button").forEach((c) => c.setAttribute("aria-pressed", Number(c.dataset.usd) === l.priceUsd ? "true" : "false"));
462
+ swatches.querySelectorAll<HTMLButtonElement>("button").forEach((c) => c.setAttribute("aria-pressed", c.dataset.colour === l.change.body.toLowerCase() ? "true" : "false"));
463
+ hatChoices.style.setProperty("color", l.change.hatColor ?? "#9aa4b2");
197
464
  refreshFoot();
198
465
  };
199
- const name = h("input", { class: "shelf-input", type: "text", maxlength: 40, value: l.name, "aria-label": `Name of look ${i + 1}`, oninput: (e: Event) => {
466
+
467
+ const name = h("input", { class: "input", type: "text", maxlength: 40, value: l.name, "aria-label": `Name of look ${i + 1}`, oninput: (e: Event) => {
200
468
  l.name = (e.target as HTMLInputElement).value;
201
469
  if (!isOriginal(l.id)) l.id = slugId(l.name, [...draft.filter((x) => x !== l).map((x) => x.id), ...owned()]);
202
470
  sync();
203
471
  } });
204
- const priceInput = h("input", { class: "shelf-input shelf-num", type: "number", step: 0.01, min: 0, max: 1000, inputmode: "decimal", value: Number.isFinite(l.priceUsd) ? l.priceUsd : "", "aria-label": `Price in US dollars of ${l.name}`, oninput: (e: Event) => {
472
+
473
+ const priceInput = h("input", { class: "input input-num", type: "number", step: 0.01, min: 0, max: 1000, inputmode: "decimal", value: Number.isFinite(l.priceUsd) ? l.priceUsd : "", "aria-label": `Price in US dollars of ${l.name}`, oninput: (e: Event) => {
205
474
  const v = (e.target as HTMLInputElement).value;
206
475
  l.priceUsd = v === "" ? Number.NaN : Number(v);
207
476
  sync();
208
477
  } });
209
- const ladder = h("div", { class: "shelf-ladder", role: "group", "aria-label": "Price ladder" });
478
+ const ladder = h("div", { class: "ladder", role: "group", "aria-label": "Price ladder" });
210
479
  const setPrice = (usd: number) => {
211
480
  l.priceUsd = usd;
212
481
  priceInput.value = String(usd);
213
482
  sync();
214
483
  };
215
- ladder.append(h("button", { class: "chip", type: "button", onclick: () => setPrice(0) }, "Free"));
216
- for (const r of PRICE_LADDER) ladder.append(h("button", { class: "chip", type: "button", title: `${r.name}: ${r.for}`, onclick: () => setPrice(rungUsd(r)) }, priceText(rungUsd(r))));
217
- const body = h("input", { class: "shelf-color", type: "color", value: l.change.body, "aria-label": `Body colour of ${l.name}`, oninput: (e: Event) => {
484
+ const step = (usd: number, price: string, word: string, title: string) =>
485
+ h("button", { class: "ladder-step", type: "button", title, "data-usd": usd, "aria-pressed": l.priceUsd === usd ? "true" : "false", onclick: () => setPrice(usd) }, h("strong", {}, price), h("span", {}, word));
486
+ ladder.append(step(0, "Free", "everyone", "Every player has it"));
487
+ for (const r of PRICE_LADDER) ladder.append(step(rungUsd(r), priceText(rungUsd(r)), r.name, `${r.name}: ${r.for}`));
488
+
489
+ const body = h("input", { class: "colour", type: "color", value: l.change.body, "aria-label": `Body colour of ${l.name}`, oninput: (e: Event) => {
218
490
  l.change.body = (e.target as HTMLInputElement).value;
219
491
  sync();
220
492
  } });
221
- const hatColor = h("input", { class: "shelf-color", type: "color", value: l.change.hatColor ?? "#1c6e4f", "aria-label": `Hat colour of ${l.name}`, disabled: !l.change.hat, oninput: (e: Event) => {
493
+ const swatches = h("div", { class: "swatches", role: "group", "aria-label": "Body colours" });
494
+ for (const c of BODY_COLOURS) {
495
+ const b = h("button", { class: "swatch-btn", type: "button", "data-colour": c, title: c, "aria-label": `Body colour ${c}`, "aria-pressed": c === l.change.body.toLowerCase() ? "true" : "false", onclick: () => {
496
+ l.change.body = c;
497
+ body.value = c;
498
+ sync();
499
+ } }, h("span", { class: "swatch", "aria-hidden": "true" }));
500
+ (b.firstChild as HTMLElement).style.setProperty("--swatch", c);
501
+ swatches.append(b);
502
+ }
503
+
504
+ const hatColor = h("input", { class: "colour", type: "color", value: l.change.hatColor ?? "#1c6e4f", "aria-label": `Hat colour of ${l.name}`, disabled: !l.change.hat, oninput: (e: Event) => {
222
505
  l.change.hatColor = (e.target as HTMLInputElement).value;
223
506
  sync();
224
507
  } });
225
- const hat = h("select", { class: "shelf-input", "aria-label": `Hat of ${l.name}`, onchange: (e: Event) => {
226
- const v = (e.target as HTMLSelectElement).value;
227
- if (v === "") {
228
- delete l.change.hat;
229
- delete l.change.hatColor;
230
- hatColor.disabled = true;
231
- } else {
232
- l.change.hat = v as Hat;
233
- l.change.hatColor ??= hatColor.value;
234
- hatColor.disabled = false;
235
- }
236
- sync();
237
- } }, h("option", { value: "" }, "No hat"), ...HATS.map((x) => h("option", { value: x }, x)));
238
- hat.value = l.change.hat ?? "";
508
+ const hatName = `n133-hat-${i}`;
509
+ const hatChoices = h("div", { class: "hat-choices", role: "radiogroup", "aria-label": `Hat of ${l.name}` });
510
+ for (const x of [null, ...HATS] as (Hat | null)[]) {
511
+ const input = h("input", { type: "radio", name: hatName, value: x ?? "", checked: (l.change.hat ?? null) === x, onchange: () => {
512
+ if (x === null) {
513
+ delete l.change.hat;
514
+ delete l.change.hatColor;
515
+ hatColor.disabled = true;
516
+ } else {
517
+ l.change.hat = x;
518
+ l.change.hatColor ??= hatColor.value;
519
+ hatColor.disabled = false;
520
+ }
521
+ hatChoices.querySelectorAll(".hat-choice").forEach((c) => c.classList.toggle("is-on", (c.querySelector("input") as HTMLInputElement).checked));
522
+ sync();
523
+ } });
524
+ hatChoices.append(h("label", { class: `hat-choice${(l.change.hat ?? null) === x ? " is-on" : ""}` }, input, hatChoicePicture(x), h("span", {}, x ? HAT_WORDS[x] : "No hat")));
525
+ }
526
+ hatChoices.style.setProperty("color", l.change.hatColor ?? "#9aa4b2");
239
527
 
240
528
  const lastFree = l.priceUsd === 0 && freeCount() === 1;
241
529
  const keep = isOriginal(l.id) && own.has(l.id);
242
- const remove = h("button", { class: "btn", type: "button", disabled: keep || lastFree, title: keep ? "A player owns this look. Keep it in the file." : lastFree ? "Keep one free look: every player starts with it." : "", onclick: () => ((draft = draft.filter((x) => x !== l)), redraw()) }, "Remove");
243
- const dup = h("button", { class: "btn", type: "button", onclick: () => addLook(l) }, "Duplicate");
530
+ const remove = h("button", { class: "btn btn-danger", type: "button", disabled: keep || lastFree, title: keep ? "A player owns this look. Keep it in the file." : lastFree ? "Keep one free look: every player starts with it." : "", onclick: () => {
531
+ draft = draft.filter((x) => x !== l);
532
+ open = null;
533
+ redraw();
534
+ } }, icon("trash", 18), "Remove");
535
+ const dup = h("button", { class: "btn", type: "button", onclick: () => addLook(l) }, icon("dup", 18), "Duplicate");
244
536
 
537
+ const openMarks = marks(l, own, mine, mark);
538
+ const openProblems = h("ul", { class: "card-problems" }, ...mine.map((p) => h("li", {}, p.message)));
539
+ openProblems.hidden = !mine.length;
540
+ openParts = { marks: openMarks, problems: openProblems };
541
+ const group = (title: string, ...kids: (Node | null)[]) => h("div", { class: "edit-group" }, h("h3", {}, title), ...kids);
245
542
  return h(
246
543
  "article",
247
- { class: "shelf-card shelf-editing" },
248
- top,
249
- h("label", { class: "shelf-field" }, h("span", {}, "Name"), name),
250
- h("div", { class: "shelf-field" }, h("span", {}, "Price in US dollars"), priceInput, ladder),
251
- h("div", { class: "shelf-colors" }, h("label", { class: "shelf-field" }, h("span", {}, "Body colour"), body), h("label", { class: "shelf-field" }, h("span", {}, "Hat"), hat), h("label", { class: "shelf-field" }, h("span", {}, "Hat colour"), hatColor)),
252
- slotsEditor(l, isOriginal(l.id) && own.has(l.id)),
253
- keep ? h("p", { class: "fine" }, "A player owns this look: its id stays, and it cannot be removed.") : null,
254
- h("div", { class: "shelf-actions" }, dup, " ", remove),
544
+ { class: `shelf-card shelf-editing${mark ? ` is-${mark}` : ""}${mine.length ? " has-problem" : ""}`, tabindex: -1, "aria-label": `Editing ${l.name || "a look"}` },
545
+ h(
546
+ "div",
547
+ { class: "edit-top" },
548
+ stageBox,
549
+ h(
550
+ "div",
551
+ { class: "shelf-body" },
552
+ nameLine,
553
+ priceLine,
554
+ h("span", { class: "fine" }, isOriginal(l.id) ? (l.priceUsd === 0 ? "Every player starts with it" : soldText(st.get(l.id))) : "New: not in your game yet"),
555
+ openMarks,
556
+ ),
557
+ h("button", { class: "btn btn-small card-done", type: "button", "aria-expanded": "true", onclick: () => openCard(null) }, icon("check", 16), "Done"),
558
+ ),
559
+ openProblems,
560
+ h(
561
+ "div",
562
+ { class: "edit-groups" },
563
+ group("Name", h("label", { class: "field" }, h("span", {}, "What players read"), name)),
564
+ group("Price", h("label", { class: "field" }, h("span", {}, "Price in US dollars"), priceInput), ladder),
565
+ h(
566
+ "div",
567
+ { class: "edit-group group-look" },
568
+ h("h3", {}, "Look"),
569
+ h(
570
+ "div",
571
+ { class: "look-fields" },
572
+ h("div", { class: "field" }, h("span", {}, "Body colour"), h("div", { class: "colour-row" }, swatches, body)),
573
+ h("div", { class: "field" }, h("span", {}, "Hat"), hatChoices),
574
+ h("label", { class: "field" }, h("span", {}, "Hat colour"), hatColor),
575
+ ),
576
+ ),
577
+ slotsEditor(l, isOriginal(l.id) && own.has(l.id)),
578
+ ),
579
+ keep ? h("p", { class: "fine owned-note" }, icon("lock", 14), "A player owns this look: its id stays, and it cannot be removed.") : null,
580
+ h("div", { class: "shelf-actions" }, dup, remove),
255
581
  );
256
582
  }
257
583
 
@@ -270,9 +596,9 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
270
596
  h(
271
597
  "div",
272
598
  { class: "shelf-slot" },
273
- h("input", { class: "shelf-input", type: "text", maxlength: 24, value: name, placeholder: "name", autocapitalize: "none", spellcheck: false, "aria-label": `Name of slot ${r + 1} of ${l.name}`, oninput: edit(0) }),
274
- h("input", { class: "shelf-input", type: "text", maxlength: 40, value: value, placeholder: "word or #rrggbb", autocapitalize: "none", spellcheck: false, "aria-label": `Value of slot ${r + 1} of ${l.name}`, oninput: edit(1) }),
275
- h("button", { class: "btn btn-small", type: "button", "aria-label": `Remove slot ${name || r + 1} of ${l.name}`, onclick: () => (setRows(l, rowsOf(l).filter((_, i) => i !== r)), redraw()) }, "Remove"),
599
+ h("input", { class: "input", type: "text", maxlength: 24, value: name, placeholder: "name", autocapitalize: "none", spellcheck: false, "aria-label": `Name of slot ${r + 1} of ${l.name}`, oninput: edit(0) }),
600
+ h("input", { class: "input", type: "text", maxlength: 40, value: value, placeholder: "word or #rrggbb", autocapitalize: "none", spellcheck: false, "aria-label": `Value of slot ${r + 1} of ${l.name}`, oninput: edit(1) }),
601
+ h("button", { class: "btn btn-small btn-quiet", type: "button", "aria-label": `Remove slot ${name || r + 1} of ${l.name}`, onclick: () => (setRows(l, rowsOf(l).filter((_, i) => i !== r)), redraw()) }, "Remove"),
276
602
  ),
277
603
  );
278
604
  });
@@ -282,12 +608,13 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
282
608
  while (cur.some(([k]) => k === `slot-${n}`)) n++;
283
609
  setRows(l, [...cur, [`slot-${n}`, ""]]);
284
610
  redraw();
285
- } }, "Add a slot");
611
+ } }, icon("plus", 16), "Add a slot");
286
612
  return h(
287
613
  "div",
288
- { class: "shelf-field shelf-slots-edit" },
289
- h("span", {}, `Slots (${rows.length} of ${SLOTS_MAX}): words and colours that your game reads`),
290
- list,
614
+ { class: "edit-group shelf-slots-edit" },
615
+ h("h3", {}, "Slots"),
616
+ h("span", { class: "fine" }, `Slots (${rows.length} of ${SLOTS_MAX}): words and colours that your game reads`),
617
+ rows.length ? list : null,
291
618
  add,
292
619
  owned ? h("p", { class: "fine" }, "A player owns this look: its slots cannot change. Add a new look with the new slots instead.") : null,
293
620
  );
@@ -298,29 +625,65 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
298
625
  const base: Look = from ? { id: "", name, priceUsd: from.priceUsd, change: { ...from.change }, ...(from.slots ? { slots: { ...from.slots } } : {}) } : { id: "", name, priceUsd: 0.05, change: { body: "#4aa8ff" } };
299
626
  // An id that a player owns is never given to a new look (even when that look is not in looks.json now): the new look would be what the owners own.
300
627
  base.id = slugId(name, [...draft.map((x) => x.id), ...owned()]);
301
- draft = [...draft, base];
302
- redraw();
303
- el.querySelectorAll<HTMLInputElement>(".shelf-card:last-of-type input[type=text]").forEach((i) => i.focus());
628
+ const at = from ? draft.indexOf(from) + 1 : draft.length;
629
+ draft = [...draft.slice(0, at), base, ...draft.slice(at)];
630
+ openCard(base);
631
+ el.querySelector<HTMLInputElement>(".shelf-editing input[type=text]")?.focus();
304
632
  }
305
633
 
306
- const footBox = h("div", { class: "shelf-foot" });
307
- const foot = () => {
634
+ // ---- The change bar ----
635
+ //
636
+ // Two parts. The bar that stays at the bottom of the screen while the Shelf is edited holds only what is needed at every moment: the state of the draft
637
+ // and the two copy keys (on a phone it is about 110 px high). The rest comes after it in the page: the problems in full, GitHub, Start over, what changed,
638
+ // the new file.
639
+
640
+ const footBar = h("div", { class: "shelf-foot", role: "region", "aria-label": "Your draft" });
641
+ const footMore = h("div", { class: "shelf-foot-more" });
642
+ const foot = (): Node[] => {
308
643
  refreshFoot();
309
- return footBox;
644
+ return [footBar, footMore];
310
645
  };
311
646
 
647
+ /** "1 changed", "2 added, 1 removed": only the parts that are not zero. */
648
+ const shortChange = (d: ReturnType<typeof diffLooks>) =>
649
+ [d.added.length && `${d.added.length} added`, d.changed.length && `${d.changed.length} changed`, d.removed.length && `${d.removed.length} removed`].filter(Boolean).join(", ");
650
+
312
651
  function refreshFoot() {
313
652
  const bad = problems();
314
653
  const diff = diffLooks(LOOKS, draft);
315
654
  const changed = diff.added.length + diff.removed.length + diff.changed.length > 0;
316
655
  const text = bad.length ? "" : looksJson(draft);
656
+ const ready = !bad.length && changed;
657
+ footBar.replaceChildren(
658
+ h(
659
+ "div",
660
+ { class: "foot-status" },
661
+ h("strong", {}, changed ? shortChange(diff) : "No change yet"),
662
+ bad.length ? h("span", { class: "mark mark-bad" }, `${plural(bad.length, "problem")} to fix`) : changed ? h("span", { class: "mark mark-ok" }, icon("check", 14), "Ready to copy") : null,
663
+ ),
664
+ h(
665
+ "div",
666
+ { class: "foot-keys" },
667
+ h("button", { class: "btn btn-primary", type: "button", disabled: !ready, onclick: (e: Event) => void copy(text, e.currentTarget as HTMLButtonElement) }, "Copy looks.json"),
668
+ h("button", { class: "btn", type: "button", title: "Copy a message for your AI assistant: the new looks.json, what changed and the rules", disabled: !ready, onclick: (e: Event) => void copy(aiPrompt(text, diff, [...owned()].sort(), LOOKS), e.currentTarget as HTMLButtonElement) }, "Copy for my AI"),
669
+ ),
670
+ );
317
671
  const out: (Node | null)[] = [];
318
- if (bad.length) out.push(h("div", { class: "late-note", role: "alert" }, h("strong", {}, "Fix these first"), h("ul", {}, ...bad.map((p) => h("li", {}, p.message)))));
672
+ if (bad.length) out.push(h("div", { class: "late-note foot-problems", role: "alert" }, h("strong", {}, "Fix these first"), h("ul", {}, ...bad.map((p) => h("li", {}, p.message)))));
319
673
  out.push(
320
674
  h(
321
675
  "div",
322
- { class: "shelf-changes" },
323
- h("strong", {}, changed ? changeText(diff) : "No change yet"),
676
+ { class: "foot-links" },
677
+ data.repo ? h("a", { class: "btn btn-small", href: `https://github.com/${data.repo}/edit/main/looks.json`, target: "_blank", rel: "noopener noreferrer" }, "Open looks.json on GitHub", icon("external", 16)) : null,
678
+ h("button", { class: "btn btn-small btn-quiet", type: "button", disabled: !changedFromGame(), onclick: () => ((draft = clone(LOOKS)), (open = null), redraw()) }, "Start over"),
679
+ ),
680
+ );
681
+ if (!data.repo) out.push(h("p", { class: "fine" }, repoHelp));
682
+ out.push(
683
+ h(
684
+ "details",
685
+ { class: "foot-more shelf-changes" },
686
+ h("summary", {}, `What changed: ${changed ? changeText(diff) : "No change yet"}`),
324
687
  changed
325
688
  ? h(
326
689
  "ul",
@@ -332,22 +695,17 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
332
695
  : null,
333
696
  ),
334
697
  );
335
- const ready = !bad.length && changed;
336
- const copyJson = h("button", { class: "btn btn-primary", type: "button", disabled: !ready, onclick: (e: Event) => void copy(text, e.currentTarget as HTMLButtonElement) }, "Copy looks.json");
337
- const copyAi = h("button", { class: "btn", type: "button", disabled: !ready, onclick: (e: Event) => void copy(aiPrompt(text, diff, [...owned()].sort(), LOOKS), e.currentTarget as HTMLButtonElement) }, "Copy a message for my AI");
338
- out.push(
339
- h(
340
- "div",
341
- { class: "shelf-actions" },
342
- copyJson,
343
- " ",
344
- copyAi,
345
- data.repo ? h("a", { class: "btn", href: `https://github.com/${data.repo}/edit/main/looks.json`, target: "_blank", rel: "noopener noreferrer" }, "Open looks.json on GitHub") : null,
346
- ),
347
- !data.repo ? h("p", { class: "fine" }, repoHelp) : null,
348
- ready ? h("details", { class: "shelf-file" }, h("summary", {}, "Show the new looks.json"), h("pre", {}, text)) : null,
349
- );
350
- footBox.replaceChildren(...out.filter((n): n is Node => n !== null));
698
+ if (ready) out.push(h("details", { class: "foot-more shelf-file" }, h("summary", {}, "Show the new looks.json"), h("pre", {}, text)));
699
+ footMore.replaceChildren(...out.filter((n): n is Node => n !== null));
700
+ // The open card's own marks and problems, from the same check.
701
+ if (open && openParts) {
702
+ const at = draft.indexOf(open) + 1;
703
+ const mine = bad.filter((p) => p.look === at);
704
+ const mark = diff.added.some((l) => l === open) ? "new" : diff.changed.some((c) => c.after === open) ? "changed" : null;
705
+ openParts.marks.replaceChildren(...markList(open, owned(), mine, mark).filter((n): n is Node => n !== null));
706
+ openParts.problems.replaceChildren(...mine.map((p) => h("li", {}, p.message)));
707
+ openParts.problems.hidden = !mine.length;
708
+ }
351
709
  }
352
710
 
353
711
  redraw();