@parley-im/parley 0.2.9 → 0.2.11

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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # parley
1
+ # Parley
2
2
 
3
3
  Send someone a link. They open a private chat with your own Claude or Codex, running read-only in your project folder, and tell it what they think. It asks follow-up questions, answers their questions by reading the code, and saves everything for you.
4
4
 
@@ -6,7 +6,7 @@ The agent runs on your computer, with your Claude or Codex login and plan. Links
6
6
 
7
7
  ## Using it
8
8
 
9
- Link your computer to your parley account once:
9
+ Link your computer to your Parley account once:
10
10
 
11
11
  ```bash
12
12
  parley login
@@ -68,9 +68,9 @@ Each session is a fresh agent conversation in the project folder, guided by a ch
68
68
  It can read the project folder and nothing else on your computer. Inside it, a blocked list keeps it from secrets (`.env` files, keys and certificates, `.ssh`, cloud and package-manager logins, `secrets/`), from other guests' conversations in any `.parley` folder, and always from parley's own database. Each agent's own sandbox enforces that, not just the charter (which asks too). `src/access.ts` builds the rules; sessions won't start in your home folder. Both are settings (see Config).
69
69
 
70
70
  - **Claude:** `claude -p --restricted --tools Read,Grep,Glob --strict-mcp-config`, plus `Read(...)` deny rules from the blocked list. No command, write or web tools; `--restricted` confines the file tools to the project; your MCP servers are left out.
71
- - **Codex:** `codex exec --ignore-user-config` under a permission profile that can read the project and the system files tools need (`:minimal`), with the blocked list denied, no writes and no network. Apps, browser, computer use, plugins, hooks and web search are off; your model and reasoning effort carry over. Codex re-runs itself inside that sandbox, so parley starts it by its real path and lets it read that one file.
71
+ - **Codex:** `codex exec --ignore-user-config` under a permission profile that can read the project and the system files tools need (`:minimal`), with the blocked list denied, no writes and no network. Apps, browser, computer use, plugins, hooks and web search are off; your model and reasoning effort carry over. Codex re-runs itself inside that sandbox, so Parley starts it by its real path and lets it read that one file.
72
72
 
73
- Before a session is made, parley checks the agent is installed and new enough for these flags, and says what to update if not.
73
+ Before a session is made, Parley checks the agent is installed and new enough for these flags, and says what to update if not.
74
74
 
75
75
  Each reply records which model answered, on which login (`auth: "none"` means your subscription, not an API key), and what it would cost at API prices.
76
76
 
package/dist/access.js CHANGED
@@ -44,7 +44,7 @@ export function agentProblem(config, agent) {
44
44
  try {
45
45
  const help = execFileSync(bin, NEEDS[agent].args, { encoding: "utf8", timeout: 20_000, env: cleanEnv(), stdio: ["ignore", "pipe", "pipe"] });
46
46
  if (NEEDS[agent].flags.some((flag) => !help.includes(flag))) {
47
- problem = `This ${NAMES[agent]} is too old for parley's sandbox. Update it (${UPDATE[agent]}) and try again.`;
47
+ problem = `This ${NAMES[agent]} is too old for Parley's sandbox. Update it (${UPDATE[agent]}) and try again.`;
48
48
  }
49
49
  }
50
50
  catch (error) {
package/dist/account.js CHANGED
@@ -34,7 +34,7 @@ async function call(creds, path, init = {}) {
34
34
  if (res.status === 401)
35
35
  throw Object.assign(new Error("This computer isn't linked to your parley account anymore. Run parley login."), { unlinked: true });
36
36
  if (!res.ok)
37
- throw new Error((await res.json().catch(() => null))?.message ?? `parley's servers answered ${res.status}`);
37
+ throw new Error((await res.json().catch(() => null))?.message ?? `Parley's servers answered ${res.status}`);
38
38
  return res.json();
39
39
  }
40
40
  // What parley.im is told about a session: its id and a title for link
package/dist/cli.js CHANGED
@@ -12,8 +12,8 @@ import { readState, linkFor, dashboardUrl, isServerUp } from "./state.js";
12
12
  import { transcript, summaryLine } from "./transcript.js";
13
13
  import { startSession, endSession, deleteSession, setReplyLimit } from "./control.js";
14
14
  import { login, readCredentials, clearCredentials, relay as registry } from "./account.js";
15
- const HELP = `parley: someone chats with a read-only Claude or Codex running in this project, on this
16
- computer, and you get everything they said. Good for feedback from guests, or any interview.
15
+ const HELP = `Parley: someone chats with a read-only Claude or Codex running in this project, on this
16
+ computer, and what they share comes back to this project. Good for feedback from guests, or any interview.
17
17
 
18
18
  If you're an agent and the user asks for "a parley" (or to get someone's take on this project):
19
19
 
@@ -37,16 +37,16 @@ Commands
37
37
  parley limit ID N Let a session give N replies in total
38
38
  parley end ID Kill its link for good; the transcript stays
39
39
  parley delete ID Erase it, screenshots included
40
- parley login | logout | whoami Link this computer to your parley account
41
- parley start | stop | status The background server (holds the connection to parley)
40
+ parley login | logout | whoami Link this computer to your Parley account
41
+ parley start | stop | status The background server (holds the connection to Parley)
42
42
  parley panel Open the control panel (localhost)
43
- parley install Add parley's MCP tools to Claude Code and Codex; start at login
43
+ parley install Add Parley's MCP tools to Claude Code and Codex; start at login
44
44
  parley uninstall Undo install (your conversations stay)
45
45
  parley serve | mcp Run the server in the foreground | MCP over stdio
46
46
  parley --version
47
47
 
48
48
  Conversations and screenshots live in each project's .parley folder (kept out of git), with a
49
- readable transcript.md per session. Settings and your parley login live in ${paths.home}.`;
49
+ readable transcript.md per session. Settings and your Parley login live in ${paths.home}.`;
50
50
  const CLI_PATH = fileURLToPath(import.meta.url);
51
51
  async function main() {
52
52
  const [command, ...rest] = process.argv.slice(2);
@@ -253,14 +253,14 @@ async function install(config) {
253
253
  if (config.claude.bin.startsWith("/")) {
254
254
  run(config.claude.bin, ["mcp", "remove", "--scope", "user", "parley"]);
255
255
  const ok = run(config.claude.bin, ["mcp", "add", "--scope", "user", "parley", "--", ...launch]);
256
- console.log(ok ? "Claude Code: added the parley MCP server (user scope)." : "Claude Code: couldn't add the MCP server.");
256
+ console.log(ok ? "Claude Code: added the Parley MCP server (user scope)." : "Claude Code: couldn't add the MCP server.");
257
257
  }
258
258
  else
259
259
  console.log("Claude Code: not found, skipped.");
260
260
  if (config.codex.bin.startsWith("/")) {
261
261
  run(config.codex.bin, ["mcp", "remove", "parley"]);
262
262
  const ok = run(config.codex.bin, ["mcp", "add", "parley", "--", ...launch]);
263
- console.log(ok ? "Codex: added the parley MCP server." : "Codex: couldn't add the MCP server.");
263
+ console.log(ok ? "Codex: added the Parley MCP server." : "Codex: couldn't add the MCP server.");
264
264
  }
265
265
  else
266
266
  console.log("Codex: not found, skipped.");
package/dist/daemon.js CHANGED
@@ -15,7 +15,7 @@ export async function ensureServer(config, waitMs = 20_000) {
15
15
  const running = await serverHealth(config);
16
16
  if (running?.version && running.version !== VERSION) {
17
17
  if (compareVersions(running.version, VERSION) > 0) {
18
- throw new Error(`parley was updated to ${running.version}, but this process is still ${VERSION}. Restart this Claude Code or Codex session (or the parley command) to use the new version.`);
18
+ throw new Error(`Parley was updated to ${running.version}, but this process is still ${VERSION}. Restart this Claude Code or Codex session (or the parley command) to use the new version.`);
19
19
  }
20
20
  await stopServer(config);
21
21
  await waitUntilDown(config);
package/dist/mcp.js CHANGED
@@ -20,7 +20,7 @@ export async function runMcp() {
20
20
  const currentLink = (id) => linkFor(store.session(id), config);
21
21
  server.registerTool("create_session", {
22
22
  title: "Create a feedback session",
23
- description: "Create a parley session: a private link the user can send to a guest. The guest chats with a fresh, read-only " +
23
+ description: "Create a Parley session: a private link the user can send to a guest. The guest chats with a fresh, read-only " +
24
24
  "agent in the project folder that hears their feedback, answers questions by reading the code, and records everything. " +
25
25
  "Starts the local server and tunnel if needed. Returns the link to give the user. Only for guests the user trusts, " +
26
26
  "since the guest can ask about anything in the project folder.",
@@ -83,14 +83,14 @@ export async function runMcp() {
83
83
  }
84
84
  else {
85
85
  lines.push("Everything after the # in the link is the session's encryption key: share the whole link, and only with the guest. " +
86
- "Replies come from this computer, so it needs to be awake with parley running.");
86
+ "Replies come from this computer, so it needs to be awake with Parley running.");
87
87
  }
88
88
  lines.push(`Tell the user to send it only to someone they trust: ${session.guest} can ask about anything in this project folder.`);
89
89
  return reply(lines.join("\n"));
90
90
  });
91
91
  server.registerTool("list_sessions", {
92
92
  title: "List feedback sessions",
93
- description: "List parley sessions, newest activity first, with how many guest messages are new since you last read each one. " +
93
+ description: "List Parley sessions, newest activity first, with how many guest messages are new since you last read each one. " +
94
94
  "Open sessions include their current guest link.",
95
95
  inputSchema: {
96
96
  status: z.enum(["open", "ended", "all"]).optional().describe("Defaults to open."),
@@ -111,7 +111,7 @@ export async function runMcp() {
111
111
  });
112
112
  server.registerTool("get_transcript", {
113
113
  title: "Read a feedback session",
114
- description: "Read a parley session as Markdown: the brief, every message, which model interviewed, and paths to screenshots the " +
114
+ description: "Read a Parley session as Markdown: the brief, every message, which model interviewed, and paths to screenshots the " +
115
115
  "guest shared. Set only_new to read just what came in since you last read it. Reading marks it read.",
116
116
  inputSchema: {
117
117
  session_id: z.string(),
@@ -125,7 +125,7 @@ export async function runMcp() {
125
125
  });
126
126
  server.registerTool("end_session", {
127
127
  title: "End a feedback session",
128
- description: "End a parley session for good when the user is done with it. The guest's link stops working entirely and an open " +
128
+ description: "End a Parley session for good when the user is done with it. The guest's link stops working entirely and an open " +
129
129
  "page clears itself. The transcript stays readable here. This can't be undone; make a new session to talk again.",
130
130
  inputSchema: { session_id: z.string() },
131
131
  }, async (args) => {
package/dist/relay.js CHANGED
@@ -108,7 +108,7 @@ export class RelayLink {
108
108
  return;
109
109
  }
110
110
  if (event.code === 4000) {
111
- this.status = { state: "replaced", relayUrl: creds.relayUrl, error: "Another parley on this computer took over the connection." };
111
+ this.status = { state: "replaced", relayUrl: creds.relayUrl, error: "Another Parley server on this computer took over the connection." };
112
112
  return;
113
113
  }
114
114
  this.status = { state: "offline", relayUrl: creds.relayUrl, email: creds.account.email, error: `Reconnecting to ${creds.relayUrl}` };
package/dist/server.js CHANGED
@@ -156,7 +156,7 @@ export async function serve() {
156
156
  if (rest === "/" && req.method === "GET") {
157
157
  const from = ownerName(config);
158
158
  const html = readFileSync(join(WEB, "chat.html"), "utf8")
159
- .replaceAll("{{TITLE}}", escapeHtml(`${session.project_name} · parley`))
159
+ .replaceAll("{{TITLE}}", escapeHtml(`${session.project_name} · Parley`))
160
160
  .replaceAll("{{PREVIEW_TITLE}}", escapeHtml(`${from ? `${from} wants` : "Someone wants"} your feedback on ${session.project_name}`))
161
161
  .replaceAll("{{PREVIEW_TEXT}}", escapeHtml(`A private chat about ${session.project_name}.`))
162
162
  .replace("{{CONFIG}}", JSON.stringify({ base: scope.base, owner: scope.owner, maxText: MAX_TEXT }).replaceAll("<", "\\u003c"));
@@ -245,7 +245,7 @@ export async function serve() {
245
245
  // Already running (started at login, and again by hand, say): leave it be
246
246
  // and exit cleanly, which also tells a login service not to retry.
247
247
  if (await isServerUp(config)) {
248
- console.log(`parley: a server is already running on ${config.dashboardPort}; leaving it be.`);
248
+ console.log(`Parley: a server is already running on ${config.dashboardPort}; leaving it be.`);
249
249
  process.exit(0);
250
250
  }
251
251
  guest.listen(config.port, "127.0.0.1");
@@ -255,7 +255,7 @@ export async function serve() {
255
255
  server.once("error", reject);
256
256
  })));
257
257
  writeState(state);
258
- console.log(`parley: control panel http://localhost:${config.dashboardPort}, local links on ${config.port}`);
258
+ console.log(`Parley: control panel http://localhost:${config.dashboardPort}, local links on ${config.port}`);
259
259
  link.start();
260
260
  runner.resume();
261
261
  // Says goodbye to parley.im before exiting, so guests see this computer go
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parley-im/parley",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "Send someone a link. They talk to a read-only Claude or Codex in your project, on your computer, and you get everything they said.",
5
5
  "keywords": [
6
6
  "claude",
package/web/app.css CHANGED
@@ -2,8 +2,10 @@
2
2
  control panel and parley's site. Dark, with lilac for the accents, for the
3
3
  people who use Claude Code and Codex: Instrument Sans for everything
4
4
  people read, JetBrains Mono for the wordmark, labels, files and numbers.
5
- The agent's words are marked with a dot, as in Claude Code, and three
6
- dots pulse while it writes. The fonts are served from here, never from a
5
+ The mark (mark.js) is equilateral triangles gathered into hexagons, and
6
+ its triangles do the motion: they say when a page opens, when the agent
7
+ is reading or writing, when a reply lands, and when the sender's computer
8
+ is offline. Six of them mark the agent's words. The fonts are served from here, never from a
7
9
  font service, so guest pages only ever talk to parley. */
8
10
 
9
11
  @font-face { font-family: "Instrument Sans"; src: url("/static/fonts/instrument-sans.woff2") format("woff2"); font-weight: 400 700; font-style: normal; font-display: swap; }
@@ -24,7 +26,6 @@
24
26
  --accent-fill: #9d92ff; /* buttons, with dark text */
25
27
  --accent-soft: #221f3d;
26
28
  --accent-ink: #0c0c0d;
27
- --tile: #9d92ff; /* the mark's tile */
28
29
  --guest: #221f3d; /* your own messages */
29
30
  --guest-ink: #e6e2ff;
30
31
  --ok: #3ccf7a;
@@ -57,11 +58,27 @@ code, kbd, pre { font-family: var(--mono); }
57
58
 
58
59
  /* ---------- shared pieces ---------- */
59
60
 
60
- /* The mark, for now: a p on a deep green tile. */
61
- .mark { display: inline-block; width: 24px; height: 24px; flex: none; }
62
- .mark svg { display: block; width: 100%; height: 100%; }
63
- .mark .tile { fill: var(--tile); }
64
- .mark .glyph { fill: var(--accent-ink); }
61
+ /* The mark: a ring of thirty triangles around a ring of space and a core of
62
+ six, two tones each by which way a triangle points. */
63
+ .mark { display: inline-block; width: 26px; height: 26px; flex: none; }
64
+ .mark svg { display: block; width: 100%; height: 100%; overflow: visible; }
65
+ .r1 { fill: #9d92ff; } .r2 { fill: #8a7ff0; }
66
+ .c1 { fill: #d2ccff; } .c2 { fill: #bdb4ff; }
67
+ .g1 { fill: #6b60d6; } .g2 { fill: #5e54c4; }
68
+ .mark path { transition: fill 0.4s ease; }
69
+ /* Offline, the ring goes dark and the core turns red; ended, it all dims. */
70
+ .app.offline .topbar .mark .r1, .mark.dim .r1, .mark.dim .c1 { fill: #2e2a45; }
71
+ .app.offline .topbar .mark .r2, .mark.dim .r2, .mark.dim .c2 { fill: #28253c; }
72
+ .app.offline .topbar .mark .c1 { fill: #ff6b63; }
73
+ .app.offline .topbar .mark .c2 { fill: #e85a53; }
74
+ /* As a page opens, the ring builds around and then the core arrives. */
75
+ .mark.opening .r1, .mark.opening .r2 { transform-box: fill-box; transform-origin: center; animation: build 0.45s ease-out both; }
76
+ .mark.opening .c1, .mark.opening .c2 { transform-box: view-box; transform-origin: 32px 32px; animation: arrive 0.5s ease-out 1.05s both; }
77
+ @keyframes build { from { opacity: 0; transform: scale(0.2); } }
78
+ @keyframes arrive { from { opacity: 0; transform: scale(0.3); } 70% { transform: scale(1.12); } }
79
+ /* A reply lands: the core pulses once. */
80
+ .mark.pulse .c1, .mark.pulse .c2 { transform-box: view-box; transform-origin: 32px 32px; animation: pulse-core 0.8s ease-in-out; }
81
+ @keyframes pulse-core { 40% { transform: scale(1.3); } 75% { transform: scale(0.94); } }
65
82
  .wordmark { font-family: var(--mono); font-weight: 700; letter-spacing: -0.04em; }
66
83
 
67
84
  .topbar {
@@ -166,8 +183,12 @@ body:has(> .app) { overflow: hidden; overscroll-behavior: none; }
166
183
  position: absolute; left: 0; top: 0; width: 14px; text-align: center;
167
184
  font-family: var(--mono); font-size: 14px; line-height: 1.55;
168
185
  }
169
- /* The agent's words get a dot, as in Claude Code. */
170
- .msg.agent::before { content: ""; left: 3px; top: 9px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
186
+ /* The agent's words are marked with the mark's core: six triangles. */
187
+ .msg.agent::before {
188
+ content: ""; left: 2px; top: 6px; width: 11px; height: 12.7px;
189
+ clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
190
+ background: conic-gradient(#d2ccff 0deg 60deg, #bdb4ff 60deg 120deg, #d2ccff 120deg 180deg, #bdb4ff 180deg 240deg, #d2ccff 240deg 300deg, #bdb4ff 300deg 360deg);
191
+ }
171
192
  /* The interviewer's words are what people read: set for reading. */
172
193
  .msg.agent .body { font-size: 16.5px; line-height: 1.6; }
173
194
  /* Your own words are compact and plainly yours, on the right. */
@@ -222,15 +243,21 @@ body:has(> .app) { overflow: hidden; overscroll-behavior: none; }
222
243
  .body th { background: var(--subtle); font-weight: 600; white-space: nowrap; }
223
244
  .body tr:last-child td { border-bottom: 0; }
224
245
 
225
- /* Writing: three dots, pulsing in turn. */
226
- .thinking { display: inline-flex; align-items: center; gap: 5px; height: 26px; }
227
- .thinking span { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); opacity: 0.3; animation: pulse-dot 1.2s ease-in-out infinite; }
228
- .thinking span:nth-child(2) { animation-delay: 0.16s; }
229
- .thinking span:nth-child(3) { animation-delay: 0.32s; }
230
- @keyframes pulse-dot { 0%, 70%, 100% { opacity: 0.3; transform: none; } 30% { opacity: 1; transform: translateY(-2px); } }
246
+ /* Working: the mark, small. Light runs around its empty ring while the agent
247
+ writes; while it looks things up (.reading), its core flickers instead. */
248
+ .thinking { display: inline-block; width: 24px; height: 24px; margin-top: 2px; vertical-align: top; }
249
+ .thinking svg { display: block; width: 100%; height: 100%; }
250
+ .thinking .g1, .thinking .g2 { opacity: 0; animation: chase 1.8s linear infinite; }
251
+ .thinking.reading .g1, .thinking.reading .g2 { animation: none; }
252
+ .thinking.reading .c1, .thinking.reading .c2 { animation: scan 1.2s ease-in-out infinite; }
253
+ @keyframes chase { 0% { opacity: 0; } 4% { opacity: 1; } 22% { opacity: 0; } 100% { opacity: 0; } }
254
+ @keyframes scan { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
231
255
  @keyframes blink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
232
256
  .cursor::after { content: ""; display: inline-block; width: 7px; height: 1.05em; background: var(--accent); border-radius: 1px; margin-left: 3px; vertical-align: -3px; animation: blink 1.06s steps(1) infinite; }
233
- @media (prefers-reduced-motion: reduce) { .thinking span, .cursor::after, .dot.busy { animation: none; } .thinking span { opacity: 0.7; } }
257
+ @media (prefers-reduced-motion: reduce) {
258
+ .cursor::after, .dot.busy, .thinking .c1, .thinking .c2, .mark.opening path, .mark.pulse path { animation: none; }
259
+ .thinking .g1, .thinking .g2 { animation: none; opacity: 0.6; }
260
+ }
234
261
 
235
262
  .dock { border-top: 1px solid var(--line); background: var(--bg); }
236
263
  .dock .chat-inner { padding: 8px 16px calc(8px + env(safe-area-inset-bottom)); }
@@ -387,7 +414,7 @@ body.panel { overflow: auto; }
387
414
 
388
415
  .lost { min-height: 100dvh; display: grid; place-items: center; padding: 16px; text-align: center; }
389
416
  .lost.inline { min-height: 100%; }
390
- .lost .mark { width: 40px; height: 40px; margin: 0 auto 16px; }
417
+ .lost .mark { width: 52px; height: 52px; margin: 0 auto 16px; }
391
418
  .lost h1 { font-size: 20px; margin: 0 0 6px; letter-spacing: -0.01em; }
392
419
  .lost p { color: var(--muted); margin: 0 auto; max-width: 34ch; }
393
420
 
@@ -429,14 +456,63 @@ body.panel { overflow: auto; }
429
456
  /* A session's status on the dashboard (not the chat's reply in progress,
430
457
  which is li.live). */
431
458
  span.live { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--muted); white-space: nowrap; }
432
- .docs { padding-bottom: 40px; }
433
- .docs h2 { font-size: 17px; letter-spacing: -0.01em; margin: 36px 0 10px; }
434
- .docs p, .docs li { max-width: 66ch; }
435
- .docs ul { padding-left: 1.2em; }
436
- .docs li { margin: 6px 0; }
437
- .docs pre { background: var(--subtle); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; overflow-x: auto; font-size: 13px; line-height: 1.6; }
438
- .docs pre code { background: none; border: 0; padding: 0; font-size: inherit; }
439
- .docs blockquote { margin: 10px 0; padding: 8px 14px; border-left: 3px solid var(--accent); background: var(--accent-soft); border-radius: 0 8px 8px 0; color: var(--text); }
459
+ /* The docs: a contents column beside the article. */
460
+ .docs-page { max-width: 1120px; }
461
+ .docs-layout { display: grid; grid-template-columns: 210px minmax(0, 1fr); gap: 56px; padding: 32px 0 80px; }
462
+ .docs-nav { position: sticky; top: 16px; align-self: start; max-height: calc(100vh - 32px); overflow-y: auto; display: grid; gap: 1px; font-size: 14px; }
463
+ .docs-nav .label { margin: 18px 0 6px 10px; }
464
+ .docs-nav .label:first-child { margin-top: 4px; }
465
+ .docs-nav a { color: var(--muted); text-decoration: none; padding: 5px 10px; border-radius: 7px; }
466
+ .docs-nav a:hover { color: var(--text); background: var(--subtle); }
467
+ .docs { min-width: 0; max-width: 760px; font-size: 15.5px; line-height: 1.65; }
468
+ .docs-head { padding-bottom: 8px; }
469
+ .docs-head .label { margin: 0 0 8px; }
470
+ .docs h1 { font-size: 38px; line-height: 1.1; letter-spacing: -0.03em; margin: 0 0 14px; }
471
+ .docs .lede { font-size: 18px; line-height: 1.6; color: var(--muted); margin: 0; }
472
+ .docs h2 { font-size: 23px; letter-spacing: -0.02em; margin: 52px 0 14px; padding-top: 28px; border-top: 1px solid var(--line); scroll-margin-top: 12px; }
473
+ .docs h3 { font-size: 16px; letter-spacing: -0.01em; margin: 26px 0 8px; }
474
+ .docs p { margin: 0 0 14px; }
475
+ .docs ul { padding-left: 1.2em; margin: 0 0 16px; }
476
+ .docs li { margin: 7px 0; }
477
+ .docs li::marker { color: var(--faint); }
478
+ .docs a { color: var(--accent); }
479
+ .docs code { font-family: var(--mono); font-size: 0.85em; background: var(--subtle); border: 1px solid var(--line); padding: 0.08em 0.35em; border-radius: 5px; white-space: nowrap; }
480
+ .docs pre { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; overflow-x: auto; font-size: 13.5px; line-height: 1.65; margin: 0 0 16px; }
481
+ .docs pre code { background: none; border: 0; padding: 0; font-size: inherit; white-space: pre; }
482
+ .docs blockquote { margin: 0 0 12px; padding: 12px 16px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; color: var(--text); }
483
+ .docs blockquote::before { content: "you"; display: block; font-family: var(--mono); font-size: 11px; letter-spacing: 0.04em; color: var(--faint); margin-bottom: 2px; }
484
+ .docs table { width: 100%; border-collapse: collapse; font-size: 14.5px; line-height: 1.5; margin: 0 0 18px; }
485
+ .docs th { text-align: left; font-weight: 600; font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--faint); padding: 8px 12px; border-bottom: 1px solid var(--line-strong); }
486
+ .docs td { padding: 10px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
487
+ .docs td:first-child { white-space: nowrap; }
488
+ .docs .table-wrap { overflow-x: auto; margin: 0 0 18px; }
489
+ .docs .table-wrap table { margin: 0; }
490
+ .docs .callout { margin: 18px 0; padding: 14px 16px; background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--accent); border-radius: 10px; }
491
+ .docs .callout.warn { border-left-color: var(--warn); }
492
+ .docs .flow { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 28px; margin: 22px 0; position: relative; }
493
+ .docs .flow div { display: grid; gap: 4px; align-content: start; padding: 14px 16px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; position: relative; }
494
+ .docs .flow div + div::before { content: "⇄"; position: absolute; left: -22px; top: 50%; transform: translateY(-50%); color: var(--accent); font-size: 16px; }
495
+ .docs .flow strong { font-size: 15px; }
496
+ .docs .flow span { font-size: 13.5px; line-height: 1.5; color: var(--muted); }
497
+ .docs .howto { list-style: none; counter-reset: step; padding: 0; margin: 22px 0; display: grid; gap: 18px; }
498
+ .docs .howto li { counter-increment: step; position: relative; padding-left: 44px; margin: 0; }
499
+ .docs .howto li::before { content: counter(step); position: absolute; left: 0; top: 0; width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-family: var(--mono); font-size: 13px; font-weight: 700; color: var(--accent-ink); background: var(--accent-fill); }
500
+ .docs .howto h3 { margin: 2px 0 8px; }
501
+ .docs .howto p, .docs .howto pre { margin: 0 0 8px; }
502
+ .docs details.more { margin: 0 0 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); padding: 10px 14px; }
503
+ .docs details.more summary { cursor: pointer; font-weight: 600; font-size: 14.5px; }
504
+ .docs details.more[open] summary { margin-bottom: 10px; }
505
+ .docs details.more pre { background: var(--bg); }
506
+ .docs .faq dt { font-weight: 600; margin-top: 18px; }
507
+ .docs .faq dd { margin: 4px 0 0; color: var(--muted); }
508
+ @media (max-width: 860px) {
509
+ .docs-layout { grid-template-columns: 1fr; gap: 24px; padding-top: 20px; }
510
+ .docs-nav { position: static; max-height: none; grid-template-columns: 1fr 1fr; padding-bottom: 18px; border-bottom: 1px solid var(--line); }
511
+ .docs-nav .label { display: none; }
512
+ .docs h1 { font-size: 30px; }
513
+ .docs .flow { grid-template-columns: 1fr; gap: 26px; }
514
+ .docs .flow div + div::before { left: 50%; top: -21px; transform: translateX(-50%) rotate(90deg); }
515
+ }
440
516
  .trow.site-row { grid-template-columns: 1fr auto; }
441
517
  /* Long details shorten instead of pushing the buttons off a phone screen. */
442
518
  .trow.site-row .who-cell { min-width: 0; }
@@ -457,23 +533,37 @@ span.live { display: inline-flex; align-items: center; gap: 7px; font-size: 13px
457
533
  .demo-tabs button { font-family: var(--mono); font-size: 12px; padding: 6px 10px; border: 1px solid transparent; border-radius: 8px; background: none; color: var(--muted); cursor: pointer; }
458
534
  .demo-tabs button:hover { color: var(--text); }
459
535
  .demo-tabs button.on { background: var(--panel); border-color: var(--line); color: var(--text); }
460
- .demo-chat { background: var(--panel); border: 1px solid var(--line); border-radius: 18px; box-shadow: var(--shadow); overflow: hidden; }
536
+ /* A fixed frame, like the real chat: the conversation scrolls inside it. */
537
+ .demo-chat { height: 500px; display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--line); border-radius: 18px; box-shadow: var(--shadow); overflow: hidden; }
461
538
  .demo-top { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--line); font-size: 14px; }
462
539
  .demo-top .mark { width: 20px; height: 20px; }
463
540
  .demo-top .product { font-family: var(--mono); font-weight: 700; letter-spacing: -0.04em; }
464
541
  .demo-top .slash { color: var(--faint); }
465
542
  .demo-top .here { font-weight: 600; flex: 1; }
466
- .demo-msgs { list-style: none; margin: 0; padding: 18px 18px 6px; min-height: 340px; display: flex; flex-direction: column; gap: 14px; }
543
+ .demo-msgs { flex: 1; min-height: 0; overflow: hidden; list-style: none; margin: 0; padding: 18px 18px 14px; display: flex; flex-direction: column; gap: 14px; }
467
544
  .demo .msg { margin: 0; animation: rise 0.25s ease-out; }
468
545
  .demo .msg.agent .body { font-size: 15.5px; }
469
- .demo .msg.agent::before { top: 8px; }
546
+ .demo .msg.agent::before { top: 5px; }
470
547
  .demo .msg.guest .bubble { font-size: 14.5px; max-width: 86%; }
471
548
  .demo .steps { margin-top: 2px; }
472
- .demo-say { margin: 0 14px 14px; padding: 8px 8px 8px 14px; border: 1.5px solid var(--line-strong); border-radius: 13px; color: var(--faint); font-size: 15px; display: flex; align-items: center; justify-content: space-between; }
473
- .demo-say .send-dot { width: 32px; height: 32px; border-radius: 9px; background: var(--subtle); }
549
+ /* The demo's composer is the real one, played: text types in, the send
550
+ button wakes, and the message goes. */
551
+ .demo-dock { padding: 8px 12px 12px; border-top: 1px solid var(--line); background: var(--bg); }
552
+ .demo .composer { box-shadow: none; transition: border-color 0.15s, box-shadow 0.15s; }
553
+ .demo .composer.focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
554
+ .demo-input { flex: 1; min-width: 0; min-height: 40px; padding: 8px 4px; font-size: 15px; line-height: 1.55; color: var(--text); }
555
+ .demo-placeholder { color: var(--faint); }
556
+ .demo .composer.focus .demo-input::after { content: ""; display: inline-block; width: 1.5px; height: 1.1em; margin-left: 1px; vertical-align: -3px; background: var(--text); animation: blink 1.06s steps(1) infinite; }
557
+ .demo .icon-btn { cursor: default; }
558
+ .demo .icon-btn:hover { background: transparent; color: var(--muted); }
559
+ .demo .send, .demo .send:hover { background: var(--accent-fill); color: var(--accent-ink); transition: transform 0.12s, background 0.15s; }
560
+ .demo .send.off { background: var(--subtle); color: var(--faint); }
561
+ .demo .send.press { transform: scale(0.9); }
562
+ .demo-top .chip { margin-left: 4px; }
563
+ .demo-top .icon-btn { width: 32px; height: 32px; margin-right: -6px; }
474
564
  @keyframes rise { from { opacity: 0; transform: translateY(4px); } }
475
565
  @media (prefers-reduced-motion: reduce) { .demo .msg { animation: none; } }
476
- @media (max-width: 560px) { .demo-msgs { min-height: 400px; padding: 14px 12px 6px; } }
566
+ @media (max-width: 560px) { .demo-chat { height: 560px; } .demo-msgs { padding: 14px 12px 12px; } }
477
567
  .how { list-style: none; counter-reset: step; padding: 0; margin: 40px 0 0; display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
478
568
  .how li { counter-increment: step; border: 1px solid var(--line); border-radius: 16px; padding: 22px; background: var(--panel); display: grid; gap: 10px; align-content: start; }
479
569
  .how li::before { content: "0" counter(step); font-family: var(--mono); font-weight: 700; color: var(--accent); font-size: 13px; }
package/web/chat.html CHANGED
@@ -10,7 +10,7 @@
10
10
  <meta property="og:description" content="{{PREVIEW_TEXT}}">
11
11
  <meta property="og:site_name" content="parley">
12
12
  <title>{{TITLE}}</title>
13
- <link rel="icon" href="/static/icon.svg?v=5" type="image/svg+xml">
13
+ <link rel="icon" href="/static/icon.svg?v=6" type="image/svg+xml">
14
14
  <link rel="stylesheet" href="/static/app.css">
15
15
  </head>
16
16
  <body>
@@ -18,8 +18,8 @@
18
18
  <header class="topbar">
19
19
  <nav class="crumbs" id="crumbs">
20
20
  <!-- What parley is, in a new tab so the conversation stays put. -->
21
- <a class="brand" id="home-link" href="https://parley.im" target="_blank" rel="noopener" title="parley: let people talk to your agent">
22
- <span class="mark" aria-hidden="true"><svg viewBox="0 0 64 64"><rect class="tile" width="64" height="64" rx="14"/><rect class="glyph" x="19.5" y="16" width="7" height="32" rx="1.5"/><path class="glyph" fill-rule="evenodd" d="M44.5 25.5A10 10 0 1 1 24.5 25.5A10 10 0 1 1 44.5 25.5ZM38.5 25.5A4 4 0 1 0 30.5 25.5A4 4 0 1 0 38.5 25.5Z"/></svg></span><span class="product">parley</span>
21
+ <a class="brand" id="home-link" href="https://parley.im" target="_blank" rel="noopener" title="Parley: let people talk to your agent">
22
+ <span class="mark" aria-hidden="true"><svg viewBox="0 0 64 64" aria-hidden="true"><path class="r1" d="M32 7L39.2 11.2L32 15.3Z"/><path class="r2" d="M39.2 11.2L39.2 19.5L32 15.3Z"/><path class="r1" d="M39.2 11.2L46.4 15.3L39.2 19.5Z"/><path class="r2" d="M46.4 15.3L46.4 23.7L39.2 19.5Z"/><path class="r1" d="M46.4 15.3L53.7 19.5L46.4 23.7Z"/><path class="r2" d="M53.7 19.5L53.7 27.8L46.4 23.7Z"/><path class="r1" d="M46.4 23.7L53.7 27.8L46.4 32Z"/><path class="r2" d="M53.7 27.8L53.7 36.2L46.4 32Z"/><path class="r1" d="M46.4 32L53.7 36.2L46.4 40.3Z"/><path class="r2" d="M53.7 36.2L53.7 44.5L46.4 40.3Z"/><path class="r1" d="M46.4 40.3L53.7 44.5L46.4 48.7Z"/><path class="r2" d="M46.4 40.3L46.4 48.7L39.2 44.5Z"/><path class="r1" d="M39.2 44.5L46.4 48.7L39.2 52.8Z"/><path class="r2" d="M39.2 44.5L39.2 52.8L32 48.7Z"/><path class="r1" d="M32 48.7L39.2 52.8L32 57Z"/><path class="r2" d="M32 48.7L32 57L24.8 52.8Z"/><path class="r1" d="M24.8 44.5L32 48.7L24.8 52.8Z"/><path class="r2" d="M24.8 44.5L24.8 52.8L17.6 48.7Z"/><path class="r1" d="M17.6 40.3L24.8 44.5L17.6 48.7Z"/><path class="r2" d="M17.6 40.3L17.6 48.7L10.3 44.5Z"/><path class="r1" d="M10.3 36.2L17.6 40.3L10.3 44.5Z"/><path class="r2" d="M17.6 32L17.6 40.3L10.3 36.2Z"/><path class="r1" d="M10.3 27.8L17.6 32L10.3 36.2Z"/><path class="r2" d="M17.6 23.7L17.6 32L10.3 27.8Z"/><path class="r1" d="M10.3 19.5L17.6 23.7L10.3 27.8Z"/><path class="r2" d="M17.6 15.3L17.6 23.7L10.3 19.5Z"/><path class="r1" d="M17.6 15.3L24.8 19.5L17.6 23.7Z"/><path class="r2" d="M24.8 11.2L24.8 19.5L17.6 15.3Z"/><path class="r1" d="M24.8 11.2L32 15.3L24.8 19.5Z"/><path class="r2" d="M32 7L32 15.3L24.8 11.2Z"/><path class="c1" d="M32 23.7L39.2 27.8L32 32Z"/><path class="c2" d="M39.2 27.8L39.2 36.2L32 32Z"/><path class="c1" d="M32 32L39.2 36.2L32 40.3Z"/><path class="c2" d="M32 32L32 40.3L24.8 36.2Z"/><path class="c1" d="M24.8 27.8L32 32L24.8 36.2Z"/><path class="c2" d="M32 23.7L32 32L24.8 27.8Z"/></svg></span><span class="product">parley</span>
23
23
  </a><span class="slash">/</span><span class="here" id="project">&nbsp;</span>
24
24
  </nav>
25
25
  <span class="status" id="status" hidden><span class="dot" id="status-dot"></span><span id="status-text"></span></span>
@@ -48,7 +48,7 @@
48
48
  <div class="attachments" id="attachments" hidden></div>
49
49
  <div class="row">
50
50
  <span class="prompt" aria-hidden="true">›</span>
51
- <textarea id="input" rows="1" placeholder="Say anything. Ramble away." autocomplete="off" autocorrect="on" spellcheck="true" aria-label="Message"></textarea>
51
+ <textarea id="input" rows="1" placeholder="Write a message" autocomplete="off" autocorrect="on" spellcheck="true" aria-label="Message"></textarea>
52
52
  <label class="icon-btn" title="Add a screenshot">
53
53
  <input type="file" id="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden>
54
54
  <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5" width="17" height="14" rx="2.5"/><path d="m4 16 4.5-4.5a1.5 1.5 0 0 1 2.1 0L15 16"/><path d="m13.5 14.5 1.7-1.7a1.5 1.5 0 0 1 2.1 0l2.7 2.7"/><circle cx="15.5" cy="9.5" r="1.2"/></svg>
@@ -84,6 +84,7 @@
84
84
  <script>window.PARLEY = {{CONFIG}};</script>
85
85
  <script src="/static/markdown.js"></script>
86
86
  <script src="/static/seal.js"></script>
87
+ <script src="/static/mark.js"></script>
87
88
  <script src="/static/chat.js"></script>
88
89
  </body>
89
90
  </html>
package/web/chat.js CHANGED
@@ -222,7 +222,16 @@
222
222
  if (live.text.trim()) {
223
223
  live.bodyEl.innerHTML = window.renderMarkdown(live.text);
224
224
  live.bodyEl.lastElementChild?.classList.add("cursor");
225
- } else live.bodyEl.innerHTML = `<span class="thinking" role="img" aria-label="Writing"><span></span><span></span><span></span></span>`;
225
+ } else {
226
+ // Working: the mark's core flickers while it looks things up, and light
227
+ // runs around it while it writes.
228
+ let working = live.bodyEl.querySelector(".thinking");
229
+ if (!working) {
230
+ live.bodyEl.innerHTML = `<span class="thinking" role="img" aria-label="Working">${window.parleyMark({ moving: true })}</span>`;
231
+ working = live.bodyEl.querySelector(".thinking");
232
+ }
233
+ working.classList.toggle("reading", live.steps.length > 0 && !live.text);
234
+ }
226
235
  // The last few things it looked at, newest highlighted.
227
236
  live.stepsEl.textContent = "";
228
237
  live.steps.slice(-4).forEach((line, i, shown) => live.stepsEl.append(el("li", { className: i === shown.length - 1 ? "now" : "", textContent: line })));
@@ -294,7 +303,7 @@
294
303
  if (seen.size === before) break;
295
304
  }
296
305
  const projectName = session?.project ?? knownProject ?? "a project";
297
- const lines = [`parley about ${projectName}, ${new Date().toLocaleDateString()}`, ""];
306
+ const lines = [`A parley about ${projectName}, ${new Date().toLocaleDateString()}`, ""];
298
307
  for (const m of [...kept.values()].sort((a, b) => a.id - b.id)) {
299
308
  if (m.role === "error") continue;
300
309
  lines.push(`${m.role === "guest" ? "You" : agentName()} · ${new Date(m.createdAt).toLocaleString()}`, m.body.trim(), "");
@@ -345,7 +354,7 @@
345
354
  end.onclick = () => endConversation(end);
346
355
  const open = !ended && session?.status !== "ended";
347
356
  els.sheetBody.replaceChildren(
348
- el("p", {}, strong(who), " set this up to hear what you think of ", strong(projectName), sender ? `. parley checked it's really them, with Google.` : "."),
357
+ el("p", {}, strong(who), " set this up to hear what you think of ", strong(projectName), sender ? `. Parley checked it's really them, with Google.` : "."),
349
358
  kv(facts),
350
359
  el("div", { className: "sheet-actions" }, save, open ? end : null),
351
360
  open ? el("p", { className: "fine" }, `Ending it stops this link working, for you and anyone else who has it. ${ownerName || "They"} keep${ownerName ? "s" : ""} everything said so far.`) : null,
@@ -358,7 +367,7 @@
358
367
  ? el(
359
368
  "p",
360
369
  {},
361
- `Messages and screenshots are end-to-end encrypted between this page and ${theirs} computer. parley passes them along but can't read them; it knows who sent this link, the project's name and when messages go by. The key is the part of your link after the #, so anyone with the whole link can read this chat: keep it to yourself. (This page's code comes from parley's servers, so that promise relies on parley serving it honestly. And ${agentName()} reads the conversation to reply, as with any ${agentName()} chat.)`,
370
+ `Messages and screenshots are end-to-end encrypted between this page and ${theirs} computer. Parley passes them along but can't read them; it knows who sent this link, the project's name and when messages go by. The key is the part of your link after the #, so anyone with the whole link can read this chat: keep it to yourself. (This page's code comes from Parley's servers, so that promise relies on Parley serving it honestly. And ${agentName()} reads the conversation to reply, as with any ${agentName()} chat.)`,
362
371
  )
363
372
  : null,
364
373
  el("p", {}, `When the replies run out, the chat pauses until ${whom} adds more. Replies come from ${theirs} computer, so it needs to be on, and the link stops working for good once the chat ends.`),
@@ -421,7 +430,7 @@
421
430
  function showEnded() {
422
431
  // For the guest, one quiet line about having their own.
423
432
  const own = () =>
424
- owner ? null : el("p", { className: "own" }, "Want people to talk to your agent? ", el("a", { href: homeUrl, target: "_blank", rel: "noopener", textContent: "Get parley" }));
433
+ owner ? null : el("p", { className: "own" }, "Want people to talk to your agent? ", el("a", { href: homeUrl, target: "_blank", rel: "noopener", textContent: "Get Parley" }));
425
434
  showNote("This parley has ended", "Thanks for the feedback. The conversation isn't available here anymore.", own);
426
435
  }
427
436
 
@@ -438,12 +447,26 @@
438
447
  els.layout.append(el("div", { className: "lost inline", style: "flex:1" }, el("div", {}, markEl(), el("h1", { textContent: title }), el("p", { textContent: text }), extra())));
439
448
  }
440
449
 
450
+ // An ended or broken link shows the mark dimmed.
441
451
  function markEl() {
442
- const span = el("span", { className: "mark" });
443
- span.innerHTML = `<svg viewBox="0 0 64 64"><rect class="tile" width="64" height="64" rx="14"/><rect class="glyph" x="19.5" y="16" width="7" height="32" rx="1.5"/><path class="glyph" fill-rule="evenodd" d="M44.5 25.5A10 10 0 1 1 24.5 25.5A10 10 0 1 1 44.5 25.5ZM38.5 25.5A4 4 0 1 0 30.5 25.5A4 4 0 1 0 38.5 25.5Z"/></svg>`;
452
+ const span = el("span", { className: "mark dim" });
453
+ span.innerHTML = window.parleyMark();
444
454
  return span;
445
455
  }
446
456
 
457
+ // The header's mark: it builds itself as the page opens, and its core
458
+ // pulses once when a reply lands.
459
+ const headMark = document.querySelector(".topbar .mark");
460
+ function markOnce(name, ms) {
461
+ if (!headMark || matchMedia("(prefers-reduced-motion: reduce)").matches) return;
462
+ headMark.classList.remove(name);
463
+ void headMark.offsetWidth;
464
+ headMark.classList.add(name);
465
+ setTimeout(() => headMark.classList.remove(name), ms);
466
+ }
467
+ headMark?.querySelectorAll(".r1, .r2").forEach((path, i) => (path.style.animationDelay = `${i * 30}ms`));
468
+ markOnce("opening", 1800);
469
+
447
470
  function banner(text) {
448
471
  els.banner.hidden = !text;
449
472
  els.banner.textContent = text || "";
@@ -474,7 +497,7 @@
474
497
  el("span", { className: "slash", textContent: "/" }),
475
498
  el("span", { className: "here", textContent: `${s.guest} on ${s.project}` }),
476
499
  );
477
- document.title = `${s.guest} on ${s.project} · parley`;
500
+ document.title = `${s.guest} on ${s.project} · Parley`;
478
501
  const used = s.replyLimit - s.repliesLeft;
479
502
  const pct = Math.min(100, Math.round((used / s.replyLimit) * 100));
480
503
  const kv = (pairs) => el("dl", { className: "kv" }, ...pairs.flatMap(([k, v]) => [el("dt", { textContent: k }), el("dd", { textContent: v })]));
@@ -902,7 +925,10 @@
902
925
  // A live update: a new message, the reply streaming in, what the agent is
903
926
  // looking at (your view only), replies left, or the end.
904
927
  function apply(event) {
905
- if (event.type === "message") addMessage(event.message);
928
+ if (event.type === "message") {
929
+ if (event.message.role === "agent" && !seen.has(event.message.id)) markOnce("pulse", 900);
930
+ addMessage(event.message);
931
+ }
906
932
  else if (event.type === "turn") event.live ? showLive(event.live) : clearLive();
907
933
  else if (event.type === "delta") {
908
934
  if (!live) showLive({ text: "", activity: null });
package/web/dashboard.js CHANGED
@@ -52,7 +52,7 @@
52
52
  const row = document.querySelector(`[data-computer="${computer}"]`);
53
53
  if (row) {
54
54
  row.querySelector(".dot").className = `dot${m.on ? " on" : ""}`;
55
- row.querySelector(".computer-status").textContent = m.on ? `online now${m.version ? ` · parley ${m.version}` : ""}` : "offline";
55
+ row.querySelector(".computer-status").textContent = m.on ? `online now${m.version ? ` · Parley ${m.version}` : ""}` : "offline";
56
56
  }
57
57
  paint();
58
58
  }
package/web/ended.html CHANGED
@@ -6,13 +6,13 @@
6
6
  <meta name="theme-color" content="#0c0c0d">
7
7
  <meta name="robots" content="noindex, nofollow">
8
8
  <title>This parley has ended</title>
9
- <link rel="icon" href="/static/icon.svg?v=5" type="image/svg+xml">
9
+ <link rel="icon" href="/static/icon.svg?v=6" type="image/svg+xml">
10
10
  <link rel="stylesheet" href="/static/app.css">
11
11
  </head>
12
12
  <body>
13
13
  <div class="lost">
14
14
  <div>
15
- <span class="mark" aria-hidden="true"><svg viewBox="0 0 64 64"><rect class="tile" width="64" height="64" rx="14"/><rect class="glyph" x="19.5" y="16" width="7" height="32" rx="1.5"/><path class="glyph" fill-rule="evenodd" d="M44.5 25.5A10 10 0 1 1 24.5 25.5A10 10 0 1 1 44.5 25.5ZM38.5 25.5A4 4 0 1 0 30.5 25.5A4 4 0 1 0 38.5 25.5Z"/></svg></span>
15
+ <span class="mark dim" aria-hidden="true"><svg viewBox="0 0 64 64" aria-hidden="true"><path class="r1" d="M32 7L39.2 11.2L32 15.3Z"/><path class="r2" d="M39.2 11.2L39.2 19.5L32 15.3Z"/><path class="r1" d="M39.2 11.2L46.4 15.3L39.2 19.5Z"/><path class="r2" d="M46.4 15.3L46.4 23.7L39.2 19.5Z"/><path class="r1" d="M46.4 15.3L53.7 19.5L46.4 23.7Z"/><path class="r2" d="M53.7 19.5L53.7 27.8L46.4 23.7Z"/><path class="r1" d="M46.4 23.7L53.7 27.8L46.4 32Z"/><path class="r2" d="M53.7 27.8L53.7 36.2L46.4 32Z"/><path class="r1" d="M46.4 32L53.7 36.2L46.4 40.3Z"/><path class="r2" d="M53.7 36.2L53.7 44.5L46.4 40.3Z"/><path class="r1" d="M46.4 40.3L53.7 44.5L46.4 48.7Z"/><path class="r2" d="M46.4 40.3L46.4 48.7L39.2 44.5Z"/><path class="r1" d="M39.2 44.5L46.4 48.7L39.2 52.8Z"/><path class="r2" d="M39.2 44.5L39.2 52.8L32 48.7Z"/><path class="r1" d="M32 48.7L39.2 52.8L32 57Z"/><path class="r2" d="M32 48.7L32 57L24.8 52.8Z"/><path class="r1" d="M24.8 44.5L32 48.7L24.8 52.8Z"/><path class="r2" d="M24.8 44.5L24.8 52.8L17.6 48.7Z"/><path class="r1" d="M17.6 40.3L24.8 44.5L17.6 48.7Z"/><path class="r2" d="M17.6 40.3L17.6 48.7L10.3 44.5Z"/><path class="r1" d="M10.3 36.2L17.6 40.3L10.3 44.5Z"/><path class="r2" d="M17.6 32L17.6 40.3L10.3 36.2Z"/><path class="r1" d="M10.3 27.8L17.6 32L10.3 36.2Z"/><path class="r2" d="M17.6 23.7L17.6 32L10.3 27.8Z"/><path class="r1" d="M10.3 19.5L17.6 23.7L10.3 27.8Z"/><path class="r2" d="M17.6 15.3L17.6 23.7L10.3 19.5Z"/><path class="r1" d="M17.6 15.3L24.8 19.5L17.6 23.7Z"/><path class="r2" d="M24.8 11.2L24.8 19.5L17.6 15.3Z"/><path class="r1" d="M24.8 11.2L32 15.3L24.8 19.5Z"/><path class="r2" d="M32 7L32 15.3L24.8 11.2Z"/><path class="c1" d="M32 23.7L39.2 27.8L32 32Z"/><path class="c2" d="M39.2 27.8L39.2 36.2L32 32Z"/><path class="c1" d="M32 32L39.2 36.2L32 40.3Z"/><path class="c2" d="M32 32L32 40.3L24.8 36.2Z"/><path class="c1" d="M24.8 27.8L32 32L24.8 36.2Z"/><path class="c2" d="M32 23.7L32 32L24.8 27.8Z"/></svg></span>
16
16
  <h1>This parley has ended</h1>
17
17
  <p>Thanks for the feedback. The conversation isn't available here anymore.</p>
18
18
  </div>
package/web/home.js CHANGED
@@ -1,7 +1,9 @@
1
- // parley's front page: plays the demo, three short parleys in turn, the way
2
- // a guest sees one. The first is already in the page, so without script (or
3
- // for anyone who prefers less motion) it just sits there, and the tabs swap
4
- // scenes without playing them.
1
+ // Parley's front page: plays the demo, three short parleys in turn, the way
2
+ // a guest sees one. The header, the composer and the working mark (mark.js)
3
+ // are the real chat's. The first scene is already in the page, so without
4
+ // script (or for anyone who prefers less motion) it just sits there, and the
5
+ // tabs swap scenes without playing them. Picking a scene plays it, then
6
+ // stays on it for a while before the others come round again.
5
7
  (() => {
6
8
  const root = document.getElementById("demo");
7
9
  const list = document.getElementById("demo-msgs");
@@ -9,7 +11,12 @@
9
11
  if (!root || !list || !data) return;
10
12
  const scenes = JSON.parse(data.textContent);
11
13
  const tabs = [...root.querySelectorAll("[data-scene]")];
14
+ const composer = document.getElementById("demo-composer");
15
+ const input = document.getElementById("demo-input");
16
+ const send = document.getElementById("demo-send");
17
+ const chip = document.getElementById("demo-chip");
12
18
  const still = matchMedia("(prefers-reduced-motion: reduce)").matches;
19
+ const STAY = 30_000; // after someone picks a scene
13
20
  let run = 0; // a new run cancels the one playing
14
21
 
15
22
  const el = (tag, className, text) => {
@@ -32,6 +39,43 @@
32
39
  };
33
40
  const wait = (ms, id) => new Promise((resolve, reject) => setTimeout(() => (id === run ? resolve() : reject(new Error("stopped"))), ms));
34
41
 
42
+ // Each parley opens the way a guest's does: the mark builds itself.
43
+ const mark = root.querySelector(".demo-top .mark");
44
+ mark?.querySelectorAll(".r1, .r2").forEach((path, k) => (path.style.animationDelay = `${k * 30}ms`));
45
+ function opening() {
46
+ if (!mark) return;
47
+ mark.classList.remove("opening");
48
+ void mark.getBoundingClientRect();
49
+ mark.classList.add("opening");
50
+ }
51
+
52
+ // Like the real chat, the frame stays put and the conversation scrolls.
53
+ const toEnd = () => (list.scrollTop = list.scrollHeight);
54
+
55
+ function setInput(text) {
56
+ input?.replaceChildren(text ? document.createTextNode(text) : el("span", "demo-placeholder", "Write a message"));
57
+ send?.classList.toggle("off", !text);
58
+ toEnd();
59
+ }
60
+
61
+ // The guest types into the composer and sends, as in the real chat.
62
+ async function say(text, id) {
63
+ composer?.classList.add("focus");
64
+ for (let k = 2; k < text.length; k += 2) {
65
+ setInput(text.slice(0, k));
66
+ await wait(30, id);
67
+ }
68
+ setInput(text);
69
+ await wait(450, id);
70
+ send?.classList.add("press");
71
+ await wait(130, id);
72
+ send?.classList.remove("press");
73
+ setInput("");
74
+ composer?.classList.remove("focus");
75
+ list.append(guest(text));
76
+ toEnd();
77
+ }
78
+
35
79
  function pick(i) {
36
80
  tabs.forEach((tab, j) => {
37
81
  tab.classList.toggle("on", i === j);
@@ -49,42 +93,52 @@
49
93
  const scene = scenes[i];
50
94
  pick(i);
51
95
  list.replaceChildren();
96
+ setInput("");
97
+ composer?.classList.remove("focus");
98
+ if (chip) chip.textContent = "100/100";
99
+ opening();
100
+ await wait(1200, id);
101
+ await say(scene.asks, id);
52
102
  await wait(500, id);
53
- list.append(guest(scene.asks));
54
- await wait(900, id);
55
- // Working: the agent looks something up in the project, then writes.
103
+ // Working: the mark reads (its core flickers), then writes (light runs
104
+ // around it), then the reply streams in.
56
105
  const node = el("li", "msg agent");
57
106
  const body = el("div", "body");
58
- body.innerHTML = `<span class="thinking" role="img" aria-label="Writing"><span></span><span></span><span></span></span>`;
59
- const steps = el("ul", "steps");
60
- node.append(body, steps);
107
+ body.innerHTML = `<span class="thinking reading" role="img" aria-label="Working">${window.parleyMark({ moving: true })}</span>`;
108
+ node.append(body);
61
109
  list.append(node);
62
- await wait(700, id);
63
- steps.append(el("li", "now", scene.step));
110
+ toEnd();
111
+ await wait(1600, id);
112
+ body.firstElementChild.classList.remove("reading");
64
113
  await wait(1400, id);
65
114
  const p = el("p", "cursor");
66
115
  body.replaceChildren(p);
67
116
  const words = scene.says.split(" ");
68
117
  for (let w = 1; w <= words.length; w++) {
69
118
  p.textContent = words.slice(0, w).join(" ");
119
+ toEnd();
70
120
  await wait(55, id);
71
121
  }
72
122
  p.classList.remove("cursor");
73
- steps.remove();
74
- await wait(1500, id);
75
- list.append(guest(scene.then));
123
+ if (chip) chip.textContent = "99/100";
124
+ await wait(1600, id);
125
+ await say(scene.then, id);
76
126
  await wait(4500, id);
77
127
  }
78
128
 
79
- async function loop(from) {
129
+ async function loop(from, stay = 0) {
80
130
  const id = ++run;
81
131
  try {
82
- for (let i = from; ; i = (i + 1) % scenes.length) await play(i, id);
132
+ for (let i = from; ; i = (i + 1) % scenes.length) {
133
+ await play(i, id);
134
+ if (stay) await wait(stay, id);
135
+ stay = 0;
136
+ }
83
137
  } catch {
84
138
  // Stopped for another scene.
85
139
  }
86
140
  }
87
141
 
88
- tabs.forEach((tab, i) => tab.addEventListener("click", () => (still ? show(i) : loop(i))));
142
+ tabs.forEach((tab, i) => tab.addEventListener("click", () => (still ? show(i) : loop(i, STAY))));
89
143
  if (!still) loop(0);
90
144
  })();
package/web/icon-180.png CHANGED
Binary file
package/web/icon.png CHANGED
Binary file
package/web/icon.svg CHANGED
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#9D92FF"/><rect x="19.5" y="16" width="7" height="32" rx="1.5" fill="#0C0C0D"/><path fill="#0C0C0D" fill-rule="evenodd" d="M44.5 25.5A10 10 0 1 1 24.5 25.5A10 10 0 1 1 44.5 25.5ZM38.5 25.5A4 4 0 1 0 30.5 25.5A4 4 0 1 0 38.5 25.5Z"/></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#1E1C27"/><path fill="#9D92FF" d="M32 7L39.2 11.2L32 15.3Z"/><path fill="#8A7FF0" d="M39.2 11.2L39.2 19.5L32 15.3Z"/><path fill="#9D92FF" d="M39.2 11.2L46.4 15.3L39.2 19.5Z"/><path fill="#8A7FF0" d="M46.4 15.3L46.4 23.7L39.2 19.5Z"/><path fill="#9D92FF" d="M46.4 15.3L53.7 19.5L46.4 23.7Z"/><path fill="#8A7FF0" d="M53.7 19.5L53.7 27.8L46.4 23.7Z"/><path fill="#9D92FF" d="M46.4 23.7L53.7 27.8L46.4 32Z"/><path fill="#8A7FF0" d="M53.7 27.8L53.7 36.2L46.4 32Z"/><path fill="#9D92FF" d="M46.4 32L53.7 36.2L46.4 40.3Z"/><path fill="#8A7FF0" d="M53.7 36.2L53.7 44.5L46.4 40.3Z"/><path fill="#9D92FF" d="M46.4 40.3L53.7 44.5L46.4 48.7Z"/><path fill="#8A7FF0" d="M46.4 40.3L46.4 48.7L39.2 44.5Z"/><path fill="#9D92FF" d="M39.2 44.5L46.4 48.7L39.2 52.8Z"/><path fill="#8A7FF0" d="M39.2 44.5L39.2 52.8L32 48.7Z"/><path fill="#9D92FF" d="M32 48.7L39.2 52.8L32 57Z"/><path fill="#8A7FF0" d="M32 48.7L32 57L24.8 52.8Z"/><path fill="#9D92FF" d="M24.8 44.5L32 48.7L24.8 52.8Z"/><path fill="#8A7FF0" d="M24.8 44.5L24.8 52.8L17.6 48.7Z"/><path fill="#9D92FF" d="M17.6 40.3L24.8 44.5L17.6 48.7Z"/><path fill="#8A7FF0" d="M17.6 40.3L17.6 48.7L10.3 44.5Z"/><path fill="#9D92FF" d="M10.3 36.2L17.6 40.3L10.3 44.5Z"/><path fill="#8A7FF0" d="M17.6 32L17.6 40.3L10.3 36.2Z"/><path fill="#9D92FF" d="M10.3 27.8L17.6 32L10.3 36.2Z"/><path fill="#8A7FF0" d="M17.6 23.7L17.6 32L10.3 27.8Z"/><path fill="#9D92FF" d="M10.3 19.5L17.6 23.7L10.3 27.8Z"/><path fill="#8A7FF0" d="M17.6 15.3L17.6 23.7L10.3 19.5Z"/><path fill="#9D92FF" d="M17.6 15.3L24.8 19.5L17.6 23.7Z"/><path fill="#8A7FF0" d="M24.8 11.2L24.8 19.5L17.6 15.3Z"/><path fill="#9D92FF" d="M24.8 11.2L32 15.3L24.8 19.5Z"/><path fill="#8A7FF0" d="M32 7L32 15.3L24.8 11.2Z"/><path fill="#D2CCFF" d="M32 23.7L39.2 27.8L32 32Z"/><path fill="#BDB4FF" d="M39.2 27.8L39.2 36.2L32 32Z"/><path fill="#D2CCFF" d="M32 32L39.2 36.2L32 40.3Z"/><path fill="#BDB4FF" d="M32 32L32 40.3L24.8 36.2Z"/><path fill="#D2CCFF" d="M24.8 27.8L32 32L24.8 36.2Z"/><path fill="#BDB4FF" d="M32 23.7L32 32L24.8 27.8Z"/></svg>
package/web/mark.js ADDED
@@ -0,0 +1,43 @@
1
+ // parley's mark, drawn on an isometric grid of equilateral triangles: a ring
2
+ // of thirty around a ring of space and a core of six. The same triangles do
3
+ // the motion. While the agent works, the core flickers as it reads and light
4
+ // runs around the empty ring as it writes (app.css has the animations).
5
+ (() => {
6
+ const S = 25 / 3; // a triangle's side, so the mark spans 50 of 64 units
7
+ const H = Math.sqrt(3) / 2;
8
+ const at = (i, j) => [32 + i * S * H, 32 + j * S + (i * S) / 2];
9
+ const far = (i, j) => Math.max(Math.abs(i), Math.abs(j), Math.abs(i + j));
10
+ const fmt = (n) => +n.toFixed(1);
11
+
12
+ const tris = [];
13
+ for (let i = -4; i <= 3; i++) {
14
+ for (let j = -4; j <= 3; j++) {
15
+ for (const corners of [[[i, j], [i + 1, j], [i, j + 1]], [[i + 1, j], [i + 1, j + 1], [i, j + 1]]]) {
16
+ const ring = Math.max(...corners.map(([a, b]) => far(a, b)));
17
+ if (ring > 3) continue;
18
+ const pts = corners.map(([a, b]) => at(a, b));
19
+ const cx = (pts[0][0] + pts[1][0] + pts[2][0]) / 3 - 32;
20
+ const cy = (pts[0][1] + pts[1][1] + pts[2][1]) / 3 - 32;
21
+ const xs = pts.map((p) => p[0]).sort((a, b) => a - b);
22
+ tris.push({
23
+ ring,
24
+ turn: ((Math.atan2(cx, -cy) * 180) / Math.PI + 360) % 360, // clockwise from the top
25
+ tone: Math.abs(xs[0] - xs[1]) < 0.01 ? 1 : 2, // which way it points
26
+ d: "M" + pts.map((p) => `${fmt(p[0])} ${fmt(p[1])}`).join("L") + "Z",
27
+ });
28
+ }
29
+ }
30
+ }
31
+ tris.sort((a, b) => a.turn - b.turn);
32
+ const SCAN = [0, 0.4, 0.8, 0.2, 0.6, 1.0];
33
+
34
+ // The still mark, or with `moving` the one that reads and writes.
35
+ window.parleyMark = ({ moving = false } = {}) => {
36
+ const ring = tris.filter((t) => t.ring === 3).map((t) => `<path class="r${t.tone}" d="${t.d}"/>`);
37
+ const core = tris
38
+ .filter((t) => t.ring === 1)
39
+ .map((t, k) => `<path class="c${t.tone}"${moving ? ` style="animation-delay:${SCAN[k]}s"` : ""} d="${t.d}"/>`);
40
+ const space = moving ? tris.filter((t) => t.ring === 2).map((t, k) => `<path class="g${t.tone}" style="animation-delay:${(k * 0.1).toFixed(1)}s" d="${t.d}"/>`) : [];
41
+ return `<svg viewBox="0 0 64 64" aria-hidden="true">${ring.join("")}${core.join("")}${space.join("")}</svg>`;
42
+ };
43
+ })();
package/web/missing.html CHANGED
@@ -6,13 +6,13 @@
6
6
  <meta name="theme-color" content="#0c0c0d">
7
7
  <meta name="robots" content="noindex, nofollow">
8
8
  <title>Link not found</title>
9
- <link rel="icon" href="/static/icon.svg?v=5" type="image/svg+xml">
9
+ <link rel="icon" href="/static/icon.svg?v=6" type="image/svg+xml">
10
10
  <link rel="stylesheet" href="/static/app.css">
11
11
  </head>
12
12
  <body>
13
13
  <div class="lost">
14
14
  <div>
15
- <span class="mark" aria-hidden="true"><svg viewBox="0 0 64 64"><rect class="tile" width="64" height="64" rx="14"/><rect class="glyph" x="19.5" y="16" width="7" height="32" rx="1.5"/><path class="glyph" fill-rule="evenodd" d="M44.5 25.5A10 10 0 1 1 24.5 25.5A10 10 0 1 1 44.5 25.5ZM38.5 25.5A4 4 0 1 0 30.5 25.5A4 4 0 1 0 38.5 25.5Z"/></svg></span>
15
+ <span class="mark dim" aria-hidden="true"><svg viewBox="0 0 64 64" aria-hidden="true"><path class="r1" d="M32 7L39.2 11.2L32 15.3Z"/><path class="r2" d="M39.2 11.2L39.2 19.5L32 15.3Z"/><path class="r1" d="M39.2 11.2L46.4 15.3L39.2 19.5Z"/><path class="r2" d="M46.4 15.3L46.4 23.7L39.2 19.5Z"/><path class="r1" d="M46.4 15.3L53.7 19.5L46.4 23.7Z"/><path class="r2" d="M53.7 19.5L53.7 27.8L46.4 23.7Z"/><path class="r1" d="M46.4 23.7L53.7 27.8L46.4 32Z"/><path class="r2" d="M53.7 27.8L53.7 36.2L46.4 32Z"/><path class="r1" d="M46.4 32L53.7 36.2L46.4 40.3Z"/><path class="r2" d="M53.7 36.2L53.7 44.5L46.4 40.3Z"/><path class="r1" d="M46.4 40.3L53.7 44.5L46.4 48.7Z"/><path class="r2" d="M46.4 40.3L46.4 48.7L39.2 44.5Z"/><path class="r1" d="M39.2 44.5L46.4 48.7L39.2 52.8Z"/><path class="r2" d="M39.2 44.5L39.2 52.8L32 48.7Z"/><path class="r1" d="M32 48.7L39.2 52.8L32 57Z"/><path class="r2" d="M32 48.7L32 57L24.8 52.8Z"/><path class="r1" d="M24.8 44.5L32 48.7L24.8 52.8Z"/><path class="r2" d="M24.8 44.5L24.8 52.8L17.6 48.7Z"/><path class="r1" d="M17.6 40.3L24.8 44.5L17.6 48.7Z"/><path class="r2" d="M17.6 40.3L17.6 48.7L10.3 44.5Z"/><path class="r1" d="M10.3 36.2L17.6 40.3L10.3 44.5Z"/><path class="r2" d="M17.6 32L17.6 40.3L10.3 36.2Z"/><path class="r1" d="M10.3 27.8L17.6 32L10.3 36.2Z"/><path class="r2" d="M17.6 23.7L17.6 32L10.3 27.8Z"/><path class="r1" d="M10.3 19.5L17.6 23.7L10.3 27.8Z"/><path class="r2" d="M17.6 15.3L17.6 23.7L10.3 19.5Z"/><path class="r1" d="M17.6 15.3L24.8 19.5L17.6 23.7Z"/><path class="r2" d="M24.8 11.2L24.8 19.5L17.6 15.3Z"/><path class="r1" d="M24.8 11.2L32 15.3L24.8 19.5Z"/><path class="r2" d="M32 7L32 15.3L24.8 11.2Z"/><path class="c1" d="M32 23.7L39.2 27.8L32 32Z"/><path class="c2" d="M39.2 27.8L39.2 36.2L32 32Z"/><path class="c1" d="M32 32L39.2 36.2L32 40.3Z"/><path class="c2" d="M32 32L32 40.3L24.8 36.2Z"/><path class="c1" d="M24.8 27.8L32 32L24.8 36.2Z"/><path class="c2" d="M32 23.7L32 32L24.8 27.8Z"/></svg></span>
16
16
  <h1>This link doesn't lead anywhere</h1>
17
17
  <p>Check that you have the whole link, or ask whoever sent it for a fresh one.</p>
18
18
  </div>
package/web/panel.html CHANGED
@@ -4,13 +4,13 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <meta name="theme-color" content="#0c0c0d">
7
- <title>Control panel · parley</title>
8
- <link rel="icon" href="/static/icon.svg?v=5" type="image/svg+xml">
7
+ <title>Control panel · Parley</title>
8
+ <link rel="icon" href="/static/icon.svg?v=6" type="image/svg+xml">
9
9
  <link rel="stylesheet" href="/static/app.css">
10
10
  </head>
11
11
  <body class="panel">
12
12
  <header class="topbar">
13
- <span class="mark" aria-hidden="true"><svg viewBox="0 0 64 64"><rect class="tile" width="64" height="64" rx="14"/><rect class="glyph" x="19.5" y="16" width="7" height="32" rx="1.5"/><path class="glyph" fill-rule="evenodd" d="M44.5 25.5A10 10 0 1 1 24.5 25.5A10 10 0 1 1 44.5 25.5ZM38.5 25.5A4 4 0 1 0 30.5 25.5A4 4 0 1 0 38.5 25.5Z"/></svg></span>
13
+ <span class="mark" aria-hidden="true"><svg viewBox="0 0 64 64" aria-hidden="true"><path class="r1" d="M32 7L39.2 11.2L32 15.3Z"/><path class="r2" d="M39.2 11.2L39.2 19.5L32 15.3Z"/><path class="r1" d="M39.2 11.2L46.4 15.3L39.2 19.5Z"/><path class="r2" d="M46.4 15.3L46.4 23.7L39.2 19.5Z"/><path class="r1" d="M46.4 15.3L53.7 19.5L46.4 23.7Z"/><path class="r2" d="M53.7 19.5L53.7 27.8L46.4 23.7Z"/><path class="r1" d="M46.4 23.7L53.7 27.8L46.4 32Z"/><path class="r2" d="M53.7 27.8L53.7 36.2L46.4 32Z"/><path class="r1" d="M46.4 32L53.7 36.2L46.4 40.3Z"/><path class="r2" d="M53.7 36.2L53.7 44.5L46.4 40.3Z"/><path class="r1" d="M46.4 40.3L53.7 44.5L46.4 48.7Z"/><path class="r2" d="M46.4 40.3L46.4 48.7L39.2 44.5Z"/><path class="r1" d="M39.2 44.5L46.4 48.7L39.2 52.8Z"/><path class="r2" d="M39.2 44.5L39.2 52.8L32 48.7Z"/><path class="r1" d="M32 48.7L39.2 52.8L32 57Z"/><path class="r2" d="M32 48.7L32 57L24.8 52.8Z"/><path class="r1" d="M24.8 44.5L32 48.7L24.8 52.8Z"/><path class="r2" d="M24.8 44.5L24.8 52.8L17.6 48.7Z"/><path class="r1" d="M17.6 40.3L24.8 44.5L17.6 48.7Z"/><path class="r2" d="M17.6 40.3L17.6 48.7L10.3 44.5Z"/><path class="r1" d="M10.3 36.2L17.6 40.3L10.3 44.5Z"/><path class="r2" d="M17.6 32L17.6 40.3L10.3 36.2Z"/><path class="r1" d="M10.3 27.8L17.6 32L10.3 36.2Z"/><path class="r2" d="M17.6 23.7L17.6 32L10.3 27.8Z"/><path class="r1" d="M10.3 19.5L17.6 23.7L10.3 27.8Z"/><path class="r2" d="M17.6 15.3L17.6 23.7L10.3 19.5Z"/><path class="r1" d="M17.6 15.3L24.8 19.5L17.6 23.7Z"/><path class="r2" d="M24.8 11.2L24.8 19.5L17.6 15.3Z"/><path class="r1" d="M24.8 11.2L32 15.3L24.8 19.5Z"/><path class="r2" d="M32 7L32 15.3L24.8 11.2Z"/><path class="c1" d="M32 23.7L39.2 27.8L32 32Z"/><path class="c2" d="M39.2 27.8L39.2 36.2L32 32Z"/><path class="c1" d="M32 32L39.2 36.2L32 40.3Z"/><path class="c2" d="M32 32L32 40.3L24.8 36.2Z"/><path class="c1" d="M24.8 27.8L32 32L24.8 36.2Z"/><path class="c2" d="M32 23.7L32 32L24.8 27.8Z"/></svg></span>
14
14
  <nav class="crumbs"><span class="product">parley</span><span class="slash">/</span><span class="here">Control panel</span></nav>
15
15
  <span class="status" id="server"><span class="dot" id="server-dot"></span><span id="server-text">Checking…</span></span>
16
16
  </header>
package/web/panel.js CHANGED
@@ -51,7 +51,7 @@
51
51
  offline: ["warn", `Reconnecting to ${host}`],
52
52
  "signed-out": ["warn", "Not signed in · run parley login"],
53
53
  unlinked: ["off", "Unlinked · run parley login"],
54
- replaced: ["off", "Another parley took over"],
54
+ replaced: ["off", "Another Parley server took over"],
55
55
  };
56
56
  const [dot, label] = RELAY[relay.state] ?? ["warn", relay.state];
57
57
  $("server-dot").className = `dot ${dot}`;