@nano133/nanoterra 0.3.2 → 0.5.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +51 -7
  3. package/ai/SPEC.md +2 -1
  4. package/dist/admin/panel/dom.d.ts +11 -0
  5. package/dist/admin/panel/dom.js +33 -0
  6. package/dist/admin/panel/gameCard.d.ts +5 -2
  7. package/dist/admin/panel/gameCard.js +8 -4
  8. package/dist/admin/panel/icons.d.ts +121 -0
  9. package/dist/admin/panel/icons.js +41 -0
  10. package/dist/admin/panel/index.d.ts +11 -2
  11. package/dist/admin/panel/index.js +6 -1
  12. package/dist/admin/panel/mount.js +101 -67
  13. package/dist/admin/panel/orders.d.ts +23 -0
  14. package/dist/admin/panel/orders.js +130 -0
  15. package/dist/admin/panel/overview.d.ts +22 -0
  16. package/dist/admin/panel/overview.js +75 -0
  17. package/dist/admin/panel/players.d.ts +7 -0
  18. package/dist/admin/panel/players.js +61 -0
  19. package/dist/admin/panel/shelf.d.ts +15 -3
  20. package/dist/admin/panel/shelf.js +385 -84
  21. package/dist/admin/panel/shelfData.js +1 -1
  22. package/dist/admin/panel/tabs.d.ts +23 -0
  23. package/dist/admin/panel/tabs.js +68 -0
  24. package/dist/looks/change.d.ts +5 -5
  25. package/dist/looks/change.js +12 -7
  26. package/dist/looks/index.d.ts +2 -2
  27. package/dist/looks/index.js +1 -1
  28. package/dist/looks/json.d.ts +3 -2
  29. package/dist/looks/json.js +16 -9
  30. package/dist/looks/look.d.ts +26 -6
  31. package/dist/looks/look.js +37 -12
  32. package/package.json +1 -1
  33. package/src/admin/panel/dom.ts +34 -0
  34. package/src/admin/panel/gameCard.ts +8 -4
  35. package/src/admin/panel/icons.ts +46 -0
  36. package/src/admin/panel/index.ts +11 -2
  37. package/src/admin/panel/mount.ts +120 -101
  38. package/src/admin/panel/orders.ts +172 -0
  39. package/src/admin/panel/overview.ts +134 -0
  40. package/src/admin/panel/players.ts +79 -0
  41. package/src/admin/panel/shelf.ts +549 -121
  42. package/src/admin/panel/shelfData.ts +1 -1
  43. package/src/admin/panel/style.css +1775 -270
  44. package/src/admin/panel/tabs.ts +94 -0
  45. package/src/looks/change.ts +12 -9
  46. package/src/looks/index.ts +2 -2
  47. package/src/looks/json.ts +12 -7
  48. package/src/looks/look.ts +47 -9
@@ -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, isOutfit, lookProblems, OUTFIT, SLOT_NAME_PATTERN, usdText, wearOf, type Change, 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,35 @@ 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
 
25
- /** 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.) */
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
+
41
+ /**
42
+ * A small flat picture of a look: an outfit's body in its colour and its hat in its colour; an item of another wear slot as a tag with the slot's name (the game
43
+ * knows what the item is). (The game draws the real thing; a game can give its own picture.)
44
+ */
26
45
  export function lookPicture(l: Look): Element {
46
+ if (!isOutfit(l)) {
47
+ const wear = wearOf(l);
48
+ const label = svg("text", { x: 33, y: 40, "font-size": 9, "font-family": "system-ui, sans-serif", "text-anchor": "middle", fill: INK });
49
+ label.textContent = wear.slice(0, 8);
50
+ return svg(
51
+ "svg",
52
+ { viewBox: "0 0 64 72", width: 56, height: 63, role: "img", "aria-label": `${l.name}: worn in the ${wear} slot` },
53
+ shape("path", { d: "M14 20 L36 20 L52 36 L36 52 L14 52 Z", fill: "#f6e7c1" }),
54
+ shape("circle", { cx: 22, cy: 36, r: 3.5, fill: "#ffffff" }),
55
+ label,
56
+ );
57
+ }
27
58
  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
59
  return svg(
36
60
  "svg",
37
61
  { viewBox: "0 0 64 72", width: 56, height: 63, role: "img", "aria-label": `${l.name}: ${l.change.hat ?? "no hat"}` },
@@ -41,10 +65,17 @@ export function lookPicture(l: Look): Element {
41
65
  shape("circle", { cx: 32, cy: 26, r: 13, fill: "#f2c9a0" }),
42
66
  svg("circle", { cx: 27, cy: 27, r: 1.7, fill: INK }),
43
67
  svg("circle", { cx: 37, cy: 27, r: 1.7, fill: INK }),
44
- ...(hat ? hats[hat] : []),
68
+ ...(hat ? hatShapes(hat, l.change.hatColor ?? "#444444") : []),
45
69
  );
46
70
  }
47
71
 
72
+ /** 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. */
73
+ function hatChoicePicture(hat: Hat | null): Element {
74
+ const head = shape("circle", { cx: 32, cy: 30, r: 12, fill: "#f2c9a0" });
75
+ 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" })];
76
+ return svg("svg", { viewBox: "0 0 64 46", width: 46, height: 33, "aria-hidden": "true", focusable: "false" }, ...kids);
77
+ }
78
+
48
79
  /**
49
80
  * 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
81
  * 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
@@ -56,7 +87,7 @@ export function previewPicture(load: () => Promise<(l: Look) => Promise<string |
56
87
  return (l) => {
57
88
  const box = h("span", { class: "shelf-preview" }, fallback(l));
58
89
  ready ??= load().catch(() => null);
59
- const look: Look = { ...l, change: { ...l.change }, ...(l.slots ? { slots: { ...l.slots } } : {}) };
90
+ const look = copyLook(l);
60
91
  void ready
61
92
  .then((render) => (render ? render(look) : null))
62
93
  .then((src) => {
@@ -69,10 +100,9 @@ export function previewPicture(load: () => Promise<(l: Look) => Promise<string |
69
100
  };
70
101
  }
71
102
 
72
- 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
-
74
- /** "wings ember, trail #ffcf5a" */
75
- const slotsText = (l: Look): string => Object.entries(l.slots ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
103
+ /** A copy of a look that shares nothing with it: its wear slot, an outfit's change, its slots. */
104
+ const copyLook = (l: Look): Look => ({ id: l.id, name: l.name, priceUsd: l.priceUsd, ...(l.wear !== undefined ? { wear: l.wear } : {}), ...(l.change ? { change: { ...l.change } } : {}), ...(l.slots ? { slots: { ...l.slots } } : {}) });
105
+ const clone = (ls: readonly Look[]): Look[] => ls.map(copyLook);
76
106
 
77
107
  /** The names of the slots that differ between two looks (added, removed or another value). */
78
108
  function slotNamesChanged(before: Look, after: Look): string {
@@ -85,28 +115,124 @@ function fieldWords(d: ReturnType<typeof diffLooks>["changed"][number]): string
85
115
  if (f === "name") return `name "${d.before.name}" to "${d.after.name}"`;
86
116
  if (f === "priceUsd") return `price ${usdText(d.before.priceUsd)} to ${usdText(d.after.priceUsd)}`;
87
117
  if (f === "body") return "body colour";
88
- if (f === "hat") return `hat ${d.before.change.hat ?? "none"} to ${d.after.change.hat ?? "none"}`;
118
+ if (f === "hat") return `hat ${d.before.change?.hat ?? "none"} to ${d.after.change?.hat ?? "none"}`;
119
+ if (f === "wear") return `wear slot ${wearOf(d.before)} to ${wearOf(d.after)}`;
89
120
  if (f === "slots") return `slots changed (${slotNamesChanged(d.before, d.after)})`;
90
121
  return "hat colour";
91
122
  });
92
123
  return `${d.after.name}: ${parts.join(", ")}`;
93
124
  }
94
125
 
126
+ /** 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". */
127
+ function priceWords(usd: number): string {
128
+ if (!Number.isFinite(usd)) return "No price";
129
+ if (usd === 0) return "Free";
130
+ const r = rungOf(usd);
131
+ return `${priceText(usd)}${r ? ` · ${r.name}` : ""}`;
132
+ }
133
+
134
+ /** The colours the body colour offers in one press (any colour is good: the colour field is next to them). */
135
+ export const BODY_COLOURS = ["#4aa8ff", "#3ddc97", "#ffcf5a", "#ff7a3d", "#ff5c8a", "#9b6bff", "#2b2d42", "#f4f1ea"] as const;
136
+
137
+ /** A hat's name as the editor writes it. */
138
+ const HAT_WORDS: Record<Hat, string> = { cap: "Cap", beanie: "Beanie", crown: "Crown", tophat: "Top hat" };
139
+
140
+ export type ShelfFilter = "all" | "sale" | "free" | "unsold";
141
+ export type ShelfSort = "best" | "price-desc" | "price-asc" | "name" | "file";
142
+
143
+ const FILTERS: readonly { id: ShelfFilter; label: string }[] = [
144
+ { id: "all", label: "All" },
145
+ { id: "sale", label: "For sale" },
146
+ { id: "free", label: "Free" },
147
+ { id: "unsold", label: "Not sold yet" },
148
+ ];
149
+ const SORTS: readonly { id: ShelfSort; label: string }[] = [
150
+ { id: "best", label: "Best selling" },
151
+ { id: "price-desc", label: "Price: high to low" },
152
+ { id: "price-asc", label: "Price: low to high" },
153
+ { id: "name", label: "Name" },
154
+ { id: "file", label: "File order" },
155
+ ];
156
+
157
+ /** 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. */
158
+ export function shelfFilter(l: Look, f: ShelfFilter, s: LookStats | undefined): boolean {
159
+ if (f === "sale") return l.priceUsd > 0;
160
+ if (f === "free") return l.priceUsd === 0;
161
+ if (f === "unsold") return l.priceUsd > 0 && !s?.paid && !s?.owners;
162
+ return true;
163
+ }
164
+
165
+ /** The looks in a sort's order (a new list; equal looks keep the file's order). Pure, for the screen and its tests. */
166
+ export function shelfSort(ls: readonly Look[], by: ShelfSort, stats: Map<string, LookStats>): Look[] {
167
+ const place = new Map(ls.map((l, i) => [l, i]));
168
+ const cents = (l: Look) => stats.get(l.id)?.paidCents ?? 0;
169
+ const owners = (l: Look) => stats.get(l.id)?.owners ?? 0;
170
+ const cmp: Record<ShelfSort, (a: Look, b: Look) => number> = {
171
+ best: (a, b) => cents(b) - cents(a) || owners(b) - owners(a),
172
+ "price-desc": (a, b) => b.priceUsd - a.priceUsd,
173
+ "price-asc": (a, b) => a.priceUsd - b.priceUsd,
174
+ name: (a, b) => a.name.localeCompare(b.name, "en", { sensitivity: "base" }),
175
+ file: () => 0,
176
+ };
177
+ return [...ls].sort((a, b) => cmp[by](a, b) || place.get(a)! - place.get(b)!);
178
+ }
179
+
180
+ /** A slot's value as a chip: a colour gets a swatch. */
181
+ function slotChip(name: string, value: string): HTMLElement {
182
+ const sw = COLOR_PATTERN.test(value) ? h("span", { class: "swatch", "aria-hidden": "true" }) : null;
183
+ sw?.style.setProperty("--swatch", value);
184
+ return h("li", { class: "slot-chip" }, sw, h("span", { class: "slot-name" }, name), " ", h("span", { class: "slot-value" }, value));
185
+ }
186
+
95
187
  /**
96
188
  * 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.
189
+ * 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
190
+ * what to tell a game that has no repository set.
98
191
  */
99
192
  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
193
  const LOOKS = looks;
101
194
  let data = first;
102
195
  let draft = clone(LOOKS);
103
196
  let editing = false;
197
+ /** The one card that is open in the editor (a look of the draft), or none. */
198
+ let open: Look | null = null;
199
+ /** 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). */
200
+ let openParts: { marks: HTMLElement; problems: HTMLElement } | null = null;
201
+ let filter: ShelfFilter = "all";
202
+ let sort: ShelfSort = "best";
104
203
  const el = h("section", { class: "admin-card shelf", "aria-labelledby": "shelf-title" });
105
204
 
106
205
  const stats = () => shelfStats(data.orders, data.players);
107
206
  const owned = () => ownedIds(data.players);
108
207
  const isOriginal = (id: string) => LOOKS.some((l) => l.id === id);
109
- const freeCount = () => draft.filter((l) => l.priceUsd === 0).length;
208
+ /** Free outfits in the draft: one must stay (every player starts with it). */
209
+ const freeCount = () => draft.filter((l) => l.priceUsd === 0 && wearOf(l) === OUTFIT).length;
210
+ /** The wear slots that the draft uses besides the outfit, for the wear slot field's suggestions. */
211
+ const wearSlots = () => [...new Set(draft.map(wearOf).filter((w) => w !== OUTFIT))].sort();
212
+ /** What a card says under the price: who has it (a free outfit), what it sold, or that it is new. */
213
+ const fineWords = (l: Look, s: LookStats | undefined) => (l.priceUsd === 0 && wearOf(l) === OUTFIT ? "Every player starts with it" : soldText(s));
214
+ /** 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). */
215
+ const changedFromGame = () => looksJson(draft) !== looksJson(LOOKS);
216
+
217
+ /**
218
+ * 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,
219
+ * and the card does not flash its flat picture first). Pictures that the last redraw did not use are let go.
220
+ */
221
+ const pictures = new Map<string, Element>();
222
+ let used = new Set<string>();
223
+ function pictureOf(l: Look): Element {
224
+ const key = JSON.stringify([l.id, l.name, l.priceUsd, l.wear ?? null, l.change ?? null, l.slots ?? null]);
225
+ const kept = pictures.get(key);
226
+ if (kept && !used.has(key)) {
227
+ used.add(key);
228
+ return kept;
229
+ }
230
+ const made = picture(l);
231
+ pictures.set(key, made);
232
+ used.add(key);
233
+ return made;
234
+ }
235
+
110
236
  /**
111
237
  * 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
238
  * 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 +260,17 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
134
260
  }
135
261
 
136
262
  function redraw() {
263
+ used = new Set();
264
+ openParts = null;
137
265
  const st = stats();
138
266
  const own = owned();
267
+ const forSale = LOOKS.filter((l) => l.priceUsd > 0).length;
268
+ const earned = [...st.values()].reduce((n, s) => n + s.paidCents, 0);
269
+
139
270
  const head = h(
140
271
  "div",
141
272
  { class: "card-head" },
142
- h("h2", { id: "shelf-title" }, "Shelf"),
273
+ 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
274
  h(
144
275
  "button",
145
276
  {
@@ -151,108 +282,373 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
151
282
  redraw();
152
283
  },
153
284
  },
285
+ editing ? icon("close", 18) : icon("pencil", 18),
154
286
  editing ? "Close the editor" : "Edit the shelf",
155
287
  ),
156
288
  );
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];
289
+ const parts: (Node | null)[] = [head];
168
290
 
169
- if (editing) {
291
+ if (!editing) {
170
292
  parts.push(
171
293
  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"),
294
+ "ul",
295
+ { class: "shelf-stats", "aria-label": "The shelf in numbers" },
296
+ h("li", {}, h("strong", {}, String(LOOKS.length)), ` ${LOOKS.length === 1 ? "look" : "looks"}`),
297
+ h("li", {}, h("strong", {}, String(forSale)), " for sale"),
298
+ h("li", {}, h("strong", {}, String(LOOKS.length - forSale)), " free"),
299
+ h("li", {}, h("strong", {}, dollars(earned)), " earned"),
177
300
  ),
178
- foot(),
301
+ );
302
+ if (changedFromGame()) {
303
+ const diff = diffLooks(LOOKS, draft);
304
+ parts.push(
305
+ h(
306
+ "div",
307
+ { class: "draft-note", role: "status" },
308
+ icon("pencil", 18),
309
+ h("span", {}, `You have a draft that is not in your game yet: ${changeText(diff)}.`),
310
+ h("button", { class: "btn btn-small", type: "button", onclick: () => ((editing = true), redraw()) }, "Open the draft"),
311
+ ),
312
+ );
313
+ }
314
+ parts.push(toolbar(st), viewGrid(st));
315
+ } else {
316
+ parts.push(
317
+ 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."),
318
+ editGrid(st, own),
319
+ ...foot(),
179
320
  );
180
321
  }
181
322
  el.replaceChildren(...parts.filter((p): p is Node => p !== null));
323
+ for (const k of [...pictures.keys()]) if (!used.has(k)) pictures.delete(k);
182
324
  }
183
325
 
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);
326
+ // ---- To see ----
327
+
328
+ function toolbar(st: Map<string, LookStats>): HTMLElement {
329
+ const chips = h("div", { class: "seg", role: "group", "aria-label": "Show looks" });
330
+ for (const f of FILTERS) {
331
+ const n = LOOKS.filter((l) => shelfFilter(l, f.id, st.get(l.id))).length;
332
+ chips.append(
333
+ h(
334
+ "button",
335
+ { class: `seg-btn${filter === f.id ? " is-on" : ""}`, type: "button", "aria-pressed": filter === f.id ? "true" : "false", onclick: () => ((filter = f.id), redraw()) },
336
+ f.label,
337
+ h("span", { class: "seg-count" }, String(n)),
338
+ ),
339
+ );
340
+ }
341
+ 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)));
342
+ select.value = sort;
343
+ return h("div", { class: "toolbar" }, chips, h("label", { class: "sort" }, h("span", {}, "Sort"), select));
344
+ }
191
345
 
346
+ function viewGrid(st: Map<string, LookStats>): HTMLElement {
347
+ const total = [...st.values()].reduce((n, s) => n + s.paidCents, 0);
348
+ const shown = shelfSort(LOOKS.filter((l) => shelfFilter(l, filter, st.get(l.id))), sort, st);
349
+ const grid = h("div", { class: "shelf-list" });
350
+ if (!shown.length) grid.append(h("p", { class: "empty" }, "No look matches this filter."));
351
+ for (const l of shown) grid.append(viewCard(l, st.get(l.id), total));
352
+ return grid;
353
+ }
354
+
355
+ function stage(l: Look, ...extra: (Node | null)[]): HTMLElement {
356
+ const box = h("div", { class: "shelf-stage" }, h("div", { class: "shelf-pic" }, pictureOf(l)), ...extra);
357
+ const body = l.change?.body;
358
+ if (body && COLOR_PATTERN.test(body)) box.style.setProperty("--tint", body);
359
+ return box;
360
+ }
361
+
362
+ /** An item's wear slot, under its name ("Worn in the shovel slot"); nothing for an outfit. */
363
+ function wearLine(l: Look): HTMLElement | null {
364
+ return wearOf(l) === OUTFIT ? null : h("span", { class: "fine shelf-wear" }, `Worn in the ${wearOf(l)} slot`);
365
+ }
366
+
367
+ function slotList(l: Look): HTMLElement | null {
368
+ const entries = Object.entries(l.slots ?? {});
369
+ if (!entries.length) return null;
370
+ 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)));
371
+ }
372
+
373
+ function viewCard(l: Look, s: LookStats | undefined, total: number): HTMLElement {
374
+ const share = s && total > 0 && s.paidCents > 0 ? Math.round((s.paidCents / total) * 100) : 0;
375
+ const bar = h("div", { class: "share-bar" });
376
+ bar.style.setProperty("--share", `${share}%`);
377
+ return h(
378
+ "article",
379
+ { class: `shelf-card${l.priceUsd === 0 ? " is-free" : ""}` },
380
+ stage(l, h("span", { class: `price-pill${l.priceUsd === 0 ? " is-free" : ""}` }, priceWords(l.priceUsd))),
381
+ h(
382
+ "div",
383
+ { class: "shelf-body" },
384
+ h("strong", { class: "shelf-name" }, l.name || "(no name)"),
385
+ wearLine(l),
386
+ h("span", { class: "fine" }, fineWords(l, s)),
387
+ share ? h("div", { class: "share", title: `${share}% of the money from sales` }, bar, h("span", { class: "fine" }, `${share}% of sales`)) : null,
388
+ slotList(l),
389
+ ),
390
+ );
391
+ }
392
+
393
+ // ---- To edit ----
394
+
395
+ function editGrid(st: Map<string, LookStats>, own: Set<string>): HTMLElement {
396
+ const bad = problems();
397
+ const diff = diffLooks(LOOKS, draft);
398
+ const added = new Set(diff.added.map((l) => l.id));
399
+ const changed = new Set(diff.changed.map((c) => c.id));
400
+ const grid = h("div", { class: "shelf-list is-editing" });
401
+ draft.forEach((l, i) => {
402
+ const mine = bad.filter((p) => p.look === i + 1);
403
+ const mark = added.has(l.id) ? "new" : changed.has(l.id) ? "changed" : null;
404
+ grid.append(l === open ? editCard(l, i, st, own, mine, mark) : compactCard(l, st, own, mine, mark));
405
+ });
406
+ for (const gone of diff.removed) grid.append(ghostCard(gone));
407
+ grid.append(h("button", { class: "shelf-add-tile", type: "button", onclick: () => addLook() }, icon("plus", 22), h("span", {}, "Add a look")));
408
+ return grid;
409
+ }
410
+
411
+ function marks(l: Look, own: Set<string>, mine: Problem[], mark: "new" | "changed" | null): HTMLElement {
412
+ return h("span", { class: "marks" }, ...markList(l, own, mine, mark));
413
+ }
414
+
415
+ function markList(l: Look, own: Set<string>, mine: Problem[], mark: "new" | "changed" | null): (Node | null)[] {
416
+ return [
417
+ mark ? h("span", { class: `mark mark-${mark}` }, mark === "new" ? "New" : "Changed") : null,
418
+ isOriginal(l.id) && own.has(l.id) ? h("span", { class: "mark mark-owned", title: "A player owns this look" }, icon("lock", 14), "Owned") : null,
419
+ mine.length ? h("span", { class: "mark mark-bad" }, plural(mine.length, "problem")) : null,
420
+ ];
421
+ }
422
+
423
+ function compactCard(l: Look, st: Map<string, LookStats>, own: Set<string>, mine: Problem[], mark: "new" | "changed" | null): HTMLElement {
424
+ return h(
425
+ "article",
426
+ { class: `shelf-card is-compact${mark ? ` is-${mark}` : ""}${mine.length ? " has-problem" : ""}` },
427
+ stage(l),
428
+ h(
429
+ "div",
430
+ { class: "shelf-body" },
431
+ h("strong", { class: "shelf-name" }, l.name || "(no name)"),
432
+ h("span", { class: "shelf-price" }, priceWords(l.priceUsd)),
433
+ wearLine(l),
434
+ h("span", { class: "fine" }, isOriginal(l.id) ? fineWords(l, st.get(l.id)) : "New: not in your game yet"),
435
+ marks(l, own, mine, mark),
436
+ ),
437
+ h(
438
+ "button",
439
+ { class: "btn btn-small card-open", type: "button", "aria-expanded": "false", "aria-label": `Edit ${l.name || "this look"}`, onclick: () => openCard(l) },
440
+ icon("pencil", 16),
441
+ "Edit",
442
+ ),
443
+ );
444
+ }
445
+
446
+ function ghostCard(l: Look): HTMLElement {
447
+ return h(
448
+ "article",
449
+ { class: "shelf-ghost" },
450
+ h("div", { class: "shelf-body" }, h("strong", { class: "shelf-name" }, l.name), h("span", { class: "fine" }, "Removed from the draft")),
451
+ h(
452
+ "button",
453
+ {
454
+ class: "btn btn-small",
455
+ type: "button",
456
+ onclick: () => {
457
+ const at = Math.min(LOOKS.findIndex((x) => x.id === l.id), draft.length);
458
+ draft = [...draft.slice(0, at), ...clone([l]), ...draft.slice(at)];
459
+ redraw();
460
+ },
461
+ },
462
+ "Put back",
463
+ ),
464
+ );
465
+ }
466
+
467
+ function openCard(l: Look | null) {
468
+ open = l;
469
+ redraw();
470
+ const card = el.querySelector<HTMLElement>(".shelf-editing");
471
+ if (card) {
472
+ card.focus({ preventScroll: true });
473
+ card.scrollIntoView?.({ block: "nearest" });
474
+ }
475
+ }
476
+
477
+ function editCard(l: Look, i: number, st: Map<string, LookStats>, own: Set<string>, mine: Problem[], mark: "new" | "changed" | null): HTMLElement {
478
+ const pic = h("div", { class: "shelf-pic" }, pictureOf(l));
479
+ const stageBox = h("div", { class: "shelf-stage is-big" }, pic);
480
+ const nameLine = h("strong", { class: "shelf-name" }, l.name || "(no name)");
481
+ const priceLine = h("span", { class: "shelf-price" }, priceWords(l.priceUsd));
482
+ const tint = () => (l.change && COLOR_PATTERN.test(l.change.body) ? stageBox.style.setProperty("--tint", l.change.body) : stageBox.style.removeProperty("--tint"));
483
+ tint();
484
+ // The real picture is drawn a moment after the last change: a colour dragged across the picker draws once, not at every step.
485
+ let later: ReturnType<typeof setTimeout> | null = null;
192
486
  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)";
487
+ nameLine.textContent = l.name || "(no name)";
488
+ priceLine.textContent = priceWords(l.priceUsd);
489
+ tint();
490
+ if (later) clearTimeout(later);
491
+ later = setTimeout(() => pic.replaceChildren(pictureOf(l)), 150);
492
+ ladder.querySelectorAll<HTMLButtonElement>("button").forEach((c) => c.setAttribute("aria-pressed", Number(c.dataset.usd) === l.priceUsd ? "true" : "false"));
493
+ if (outfit) {
494
+ lookParts?.swatches.querySelectorAll<HTMLButtonElement>("button").forEach((c) => c.setAttribute("aria-pressed", c.dataset.colour === outfit.change.body.toLowerCase() ? "true" : "false"));
495
+ lookParts?.hatChoices.style.setProperty("color", outfit.change.hatColor ?? "#9aa4b2");
496
+ }
197
497
  refreshFoot();
198
498
  };
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) => {
499
+
500
+ const name = h("input", { class: "input", type: "text", maxlength: 40, value: l.name, "aria-label": `Name of look ${i + 1}`, oninput: (e: Event) => {
200
501
  l.name = (e.target as HTMLInputElement).value;
201
502
  if (!isOriginal(l.id)) l.id = slugId(l.name, [...draft.filter((x) => x !== l).map((x) => x.id), ...owned()]);
202
503
  sync();
203
504
  } });
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) => {
505
+
506
+ 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
507
  const v = (e.target as HTMLInputElement).value;
206
508
  l.priceUsd = v === "" ? Number.NaN : Number(v);
207
509
  sync();
208
510
  } });
209
- const ladder = h("div", { class: "shelf-ladder", role: "group", "aria-label": "Price ladder" });
511
+ const ladder = h("div", { class: "ladder", role: "group", "aria-label": "Price ladder" });
210
512
  const setPrice = (usd: number) => {
211
513
  l.priceUsd = usd;
212
514
  priceInput.value = String(usd);
213
515
  sync();
214
516
  };
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) => {
517
+ const step = (usd: number, price: string, word: string, title: string) =>
518
+ 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));
519
+ ladder.append(step(0, "Free", "everyone", "Every player has it"));
520
+ for (const r of PRICE_LADDER) ladder.append(step(rungUsd(r), priceText(rungUsd(r)), r.name, `${r.name}: ${r.for}`));
521
+
522
+ // An outfit's body colour and hat (an item of another wear slot has neither).
523
+ const outfit = isOutfit(l) ? l : null;
524
+ const lookParts = outfit ? outfitFields(outfit, i, sync) : null;
525
+ const lastFree = l.priceUsd === 0 && wearOf(l) === OUTFIT && freeCount() === 1;
526
+ const keep = isOriginal(l.id) && own.has(l.id);
527
+ 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 outfit: every player starts with it." : "", onclick: () => {
528
+ draft = draft.filter((x) => x !== l);
529
+ open = null;
530
+ redraw();
531
+ } }, icon("trash", 18), "Remove");
532
+ const dup = h("button", { class: "btn", type: "button", onclick: () => addLook(l) }, icon("dup", 18), "Duplicate");
533
+
534
+ const openMarks = marks(l, own, mine, mark);
535
+ const openProblems = h("ul", { class: "card-problems" }, ...mine.map((p) => h("li", {}, p.message)));
536
+ openProblems.hidden = !mine.length;
537
+ openParts = { marks: openMarks, problems: openProblems };
538
+ const group = (title: string, ...kids: (Node | null)[]) => h("div", { class: "edit-group" }, h("h3", {}, title), ...kids);
539
+ return h(
540
+ "article",
541
+ { class: `shelf-card shelf-editing${mark ? ` is-${mark}` : ""}${mine.length ? " has-problem" : ""}`, tabindex: -1, "aria-label": `Editing ${l.name || "a look"}` },
542
+ h(
543
+ "div",
544
+ { class: "edit-top" },
545
+ stageBox,
546
+ h(
547
+ "div",
548
+ { class: "shelf-body" },
549
+ nameLine,
550
+ priceLine,
551
+ wearLine(l),
552
+ h("span", { class: "fine" }, isOriginal(l.id) ? fineWords(l, st.get(l.id)) : "New: not in your game yet"),
553
+ openMarks,
554
+ ),
555
+ h("button", { class: "btn btn-small card-done", type: "button", "aria-expanded": "true", onclick: () => openCard(null) }, icon("check", 16), "Done"),
556
+ ),
557
+ openProblems,
558
+ h(
559
+ "div",
560
+ { class: "edit-groups" },
561
+ group("Name", h("label", { class: "field" }, h("span", {}, "What players read"), name)),
562
+ group("Price", h("label", { class: "field" }, h("span", {}, "Price in US dollars"), priceInput), ladder),
563
+ wearEditor(l, keep || lastFree, i),
564
+ lookParts?.group ?? null,
565
+ slotsEditor(l, isOriginal(l.id) && own.has(l.id)),
566
+ ),
567
+ keep ? h("p", { class: "fine owned-note" }, icon("lock", 14), "A player owns this look: its id stays, and it cannot be removed.") : null,
568
+ h("div", { class: "shelf-actions" }, dup, remove),
569
+ );
570
+ }
571
+
572
+ /**
573
+ * The wear slot of a look: "outfit" (the character's own look) or one of the game's own slots ("shovel"). Typed, with the slots the draft already uses as
574
+ * suggestions. Locked for a look that a player owns (it stays in its slot) and for the last free outfit. An outfit that becomes an item loses its colours; an
575
+ * item that becomes an outfit gets the free look's blue body.
576
+ */
577
+ function wearEditor(l: Look, locked: boolean, i: number): HTMLElement {
578
+ const list = `n133-wears-${i}`;
579
+ const input = h("input", { class: "input", type: "text", maxlength: 24, value: wearOf(l), list, autocapitalize: "none", spellcheck: false, disabled: locked, "aria-label": `Wear slot of ${l.name}`, onchange: (e: Event) => {
580
+ const v = (e.target as HTMLInputElement).value.trim();
581
+ if (v === "" || v === OUTFIT) {
582
+ delete l.wear;
583
+ l.change ??= { body: "#4aa8ff" };
584
+ } else {
585
+ l.wear = v;
586
+ if (SLOT_NAME_PATTERN.test(v)) delete l.change;
587
+ }
588
+ redraw();
589
+ } });
590
+ return h(
591
+ "div",
592
+ { class: "edit-group" },
593
+ h("h3", {}, "Wear slot"),
594
+ h("label", { class: "field" }, h("span", {}, "outfit (the character's colour and hat), or a slot of your game for an item (shovel, charm)"), input),
595
+ h("datalist", { id: list }, ...[OUTFIT, ...wearSlots()].map((w) => h("option", { value: w }))),
596
+ );
597
+ }
598
+
599
+ /** An outfit's fields: the body colour (swatches and a picker), the hat (pictures) and the hat colour, as the editor's "Look" group. */
600
+ function outfitFields(l: Look & { change: Change }, i: number, sync: () => void): { group: HTMLElement; swatches: HTMLElement; hatChoices: HTMLElement } {
601
+ const body = h("input", { class: "colour", type: "color", value: l.change.body, "aria-label": `Body colour of ${l.name}`, oninput: (e: Event) => {
218
602
  l.change.body = (e.target as HTMLInputElement).value;
219
603
  sync();
220
604
  } });
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) => {
605
+ const swatches = h("div", { class: "swatches", role: "group", "aria-label": "Body colours" });
606
+ for (const c of BODY_COLOURS) {
607
+ 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: () => {
608
+ l.change.body = c;
609
+ body.value = c;
610
+ sync();
611
+ } }, h("span", { class: "swatch", "aria-hidden": "true" }));
612
+ (b.firstChild as HTMLElement).style.setProperty("--swatch", c);
613
+ swatches.append(b);
614
+ }
615
+
616
+ 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
617
  l.change.hatColor = (e.target as HTMLInputElement).value;
223
618
  sync();
224
619
  } });
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 ?? "";
239
-
240
- const lastFree = l.priceUsd === 0 && freeCount() === 1;
241
- 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");
244
-
245
- return h(
246
- "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),
620
+ const hatName = `n133-hat-${i}`;
621
+ const hatChoices = h("div", { class: "hat-choices", role: "radiogroup", "aria-label": `Hat of ${l.name}` });
622
+ for (const x of [null, ...HATS] as (Hat | null)[]) {
623
+ const input = h("input", { type: "radio", name: hatName, value: x ?? "", checked: (l.change.hat ?? null) === x, onchange: () => {
624
+ if (x === null) {
625
+ delete l.change.hat;
626
+ delete l.change.hatColor;
627
+ hatColor.disabled = true;
628
+ } else {
629
+ l.change.hat = x;
630
+ l.change.hatColor ??= hatColor.value;
631
+ hatColor.disabled = false;
632
+ }
633
+ hatChoices.querySelectorAll(".hat-choice").forEach((c) => c.classList.toggle("is-on", (c.querySelector("input") as HTMLInputElement).checked));
634
+ sync();
635
+ } });
636
+ 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")));
637
+ }
638
+ hatChoices.style.setProperty("color", l.change.hatColor ?? "#9aa4b2");
639
+ const group = h(
640
+ "div",
641
+ { class: "edit-group group-look" },
642
+ h("h3", {}, "Look"),
643
+ h(
644
+ "div",
645
+ { class: "look-fields" },
646
+ h("div", { class: "field" }, h("span", {}, "Body colour"), h("div", { class: "colour-row" }, swatches, body)),
647
+ h("div", { class: "field" }, h("span", {}, "Hat"), hatChoices),
648
+ h("label", { class: "field" }, h("span", {}, "Hat colour"), hatColor),
649
+ ),
255
650
  );
651
+ return { group, swatches, hatChoices };
256
652
  }
257
653
 
258
654
  /** The slots of a look: names and values (words or #rrggbb colours) that the game reads. Rows to type in, at most SLOTS_MAX. */
@@ -270,9 +666,9 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
270
666
  h(
271
667
  "div",
272
668
  { 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"),
669
+ 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) }),
670
+ 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) }),
671
+ 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
672
  ),
277
673
  );
278
674
  });
@@ -282,12 +678,13 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
282
678
  while (cur.some(([k]) => k === `slot-${n}`)) n++;
283
679
  setRows(l, [...cur, [`slot-${n}`, ""]]);
284
680
  redraw();
285
- } }, "Add a slot");
681
+ } }, icon("plus", 16), "Add a slot");
286
682
  return h(
287
683
  "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,
684
+ { class: "edit-group shelf-slots-edit" },
685
+ h("h3", {}, "Slots"),
686
+ h("span", { class: "fine" }, `Slots (${rows.length} of ${SLOTS_MAX}): words and colours that your game reads`),
687
+ rows.length ? list : null,
291
688
  add,
292
689
  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
690
  );
@@ -295,32 +692,68 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
295
692
 
296
693
  function addLook(from?: Look) {
297
694
  const name = from ? `${from.name} copy`.slice(0, 40) : "New look";
298
- 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" } };
695
+ const base: Look = from ? { ...copyLook(from), id: "", name } : { id: "", name, priceUsd: 0.05, change: { body: "#4aa8ff" } };
299
696
  // 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
697
  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());
698
+ const at = from ? draft.indexOf(from) + 1 : draft.length;
699
+ draft = [...draft.slice(0, at), base, ...draft.slice(at)];
700
+ openCard(base);
701
+ el.querySelector<HTMLInputElement>(".shelf-editing input[type=text]")?.focus();
304
702
  }
305
703
 
306
- const footBox = h("div", { class: "shelf-foot" });
307
- const foot = () => {
704
+ // ---- The change bar ----
705
+ //
706
+ // 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
707
+ // 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,
708
+ // the new file.
709
+
710
+ const footBar = h("div", { class: "shelf-foot", role: "region", "aria-label": "Your draft" });
711
+ const footMore = h("div", { class: "shelf-foot-more" });
712
+ const foot = (): Node[] => {
308
713
  refreshFoot();
309
- return footBox;
714
+ return [footBar, footMore];
310
715
  };
311
716
 
717
+ /** "1 changed", "2 added, 1 removed": only the parts that are not zero. */
718
+ const shortChange = (d: ReturnType<typeof diffLooks>) =>
719
+ [d.added.length && `${d.added.length} added`, d.changed.length && `${d.changed.length} changed`, d.removed.length && `${d.removed.length} removed`].filter(Boolean).join(", ");
720
+
312
721
  function refreshFoot() {
313
722
  const bad = problems();
314
723
  const diff = diffLooks(LOOKS, draft);
315
724
  const changed = diff.added.length + diff.removed.length + diff.changed.length > 0;
316
725
  const text = bad.length ? "" : looksJson(draft);
726
+ const ready = !bad.length && changed;
727
+ footBar.replaceChildren(
728
+ h(
729
+ "div",
730
+ { class: "foot-status" },
731
+ h("strong", {}, changed ? shortChange(diff) : "No change yet"),
732
+ 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,
733
+ ),
734
+ h(
735
+ "div",
736
+ { class: "foot-keys" },
737
+ h("button", { class: "btn btn-primary", type: "button", disabled: !ready, onclick: (e: Event) => void copy(text, e.currentTarget as HTMLButtonElement) }, "Copy looks.json"),
738
+ 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"),
739
+ ),
740
+ );
317
741
  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)))));
742
+ 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
743
  out.push(
320
744
  h(
321
745
  "div",
322
- { class: "shelf-changes" },
323
- h("strong", {}, changed ? changeText(diff) : "No change yet"),
746
+ { class: "foot-links" },
747
+ 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,
748
+ h("button", { class: "btn btn-small btn-quiet", type: "button", disabled: !changedFromGame(), onclick: () => ((draft = clone(LOOKS)), (open = null), redraw()) }, "Start over"),
749
+ ),
750
+ );
751
+ if (!data.repo) out.push(h("p", { class: "fine" }, repoHelp));
752
+ out.push(
753
+ h(
754
+ "details",
755
+ { class: "foot-more shelf-changes" },
756
+ h("summary", {}, `What changed: ${changed ? changeText(diff) : "No change yet"}`),
324
757
  changed
325
758
  ? h(
326
759
  "ul",
@@ -332,22 +765,17 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
332
765
  : null,
333
766
  ),
334
767
  );
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));
768
+ if (ready) out.push(h("details", { class: "foot-more shelf-file" }, h("summary", {}, "Show the new looks.json"), h("pre", {}, text)));
769
+ footMore.replaceChildren(...out.filter((n): n is Node => n !== null));
770
+ // The open card's own marks and problems, from the same check.
771
+ if (open && openParts) {
772
+ const at = draft.indexOf(open) + 1;
773
+ const mine = bad.filter((p) => p.look === at);
774
+ const mark = diff.added.some((l) => l === open) ? "new" : diff.changed.some((c) => c.after === open) ? "changed" : null;
775
+ openParts.marks.replaceChildren(...markList(open, owned(), mine, mark).filter((n): n is Node => n !== null));
776
+ openParts.problems.replaceChildren(...mine.map((p) => h("li", {}, p.message)));
777
+ openParts.problems.hidden = !mine.length;
778
+ }
351
779
  }
352
780
 
353
781
  redraw();