@motujs/host 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/package.json +37 -3
- package/src/access.mjs +180 -0
- package/src/cli.mjs +274 -0
- package/src/document.mjs +65 -0
- package/src/manifest.mjs +68 -0
- package/src/server.mjs +1243 -0
- package/src/store.mjs +680 -0
- package/src/views.mjs +936 -0
- package/README.md +0 -3
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
|
+
}
|