@marver-design/marver 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,7 +21,8 @@ file in design/instructions/ - they are short, strict, and part of this contract
21
21
  | Build | hi-fi frames from real components | instructions/craft.md + components.md |
22
22
  | Iterate | changing a frame the human has seen, or retiring explorations | instructions/iterate.md |
23
23
  | Review | before presenting anything | instructions/review.md |
24
- | Boards | creating a board or publishing | instructions/boards.md |
24
+ | Boards | creating a board, choosing what ships | instructions/boards.md |
25
+ | Publish | deploying the canvas: gate, volume, accounts, invites | instructions/publish.md |
25
26
 
26
27
  Refining an existing screen: Configure must hold, then Build + Review. New work runs
27
28
  the full ladder. Unsure which phase you are in? Ask the human - one question beats a
@@ -37,6 +38,17 @@ Stuck, or the human is unhappy with a result? instructions/reference/ holds the
37
38
  guides (layout, typography, color, motion, copy, states, tuning, critique, concepts) -
38
39
  the routing index is at the top of instructions/craft.md. Pull ONE file, apply, return.
39
40
 
41
+ ## When the human points at a specific element
42
+
43
+ Two channels carry element-precise feedback - honor both:
44
+ - **A pasted address** like `design/scenes/hero/a.tsx · #root > div > h1 (a.tsx:12)` is
45
+ a LASER-COPIED pointer: the human pressed L (laser mode), hovered to see the element,
46
+ clicked it, and its exact address landed on their clipboard. Open that frame file and
47
+ go straight to that element - the css path (and source location, when present) are exact.
48
+ - **A pinned comment** on an element: run `npx marver comments list --open --json` - each
49
+ thread carries the anchored element (tag, quoted text, css path, frame). Work that queue
50
+ per instructions/iterate.md; the comment names the div, so read the anchor before the words.
51
+
40
52
  ## Frames
41
53
  - A frame = one file: design/scenes/<scene>/<name>.tsx or .html. One frame, one surface.
42
54
  - It default-exports a React component. No imports from the tool are needed. Optional:
@@ -78,9 +78,12 @@ ordered list of atoms and `{ "space": n }` tokens.
78
78
 
79
79
  ## Publishing
80
80
 
81
- Boards are the unit of publishing (`marver build --boards <name>`): every frame a
82
- published flow data-gotos must be ON that board - unlisted frames are excluded from
83
- the bundle at build time.
81
+ Boards are the unit of publishing: `design/publish.json` names each shipped board
82
+ with `read` or `comment` rights (`marver build` fails without it - default-closed;
83
+ `--boards <name>` overrides ad hoc). Every frame a published flow data-gotos must
84
+ be ON a published board - unlisted frames are excluded from the bundle at build
85
+ time. Deploying the built canvas - gate password, the collaboration volume,
86
+ accounts and invites - is its own phase: **instructions/publish.md**.
84
87
 
85
88
  The published gate page shows the app's identity: `design/logo.svg` + the host
86
89
  package name (overridable via config `share`). If the app has no logo asset yet,
@@ -39,6 +39,34 @@ canvas, the tour - that flow lives in instructions/welcome.md, run it alongside.
39
39
  several half-brands - document the one the app actually ships in DESIGN.md and
40
40
  name the others as legacy.
41
41
 
42
+ ## Working with teammates (branches, merges, a second engineer)
43
+
44
+ The whole `design/` folder is git-tracked - boards, scenes, comment logs, the
45
+ publish policy, these instructions. Only `design/.local/` (this machine's
46
+ connect credential) and `design/.dist/` (built on the host) are ignored. So a
47
+ teammate gets the exact canvas by pulling the branch: `pnpm install` →
48
+ `npx marver dev`. Nothing else to sync.
49
+
50
+ How the three kinds of design state merge across branches:
51
+
52
+ - **Comment logs (`design/comments/*.jsonl`) merge themselves.** They're
53
+ append-only and keyed by event id, and init writes a `merge=union` git
54
+ attribute for them - two branches that both collected feedback union
55
+ cleanly, no conflict. Even a hand-botched merge self-heals: replay dedupes
56
+ by id. This is the point of the event-log design - multiplayer comments
57
+ Just Work through plain git.
58
+ - **Scenes (`design/scenes/**.tsx`) merge like any code** - they're React
59
+ components. Standard review, standard conflicts.
60
+ - **Boards (`design/boards/*.json`) are the one friction** - they're node
61
+ positions, so two people rearranging the same board conflict on x/y. It's
62
+ cosmetic: take either side and re-run Tidy, or give features their own
63
+ boards so layouts don't overlap. Never let a boards conflict block a merge.
64
+
65
+ If collaboration is deployed, each engineer runs `marver comments connect
66
+ <url>` ONCE with their own account (the owner invites them) to get the live
67
+ cloud sync on top of git. Git carries the committed comments; `connect` adds
68
+ the real-time stream from published viewers.
69
+
42
70
  ## When it breaks mid-project
43
71
 
44
72
  Frames suddenly unstyled → the theme import path moved: fix `design/theme.css`.
@@ -48,3 +48,41 @@ The human picks a direction; then, in one pass:
48
48
  Archived frames are history, not options: never link them from live flows,
49
49
  never count them as current design. They exist so "what did we try for the
50
50
  editor?" has a visual answer.
51
+
52
+ ## Comments are your work queue
53
+
54
+ When collaboration is on, humans pin comments to specific elements inside
55
+ frames. Those threads are addressed to YOU as much as to the designer - treat
56
+ the open list as a queue:
57
+
58
+ ```bash
59
+ npx marver comments list --open --json # what needs you (anchors included)
60
+ npx marver comments reply <thread> --body "…"
61
+ npx marver comments resolve <thread> --addressed-in <scene/frame>
62
+ ```
63
+
64
+ The discipline:
65
+
66
+ 1. **Read the anchor before the words.** Each thread carries the element it
67
+ points at - tag, quote, source hint, position. "Too cramped" pinned to a
68
+ button is a different task than "too cramped" on the whole frame.
69
+ 2. **Fork, don't overwrite.** Address feedback by creating a NEW variant of the
70
+ frame (the letter convention above) and iterating there. The commented
71
+ frame stays as the before; your variant is the after.
72
+ 3. **Resolve with the receipt.** `--addressed-in <the-new-variant>` records
73
+ WHICH frame answered the feedback - the thread becomes an auditable link
74
+ from complaint to fix. Reply first when the change deserves a sentence of
75
+ explanation; resolve silently only for trivial mechanical fixes.
76
+ 4. **Never resolve what you didn't address.** Disagree? Reply with your
77
+ reasoning and leave the thread open - the human closes debates, you close
78
+ completed work.
79
+ 5. **A frame with open comments is load-bearing - never delete, rename, or
80
+ gut it.** Its threads are anchored to elements INSIDE it; restructure the
81
+ frame and the anchors strand (a dead anchor parks the pin at the frame
82
+ edge; deleting the whole frame strands the thread off-canvas entirely -
83
+ never lost from the log, but invisible until the frame returns). So: fork
84
+ the variant and iterate THERE, leave the commented frame untouched as the
85
+ before, and only once you `resolve --addressed-in <variant>` its threads
86
+ may it move to `archive/`. Resolve first, restructure second - never the
87
+ reverse. Check `comments list --board <b>` (no `--open`) to see resolved
88
+ threads too; the full history lives in the append-only log and in git.
@@ -0,0 +1,129 @@
1
+ # Publish - put the canvas on a URL, collaboration included
2
+
3
+ Publishing is three decisions, then one build and one process. A coding agent can
4
+ run the whole thing; nothing here needs a human at a dashboard except pasting env
5
+ vars if the host has no CLI.
6
+
7
+ ## The three decisions
8
+
9
+ 1. **Which boards ship, with which rights** - `design/publish.json`:
10
+
11
+ ```json
12
+ { "boards": { "release-review": "comment", "roadmap": "read" } }
13
+ ```
14
+
15
+ `read` = visible to anyone past the gate. `comment` = signed-in accounts can
16
+ also pin threads. Boards not listed do not ship at all - their frames are not
17
+ even in the bundle. `marver build` FAILS without this file (default-closed);
18
+ `--boards a,b` overrides ad hoc (grants comment), `--all-boards` ships
19
+ everything loudly.
20
+
21
+ 2. **The canvas password** - `MARVER_PASSWORD` at serve time. This is the READ
22
+ boundary: one shared password for GUESTS. Members never need it - their own
23
+ account signs them in at the gate, and invite links skip it entirely (the
24
+ token is the authorization). Rotating it therefore only affects guests.
25
+ No env var = an open canvas.
26
+
27
+ 3. **Collaboration on or off** - `MARVER_DATA_DIR` at serve time. Set it to a
28
+ path on a PERSISTENT disk and the serve grows accounts + live comments.
29
+ Leave it unset for a static, comment-free canvas. Setting it to ephemeral
30
+ container disk is the one real deploy mistake - comments would vanish on
31
+ redeploy; marver fails loudly if the dir cannot be created.
32
+
33
+ ## The serve contract (env vars, complete list)
34
+
35
+ | Var | Meaning |
36
+ |---|---|
37
+ | `PORT` | listen port (hosts inject this) |
38
+ | `MARVER_PASSWORD` | canvas password (guests' read credential); unset = open |
39
+ | `MARVER_DATA_DIR` | persistent dir for `comments/` + `auth.json`; unset = no collaboration |
40
+ | `MARVER_OWNER_EMAIL` | prints a single-use OWNER claim link in the deploy logs on first boot |
41
+ | `MARVER_TRUSTED_PROXY` | set to `1` behind a reverse proxy (Railway, Fly) so rate limits see real IPs |
42
+
43
+ ## The host contract (works on any volume-capable host)
44
+
45
+ The host does two things, and the deploy config names both. **`design/.dist` is
46
+ gitignored - it is built ON THE HOST at deploy time, never committed.**
47
+
48
+ - **build command**: `<install> && npx marver build` (respects `publish.json`,
49
+ seeds comment logs into the bundle)
50
+ - **start command**: `npx marver serve` (reads `PORT` + the env vars above)
51
+ - a **persistent volume** mounted at some path, named by `MARVER_DATA_DIR`
52
+
53
+ The `@marver-design/marver` dependency must resolve from the registry (a local
54
+ `link:`/`file:` dep cannot ride to a remote host) - a normal `^0.4.0` in
55
+ `package.json` is all it takes.
56
+
57
+ ## Railway quickstart
58
+
59
+ Commit a `railway.json` so `railway up` knows how to build and serve:
60
+
61
+ ```json
62
+ {
63
+ "$schema": "https://railway.com/railway.schema.json",
64
+ "build": { "builder": "NIXPACKS", "buildCommand": "pnpm install && npx marver build" },
65
+ "deploy": { "startCommand": "npx marver serve" }
66
+ }
67
+ ```
68
+
69
+ Then, once per service:
70
+
71
+ ```bash
72
+ railway init # or `railway link` an existing service
73
+ railway volume add --mount-path /data
74
+ railway variables --set MARVER_PASSWORD=<pw> --set MARVER_DATA_DIR=/data \
75
+ --set MARVER_OWNER_EMAIL=<owner@email> --set MARVER_TRUSTED_PROXY=1
76
+ railway up # uploads the repo; Railway runs build then start
77
+ railway logs # ← the owner claim LINK prints here, once (see below)
78
+ ```
79
+
80
+ Republishing is just `railway up` again: the server unions the seeded logs on
81
+ boot, so collected feedback is NEVER clobbered by a new build. Run ONE instance -
82
+ the event log is single-writer by design.
83
+
84
+ ## What the deployed gate offers (so you know what you're wiring)
85
+
86
+ On a collaboration canvas the gate has three doors, one credential each:
87
+ - **Guest** - the canvas password → read-only across published boards.
88
+ - **Member** - "Sign in instead" → their own email + password → read + comment.
89
+ A member session IS gate passage; they never touch the shared password again.
90
+ - **Invited** - opening an invite link → set a display name + password (+ optional
91
+ avatar) → account created, read + comment. The link skips the canvas password.
92
+
93
+ ## Wiring people up (after first deploy)
94
+
95
+ `MARVER_OWNER_EMAIL` makes the first boot print an owner bootstrap in the logs:
96
+ a browser link (`<url>/#/i/<token>`) AND the exact repo command. The token is
97
+ single-use. Claim it from the repo so this machine can mint invites:
98
+
99
+ ```bash
100
+ # 1. claim the owner account - copy the command the deploy logs printed:
101
+ marver comments connect https://canvas.example.com --invite <token-from-logs>
102
+
103
+ # 2. invite each colleague - one command, one link, no email infrastructure
104
+ marver comments invite colleague@company.com
105
+ # → prints a single invite LINK (<url>/#/i/<token>). Send it over Slack/DM
106
+ # with the canvas password. It opens straight into the claim (name +
107
+ # password + optional avatar). Single-use; 7-day expiry.
108
+
109
+ # 3. the loop is now closed
110
+ marver dev # two-way syncs comments every 30s
111
+ marver comments list --open # the agent's work queue
112
+ marver comments reply <thread> --body "..." # answer in the loop
113
+ marver comments resolve <thread> --addressed-in <frame> # close with a receipt
114
+ marver comments revoke <email> # kills the account + its sessions, mid-flight
115
+ ```
116
+
117
+ Share the canvas as `<url>/#/b/<board>` (with the canvas password), or copy-link
118
+ on any thread for a deep link straight to it - the gate carries deep links
119
+ through sign-in.
120
+
121
+ ## What lands where (so you can reason about persistence)
122
+
123
+ - `design/publish.json` - the policy, git-tracked, part of the repo.
124
+ - `<MARVER_DATA_DIR>/comments/<board>.jsonl` - the live event log, on the volume.
125
+ - `<MARVER_DATA_DIR>/auth.json` - accounts (scrypt), sessions, invites, on the volume.
126
+ - `design/comments/<board>.jsonl` - the dev-side mirror, git-tracked: feedback
127
+ has history, and the volume has an off-site replica for free.
128
+ - `design/.local/collab.json` - THIS machine's device credential. Gitignored;
129
+ never commit it.
@@ -1,207 +0,0 @@
1
- import { n as NAME } from "./cli.mjs";
2
- import { existsSync, readFileSync, realpathSync } from "node:fs";
3
- import { extname, isAbsolute, join, relative, resolve } from "node:path";
4
- import { createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
5
- import { createServer } from "node:http";
6
- //#region src/server/serve.ts
7
- /**
8
- * `marver serve` - a zero-dependency static server for design/.dist with an optional
9
- * password gate (SPEC-M2 §4b). No MARVER_PASSWORD → plain static serving. With it, the
10
- * bundle is never sent pre-auth: every unauthenticated request gets the gate page, auth
11
- * is a POST compare + an HMAC-signed 30-day cookie keyed off the password itself (all
12
- * instances agree, nothing stored server-side).
13
- */
14
- const MIME = {
15
- ".html": "text/html; charset=utf-8",
16
- ".js": "text/javascript",
17
- ".mjs": "text/javascript",
18
- ".css": "text/css",
19
- ".json": "application/json",
20
- ".svg": "image/svg+xml",
21
- ".png": "image/png",
22
- ".ico": "image/x-icon",
23
- ".webmanifest": "application/manifest+json",
24
- ".woff": "font/woff",
25
- ".woff2": "font/woff2",
26
- ".map": "application/json",
27
- ".txt": "text/plain"
28
- };
29
- const COOKIE = "mv_a";
30
- const MONTH = 2592e3;
31
- function serve(root, portFlag) {
32
- const dist = join(root, "design", ".dist");
33
- if (!existsSync(join(dist, "index.html"))) {
34
- console.error(`[${NAME}] design/.dist not found - run \`npx ${NAME} build\` first.`);
35
- process.exit(1);
36
- }
37
- const realDist = realpathSync(dist);
38
- let meta = {
39
- name: "Marver",
40
- branding: true
41
- };
42
- try {
43
- meta = {
44
- ...meta,
45
- ...JSON.parse(readFileSync(join(dist, "meta.json"), "utf8"))
46
- };
47
- } catch {}
48
- const password = process.env.MARVER_PASSWORD ?? "";
49
- const verifier = password ? scryptSync(password, "marver-gate", 32) : null;
50
- const cookieKey = randomBytes(32);
51
- const sign = (exp) => createHmac("sha256", cookieKey).update(`v1.${exp}`).digest("hex");
52
- const authed = (req) => {
53
- const m = /(?:^|;\s*)mv_a=(\d+)\.([0-9a-f]+)/.exec(String(req.headers.cookie ?? ""));
54
- if (!m) return false;
55
- const exp = Number(m[1]);
56
- if (!Number.isFinite(exp) || exp * 1e3 < Date.now()) return false;
57
- const want = Buffer.from(sign(exp)), got = Buffer.from(m[2]);
58
- return want.length === got.length && timingSafeEqual(want, got);
59
- };
60
- const server = createServer((req, res) => {
61
- const url = new URL(req.url ?? "/", "http://x");
62
- if (verifier) {
63
- if (req.method === "POST" && url.pathname === "/__mv/auth") {
64
- let body = "";
65
- req.on("data", (c) => {
66
- body += c;
67
- if (body.length > 1e4) req.destroy();
68
- });
69
- req.on("end", () => {
70
- const form = new URLSearchParams(body);
71
- const given = form.get("password") ?? "";
72
- if (timingSafeEqual(scryptSync(given, "marver-gate", 32), verifier)) {
73
- const exp = Math.floor(Date.now() / 1e3) + MONTH;
74
- const secure = req.headers["x-forwarded-proto"] === "https" ? "; Secure" : "";
75
- res.setHeader("set-cookie", `${COOKIE}=${exp}.${sign(exp)}; Path=/; Max-Age=${MONTH}; HttpOnly; SameSite=Lax${secure}`);
76
- const next = form.get("next") ?? "";
77
- res.statusCode = 303;
78
- res.setHeader("location", /^#\/[\w\/?&=%.,~-]*$/.test(next) ? `/${next}` : "/");
79
- return res.end();
80
- }
81
- return gate(res, meta, "Wrong password - try again");
82
- });
83
- return;
84
- }
85
- const cosmetic = url.pathname.startsWith("/__mv/favicon/") || /^\/__mv\/logo\.(svg|png)$/.test(url.pathname);
86
- if (!authed(req) && !cosmetic) return gate(res, meta);
87
- }
88
- let path;
89
- try {
90
- path = decodeURIComponent(url.pathname);
91
- } catch {
92
- res.statusCode = 400;
93
- return res.end("bad request");
94
- }
95
- if (path.endsWith("/")) path += "index.html";
96
- let file = resolve(dist, path.slice(1));
97
- try {
98
- const real = realpathSync(file);
99
- const rel = relative(realDist, real);
100
- if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("outside dist");
101
- file = real;
102
- } catch {
103
- file = join(dist, "index.html");
104
- }
105
- if (!extname(file)) file = join(dist, "index.html");
106
- try {
107
- const content = readFileSync(file);
108
- res.setHeader("content-type", MIME[extname(file)] ?? "application/octet-stream");
109
- const cache = verifier ? "private, no-store" : file.startsWith(join(dist, "assets")) ? "public, max-age=31536000, immutable" : "no-store";
110
- res.setHeader("cache-control", cache);
111
- res.end(content);
112
- } catch {
113
- res.statusCode = 404;
114
- res.end("not found");
115
- }
116
- });
117
- const port = portFlag ?? (Number(process.env.PORT) || 4199);
118
- server.on("error", (err) => {
119
- if (err.code === "EADDRINUSE") {
120
- console.error(`[${NAME}] port ${port} is already in use - another \`${NAME} serve\` still running? Stop it, or pass --port <n>.`);
121
- process.exit(1);
122
- }
123
- throw err;
124
- });
125
- server.listen(port, () => {
126
- console.log(`\n ${NAME} serving design/.dist → http://localhost:${port}/`);
127
- console.log(verifier ? " gate: ON (MARVER_PASSWORD set)\n" : " gate: off - set MARVER_PASSWORD to require a password\n");
128
- });
129
- return server;
130
- }
131
- /** The Marver logo mark (ParallelogramDuo, same as the shell's sidebar). */
132
- const MARK_AT = (size) => `<svg viewBox="0 0 256 256" width="${size}" height="${size}" fill="currentColor" aria-hidden><path d="M239.29,59.28l-64.8,144a8,8,0,0,1-7.3,4.72H24a8,8,0,0,1-7.3-11.28l64.8-144A8,8,0,0,1,88.81,48H232A8,8,0,0,1,239.29,59.28Z" opacity=".1"/><path d="M245.43,47.31A15.94,15.94,0,0,0,232,40H88.81a16,16,0,0,0-14.59,9.43l-64.8,144A16,16,0,0,0,24,216H167.19a16,16,0,0,0,14.59-9.43l64.8-144A16,16,0,0,0,245.43,47.31ZM167.19,200H24L88.81,56H232Z"/></svg>`;
133
- const MARK = MARK_AT(17);
134
- const MARK_LG = MARK_AT(24);
135
- /** The gate: the canvas's own antechamber - light ground, dot grid, glass card in the
136
- * shell's exact token language. The visitor is one password away from stepping in. */
137
- function gate(res, meta, error) {
138
- const name = meta.name ? meta.name[0].toUpperCase() + meta.name.slice(1) : "Marver";
139
- const appMark = meta.logo ? `<img src="${esc(meta.logo)}" alt="" width="24" height="24" />` : MARK_LG;
140
- const title = meta.branding ? `${name} | Marver - Visualize your software` : name;
141
- const desc = meta.branding ? `${name}, shared as a live Marver canvas - real screens and prototypes, built from the codebase. Marver is the agent-native design canvas.` : `${name} - a private design canvas.`;
142
- res.statusCode = 200;
143
- res.setHeader("content-type", "text/html; charset=utf-8");
144
- res.setHeader("cache-control", "no-store");
145
- res.end(`<!doctype html>
146
- <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
147
- <title>${esc(title)}</title>
148
- <meta name="description" content="${esc(desc)}" />
149
- <meta name="robots" content="noindex" />
150
- <meta name="theme-color" content="#e7e9ef" />
151
- <meta property="og:title" content="${esc(title)}" />
152
- <meta property="og:description" content="${esc(desc)}" />
153
- ${meta.branding ? "<meta property=\"og:site_name\" content=\"Marver\" />" : ""}
154
- <meta property="og:type" content="website" />
155
- <meta name="twitter:card" content="summary" />
156
- <link rel="icon" href="/__mv/favicon/favicon.ico" sizes="48x48" />
157
- <link rel="icon" type="image/png" sizes="32x32" href="/__mv/favicon/favicon-32x32.png" />
158
- <link rel="icon" type="image/png" sizes="16x16" href="/__mv/favicon/favicon-16x16.png" />
159
- <link rel="apple-touch-icon" href="/__mv/favicon/apple-touch-icon.png" />
160
- <style>
161
- * { box-sizing: border-box; margin: 0 }
162
- body { min-height: 100vh; display: flex; align-items: center; justify-content: center;
163
- background-color: #e7e9ef;
164
- background-image: radial-gradient(#c9cbd5 1px, transparent 1px); background-size: 20px 20px;
165
- font: 500 14px -apple-system, system-ui, sans-serif; color: #18181b;
166
- -webkit-font-smoothing: antialiased }
167
- main { display: flex; flex-direction: column; align-items: center; gap: 22px; padding: 16px }
168
- form { width: 340px; padding: 24px; border-radius: 24px; display: flex; flex-direction: column; gap: 14px;
169
- background: rgba(255, 255, 255, .64); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
170
- border: 1px solid rgba(24, 24, 27, .1);
171
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, .65), 0 1px 2px rgba(24, 24, 27, .05), 0 4px 12px -6px rgba(24, 24, 27, .12) }
172
- header { display: flex; align-items: center; gap: 10px; padding: 4px 2px 4px 4px }
173
- header svg, header img { color: #0088ff; flex: none; border-radius: 6px }
174
- h1 { font-size: 17px; font-weight: 600; letter-spacing: -.01em }
175
- p { font-size: 12.5px; line-height: 1.5; color: rgba(24, 24, 27, .66); padding: 0 4px }
176
- input { height: 40px; padding: 0 16px; border-radius: 999px; border: 1px solid rgba(24, 24, 27, .14);
177
- background: #fff; color: #18181b; font: inherit; outline: none; transition: border-color .15s, box-shadow .15s }
178
- input::placeholder { color: rgba(24, 24, 27, .35) }
179
- input:focus { border-color: #0088ff; box-shadow: 0 0 0 3px rgba(0, 136, 255, .18) }
180
- button { height: 40px; border: 0; border-radius: 999px; background: #18181b; color: #fafafa;
181
- font: 600 13px -apple-system, system-ui, sans-serif; cursor: pointer; transition: background .15s }
182
- button:hover { background: #000 }
183
- .err { font-size: 12px; color: #b42318; padding: 0 4px }
184
- footer a { display: inline-flex; align-items: center; gap: 7px; font: 600 12.5px -apple-system, system-ui, sans-serif;
185
- color: rgba(24, 24, 27, .55); text-decoration: none }
186
- footer a > svg:first-of-type { width: 20px; height: 20px; color: #0088ff }
187
- footer .md, footer .up { transition: color .15s, opacity .15s }
188
- footer a:hover .md { color: #0088ff; text-decoration: underline; text-underline-offset: 3px }
189
- footer .up { opacity: .45; margin-left: -3px }
190
- footer a:hover .up { opacity: 1; color: #0088ff }
191
- </style></head>
192
- <body><main>
193
- <form method="post" action="/__mv/auth">
194
- <header>${appMark}<h1>${esc(name)}</h1></header>
195
- <p>You're one step from the canvas. This space is private - enter the password to step inside.</p>
196
- ${error ? `<div class="err">${esc(error)}</div>` : ""}
197
- <input type="password" name="password" placeholder="Password" autofocus autocomplete="current-password" />
198
- <input type="hidden" name="next" />
199
- <button type="submit">Open the canvas</button>
200
- </form>
201
- <script>document.querySelector('[name=next]').value = location.hash<\/script>
202
- ${meta.branding ? `<footer><a href="https://marver.design" target="_blank" rel="noopener">${MARK} <span>Powered by <span class="md">Marver.design</span></span> <svg class="up" viewBox="0 0 256 256" width="11" height="11" fill="currentColor" aria-hidden><path d="M200,64V168a8,8,0,0,1-16,0V83.31L69.66,197.66a8,8,0,0,1-11.32-11.32L172.69,72H88a8,8,0,0,1,0-16H192A8,8,0,0,1,200,64Z"/></svg></a></footer>` : ""}
203
- </main></body></html>`);
204
- }
205
- const esc = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
206
- //#endregion
207
- export { serve };