@nano133/nanoterra 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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, lookProblems, usdText } 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,26 @@ 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 });
26
+ /** The shapes of a hat in the flat picture's 64 x 72 box, in the colour `fill`. */
27
+ function hatShapes(hat, fill) {
28
+ const hats = {
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 })],
33
+ };
34
+ return hats[hat]();
35
+ }
21
36
  /** 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
37
  export function lookPicture(l) {
23
38
  const hat = l.change.hat;
24
- const hc = l.change.hatColor ?? "#444444";
25
- 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 })],
30
- };
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] : []));
39
+ 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") : []));
40
+ }
41
+ /** 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. */
42
+ function hatChoicePicture(hat) {
43
+ const head = shape("circle", { cx: 32, cy: 30, r: 12, fill: "#f2c9a0" });
44
+ 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" })];
45
+ return svg("svg", { viewBox: "0 0 64 46", width: 46, height: 33, "aria-hidden": "true", focusable: "false" }, ...kids);
32
46
  }
33
47
  /**
34
48
  * 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
@@ -55,8 +69,6 @@ export function previewPicture(load, fallback = lookPicture, size = 64) {
55
69
  };
56
70
  }
57
71
  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(", ");
60
72
  /** The names of the slots that differ between two looks (added, removed or another value). */
61
73
  function slotNamesChanged(before, after) {
62
74
  const names = new Set([...Object.keys(before.slots ?? {}), ...Object.keys(after.slots ?? {})]);
@@ -78,20 +90,103 @@ function fieldWords(d) {
78
90
  });
79
91
  return `${d.after.name}: ${parts.join(", ")}`;
80
92
  }
93
+ /** 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". */
94
+ function priceWords(usd) {
95
+ if (!Number.isFinite(usd))
96
+ return "No price";
97
+ if (usd === 0)
98
+ return "Free";
99
+ const r = rungOf(usd);
100
+ return `${priceText(usd)}${r ? ` · ${r.name}` : ""}`;
101
+ }
102
+ /** The colours the body colour offers in one press (any colour is good: the colour field is next to them). */
103
+ export const BODY_COLOURS = ["#4aa8ff", "#3ddc97", "#ffcf5a", "#ff7a3d", "#ff5c8a", "#9b6bff", "#2b2d42", "#f4f1ea"];
104
+ /** A hat's name as the editor writes it. */
105
+ const HAT_WORDS = { cap: "Cap", beanie: "Beanie", crown: "Crown", tophat: "Top hat" };
106
+ const FILTERS = [
107
+ { id: "all", label: "All" },
108
+ { id: "sale", label: "For sale" },
109
+ { id: "free", label: "Free" },
110
+ { id: "unsold", label: "Not sold yet" },
111
+ ];
112
+ const SORTS = [
113
+ { id: "best", label: "Best selling" },
114
+ { id: "price-desc", label: "Price: high to low" },
115
+ { id: "price-asc", label: "Price: low to high" },
116
+ { id: "name", label: "Name" },
117
+ { id: "file", label: "File order" },
118
+ ];
119
+ /** 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. */
120
+ export function shelfFilter(l, f, s) {
121
+ if (f === "sale")
122
+ return l.priceUsd > 0;
123
+ if (f === "free")
124
+ return l.priceUsd === 0;
125
+ if (f === "unsold")
126
+ return l.priceUsd > 0 && !s?.paid && !s?.owners;
127
+ return true;
128
+ }
129
+ /** The looks in a sort's order (a new list; equal looks keep the file's order). Pure, for the screen and its tests. */
130
+ export function shelfSort(ls, by, stats) {
131
+ const place = new Map(ls.map((l, i) => [l, i]));
132
+ const cents = (l) => stats.get(l.id)?.paidCents ?? 0;
133
+ const owners = (l) => stats.get(l.id)?.owners ?? 0;
134
+ const cmp = {
135
+ best: (a, b) => cents(b) - cents(a) || owners(b) - owners(a),
136
+ "price-desc": (a, b) => b.priceUsd - a.priceUsd,
137
+ "price-asc": (a, b) => a.priceUsd - b.priceUsd,
138
+ name: (a, b) => a.name.localeCompare(b.name, "en", { sensitivity: "base" }),
139
+ file: () => 0,
140
+ };
141
+ return [...ls].sort((a, b) => cmp[by](a, b) || place.get(a) - place.get(b));
142
+ }
143
+ /** A slot's value as a chip: a colour gets a swatch. */
144
+ function slotChip(name, value) {
145
+ const sw = COLOR_PATTERN.test(value) ? h("span", { class: "swatch", "aria-hidden": "true" }) : null;
146
+ sw?.style.setProperty("--swatch", value);
147
+ return h("li", { class: "slot-chip" }, sw, h("span", { class: "slot-name" }, name), " ", h("span", { class: "slot-value" }, value));
148
+ }
81
149
  /**
82
150
  * 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.
151
+ * 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
152
+ * what to tell a game that has no repository set.
84
153
  */
85
154
  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
155
  const LOOKS = looks;
87
156
  let data = first;
88
157
  let draft = clone(LOOKS);
89
158
  let editing = false;
159
+ /** The one card that is open in the editor (a look of the draft), or none. */
160
+ let open = null;
161
+ /** 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). */
162
+ let openParts = null;
163
+ let filter = "all";
164
+ let sort = "best";
90
165
  const el = h("section", { class: "admin-card shelf", "aria-labelledby": "shelf-title" });
91
166
  const stats = () => shelfStats(data.orders, data.players);
92
167
  const owned = () => ownedIds(data.players);
93
168
  const isOriginal = (id) => LOOKS.some((l) => l.id === id);
94
169
  const freeCount = () => draft.filter((l) => l.priceUsd === 0).length;
170
+ /** 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). */
171
+ const changedFromGame = () => looksJson(draft) !== looksJson(LOOKS);
172
+ /**
173
+ * 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,
174
+ * and the card does not flash its flat picture first). Pictures that the last redraw did not use are let go.
175
+ */
176
+ const pictures = new Map();
177
+ let used = new Set();
178
+ function pictureOf(l) {
179
+ const key = JSON.stringify([l.id, l.name, l.priceUsd, l.change, l.slots ?? null]);
180
+ const kept = pictures.get(key);
181
+ if (kept && !used.has(key)) {
182
+ used.add(key);
183
+ return kept;
184
+ }
185
+ const made = picture(l);
186
+ pictures.set(key, made);
187
+ used.add(key);
188
+ return made;
189
+ }
95
190
  /**
96
191
  * 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
192
  * 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 +215,13 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
120
215
  return own.length ? own : removalProblems(LOOKS, draft, owned());
121
216
  }
122
217
  function redraw() {
218
+ used = new Set();
219
+ openParts = null;
123
220
  const st = stats();
124
221
  const own = owned();
125
- const head = h("div", { class: "card-head" }, h("h2", { id: "shelf-title" }, "Shelf"), h("button", {
222
+ const forSale = LOOKS.filter((l) => l.priceUsd > 0).length;
223
+ const earned = [...st.values()].reduce((n, s) => n + s.paidCents, 0);
224
+ 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
225
  class: `btn ${editing ? "" : "btn-primary"}`,
127
226
  type: "button",
128
227
  "aria-pressed": editing ? "true" : "false",
@@ -130,81 +229,207 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
130
229
  editing = !editing;
131
230
  redraw();
132
231
  },
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());
232
+ }, editing ? icon("close", 18) : icon("pencil", 18), editing ? "Close the editor" : "Edit the shelf"));
233
+ const parts = [head];
234
+ if (!editing) {
235
+ 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")));
236
+ if (changedFromGame()) {
237
+ const diff = diffLooks(LOOKS, draft);
238
+ 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")));
239
+ }
240
+ parts.push(toolbar(st), viewGrid(st));
241
+ }
242
+ else {
243
+ 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
244
  }
143
245
  el.replaceChildren(...parts.filter((p) => p !== null));
246
+ for (const k of [...pictures.keys()])
247
+ if (!used.has(k))
248
+ pictures.delete(k);
249
+ }
250
+ // ---- To see ----
251
+ function toolbar(st) {
252
+ const chips = h("div", { class: "seg", role: "group", "aria-label": "Show looks" });
253
+ for (const f of FILTERS) {
254
+ const n = LOOKS.filter((l) => shelfFilter(l, f.id, st.get(l.id))).length;
255
+ 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))));
256
+ }
257
+ 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)));
258
+ select.value = sort;
259
+ return h("div", { class: "toolbar" }, chips, h("label", { class: "sort" }, h("span", {}, "Sort"), select));
144
260
  }
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);
261
+ function viewGrid(st) {
262
+ const total = [...st.values()].reduce((n, s) => n + s.paidCents, 0);
263
+ const shown = shelfSort(LOOKS.filter((l) => shelfFilter(l, filter, st.get(l.id))), sort, st);
264
+ const grid = h("div", { class: "shelf-list" });
265
+ if (!shown.length)
266
+ grid.append(h("p", { class: "empty" }, "No look matches this filter."));
267
+ for (const l of shown)
268
+ grid.append(viewCard(l, st.get(l.id), total));
269
+ return grid;
270
+ }
271
+ function stage(l, ...extra) {
272
+ const box = h("div", { class: "shelf-stage" }, h("div", { class: "shelf-pic" }, pictureOf(l)), ...extra);
273
+ if (COLOR_PATTERN.test(l.change.body))
274
+ box.style.setProperty("--tint", l.change.body);
275
+ return box;
276
+ }
277
+ function slotList(l) {
278
+ const entries = Object.entries(l.slots ?? {});
279
+ if (!entries.length)
280
+ return null;
281
+ 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)));
282
+ }
283
+ function viewCard(l, s, total) {
284
+ const share = s && total > 0 && s.paidCents > 0 ? Math.round((s.paidCents / total) * 100) : 0;
285
+ const bar = h("div", { class: "share-bar" });
286
+ bar.style.setProperty("--share", `${share}%`);
287
+ 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)"), h("span", { class: "fine" }, l.priceUsd === 0 ? "Every player starts with it" : soldText(s)), share ? h("div", { class: "share", title: `${share}% of the money from sales` }, bar, h("span", { class: "fine" }, `${share}% of sales`)) : null, slotList(l)));
288
+ }
289
+ // ---- To edit ----
290
+ function editGrid(st, own) {
291
+ const bad = problems();
292
+ const diff = diffLooks(LOOKS, draft);
293
+ const added = new Set(diff.added.map((l) => l.id));
294
+ const changed = new Set(diff.changed.map((c) => c.id));
295
+ const grid = h("div", { class: "shelf-list is-editing" });
296
+ draft.forEach((l, i) => {
297
+ const mine = bad.filter((p) => p.look === i + 1);
298
+ const mark = added.has(l.id) ? "new" : changed.has(l.id) ? "changed" : null;
299
+ grid.append(l === open ? editCard(l, i, st, own, mine, mark) : compactCard(l, st, own, mine, mark));
300
+ });
301
+ for (const gone of diff.removed)
302
+ grid.append(ghostCard(gone));
303
+ grid.append(h("button", { class: "shelf-add-tile", type: "button", onclick: () => addLook() }, icon("plus", 22), h("span", {}, "Add a look")));
304
+ return grid;
305
+ }
306
+ function marks(l, own, mine, mark) {
307
+ return h("span", { class: "marks" }, ...markList(l, own, mine, mark));
308
+ }
309
+ function markList(l, own, mine, mark) {
310
+ return [
311
+ mark ? h("span", { class: `mark mark-${mark}` }, mark === "new" ? "New" : "Changed") : null,
312
+ isOriginal(l.id) && own.has(l.id) ? h("span", { class: "mark mark-owned", title: "A player owns this look" }, icon("lock", 14), "Owned") : null,
313
+ mine.length ? h("span", { class: "mark mark-bad" }, plural(mine.length, "problem")) : null,
314
+ ];
315
+ }
316
+ function compactCard(l, st, own, mine, mark) {
317
+ 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)), 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"), 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"));
318
+ }
319
+ function ghostCard(l) {
320
+ 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", {
321
+ class: "btn btn-small",
322
+ type: "button",
323
+ onclick: () => {
324
+ const at = Math.min(LOOKS.findIndex((x) => x.id === l.id), draft.length);
325
+ draft = [...draft.slice(0, at), ...clone([l]), ...draft.slice(at)];
326
+ redraw();
327
+ },
328
+ }, "Put back"));
329
+ }
330
+ function openCard(l) {
331
+ open = l;
332
+ redraw();
333
+ const card = el.querySelector(".shelf-editing");
334
+ if (card) {
335
+ card.focus({ preventScroll: true });
336
+ card.scrollIntoView?.({ block: "nearest" });
337
+ }
338
+ }
339
+ function editCard(l, i, st, own, mine, mark) {
340
+ const pic = h("div", { class: "shelf-pic" }, pictureOf(l));
341
+ const stageBox = h("div", { class: "shelf-stage is-big" }, pic);
342
+ const nameLine = h("strong", { class: "shelf-name" }, l.name || "(no name)");
343
+ const priceLine = h("span", { class: "shelf-price" }, priceWords(l.priceUsd));
344
+ const tint = () => (COLOR_PATTERN.test(l.change.body) ? stageBox.style.setProperty("--tint", l.change.body) : stageBox.style.removeProperty("--tint"));
345
+ tint();
346
+ // The real picture is drawn a moment after the last change: a colour dragged across the picker draws once, not at every step.
347
+ let later = null;
153
348
  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)";
349
+ nameLine.textContent = l.name || "(no name)";
350
+ priceLine.textContent = priceWords(l.priceUsd);
351
+ tint();
352
+ if (later)
353
+ clearTimeout(later);
354
+ later = setTimeout(() => pic.replaceChildren(pictureOf(l)), 150);
355
+ ladder.querySelectorAll("button").forEach((c) => c.setAttribute("aria-pressed", Number(c.dataset.usd) === l.priceUsd ? "true" : "false"));
356
+ swatches.querySelectorAll("button").forEach((c) => c.setAttribute("aria-pressed", c.dataset.colour === l.change.body.toLowerCase() ? "true" : "false"));
357
+ hatChoices.style.setProperty("color", l.change.hatColor ?? "#9aa4b2");
158
358
  refreshFoot();
159
359
  };
160
- const name = h("input", { class: "shelf-input", type: "text", maxlength: 40, value: l.name, "aria-label": `Name of look ${i + 1}`, oninput: (e) => {
360
+ const name = h("input", { class: "input", type: "text", maxlength: 40, value: l.name, "aria-label": `Name of look ${i + 1}`, oninput: (e) => {
161
361
  l.name = e.target.value;
162
362
  if (!isOriginal(l.id))
163
363
  l.id = slugId(l.name, [...draft.filter((x) => x !== l).map((x) => x.id), ...owned()]);
164
364
  sync();
165
365
  } });
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) => {
366
+ 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
367
  const v = e.target.value;
168
368
  l.priceUsd = v === "" ? Number.NaN : Number(v);
169
369
  sync();
170
370
  } });
171
- const ladder = h("div", { class: "shelf-ladder", role: "group", "aria-label": "Price ladder" });
371
+ const ladder = h("div", { class: "ladder", role: "group", "aria-label": "Price ladder" });
172
372
  const setPrice = (usd) => {
173
373
  l.priceUsd = usd;
174
374
  priceInput.value = String(usd);
175
375
  sync();
176
376
  };
177
- ladder.append(h("button", { class: "chip", type: "button", onclick: () => setPrice(0) }, "Free"));
377
+ 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));
378
+ ladder.append(step(0, "Free", "everyone", "Every player has it"));
178
379
  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) => {
380
+ ladder.append(step(rungUsd(r), priceText(rungUsd(r)), r.name, `${r.name}: ${r.for}`));
381
+ const body = h("input", { class: "colour", type: "color", value: l.change.body, "aria-label": `Body colour of ${l.name}`, oninput: (e) => {
181
382
  l.change.body = e.target.value;
182
383
  sync();
183
384
  } });
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) => {
385
+ const swatches = h("div", { class: "swatches", role: "group", "aria-label": "Body colours" });
386
+ for (const c of BODY_COLOURS) {
387
+ 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: () => {
388
+ l.change.body = c;
389
+ body.value = c;
390
+ sync();
391
+ } }, h("span", { class: "swatch", "aria-hidden": "true" }));
392
+ b.firstChild.style.setProperty("--swatch", c);
393
+ swatches.append(b);
394
+ }
395
+ 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
396
  l.change.hatColor = e.target.value;
186
397
  sync();
187
398
  } });
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 ?? "";
399
+ const hatName = `n133-hat-${i}`;
400
+ const hatChoices = h("div", { class: "hat-choices", role: "radiogroup", "aria-label": `Hat of ${l.name}` });
401
+ for (const x of [null, ...HATS]) {
402
+ const input = h("input", { type: "radio", name: hatName, value: x ?? "", checked: (l.change.hat ?? null) === x, onchange: () => {
403
+ if (x === null) {
404
+ delete l.change.hat;
405
+ delete l.change.hatColor;
406
+ hatColor.disabled = true;
407
+ }
408
+ else {
409
+ l.change.hat = x;
410
+ l.change.hatColor ??= hatColor.value;
411
+ hatColor.disabled = false;
412
+ }
413
+ hatChoices.querySelectorAll(".hat-choice").forEach((c) => c.classList.toggle("is-on", c.querySelector("input").checked));
414
+ sync();
415
+ } });
416
+ 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")));
417
+ }
418
+ hatChoices.style.setProperty("color", l.change.hatColor ?? "#9aa4b2");
203
419
  const lastFree = l.priceUsd === 0 && freeCount() === 1;
204
420
  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));
421
+ const remove = h("button", { class: "btn btn-danger", type: "button", disabled: keep || lastFree, title: keep ? "A player owns this look. Keep it in the file." : lastFree ? "Keep one free look: every player starts with it." : "", onclick: () => {
422
+ draft = draft.filter((x) => x !== l);
423
+ open = null;
424
+ redraw();
425
+ } }, icon("trash", 18), "Remove");
426
+ const dup = h("button", { class: "btn", type: "button", onclick: () => addLook(l) }, icon("dup", 18), "Duplicate");
427
+ const openMarks = marks(l, own, mine, mark);
428
+ const openProblems = h("ul", { class: "card-problems" }, ...mine.map((p) => h("li", {}, p.message)));
429
+ openProblems.hidden = !mine.length;
430
+ openParts = { marks: openMarks, problems: openProblems };
431
+ const group = (title, ...kids) => h("div", { class: "edit-group" }, h("h3", {}, title), ...kids);
432
+ 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, 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"), 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), 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))), 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));
208
433
  }
209
434
  /** 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
435
  function slotsEditor(l, owned) {
@@ -217,7 +442,7 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
217
442
  setRows(l, cur);
218
443
  refreshFoot();
219
444
  };
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")));
445
+ 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
446
  });
222
447
  const add = h("button", { class: "btn btn-small", type: "button", disabled: rows.length >= SLOTS_MAX, onclick: () => {
223
448
  const cur = rowsOf(l);
@@ -226,39 +451,60 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
226
451
  n++;
227
452
  setRows(l, [...cur, [`slot-${n}`, ""]]);
228
453
  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);
454
+ } }, icon("plus", 16), "Add a slot");
455
+ 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
456
  }
232
457
  function addLook(from) {
233
458
  const name = from ? `${from.name} copy`.slice(0, 40) : "New look";
234
459
  const base = from ? { id: "", name, priceUsd: from.priceUsd, change: { ...from.change }, ...(from.slots ? { slots: { ...from.slots } } : {}) } : { id: "", name, priceUsd: 0.05, change: { body: "#4aa8ff" } };
235
460
  // 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
461
  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());
462
+ const at = from ? draft.indexOf(from) + 1 : draft.length;
463
+ draft = [...draft.slice(0, at), base, ...draft.slice(at)];
464
+ openCard(base);
465
+ el.querySelector(".shelf-editing input[type=text]")?.focus();
240
466
  }
241
- const footBox = h("div", { class: "shelf-foot" });
467
+ // ---- The change bar ----
468
+ //
469
+ // 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
470
+ // 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,
471
+ // the new file.
472
+ const footBar = h("div", { class: "shelf-foot", role: "region", "aria-label": "Your draft" });
473
+ const footMore = h("div", { class: "shelf-foot-more" });
242
474
  const foot = () => {
243
475
  refreshFoot();
244
- return footBox;
476
+ return [footBar, footMore];
245
477
  };
478
+ /** "1 changed", "2 added, 1 removed": only the parts that are not zero. */
479
+ 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
480
  function refreshFoot() {
247
481
  const bad = problems();
248
482
  const diff = diffLooks(LOOKS, draft);
249
483
  const changed = diff.added.length + diff.removed.length + diff.changed.length > 0;
250
484
  const text = bad.length ? "" : looksJson(draft);
485
+ const ready = !bad.length && changed;
486
+ 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
487
  const out = [];
252
488
  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
489
+ 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)))));
490
+ 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")));
491
+ if (!data.repo)
492
+ out.push(h("p", { class: "fine" }, repoHelp));
493
+ out.push(h("details", { class: "foot-more shelf-changes" }, h("summary", {}, `What changed: ${changed ? changeText(diff) : "No change yet"}`), changed
255
494
  ? 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
495
  : 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));
496
+ if (ready)
497
+ out.push(h("details", { class: "foot-more shelf-file" }, h("summary", {}, "Show the new looks.json"), h("pre", {}, text)));
498
+ footMore.replaceChildren(...out.filter((n) => n !== null));
499
+ // The open card's own marks and problems, from the same check.
500
+ if (open && openParts) {
501
+ const at = draft.indexOf(open) + 1;
502
+ const mine = bad.filter((p) => p.look === at);
503
+ const mark = diff.added.some((l) => l === open) ? "new" : diff.changed.some((c) => c.after === open) ? "changed" : null;
504
+ openParts.marks.replaceChildren(...markList(open, owned(), mine, mark).filter((n) => n !== null));
505
+ openParts.problems.replaceChildren(...mine.map((p) => h("li", {}, p.message)));
506
+ openParts.problems.hidden = !mine.length;
507
+ }
262
508
  }
263
509
  redraw();
264
510
  return {
@@ -0,0 +1,23 @@
1
+ import { type IconName } from "./icons.js";
2
+ export type TabId = "overview" | "cosmetics" | "orders" | "players" | "settings";
3
+ export declare const TABS: readonly {
4
+ id: TabId;
5
+ label: string;
6
+ icon: IconName;
7
+ }[];
8
+ export type TabBar = {
9
+ /** The bar (role tablist). */
10
+ bar: HTMLElement;
11
+ /** The panel of each tab (role tabpanel), empty: the caller fills it. */
12
+ panels: Record<TabId, HTMLElement>;
13
+ /**
14
+ * Shows a tab. `focus` moves the keyboard to it (an arrow key does; a link from another tab does not). When the page was scrolled into the panels, the top of
15
+ * the new panel comes up under the bar (a short tab after a long one must not open at its end).
16
+ */
17
+ select(id: TabId, focus?: boolean): void;
18
+ /** The tab that shows now. */
19
+ readonly current: TabId;
20
+ /** The small number on a tab ("Orders 25"); null takes it away. */
21
+ setCount(id: TabId, n: number | null): void;
22
+ };
23
+ export declare function tabBar(first?: TabId): TabBar;