@scoutmesh/viewer 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ import { isObject } from "./data.js";
2
+
3
+ /** Unlock the page without putting credentials in its URL or browser storage. */
4
+ export async function unlockViewer() {
5
+ const response = await fetch("/api/session");
6
+ /** @type {unknown} */
7
+ const session = await response.json();
8
+ if (isObject(session) && session.authorized) {
9
+ return;
10
+ }
11
+ const panel = document.querySelector("#unlock");
12
+ const form = document.querySelector("#unlock-form");
13
+ const input = document.querySelector("#access-token");
14
+ const errorElement = document.querySelector("#unlock-error");
15
+ const welcome = document.querySelector("#welcome");
16
+ if (
17
+ !(panel instanceof HTMLElement) ||
18
+ !(form instanceof HTMLFormElement) ||
19
+ !(input instanceof HTMLInputElement) ||
20
+ !(errorElement instanceof HTMLElement) ||
21
+ !(welcome instanceof HTMLElement)
22
+ ) {
23
+ throw new Error("Missing unlock form.");
24
+ }
25
+ welcome.hidden = true;
26
+ panel.hidden = false;
27
+ input.focus();
28
+ /** @type {PromiseWithResolvers<void>} */
29
+ const { promise, resolve } = Promise.withResolvers();
30
+ /** @param {Event} event - Browser submit event. */
31
+ const submit = async (event) => {
32
+ event.preventDefault();
33
+ try {
34
+ const result = await fetch("/api/session", {
35
+ body: JSON.stringify({ token: input.value.trim() }),
36
+ headers: { "Content-Type": "application/json" },
37
+ method: "POST",
38
+ });
39
+ if (!result.ok) {
40
+ throw new Error("The access token did not match. Try again.");
41
+ }
42
+ input.value = "";
43
+ panel.hidden = true;
44
+ welcome.hidden = false;
45
+ resolve();
46
+ } catch (error) {
47
+ errorElement.textContent =
48
+ error instanceof Error ? error.message : "Could not unlock viewer.";
49
+ }
50
+ };
51
+ form.addEventListener("submit", (event) => {
52
+ void submit(event);
53
+ });
54
+ await promise;
55
+ }
@@ -0,0 +1,105 @@
1
+ /** @param {unknown} value - JSON value to inspect. @returns {value is Record<string, unknown>} */
2
+ export function isObject(value) {
3
+ return !!value && typeof value === "object" && !Array.isArray(value);
4
+ }
5
+
6
+ /** @param {unknown} value - JSON value to inspect. @returns {value is string[]} */
7
+ function isStrings(value) {
8
+ return (
9
+ Array.isArray(value) && value.every((item) => typeof item === "string")
10
+ );
11
+ }
12
+
13
+ /** @param {unknown} value - Candidate field to inspect. @returns {value is import('../types.ts').Cell} */
14
+ function isCell(value) {
15
+ return (
16
+ value === null ||
17
+ typeof value === "string" ||
18
+ typeof value === "boolean" ||
19
+ (typeof value === "number" && Number.isFinite(value)) ||
20
+ isStrings(value)
21
+ );
22
+ }
23
+
24
+ /** @param {unknown} value - Candidate to inspect. @returns {value is import('../types.ts').Candidate} */
25
+ function isCandidate(value) {
26
+ return (
27
+ isObject(value) &&
28
+ typeof value.id === "string" &&
29
+ typeof value.name === "string" &&
30
+ Object.values(value).every(isCell)
31
+ );
32
+ }
33
+
34
+ /** @param {unknown} value - API response to inspect. @returns {value is import('../types.ts').View} */
35
+ function isView(value) {
36
+ if (!isObject(value) || !isObject(value.layout) || !isObject(value.state)) {
37
+ return false;
38
+ }
39
+ const { layout, state } = value;
40
+ return (
41
+ typeof value.id === "string" &&
42
+ typeof value.title === "string" &&
43
+ typeof value.revision === "number" &&
44
+ typeof value.updatedAt === "string" &&
45
+ Array.isArray(value.records) &&
46
+ value.records.every(isCandidate) &&
47
+ (layout.type === "table" || layout.type === "cards") &&
48
+ isStrings(layout.columns) &&
49
+ typeof state.filter === "string" &&
50
+ isStrings(state.selectedIds) &&
51
+ isStrings(state.removedIds) &&
52
+ isStrings(state.maybeIds) &&
53
+ typeof state.sortBy === "string" &&
54
+ (state.sortDirection === "asc" || state.sortDirection === "desc")
55
+ );
56
+ }
57
+
58
+ /** @param {unknown} value - Complete view response from the local API. */
59
+ export function readView(value) {
60
+ if (!isView(value)) {
61
+ throw new Error("The local server returned an invalid view.");
62
+ }
63
+ return value;
64
+ }
65
+
66
+ /** @param {unknown} value - List entry from the local API. @returns {value is {id: string, title: string, count: number}} */
67
+ function isSummary(value) {
68
+ return (
69
+ isObject(value) &&
70
+ typeof value.id === "string" &&
71
+ typeof value.title === "string" &&
72
+ typeof value.count === "number"
73
+ );
74
+ }
75
+
76
+ /** @param {unknown} value - List response from the local API. */
77
+ export function readSummaries(value) {
78
+ if (!Array.isArray(value) || !value.every(isSummary)) {
79
+ throw new Error("The local server returned an invalid view list.");
80
+ }
81
+ return value;
82
+ }
83
+
84
+ /** @template T @param {T[]} records - Filtered records in their existing order. @param {number} requestedPage - Zero-based page. */
85
+ export function pageOf(records, requestedPage) {
86
+ const size = 20;
87
+ const pages = Math.max(1, Math.ceil(records.length / size));
88
+ const page = Math.max(0, Math.min(requestedPage, pages - 1));
89
+ return {
90
+ end: Math.min((page + 1) * size, records.length),
91
+ page,
92
+ pages,
93
+ records: records.slice(page * size, (page + 1) * size),
94
+ start: records.length === 0 ? 0 : page * size + 1,
95
+ total: records.length,
96
+ };
97
+ }
98
+
99
+ /** @param {import('../types.ts').Candidate[]} previous - Previous snapshot. @param {import('../types.ts').Candidate[]} next - Incoming snapshot. */
100
+ export function addedRecordIds(previous, next) {
101
+ const known = new Set(previous.map((record) => record.id));
102
+ return next
103
+ .filter((record) => !known.has(record.id))
104
+ .map((record) => record.id);
105
+ }
@@ -0,0 +1,153 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Scout Mesh · Local viewer</title>
7
+ <link rel="stylesheet" href="/styles.css" />
8
+ <script type="module" src="/app.js"></script>
9
+ </head>
10
+ <body>
11
+ <main>
12
+ <section id="unlock" class="welcome" hidden>
13
+ <h1>Unlock your candidate workspace</h1>
14
+ <p>Your agent can unlock this page using the local viewer token.</p>
15
+ <form id="unlock-form">
16
+ <label for="access-token">Local access token</label>
17
+ <input
18
+ id="access-token"
19
+ type="password"
20
+ autocomplete="off"
21
+ required
22
+ />
23
+ <button class="button" type="submit">Unlock workspace</button>
24
+ <p id="unlock-error" role="alert"></p>
25
+ </form>
26
+ <small
27
+ >On this computer, run <code>scoutmesh-viewer token</code> to get the
28
+ token.</small
29
+ >
30
+ </section>
31
+ <section id="welcome" class="welcome">
32
+ <a class="brand" href="/">scout<span>mesh</span></a>
33
+ <h1>Your results, beyond the chat.</h1>
34
+ <p>
35
+ Ask your agent to send candidates to this viewer. Browse profiles here
36
+ while you keep the conversation going.
37
+ </p>
38
+ <div id="views" class="view-list"></div>
39
+ <div class="setup-note">
40
+ <span>Waiting for your first results?</span
41
+ ><code>npm run viewer:demo</code
42
+ ><small>Results and review decisions stay on this computer.</small>
43
+ </div>
44
+ </section>
45
+ <section id="workspace" hidden>
46
+ <h1 id="view-title"></h1>
47
+ <nav class="review-tabs" aria-label="Candidate lists">
48
+ <button id="list-candidates" type="button" aria-pressed="false">
49
+ All
50
+ </button>
51
+ <button id="list-unreviewed" type="button" aria-pressed="true">
52
+ Unreviewed
53
+ </button>
54
+ <button id="list-shortlist" type="button" aria-pressed="false">
55
+ Yes
56
+ </button>
57
+ <button id="list-maybe" type="button" aria-pressed="false">
58
+ Maybe
59
+ </button>
60
+ <button id="list-removed" type="button" aria-pressed="false">
61
+ No
62
+ </button>
63
+ </nav>
64
+ <p id="review-feedback" class="muted" role="status"></p>
65
+ <div id="undo-removal" class="undo-removal" hidden>
66
+ <span id="removal-message"></span>
67
+ <button id="undo-button" type="button" class="text-button">
68
+ Undo
69
+ </button>
70
+ </div>
71
+ <p
72
+ id="connection-warning"
73
+ class="connection-warning"
74
+ role="status"
75
+ hidden
76
+ >
77
+ Connection lost. Reconnecting…
78
+ </p>
79
+ <div id="new-batch" class="new-batch" hidden>
80
+ <span id="new-batch-count" role="status"></span>
81
+ <button id="view-new" class="button" type="button">View new</button>
82
+ </div>
83
+ <div id="batch-context" class="batch-context" hidden>
84
+ <strong>New candidates</strong>
85
+ <button id="back-to-candidates" class="text-button" type="button">
86
+ Back to candidates
87
+ </button>
88
+ </div>
89
+ <nav
90
+ id="pagination-top"
91
+ class="pagination"
92
+ aria-label="Candidate pages (top)"
93
+ hidden
94
+ >
95
+ <button id="previous-top" class="button" type="button">
96
+ Previous
97
+ </button>
98
+ <span id="page-range-top" tabindex="-1" role="status"></span>
99
+ <button id="next-top" class="button" type="button">Next</button>
100
+ </nav>
101
+ <div id="results" class="results"></div>
102
+ <nav
103
+ id="pagination-bottom"
104
+ class="pagination"
105
+ aria-label="Candidate pages (bottom)"
106
+ hidden
107
+ >
108
+ <button id="previous-bottom" class="button" type="button">
109
+ Previous
110
+ </button>
111
+ <span id="page-range-bottom" tabindex="-1" role="status"></span>
112
+ <button id="next-bottom" class="button" type="button">Next</button>
113
+ </nav>
114
+ </section>
115
+ <p id="error" role="alert" hidden></p>
116
+ </main>
117
+ <dialog id="profile" aria-labelledby="profile-name">
118
+ <div class="profile-top">
119
+ <nav class="profile-navigation" aria-label="Candidate navigation">
120
+ <button
121
+ id="profile-previous"
122
+ class="button quiet"
123
+ type="button"
124
+ aria-label="Previous candidate"
125
+ >
126
+ Previous
127
+ </button>
128
+ <span
129
+ id="profile-position"
130
+ role="status"
131
+ aria-live="polite"
132
+ aria-atomic="true"
133
+ ></span>
134
+ <button
135
+ id="profile-next"
136
+ class="button quiet"
137
+ type="button"
138
+ aria-label="Next candidate"
139
+ >
140
+ Next
141
+ </button>
142
+ </nav>
143
+ <button id="close-profile" class="button quiet" type="button">
144
+ Close
145
+ </button>
146
+ </div>
147
+ <div id="profile-identity"></div>
148
+ <div id="profile-tabs" role="tablist" aria-label="Profile sections"></div>
149
+ <div id="profile-content" role="tabpanel" tabindex="0"></div>
150
+ <footer id="profile-actions" aria-label="Review decision"></footer>
151
+ </dialog>
152
+ </body>
153
+ </html>