@khanglvm/relay 0.14.0 → 0.14.2
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 +13 -9
- package/docs/AGENT.md +13 -10
- package/package.json +1 -1
- package/skills/relay/SKILL.md +10 -3
- package/src/cli.js +11 -4
- package/src/server.js +19 -32
- package/src/ui/app.js +31 -24
- package/src/ui/style.css +30 -17
package/README.md
CHANGED
|
@@ -80,10 +80,11 @@ unchanged; pick whichever surface fits.
|
|
|
80
80
|
| "Type *done* when finished reviewing" | A Submit button; answers, notes, and inline comments returned as JSON |
|
|
81
81
|
| Feedback = another wall of text | Click any chart point, diagram node, table cell, or sentence to comment — the agent replies and the thread grows on the board |
|
|
82
82
|
|
|
83
|
-
Everything autosaves in real time
|
|
84
|
-
run at once, and the package has **zero npm
|
|
85
|
-
Node ≥ 18. Browser-side Chart.js, Mermaid, and
|
|
86
|
-
the package and lazy-loaded offline when a board
|
|
83
|
+
Everything autosaves in real time, detached board links keep serving after
|
|
84
|
+
agent timeouts, multiple boards run at once, and the package has **zero npm
|
|
85
|
+
runtime dependencies** — plain Node ≥ 18. Browser-side Chart.js, Mermaid, and
|
|
86
|
+
Viz.js assets are vendored in the package and lazy-loaded offline when a board
|
|
87
|
+
actually needs them.
|
|
87
88
|
|
|
88
89
|
## Learn more
|
|
89
90
|
|
|
@@ -171,9 +172,12 @@ npm test # smoke tests with no external services (spawns real servers, fake-
|
|
|
171
172
|
embedded in the payload.
|
|
172
173
|
- **`pdf` block** — render local `.pdf` files or PDF URLs inline. Local PDFs
|
|
173
174
|
stream from the board server and are never embedded in the page payload.
|
|
174
|
-
- **Durable
|
|
175
|
-
|
|
176
|
-
|
|
175
|
+
- **Durable boards / rescue** — detached boards keep serving after timeout
|
|
176
|
+
until Submit or `rly stop`; every autosave mirrors to `localStorage`; a board
|
|
177
|
+
whose connection drops blocks further input instead of losing it, and
|
|
178
|
+
`rly rescue <id>` re-serves on the same port so an open tab reconnects. Active
|
|
179
|
+
same-Wi-Fi share links stay tied to the board and survive a same-port re-serve
|
|
180
|
+
until revoked.
|
|
177
181
|
- Still **zero npm runtime dependencies**, offline, and cross-platform. Vendored
|
|
178
182
|
browser libraries are loaded only for boards that need them.
|
|
179
183
|
|
|
@@ -201,8 +205,8 @@ npm test # smoke tests with no external services (spawns real servers, fake-
|
|
|
201
205
|
- **Markdown blocks render GFM tables**; element comments moved to an
|
|
202
206
|
Outline-style right sidebar with inline highlights on commented text.
|
|
203
207
|
- **Seamless timeouts** — a detached board that runs past its deadline keeps
|
|
204
|
-
serving so you can still submit (it lands as
|
|
205
|
-
calm note instead of disconnecting.
|
|
208
|
+
serving until Submit or explicit stop, so you can still submit (it lands as
|
|
209
|
+
`submitted`); the page shows a calm note instead of disconnecting.
|
|
206
210
|
- **`rly upgrade`** — install the latest CLI and refresh the skill in one step.
|
|
207
211
|
- Per-question notes are multi-line textboxes.
|
|
208
212
|
|
package/docs/AGENT.md
CHANGED
|
@@ -75,8 +75,7 @@ results), so partial input is never lost.
|
|
|
75
75
|
|
|
76
76
|
Browser boards are local-owner only by default. A same-Wi-Fi phone/tablet/other
|
|
77
77
|
laptop gets a locked page until sharing is explicitly activated. The owner can
|
|
78
|
-
click **Share**
|
|
79
|
-
the user:
|
|
78
|
+
click the topbar **Share** icon, or you can manage the same links for the user:
|
|
80
79
|
|
|
81
80
|
```sh
|
|
82
81
|
rly share b-xxxxx # list active share roles
|
|
@@ -91,7 +90,8 @@ owner. Reviewers cannot change answers, submit, edit comments, or delete
|
|
|
91
90
|
comments; their autosaves merge new comments into the existing draft.
|
|
92
91
|
Collaborators can edit answers, comment, open allowed local file links, and
|
|
93
92
|
submit. Shared viewers refresh from the live draft when another viewer saves,
|
|
94
|
-
deferred while someone is typing/commenting.
|
|
93
|
+
deferred while someone is typing/commenting. Active share links keep the same
|
|
94
|
+
token across a same-port `rly reopen`/`rly rescue` until revoked.
|
|
95
95
|
|
|
96
96
|
## Inline mode — relay as an MCP App (Claude & Codex apps)
|
|
97
97
|
|
|
@@ -672,12 +672,13 @@ raising `--timeout`.
|
|
|
672
672
|
For a **detached** board, the `timeout` deadline is *soft*: it hands you a
|
|
673
673
|
`timeout` result (with the autosaved draft) so you regain control, but the
|
|
674
674
|
server stays up and the board stays fully usable — the user can keep
|
|
675
|
-
commenting and still hit Submit.
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
`
|
|
679
|
-
|
|
680
|
-
|
|
675
|
+
commenting and still hit Submit. It stays on the same port until Submit or an
|
|
676
|
+
explicit `rly stop`, so don't reopen just because your wait timed out. The page
|
|
677
|
+
tells them you stopped waiting and to prompt you afterward. So if you got a
|
|
678
|
+
`timeout` and the user might still be working: re-check later with `rly result
|
|
679
|
+
<id>` (its status flips to `submitted` once they finish), or pass `--on-result`
|
|
680
|
+
so a late submit push-wakes you. A blocking `rly ask` (no `--detach`) still ends
|
|
681
|
+
hard on timeout, since there's no separate waiter to hand back to.
|
|
681
682
|
|
|
682
683
|
## Push-wake — get notified instead of polling
|
|
683
684
|
|
|
@@ -745,7 +746,9 @@ rly rm <id> | --all # delete saved board(s)
|
|
|
745
746
|
```
|
|
746
747
|
|
|
747
748
|
Multiple boards can run at once (each gets its own port on 127.0.0.1).
|
|
748
|
-
|
|
749
|
+
Detached boards are durable: they keep serving past timeout, `reopen`/`rescue`
|
|
750
|
+
prefer the last port, and active same-Wi-Fi share links keep the same token until
|
|
751
|
+
revoked. Storage lives in `~/.relay` (override with `RLY_HOME`).
|
|
749
752
|
|
|
750
753
|
## Live board mutation — `rly update`
|
|
751
754
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanglvm/relay",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
4
4
|
"description": "Question boards with rich blocks (markdown, charts, mermaid, tables, code, diffs, video, sandboxed HTML), clickable local file-links, and element-level annotations for AI coding agents (Claude Code, Codex, …): ask users structured questions, present interactive visuals, collect inline comments, read answers as JSON — in a local browser board OR rendered INLINE inside the Claude & Codex apps as an MCP App (SEP-1865).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
package/skills/relay/SKILL.md
CHANGED
|
@@ -135,6 +135,10 @@ rly wait b-xxxxx --timeout 550 # blocks until submit, prints result JSON
|
|
|
135
135
|
rly result b-xxxxx # non-blocking peek (includes live draft)
|
|
136
136
|
```
|
|
137
137
|
|
|
138
|
+
Detached boards are durable: their own timeout hands the agent a `timeout`
|
|
139
|
+
result but keeps the same URL/port serving until Submit or `rly stop`. Do not
|
|
140
|
+
create a new board just because a wait timed out.
|
|
141
|
+
|
|
138
142
|
For long waits prefer presence-aware waiting over a huge --timeout:
|
|
139
143
|
|
|
140
144
|
```sh
|
|
@@ -178,8 +182,8 @@ answers and any annotations written so far.
|
|
|
178
182
|
|
|
179
183
|
Browser boards are local-owner only by default. A phone/tablet/other laptop on
|
|
180
184
|
the same Wi-Fi gets a locked page until sharing is explicitly activated. The
|
|
181
|
-
owner can click **Share**
|
|
182
|
-
|
|
185
|
+
owner can click the topbar **Share** icon, or an agent can manage the same links
|
|
186
|
+
with `rly share`:
|
|
183
187
|
|
|
184
188
|
```sh
|
|
185
189
|
rly share b-xxxxx # list active share roles
|
|
@@ -195,6 +199,8 @@ new comments into the existing draft. Use `collab` only when the user explicitly
|
|
|
195
199
|
wants that device to act as the owner: collaborators can edit answers, comment,
|
|
196
200
|
open allowed local file links, and submit. Shared viewers refresh from the live
|
|
197
201
|
draft when another viewer saves, deferred while someone is typing/commenting.
|
|
202
|
+
Active share links are durable too: they keep the same token across a same-port
|
|
203
|
+
`rly reopen`/`rly rescue` until `rly share --revoke` disables them.
|
|
198
204
|
|
|
199
205
|
## Minimal spec
|
|
200
206
|
|
|
@@ -534,7 +540,8 @@ one yesno "Does this match your mental model?" + a textarea for notes.
|
|
|
534
540
|
answers prefilled) · `rly reopen <id> --replies file.json` (add agent replies) ·
|
|
535
541
|
`rly rescue <id>` (re-serve a dropped board on its ORIGINAL port) ·
|
|
536
542
|
`rly list` / `rly open` / `rly stop <id>` · `rly rm <id>`.
|
|
537
|
-
Multiple boards can run concurrently.
|
|
543
|
+
Multiple boards can run concurrently. Detached boards keep serving past timeout;
|
|
544
|
+
use `rly stop` when you actually want to close one.
|
|
538
545
|
|
|
539
546
|
### Continue a board — reconnect, NEVER recreate it
|
|
540
547
|
|
package/src/cli.js
CHANGED
|
@@ -1084,6 +1084,9 @@ the user should view — put it in relay instead of printing it.**
|
|
|
1084
1084
|
if it is still open and the user may continue, run \`rly wait\` again. Do not
|
|
1085
1085
|
use \`--on-result\` as the primary Codex return path; normal Codex CLI sessions
|
|
1086
1086
|
do not expose a portable inbound API that wakes the current agent turn.
|
|
1087
|
+
Detached boards are durable: a board timeout hands the agent a \`timeout\`
|
|
1088
|
+
result but keeps the same URL/port serving until Submit or \`rly stop\`. Do
|
|
1089
|
+
not create a new board just because a wait timed out.
|
|
1087
1090
|
- **A plan, structure, architecture, data, or prototype** → a relay board with
|
|
1088
1091
|
diagram/chart/table/code/image/html blocks — never ASCII diagrams or walls of prose.
|
|
1089
1092
|
- **"Show me the diff / git diff / these changes"** → \`rly diff\` (runs git diff →
|
|
@@ -1098,7 +1101,9 @@ the user should view — put it in relay instead of printing it.**
|
|
|
1098
1101
|
- Same-Wi-Fi sharing is locked by default. When the user wants another device to
|
|
1099
1102
|
review or co-fill a running board, manage explicit links with \`rly share <id>\`:
|
|
1100
1103
|
\`--role review\` creates a comments-only reviewer link, \`--role collab\` creates
|
|
1101
|
-
an edit/comment/submit link, and \`--revoke\` disables active links.
|
|
1104
|
+
an edit/comment/submit link, and \`--revoke\` disables active links. Active
|
|
1105
|
+
share links are durable across same-port \`rly reopen\`/\`rly rescue\` until
|
|
1106
|
+
revoked.
|
|
1102
1107
|
- **There's a purpose-built component for most content — use the MOST SPECIFIC one,
|
|
1103
1108
|
never plain prose when a block fits.** Blocks: \`table\` (sortable/filterable/CSV,
|
|
1104
1109
|
load from .csv/.json), \`chart\`, \`kpi\` (stat cards), \`mermaid\`/\`graphviz\`/\`plantuml\`,
|
|
@@ -1568,8 +1573,8 @@ async function cmdServeInternal(args) {
|
|
|
1568
1573
|
open: args.open !== false,
|
|
1569
1574
|
timeoutSec: args.timeout !== undefined ? Math.max(0, Number.parseInt(args.timeout, 10) || 0) : 1800,
|
|
1570
1575
|
quiet: true,
|
|
1571
|
-
// Detached board: timeout hands back to the agent but keeps
|
|
1572
|
-
//
|
|
1576
|
+
// Detached board: timeout hands back to the agent but keeps the same
|
|
1577
|
+
// URL/port serving until Submit or explicit stop.
|
|
1573
1578
|
keepAliveOnTimeout: true,
|
|
1574
1579
|
});
|
|
1575
1580
|
await done;
|
|
@@ -1609,6 +1614,7 @@ USAGE
|
|
|
1609
1614
|
rly share <id> --role collab activate a collaborator link (edit/comment/submit)
|
|
1610
1615
|
rly share <id> --role review --revoke
|
|
1611
1616
|
revoke one share role; use --revoke --all to disable all roles
|
|
1617
|
+
active links survive same-port reopen/rescue until revoked
|
|
1612
1618
|
rly list [--json] running boards
|
|
1613
1619
|
rly open [id] re-open the browser tab of a running board
|
|
1614
1620
|
rly reopen <id> [--replies f.json] serve a saved board again, prefilled with saved answers
|
|
@@ -1643,7 +1649,8 @@ COMMON FLAGS
|
|
|
1643
1649
|
|
|
1644
1650
|
EXIT CODES 0 submitted/acknowledged · 2 timeout · 3 cancelled · 4 usage · 5 not found
|
|
1645
1651
|
|
|
1646
|
-
NOTES
|
|
1652
|
+
NOTES detached boards keep serving after timeout until Submit or stop;
|
|
1653
|
+
answers & annotations autosave in real time (drafts survive timeout/cancel);
|
|
1647
1654
|
submitting auto-closes the tab and unblocks the CLI.
|
|
1648
1655
|
|
|
1649
1656
|
AI AGENTS run \`rly agent\` for the complete machine-oriented guide.
|
package/src/server.js
CHANGED
|
@@ -698,11 +698,15 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
698
698
|
// Mutation token: only `rly update` (which reads the running-file record)
|
|
699
699
|
// can authenticate to POST /api/update. Never embedded in the page/boot.
|
|
700
700
|
const token = crypto.randomBytes(16).toString('hex');
|
|
701
|
+
const savedShares = record.share && typeof record.share === 'object' ? record.share : {};
|
|
701
702
|
const shareTokens = {
|
|
702
|
-
collab: crypto.randomBytes(16).toString('hex'),
|
|
703
|
-
review: crypto.randomBytes(16).toString('hex'),
|
|
703
|
+
collab: typeof savedShares.collab?.token === 'string' ? savedShares.collab.token : crypto.randomBytes(16).toString('hex'),
|
|
704
|
+
review: typeof savedShares.review?.token === 'string' ? savedShares.review.token : crypto.randomBytes(16).toString('hex'),
|
|
705
|
+
};
|
|
706
|
+
const activeShares = {
|
|
707
|
+
collab: savedShares.collab?.active === true,
|
|
708
|
+
review: savedShares.review?.active === true,
|
|
704
709
|
};
|
|
705
|
-
const activeShares = { collab: false, review: false };
|
|
706
710
|
let rev = 1;
|
|
707
711
|
let draftRev = Number.isFinite(record.draftRev) ? record.draftRev : 0;
|
|
708
712
|
let status = 'open';
|
|
@@ -766,6 +770,14 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
766
770
|
};
|
|
767
771
|
};
|
|
768
772
|
const shareUrlFor = (role) => advertisedHost ? `http://${advertisedHost}:${actualPort}/?share=${role}&token=${shareTokens[role]}` : null;
|
|
773
|
+
const persistShareState = () => {
|
|
774
|
+
record.share = {
|
|
775
|
+
collab: { active: activeShares.collab === true, token: shareTokens.collab },
|
|
776
|
+
review: { active: activeShares.review === true, token: shareTokens.review },
|
|
777
|
+
updatedAt: new Date().toISOString(),
|
|
778
|
+
};
|
|
779
|
+
saveBoard(record);
|
|
780
|
+
};
|
|
769
781
|
let resolveDone;
|
|
770
782
|
const done = new Promise((r) => {
|
|
771
783
|
resolveDone = r;
|
|
@@ -970,6 +982,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
970
982
|
const role = body.role === 'review' ? 'review' : body.role === 'collab' ? 'collab' : null;
|
|
971
983
|
if (!role) return sendJson(res, 400, { error: 'role must be "collab" or "review"' });
|
|
972
984
|
activeShares[role] = true;
|
|
985
|
+
persistShareState();
|
|
973
986
|
sendJson(res, 200, { ok: true, role, url: shareUrlFor(role) });
|
|
974
987
|
} else if (req.method === 'DELETE' && pathname === '/api/share') {
|
|
975
988
|
if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can manage sharing' });
|
|
@@ -982,6 +995,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
982
995
|
} else {
|
|
983
996
|
activeShares[role] = false;
|
|
984
997
|
}
|
|
998
|
+
persistShareState();
|
|
985
999
|
sendJson(res, 200, { ok: true, role, active: false });
|
|
986
1000
|
} else {
|
|
987
1001
|
sendJson(res, 404, { error: 'not found' });
|
|
@@ -1040,15 +1054,13 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
1040
1054
|
});
|
|
1041
1055
|
|
|
1042
1056
|
let timer = null;
|
|
1043
|
-
let idleTimer = null;
|
|
1044
1057
|
// The detached server's timeout is SOFT (keepAliveOnTimeout): at the deadline
|
|
1045
1058
|
// we hand a `timeout` result back to the waiting agent (so `rly wait` returns
|
|
1046
1059
|
// with the autosaved draft) but keep the server listening, so the user can
|
|
1047
1060
|
// keep working and still submit. A late submit overwrites the result with
|
|
1048
1061
|
// `submitted` and re-fires the push-wake; the board only truly closes on
|
|
1049
|
-
// submit
|
|
1050
|
-
//
|
|
1051
|
-
// its timeout stays hard (close + resolve, exit 2).
|
|
1062
|
+
// submit or an explicit stop. A BLOCKING `rly ask` has no separate waiter to
|
|
1063
|
+
// hand back to, so its timeout stays hard (close + resolve, exit 2).
|
|
1052
1064
|
if (timeoutSec > 0) {
|
|
1053
1065
|
timer = setTimeout(keepAliveOnTimeout ? softTimeout : () => finish({ status: 'timeout' }), timeoutSec * 1000);
|
|
1054
1066
|
}
|
|
@@ -1097,7 +1109,6 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
1097
1109
|
function closeServer(result) {
|
|
1098
1110
|
removeRunning(record.id);
|
|
1099
1111
|
if (timer) clearTimeout(timer);
|
|
1100
|
-
if (idleTimer) clearInterval(idleTimer);
|
|
1101
1112
|
process.removeListener('SIGINT', onSignal);
|
|
1102
1113
|
process.removeListener('SIGTERM', onSignal);
|
|
1103
1114
|
setTimeout(() => {
|
|
@@ -1131,30 +1142,6 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
1131
1142
|
softTimedOut = true;
|
|
1132
1143
|
const result = persistResult({ status: 'timeout' });
|
|
1133
1144
|
runOnResult(record.onResult, result, { quiet });
|
|
1134
|
-
startIdleWatchdog();
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
// After a soft timeout, close the board for real once the user has clearly
|
|
1138
|
-
// left (no presence ping for IDLE_CLOSE_MS) or a hard absolute cap is hit, so
|
|
1139
|
-
// an abandoned board doesn't keep a server alive forever. Re-persists the
|
|
1140
|
-
// latest draft as the final `timeout` result; no double push-wake.
|
|
1141
|
-
function startIdleWatchdog() {
|
|
1142
|
-
const IDLE_CLOSE_MS = 15 * 60 * 1000;
|
|
1143
|
-
const HARD_CAP_MS = 6 * 60 * 60 * 1000;
|
|
1144
|
-
const softAt = Date.now();
|
|
1145
|
-
idleTimer = setInterval(() => {
|
|
1146
|
-
if (finished) {
|
|
1147
|
-
clearInterval(idleTimer);
|
|
1148
|
-
return;
|
|
1149
|
-
}
|
|
1150
|
-
const lastSeen = presence ? presence.atMs : softAt;
|
|
1151
|
-
if (Date.now() - lastSeen > IDLE_CLOSE_MS || Date.now() - softAt > HARD_CAP_MS) {
|
|
1152
|
-
finished = true;
|
|
1153
|
-
clearInterval(idleTimer);
|
|
1154
|
-
closeServer(persistResult({ status: 'timeout' }));
|
|
1155
|
-
}
|
|
1156
|
-
}, 60 * 1000);
|
|
1157
|
-
idleTimer.unref?.();
|
|
1158
1145
|
}
|
|
1159
1146
|
|
|
1160
1147
|
if (open) openUrl(url);
|
package/src/ui/app.js
CHANGED
|
@@ -53,15 +53,19 @@
|
|
|
53
53
|
// live connection dropped — used to tailor the post-submit message. The board
|
|
54
54
|
// stays fully usable either way; we never disable Submit.
|
|
55
55
|
let handedBack = false;
|
|
56
|
-
// Calm, persistent status
|
|
56
|
+
// Calm, persistent status notes (the #banner stack). tone 'info' is the
|
|
57
57
|
// default neutral style; 'warn' is a softer amber, NOT the old red error.
|
|
58
|
-
//
|
|
58
|
+
// Each message is shown at most once so the heartbeat can call it every tick.
|
|
59
59
|
const notesShown = new Set();
|
|
60
60
|
function showNotice(message, tone) {
|
|
61
61
|
if (notesShown.has(message)) return;
|
|
62
62
|
notesShown.add(message);
|
|
63
|
-
banner.
|
|
64
|
-
|
|
63
|
+
if (banner.dataset.ready !== '1') {
|
|
64
|
+
banner.replaceChildren();
|
|
65
|
+
banner.dataset.ready = '1';
|
|
66
|
+
}
|
|
67
|
+
banner.className = 'banner-stack';
|
|
68
|
+
banner.append(el('div', { class: 'banner-item ' + (tone === 'warn' ? 'warn' : 'info') }, message));
|
|
65
69
|
banner.style.display = 'block';
|
|
66
70
|
}
|
|
67
71
|
|
|
@@ -1036,6 +1040,24 @@
|
|
|
1036
1040
|
fsUp.addEventListener('click', () => setFontScale(fontScale + FS_STEP));
|
|
1037
1041
|
const fsCtrl = el('div', { class: 'fs-ctrl' }, fsDown, fsUp);
|
|
1038
1042
|
|
|
1043
|
+
function buildShareControl() {
|
|
1044
|
+
if (!access.canShare) return null;
|
|
1045
|
+
const wrap = el('span', { class: 'share-wrap' });
|
|
1046
|
+
const shareBtn = el('button', { class: 'share-btn icon-btn', type: 'button', title: 'Share board', 'aria-label': 'Share board' });
|
|
1047
|
+
shareBtn.innerHTML =
|
|
1048
|
+
'<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' +
|
|
1049
|
+
'<path d="M6.5 9.5 9.5 11.2M9.5 4.8 6.5 6.5"/>' +
|
|
1050
|
+
'<circle cx="4.5" cy="8" r="2"/><circle cx="11.5" cy="4" r="2"/><circle cx="11.5" cy="12" r="2"/>' +
|
|
1051
|
+
'</svg>';
|
|
1052
|
+
shareBtn.addEventListener('click', () => {
|
|
1053
|
+
const existing = wrap.querySelector('.share-panel');
|
|
1054
|
+
if (existing) existing.remove();
|
|
1055
|
+
else openSharePanel(wrap);
|
|
1056
|
+
});
|
|
1057
|
+
wrap.append(shareBtn);
|
|
1058
|
+
return wrap;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1039
1061
|
// Just reloaded after a live `rly update`? Flag set before reload below.
|
|
1040
1062
|
try {
|
|
1041
1063
|
if (sessionStorage.getItem('relay-updated') === '1') {
|
|
@@ -1051,7 +1073,7 @@
|
|
|
1051
1073
|
}
|
|
1052
1074
|
|
|
1053
1075
|
const titleEl = el('h1', {}, spec.title);
|
|
1054
|
-
app.append(el('header', { class: 'qb-header' }, titleEl, el('div', { class: 'qb-controls' }, fsCtrl, themeBtn)));
|
|
1076
|
+
app.append(el('header', { class: 'qb-header' }, titleEl, el('div', { class: 'qb-controls' }, fsCtrl, buildShareControl(), themeBtn)));
|
|
1055
1077
|
// The board title is commentable too: hovering it shows the pin, like the
|
|
1056
1078
|
// intro and every other content element. (themeBtn stays out of it.)
|
|
1057
1079
|
if (spec.title) {
|
|
@@ -1153,7 +1175,7 @@
|
|
|
1153
1175
|
function openSharePanel(host) {
|
|
1154
1176
|
const panel = el('div', { class: 'share-panel' },
|
|
1155
1177
|
el('div', { class: 'share-title' }, 'Share this board'),
|
|
1156
|
-
el('div', { class: 'share-desc' }, 'Choose a permission. The link starts working
|
|
1178
|
+
el('div', { class: 'share-desc' }, 'Choose a permission. The link starts working immediately.'),
|
|
1157
1179
|
el('button', { class: 'share-choice', type: 'button', 'data-role': 'collab' },
|
|
1158
1180
|
el('span', { class: 'share-choice-title' }, 'Collaborator'),
|
|
1159
1181
|
el('span', { class: 'share-choice-sub' }, 'Can edit answers, comment, and submit as owner.')
|
|
@@ -1172,8 +1194,6 @@
|
|
|
1172
1194
|
for (const btn of panel.querySelectorAll('.share-choice')) {
|
|
1173
1195
|
btn.addEventListener('click', async () => {
|
|
1174
1196
|
const role = btn.getAttribute('data-role');
|
|
1175
|
-
const label = role === 'collab' ? 'collaborator' : 'reviewer';
|
|
1176
|
-
if (!window.confirm(`Activate a ${label} link for same-Wi-Fi devices?`)) return;
|
|
1177
1197
|
btn.disabled = true;
|
|
1178
1198
|
result.textContent = 'Activating...';
|
|
1179
1199
|
try {
|
|
@@ -1203,27 +1223,14 @@
|
|
|
1203
1223
|
|
|
1204
1224
|
function buildFooter() {
|
|
1205
1225
|
const footer = el('footer', { class: 'qb-footer' }, el('span', {}, `relay · ${boot.boardId}`));
|
|
1206
|
-
if (access.canShare) {
|
|
1207
|
-
const wrap = el('span', { class: 'share-wrap' });
|
|
1208
|
-
const shareBtn = el('button', { class: 'share-btn', type: 'button' }, 'Share');
|
|
1209
|
-
shareBtn.addEventListener('click', () => {
|
|
1210
|
-
const existing = wrap.querySelector('.share-panel');
|
|
1211
|
-
if (existing) existing.remove();
|
|
1212
|
-
else openSharePanel(wrap);
|
|
1213
|
-
});
|
|
1214
|
-
wrap.append(shareBtn);
|
|
1215
|
-
footer.append(wrap);
|
|
1216
|
-
} else if (access.role === 'review') {
|
|
1217
|
-
footer.append(el('span', { class: 'share-mode' }, 'reviewer · comments only'));
|
|
1218
|
-
} else if (access.role === 'collab') {
|
|
1219
|
-
footer.append(el('span', { class: 'share-mode' }, 'collaborator'));
|
|
1220
|
-
}
|
|
1221
1226
|
return footer;
|
|
1222
1227
|
}
|
|
1223
1228
|
|
|
1224
1229
|
if (!access.canSubmit) {
|
|
1225
1230
|
submitbar.style.display = 'none';
|
|
1226
|
-
|
|
1231
|
+
showNotice('Reviewer mode: comments are saved live. Answers and submission are disabled.', 'info');
|
|
1232
|
+
} else if (access.role === 'collab') {
|
|
1233
|
+
showNotice('Editor mode: answers, comments, and submit are enabled for this shared board.', 'info');
|
|
1227
1234
|
}
|
|
1228
1235
|
if (!access.canEditAnswers) {
|
|
1229
1236
|
document.documentElement.classList.add('relay-review');
|
package/src/ui/style.css
CHANGED
|
@@ -96,14 +96,21 @@ h1 {
|
|
|
96
96
|
.intro.blk-markdown .md { color: var(--fg-2); }
|
|
97
97
|
.intro.blk-markdown .md > :first-child { margin-top: 0; }
|
|
98
98
|
.intro.blk-markdown .md > :last-child { margin-bottom: 0; }
|
|
99
|
-
.theme-btn
|
|
99
|
+
.theme-btn,
|
|
100
|
+
.icon-btn {
|
|
100
101
|
background: transparent; color: var(--fg-2);
|
|
101
102
|
border: 1px solid var(--border-strong); border-radius: 999px;
|
|
102
|
-
|
|
103
|
+
cursor: pointer; font-size: 0.8rem; flex: none;
|
|
103
104
|
font-family: var(--sans);
|
|
104
105
|
transition: border-color 150ms var(--ease), color 150ms var(--ease);
|
|
105
106
|
}
|
|
106
|
-
.theme-btn
|
|
107
|
+
.theme-btn { padding: 6px 14px; }
|
|
108
|
+
.icon-btn {
|
|
109
|
+
width: 32px; height: 32px; padding: 0;
|
|
110
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
111
|
+
}
|
|
112
|
+
.theme-btn:hover,
|
|
113
|
+
.icon-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
107
114
|
/* header controls: font-size knob + theme toggle, grouped at the right */
|
|
108
115
|
.qb-controls { display: flex; align-items: center; gap: 8px; flex: none; }
|
|
109
116
|
.fs-ctrl { display: inline-flex; border: 1px solid var(--border-strong); border-radius: 999px; overflow: hidden; }
|
|
@@ -317,10 +324,22 @@ textarea { min-height: 90px; resize: vertical; }
|
|
|
317
324
|
z-index: 10; display: none;
|
|
318
325
|
border-bottom: 1px solid transparent;
|
|
319
326
|
}
|
|
327
|
+
.banner-stack {
|
|
328
|
+
position: sticky; top: 0;
|
|
329
|
+
display: flex; flex-direction: column; gap: 0;
|
|
330
|
+
z-index: 10;
|
|
331
|
+
}
|
|
332
|
+
.banner-item {
|
|
333
|
+
text-align: center; padding: 9px 16px; font-size: 0.9rem;
|
|
334
|
+
line-height: 1.45;
|
|
335
|
+
border-bottom: 1px solid transparent;
|
|
336
|
+
}
|
|
320
337
|
/* Calm status notes (timeout hand-back / lost connection). Never the red error
|
|
321
338
|
state — the board stays usable, so these only inform. */
|
|
322
|
-
.banner.info
|
|
323
|
-
.banner.
|
|
339
|
+
.banner.info,
|
|
340
|
+
.banner-item.info { background: var(--accent-soft); color: var(--accent); }
|
|
341
|
+
.banner.warn,
|
|
342
|
+
.banner-item.warn { background: var(--bg-sunken); color: var(--fg-2); border-bottom-color: var(--border-strong); }
|
|
324
343
|
|
|
325
344
|
/* Live-update toast: flagged after a `rly update` reload (see app.js).
|
|
326
345
|
Fixed top-center, accent-soft bg, accent text; JS auto-removes it. */
|
|
@@ -349,14 +368,9 @@ textarea { min-height: 90px; resize: vertical; }
|
|
|
349
368
|
.share-btn, .share-copy, .share-choice, .share-close {
|
|
350
369
|
font: inherit;
|
|
351
370
|
}
|
|
352
|
-
.share-btn {
|
|
353
|
-
border: 1px solid var(--border-strong); border-radius: 999px;
|
|
354
|
-
background: var(--card); color: var(--fg-2);
|
|
355
|
-
padding: 5px 12px; cursor: pointer;
|
|
356
|
-
}
|
|
357
|
-
.share-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
371
|
+
.share-btn { background: transparent; }
|
|
358
372
|
.share-panel {
|
|
359
|
-
position: absolute;
|
|
373
|
+
position: absolute; right: 0; top: calc(100% + 10px);
|
|
360
374
|
width: min(360px, calc(100vw - 28px));
|
|
361
375
|
background: var(--card); color: var(--fg);
|
|
362
376
|
border: 1px solid var(--border-strong); border-radius: 12px;
|
|
@@ -386,20 +400,19 @@ textarea { min-height: 90px; resize: vertical; }
|
|
|
386
400
|
border: 0; border-radius: 8px; background: var(--accent); color: var(--accent-fg);
|
|
387
401
|
padding: 7px 12px; cursor: pointer; font-weight: 650;
|
|
388
402
|
}
|
|
389
|
-
.share-mode { color: var(--fg-2); }
|
|
390
|
-
.access-note {
|
|
391
|
-
color: var(--fg-2); background: var(--accent-soft);
|
|
392
|
-
border-radius: 10px; padding: 10px 14px; font-size: 0.86rem;
|
|
393
|
-
}
|
|
394
403
|
.relay-review .ann-del,
|
|
395
404
|
.relay-review .ann-edit { display: none !important; }
|
|
396
405
|
|
|
397
406
|
@media (max-width: 480px) {
|
|
398
407
|
.wrap { padding: 18px 12px 18px; }
|
|
408
|
+
.qb-header { align-items: flex-start; }
|
|
409
|
+
.qb-controls { gap: 6px; }
|
|
399
410
|
.card { padding: 16px; border-radius: 10px; }
|
|
400
411
|
h1 { font-size: 1.35rem; }
|
|
401
412
|
.seg button { padding: 10px 18px; }
|
|
402
413
|
.scale button { width: 40px; height: 40px; }
|
|
414
|
+
.theme-btn { padding: 6px 10px; }
|
|
415
|
+
.share-panel { right: -46px; }
|
|
403
416
|
}
|
|
404
417
|
|
|
405
418
|
/* Persistence-lost notice: when the board can no longer save the user's input
|