@motujs/host 0.0.0-stage → 0.1.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.
package/src/views.mjs ADDED
@@ -0,0 +1,936 @@
1
+ // The pages the host renders itself: the composed multi-repo lagoon, and the two indexes.
2
+ //
3
+ // Every colour, surface and motion here comes from `@motujs/chrome` — the same tokens the tide line
4
+ // paints the lagoon with. These pages had their own dark-slate palette first, which looked like a
5
+ // different product sitting in front of motu's; that is what `@motujs/chrome` exists to prevent, and
6
+ // nothing in this file may reintroduce a literal colour.
7
+ //
8
+ // WHY THE COMPOSED LAGOON IS IFRAMES, and not one merged bundle. Merging pre-built archipelagos from
9
+ // different repositories walks into the three problems motu's README already declines:
10
+ // - React: the lagoon installs no React, it dedupes onto the host app's copy — two Reacts in one
11
+ // document break hooks the moment an island renders a component from the host's library.
12
+ // - CSS: `--host next` defaults isolation to `light` precisely so islands SEE the host's Tailwind,
13
+ // so two light-DOM regions from two repos collide (preflight twice, same utility names from
14
+ // different configs) — and it fails VISUALLY AND SILENTLY, which is the failure class motu
15
+ // exists to delete.
16
+ // - Version skew between independently built fragments is the micro-frontend problem "one artifact,
17
+ // one deploy, one version" refuses outright.
18
+ // A frame gives each archipelago its own React, its own stylesheet and its own document for free, and
19
+ // the intermediate representation it needs is the self-contained page `publish` ALREADY emits. No
20
+ // second build stage, no packager that has to understand anyone's bundle.
21
+ //
22
+ // The line this must not cross: the composed lagoon is a VIEWING SURFACE. It is never a deploy
23
+ // target and never what `motu island verify` drives — verify keeps driving lagoon.html directly, one
24
+ // document, no frames. Compose islands at runtime for production and this stops being a gallery and
25
+ // starts being federation.
26
+ import {
27
+ motuChromeCss,
28
+ motuPage,
29
+ motuBay,
30
+ motuMark,
31
+ motuPanel,
32
+ motuRow,
33
+ motuRailedList,
34
+ motuPill,
35
+ motuMeter,
36
+ escapeHtml,
37
+ PRIMARY_DETECT_JS,
38
+ PRIMARY_VAR_NAMES,
39
+ } from '@motujs/chrome';
40
+ import { motuDockCss, motuDockJs } from '@motujs/chrome/dock';
41
+
42
+ /** kB / MB, whichever reads. Sizes here are artifact sizes, and 431 kB is more useful than 0.4 MB. */
43
+ function size(bytes) {
44
+ return bytes >= 1048576 ? `${(bytes / 1048576).toFixed(1)} MB` : `${Math.round(bytes / 1024)} kB`;
45
+ }
46
+
47
+ const when = (iso) => escapeHtml(iso.slice(0, 16).replace('T', ' '));
48
+
49
+ // --- the composed lagoon ------------------------------------------------------------------------
50
+
51
+ const SHELL_CSS = `
52
+ html, body { height: 100%; overflow: hidden; }
53
+ .wrap { display: flex; height: 100%; }
54
+ aside {
55
+ width: 268px;
56
+ flex: none;
57
+ display: flex;
58
+ flex-direction: column;
59
+ overflow: auto;
60
+ background: var(--surface-page);
61
+ border-right: 1px solid var(--line);
62
+ }
63
+ /* Sticky, not merely first: the rail scrolls inside the aside — most of all in the stacked layout,
64
+ where the aside is capped at 40vh — and a header that scrolls away takes the only label saying
65
+ WHICH composed lagoon this is with it. */
66
+ aside .motu-bay { flex: none; position: sticky; top: 0; z-index: 2; }
67
+ aside .rail { padding: 12px 10px; display: flex; flex-direction: column; gap: 4px; }
68
+ .rail .motu-cap { padding: 10px 8px 4px; }
69
+ /* THE WATER IS A READOUT, the same rule the lagoon's own chrome follows: the gauge down the left of a
70
+ row is depth — faint for a lagoon sitting there, full and lit for the one on screen. */
71
+ button.member {
72
+ position: relative; overflow: hidden;
73
+ display: flex; align-items: stretch; gap: 10px;
74
+ width: 100%;
75
+ text-align: left;
76
+ padding: 9px 11px 9px 0;
77
+ border: 1px solid var(--line);
78
+ border-radius: 8px;
79
+ /* FLAT, the same surface motu-row uses. A vertical tint inside a selected card read as a different
80
+ era of UI beside the lens and the dock — the colour belongs in the gauge, which is a readout, not
81
+ in the surface behind the text. Only the BAY and the panels carry a gradient.
82
+ (No backticks in here: this whole stylesheet is a template literal, and one ends it.) */
83
+ background: var(--surface-row);
84
+ color: var(--ink);
85
+ font: 600 12.5px/1.35 inherit;
86
+ cursor: pointer;
87
+ animation: motu-swim 260ms cubic-bezier(.2,.9,.3,1) both;
88
+ transition: border-color 160ms ease, background 160ms ease, transform 160ms cubic-bezier(.2,.9,.3,1);
89
+ }
90
+ button.member .gauge {
91
+ flex: none; width: 4px; margin: 2px 0; border-radius: 999px;
92
+ background: linear-gradient(180deg, var(--w-shallow), var(--w-deep));
93
+ opacity: .3; transition: opacity 160ms ease, width 160ms cubic-bezier(.2,.9,.3,1);
94
+ }
95
+ button.member .body { display: flex; flex-direction: column; gap: 5px; min-width: 0; padding: 1px 0; }
96
+ button.member .name { display: block; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
97
+ button.member .tags { display: flex; flex-wrap: wrap; gap: 4px; }
98
+ button.member .tag {
99
+ font-style: normal; font-weight: 600; font-size: 10.5px; line-height: 1;
100
+ padding: 4px 8px; border-radius: 999px;
101
+ background: rgba(11,111,104,.08); color: var(--w-deep);
102
+ }
103
+ button.member .tag.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: -.02em; }
104
+ button.member .tag.faint { background: none; color: var(--ink-muted); padding-left: 2px; }
105
+ button.member:hover { border-color: var(--w-shallow); transform: translateX(2px); }
106
+ button.member:hover .gauge { opacity: .65; }
107
+ button.member:focus-visible { outline: 2px solid var(--w-mid); outline-offset: 2px; }
108
+ button.member[aria-current="true"] {
109
+ border-color: var(--tide-accent);
110
+ background: rgba(255, 255, 255, .95);
111
+ }
112
+ button.member[aria-current="true"] .gauge { opacity: 1; width: 6px; }
113
+ button.member[aria-current="true"] .tag { background: rgba(11,111,104,.07); }
114
+ /* One sweep when a lagoon becomes the one on screen — the tide arriving, not a spinner. */
115
+ button.member[aria-current="true"]::after {
116
+ content: ''; position: absolute; inset: 0; pointer-events: none;
117
+ background: linear-gradient(100deg, transparent 20%, rgba(255,255,255,.5) 50%, transparent 80%);
118
+ transform: translateX(-100%); animation: motu-sheen 720ms ease-out 1;
119
+ }
120
+ /* LIVE is a state, not a decoration: this member is being served by a dev server right now, so what is
121
+ in the frame can change under you — which is exactly what you asked for, and worth saying. */
122
+ /* LIVE is a state, not a decoration: this member is being served by a dev server right now, so what is
123
+ in the frame can change under you. It breathes, because that is the difference between "this is a
124
+ build" and "this is someone's editor". */
125
+ /* The ring itself is .motu-breathe in the kit, which this element also carries. What stays here is
126
+ only where it SITS in a member row — a second @keyframes motu-breathe lived here and silently
127
+ outranked the kit's for every element in any document that loaded both. */
128
+ button.member .live-dot {
129
+ float: right; margin-left: 8px;
130
+ font-style: normal; font-weight: 800; font-size: 9px; letter-spacing: .1em;
131
+ text-transform: uppercase; color: var(--motu-on-primary);
132
+ background: var(--tide-accent); border-radius: 999px; padding: 3px 8px;
133
+ }
134
+ /* The repo caption gets the water too, so the eye groups by project before it reads a word. */
135
+ .rail .motu-cap { display: flex; align-items: center; gap: 7px; }
136
+ .rail .motu-cap::before {
137
+ content: ''; width: 6px; height: 6px; border-radius: 999px; flex: none;
138
+ background: linear-gradient(180deg, var(--w-shallow), var(--w-mid));
139
+ }
140
+ @media (prefers-reduced-motion: reduce) {
141
+ button.member, button.member .gauge { transition: none; }
142
+ button.member[aria-current="true"] { transform: none; }
143
+ button.member[aria-current="true"]::after { display: none; }
144
+ button.member .live-dot { animation: none; box-shadow: none; }
145
+ }
146
+ /* THE RAIL'S ONE ACTION. Under the members because it is about them, above the footer because the
147
+ footer is about this VIEW — when it was pinned there it read as a property of the page rather than
148
+ something to do with the project. */
149
+ .rail-action {
150
+ margin: 4px 10px 10px;
151
+ justify-content: center;
152
+ gap: 8px;
153
+ }
154
+
155
+ aside footer {
156
+ margin-top: auto; padding: 12px 14px; border-top: 1px solid var(--line);
157
+ display: flex; align-items: center; justify-content: space-between; gap: 10px;
158
+ }
159
+ aside footer > .motu-cap { min-width: 0; word-break: break-all; }
160
+ /* THE ACCOUNT ON A LIGHT FOOTER. .motu-account is the kit's, and it was drawn for the bay — white
161
+ text on water. Here it sits on the sheet's own pale surface, so the ink and the focus ring flip;
162
+ the SHAPE, the disc and the hover word are the kit's and are not restated. */
163
+ aside footer .motu-account { color: var(--ink); flex: none; }
164
+ aside footer .motu-account__out:focus-visible { outline-color: var(--tide-accent); }
165
+ main.stage { flex: 1; position: relative; background: #fff; }
166
+ main.stage iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: #fff; }
167
+
168
+ /* --- the switcher, on a phone -------------------------------------------------------------------
169
+ The rail used to stack above the stage at max-height 40vh, so on a 844px-tall phone half the screen
170
+ was a list of four buttons and the lagoon — the thing being looked at — got the rest. A composed
171
+ view exists to SHOW the lagoons, so the switcher becomes a bar you can read in one line and a sheet
172
+ you pull up when you actually want to switch. Desktop is unchanged: a 268px rail beside a wide
173
+ stage costs nothing there and is faster than any sheet. */
174
+ /* THE MARK AT RAIL SIZE. The masthead's is 30px against a 46px heading; the rail's band is 16px of
175
+ padding around a 13.5px title, so the same square would be the tallest thing in it. */
176
+ aside .motu-home .motu-mark { width: 22px; height: 22px; border-radius: 6px; }
177
+ .topbar { display: none; }
178
+ .scrim { display: none; }
179
+ @media (max-width: 760px) {
180
+ .wrap { flex-direction: column; }
181
+ .topbar {
182
+ display: flex;
183
+ align-items: center;
184
+ gap: 10px;
185
+ flex: none;
186
+ padding: 8px 10px 8px 14px;
187
+ background: var(--motu-primary);
188
+ color: var(--motu-on-primary);
189
+ border-bottom: 1px solid var(--line);
190
+ }
191
+ .topbar .motu-home .motu-mark { width: 24px; height: 24px; border-radius: 6px; }
192
+ .topbar .who { min-width: 0; flex: 1; line-height: 1.25; }
193
+ .topbar .who strong { display: block; font: 700 14px/1.25 inherit; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
194
+ .topbar .who span { display: block; font: 500 11.5px/1.3 inherit; opacity: .82; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
195
+ /* THE SHAPE IS THE KIT'S — .motu-btn[data-weight="water"], which this and the review console's
196
+ sheet each drew for themselves before it existed there. What stays is the one thing that is
197
+ genuinely about THIS button: the caret that says it opens a sheet. */
198
+ .topbar button::after { content: '▾'; font-size: 11px; opacity: .9; }
199
+
200
+ /* The rail becomes a bottom sheet. Same markup, same buttons — only where it sits changes, so the
201
+ switcher cannot drift into two implementations. */
202
+ aside {
203
+ position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
204
+ width: auto; max-height: 76vh;
205
+ border-right: 0; border-top: 0;
206
+ border-radius: 16px 16px 0 0;
207
+ /* THE LENS'S OWN TREATMENT: its panel is this surface, this shadow, and a backdrop blur. The
208
+ sheet had a flat page background and read as a different surface to the one motu shows over
209
+ every lagoon. overflow:hidden is what lets the bay fill the rounded top corners. */
210
+ overflow: hidden;
211
+ background: linear-gradient(180deg, rgba(247, 253, 252, .96), rgba(232, 248, 246, .94));
212
+ backdrop-filter: blur(14px) saturate(1.35);
213
+ -webkit-backdrop-filter: blur(14px) saturate(1.35);
214
+ box-shadow: 0 -14px 40px rgba(11, 111, 104, .22);
215
+ transform: translateY(100%);
216
+ transition: transform 220ms cubic-bezier(.2,.9,.3,1);
217
+ /* NO SAFE-AREA INSET. Nothing here sets viewport-fit=cover, so this is specified to be zero —
218
+ and on Firefox for Android it is not, which is what doubled the dock's bottom bar. acme deleted
219
+ every one of these for the same reason (acme e0eeea7). */
220
+ }
221
+ body.sheet-open aside { transform: translateY(0); }
222
+ body.sheet-dragging aside { transition: none; }
223
+ aside .grab { display: block; }
224
+ .scrim {
225
+ display: block; position: fixed; inset: 0; z-index: 39;
226
+ background: rgba(6, 46, 43, .42);
227
+ opacity: 0; pointer-events: none; transition: opacity 220ms ease;
228
+ }
229
+ body.sheet-open .scrim { opacity: 1; pointer-events: auto; }
230
+ /* Comfortable targets: this is the one control on the page a thumb uses. */
231
+ button.member { padding: 12px 13px; font-size: 13.5px; }
232
+ aside footer { padding-bottom: 16px; }
233
+ }
234
+ /* The drag handle only means anything on the sheet, and it rides ON the water. */
235
+ .grab { display: none; padding: 2px 0 8px; cursor: grab; touch-action: none; }
236
+ .grab i { display: block; width: 40px; height: 4px; margin: 0 auto; border-radius: 999px; background: rgba(255,255,255,.5); }
237
+ `;
238
+
239
+ /**
240
+ * One frame per member, created on FIRST selection and then kept mounted.
241
+ *
242
+ * Lazy because a composed lagoon is N × ~430 kB of inlined bundle and nobody looks at all of it;
243
+ * kept because re-pointing a single frame would throw away whatever state you had just driven the
244
+ * region into, which is the one thing you opened it to look at.
245
+ */
246
+ // `docTitle` defaults to '' rather than null so its inferred type is a STRING. These files are plain
247
+ // .mjs read through allowJs, and a `= null` default infers as `null | undefined` — which makes every
248
+ // caller passing a real title a type error, with the error pointing at the caller.
249
+ /**
250
+ * ONE LAGOON, WITH A RAIL — the shell served at `/<repo>/<ref>/<slug>`.
251
+ *
252
+ * This was `composedPage({ group, id, … })`, and a group was the only thing that had one. `b1719dd`
253
+ * had already decided that was backwards — "a group was never a thing to browse; it is a way of
254
+ * LOOKING at lagoons" — and had already built the half that mattered here (`frameHref`, an explicit
255
+ * per-lagoon address, so no index has to agree with anything). What it did not do is give the
256
+ * canonical lagoon URL this shell, so the group route stayed the only way to get a rail, a dock and a
257
+ * lens, and groups outlived their own removal.
258
+ *
259
+ * `members[focus]` is the lagoon this URL names; the rest is everything else the viewer may see.
260
+ * There is no group id and no manifest, because there is nothing curated left to pin.
261
+ */
262
+ export function lagoonPage({ members, live = false, focus = 0, docTitle = '' }) {
263
+ const byRepo = new Map();
264
+ members.forEach((m, i) => {
265
+ if (!byRepo.has(m.repo)) byRepo.set(m.repo, []);
266
+ byRepo.get(m.repo).push({ ...m, i });
267
+ });
268
+
269
+ const rail = [...byRepo.entries()]
270
+ .map(
271
+ ([repo, list]) =>
272
+ `<div class="motu-cap">${escapeHtml(repo)}</div>` +
273
+ list
274
+ .map(
275
+ (m) =>
276
+ `<button class="member" data-i="${m.i}" aria-current="${m.i === focus}" data-live="${m.live ? 'true' : 'false'}"` +
277
+ // AN EXPLICIT ADDRESS, when the caller has one. A GROUP's frames are relative (`f/<i>`)
278
+ // because a group is a named thing with its own URL to be relative to. The rail on an
279
+ // individual lagoon is not: its members are whole lagoons with addresses of their own,
280
+ // and pointing each frame at its own bare page means no index has to agree with
281
+ // anything — the class of bug that made the shell and the frames move together.
282
+ (m.frameHref ? ` data-href="${escapeHtml(m.frameHref)}"` : '') +
283
+ (m.brand ? ` data-brand="${escapeHtml(m.brand)}"` : '') +
284
+ ` data-repo="${escapeHtml(m.repo)}"` +
285
+ ` data-slug="${escapeHtml(m.slug)}"` +
286
+ `>` +
287
+ `<span class="gauge" aria-hidden="true"></span>` +
288
+ `<span class="body"><span class="name">${escapeHtml(m.title || m.slug)}` +
289
+ (m.live ? `<em class="live-dot motu-breathe" title="served live by motu lagoon serve --watch">live</em>` : '') +
290
+ `</span><span class="tags"><em class="tag">${escapeHtml(m.slug)}</em>` +
291
+ (m.sha
292
+ ? `<em class="tag mono">${escapeHtml(m.sha.slice(0, 7))}</em>`
293
+ : m.live
294
+ ? `<em class="tag faint">not published yet</em>`
295
+ : '') +
296
+ `</span></span></button>`,
297
+ )
298
+ .join(''),
299
+ )
300
+ .join('');
301
+
302
+ return `<!doctype html>
303
+ <html lang="en">
304
+ <head>
305
+ <meta charset="utf-8" />
306
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
307
+ <title>${escapeHtml(docTitle || members[focus]?.title || members[focus]?.slug || 'lagoon')}</title>
308
+ <style>${motuChromeCss()}${SHELL_CSS}</style>
309
+ </head>
310
+ <body>
311
+ <div class="wrap">
312
+ <header class="topbar">
313
+ <a class="motu-home" href="/" aria-label="All repositories">${motuMark()}</a>
314
+ <div class="who">
315
+ <strong id="tb-title">${escapeHtml(members[focus]?.title || members[focus]?.slug || 'lagoon')}</strong>
316
+ <span id="tb-sub">${escapeHtml(members[focus]?.repo ?? '')} · ${members.length} lagoon${members.length === 1 ? '' : 's'}</span>
317
+ </div>
318
+ <button type="button" class="motu-btn" data-weight="water" id="tb-switch" aria-haspopup="dialog" aria-expanded="false" aria-controls="switcher">Switch</button>
319
+ </header>
320
+ <div class="scrim" id="scrim" hidden></div>
321
+ <aside id="switcher" aria-label="Choose a lagoon">
322
+ ${motuBay({
323
+ // THE RAIL IS NOT A NAMED THING any more, so the bay names what it lists. It used to carry the
324
+ // group's name, which was the one piece of vocabulary a group contributed to this screen — and
325
+ // the client swaps it for the selected lagoon anyway whenever the rail is a switcher between
326
+ // whole lagoons (`FOLLOWS_MEMBER`), which is now always.
327
+ title: 'Lagoons',
328
+ subtitle: `${members.length} lagoon${members.length === 1 ? '' : 's'}`,
329
+ compact: true,
330
+ // THE SAME WATER THE INDEX OPENS WITH, at rail size. A person reaches this view FROM that page,
331
+ // and a different gradient at the top of the sheet reads as a different place.
332
+ shape: 'masthead',
333
+ // INSIDE the bay, not above it. As a sibling it sat on the sheet's own light surface, so the
334
+ // water started an inch down and the sheet read as two stacked headers.
335
+ lead: '<div class="bay-lead grab" id="grab" aria-hidden="true"><i></i></div>',
336
+ // THE WAY OUT. A composed view is the deepest surface this host has — a lagoon, inside a
337
+ // gallery, inside a group — and until now the only way back to the index was the browser's own
338
+ // back button, which is not a way back when somebody arrived here from a shared link. The mark
339
+ // is where it already is on the front page, and it goes where a mark goes.
340
+ leading: `<a class="motu-home" href="/" aria-label="All repositories">${motuMark()}</a>`,
341
+ })}
342
+ <div class="rail">${rail}</div>
343
+ <!-- BASELINE REVIEW, HERE AND NOT ON THE FRONT PAGE. Reviewing is something you do TO a project,
344
+ so the way in belongs beside the project you are looking at — not in the masthead of the page
345
+ that lists them all, where it was a link with no subject and you had to pick one again on
346
+ arrival. data-repo is rewritten by show(i) so it always names the selected member. -->
347
+ <a class="rail-action motu-btn" data-weight="quiet" id="rail-review" href="/console">
348
+ <span aria-hidden="true">◎</span> Baseline review
349
+ </a>
350
+ <footer>
351
+ <!-- WHICH AXIS THIS URL IS ON. The latest alias moves and says so; an immutable build is what a
352
+ day looked like and keeps saying it. There used to be a "pin this view" link here, which
353
+ minted a group MANIFEST - an immutable snapshot of a curated set. Every lagoon already has
354
+ an immutable address of its own (the commit-keyed URL publish prints beside latest), so
355
+ pinning a composition was only ever needed because a composition had no other way to be
356
+ addressed. NO BACKTICKS IN HERE: this is inside a template literal, and the first one ends
357
+ the string - which is what the no-backticks test next door exists to catch. -->
358
+ <span class="motu-cap">${live ? 'today' : 'this build, forever'}</span>
359
+ <!-- WHO IS READING THIS, and the way out — filled in by the script below.
360
+ EMPTY IN THE HTML, deliberately. This page is rendered by the node host, which has no
361
+ session at all: it serves the same bytes to everyone, and guessing would mean rendering
362
+ somebody's handle for the next visitor. It asks the APP (/auth/whoami, same origin
363
+ through the proxy) once the page is up, and until it answers this is nothing rather than a
364
+ wrong state. When this shell moves into the app the answer arrives server-side and the
365
+ fetch goes away. -->
366
+ <div class="motu-account" id="account" hidden></div>
367
+ </footer>
368
+ </aside>
369
+ <main class="stage" id="stage"></main>
370
+ </div>
371
+ <style>${motuDockCss()}</style>
372
+ <script>
373
+ ${motuDockJs()}
374
+ ${PRIMARY_DETECT_JS}
375
+ (function () {
376
+ var PRIMARY_VARS = ${JSON.stringify(PRIMARY_VAR_NAMES)};
377
+ var FOCUS = ${Number(focus) || 0};
378
+ // Whether the header names the SELECTION or the view. A group's bay names the group — a stable
379
+ // thing that does not change as you move inside it. A lagoon's rail is a switcher between whole
380
+ // lagoons, so a static header would name whichever page you arrived at and then go stale the
381
+ // moment you picked another: the header saying one lagoon while the frame shows a different one.
382
+ var FOLLOWS_MEMBER = ${docTitle ? 'true' : 'false'};
383
+ var stage = document.getElementById('stage');
384
+ var frames = {};
385
+ var current = null;
386
+ function show(i) {
387
+ if (current === i) return;
388
+ if (!frames[i]) {
389
+ var f = document.createElement('iframe');
390
+ // Each member is its OWN document: its own React, its own stylesheet. That isolation is the
391
+ // whole reason composition works, so the frame gets no privileges it does not need.
392
+ f.setAttribute('title', 'lagoon frame ' + i);
393
+ f.setAttribute('loading', 'lazy');
394
+ // The member's own address when it has one, otherwise this view's relative frame.
395
+ var chosenBtn = document.querySelector('button.member[data-i="' + i + '"]');
396
+ f.src = (chosenBtn && chosenBtn.dataset.href) ? chosenBtn.dataset.href : 'f/' + i;
397
+ stage.appendChild(f);
398
+ frames[i] = f;
399
+ }
400
+ Object.keys(frames).forEach(function (k) { frames[k].style.display = (+k === i) ? 'block' : 'none'; });
401
+ Array.prototype.forEach.call(document.querySelectorAll('button.member'), function (b) {
402
+ b.setAttribute('aria-current', String(+b.dataset.i === i));
403
+ });
404
+ current = i;
405
+ var chosen = document.querySelector('button.member[data-i="' + i + '"]');
406
+ // querySelector('.name'), NOT childNodes[0] — which is the GAUGE span, and has no text. The topbar
407
+ // has been setting itself to an empty string since it was written; nobody saw it because that
408
+ // title only exists below 760px, where it read as "the header has no title on a phone".
409
+ var nameEl = chosen ? chosen.querySelector('.name') : null;
410
+ // TEXT NODES ONLY, because the LIVE chip is an <em> INSIDE this span -- so reading the whole
411
+ // element's text gives "peps ta boitelive" and puts THAT in the masthead, which is what shipped
412
+ // the moment the rail learned to draw the badge. The chip marks the row; it is not the name.
413
+ // (No backticks in this comment: it lives inside a template literal.)
414
+ var name = '';
415
+ if (nameEl) {
416
+ for (var n = 0; n < nameEl.childNodes.length; n++) {
417
+ if (nameEl.childNodes[n].nodeType === 3) name += nameEl.childNodes[n].textContent;
418
+ }
419
+ name = name.trim();
420
+ }
421
+ var label = document.getElementById('tb-title');
422
+ if (chosen && label) label.textContent = name;
423
+ // THE BAY FOLLOWS THE RAIL TOO, and only where the rail is a switcher between whole lagoons.
424
+ // A GROUP's bay names the group — a stable thing that does not change when you move inside it —
425
+ // so it opts out. A lagoon's bay named whichever page you happened to ARRIVE at, and then went
426
+ // stale the moment you picked another: the header said one lagoon while the frame showed another.
427
+ var bayName = document.querySelector('#switcher .bay-name');
428
+ if (chosen && bayName && FOLLOWS_MEMBER) bayName.textContent = name;
429
+ // The review link names whichever project is on screen. Without this it would keep pointing at
430
+ // the one you arrived on, which is the same staleness the header had.
431
+ var review = document.getElementById('rail-review');
432
+ if (chosen && review && chosen.dataset.repo) {
433
+ review.href = '/console?repo=' + encodeURIComponent(chosen.dataset.repo);
434
+ }
435
+ // THE ADDRESS FOLLOWS THE RAIL, where the rail switches between whole lagoons.
436
+ //
437
+ // A hash index is an index into THIS page's member list: the right address for a GROUP (a named
438
+ // thing whose members have no page of their own) and the wrong one for a lagoon, where every
439
+ // member has its own URL. So picking one left the bar naming the lagoon you arrived at; copy that
440
+ // link and it reopens the first one. Same staleness the bay and the review link were already
441
+ // fixed for — this was the last thing on the page still pointing at where you came in.
442
+ //
443
+ // Derived from the frame's own address by dropping the frame suffix, so the page and the frame
444
+ // cannot disagree about which lagoon is showing.
445
+ //
446
+ // split(), not a regex: THIS WHOLE SCRIPT IS A TEMPLATE LITERAL in views.mjs, so a backslash is
447
+ // an escape before it is ever code — the regex form emitted as a line comment and took the rest
448
+ // of the function with it. Backticks are the same trap (one ended the literal here an edit
449
+ // earlier). Prefer forms that need neither.
450
+ var pageHref = null;
451
+ if (chosen && FOLLOWS_MEMBER && chosen.dataset.href) {
452
+ pageHref = chosen.dataset.href.split('/__motu_frame')[0];
453
+ }
454
+ if (history.replaceState) history.replaceState(null, '', pageHref || '#' + i);
455
+ wearTheLagoonsColour(i);
456
+ driveTheLagoon(i);
457
+ }
458
+ // --- the dock, drawn out here rather than inside the artifact -----------------------------------
459
+ //
460
+ // The artifact still ships its own, because a lagoon opened directly has no host to draw one for
461
+ // it. When there IS a host, that copy is hidden and this one takes over: it reads the same
462
+ // catalogue and drives the same control surface, and it lives in this document, so it cannot
463
+ // overlay the application the way an in-page dock has to.
464
+ var unmountDock = null;
465
+ function driveTheLagoon(i) {
466
+ var f = frames[i];
467
+ if (!f) return;
468
+ if (unmountDock) { unmountDock(); unmountDock = null; }
469
+ unmountDock = motuMountDock({
470
+ mount: document.body,
471
+ lagoonWindow: function () { return f.contentWindow; },
472
+ });
473
+ var tide = document.getElementById('tide');
474
+ // ONLY HUSH A LAGOON THIS DOCK CAN ACTUALLY DRIVE.
475
+ //
476
+ // Every artifact published before the control surface existed has no __motuLagoonControl, so
477
+ // the dock out here can list nothing and switch nothing. Hiding the artifact's own dock in that
478
+ // case would take the controls away and put nothing in their place — every lagoon already
479
+ // published, left with no way to change region until somebody republished it. The transition has
480
+ // to be the other way round: the old dock stays until the new one can replace it, per artifact.
481
+ var hush = function () {
482
+ try {
483
+ var d = f.contentDocument;
484
+ if (!d) return;
485
+ var driveable = !!(f.contentWindow && f.contentWindow.__motuLagoonControl);
486
+ tide.hidden = !driveable;
487
+ // AND THIS DOCUMENT KEEPS THE STRIP — on the side the dock is actually standing on.
488
+ //
489
+ // Hardcoding 'right' reserved a 46px column down the right edge while the dock was a bar
490
+ // along the BOTTOM, which is the empty gutter beside the sheet in the screenshot. The dock
491
+ // moves to the bottom edge under 760px, so the reserve has to move with it.
492
+ if (driveable) {
493
+ document.documentElement.dataset.motuDock = phone.matches ? 'bottom' : 'right';
494
+ } else {
495
+ document.documentElement.removeAttribute('data-motu-dock');
496
+ }
497
+ var had = d.getElementById('motu-dock-hush');
498
+ if (!driveable) { if (had) had.remove(); return; }
499
+ if (had) return;
500
+ var st = d.createElement('style');
501
+ st.id = 'motu-dock-hush';
502
+ // Hidden, not removed: the artifact owns that element and may still be using it for its own
503
+ // state. What it must stop doing is drawing a second dock over the first.
504
+ st.textContent = '#tide{display:none!important}#motu-toolbar{display:none!important}html[data-motu-dock]{padding:0!important}';
505
+ d.head.appendChild(st);
506
+ } catch (e) { /* not ours */ }
507
+ };
508
+ hush();
509
+ f.addEventListener('load', hush);
510
+ // The lagoon boots asynchronously, so the answer to "can this be driven" changes after load.
511
+ var settle = 0;
512
+ var watch = setInterval(function () { if (settle++ > 40) return clearInterval(watch); hush(); }, 250);
513
+ // AND WHEN THE BREAKPOINT MOVES, which the poll above cannot cover — it stops after ten seconds,
514
+ // and a window resize or a rotation happens whenever the reader feels like it. The reserve flips
515
+ // between an edge and the bottom at the same width the dock does, so leaving it stale reserves a
516
+ // strip along an edge the bar is no longer standing on: measured after resizing a phone-width
517
+ // page back to 1280, the page kept 44px at the bottom and gave the 46px rail on the right none.
518
+ // The artifact's own dock has listened for this since the inset existed; this one had not.
519
+ phone.addEventListener('change', hush);
520
+ }
521
+ // --- the chrome wears the lagoon's own colour ---------------------------------------------------
522
+ // Read from the artifact's PIXELS rather than from a config parameter somebody has to remember.
523
+ // See @motujs/chrome/primary for why the stylesheet cannot answer this and why a grey answer is a
524
+ // real answer rather than a failure.
525
+ // BOTH ROOTS, because the dock is not in this document. The tide line lives INSIDE the artifact,
526
+ // and the kit deliberately scopes its variables onto #tide rather than :root so that injecting the
527
+ // kit into somebody's application cannot repaint that application. That isolation is why setting
528
+ // --motu-primary out here does nothing to the overlay in there. What makes it reachable anyway is
529
+ // that the kit derives its ramp -- --tide-accent is var(--motu-primary, <fallback>) -- so the
530
+ // property only has to be set somewhere ABOVE #tide in its own document. The frame is same-origin
531
+ // (the host proxies it), so that is one setProperty on the frame's documentElement.
532
+ function rootsFor(frame) {
533
+ var roots = [document.documentElement];
534
+ try { if (frame && frame.contentDocument) roots.push(frame.contentDocument.documentElement); } catch (e) { /* not ours */ }
535
+ return roots;
536
+ }
537
+ function applyPrimary(found, frame) {
538
+ var vars = primaryVars(found.primary, found.onPrimary);
539
+ var roots = rootsFor(frame);
540
+ for (var n = 0; n < roots.length; n++) {
541
+ for (var k in vars) roots[n].style.setProperty(k, vars[k]);
542
+ }
543
+ }
544
+ function clearPrimary(frame) {
545
+ var roots = rootsFor(frame);
546
+ for (var n = 0; n < roots.length; n++) {
547
+ for (var v = 0; v < PRIMARY_VARS.length; v++) roots[n].style.removeProperty(PRIMARY_VARS[v]);
548
+ roots[n].style.removeProperty('--motu-on-primary');
549
+ }
550
+ }
551
+ function wearTheLagoonsColour(i) {
552
+ var f = frames[i];
553
+ if (!f) return;
554
+ // CLEAR FIRST. applyPrimary only ever sets, so switching from a gold lagoon to one that cannot be
555
+ // read would leave the shell gold and attribute one project's colour to another.
556
+ clearPrimary(f);
557
+ var btn = document.querySelector('button.member[data-i="' + i + '"]');
558
+ // A DECLARED COLOUR WINS, and costs nothing to honour. Detection exists so that nobody HAS to set
559
+ // chrome.brand, not to overrule the projects that did -- acme tuned its gold by hand (a 25%
560
+ // darkening of its own control colour), and inferring over the top of that would be replacing a
561
+ // decision with a guess.
562
+ var declared = btn && btn.dataset.brand ? { primary: btn.dataset.brand } : null;
563
+ // KEYED BY THE LAGOON, NOT BY THE URL IT WAS REACHED THROUGH. This was f.src, which meant one
564
+ // lagoon cached under as many entries as it has spellings -- and, more to the point, an entry the
565
+ // host's boot splash could not predict, because the splash is written server-side before any
566
+ // frame src exists. repo/slug is the identity both sides already have.
567
+ // A member with no button (or no slug -- a GROUP's frames are positional) has no stable identity
568
+ // to cache under, so it simply does not cache rather than sharing one bucket with its neighbours.
569
+ var ident = btn && btn.dataset.repo && btn.dataset.slug ? btn.dataset.repo + '/' + btn.dataset.slug : null;
570
+ var key = ident ? 'motu-primary:' + ident : null;
571
+ var cached = null;
572
+ if (!declared && key) {
573
+ try { cached = JSON.parse(sessionStorage.getItem(key)); } catch (e) { /* private mode, or nothing stored */ }
574
+ }
575
+ var known = declared || cached;
576
+ var paint = function (found) { if (current === i) applyPrimary(found, f); };
577
+
578
+ // LOAD IS NOT PAINTED, and this is the whole reason the reader retries. A lagoon renders itself
579
+ // client-side, so at the frame's load event the document exists and the screen is still blank
580
+ // white -- which rasterises to no chromatic pixels and no neutral in band, so detection correctly
581
+ // answers "nothing here" and the shell keeps motu's colour forever. It looked like the detector
582
+ // failing on real pages; it was the detector being asked too early. Poll until the artifact has
583
+ // something to say, then stop.
584
+ var read = function (attempt) {
585
+ if (current !== i) return;
586
+ var doc = null;
587
+ // A frame the host proxies is same-origin; one that somehow is not simply keeps motu's colour.
588
+ try { doc = f.contentDocument; } catch (e) { return; }
589
+ if (!doc || !doc.body) return;
590
+ // THE ARTIFACT MAY HAVE ALREADY DECIDED. A published lagoon detects its own colour at boot and
591
+ // publishes it on its window -- from a raster that excluded its own dock, and knowing whether
592
+ // the project declared a colour. That answer is better than the one out here and it is already
593
+ // paid for, so prefer it and only rasterise for an artifact too old to have done it.
594
+ var own = null;
595
+ try { own = f.contentWindow && f.contentWindow.__motuPrimary; } catch (e) { /* not ours */ }
596
+ if (own && own.primary) {
597
+ try { if (key) sessionStorage.setItem(key, JSON.stringify(own)); } catch (e) { /* private mode */ }
598
+ return paint(own);
599
+ }
600
+ detectPrimary(doc).then(function (found) {
601
+ if (current !== i) return;
602
+ if (!found) {
603
+ // About 38s in total, from 300ms doubling. Ten seconds was enough for every lagoon tested
604
+ // locally and too short for twenty's published artifact -- 19.66 MB and ~25s to first
605
+ // paint -- which detected in dev and never once in production.
606
+ if (attempt < 7) setTimeout(function () { read(attempt + 1); }, 300 * Math.pow(2, attempt));
607
+ return;
608
+ }
609
+ try { if (key) sessionStorage.setItem(key, JSON.stringify(found)); } catch (e) { /* private mode */ }
610
+ paint(found);
611
+ });
612
+ };
613
+
614
+ // The shell can wear a colour it already knows immediately; the frame cannot be painted until it
615
+ // has a document of its own.
616
+ if (known) paint(known);
617
+ var settle = function () { if (known) paint(known); else read(0); };
618
+ // ON EVERY LOAD, not just the first. A live lagoon reloads itself, and the new document arrives
619
+ // with none of the properties the last one was given -- the dock would go back to motu teal
620
+ // halfway through a session and nothing would put it back.
621
+ if (!f.dataset.motuBound) {
622
+ f.dataset.motuBound = '1';
623
+ f.addEventListener('load', function () { f.dataset.motuLoaded = '1'; settle(); });
624
+ }
625
+ // ALREADY LOADED COUNTS AS LOADED. motuLoaded is only set by the handler, so a frame whose load
626
+ // fired before the handler was bound -- a cached artifact, which is the common case on a second
627
+ // visit -- had the flag unset AND the event gone. The shell wore the cached colour and the dock
628
+ // inside the frame stayed motu teal, which reads as "the frame is not being painted" and is
629
+ // really "the frame was painted before anyone was listening".
630
+ var ready = false;
631
+ try { ready = !!(f.contentDocument && f.contentDocument.readyState === 'complete' && f.contentDocument.body); } catch (e) { /* not ours */ }
632
+ if (f.dataset.motuLoaded || ready) settle();
633
+ }
634
+ // --- the sheet, on a phone --------------------------------------------------------------------
635
+ // Desktop never opens it: the rail is always visible there and these handlers simply never fire,
636
+ // because the button they hang off is display:none.
637
+ var body = document.body;
638
+ var scrim = document.getElementById('scrim');
639
+ var switchBtn = document.getElementById('tb-switch');
640
+ var sheet = document.getElementById('switcher');
641
+ var grab = document.getElementById('grab');
642
+
643
+ // TWO SHEETS, ONE EDGE. The lagoon's own dock becomes a bottom sheet on a phone, and so is this
644
+ // one -- but they belong to different things: this switches which lagoon you are looking at, that
645
+ // one drives the region inside it. Both arriving from the bottom edge at once is a stack nobody
646
+ // can read, and they cannot negotiate it themselves because they are in different documents.
647
+ //
648
+ // The shell is the one that knows, so the shell decides: while this sheet is open the frame's own
649
+ // root is stamped and the dock stands down (see the :root[data-motu-shell-sheet] rule in the
650
+ // tideline's stylesheet). Same origin, so this is one attribute rather than a message protocol.
651
+ // ONE SHEET AT A TIME, AND THE DOCK IS OURS NOW.
652
+ //
653
+ // This used to reach into the frame, because that is where the dock lived. It is drawn in this
654
+ // document now, so stamping the frame hid nothing and both sheets came up the same edge together.
655
+ // The stamp still goes to the frame as well — an artifact old enough to draw its own dock is still
656
+ // out there, and it still has to stand down.
657
+ var phone = window.matchMedia('(max-width: 760px)');
658
+ function markSheet(open) {
659
+ var here = document.getElementById('tide');
660
+ if (here) here.dataset.shellSheet = open ? 'open' : 'closed';
661
+ if (open) document.documentElement.removeAttribute('data-motu-dock');
662
+ markFrames(open);
663
+ }
664
+ function markFrames(open) {
665
+ Array.prototype.forEach.call(document.querySelectorAll('iframe'), function (f) {
666
+ try {
667
+ var root = f.contentDocument && f.contentDocument.documentElement;
668
+ if (!root) return;
669
+ if (open) root.setAttribute('data-motu-shell-sheet', 'open');
670
+ else root.removeAttribute('data-motu-shell-sheet');
671
+ } catch (e) { /* not ours to touch */ }
672
+ });
673
+ }
674
+ function openSheet() {
675
+ scrim.hidden = false;
676
+ body.classList.add('sheet-open');
677
+ switchBtn.setAttribute('aria-expanded', 'true');
678
+ markSheet(true);
679
+ }
680
+ function closeSheet() {
681
+ body.classList.remove('sheet-open');
682
+ switchBtn.setAttribute('aria-expanded', 'false');
683
+ markSheet(false);
684
+ // Kept in the layout until the transition ends, or the backdrop vanishes before the sheet does.
685
+ setTimeout(function () { if (!body.classList.contains('sheet-open')) scrim.hidden = true; }, 240);
686
+ }
687
+ switchBtn.addEventListener('click', function () {
688
+ body.classList.contains('sheet-open') ? closeSheet() : openSheet();
689
+ });
690
+ scrim.addEventListener('click', closeSheet);
691
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeSheet(); });
692
+
693
+ // DRAG TO DISMISS. A sheet a thumb can only close by reaching the backdrop is a sheet that feels
694
+ // stuck; following the finger is the part that makes it read as a panel rather than a popup.
695
+ var startY = null, dy = 0;
696
+ grab.addEventListener('pointerdown', function (e) {
697
+ startY = e.clientY; dy = 0;
698
+ body.classList.add('sheet-dragging');
699
+ grab.setPointerCapture(e.pointerId);
700
+ });
701
+ grab.addEventListener('pointermove', function (e) {
702
+ if (startY === null) return;
703
+ dy = Math.max(0, e.clientY - startY);
704
+ sheet.style.transform = 'translateY(' + dy + 'px)';
705
+ });
706
+ function endDrag() {
707
+ if (startY === null) return;
708
+ startY = null;
709
+ body.classList.remove('sheet-dragging');
710
+ sheet.style.transform = '';
711
+ // A short pull springs back; past a third of the sheet it is a dismissal.
712
+ if (dy > Math.min(120, sheet.offsetHeight / 3)) closeSheet();
713
+ }
714
+ grab.addEventListener('pointerup', endDrag);
715
+ grab.addEventListener('pointercancel', endDrag);
716
+
717
+ document.addEventListener('click', function (e) {
718
+ var b = e.target.closest && e.target.closest('button.member');
719
+ if (b) { show(+b.dataset.i); closeSheet(); }
720
+ });
721
+ var initial = parseInt((location.hash || '').slice(1), 10);
722
+ show(Number.isInteger(initial) ? initial : FOCUS);
723
+
724
+ // WHO IS READING THIS. Asked, not assumed: this page is bytes the node host serves identically to
725
+ // everyone, so the only honest starting state is hidden and the only way to leave it is to ask
726
+ // the app. same-origin credentials because the session is a cookie and this is the same origin
727
+ // through the proxy; a failure leaves the control hidden, which is the state it started in.
728
+ fetch('/auth/whoami', { credentials: 'same-origin', headers: { accept: 'application/json' } })
729
+ .then(function (r) { return r.ok ? r.json() : null; })
730
+ .then(function (me) {
731
+ var box = document.getElementById('account');
732
+ if (!box || !me) return;
733
+ if (!me.signedIn) {
734
+ box.innerHTML = '<a class="motu-account" href="/signin"><span class="motu-account__name">Sign in</span></a>';
735
+ box.hidden = false;
736
+ return;
737
+ }
738
+ // TEXT THROUGH textContent, markup through a template. The handle comes from a provider and is
739
+ // the one value on this page nobody here chose, so it is never concatenated into HTML.
740
+ var form = document.createElement('form');
741
+ form.action = '/auth/signout';
742
+ form.method = 'post';
743
+ var name = document.createElement('span');
744
+ name.className = 'motu-account__name';
745
+ name.textContent = me.handle;
746
+ var out = document.createElement('button');
747
+ out.type = 'submit';
748
+ out.className = 'motu-account__out';
749
+ out.title = 'Sign out ' + me.handle;
750
+ var disc = document.createElement('span');
751
+ disc.className = 'motu-avatar';
752
+ disc.setAttribute('aria-hidden', 'true');
753
+ disc.textContent = me.initial;
754
+ var hint = document.createElement('span');
755
+ hint.className = 'motu-account__hint';
756
+ hint.textContent = 'Sign out';
757
+ out.appendChild(disc);
758
+ out.appendChild(hint);
759
+ form.appendChild(name);
760
+ form.appendChild(out);
761
+ box.appendChild(form);
762
+ box.hidden = false;
763
+ })
764
+ .catch(function () { /* still hidden, which is where it started */ });
765
+ })();
766
+ </script>
767
+ </body>
768
+ </html>
769
+ `;
770
+ }
771
+
772
+ // --- the indexes --------------------------------------------------------------------------------
773
+
774
+ export function rootIndexPage({ repos, stats }) {
775
+ const n = (x) => Number(x ?? 0).toLocaleString('en');
776
+ const repoRows = repos.map((r, i) =>
777
+ motuRow({
778
+ href: `/${r.repo}/`,
779
+ label: r.repo,
780
+ scale: 'page',
781
+ index: i,
782
+ // A repo holding records is live water; one holding none is not broken, it is EMPTY — which is
783
+ // `neutral`, not `warn`. Same reading as the React row's <Dot>, because it is the same dot.
784
+ tone: r.records ? 'ok' : 'neutral',
785
+ kind: 'repo',
786
+ // HOW FULL, AS A RATIO, so it is drawn rather than written — the one number on the row that is
787
+ // not a count. Against `maxRecords`, the same cap the readout names, because the two disagreeing
788
+ // six hundred pixels apart is exactly what the React row's own comment was written about.
789
+ fill: stats.maxRecords ? Math.min(100, (r.records / stats.maxRecords) * 100) : null,
790
+ badge: r.live ? motuPill('live', { tone: 'ok', fill: true, className: 'motu-breathe', title: 'served live from somebody\'s dev server' }) : '',
791
+ sub:
792
+ escapeHtml(`${r.slugs.length} lagoon${r.slugs.length === 1 ? '' : 's'}`) +
793
+ (r.live ? escapeHtml(' · updating as it is edited') : ''),
794
+ trailing: escapeHtml(`${n(r.records)} / ${n(stats.maxRecords)}`),
795
+ }),
796
+ );
797
+
798
+ return motuPage({
799
+ title: 'motu lagoons',
800
+ // THE MASTHEAD, which is the SAME BAY the console uses and not a second header — `shape` is the
801
+ // tall end of one component. This page asked for the chrome-scale form and got a 16px band above
802
+ // a settings list, while the app's own front page rendered the masthead: one product, two
803
+ // openings, drifting apart because nothing compares them. The vocabulary was already here.
804
+ bay: motuBay({
805
+ shape: 'masthead',
806
+ leading: motuMark(),
807
+ title: 'motu',
808
+ headline: 'Your lagoons',
809
+ blurb: 'Every declared state, published and addressable.',
810
+ // THE READOUT IS A METER, not a sentence: three separate facts, each with a label over a value
811
+ // and tabular figures, rather than prose run together with dots.
812
+ meta: motuMeter([
813
+ { label: 'Objects', value: n(stats.blobs) },
814
+ { label: 'Size', value: size(stats.bytes) },
815
+ { label: 'Cap', value: `${n(stats.maxRecords)}/repo` },
816
+ ]),
817
+ }),
818
+ body:
819
+ // ONE KIND OF THING. A "Composed" panel of groups listed above this and, on a host where every
820
+ // group holds very nearly every repository, said the same thing twice while pushing the actual
821
+ // list down the page — which is what `b1719dd` removed from the motu-built index region and
822
+ // never removed from here, so the two indexes disagreed for months.
823
+ repoRows.length
824
+ ? motuRailedList(repoRows)
825
+ : motuPanel({
826
+ caption: 'Repositories',
827
+ rows: [],
828
+ empty: 'Nothing published yet — run motu lagoon publish --remote from a project.',
829
+ }),
830
+ });
831
+ }
832
+
833
+ export function repoIndexPage({ repo, aliases, history, liveSlugs = [] }) {
834
+ const byId = new Map(history.map((r) => [r.id, r]));
835
+ const current = Object.entries(aliases.latest ?? {})
836
+ .map(([slug, id]) => ({ slug, rec: byId.get(id) }))
837
+ .filter((x) => x.rec)
838
+ .sort((a, b) => a.slug.localeCompare(b.slug));
839
+
840
+ // THE SUB LINE IS UNCHANGED — slug, the sha as its own permalink, the branch, when. It is the one
841
+ // line on this page that is not decoration: the sha link is how somebody pins the version they are
842
+ // looking at, and the design brief says "exactly what repoIndexPage emits today" for that reason.
843
+ const subFor = (slug, rec) =>
844
+ `${escapeHtml(slug)} · <a href="/${escapeHtml(repo)}/${escapeHtml(rec.sha)}/${escapeHtml(slug)}">${escapeHtml(rec.sha.slice(0, 7))}</a>` +
845
+ `${rec.branch ? ` · ${escapeHtml(rec.branch)}` : ''} · ${when(rec.publishedAt)}`;
846
+
847
+ // NOT A LINK ROW, and that is forced rather than chosen. The sub line contains the sha PERMALINK,
848
+ // and an <a> inside an <a> is invalid HTML: the browser closes the outer anchor at the inner one,
849
+ // so the sub and the action fell out of the card and rendered underneath it. This has been the
850
+ // markup since the page was written; a flat row hid it, and a card made it visible in one look.
851
+ //
852
+ // So the row carries the two links it has — the sha, and `Open →` — and is not itself one. Which is
853
+ // also what the design draws: a card with an explicit action, not a card that is entirely a target.
854
+ // A live slug that is ALSO published appears in both sections, deliberately: the two rows point at
855
+ // the same URL but answer different questions — "what is deployed" and "what is being worked on
856
+ // right now" — and collapsing them would hide whichever the reader came for.
857
+ const liveRows = [...liveSlugs].sort().map((slug) =>
858
+ motuRow({
859
+ label: escapeHtml(slug),
860
+ scale: 'page',
861
+ sub: '<em class="live-dot motu-breathe">live</em> · served by a dev server right now',
862
+ trailing: `<a class="motu-open" href="/${escapeHtml(repo)}/latest/${escapeHtml(slug)}">Open →</a>`,
863
+ }),
864
+ );
865
+
866
+ const latestRows = current.map(({ slug, rec }, i) =>
867
+ motuRow({
868
+ label: rec.title || slug,
869
+ scale: 'page',
870
+ tone: 'ok',
871
+ index: i,
872
+ sub: subFor(slug, rec),
873
+ trailing: `<a class="motu-open" href="/${escapeHtml(repo)}/latest/${escapeHtml(slug)}">Open →</a>`,
874
+ }),
875
+ );
876
+
877
+ const past = history.slice(0, 50);
878
+ // HISTORY IS THE SAME ROW WITHOUT THE CARD, and that is a deliberate reading of the brief rather
879
+ // than the geometry it describes. The mockup gives history its own four-column grid with a vertical
880
+ // gauge; a third row geometry in a kit that already has two is the drift this package exists to
881
+ // stop, so the recency the gauge was carrying is carried by --age on the row's own gauge instead.
882
+ const historyRows = past.map((r, i) =>
883
+ motuRow({
884
+ href: `/${repo}/${r.sha}/${r.slug}`,
885
+ label: r.slug,
886
+ scale: 'page',
887
+ surface: 'flat',
888
+ index: i,
889
+ age: i,
890
+ sub: `${escapeHtml(r.sha.slice(0, 7))}${r.branch ? ` · ${escapeHtml(r.branch)}` : ''} · ${when(r.publishedAt)}`,
891
+ }),
892
+ );
893
+
894
+ const [owner, name] = repo.includes('/') ? [repo.slice(0, repo.indexOf('/')), repo.slice(repo.indexOf('/') + 1)] : ['', repo];
895
+ const records = history.length;
896
+ return motuPage({
897
+ title: repo,
898
+ column: 'page',
899
+ bay: motuBay({
900
+ shape: 'masthead',
901
+ // WHERE THE WORDMARK SITS ON THE FRONT PAGE, a way back. This page is reached FROM the index,
902
+ // and the mark that identifies the product there is less useful here than the way out.
903
+ leading: '<a class="motu-back" href="/">← all repositories</a>',
904
+ title: owner ? `${escapeHtml(owner)}/` : '',
905
+ titleRaw: true,
906
+ headline: name,
907
+ meta:
908
+ `<span class="motu-sand">${records}</span> record${records === 1 ? '' : 's'}` +
909
+ ` · ${current.length} lagoon${current.length === 1 ? '' : 's'}`,
910
+ }),
911
+ body:
912
+ // LIVE FIRST, because it is the one section that is only true right now. A published lagoon is
913
+ // still there tomorrow; a dev server's member is gone ninety seconds after the process is. The
914
+ // slugs here have no record behind them — no sha, no published date — so they get their own
915
+ // section rather than being faked into the Latest rail with empty columns.
916
+ (liveRows.length
917
+ ? `<section><div class="motu-cap panel-cap">Live now<span class="motu-cap-trail">served by a dev server · gone when it stops</span></div>${motuRailedList(liveRows)}</section>`
918
+ : '') +
919
+ `<section><div class="motu-cap panel-cap">Latest</div>${
920
+ latestRows.length
921
+ ? motuRailedList(latestRows)
922
+ : '<p class="motu-empty">No lagoons published for this repository.</p>'
923
+ }</section>` +
924
+ (historyRows.length
925
+ ? `<section><div class="motu-cap panel-cap">History<span class="motu-cap-trail">newest ${past.length} of ${history.length}</span></div>${motuRailedList(historyRows)}</section>`
926
+ : ''),
927
+ });
928
+ }
929
+
930
+ export function errorPage(status, message) {
931
+ return motuPage({
932
+ title: String(status),
933
+ bay: motuBay({ title: String(status), subtitle: 'nothing here' }),
934
+ body: motuPanel({ caption: 'What happened', rows: [], empty: message }) + `<p><a href="/">all repositories</a></p>`,
935
+ });
936
+ }