@goodbones/browser 0.1.0-beta.1

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/build/dts/index.d.ts +12 -0
  3. package/build/dts/index.d.ts.map +1 -0
  4. package/build/dts/main.d.ts +3 -0
  5. package/build/dts/main.d.ts.map +1 -0
  6. package/build/dts/model/atlas.d.ts +157 -0
  7. package/build/dts/model/atlas.d.ts.map +1 -0
  8. package/build/dts/model/campaigns.d.ts +141 -0
  9. package/build/dts/model/campaigns.d.ts.map +1 -0
  10. package/build/dts/server/collect.d.ts +16 -0
  11. package/build/dts/server/collect.d.ts.map +1 -0
  12. package/build/dts/server/compose.d.ts +13 -0
  13. package/build/dts/server/compose.d.ts.map +1 -0
  14. package/build/dts/server/export.d.ts +3 -0
  15. package/build/dts/server/export.d.ts.map +1 -0
  16. package/build/dts/server/handler.d.ts +6 -0
  17. package/build/dts/server/handler.d.ts.map +1 -0
  18. package/build/dts/server/serve.d.ts +15 -0
  19. package/build/dts/server/serve.d.ts.map +1 -0
  20. package/build/dts/server/site.d.ts +2 -0
  21. package/build/dts/server/site.d.ts.map +1 -0
  22. package/build/dts/server/source.d.ts +19 -0
  23. package/build/dts/server/source.d.ts.map +1 -0
  24. package/build/dts/server/vite-plugin.d.ts +8 -0
  25. package/build/dts/server/vite-plugin.d.ts.map +1 -0
  26. package/build/dts/server/watch.d.ts +10 -0
  27. package/build/dts/server/watch.d.ts.map +1 -0
  28. package/build/esm/index.js +14 -0
  29. package/build/esm/index.js.map +1 -0
  30. package/build/esm/main.js +134 -0
  31. package/build/esm/main.js.map +1 -0
  32. package/build/esm/model/atlas.js +431 -0
  33. package/build/esm/model/atlas.js.map +1 -0
  34. package/build/esm/model/campaigns.js +181 -0
  35. package/build/esm/model/campaigns.js.map +1 -0
  36. package/build/esm/server/collect.js +83 -0
  37. package/build/esm/server/collect.js.map +1 -0
  38. package/build/esm/server/compose.js +50 -0
  39. package/build/esm/server/compose.js.map +1 -0
  40. package/build/esm/server/export.js +19 -0
  41. package/build/esm/server/export.js.map +1 -0
  42. package/build/esm/server/handler.js +67 -0
  43. package/build/esm/server/handler.js.map +1 -0
  44. package/build/esm/server/serve.js +80 -0
  45. package/build/esm/server/serve.js.map +1 -0
  46. package/build/esm/server/site.js +6 -0
  47. package/build/esm/server/site.js.map +1 -0
  48. package/build/esm/server/source.js +67 -0
  49. package/build/esm/server/source.js.map +1 -0
  50. package/build/esm/server/vite-plugin.js +21 -0
  51. package/build/esm/server/vite-plugin.js.map +1 -0
  52. package/build/esm/server/watch.js +56 -0
  53. package/build/esm/server/watch.js.map +1 -0
  54. package/build/site/_astro/Browser.yxldcnQ3.js +3 -0
  55. package/build/site/_astro/client.Buyw3Q1S.js +9 -0
  56. package/build/site/_astro/index.DQrWs1cb.css +1 -0
  57. package/build/site/_astro/react.DJY1zw8Z.js +1 -0
  58. package/build/site/favicon.svg +1 -0
  59. package/build/site/index.html +8 -0
  60. package/package.json +78 -0
  61. package/src/app/components/Browser.tsx +153 -0
  62. package/src/app/components/architecture/ArchitectureBrowser.tsx +240 -0
  63. package/src/app/components/architecture/Detail.tsx +655 -0
  64. package/src/app/components/architecture/ManifestPane.tsx +178 -0
  65. package/src/app/components/architecture/Tree.tsx +205 -0
  66. package/src/app/components/architecture/rows.ts +181 -0
  67. package/src/app/components/architecture/selection.ts +6 -0
  68. package/src/app/components/campaigns/CampaignBrowser.tsx +146 -0
  69. package/src/app/components/campaigns/Detail.tsx +653 -0
  70. package/src/app/components/campaigns/Ladder.tsx +326 -0
  71. package/src/app/components/campaigns/pick.ts +7 -0
  72. package/src/app/components/lib/data.ts +132 -0
  73. package/src/app/pages/index.astro +34 -0
  74. package/src/app/public/favicon.svg +1 -0
  75. package/src/app/styles/theme.css +993 -0
  76. package/src/index.ts +37 -0
  77. package/src/main.ts +155 -0
  78. package/src/model/atlas.ts +751 -0
  79. package/src/model/campaigns.ts +376 -0
  80. package/src/server/collect.ts +132 -0
  81. package/src/server/compose.ts +79 -0
  82. package/src/server/export.ts +24 -0
  83. package/src/server/handler.ts +77 -0
  84. package/src/server/serve.ts +100 -0
  85. package/src/server/site.ts +6 -0
  86. package/src/server/source.ts +89 -0
  87. package/src/server/vite-plugin.ts +32 -0
  88. package/src/server/watch.ts +65 -0
@@ -0,0 +1,326 @@
1
+ import type { CampaignCard, ObjectiveCard, SectorCard } from "../../../model/campaigns.js";
2
+ import type { Pick } from "./pick.js";
3
+
4
+ // The ladder, drawn: one column per phase with its objectives beneath, an end
5
+ // column, and a lane of sectors each standing in the column of its phase.
6
+ // Objectives no phase names — every objective of a campaign without phases —
7
+ // stand in a first "standing" column, in window everywhere.
8
+
9
+ const COL = 168;
10
+ const GAP = 14;
11
+ const X0 = 16;
12
+ const PHASE_Y = 14;
13
+ const PHASE_H = 52;
14
+ const CHIP_H = 24;
15
+ const CHIP_GAP = 6;
16
+ const SECTOR_H = 28;
17
+ const SECTOR_GAP = 8;
18
+
19
+ type Column =
20
+ | { readonly kind: "standing" }
21
+ | { readonly kind: "phase"; readonly index: number }
22
+ | { readonly kind: "end" };
23
+
24
+ type Props = {
25
+ readonly campaign: CampaignCard;
26
+ readonly nudged: ReadonlySet<string>;
27
+ readonly hover: Pick | null;
28
+ readonly selected: Pick | null;
29
+ readonly onHover: (pick: Pick | null) => void;
30
+ readonly onPick: (pick: Pick | null) => void;
31
+ };
32
+
33
+ const same = (a: Pick | null, b: Pick): boolean =>
34
+ a !== null &&
35
+ a.kind === b.kind &&
36
+ (a.kind === "campaign" ||
37
+ (a.kind === "sector" && b.kind === "sector"
38
+ ? a.name === b.name
39
+ : (a as { id?: string }).id === (b as { id?: string }).id));
40
+
41
+ export const columnsOf = (campaign: CampaignCard): ReadonlyArray<Column> => {
42
+ const standing = campaign.objectives.some((one) => one.phase === null);
43
+ return [
44
+ ...(standing || campaign.phases.length === 0 ? [{ kind: "standing" } as const] : []),
45
+ ...campaign.phases.map((_, index) => ({ kind: "phase", index }) as const),
46
+ { kind: "end" } as const,
47
+ ];
48
+ };
49
+
50
+ // Which column a sector stands in.
51
+ export const columnOfSector = (
52
+ campaign: CampaignCard,
53
+ columns: ReadonlyArray<Column>,
54
+ sector: SectorCard,
55
+ ): number => {
56
+ if (sector.done) return columns.length - 1;
57
+ if (campaign.phases.length === 0) return 0;
58
+ const at = columns.findIndex((one) => one.kind === "phase" && one.index === sector.phase);
59
+ return at === -1 ? 0 : at;
60
+ };
61
+
62
+ const truncate = (text: string, max: number): string =>
63
+ text.length <= max ? text : `${text.slice(0, max - 1)}…`;
64
+
65
+ const scalarLabel = (objective: ObjectiveCard): string => {
66
+ const measure = objective.measure;
67
+ if (measure === null) return String(objective.count);
68
+ const value = measure.value === null ? "?" : String(measure.value);
69
+ return measure.target === null ? value : `${value}→${String(measure.target)}`;
70
+ };
71
+
72
+ export const Ladder = ({
73
+ campaign,
74
+ hover,
75
+ nudged,
76
+ onHover,
77
+ onPick,
78
+ selected,
79
+ }: Props): React.JSX.Element => {
80
+ const columns = columnsOf(campaign);
81
+ const focus = hover ?? selected;
82
+ const x = (column: number): number => X0 + column * (COL + GAP);
83
+ const objectivesIn = (column: Column): ReadonlyArray<ObjectiveCard> =>
84
+ column.kind === "standing"
85
+ ? campaign.objectives.filter((one) => one.phase === null)
86
+ : column.kind === "phase"
87
+ ? campaign.objectives.filter((one) => one.phase === campaign.phases[column.index]?.id)
88
+ : [];
89
+ const maxChips = Math.max(1, ...columns.map((column) => objectivesIn(column).length));
90
+ const laneY = PHASE_Y + PHASE_H + 14 + maxChips * (CHIP_H + CHIP_GAP) + 18;
91
+ const placed = campaign.sectors.map((sector) => ({
92
+ sector,
93
+ column: columnOfSector(campaign, columns, sector),
94
+ }));
95
+ const rowsIn = new Map<number, number>();
96
+ const withRows = placed.map((one) => {
97
+ const row = rowsIn.get(one.column) ?? 0;
98
+ rowsIn.set(one.column, row + 1);
99
+ return { ...one, row };
100
+ });
101
+ const maxRows = Math.max(1, ...rowsIn.values());
102
+ const laneH = maxRows * (SECTOR_H + SECTOR_GAP) + 12;
103
+ const width = x(columns.length) + 8;
104
+ const height = laneY + laneH + 24;
105
+
106
+ // What the focus relates to.
107
+ const focusObjective =
108
+ focus?.kind === "objective"
109
+ ? campaign.objectives.find((one) => one.id === focus.id)
110
+ : undefined;
111
+ const focusPhaseIndex =
112
+ focus?.kind === "phase" ? campaign.phases.findIndex((one) => one.id === focus.id) : -1;
113
+ const focusSector =
114
+ focus?.kind === "sector" ? campaign.sectors.find((one) => one.name === focus.name) : undefined;
115
+ const sectorLit = (sector: SectorCard): boolean =>
116
+ focusObjective !== undefined
117
+ ? (sector.residue[focusObjective.id] ?? sector.counts[focusObjective.id] ?? 0) > 0
118
+ : focusPhaseIndex !== -1
119
+ ? sector.phase === focusPhaseIndex && !sector.done
120
+ : focusSector !== undefined
121
+ ? focusSector.name === sector.name
122
+ : false;
123
+ const objectiveLit = (objective: ObjectiveCard): boolean =>
124
+ focusSector !== undefined
125
+ ? (focusSector.residue[objective.id] ?? 0) > 0
126
+ : focusPhaseIndex !== -1
127
+ ? objective.phase === campaign.phases[focusPhaseIndex]?.id
128
+ : focusObjective !== undefined
129
+ ? focusObjective.id === objective.id
130
+ : false;
131
+ const phaseLit = (index: number): boolean =>
132
+ focusSector !== undefined
133
+ ? focusSector.phase === index && !focusSector.done
134
+ : focusObjective !== undefined
135
+ ? focusObjective.phase === campaign.phases[index]?.id
136
+ : focusPhaseIndex === index;
137
+ const dimmed = focus !== null && focus.kind !== "campaign";
138
+
139
+ return (
140
+ <svg
141
+ className={`ladder ${dimmed ? "focused" : ""}`}
142
+ width={width}
143
+ height={height}
144
+ viewBox={`0 0 ${String(width)} ${String(height)}`}
145
+ role="img"
146
+ aria-label={`Campaign ${campaign.id}: ${String(campaign.phases.length)} phases, ${String(campaign.sectors.length)} sectors`}
147
+ onMouseLeave={() => {
148
+ onHover(null);
149
+ }}
150
+ >
151
+ <defs>
152
+ <marker
153
+ id="ladder-arrow"
154
+ viewBox="0 0 10 10"
155
+ refX="9"
156
+ refY="5"
157
+ markerWidth="7"
158
+ markerHeight="7"
159
+ orient="auto-start-reverse"
160
+ >
161
+ <path d="M0,0 L10,5 L0,10 z" className="arrowhead" />
162
+ </marker>
163
+ </defs>
164
+
165
+ {columns.map((column, i) => {
166
+ const cx = x(i);
167
+ const objectives = objectivesIn(column);
168
+ if (column.kind === "end") {
169
+ const done = campaign.sectors.filter((one) => one.done).length;
170
+ return (
171
+ <g key="end" className={`column end ${campaign.complete ? "lit" : ""}`}>
172
+ <rect x={cx} y={PHASE_Y} width={COL} height={PHASE_H} rx="8" className="phase end" />
173
+ <text x={cx + 12} y={PHASE_Y + 19} className="eyebrow end-text">
174
+ THE END
175
+ </text>
176
+ <text x={cx + 12} y={PHASE_Y + 38} className="phase-name end-text">
177
+ end state
178
+ </text>
179
+ <text x={cx + 12} y={PHASE_Y + PHASE_H + 20} className="small muted">
180
+ {done} of {campaign.sectors.length} sectors there
181
+ </text>
182
+ </g>
183
+ );
184
+ }
185
+ const phase = column.kind === "phase" ? campaign.phases[column.index] : undefined;
186
+ const lit =
187
+ column.kind === "phase"
188
+ ? phaseLit(column.index)
189
+ : focus?.kind === "objective" && focusObjective?.phase === null;
190
+ const pick: Pick | null = phase === undefined ? null : { kind: "phase", id: phase.id };
191
+ return (
192
+ <g
193
+ key={column.kind === "phase" ? `phase:${String(column.index)}` : "standing"}
194
+ className={`column ${lit ? "lit" : ""}`}
195
+ >
196
+ <g
197
+ className={`phase-box ${phase === undefined ? "standing" : phase.defined ? "defined" : "open"} ${pick !== null && same(selected, pick) ? "selected" : ""}`}
198
+ onMouseEnter={() => {
199
+ onHover(pick);
200
+ }}
201
+ onClick={() => {
202
+ onPick(pick);
203
+ }}
204
+ >
205
+ <rect x={cx} y={PHASE_Y} width={COL} height={PHASE_H} rx="8" className="phase" />
206
+ <text x={cx + 12} y={PHASE_Y + 19} className="eyebrow">
207
+ {phase === undefined || column.kind !== "phase"
208
+ ? "STANDING"
209
+ : phase.defined
210
+ ? `PHASE ${String(column.index + 1)}`
211
+ : "OPEN"}
212
+ {phase?.attested === true ? " · ATTESTED" : ""}
213
+ </text>
214
+ <text x={cx + 12} y={PHASE_Y + 38} className="phase-name">
215
+ {phase === undefined ? "in window everywhere" : truncate(phase.id, 20)}
216
+ </text>
217
+ {phase !== undefined ? (
218
+ <text x={cx + COL - 10} y={PHASE_Y + 38} className="phase-count" textAnchor="end">
219
+ {phase.sectors}
220
+ </text>
221
+ ) : null}
222
+ </g>
223
+ {i < columns.length - 1 ? (
224
+ <line
225
+ x1={cx + COL + 1}
226
+ y1={PHASE_Y + PHASE_H / 2}
227
+ x2={cx + COL + GAP - 1}
228
+ y2={PHASE_Y + PHASE_H / 2}
229
+ className="connector"
230
+ markerEnd="url(#ladder-arrow)"
231
+ />
232
+ ) : null}
233
+ {phase !== undefined && !phase.defined && phase.intent !== null ? (
234
+ <text x={cx + 12} y={PHASE_Y + PHASE_H + 20} className="intent">
235
+ “{truncate(phase.intent, 26)}”
236
+ </text>
237
+ ) : null}
238
+ {objectives.map((objective, j) => {
239
+ const y =
240
+ PHASE_Y +
241
+ PHASE_H +
242
+ 14 +
243
+ j * (CHIP_H + CHIP_GAP) +
244
+ (phase !== undefined && !phase.defined ? 14 : 0);
245
+ const objectivePick: Pick = { kind: "objective", id: objective.id };
246
+ return (
247
+ <g
248
+ key={objective.id}
249
+ className={`chip-box ${objectiveLit(objective) ? "lit" : ""} ${objective.complete ? "complete" : ""} ${same(selected, objectivePick) ? "selected" : ""} ${objective.ledgered ? "" : "unledgered"}`}
250
+ onMouseEnter={() => {
251
+ onHover(objectivePick);
252
+ }}
253
+ onClick={() => {
254
+ onPick(objectivePick);
255
+ }}
256
+ >
257
+ <rect x={cx + 4} y={y} width={COL - 8} height={CHIP_H} rx="12" className="chip" />
258
+ <text x={cx + 14} y={y + 16} className="chip-text mono">
259
+ {truncate(objective.id, 18)}
260
+ </text>
261
+ <text x={cx + COL - 14} y={y + 16} className="chip-count" textAnchor="end">
262
+ {scalarLabel(objective)}
263
+ </text>
264
+ <title>
265
+ {objective.message}
266
+ {objective.measure === null
267
+ ? ` — ${String(objective.count)} holdout(s), ${String(Math.round(objective.progress * 100))}% cleared`
268
+ : ` — ${String(objective.measure.value ?? "?")} now, target ${String(objective.measure.target ?? "none")}`}
269
+ </title>
270
+ </g>
271
+ );
272
+ })}
273
+ </g>
274
+ );
275
+ })}
276
+
277
+ <line x1={X0} y1={laneY - 8} x2={width - 8} y2={laneY - 8} className="rule" />
278
+ <text x={X0} y={laneY - 14} className="label">
279
+ Sectors
280
+ </text>
281
+ <text x={X0 + 60} y={laneY - 14} className="small muted">
282
+ holdouts left
283
+ </text>
284
+ {columns.map((_, i) => (
285
+ <rect key={i} x={x(i)} y={laneY} width={COL} height={laneH} rx="8" className="lane" />
286
+ ))}
287
+ {withRows.map(({ column, row, sector }) => {
288
+ const cx = x(column) + 4;
289
+ const cy = laneY + 6 + row * (SECTOR_H + SECTOR_GAP);
290
+ const left = Object.entries(sector.residue).reduce((sum, [, n]) => sum + n, 0);
291
+ const pick: Pick = { kind: "sector", name: sector.name };
292
+ const kind = sector.done ? "done" : sector.legacy ? "legacy" : "sector";
293
+ return (
294
+ <g
295
+ key={sector.name}
296
+ className={`sector-box ${kind} ${sectorLit(sector) ? "lit" : ""} ${same(selected, pick) ? "selected" : ""} ${nudged.has(sector.name) ? "nudged" : ""} ${sector.stalled ? "stalled" : ""}`}
297
+ onMouseEnter={() => {
298
+ onHover(pick);
299
+ }}
300
+ onClick={() => {
301
+ onPick(pick);
302
+ }}
303
+ >
304
+ <rect x={cx} y={cy} width={COL - 8} height={SECTOR_H} rx="6" className="sector" />
305
+ <text x={cx + 10} y={cy + 19} className="sector-name mono">
306
+ {truncate(sector.name, 16)}
307
+ </text>
308
+ <text x={cx + COL - 18} y={cy + 19} className="sector-count" textAnchor="end">
309
+ {sector.done ? "done" : sector.stalled ? `${String(left)} ⏸` : String(left)}
310
+ </text>
311
+ <title>
312
+ {sector.name}: {String(sector.files.length)} files
313
+ {sector.phaseId === null ? "" : `, at ${sector.phaseId}`}
314
+ {sector.stalled ? ", stalled" : ""}
315
+ </title>
316
+ </g>
317
+ );
318
+ })}
319
+ {campaign.sectors.length === 0 ? (
320
+ <text x={X0 + 12} y={laneY + 24} className="small muted">
321
+ no sectors
322
+ </text>
323
+ ) : null}
324
+ </svg>
325
+ );
326
+ };
@@ -0,0 +1,7 @@
1
+ // What the Campaign Browser can hold: the campaign itself, or one of its
2
+ // phases, objectives or sectors.
3
+ export type Pick =
4
+ | { readonly kind: "campaign" }
5
+ | { readonly kind: "phase"; readonly id: string }
6
+ | { readonly kind: "objective"; readonly id: string }
7
+ | { readonly kind: "sector"; readonly name: string };
@@ -0,0 +1,132 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ // The page's two reads and its one feed, all relative to the page: the bin
4
+ // and the Vite plugin answer `__goodbones/*` beside it, and a static export
5
+ // has them as files. Where the feed is absent the page is a snapshot.
6
+
7
+ export type Loaded<T> =
8
+ | { readonly state: "loading"; readonly value: T | null; readonly error: null }
9
+ | { readonly state: "ready"; readonly value: T; readonly error: null }
10
+ | { readonly state: "failed"; readonly value: T | null; readonly error: string };
11
+
12
+ const urlOf = (route: string): string => new URL(`__goodbones/${route}`, document.baseURI).href;
13
+
14
+ const errorOf = (body: unknown, status: number): string =>
15
+ typeof body === "object" && body !== null && "error" in body
16
+ ? String(body.error)
17
+ : `HTTP ${String(status)}`;
18
+
19
+ // `version` bumps when the feed says the repository changed; the value is
20
+ // kept while the next one loads, so the page never blanks.
21
+ export const useModel = <T>(route: string, version: number): Loaded<T> => {
22
+ const [loaded, setLoaded] = useState<Loaded<T>>({ state: "loading", value: null, error: null });
23
+ useEffect(() => {
24
+ let cancelled = false;
25
+ setLoaded((previous) => ({ state: "loading", value: previous.value, error: null }));
26
+ fetch(urlOf(route), { cache: "no-store" })
27
+ .then(async (response) => {
28
+ const body: unknown = await response.json();
29
+ if (cancelled) return;
30
+ if (!response.ok) {
31
+ setLoaded((previous) => ({
32
+ state: "failed",
33
+ value: previous.value,
34
+ error: errorOf(body, response.status),
35
+ }));
36
+ return;
37
+ }
38
+ setLoaded({ state: "ready", value: body as T, error: null });
39
+ })
40
+ .catch((cause: unknown) => {
41
+ if (cancelled) return;
42
+ setLoaded((previous) => ({ state: "failed", value: previous.value, error: String(cause) }));
43
+ });
44
+ return () => {
45
+ cancelled = true;
46
+ };
47
+ }, [route, version]);
48
+ return loaded;
49
+ };
50
+
51
+ export type Feed = {
52
+ readonly live: boolean;
53
+ readonly version: number;
54
+ readonly changed: ReadonlyArray<string>;
55
+ };
56
+
57
+ export const useFeed = (): Feed => {
58
+ const [feed, setFeed] = useState<Feed>({ live: false, version: 0, changed: [] });
59
+ useEffect(() => {
60
+ if (typeof EventSource === "undefined") return undefined;
61
+ const events = new EventSource(urlOf("events"));
62
+ events.addEventListener("hello", () => {
63
+ setFeed((previous) => ({ ...previous, live: true }));
64
+ });
65
+ events.addEventListener("changed", (event) => {
66
+ let files: ReadonlyArray<string> = [];
67
+ try {
68
+ const data: unknown = JSON.parse((event as MessageEvent<string>).data);
69
+ if (
70
+ typeof data === "object" &&
71
+ data !== null &&
72
+ "files" in data &&
73
+ Array.isArray(data.files)
74
+ ) {
75
+ files = data.files.map(String);
76
+ }
77
+ } catch {
78
+ files = [];
79
+ }
80
+ setFeed((previous) => ({ live: true, version: previous.version + 1, changed: files }));
81
+ });
82
+ events.addEventListener("error", () => {
83
+ // A static export has no feed: the first error is the last.
84
+ if (events.readyState === EventSource.CLOSED) {
85
+ setFeed((previous) => ({ ...previous, live: false }));
86
+ }
87
+ });
88
+ return () => {
89
+ events.close();
90
+ };
91
+ }, []);
92
+ return feed;
93
+ };
94
+
95
+ // The page's route, in the hash: `#architecture` or `#campaigns`, with a
96
+ // selection after a slash (`#architecture/packages/core/src/index.ts`).
97
+ export type Route = {
98
+ readonly tab: "architecture" | "campaigns";
99
+ readonly selection: string | null;
100
+ };
101
+
102
+ export const routeOf = (hash: string): Route => {
103
+ const trimmed = hash.replace(/^#/, "");
104
+ const at = trimmed.indexOf("/");
105
+ const tab = at === -1 ? trimmed : trimmed.slice(0, at);
106
+ const selection = at === -1 ? null : decodeURIComponent(trimmed.slice(at + 1));
107
+ return { tab: tab === "campaigns" ? "campaigns" : "architecture", selection };
108
+ };
109
+
110
+ export const hashOf = (route: Route): string =>
111
+ `#${route.tab}${route.selection === null ? "" : `/${encodeURIComponent(route.selection)}`}`;
112
+
113
+ export const useRoute = (): readonly [Route, (route: Route) => void] => {
114
+ const [route, setRoute] = useState<Route>(() => routeOf(window.location.hash));
115
+ useEffect(() => {
116
+ const onChange = (): void => {
117
+ setRoute(routeOf(window.location.hash));
118
+ };
119
+ window.addEventListener("hashchange", onChange);
120
+ return () => {
121
+ window.removeEventListener("hashchange", onChange);
122
+ };
123
+ }, []);
124
+ return [
125
+ route,
126
+ (next) => {
127
+ const hash = hashOf(next);
128
+ if (window.location.hash !== hash) window.history.replaceState(null, "", hash);
129
+ setRoute(next);
130
+ },
131
+ ];
132
+ };
@@ -0,0 +1,34 @@
1
+ ---
2
+ import "../styles/theme.css";
3
+
4
+ import { Browser } from "../components/Browser.js";
5
+
6
+ // The one page: both browsers as a React island rendered in the browser only,
7
+ // since everything they draw comes from `__goodbones/*` beside this file.
8
+ ---
9
+
10
+ <!doctype html>
11
+ <html lang="en" data-theme="dark">
12
+ <head>
13
+ <meta charset="utf-8" />
14
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
15
+ <title>goodbones browser</title>
16
+ <meta
17
+ name="description"
18
+ content="The Architecture Browser and the Campaign Browser: a repository's tree with its architecture.yaml laid over it, and every campaign's phases, sectors and holdouts."
19
+ />
20
+ <link rel="icon" href="favicon.svg" type="image/svg+xml" />
21
+ <script is:inline>
22
+ try {
23
+ const held = window.localStorage.getItem("goodbones-theme");
24
+ if (held === "light" || held === "dark") document.documentElement.dataset.theme = held;
25
+ else if (window.matchMedia("(prefers-color-scheme: light)").matches)
26
+ document.documentElement.dataset.theme = "light";
27
+ } catch {}
28
+ </script>
29
+ </head>
30
+ <body>
31
+ <Browser client:only="react" />
32
+ <noscript>The goodbones browser needs JavaScript.</noscript>
33
+ </body>
34
+ </html>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#141311"/><path d="M8 8h6v6H8zM18 8h6v6h-6zM8 18h6v6H8zM18 18h6v6h-6z" fill="#e0714a"/><path d="M14 11h4M11 14v4M21 14v4M14 21h4" stroke="#f6f2ea" stroke-width="1.5"/></svg>