@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,12 +1,17 @@
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
  import { diffLooks, removalProblems } from "../../looks/change.js";
5
9
  import { SLOTS_MAX } from "../../looks/look.js";
6
10
  import { looksJson, slugId } from "../../looks/json.js";
7
11
  import { PRICE_LADDER, priceText, rungOf, rungUsd } from "../../looks/ladder.js";
8
- import { HATS, lookProblems, usdText } from "../../looks/look.js";
9
- import { copy, h } from "./dom.js";
12
+ import { COLOR_PATTERN, HATS, isOutfit, lookProblems, OUTFIT, SLOT_NAME_PATTERN, usdText, wearOf } from "../../looks/look.js";
13
+ import { copy, dollars, h, plural } from "./dom.js";
14
+ import { icon } from "./icons.js";
10
15
  import { aiPrompt, changeText, ownedIds, shelfStats, soldText } from "./shelfData.js";
11
16
  const NS = "http://www.w3.org/2000/svg";
12
17
  const INK = "#1d1530";
@@ -18,17 +23,35 @@ const svg = (tag, attrs = {}, ...kids) => {
18
23
  return el;
19
24
  };
20
25
  const shape = (tag, attrs) => svg(tag, { stroke: INK, "stroke-width": 1.4, "stroke-linejoin": "round", ...attrs });
21
- /** 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.) */
22
- export function lookPicture(l) {
23
- const hat = l.change.hat;
24
- const hc = l.change.hatColor ?? "#444444";
26
+ /** The shapes of a hat in the flat picture's 64 x 72 box, in the colour `fill`. */
27
+ function hatShapes(hat, fill) {
25
28
  const hats = {
26
- 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 })],
27
- 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 })],
28
- crown: [shape("polygon", { points: "20,22 20,11 26,17 32,9 38,17 44,11 44,22", fill: hc })],
29
- 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 })],
29
+ 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 })],
30
+ 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 })],
31
+ crown: () => [shape("polygon", { points: "20,22 20,11 26,17 32,9 38,17 44,11 44,22", fill })],
32
+ 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 })],
30
33
  };
31
- return svg("svg", { viewBox: "0 0 64 72", width: 56, height: 63, role: "img", "aria-label": `${l.name}: ${l.change.hat ?? "no hat"}` }, shape("rect", { x: 23, y: 61, width: 7, height: 9, rx: 2, fill: "#3a3f5c" }), shape("rect", { x: 34, y: 61, width: 7, height: 9, rx: 2, fill: "#3a3f5c" }), shape("rect", { x: 16, y: 37, width: 32, height: 28, rx: 12, fill: l.change.body }), shape("circle", { cx: 32, cy: 26, r: 13, fill: "#f2c9a0" }), svg("circle", { cx: 27, cy: 27, r: 1.7, fill: INK }), svg("circle", { cx: 37, cy: 27, r: 1.7, fill: INK }), ...(hat ? hats[hat] : []));
34
+ return hats[hat]();
35
+ }
36
+ /**
37
+ * 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
38
+ * knows what the item is). (The game draws the real thing; a game can give its own picture.)
39
+ */
40
+ export function lookPicture(l) {
41
+ if (!isOutfit(l)) {
42
+ const wear = wearOf(l);
43
+ const label = svg("text", { x: 33, y: 40, "font-size": 9, "font-family": "system-ui, sans-serif", "text-anchor": "middle", fill: INK });
44
+ label.textContent = wear.slice(0, 8);
45
+ return svg("svg", { viewBox: "0 0 64 72", width: 56, height: 63, role: "img", "aria-label": `${l.name}: worn in the ${wear} slot` }, shape("path", { d: "M14 20 L36 20 L52 36 L36 52 L14 52 Z", fill: "#f6e7c1" }), shape("circle", { cx: 22, cy: 36, r: 3.5, fill: "#ffffff" }), label);
46
+ }
47
+ const hat = l.change.hat;
48
+ return svg("svg", { viewBox: "0 0 64 72", width: 56, height: 63, role: "img", "aria-label": `${l.name}: ${l.change.hat ?? "no hat"}` }, shape("rect", { x: 23, y: 61, width: 7, height: 9, rx: 2, fill: "#3a3f5c" }), shape("rect", { x: 34, y: 61, width: 7, height: 9, rx: 2, fill: "#3a3f5c" }), shape("rect", { x: 16, y: 37, width: 32, height: 28, rx: 12, fill: l.change.body }), shape("circle", { cx: 32, cy: 26, r: 13, fill: "#f2c9a0" }), svg("circle", { cx: 27, cy: 27, r: 1.7, fill: INK }), svg("circle", { cx: 37, cy: 27, r: 1.7, fill: INK }), ...(hat ? hatShapes(hat, l.change.hatColor ?? "#444444") : []));
49
+ }
50
+ /** 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. */
51
+ function hatChoicePicture(hat) {
52
+ const head = shape("circle", { cx: 32, cy: 30, r: 12, fill: "#f2c9a0" });
53
+ 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" })];
54
+ return svg("svg", { viewBox: "0 0 64 46", width: 46, height: 33, "aria-hidden": "true", focusable: "false" }, ...kids);
32
55
  }
33
56
  /**
34
57
  * 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
@@ -41,7 +64,7 @@ export function previewPicture(load, fallback = lookPicture, size = 64) {
41
64
  return (l) => {
42
65
  const box = h("span", { class: "shelf-preview" }, fallback(l));
43
66
  ready ??= load().catch(() => null);
44
- const look = { ...l, change: { ...l.change }, ...(l.slots ? { slots: { ...l.slots } } : {}) };
67
+ const look = copyLook(l);
45
68
  void ready
46
69
  .then((render) => (render ? render(look) : null))
47
70
  .then((src) => {
@@ -54,9 +77,9 @@ export function previewPicture(load, fallback = lookPicture, size = 64) {
54
77
  return box;
55
78
  };
56
79
  }
57
- const clone = (ls) => ls.map((l) => ({ id: l.id, name: l.name, priceUsd: l.priceUsd, change: { ...l.change }, ...(l.slots ? { slots: { ...l.slots } } : {}) }));
58
- /** "wings ember, trail #ffcf5a" */
59
- const slotsText = (l) => Object.entries(l.slots ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
80
+ /** A copy of a look that shares nothing with it: its wear slot, an outfit's change, its slots. */
81
+ const copyLook = (l) => ({ 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 } } : {}) });
82
+ const clone = (ls) => ls.map(copyLook);
60
83
  /** The names of the slots that differ between two looks (added, removed or another value). */
61
84
  function slotNamesChanged(before, after) {
62
85
  const names = new Set([...Object.keys(before.slots ?? {}), ...Object.keys(after.slots ?? {})]);
@@ -71,27 +94,117 @@ function fieldWords(d) {
71
94
  if (f === "body")
72
95
  return "body colour";
73
96
  if (f === "hat")
74
- return `hat ${d.before.change.hat ?? "none"} to ${d.after.change.hat ?? "none"}`;
97
+ return `hat ${d.before.change?.hat ?? "none"} to ${d.after.change?.hat ?? "none"}`;
98
+ if (f === "wear")
99
+ return `wear slot ${wearOf(d.before)} to ${wearOf(d.after)}`;
75
100
  if (f === "slots")
76
101
  return `slots changed (${slotNamesChanged(d.before, d.after)})`;
77
102
  return "hat colour";
78
103
  });
79
104
  return `${d.after.name}: ${parts.join(", ")}`;
80
105
  }
106
+ /** 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". */
107
+ function priceWords(usd) {
108
+ if (!Number.isFinite(usd))
109
+ return "No price";
110
+ if (usd === 0)
111
+ return "Free";
112
+ const r = rungOf(usd);
113
+ return `${priceText(usd)}${r ? ` · ${r.name}` : ""}`;
114
+ }
115
+ /** The colours the body colour offers in one press (any colour is good: the colour field is next to them). */
116
+ export const BODY_COLOURS = ["#4aa8ff", "#3ddc97", "#ffcf5a", "#ff7a3d", "#ff5c8a", "#9b6bff", "#2b2d42", "#f4f1ea"];
117
+ /** A hat's name as the editor writes it. */
118
+ const HAT_WORDS = { cap: "Cap", beanie: "Beanie", crown: "Crown", tophat: "Top hat" };
119
+ const FILTERS = [
120
+ { id: "all", label: "All" },
121
+ { id: "sale", label: "For sale" },
122
+ { id: "free", label: "Free" },
123
+ { id: "unsold", label: "Not sold yet" },
124
+ ];
125
+ const SORTS = [
126
+ { id: "best", label: "Best selling" },
127
+ { id: "price-desc", label: "Price: high to low" },
128
+ { id: "price-asc", label: "Price: low to high" },
129
+ { id: "name", label: "Name" },
130
+ { id: "file", label: "File order" },
131
+ ];
132
+ /** 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. */
133
+ export function shelfFilter(l, f, s) {
134
+ if (f === "sale")
135
+ return l.priceUsd > 0;
136
+ if (f === "free")
137
+ return l.priceUsd === 0;
138
+ if (f === "unsold")
139
+ return l.priceUsd > 0 && !s?.paid && !s?.owners;
140
+ return true;
141
+ }
142
+ /** The looks in a sort's order (a new list; equal looks keep the file's order). Pure, for the screen and its tests. */
143
+ export function shelfSort(ls, by, stats) {
144
+ const place = new Map(ls.map((l, i) => [l, i]));
145
+ const cents = (l) => stats.get(l.id)?.paidCents ?? 0;
146
+ const owners = (l) => stats.get(l.id)?.owners ?? 0;
147
+ const cmp = {
148
+ best: (a, b) => cents(b) - cents(a) || owners(b) - owners(a),
149
+ "price-desc": (a, b) => b.priceUsd - a.priceUsd,
150
+ "price-asc": (a, b) => a.priceUsd - b.priceUsd,
151
+ name: (a, b) => a.name.localeCompare(b.name, "en", { sensitivity: "base" }),
152
+ file: () => 0,
153
+ };
154
+ return [...ls].sort((a, b) => cmp[by](a, b) || place.get(a) - place.get(b));
155
+ }
156
+ /** A slot's value as a chip: a colour gets a swatch. */
157
+ function slotChip(name, value) {
158
+ const sw = COLOR_PATTERN.test(value) ? h("span", { class: "swatch", "aria-hidden": "true" }) : null;
159
+ sw?.style.setProperty("--swatch", value);
160
+ return h("li", { class: "slot-chip" }, sw, h("span", { class: "slot-name" }, name), " ", h("span", { class: "slot-value" }, value));
161
+ }
81
162
  /**
82
163
  * 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
83
- * 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.
164
+ * 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
165
+ * what to tell a game that has no repository set.
84
166
  */
85
167
  export function shelfView(first, looks, picture = lookPicture, repoHelp = "To get a link to your looks.json on GitHub, give the admin your repository (for example your-name/your-game).") {
86
168
  const LOOKS = looks;
87
169
  let data = first;
88
170
  let draft = clone(LOOKS);
89
171
  let editing = false;
172
+ /** The one card that is open in the editor (a look of the draft), or none. */
173
+ let open = null;
174
+ /** 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). */
175
+ let openParts = null;
176
+ let filter = "all";
177
+ let sort = "best";
90
178
  const el = h("section", { class: "admin-card shelf", "aria-labelledby": "shelf-title" });
91
179
  const stats = () => shelfStats(data.orders, data.players);
92
180
  const owned = () => ownedIds(data.players);
93
181
  const isOriginal = (id) => LOOKS.some((l) => l.id === id);
94
- const freeCount = () => draft.filter((l) => l.priceUsd === 0).length;
182
+ /** Free outfits in the draft: one must stay (every player starts with it). */
183
+ const freeCount = () => draft.filter((l) => l.priceUsd === 0 && wearOf(l) === OUTFIT).length;
184
+ /** The wear slots that the draft uses besides the outfit, for the wear slot field's suggestions. */
185
+ const wearSlots = () => [...new Set(draft.map(wearOf).filter((w) => w !== OUTFIT))].sort();
186
+ /** What a card says under the price: who has it (a free outfit), what it sold, or that it is new. */
187
+ const fineWords = (l, s) => (l.priceUsd === 0 && wearOf(l) === OUTFIT ? "Every player starts with it" : soldText(s));
188
+ /** 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). */
189
+ const changedFromGame = () => looksJson(draft) !== looksJson(LOOKS);
190
+ /**
191
+ * 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,
192
+ * and the card does not flash its flat picture first). Pictures that the last redraw did not use are let go.
193
+ */
194
+ const pictures = new Map();
195
+ let used = new Set();
196
+ function pictureOf(l) {
197
+ const key = JSON.stringify([l.id, l.name, l.priceUsd, l.wear ?? null, l.change ?? null, l.slots ?? null]);
198
+ const kept = pictures.get(key);
199
+ if (kept && !used.has(key)) {
200
+ used.add(key);
201
+ return kept;
202
+ }
203
+ const made = picture(l);
204
+ pictures.set(key, made);
205
+ used.add(key);
206
+ return made;
207
+ }
95
208
  /**
96
209
  * 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
97
210
  * 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.
@@ -120,9 +233,13 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
120
233
  return own.length ? own : removalProblems(LOOKS, draft, owned());
121
234
  }
122
235
  function redraw() {
236
+ used = new Set();
237
+ openParts = null;
123
238
  const st = stats();
124
239
  const own = owned();
125
- const head = h("div", { class: "card-head" }, h("h2", { id: "shelf-title" }, "Shelf"), h("button", {
240
+ const forSale = LOOKS.filter((l) => l.priceUsd > 0).length;
241
+ const earned = [...st.values()].reduce((n, s) => n + s.paidCents, 0);
242
+ const head = h("div", { class: "card-head" }, 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.")), h("button", {
126
243
  class: `btn ${editing ? "" : "btn-primary"}`,
127
244
  type: "button",
128
245
  "aria-pressed": editing ? "true" : "false",
@@ -130,81 +247,244 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
130
247
  editing = !editing;
131
248
  redraw();
132
249
  },
133
- }, editing ? "Close the editor" : "Edit the shelf"));
134
- const intro = h("p", { class: "fine" }, editing
135
- ? "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."
136
- : "The looks your game sells now, with what each has sold in your own database. Nobody else sees these numbers.");
137
- const list = h("div", { class: "shelf-list" });
138
- draft.forEach((l, i) => list.append(card(l, i, st, own)));
139
- const parts = [head, intro, list];
140
- if (editing) {
141
- parts.push(h("div", { class: "shelf-add" }, h("button", { class: "btn", type: "button", onclick: () => addLook() }, "Add a look"), " ", h("button", { class: "btn", type: "button", disabled: JSON.stringify(draft) === JSON.stringify(LOOKS), onclick: () => ((draft = clone(LOOKS)), redraw()) }, "Start over")), foot());
250
+ }, editing ? icon("close", 18) : icon("pencil", 18), editing ? "Close the editor" : "Edit the shelf"));
251
+ const parts = [head];
252
+ if (!editing) {
253
+ parts.push(h("ul", { class: "shelf-stats", "aria-label": "The shelf in numbers" }, h("li", {}, h("strong", {}, String(LOOKS.length)), ` ${LOOKS.length === 1 ? "look" : "looks"}`), h("li", {}, h("strong", {}, String(forSale)), " for sale"), h("li", {}, h("strong", {}, String(LOOKS.length - forSale)), " free"), h("li", {}, h("strong", {}, dollars(earned)), " earned")));
254
+ if (changedFromGame()) {
255
+ const diff = diffLooks(LOOKS, draft);
256
+ parts.push(h("div", { class: "draft-note", role: "status" }, icon("pencil", 18), h("span", {}, `You have a draft that is not in your game yet: ${changeText(diff)}.`), h("button", { class: "btn btn-small", type: "button", onclick: () => ((editing = true), redraw()) }, "Open the draft")));
257
+ }
258
+ parts.push(toolbar(st), viewGrid(st));
259
+ }
260
+ else {
261
+ parts.push(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."), editGrid(st, own), ...foot());
142
262
  }
143
263
  el.replaceChildren(...parts.filter((p) => p !== null));
264
+ for (const k of [...pictures.keys()])
265
+ if (!used.has(k))
266
+ pictures.delete(k);
267
+ }
268
+ // ---- To see ----
269
+ function toolbar(st) {
270
+ const chips = h("div", { class: "seg", role: "group", "aria-label": "Show looks" });
271
+ for (const f of FILTERS) {
272
+ const n = LOOKS.filter((l) => shelfFilter(l, f.id, st.get(l.id))).length;
273
+ chips.append(h("button", { class: `seg-btn${filter === f.id ? " is-on" : ""}`, type: "button", "aria-pressed": filter === f.id ? "true" : "false", onclick: () => ((filter = f.id), redraw()) }, f.label, h("span", { class: "seg-count" }, String(n))));
274
+ }
275
+ const select = h("select", { class: "input select", "aria-label": "Sort the looks", onchange: (e) => ((sort = e.target.value), redraw()) }, ...SORTS.map((s) => h("option", { value: s.id }, s.label)));
276
+ select.value = sort;
277
+ return h("div", { class: "toolbar" }, chips, h("label", { class: "sort" }, h("span", {}, "Sort"), select));
278
+ }
279
+ function viewGrid(st) {
280
+ const total = [...st.values()].reduce((n, s) => n + s.paidCents, 0);
281
+ const shown = shelfSort(LOOKS.filter((l) => shelfFilter(l, filter, st.get(l.id))), sort, st);
282
+ const grid = h("div", { class: "shelf-list" });
283
+ if (!shown.length)
284
+ grid.append(h("p", { class: "empty" }, "No look matches this filter."));
285
+ for (const l of shown)
286
+ grid.append(viewCard(l, st.get(l.id), total));
287
+ return grid;
288
+ }
289
+ function stage(l, ...extra) {
290
+ const box = h("div", { class: "shelf-stage" }, h("div", { class: "shelf-pic" }, pictureOf(l)), ...extra);
291
+ const body = l.change?.body;
292
+ if (body && COLOR_PATTERN.test(body))
293
+ box.style.setProperty("--tint", body);
294
+ return box;
295
+ }
296
+ /** An item's wear slot, under its name ("Worn in the shovel slot"); nothing for an outfit. */
297
+ function wearLine(l) {
298
+ return wearOf(l) === OUTFIT ? null : h("span", { class: "fine shelf-wear" }, `Worn in the ${wearOf(l)} slot`);
299
+ }
300
+ function slotList(l) {
301
+ const entries = Object.entries(l.slots ?? {});
302
+ if (!entries.length)
303
+ return null;
304
+ 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)));
305
+ }
306
+ function viewCard(l, s, total) {
307
+ const share = s && total > 0 && s.paidCents > 0 ? Math.round((s.paidCents / total) * 100) : 0;
308
+ const bar = h("div", { class: "share-bar" });
309
+ bar.style.setProperty("--share", `${share}%`);
310
+ return h("article", { class: `shelf-card${l.priceUsd === 0 ? " is-free" : ""}` }, stage(l, h("span", { class: `price-pill${l.priceUsd === 0 ? " is-free" : ""}` }, priceWords(l.priceUsd))), h("div", { class: "shelf-body" }, h("strong", { class: "shelf-name" }, l.name || "(no name)"), wearLine(l), h("span", { class: "fine" }, fineWords(l, s)), share ? h("div", { class: "share", title: `${share}% of the money from sales` }, bar, h("span", { class: "fine" }, `${share}% of sales`)) : null, slotList(l)));
311
+ }
312
+ // ---- To edit ----
313
+ function editGrid(st, own) {
314
+ const bad = problems();
315
+ const diff = diffLooks(LOOKS, draft);
316
+ const added = new Set(diff.added.map((l) => l.id));
317
+ const changed = new Set(diff.changed.map((c) => c.id));
318
+ const grid = h("div", { class: "shelf-list is-editing" });
319
+ draft.forEach((l, i) => {
320
+ const mine = bad.filter((p) => p.look === i + 1);
321
+ const mark = added.has(l.id) ? "new" : changed.has(l.id) ? "changed" : null;
322
+ grid.append(l === open ? editCard(l, i, st, own, mine, mark) : compactCard(l, st, own, mine, mark));
323
+ });
324
+ for (const gone of diff.removed)
325
+ grid.append(ghostCard(gone));
326
+ grid.append(h("button", { class: "shelf-add-tile", type: "button", onclick: () => addLook() }, icon("plus", 22), h("span", {}, "Add a look")));
327
+ return grid;
328
+ }
329
+ function marks(l, own, mine, mark) {
330
+ return h("span", { class: "marks" }, ...markList(l, own, mine, mark));
144
331
  }
145
- function card(l, i, st, own) {
146
- const rung = rungOf(l.priceUsd);
147
- const price = l.priceUsd === 0 ? "Free" : `${priceText(l.priceUsd)}${rung ? ` · ${rung.name}` : ""}`;
148
- const pic = h("div", { class: "shelf-pic" }, picture(l));
149
- const priceLine = h("span", { class: "shelf-price" }, price);
150
- 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));
151
- if (!editing)
152
- return h("article", { class: "shelf-card" }, top);
332
+ function markList(l, own, mine, mark) {
333
+ return [
334
+ mark ? h("span", { class: `mark mark-${mark}` }, mark === "new" ? "New" : "Changed") : null,
335
+ isOriginal(l.id) && own.has(l.id) ? h("span", { class: "mark mark-owned", title: "A player owns this look" }, icon("lock", 14), "Owned") : null,
336
+ mine.length ? h("span", { class: "mark mark-bad" }, plural(mine.length, "problem")) : null,
337
+ ];
338
+ }
339
+ function compactCard(l, st, own, mine, mark) {
340
+ return h("article", { class: `shelf-card is-compact${mark ? ` is-${mark}` : ""}${mine.length ? " has-problem" : ""}` }, stage(l), h("div", { class: "shelf-body" }, h("strong", { class: "shelf-name" }, l.name || "(no name)"), h("span", { class: "shelf-price" }, priceWords(l.priceUsd)), wearLine(l), h("span", { class: "fine" }, isOriginal(l.id) ? fineWords(l, st.get(l.id)) : "New: not in your game yet"), marks(l, own, mine, mark)), h("button", { class: "btn btn-small card-open", type: "button", "aria-expanded": "false", "aria-label": `Edit ${l.name || "this look"}`, onclick: () => openCard(l) }, icon("pencil", 16), "Edit"));
341
+ }
342
+ function ghostCard(l) {
343
+ return h("article", { class: "shelf-ghost" }, h("div", { class: "shelf-body" }, h("strong", { class: "shelf-name" }, l.name), h("span", { class: "fine" }, "Removed from the draft")), h("button", {
344
+ class: "btn btn-small",
345
+ type: "button",
346
+ onclick: () => {
347
+ const at = Math.min(LOOKS.findIndex((x) => x.id === l.id), draft.length);
348
+ draft = [...draft.slice(0, at), ...clone([l]), ...draft.slice(at)];
349
+ redraw();
350
+ },
351
+ }, "Put back"));
352
+ }
353
+ function openCard(l) {
354
+ open = l;
355
+ redraw();
356
+ const card = el.querySelector(".shelf-editing");
357
+ if (card) {
358
+ card.focus({ preventScroll: true });
359
+ card.scrollIntoView?.({ block: "nearest" });
360
+ }
361
+ }
362
+ function editCard(l, i, st, own, mine, mark) {
363
+ const pic = h("div", { class: "shelf-pic" }, pictureOf(l));
364
+ const stageBox = h("div", { class: "shelf-stage is-big" }, pic);
365
+ const nameLine = h("strong", { class: "shelf-name" }, l.name || "(no name)");
366
+ const priceLine = h("span", { class: "shelf-price" }, priceWords(l.priceUsd));
367
+ const tint = () => (l.change && COLOR_PATTERN.test(l.change.body) ? stageBox.style.setProperty("--tint", l.change.body) : stageBox.style.removeProperty("--tint"));
368
+ tint();
369
+ // The real picture is drawn a moment after the last change: a colour dragged across the picker draws once, not at every step.
370
+ let later = null;
153
371
  const sync = () => {
154
- pic.replaceChildren(picture(l));
155
- const r = rungOf(l.priceUsd);
156
- priceLine.textContent = Number.isFinite(l.priceUsd) ? (l.priceUsd === 0 ? "Free" : `${priceText(l.priceUsd)}${r ? ` · ${r.name}` : ""}`) : "No price";
157
- top.querySelector("strong").textContent = l.name || "(no name)";
372
+ nameLine.textContent = l.name || "(no name)";
373
+ priceLine.textContent = priceWords(l.priceUsd);
374
+ tint();
375
+ if (later)
376
+ clearTimeout(later);
377
+ later = setTimeout(() => pic.replaceChildren(pictureOf(l)), 150);
378
+ ladder.querySelectorAll("button").forEach((c) => c.setAttribute("aria-pressed", Number(c.dataset.usd) === l.priceUsd ? "true" : "false"));
379
+ if (outfit) {
380
+ lookParts?.swatches.querySelectorAll("button").forEach((c) => c.setAttribute("aria-pressed", c.dataset.colour === outfit.change.body.toLowerCase() ? "true" : "false"));
381
+ lookParts?.hatChoices.style.setProperty("color", outfit.change.hatColor ?? "#9aa4b2");
382
+ }
158
383
  refreshFoot();
159
384
  };
160
- const name = h("input", { class: "shelf-input", type: "text", maxlength: 40, value: l.name, "aria-label": `Name of look ${i + 1}`, oninput: (e) => {
385
+ const name = h("input", { class: "input", type: "text", maxlength: 40, value: l.name, "aria-label": `Name of look ${i + 1}`, oninput: (e) => {
161
386
  l.name = e.target.value;
162
387
  if (!isOriginal(l.id))
163
388
  l.id = slugId(l.name, [...draft.filter((x) => x !== l).map((x) => x.id), ...owned()]);
164
389
  sync();
165
390
  } });
166
- 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) => {
391
+ 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) => {
167
392
  const v = e.target.value;
168
393
  l.priceUsd = v === "" ? Number.NaN : Number(v);
169
394
  sync();
170
395
  } });
171
- const ladder = h("div", { class: "shelf-ladder", role: "group", "aria-label": "Price ladder" });
396
+ const ladder = h("div", { class: "ladder", role: "group", "aria-label": "Price ladder" });
172
397
  const setPrice = (usd) => {
173
398
  l.priceUsd = usd;
174
399
  priceInput.value = String(usd);
175
400
  sync();
176
401
  };
177
- ladder.append(h("button", { class: "chip", type: "button", onclick: () => setPrice(0) }, "Free"));
402
+ const step = (usd, price, word, title) => 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));
403
+ ladder.append(step(0, "Free", "everyone", "Every player has it"));
178
404
  for (const r of PRICE_LADDER)
179
- ladder.append(h("button", { class: "chip", type: "button", title: `${r.name}: ${r.for}`, onclick: () => setPrice(rungUsd(r)) }, priceText(rungUsd(r))));
180
- const body = h("input", { class: "shelf-color", type: "color", value: l.change.body, "aria-label": `Body colour of ${l.name}`, oninput: (e) => {
405
+ ladder.append(step(rungUsd(r), priceText(rungUsd(r)), r.name, `${r.name}: ${r.for}`));
406
+ // An outfit's body colour and hat (an item of another wear slot has neither).
407
+ const outfit = isOutfit(l) ? l : null;
408
+ const lookParts = outfit ? outfitFields(outfit, i, sync) : null;
409
+ const lastFree = l.priceUsd === 0 && wearOf(l) === OUTFIT && freeCount() === 1;
410
+ const keep = isOriginal(l.id) && own.has(l.id);
411
+ 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: () => {
412
+ draft = draft.filter((x) => x !== l);
413
+ open = null;
414
+ redraw();
415
+ } }, icon("trash", 18), "Remove");
416
+ const dup = h("button", { class: "btn", type: "button", onclick: () => addLook(l) }, icon("dup", 18), "Duplicate");
417
+ const openMarks = marks(l, own, mine, mark);
418
+ const openProblems = h("ul", { class: "card-problems" }, ...mine.map((p) => h("li", {}, p.message)));
419
+ openProblems.hidden = !mine.length;
420
+ openParts = { marks: openMarks, problems: openProblems };
421
+ const group = (title, ...kids) => h("div", { class: "edit-group" }, h("h3", {}, title), ...kids);
422
+ return h("article", { class: `shelf-card shelf-editing${mark ? ` is-${mark}` : ""}${mine.length ? " has-problem" : ""}`, tabindex: -1, "aria-label": `Editing ${l.name || "a look"}` }, h("div", { class: "edit-top" }, stageBox, h("div", { class: "shelf-body" }, nameLine, priceLine, wearLine(l), h("span", { class: "fine" }, isOriginal(l.id) ? fineWords(l, st.get(l.id)) : "New: not in your game yet"), openMarks), h("button", { class: "btn btn-small card-done", type: "button", "aria-expanded": "true", onclick: () => openCard(null) }, icon("check", 16), "Done")), openProblems, h("div", { class: "edit-groups" }, group("Name", h("label", { class: "field" }, h("span", {}, "What players read"), name)), group("Price", h("label", { class: "field" }, h("span", {}, "Price in US dollars"), priceInput), ladder), wearEditor(l, keep || lastFree, i), lookParts?.group ?? null, slotsEditor(l, isOriginal(l.id) && own.has(l.id))), keep ? h("p", { class: "fine owned-note" }, icon("lock", 14), "A player owns this look: its id stays, and it cannot be removed.") : null, h("div", { class: "shelf-actions" }, dup, remove));
423
+ }
424
+ /**
425
+ * 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
426
+ * 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
427
+ * item that becomes an outfit gets the free look's blue body.
428
+ */
429
+ function wearEditor(l, locked, i) {
430
+ const list = `n133-wears-${i}`;
431
+ 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) => {
432
+ const v = e.target.value.trim();
433
+ if (v === "" || v === OUTFIT) {
434
+ delete l.wear;
435
+ l.change ??= { body: "#4aa8ff" };
436
+ }
437
+ else {
438
+ l.wear = v;
439
+ if (SLOT_NAME_PATTERN.test(v))
440
+ delete l.change;
441
+ }
442
+ redraw();
443
+ } });
444
+ return h("div", { class: "edit-group" }, h("h3", {}, "Wear slot"), 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), h("datalist", { id: list }, ...[OUTFIT, ...wearSlots()].map((w) => h("option", { value: w }))));
445
+ }
446
+ /** An outfit's fields: the body colour (swatches and a picker), the hat (pictures) and the hat colour, as the editor's "Look" group. */
447
+ function outfitFields(l, i, sync) {
448
+ const body = h("input", { class: "colour", type: "color", value: l.change.body, "aria-label": `Body colour of ${l.name}`, oninput: (e) => {
181
449
  l.change.body = e.target.value;
182
450
  sync();
183
451
  } });
184
- 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) => {
452
+ const swatches = h("div", { class: "swatches", role: "group", "aria-label": "Body colours" });
453
+ for (const c of BODY_COLOURS) {
454
+ 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: () => {
455
+ l.change.body = c;
456
+ body.value = c;
457
+ sync();
458
+ } }, h("span", { class: "swatch", "aria-hidden": "true" }));
459
+ b.firstChild.style.setProperty("--swatch", c);
460
+ swatches.append(b);
461
+ }
462
+ 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) => {
185
463
  l.change.hatColor = e.target.value;
186
464
  sync();
187
465
  } });
188
- const hat = h("select", { class: "shelf-input", "aria-label": `Hat of ${l.name}`, onchange: (e) => {
189
- const v = e.target.value;
190
- if (v === "") {
191
- delete l.change.hat;
192
- delete l.change.hatColor;
193
- hatColor.disabled = true;
194
- }
195
- else {
196
- l.change.hat = v;
197
- l.change.hatColor ??= hatColor.value;
198
- hatColor.disabled = false;
199
- }
200
- sync();
201
- } }, h("option", { value: "" }, "No hat"), ...HATS.map((x) => h("option", { value: x }, x)));
202
- hat.value = l.change.hat ?? "";
203
- const lastFree = l.priceUsd === 0 && freeCount() === 1;
204
- const keep = isOriginal(l.id) && own.has(l.id);
205
- 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");
206
- const dup = h("button", { class: "btn", type: "button", onclick: () => addLook(l) }, "Duplicate");
207
- return h("article", { class: "shelf-card shelf-editing" }, top, h("label", { class: "shelf-field" }, h("span", {}, "Name"), name), h("div", { class: "shelf-field" }, h("span", {}, "Price in US dollars"), priceInput, ladder), 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)), slotsEditor(l, isOriginal(l.id) && own.has(l.id)), keep ? h("p", { class: "fine" }, "A player owns this look: its id stays, and it cannot be removed.") : null, h("div", { class: "shelf-actions" }, dup, " ", remove));
466
+ const hatName = `n133-hat-${i}`;
467
+ const hatChoices = h("div", { class: "hat-choices", role: "radiogroup", "aria-label": `Hat of ${l.name}` });
468
+ for (const x of [null, ...HATS]) {
469
+ const input = h("input", { type: "radio", name: hatName, value: x ?? "", checked: (l.change.hat ?? null) === x, onchange: () => {
470
+ if (x === null) {
471
+ delete l.change.hat;
472
+ delete l.change.hatColor;
473
+ hatColor.disabled = true;
474
+ }
475
+ else {
476
+ l.change.hat = x;
477
+ l.change.hatColor ??= hatColor.value;
478
+ hatColor.disabled = false;
479
+ }
480
+ hatChoices.querySelectorAll(".hat-choice").forEach((c) => c.classList.toggle("is-on", c.querySelector("input").checked));
481
+ sync();
482
+ } });
483
+ 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")));
484
+ }
485
+ hatChoices.style.setProperty("color", l.change.hatColor ?? "#9aa4b2");
486
+ const group = h("div", { class: "edit-group group-look" }, h("h3", {}, "Look"), h("div", { class: "look-fields" }, h("div", { class: "field" }, h("span", {}, "Body colour"), h("div", { class: "colour-row" }, swatches, body)), h("div", { class: "field" }, h("span", {}, "Hat"), hatChoices), h("label", { class: "field" }, h("span", {}, "Hat colour"), hatColor)));
487
+ return { group, swatches, hatChoices };
208
488
  }
209
489
  /** The slots of a look: names and values (words or #rrggbb colours) that the game reads. Rows to type in, at most SLOTS_MAX. */
210
490
  function slotsEditor(l, owned) {
@@ -217,7 +497,7 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
217
497
  setRows(l, cur);
218
498
  refreshFoot();
219
499
  };
220
- list.append(h("div", { class: "shelf-slot" }, 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) }), 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) }), 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")));
500
+ list.append(h("div", { class: "shelf-slot" }, 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) }), 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) }), 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")));
221
501
  });
222
502
  const add = h("button", { class: "btn btn-small", type: "button", disabled: rows.length >= SLOTS_MAX, onclick: () => {
223
503
  const cur = rowsOf(l);
@@ -226,39 +506,60 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
226
506
  n++;
227
507
  setRows(l, [...cur, [`slot-${n}`, ""]]);
228
508
  redraw();
229
- } }, "Add a slot");
230
- return h("div", { class: "shelf-field shelf-slots-edit" }, h("span", {}, `Slots (${rows.length} of ${SLOTS_MAX}): words and colours that your game reads`), list, add, owned ? h("p", { class: "fine" }, "A player owns this look: its slots cannot change. Add a new look with the new slots instead.") : null);
509
+ } }, icon("plus", 16), "Add a slot");
510
+ return h("div", { class: "edit-group shelf-slots-edit" }, h("h3", {}, "Slots"), h("span", { class: "fine" }, `Slots (${rows.length} of ${SLOTS_MAX}): words and colours that your game reads`), rows.length ? list : null, add, owned ? h("p", { class: "fine" }, "A player owns this look: its slots cannot change. Add a new look with the new slots instead.") : null);
231
511
  }
232
512
  function addLook(from) {
233
513
  const name = from ? `${from.name} copy`.slice(0, 40) : "New look";
234
- const base = from ? { id: "", name, priceUsd: from.priceUsd, change: { ...from.change }, ...(from.slots ? { slots: { ...from.slots } } : {}) } : { id: "", name, priceUsd: 0.05, change: { body: "#4aa8ff" } };
514
+ const base = from ? { ...copyLook(from), id: "", name } : { id: "", name, priceUsd: 0.05, change: { body: "#4aa8ff" } };
235
515
  // 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.
236
516
  base.id = slugId(name, [...draft.map((x) => x.id), ...owned()]);
237
- draft = [...draft, base];
238
- redraw();
239
- el.querySelectorAll(".shelf-card:last-of-type input[type=text]").forEach((i) => i.focus());
517
+ const at = from ? draft.indexOf(from) + 1 : draft.length;
518
+ draft = [...draft.slice(0, at), base, ...draft.slice(at)];
519
+ openCard(base);
520
+ el.querySelector(".shelf-editing input[type=text]")?.focus();
240
521
  }
241
- const footBox = h("div", { class: "shelf-foot" });
522
+ // ---- The change bar ----
523
+ //
524
+ // 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
525
+ // 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,
526
+ // the new file.
527
+ const footBar = h("div", { class: "shelf-foot", role: "region", "aria-label": "Your draft" });
528
+ const footMore = h("div", { class: "shelf-foot-more" });
242
529
  const foot = () => {
243
530
  refreshFoot();
244
- return footBox;
531
+ return [footBar, footMore];
245
532
  };
533
+ /** "1 changed", "2 added, 1 removed": only the parts that are not zero. */
534
+ const shortChange = (d) => [d.added.length && `${d.added.length} added`, d.changed.length && `${d.changed.length} changed`, d.removed.length && `${d.removed.length} removed`].filter(Boolean).join(", ");
246
535
  function refreshFoot() {
247
536
  const bad = problems();
248
537
  const diff = diffLooks(LOOKS, draft);
249
538
  const changed = diff.added.length + diff.removed.length + diff.changed.length > 0;
250
539
  const text = bad.length ? "" : looksJson(draft);
540
+ const ready = !bad.length && changed;
541
+ footBar.replaceChildren(h("div", { class: "foot-status" }, h("strong", {}, changed ? shortChange(diff) : "No change yet"), 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), h("div", { class: "foot-keys" }, h("button", { class: "btn btn-primary", type: "button", disabled: !ready, onclick: (e) => void copy(text, e.currentTarget) }, "Copy looks.json"), 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) => void copy(aiPrompt(text, diff, [...owned()].sort(), LOOKS), e.currentTarget) }, "Copy for my AI")));
251
542
  const out = [];
252
543
  if (bad.length)
253
- out.push(h("div", { class: "late-note", role: "alert" }, h("strong", {}, "Fix these first"), h("ul", {}, ...bad.map((p) => h("li", {}, p.message)))));
254
- out.push(h("div", { class: "shelf-changes" }, h("strong", {}, changed ? changeText(diff) : "No change yet"), changed
544
+ 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)))));
545
+ out.push(h("div", { class: "foot-links" }, 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, h("button", { class: "btn btn-small btn-quiet", type: "button", disabled: !changedFromGame(), onclick: () => ((draft = clone(LOOKS)), (open = null), redraw()) }, "Start over")));
546
+ if (!data.repo)
547
+ out.push(h("p", { class: "fine" }, repoHelp));
548
+ out.push(h("details", { class: "foot-more shelf-changes" }, h("summary", {}, `What changed: ${changed ? changeText(diff) : "No change yet"}`), changed
255
549
  ? h("ul", {}, ...diff.added.map((l) => h("li", {}, `Added: ${l.name} (${l.priceUsd === 0 ? "free" : usdText(l.priceUsd)})`)), ...diff.changed.map((c) => h("li", {}, fieldWords(c))), ...diff.removed.map((l) => h("li", {}, `Removed: ${l.name}`)))
256
550
  : null));
257
- const ready = !bad.length && changed;
258
- const copyJson = h("button", { class: "btn btn-primary", type: "button", disabled: !ready, onclick: (e) => void copy(text, e.currentTarget) }, "Copy looks.json");
259
- const copyAi = h("button", { class: "btn", type: "button", disabled: !ready, onclick: (e) => void copy(aiPrompt(text, diff, [...owned()].sort(), LOOKS), e.currentTarget) }, "Copy a message for my AI");
260
- out.push(h("div", { class: "shelf-actions" }, copyJson, " ", copyAi, 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), !data.repo ? h("p", { class: "fine" }, repoHelp) : null, ready ? h("details", { class: "shelf-file" }, h("summary", {}, "Show the new looks.json"), h("pre", {}, text)) : null);
261
- footBox.replaceChildren(...out.filter((n) => n !== null));
551
+ if (ready)
552
+ out.push(h("details", { class: "foot-more shelf-file" }, h("summary", {}, "Show the new looks.json"), h("pre", {}, text)));
553
+ footMore.replaceChildren(...out.filter((n) => n !== null));
554
+ // The open card's own marks and problems, from the same check.
555
+ if (open && openParts) {
556
+ const at = draft.indexOf(open) + 1;
557
+ const mine = bad.filter((p) => p.look === at);
558
+ const mark = diff.added.some((l) => l === open) ? "new" : diff.changed.some((c) => c.after === open) ? "changed" : null;
559
+ openParts.marks.replaceChildren(...markList(open, owned(), mine, mark).filter((n) => n !== null));
560
+ openParts.problems.replaceChildren(...mine.map((p) => h("li", {}, p.message)));
561
+ openParts.problems.hidden = !mine.length;
562
+ }
262
563
  }
263
564
  redraw();
264
565
  return {