@gafj/gafj 0.1.20 → 0.1.28

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/bin/cli.js CHANGED
@@ -321,6 +321,7 @@ async function main(argv) {
321
321
  const { createHostedServer } = require("../http/hosted");
322
322
  const { firebaseVerifier } = require("../http/identity");
323
323
  const { Log } = require("../store/log");
324
+ const pkg = require("../package.json");
324
325
  const env = process.env;
325
326
  const root = env.GAFJ_HOSTED_ROOT || "/data";
326
327
  if (!env.GAFJ_FIREBASE_PROJECT_ID) { process.stderr.write("serve-hosted needs GAFJ_FIREBASE_PROJECT_ID (and GAFJ_FIREBASE_API_KEY, GAFJ_FIREBASE_AUTH_DOMAIN for the sign-in page)\n"); process.exit(2); }
package/bridge/credits.js CHANGED
@@ -53,7 +53,8 @@ async function relayFetch(homeDir, path, { method = "GET", body, fetchImpl, auth
53
53
  async function runPacket(homeDir, { packet, fetchImpl, auth }) {
54
54
  const started = Date.now();
55
55
  const op_id = ulid(started);
56
- const r = await relayFetch(homeDir, "/op", { method: "POST", body: { op: packet.op, op_id, packet }, fetchImpl, auth });
56
+ const op = packet.operation || packet.op;
57
+ const r = await relayFetch(homeDir, "/op", { method: "POST", body: { op, op_id, packet }, fetchImpl, auth });
57
58
  if (!r || typeof r.content !== "string") throw new CreditsError("relay reply had no content", 502);
58
59
  return { content: r.content, provider: "credits", model: r.model || null, tokens_in: r.tokens_in ?? null, tokens_out: r.tokens_out ?? null, latency_ms: Date.now() - started, charged: r.charged, credits_left: r.credits_left };
59
60
  }
package/core/scoring.js CHANGED
@@ -128,8 +128,10 @@ function conceptTags(requirementText) {
128
128
  return hit;
129
129
  }
130
130
 
131
+ /** Lines a job board adds around a posting; never a requirement of the job. */
132
+ const BOARD_NOISE = /\b(your profile and resume match|see how you compare|applicants?\b.*\b(ago|clicked)|easy apply|promoted by|reposted|set alert for similar|show more|show less|tailor my resume|help me stand out|create cover letter|people you can reach out to|meet the hiring team|skills you have|sign in to|referrals increase)\b/i;
131
133
  function lines(text) {
132
- return String(text).split(/\r?\n|(?<=[.;])\s{2,}/).map((l) => l.trim()).filter(Boolean);
134
+ return String(text).split(/\r?\n|(?<=[.;])\s{2,}/).map((l) => l.trim()).filter((l) => l && !BOARD_NOISE.test(l));
133
135
  }
134
136
 
135
137
  /** Every figure in a document: money, percents, counts, durations. */
package/http/api.js CHANGED
@@ -118,7 +118,10 @@ route("POST", "/api/kb/:id/review", (c, p, b) => mutate(c.db, { candidate_id: c.
118
118
 
119
119
  // postings: UI paste inserts accepted; accept promotes a proposed row
120
120
  route("GET", "/api/postings", (c) => c.db.prepare(`SELECT p.id AS posting_id, p.status, p.company, p.title, p.location, p.comp_text, p.captured_at, p.taken_down_at,
121
- (SELECT count(*) FROM scoring s WHERE s.posting_id = p.id AND s.status = 'frozen') > 0 AS scoring_frozen, a.id AS application_id FROM posting p LEFT JOIN application a ON a.posting_id = p.id
121
+ (SELECT count(*) FROM scoring s WHERE s.posting_id = p.id AND s.status = 'frozen') > 0 AS scoring_frozen,
122
+ (SELECT s.fit_score FROM scoring s WHERE s.posting_id = p.id AND s.status = 'frozen' ORDER BY s.scored_at DESC, s.rowid DESC LIMIT 1) AS fit_score,
123
+ (SELECT s.verdict FROM scoring s WHERE s.posting_id = p.id AND s.status = 'frozen' ORDER BY s.scored_at DESC, s.rowid DESC LIMIT 1) AS verdict,
124
+ a.id AS application_id FROM posting p LEFT JOIN application a ON a.posting_id = p.id
122
125
  WHERE p.candidate_id = ? ORDER BY p.captured_at DESC`).all(c.candidate_id).map((r) => ({ ...r, scoring_frozen: !!r.scoring_frozen })));
123
126
  route("GET", "/api/postings/:id", (c, p) => {
124
127
  const r = c.db.prepare("SELECT id AS posting_id, status, company, title, location, comp_text, source_url, captured_at, taken_down_at, raw_text FROM posting WHERE id = ? AND candidate_id = ?").get(p.id, c.candidate_id);
package/http/hosted.js CHANGED
@@ -34,8 +34,34 @@ const { UI_DIR } = require("./server");
34
34
  const COOKIE = "gafj_hs";
35
35
  const SESSION_TTL_MS = 30 * 24 * 3600 * 1000;
36
36
  const IDLE_CLOSE_MS = 10 * 60 * 1000;
37
- const SIGNIN_CSP = "default-src 'self'; script-src 'self' https://www.gstatic.com https://apis.google.com; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://identitytoolkit.googleapis.com https://securetoken.googleapis.com https://www.googleapis.com https://apis.google.com; frame-src https://*.firebaseapp.com https://accounts.google.com; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'";
37
+ const SIGNIN_CSP = "default-src 'self'; script-src 'self' https://www.gstatic.com https://apis.google.com; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://identitytoolkit.googleapis.com https://securetoken.googleapis.com https://www.googleapis.com https://apis.google.com; frame-src 'self' https://*.firebaseapp.com https://accounts.google.com; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'";
38
38
  const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".webmanifest": "application/manifest+json", ".ico": "image/x-icon", ".woff2": "font/woff2" };
39
+ /**
40
+ * Google sign-in's handler pages live on <project>.firebaseapp.com. Served from another site, the
41
+ * popup and redirect flows depend on third-party storage that browsers now block, so the app
42
+ * serves them itself: everything under /__/ is fetched from Firebase and passed through, and the
43
+ * sign-in page uses this host as its auth domain. Firebase's own headers come back with the page;
44
+ * only hop-by-hop and cookie headers are dropped.
45
+ */
46
+ const PASS_REQ = new Set(["accept", "accept-language", "content-type", "content-length", "referer", "user-agent", "x-requested-with", "origin"]);
47
+ const DROP_RES = new Set(["set-cookie", "transfer-encoding", "connection", "keep-alive", "content-encoding", "content-length", "strict-transport-security", "alt-svc"]);
48
+ async function proxyAuth(req, res, { projectId, fetchImpl, originOf }) {
49
+ const upstream = `https://${projectId}.firebaseapp.com${req.url}`;
50
+ const headers = {};
51
+ for (const [k, v] of Object.entries(req.headers)) if (PASS_REQ.has(k)) headers[k] = v;
52
+ if (headers.origin) headers.origin = `https://${projectId}.firebaseapp.com`;
53
+ if (headers.referer) headers.referer = headers.referer.replace(originOf, `https://${projectId}.firebaseapp.com`);
54
+ headers["accept-encoding"] = "identity";
55
+ const body = req.method === "GET" || req.method === "HEAD" ? undefined : await new Promise((resolve, reject) => { const c = []; req.on("data", (d) => c.push(d)); req.on("end", () => resolve(Buffer.concat(c))); req.on("error", reject); });
56
+ const r = await fetchImpl(upstream, { method: req.method, headers, body, redirect: "manual" });
57
+ const out = {};
58
+ for (const [k, v] of r.headers.entries()) if (!DROP_RES.has(k.toLowerCase())) out[k] = v;
59
+ const buf = Buffer.from(await r.arrayBuffer());
60
+ out["content-length"] = String(buf.length);
61
+ res.writeHead(r.status, out);
62
+ return res.end(buf);
63
+ }
64
+
39
65
  const BLOCKED_IN_HOSTED = [/^\/api\/settings\/providers/, /^\/api\/mcp-config$/, /^\/api\/restore-test$/];
40
66
 
41
67
  function json(res, status, body) {
@@ -157,7 +183,9 @@ function createHostedServer(o) {
157
183
  res.on("finish", () => log.write(res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info", "request",
158
184
  { entity: "http", entity_id: `${req.method} ${p}`, actor: who ? "user" : "anon", ms: Date.now() - started, status: res.statusCode }));
159
185
  try {
186
+ if (p === "/healthz" && req.method === "GET") return json(res, 200, { ok: true, version: o.version }); // the platform's check arrives under its own host name
160
187
  if (host && String(req.headers.host || "").toLowerCase() !== host) return json(res, 421, { error: "wrong host" });
188
+ if (p.startsWith("/__/") && o.firebase && o.firebase.projectId) return proxyAuth(req, res, { projectId: o.firebase.projectId, fetchImpl: o.fetch || fetch, originOf: originOf(req) });
161
189
  const mutation = req.method !== "GET" && req.method !== "HEAD";
162
190
  securityHeaders(res, { noStore: isApi || p.startsWith("/auth/") || p.startsWith("/account/") });
163
191
 
@@ -166,7 +194,8 @@ function createHostedServer(o) {
166
194
  res.writeHead(200, { "Content-Type": MIME[".html"], "Cache-Control": "no-cache" });
167
195
  return fs.createReadStream(path.join(UI_DIR, "hosted", "signin.html")).pipe(res);
168
196
  }
169
- if (p === "/signin/config.json") return json(res, 200, { firebase: o.firebase || null, signed_in: !!sessionOf(req) });
197
+ // the auth domain is this host, so Google's handler pages come through the proxy above and stay same-site
198
+ if (p === "/signin/config.json") return json(res, 200, { firebase: o.firebase ? { ...o.firebase, authDomain: String(req.headers.host || o.firebase.authDomain || "").toLowerCase() } : null, signed_in: !!sessionOf(req) });
170
199
  if (p === "/auth/session" && req.method === "POST") {
171
200
  const g = mutationCheck(req);
172
201
  if (g) return json(res, g.status, { error: g.error });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gafj/gafj",
3
- "version": "0.1.20",
3
+ "version": "0.1.28",
4
4
  "description": "GAF-J: a local campaign engine for job search. One SQLite file, one ingest door, your own AI subscription.",
5
5
  "homepage": "https://gaf-j.com",
6
6
  "repository": {
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * The categories operation's reply: the model reads a posting and proposes the five to eight
5
+ * things the employer is really asking for, each with its verbatim lines. Saving one records the
6
+ * attempt on its run and lands a proposed scoring row through the same door the paste route and
7
+ * MCP use; every evidence line must be a substring of the posting or the whole reply is refused.
8
+ */
9
+
10
+ const { ulid } = require("../core/ulid");
11
+ const { extractJson } = require("../core/extract");
12
+ const { SCHEMAS, validate } = require("../core/schemas");
13
+ const { withTx } = require("./tx");
14
+ const { appendEvent } = require("./events");
15
+ const { TransitionError, NotFound } = require("./transitions");
16
+ const { proposeCategories } = require("./proposals");
17
+
18
+ const MAX_ATTEMPTS = 3;
19
+
20
+ function saveCategories(db, { run_id, content, route, actor, now, provider, model, tokens_in, tokens_out, latency_ms }) {
21
+ return withTx(db, (d) => {
22
+ const run = d.prepare("SELECT * FROM ai_run WHERE id = ?").get(run_id);
23
+ if (!run) throw new NotFound("run");
24
+ if (run.operation !== "categories") throw new TransitionError("this run is not a categories run; use the document door");
25
+ const attempts = d.prepare("SELECT count(*) c FROM ai_attempt WHERE ai_run_id = ?").get(run.id).c;
26
+ if (attempts >= MAX_ATTEMPTS) return { run_id: run.id, status: "blocked", attempt: attempts, stop: true, errors: ["three attempts used; open a fresh packet"] };
27
+ const attemptNo = attempts + 1;
28
+ const ex = extractJson(content);
29
+ const errors = [];
30
+ let parsed = null;
31
+ if (ex.error) errors.push("schema: " + ex.error + (typeof content === "string" && content.trim() ? ` (reply began: "${content.trim().slice(0, 160).replace(/\s+/g, " ")}")` : " (the reply was empty)"));
32
+ else {
33
+ parsed = ex.value;
34
+ for (const e of validate(parsed, SCHEMAS.categories)) errors.push(`${e.path}: ${e.message}`);
35
+ }
36
+ let result = null;
37
+ if (parsed && !errors.length) {
38
+ try { result = proposeCategories(d, { candidate_id: run.candidate_id, posting_id: run.posting_id, categories: parsed.categories, now, actor }); }
39
+ catch (e) { errors.push(e.message); }
40
+ }
41
+ const ok = !!result;
42
+ const attemptId = ulid(Date.parse(now) + 100 + attemptNo);
43
+ d.prepare(`INSERT INTO ai_attempt (id, ai_run_id, attempt_no, provider, model, tokens_in, tokens_out, latency_ms, output_hash, result, error_code, started_at, finished_at)
44
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(attemptId, run.id, attemptNo, provider || null, model || null, tokens_in ?? null, tokens_out ?? null, latency_ms ?? null, null, ok ? "passed" : "blocked", ok ? null : (ex.error ? "schema" : "evidence"), now, now);
45
+ const final = ok ? "passed" : attemptNo >= MAX_ATTEMPTS ? "blocked" : "pending";
46
+ d.prepare("UPDATE ai_run SET final_result = ?, finished_at = CASE WHEN ? = 'pending' THEN finished_at ELSE ? END WHERE id = ?").run(final, final, now, run.id);
47
+ appendEvent(d, { candidate_id: run.candidate_id, entity: "ai_run", entity_id: run.id, event: ok ? "categories_saved" : "categories_blocked", actor_type: actor.type, actor_ref: actor.ref, payload: { attempt: attemptNo, categories: result ? result.categories : 0, errors: errors.length, route }, at: now });
48
+ return { run_id: run.id, status: ok ? "passed" : "blocked", attempt: attemptNo, stop: attemptNo >= MAX_ATTEMPTS && !ok, errors, scoring_id: result ? result.scoring_id : null, categories: result ? result.categories : 0 };
49
+ });
50
+ }
51
+
52
+ module.exports = { saveCategories };
package/store/reply.js CHANGED
@@ -13,6 +13,7 @@ function saveReply(db, a) {
13
13
  const run = a.run_id ? db.prepare("SELECT operation FROM ai_run WHERE id = ?").get(a.run_id) : null;
14
14
  if (run && run.operation === "extract") return require("./onboarding").saveExtraction(db, a);
15
15
  if (run && run.operation === "discover") return require("./discover").saveDiscovery(db, a);
16
+ if (run && run.operation === "categories") return require("./categories").saveCategories(db, a);
16
17
  return ingestDocument(db, a);
17
18
  }
18
19
 
package/store/scoring.js CHANGED
@@ -53,7 +53,7 @@ function proposeFromScan(db, { posting_id, now, actor }) {
53
53
  const title = String(p.title || "").toLowerCase();
54
54
  const add = (line, weight, hard) => {
55
55
  const l = String(line).trim();
56
- const name = l.replace(/^[-*•\u2022\d.)\s]+/, "").replace(/[^\w&/ ,'()+%$-]+/g, " ").replace(/\s+/g, " ").trim();
56
+ const name = l.replace(/^(?:[-*•\u2022]+\s*|\d{1,2}[.)]\s+)/, "").replace(/[^\w&/ ,'()+%$-]+/g, " ").replace(/\s+/g, " ").trim();
57
57
  if (!l || l.length < 12 || seen.has(l) || cats.length >= 8 || HEADING.test(name) || name.split(/\s+/).length < 3) return;
58
58
  if (title && name.toLowerCase().includes(title)) return; // the posting's own title line is not a requirement
59
59
  seen.add(l);
@@ -70,7 +70,7 @@ function proposeFromScan(db, { posting_id, now, actor }) {
70
70
  for (const l of all) {
71
71
  if (cats.length >= 5) break;
72
72
  const t = String(l).trim();
73
- const name = t.replace(/^[-*•\u2022\d.)\s]+/, "").replace(/\s+/g, " ").trim();
73
+ const name = t.replace(/^(?:[-*•\u2022]+\s*|\d{1,2}[.)]\s+)/, "").replace(/\s+/g, " ").trim();
74
74
  if (t.length < 8 || seen.has(t) || HEADING.test(name) || META.test(t)) continue;
75
75
  seen.add(t);
76
76
  cats.push({ category: name.slice(0, 80), weight: 5, jd_evidence: [t.slice(0, 400)], is_hard_gate: false });
package/store/setup.js CHANGED
@@ -27,8 +27,11 @@ function setupView(db, homeDir, candidate_id) {
27
27
  const aiChosen = !!(cfg.setup && cfg.setup.ai_chosen) && usable;
28
28
  const pos = require("./positions").counts(db, candidate_id);
29
29
  const posDone = pos.total > 0 && pos.proposed === 0;
30
+ const bal = cfg.credits && cfg.credits.last_balance;
31
+ const creditsDone = !!(bal && Number(bal.available) > 0);
30
32
  const steps = [
31
33
  { key: "ai", title: "Tell GAF-J which AI you use", done: aiChosen, detail: aiChosen ? `Using ${ROUTE_LABEL[cfg.route] || cfg.route}` : null },
34
+ { key: "credits", title: "Get credits", done: creditsDone, detail: bal ? `${bal.available} available` : null },
32
35
  { key: "upload", title: "Upload your resumes", done: sources > 0, detail: sources ? `${sources} file${sources === 1 ? "" : "s"} uploaded` : null },
33
36
  { key: "extract", title: "Extract records from them", done: extracted > 0 || pending > 0 || confirmed > 0, detail: extracted ? `${extracted} extraction${extracted === 1 ? "" : "s"} saved` : null },
34
37
  { key: "positions", title: "Confirm your jobs, schools, and awards", done: posDone, detail: pos.total ? (posDone ? `${pos.total} position${pos.total === 1 ? "" : "s"} confirmed` : `${pos.proposed} of ${pos.total} to confirm`) : null },
@@ -36,8 +39,10 @@ function setupView(db, homeDir, candidate_id) {
36
39
  { key: "discover", title: "Answer what's missing", done: questions > 0, detail: questions ? `${questions} question${questions === 1 ? "" : "s"} asked` : null },
37
40
  { key: "posting", title: "Paste your first job posting", done: postings > 0, detail: postings ? `${postings} posting${postings === 1 ? "" : "s"}` : null },
38
41
  ];
39
- const next = steps.find((s) => !s.done);
40
- return { steps, next: next ? next.key : null, complete: !next, dismissed: !!(cfg.setup && cfg.setup.dismissed), route: cfg.route || "paste" };
42
+ // a hosted account signed in to use the included AI; which AI is not a question there (Paste stays a Settings option)
43
+ const shown = cfg.hosted ? steps.filter((s) => s.key !== "ai") : steps.filter((s) => s.key !== "credits");
44
+ const next = shown.find((s) => !s.done);
45
+ return { steps: shown, next: next ? next.key : null, complete: !next, dismissed: !!(cfg.setup && cfg.setup.dismissed), route: cfg.route || "paste" };
41
46
  }
42
47
 
43
48
  /** The user picked how they will use AI; the route is set and the step is done. */
package/ui/app.css CHANGED
@@ -53,7 +53,10 @@ textarea { width: 100%; min-height: 120px; font-family: var(--mono); font-size:
53
53
  .rail .brand .mark { width: 40px; height: 40px; border-radius: 9px; margin-right: 10px; border: 1px solid #333744; }
54
54
  .rail .brand span { flex: 1; }
55
55
  .rail nav, .rail > a { display: flex; flex-direction: column; gap: 2px; }
56
- .rail a { color: var(--rail-muted); padding: 9px 10px; border-radius: 6px; display: block; font-size: 13.5px; }
56
+ .rail a { color: var(--rail-muted); padding: 9px 10px; border-radius: 6px; display: flex; align-items: center; gap: 9px; font-size: 13.5px; }
57
+ .rail a .ico { flex: none; opacity: .75; }
58
+ .rail a.active .ico { opacity: 1; }
59
+ .rail a .short { display: none; }
57
60
  .rail a.active { background: var(--rail-active); color: var(--rail-text); font-weight: 600; }
58
61
  .rail a:hover { background: var(--rail-active); color: var(--rail-text); text-decoration: none; }
59
62
  .rail .foot { margin-top: auto; font-size: 11.5px; color: var(--rail-dim); display: flex; flex-direction: column; gap: 4px; }
@@ -62,7 +65,7 @@ textarea { width: 100%; min-height: 120px; font-family: var(--mono); font-size:
62
65
  .rail .theme-toggle { background: transparent; border: 1px solid #333744; color: var(--rail-muted); padding: 3px 6px; font-size: 11px; border-radius: 5px; }
63
66
  .rail .theme-toggle:hover { border-color: var(--accent); color: var(--rail-text); }
64
67
 
65
- .main { padding: 28px 32px; max-width: 1200px; background: var(--surface); }
68
+ .main { padding: 28px 32px; max-width: 1200px; background: var(--surface); min-width: 0; }
66
69
  h1 { font-size: 22px; margin: 0 0 4px; font-weight: 700; }
67
70
  h2 { font-family: var(--mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin: 22px 0 8px; font-weight: 500; }
68
71
  h3 { font-size: 15px; margin: 12px 0 6px; font-weight: 600; }
@@ -130,7 +133,11 @@ form.inline input, form.inline select { min-width: 120px; }
130
133
  table.cats { border-collapse: collapse; width: 100%; font-size: 13px; }
131
134
  table.cats th, table.cats td { border-bottom: 1px solid var(--line); padding: 6px; text-align: left; vertical-align: top; }
132
135
  table.cats th { font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 500; }
133
- table.cats input:not([type=checkbox]) { width: 100%; }
136
+ table.cats input:not([type=checkbox]) { width: 100%; box-sizing: border-box; }
137
+ table.cats td:first-child { min-width: 220px; }
138
+ table.cats td:nth-child(2), table.cats td:nth-child(3) { width: 64px; }
139
+ table.cats input.narrow { min-width: 54px; padding: 6px 6px; }
140
+ table.cats td:nth-child(5) { font-family: var(--sans); font-size: 12.5px; color: var(--muted); }
134
141
  input.narrow { width: 76px; }
135
142
  pre.wrap { white-space: pre-wrap; }
136
143
  /* KB "receipt" moment: the confirmed span highlighted inline against its muted source sentence. */
@@ -159,6 +166,7 @@ mark { background: var(--good-bg); color: var(--good-bright); border-radius: 3px
159
166
  .welcome-head { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }
160
167
  .welcome-head img { border-radius: 9px; }
161
168
  .welcome-head a { margin-left: auto; }
169
+ .stepper-compact { display: none; }
162
170
  .stepper { list-style: none; margin: 0 0 16px; padding: 0; display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12.5px; color: var(--dim); }
163
171
  .stepper li { display: inline-flex; align-items: center; gap: 6px; }
164
172
  .stepper li.done { color: var(--muted); }
@@ -173,7 +181,8 @@ mark { background: var(--good-bg); color: var(--good-bright); border-radius: 3px
173
181
  .welcome-head .links { margin-left: auto; display: flex; gap: 14px; }
174
182
  .stepper li.can { cursor: pointer; }
175
183
  .stepper li.can:hover { color: var(--accent); }
176
- .welcome-nav { margin-top: 16px; justify-content: space-between; }
184
+ .welcome-nav { margin: 0 0 6px; justify-content: space-between; align-items: baseline; }
185
+ .welcome-nav h3 { margin: 0; }
177
186
  /* a switch: one provider is on at a time; off everywhere is a warning, not a state to sit in */
178
187
  .switch { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
179
188
  .switch input { position: absolute; opacity: 0; width: 0; height: 0; }
@@ -183,6 +192,45 @@ mark { background: var(--good-bg); color: var(--good-bright); border-radius: 3px
183
192
  .switch input:checked + .track::after { left: 18px; }
184
193
  .switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
185
194
  /* a small spinner beside anything that is with the model */
195
+ .tablewrap { overflow-x: auto; max-width: 100%; margin: 4px 0; }
196
+ .fit { font-family: var(--mono); font-weight: 600; min-width: 34px; text-align: center; padding: 2px 6px; border-radius: 6px; background: var(--surface-2); border: 1px solid var(--line-2); }
197
+ .fit.good { color: var(--accent); border-color: var(--accent); }
198
+ .row.wide > input { flex: 1; min-width: 220px; }
199
+ form.answer { margin: 6px 0 2px; display: grid; gap: 6px; }
200
+ form.answer input, form.answer textarea { width: 100%; box-sizing: border-box; }
201
+ .bar { display: inline-block; width: 160px; height: 8px; border-radius: 4px; background: var(--line-2); overflow: hidden; vertical-align: middle; }
202
+ .bar > span { display: block; height: 100%; background: var(--accent); border-radius: 4px; transition: width .9s linear; }
186
203
  .spin { display: inline-block; width: 10px; height: 10px; border: 2px solid var(--line-2); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; vertical-align: -1px; }
187
204
  @keyframes spin { to { transform: rotate(360deg); } }
188
205
  @media (prefers-reduced-motion: reduce) { .spin { animation: none; border-top-color: var(--line-2); } }
206
+
207
+ /* Phones: the rail becomes a bottom bar; everything else stacks. */
208
+ @media (max-width: 720px) {
209
+ html, body { overflow-x: hidden; }
210
+ .layout { grid-template-columns: 1fr; }
211
+ .rail { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; flex-direction: row; gap: 0; padding: 4px 2px calc(4px + env(safe-area-inset-bottom)); border-top: 1px solid #2a2e38; }
212
+ .rail .brand, .rail .foot { display: none; }
213
+ .rail a { flex: 1; flex-direction: column; gap: 3px; padding: 6px 2px; font-size: 10.5px; border-radius: 8px; text-align: center; letter-spacing: .01em; }
214
+ .rail a .full { display: none; }
215
+ .rail a .short { display: block; }
216
+ .rail a .ico { width: 20px; height: 20px; }
217
+ .main { padding: 16px 14px calc(76px + env(safe-area-inset-bottom)); max-width: none; }
218
+ .grid2 { grid-template-columns: 1fr; gap: 12px; }
219
+ h1 { font-size: 20px; }
220
+ .card { padding: 12px; }
221
+ .row > input, .row > select, form.inline input, form.inline select { min-width: 0; max-width: 100%; flex: 1 1 140px; }
222
+ .row.wide > input { min-width: 0; }
223
+ textarea { max-width: 100%; }
224
+ pre { max-width: 100%; }
225
+ .tablewrap { overflow-x: auto; max-width: 100%; }
226
+ .welcome { padding: 18px 14px 40px; }
227
+ .stepper { display: none; }
228
+ .stepper-compact { display: flex; align-items: center; gap: 10px; margin: 0 0 12px; font: 500 12px var(--mono); color: var(--muted); }
229
+ .stepper-compact .bar { flex: 1; width: auto; }
230
+ .welcome-head { flex-wrap: wrap; gap: 10px; }
231
+ .welcome-head > div { flex: 1 1 200px; min-width: 0; }
232
+ .welcome-head .links { flex-direction: column; gap: 4px; white-space: nowrap; align-items: flex-end; margin-left: 0; }
233
+ .welcome-head .small { white-space: nowrap; }
234
+ .ai-options { grid-template-columns: 1fr; }
235
+ .bar { width: 100px; }
236
+ }
package/ui/app.js CHANGED
@@ -7,19 +7,31 @@ import { Document } from "./screens/document.js";
7
7
  import { Postings } from "./screens/postings.js";
8
8
  import { KB } from "./screens/kb.js";
9
9
  import { Settings } from "./screens/settings.js";
10
- import { Welcome } from "./screens/welcome.js";
10
+ import { Welcome, wizardHold } from "./screens/welcome.js";
11
+
12
+ // line icons for the nav; on a phone the rail folds into a bottom bar of these with short labels
13
+ const ICON = {
14
+ home: "M3 11 12 3l9 8v10h-6v-6H9v6H3z",
15
+ apps: "M4 7h16v13H4zM9 7V4h6v3M4 12h16",
16
+ interviews: "M4 5h16v15H4zM4 10h16M8 3v4M16 3v4",
17
+ postings: "M5 4h14v16H5zM8 8h8M8 12h8M8 16h5",
18
+ kb: "M4 4h7a2 2 0 0 1 2 2v14a2 2 0 0 0-2-2H4zM20 4h-7a2 2 0 0 0-2 2v14a2 2 0 0 1 2-2h7z",
19
+ settings: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM19 12l2-1-1.5-3-2.2.6a7 7 0 0 0-1.6-1l-.3-2.3h-3.4l-.3 2.3a7 7 0 0 0-1.6 1L7.9 8 6.4 11l2 1a7 7 0 0 0 0 2l-2 1 1.5 3 2.2-.6a7 7 0 0 0 1.6 1l.3 2.3h3.4l.3-2.3a7 7 0 0 0 1.6-1l2.2.6 1.5-3-2-1a7 7 0 0 0 0-2z",
20
+ };
21
+ const Icon = ({ d }) => html`<svg class="ico" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true"><path d=${d} /></svg>`;
11
22
 
12
23
  function Rail({ route, me }) {
13
24
  const is = (p) => (route.path === p || (p !== "/" && route.path.startsWith(p)) ? "active" : "");
14
25
  const theme = useTheme();
26
+ const item = (href, key, full, short) => html`<a href=${href} class=${is(href.slice(1))}><${Icon} d=${ICON[key]} /><span class="full">${full}</span><span class="short">${short}</span></a>`;
15
27
  return html`<nav class="rail">
16
28
  <div class="brand"><img class="mark" src="/icon-192.png" alt="" width="40" height="40" /><span>GAF-J</span><button class="theme-toggle" title=${theme.dark ? "switch to light" : "switch to dark"} onClick=${theme.toggle}>${theme.dark ? "☾" : "☀"}</button></div>
17
- <a href="#/" class=${is("/")}>Dashboard</a>
18
- <a href="#/applications" class=${is("/applications")}>Applications</a>
19
- <a href="#/interviews" class=${is("/interviews")}>Interviews</a>
20
- <a href="#/postings" class=${is("/postings")}>Postings</a>
21
- <a href="#/kb" class=${is("/kb")}>Knowledge base</a>
22
- <a href="#/settings" class=${is("/settings")}>Settings</a>
29
+ ${item("#/", "home", "Dashboard", "Home")}
30
+ ${item("#/applications", "apps", "Applications", "Apps")}
31
+ ${item("#/interviews", "interviews", "Interviews", "Prep")}
32
+ ${item("#/postings", "postings", "Postings", "Jobs")}
33
+ ${item("#/kb", "kb", "Knowledge base", "Record")}
34
+ ${item("#/settings", "settings", "Settings", "Settings")}
23
35
  <div class="foot">
24
36
  <div class="name">${me ? me.name : ""}</div>
25
37
  <div>AI: ${me ? me.route : ""}</div>
@@ -60,7 +72,7 @@ function App() {
60
72
  const [me, setMe] = useState(null);
61
73
  const [gate, setGate] = useState(null); // null: unknown; true: show the first-run screen; false: the app
62
74
  useEffect(() => { api.get("/api/me").then(setMe).catch(() => setMe({ name: "no session", route: "", version: "" })); }, []);
63
- useEffect(() => { if (gate === false) return; api.get("/api/setup").then((s) => setGate(!s.complete && !s.dismissed)).catch(() => setGate(false)); }, [live]);
75
+ useEffect(() => { if (gate === false || wizardHold.on) return; api.get("/api/setup").then((s) => setGate(!s.complete && !s.dismissed)).catch(() => setGate(false)); }, [live]);
64
76
  const seg = route.path.split("/").filter(Boolean);
65
77
  if (gate === null) return html`<div class="welcome"><p class="muted">loading</p></div>`;
66
78
  if (gate && seg[0] !== "settings") return html`<${Welcome} live=${live} onDone=${() => setGate(false)} />`;
@@ -1,7 +1,6 @@
1
1
  .signin { max-width: 420px; margin: 10vh auto; padding: 32px 28px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; text-align: center; }
2
- .signin .mark { width: 64px; height: 64px; border-radius: 14px; margin-bottom: 12px; }
3
- .signin h1 { font-family: var(--mono); font-size: 20px; margin: 0 0 8px; }
4
- .signin .sub { color: var(--muted); font-size: 14px; line-height: 1.6; margin: 0 0 22px; text-align: left; }
2
+ .signin .mark { width: 80px; height: 80px; border-radius: 18px; margin-bottom: 18px; }
3
+ .signin .sub { color: var(--muted); font-family: var(--sans); font-size: 14px; line-height: 1.6; margin: 0 0 22px; text-align: left; }
5
4
  .signin button.primary { width: 100%; padding: 11px; font-size: 15px; }
6
5
  .signin details { text-align: left; margin-top: 22px; border-top: 1px solid var(--line); padding-top: 12px; }
7
6
  .signin textarea { min-height: 70px; font-size: 11px; }
@@ -10,8 +10,7 @@
10
10
  </head>
11
11
  <body>
12
12
  <main class="signin">
13
- <img class="mark" src="/icon-192.png" alt="" width="64" height="64">
14
- <h1>GAF-J</h1>
13
+ <img class="mark" src="/icon-192.png" alt="GAF-J" width="80" height="80">
15
14
  <p class="sub">Sign in with Google. Your record, documents, and campaign live in your account here, encrypted, yours to export or delete in one click.</p>
16
15
  <button id="google" class="primary" type="button">Continue with Google</button>
17
16
  <p id="status" class="small muted" hidden></p>
@@ -40,11 +40,12 @@ function Setup({ s, reload }) {
40
40
 
41
41
  export function Dashboard({ live }) {
42
42
  const [snap, setSnap] = useState(null);
43
+ const [postings, setPostings] = useState([]);
43
44
  const [setup, setSetup] = useState(null);
44
45
  const [msg, setMsg] = useState("");
45
46
  const [err, setErr] = useState("");
46
47
  const loadSetup = () => api.get("/api/setup").then(setSetup).catch(() => setSetup(null));
47
- useEffect(() => { api.get("/api/snapshot").then(setSnap).catch((e) => setErr(e.message)); loadSetup(); }, [live]);
48
+ useEffect(() => { api.get("/api/snapshot").then(setSnap).catch((e) => setErr(e.message)); api.get("/api/postings").then(setPostings).catch(() => {}); loadSetup(); }, [live]);
48
49
  const act = async (path, label) => { setMsg(""); setErr(""); try { const r = await api.post(path); setMsg(`${label}: ${r.file}`); } catch (e) { setErr(e.message); } };
49
50
  if (err && !snap) return html`<p class="error">${err}</p>`;
50
51
  if (!snap) return html`<p class="muted">loading</p>`;
@@ -54,6 +55,13 @@ export function Dashboard({ live }) {
54
55
  <div class="sub">${new Date(snap.now).toLocaleString()}</div>
55
56
  ${setup && !setup.complete && !setup.dismissed ? html`<${Setup} s=${setup} reload=${loadSetup} />` : ""}
56
57
  ${setup && !setup.complete && setup.dismissed ? html`<p class="small muted">Setup is hidden. <a href="#/" onClick=${async (e) => { e.preventDefault(); await api.post("/api/setup/dismiss", { dismissed: false }); loadSetup(); }}>Show the steps again</a>.</p>` : ""}
58
+ <h2>Postings (${postings.length})</h2>
59
+ <div class="card">${postings.length ? html`<ul class="list">${postings.slice(0, 12).map((p) => html`<li key=${p.posting_id}>
60
+ ${p.fit_score != null ? html`<span class=${"fit " + (p.verdict === "pursue" ? "good" : "")} title=${p.verdict}>${Number(p.fit_score).toFixed(1)}</span>` : html`<span class="fit muted" title="not scored yet">–</span>`}
61
+ <a href=${"#/postings/" + p.posting_id}><b>${p.company}</b>: ${p.title}</a>
62
+ ${p.verdict ? html`<span class=${"tag " + (p.verdict === "pursue" ? "passed" : "warn")}>${p.verdict}</span>` : html`<span class="tag warn">unscored</span>`}
63
+ ${p.application_id ? html`<a class="small" href=${"#/applications/" + p.application_id}>application</a>` : p.status === "accepted" && p.fit_score != null ? html`<span class="small muted">open it to log an application and make the resume</span>` : ""}</li>`)}</ul>`
64
+ : html`<span class="muted">no postings yet; paste one under Postings</span>`}</div>
57
65
  <h2>Needs you now</h2>
58
66
  <div class="card">${snap.needs_you.length ? html`<ul class="list">${snap.needs_you.map((n, i) => html`<li key=${i}>
59
67
  <span class=${"need " + (n.kind === "thankyou_due" || n.kind === "review" ? "soft" : "")}>●</span> <a href=${link(n)}>${n.text}</a></li>`)}</ul>` : html`<span class="muted">nothing waiting on you</span>`}</div>
package/ui/screens/kb.js CHANGED
@@ -30,6 +30,11 @@ export function Onboarding({ d, reload, setErr, route, compact }) {
30
30
  } catch (e) { setRunning((x) => ({ ...x, [s.source_document_id]: "failed: " + e.message })); }
31
31
  };
32
32
  const isRunning = (id) => running[id] && typeof running[id] === "object";
33
+ // a progress bar with no signal from the model: it fills against the time a file of this size usually takes,
34
+ // eases toward ninety-five percent past that, and completes only when the reply lands
35
+ const expectMs = (chars) => 12000 + (Number(chars) || 0) * 9;
36
+ const progress = (id, chars) => { const t = Date.now() - running[id].started; const e = expectMs(chars); return t < e ? (t / e) * 85 : 85 + 10 * (1 - Math.exp(-(t - e) / e)); };
37
+ const Bar = ({ id, chars }) => html`<span class="bar" title=${`usually about ${Math.round(expectMs(chars) / 1000 / 10) * 10} seconds for a file this size`}><span style=${`width:${progress(id, chars).toFixed(1)}%`}></span></span>`;
33
38
  const extract = async (s) => {
34
39
  if (!direct) { setModal({ source: s }); return; }
35
40
  await runOne(s);
@@ -65,7 +70,8 @@ export function Onboarding({ d, reload, setErr, route, compact }) {
65
70
  ${b ? html`<ul class="list">${b.sources.map((s) => html`<li key=${s.source_document_id}><b>${s.filename}</b> <span class="tag">${s.kind}</span>
66
71
  ${s.empty ? html`<span class="tag blocked">no text</span>` : html`<span class="muted small">${s.chars} chars</span>`}
67
72
  <span class="muted small">${s.pending} pending, ${s.runs} runs</span>
68
- ${!s.empty ? html`<button class="small" disabled=${isRunning(s.source_document_id)} onClick=${() => extract(s)}>${isRunning(s.source_document_id) ? html`<span class="spin"></span> extracting · ${elapsed(Date.now() - running[s.source_document_id].started)}` : direct ? "Extract" : "Extract (paste)"}</button>` : ""}
73
+ ${!s.empty ? html`<button class="small" disabled=${isRunning(s.source_document_id)} onClick=${() => extract(s)}>${isRunning(s.source_document_id) ? html`<span class="spin"></span> reading · ${elapsed(Date.now() - running[s.source_document_id].started)}` : direct ? "Extract" : "Extract (paste)"}</button>` : ""}
74
+ ${isRunning(s.source_document_id) ? html`<${Bar} id=${s.source_document_id} chars=${s.chars} />` : ""}
69
75
  <button class="small danger" title="remove this file and anything proposed from it; confirmed records stay" onClick=${async () => { if (!confirm(`Remove ${s.filename}? Records already confirmed stay; pending rows from this file go.`)) return; try { await api.del(`/api/sources/${s.source_document_id}`); await reload(); } catch (e) { setErr(e.message); } }}>remove</button>
70
76
  ${running[s.source_document_id] && !isRunning(s.source_document_id) ? html`<span class="small muted">${running[s.source_document_id]}</span>` : ""}</li>`)}
71
77
  ${b.sources.length ? "" : html`<li class="muted">upload a resume to start</li>`}</ul>` : ""}
@@ -90,6 +96,36 @@ function Excerpt({ text, span }) {
90
96
  const EMPTY = { title: "", company: "", role: "", dates: "", summary: "", metrics: "", verbs: "", wordings: "", tenure: "" };
91
97
  const toDraft = (f) => ({ title: f.title, company: f.company, role: f.role, dates: f.dates, summary: f.summary,
92
98
  metrics: f.metrics.split(/[;,]/).map((s) => s.trim()).filter(Boolean), verbs: f.verbs.split(/[;,]/).map((s) => s.trim()).filter(Boolean), wordings: f.wordings.split(/\n/).map((s) => s.trim()).filter(Boolean), ...(f.tenure && f.tenure.trim() ? { tenure: f.tenure.trim() } : {}) });
99
+ /** One box per question. The kind says which field the answer is; the record's own details ride along unchanged. */
100
+ const ANSWER = {
101
+ tenure: { hint: "the phrase you would put on a resume: nine years in operations roles", field: "tenure" },
102
+ metric: { hint: "the figure with its unit, as you would state it: $430,000, 18%, 12-person team", field: "metric" },
103
+ scope: { hint: "one resume line that states the size: led a team of 12 across 3 sites", field: "wording" },
104
+ skill: { hint: "one resume line that names the skill and where it showed", field: "wording" },
105
+ context: { hint: "a few sentences, in your own words", field: "summary", long: true },
106
+ story: { hint: "a few sentences, in your own words: the situation, what you did, what came of it", field: "summary", long: true },
107
+ };
108
+ export function AnswerBox({ q, onSubmit, onSkip }) {
109
+ const [text, setText] = useState("");
110
+ const a = ANSWER[q.kind] || ANSWER.context;
111
+ const submit = (e) => {
112
+ e.preventDefault();
113
+ const v = text.trim();
114
+ if (!v) return;
115
+ const d = { ...fromDraft(q.draft), metrics: [], verbs: [], wordings: [], tenure: "" };
116
+ const base = { title: d.title, company: d.company, role: d.role, dates: d.dates, summary: "", metrics: [], verbs: [], wordings: [], tags: [] };
117
+ if (a.field === "tenure") base.tenure = v;
118
+ else if (a.field === "metric") base.metrics = [v];
119
+ else if (a.field === "wording") base.wordings = [v];
120
+ else base.summary = v;
121
+ onSubmit(base);
122
+ };
123
+ return html`<form class="answer" onSubmit=${submit}>
124
+ ${a.long ? html`<textarea required placeholder=${a.hint} value=${text} onInput=${(e) => setText(e.target.value)} rows="3"></textarea>` : html`<input required placeholder=${a.hint} value=${text} onInput=${(e) => setText(e.target.value)} />`}
125
+ <div class="row"><button class="primary small">Add answer</button>${onSkip ? html`<button type="button" class="small" onClick=${onSkip}>Skip</button>` : ""}<span class="small muted">lands as a pending row on this record; nothing changes until you confirm it</span></div>
126
+ </form>`;
127
+ }
128
+
93
129
  /** The resume header: name, email, phone, city, links. Prefilled from the first resume; yours to change here or in Settings. */
94
130
  export function ContactForm({ setErr, compact }) {
95
131
  const [c, setC] = useState(null);
@@ -214,8 +250,7 @@ export function KB({ live }) {
214
250
  <div class="row"><button disabled=${(d.questions || []).length > 0 || d.confirmed === 0} onClick=${async () => { if (me && (me.route === "byo_key" || me.route === "credits")) { try { setErr(""); const r = await api.post("/api/run/discover", {}); if (r.status !== "passed") setErr("refused: " + (r.errors || []).join("; ")); await load(); } catch (e) { setErr(e.message); } } else setModal({ op: "discover" }); }}>Ask what's missing</button>
215
251
  <span class="small muted">${d.confirmed === 0 ? "confirm at least one record first" : (d.questions || []).length ? `${d.questions.length} open` : "twelve questions at most per round"}</span></div>
216
252
  <ul class="list">${(d.questions || []).map((q) => html`<li key=${q.question_id}><div><span class="tag">${q.kind}</span> <b>${q.title}</b> <span class="muted">${q.company}, ${q.role}</span><br />${q.question}${q.why ? html` <span class="small muted">(${q.why})</span>` : ""}
217
- ${answering === q.question_id ? html`<${DraftForm} label="Add answer as pending" initial=${fromDraft(q.draft)} onSubmit=${(draft) => { post(`/api/kb/questions/${q.question_id}/answer`, { draft }); setAnswering(null); }} />`
218
- : html`<div class="row"><button class="small" onClick=${() => setAnswering(q.question_id)}>Answer</button><button class="small danger" onClick=${() => post(`/api/kb/questions/${q.question_id}/dismiss`, {})}>Dismiss</button></div>`}</div></li>`)}
253
+ <${AnswerBox} q=${q} onSubmit=${(draft) => post(`/api/kb/questions/${q.question_id}/answer`, { draft })} onSkip=${() => post(`/api/kb/questions/${q.question_id}/dismiss`, {})} /></div></li>`)}
219
254
  ${(d.questions || []).length ? "" : html`<li class="muted">no open questions</li>`}</ul></div>
220
255
  ${modal && modal.op === "discover" ? html`<${PacketModal} op="discover" label="Discovery questions" target=${{}} onClose=${() => setModal(null)} onSaved=${load} />` : ""}
221
256
  <h2>Holes (questions your own records raise)</h2>
@@ -1,5 +1,6 @@
1
1
  import { html, useState, useEffect } from "../vendor/preact.mjs";
2
2
  import { cleanPosting, guessPosting, api } from "../lib.js";
3
+ import { PacketModal } from "./packet.js";
3
4
 
4
5
  /** Screen 2: paste a posting, store and scan locally, edit the proposed categories, freeze, log the application. */
5
6
 
@@ -27,9 +28,9 @@ function CategoryEditor({ posting, scoring, onDone }) {
27
28
  <td class="small mono">${c.jd_evidence.map((e, j) => html`<div key=${j}>"${e}"</div>`)}</td>
28
29
  <td><button class="small" onClick=${() => setCats(cats.filter((_, j) => j !== i))}>drop</button></td></tr>`)}
29
30
  </tbody></table>
30
- <p class="small muted">Five to eight categories. Weights come from the employer's emphasis. A score left blank is filled mechanically from the evidence ranking; your own score wins when you type one. Every evidence line must be verbatim from the posting or the freeze is rejected.</p>
31
+ <p class="small muted">Five to eight categories. Every evidence line must be verbatim from the posting or scoring is refused.</p>
31
32
  ${err ? html`<p class="error">${err}</p>` : ""}
32
- <div class="row"><button class="primary" disabled=${busy || cats.length < 5} onClick=${freeze}>Freeze categories score</button></div>
33
+ <div class="row"><button class="primary" disabled=${busy || cats.length < 5} onClick=${freeze}>${busy ? "scoring" : "Score my record against these"}</button><span class="small muted">weight is the employer's emphasis; score is how well your confirmed records meet it, computed unless you type one</span></div>
33
34
  </div>`;
34
35
  }
35
36
 
@@ -50,6 +51,18 @@ export function Postings({ live, id }) {
50
51
  };
51
52
  const [err, setErr] = useState("");
52
53
  const [msg, setMsg] = useState("");
54
+ const [me, setMe] = useState(null);
55
+ const [modal, setModal] = useState(null);
56
+ const [asking, setAsking] = useState(false);
57
+ useEffect(() => { api.get("/api/me").then(setMe).catch(() => {}); }, []);
58
+ const direct = me && (me.route === "byo_key" || me.route === "credits");
59
+ // the AI reads the posting and proposes the five to eight things the employer is really asking for, each with its verbatim line
60
+ const askAi = async (posting_id) => {
61
+ if (!direct) { setModal({ op: "categories", posting_id }); return; }
62
+ setAsking(true); setErr(""); setMsg("");
63
+ try { const r = await api.post(`/api/run/categories?posting_id=${encodeURIComponent(posting_id)}`, {}); if (r.status !== "passed") setErr("refused: " + (r.errors || []).join("; ")); else setMsg("your AI proposed the categories; edit and freeze below"); await load(); } catch (e) { setErr(e.message); }
64
+ setAsking(false);
65
+ };
53
66
  const load = async () => { setRows(await api.get("/api/postings")); if (id) setSel(await api.get(`/api/postings/${id}`)); };
54
67
  useEffect(() => { load().catch((e) => setErr(e.message)); }, [live, id]);
55
68
  const store = async (e) => {
@@ -57,6 +70,8 @@ export function Postings({ live, id }) {
57
70
  try {
58
71
  const r = await api.post("/api/postings", form);
59
72
  await api.post(`/api/postings/${r.posting_id}/scan`);
73
+ if (direct) { try { await api.post(`/api/run/categories?posting_id=${encodeURIComponent(r.posting_id)}`, {}); } catch (e3) { setErr("your AI could not propose categories (" + e3.message + "); the local scan's draft is scored instead"); } }
74
+ try { await api.post(`/api/postings/${r.posting_id}/freeze`, {}); } catch (e4) { /* fewer than five usable lines; the editor below says so */ }
60
75
  setForm({ company: "", title: "", source_url: "", location: "", comp_text: "", raw_text: "" });
61
76
  setGuessed({ company: true, title: true });
62
77
  location.hash = "#/postings/" + r.posting_id;
@@ -69,36 +84,35 @@ export function Postings({ live, id }) {
69
84
  return html`<div>
70
85
  <h1>Postings</h1>
71
86
  ${err ? html`<p class="error">${err}</p>` : ""}${msg ? html`<p class="small muted">${msg}</p>` : ""}
72
- <div class="grid2">
73
- <div>
74
- <h2>Paste posting</h2>
75
- <form class="card" onSubmit=${store}>
76
- <textarea required placeholder="Paste the whole job posting here, exactly as the site shows it. Company and title fill in below from what you paste." value=${form.raw_text} onInput=${(e) => onPaste(e.target.value)}></textarea>
77
- <div class="row"><input required placeholder="company (guessed from the paste; fix if wrong)" value=${form.company} onInput=${(e) => { setGuessed({ ...guessed, company: false }); setForm({ ...form, company: e.target.value }); }} /><input required placeholder="title (guessed; fix if wrong)" value=${form.title} onInput=${(e) => { setGuessed({ ...guessed, title: false }); setForm({ ...form, title: e.target.value }); }} /></div>
78
- <div class="row"><input placeholder="link to the posting (optional)" value=${form.source_url} onInput=${(e) => setForm({ ...form, source_url: e.target.value })} /></div>
79
- <div class="row"><button class="primary">Store + scan</button><span class="small muted">stored on this PC and scored locally; no AI is called for this step</span></div>
80
- </form>
81
- <h2>Stored</h2>
82
- <div class="card"><ul class="list">${rows.map((r) => html`<li key=${r.posting_id}><a href=${"#/postings/" + r.posting_id}>${r.company}: ${r.title}</a> <span class="tag">${r.status}</span> ${r.scoring_frozen ? html`<span class="tag passed">frozen</span>` : html`<span class="tag warn">unscored</span>`} ${r.application_id ? html`<a class="small" href=${"#/applications/" + r.application_id}>application</a>` : ""} ${r.taken_down_at ? html`<span class="tag warn">taken down</span>` : ""}</li>`)}${rows.length ? "" : html`<li class="muted">none yet</li>`}</ul></div>
83
- </div>
84
- <div>
85
- ${sel ? html`<h2>${sel.company}: ${sel.title}</h2>
87
+ ${sel ? html`<p class="small"><a href="#/postings">← all postings</a></p>
88
+ <h2>${sel.company}: ${sel.title}</h2>
86
89
  <div class="card">
87
90
  <div class="row"><span class="tag">${sel.status}</span><span class="muted small">${[sel.location, sel.comp_text].filter(Boolean).join(" · ")}</span>
88
91
  ${sel.status === "proposed" ? html`<button class="primary" onClick=${() => act(`/api/postings/${sel.posting_id}/accept`)}>Accept</button>` : ""}
89
92
  ${sel.status === "proposed" ? html`<button onClick=${() => act(`/api/postings/${sel.posting_id}/archive`)}>Archive</button>` : ""}
93
+ <button class="primary" disabled=${asking} onClick=${() => askAi(sel.posting_id)}>${asking ? "asking" : "Ask your AI for categories"}</button>
90
94
  <button onClick=${() => act(`/api/postings/${sel.posting_id}/scan`)}>Scan again</button></div>
95
+ ${modal ? html`<${PacketModal} op="categories" label="Categories" target=${{ posting_id: modal.posting_id }} onClose=${() => setModal(null)} onSaved=${load} />` : ""}
91
96
  <details><summary>raw text (${sel.raw_text.length} chars)</summary><pre class="mono wrap">${sel.raw_text}</pre></details>
92
97
  </div>
93
- ${frozen ? html`<div class="card"><b>fit ${frozen.fit_score} · ${frozen.verdict.toUpperCase()}</b> <span class="muted small">${frozen.verdict_reason}</span>
98
+ ${frozen ? html`<div class="card"><b>Fit ${frozen.fit_score} of 10 · ${frozen.verdict.toUpperCase()}</b> <span class="muted small">${frozen.verdict_reason}</span>
94
99
  <ul class="list">${frozen.categories.map((c, i) => html`<li key=${i}><span class="mono">w${c.weight} s${c.score}</span> ${c.category} ${c.is_hard_gate ? html`<span class="tag warn">hard gate</span>` : ""} <span class="small muted">${(c.evidence_ids || []).slice(0, 3).join(", ")}</span></li>`)}</ul>
95
100
  ${!rows.find((r) => r.posting_id === sel.posting_id && r.application_id) && sel.status === "accepted" ? html`<div class="row"><button class="primary" onClick=${async () => { const r = await act("/api/applications", { posting_id: sel.posting_id }); if (r) location.hash = "#/applications/" + r.application_id; }}>Log application</button></div>` : ""}
96
101
  </div>` : ""}
97
102
  <h3>${frozen ? "Re-score" : "Proposed categories"} ${proposed ? html`<span class="tag">draft from ${proposed.scored_at.slice(0, 10)}</span>` : ""}</h3>
98
- ${proposed || frozen ? html`<div class="card"><${CategoryEditor} key=${(proposed || frozen).scoring_id} posting=${sel} scoring=${proposed || frozen} onDone=${load} /></div>` : html`<p class="muted">no categories yet; scan the posting or let a model propose them through MCP</p>`}
103
+ ${proposed || frozen ? html`<div class="card">${proposed && !frozen ? html`<p class="small muted">${direct ? "A quick local scan made this draft. \"Ask your AI for categories\" above replaces it with a read of the whole posting." : "A quick local scan made this draft; your AI can do better through the button above."}</p>` : ""}<${CategoryEditor} key=${(proposed || frozen).scoring_id} posting=${sel} scoring=${proposed || frozen} onDone=${load} /></div>` : html`<p class="muted">no categories yet; ask your AI above, or scan the posting</p>`}
99
104
  ${sel.gaps.length ? html`<h3>KB gaps (${sel.gaps.length})</h3><ul class="list">${sel.gaps.map((g) => html`<li key=${g.gap_id}>${g.question} <span class="small muted">"${g.requirement_text}"</span> ${g.resolved_at ? html`<span class="tag passed">answered</span>` : html`<a class="small" href="#/kb">answer</a>`}</li>`)}</ul>` : ""}
100
- ` : html`<p class="muted">pick a posting</p>`}
101
- </div>
102
- </div>
105
+ ` : html`<div>
106
+ <h2>Paste posting</h2>
107
+ <form class="card" onSubmit=${store}>
108
+ <textarea required placeholder="Paste the whole job posting here, exactly as the site shows it. Company and title fill in below from what you paste." value=${form.raw_text} onInput=${(e) => onPaste(e.target.value)}></textarea>
109
+ <div class="row"><input required placeholder="company (guessed from the paste; fix if wrong)" value=${form.company} onInput=${(e) => { setGuessed({ ...guessed, company: false }); setForm({ ...form, company: e.target.value }); }} /><input required placeholder="title (guessed; fix if wrong)" value=${form.title} onInput=${(e) => { setGuessed({ ...guessed, title: false }); setForm({ ...form, title: e.target.value }); }} /></div>
110
+ <div class="row"><input placeholder="link to the posting (optional)" value=${form.source_url} onInput=${(e) => setForm({ ...form, source_url: e.target.value })} /></div>
111
+ <div class="row"><button class="primary">Store + scan</button><span class="small muted">stored, scanned, then your AI proposes what the employer wants and your record is scored against it</span></div>
112
+ </form>
113
+ <h2>Stored</h2>
114
+ <div class="card"><ul class="list">${rows.map((r) => html`<li key=${r.posting_id}><a href=${"#/postings/" + r.posting_id}>${r.company}: ${r.title}</a> <span class="tag">${r.status}</span> ${r.scoring_frozen ? html`<span class="tag passed">frozen</span>` : html`<span class="tag warn">unscored</span>`} ${r.application_id ? html`<a class="small" href=${"#/applications/" + r.application_id}>application</a>` : ""} ${r.taken_down_at ? html`<span class="tag warn">taken down</span>` : ""}</li>`)}${rows.length ? "" : html`<li class="muted">none yet</li>`}</ul></div>
115
+
116
+ </div>`}
103
117
  </div>`;
104
118
  }
@@ -51,7 +51,7 @@ export function Settings({ live }) {
51
51
  <div class="card">
52
52
  ${(s.hosted
53
53
  ? [["credits", "Credits: your documents run on the included AI, paid in credits"], ["paste", "Paste: copy a packet into any chat you already pay for, paste the JSON back"]]
54
- : [["paste", "Paste: copy a packet into any chat, paste the JSON back"], ["mcp", "Claude Desktop / Claude Code through the local MCP server"], ["byo_key", "My providers: the local server calls the one you mark active"], ["credits", "Credits: the local server sends packets to the hosted relay with your sign-in token (relay not deployed yet)"]]
54
+ : [["paste", "Paste: copy a packet into any chat, paste the JSON back"], ["mcp", "Claude Desktop / Claude Code through the local MCP server"], ["byo_key", "My providers: the local server calls the one you mark active"], ["credits", "Credits: the local server sends packets to the hosted relay with your sign-in token"]]
55
55
  ).map(([r, label]) => html`<div key=${r}><label><input type="radio" name="route" checked=${s.route === r} disabled=${r === "credits" && !s.hosted && !(s.credits && s.credits.has_token)} onChange=${() => put({ route: r })} /> ${label}</label></div>`)}
56
56
  ${s.hosted ? "" : html`<div class="row"><button onClick=${async () => setMcp(mcp ? null : await api.get("/api/mcp-config"))}>${mcp ? "hide" : "Print MCP config"}</button></div>`}
57
57
  ${mcp ? html`<div><p class="small">Claude Desktop, merge into mcpServers:</p><textarea readonly value=${JSON.stringify(mcp.desktop, null, 2)}></textarea><p class="small">Claude Code:</p><textarea readonly value=${mcp.code}></textarea></div>` : ""}
@@ -99,7 +99,7 @@ export function Settings({ live }) {
99
99
  <div class="row"><button type="button" onClick=${async () => { setMsg(""); try { const r = await api.get("/api/credits/balance"); setMsg(`balance: ${r.available} available`); await load(); } catch (e2) { setErr(e2.message); } }}>check balance</button>
100
100
  ${[["starter", "25 credits, $9"], ["campaign", "100 credits, $29"], ["season", "300 credits, $69"]].map(([k, label]) => html`<button type="button" key=${k} onClick=${async () => { setMsg(""); try { const r = await api.post("/api/credits/checkout", { pack: k }); location.href = r.url; } catch (e2) { setErr(e2.message); } }}>buy ${label}</button>`)}</div>
101
101
  <p class="small muted">Stripe takes the card; GAF-J never sees it. Credits arrive within a few seconds of paying and never expire. A document costs a few credits, reading a resume a few more; the exact prices are shown before each run.</p>
102
- ${s.credits && s.credits.last_balance ? html`<p class="small">${s.credits.last_balance.available} credits available (${s.credits.last_balance.reserved} held), checked ${s.credits.last_balance.at}</p>` : ""}` : html`<p class="small muted">A paid route for people without their own AI subscription. The relay is written and tested but not deployed; nothing here works until it is. The relay runs named operations on the packet the app built, never a free prompt, and never stores packet or reply text.</p>
102
+ ` : html`<p class="small muted">A paid route for people without their own AI subscription. Sign in at https://app.gaf-j.com/signin, copy the token it shows, paste it here with the relay address. The relay runs named operations on the packet the app built, never a free prompt, and never stores packet or reply text.</p>
103
103
  <form class="inline" onSubmit=${(e) => { e.preventDefault(); const f = e.target.elements; put({ credits: { relay_url: f.relay.value, ...(f.token.value ? { id_token: f.token.value } : {}) } }); f.token.value = ""; }}>
104
104
  <input name="relay" placeholder="relay url (https)" value=${(s.credits && s.credits.relay_url) || ""} />
105
105
  <input name="token" type="password" placeholder=${s.credits && s.credits.has_token ? `sign-in token ${s.credits.token} (blank keeps it)` : "sign-in token"} />
@@ -107,18 +107,18 @@ export function Settings({ live }) {
107
107
  <button type="button" onClick=${async () => { setMsg(""); try { const r = await api.get("/api/credits/balance"); setMsg(`balance: ${r.available} available`); await load(); } catch (e2) { setErr(e2.message); } }} disabled=${!(s.credits && s.credits.has_token)}>check balance</button>
108
108
  ${s.credits && s.credits.has_token ? html`<button type="button" class="danger" onClick=${() => put({ credits: { id_token: null } })}>sign out</button>` : ""}
109
109
  </form>`}
110
- ${s.credits && s.credits.last_balance ? html`<p class="small">${s.credits.last_balance.available} credits available (${s.credits.last_balance.reserved} held), checked ${s.credits.last_balance.at}</p>` : ""}
110
+ ${s.credits && s.credits.last_balance ? html`<p class="small"><b>${s.credits.last_balance.available} credits available</b>${s.credits.last_balance.reserved ? ` (${s.credits.last_balance.reserved} held for a run in progress)` : ""}, checked ${new Date(s.credits.last_balance.at).toLocaleString()}</p>` : ""}
111
111
  </div>
112
112
  <h2>Usage</h2>
113
113
  <div class="card">
114
- ${usage && usage.by_op.length ? html`<table class="cats"><thead><tr><th>operation</th><th>result</th><th>runs</th><th>tokens in</th><th>tokens out</th><th>model</th></tr></thead><tbody>
114
+ ${usage && usage.by_op.length ? html`<div class="tablewrap"><table class="cats"><thead><tr><th>operation</th><th>result</th><th>runs</th><th>tokens in</th><th>tokens out</th><th>model</th></tr></thead><tbody>
115
115
  ${usage.by_op.flatMap((u) => [
116
116
  html`<tr key=${u.op + "p"}><td rowspan="2"><b>${u.op}</b><div class="small muted">${(u.avg_ms / 1000).toFixed(0)} s avg</div></td><td><span class="tag passed">passed</span></td><td>${u.passed}</td><td>${u.in_passed.toLocaleString()}</td><td>${u.out_passed.toLocaleString()}</td><td rowspan="2" class="small muted">${u.model || ""}</td></tr>`,
117
117
  html`<tr key=${u.op + "f"}><td><span class="tag blocked">failed</span></td><td>${u.failed}</td><td>${u.in_failed.toLocaleString()}</td><td>${u.out_failed.toLocaleString()}</td></tr>`,
118
118
  ])}
119
119
  <tr><td><b>total</b></td><td></td><td>${usage.by_op.reduce((n, u) => n + u.attempts, 0)}</td><td><b>${usage.by_op.reduce((n, u) => n + u.tokens_in, 0).toLocaleString()}</b></td><td><b>${usage.by_op.reduce((n, u) => n + u.tokens_out, 0).toLocaleString()}</b></td><td></td></tr>
120
120
  <tr><td colspan="2"><b>of which wasted on failures</b></td><td>${usage.by_op.reduce((n, u) => n + u.failed, 0)}</td><td>${usage.by_op.reduce((n, u) => n + u.in_failed, 0).toLocaleString()}</td><td>${usage.by_op.reduce((n, u) => n + u.out_failed, 0).toLocaleString()}</td><td></td></tr>
121
- </tbody></table><p class="small muted">Counted from every run through your own provider or credits since ${usage.since ? new Date(usage.since).toLocaleDateString() : "the start"}. "Failed" is a reply that came back but did not pass the gate. ${usage.unrecorded ? `${usage.unrecorded} call${usage.unrecorded === 1 ? "" : "s"} never returned (a timeout or a dropped connection): no tokens recorded here, but the provider still billed the input.` : ""} Paste runs are not counted. Multiply by your provider's price per million tokens for the cost.</p>`
121
+ </tbody></table></div><p class="small muted">Counted from every run through your own provider or credits since ${usage.since ? new Date(usage.since).toLocaleDateString() : "the start"}. "Failed" is a reply that came back but did not pass the gate. ${usage.unrecorded ? `${usage.unrecorded} call${usage.unrecorded === 1 ? "" : "s"} never returned (a timeout or a dropped connection): no tokens recorded here, but the provider still billed the input.` : ""} Paste runs are not counted. Multiply by your provider's price per million tokens for the cost.</p>`
122
122
  : html`<p class="small muted">Nothing counted yet. Runs through your own provider key or credits record their token counts here.</p>`}
123
123
  </div>
124
124
  <h2>Bullet register</h2>
@@ -129,9 +129,9 @@ export function Settings({ live }) {
129
129
  <h2>House rules</h2>
130
130
  <div class="card">
131
131
  <p class="small muted">Style rules only. Integrity rules (provenance, metrics, tenure, verbs, history) have no switch.</p>
132
- <table class="cats"><thead><tr><th>rule</th>${KINDS.map((k) => html`<th key=${k}>${k}</th>`)}</tr></thead><tbody>
132
+ <div class="tablewrap"><table class="cats"><thead><tr><th>rule</th>${KINDS.map((k) => html`<th key=${k}>${k}</th>`)}</tr></thead><tbody>
133
133
  ${["no_dashes", "style_config"].map((rule) => html`<tr key=${rule}><td>${rule}</td>${KINDS.map((k) => html`<td key=${k}><select onChange=${(e) => setSev(rule, k, e.target.value)}>${["block", "warn", "off"].map((sev) => html`<option key=${sev} value=${sev} selected=${(h[rule][k] || "warn") === sev}>${sev}</option>`)}</select></td>`)}</tr>`)}
134
- </tbody></table>
134
+ </tbody></table></div>
135
135
  <form class="inline" onSubmit=${(e) => { e.preventDefault(); const v = e.target.elements.allow.value.trim(); if (v) hr({ hyphen_allow: [...h.hyphen_allow, v] }); e.target.reset(); }}>
136
136
  <span>allowed hyphen compounds: ${h.hyphen_allow.map((a) => html`<span class="tag" key=${a}>${a} <a href="#/settings" onClick=${(e) => { e.preventDefault(); hr({ hyphen_allow: h.hyphen_allow.filter((x) => x !== a) }); }}>×</a></span> `)}</span>
137
137
  <input name="allow" placeholder="cross-functional" /><button>allow</button></form>
@@ -1,6 +1,6 @@
1
1
  import { html, useState, useEffect } from "../vendor/preact.mjs";
2
2
  import { api, cleanPosting, guessPosting } from "../lib.js";
3
- import { Onboarding, DraftForm, fromDraft, Positions, ContactForm } from "./kb.js";
3
+ import { Onboarding, DraftForm, fromDraft, Positions, ContactForm, AnswerBox } from "./kb.js";
4
4
  import { PacketModal } from "./packet.js";
5
5
 
6
6
  /**
@@ -10,6 +10,9 @@ import { PacketModal } from "./packet.js";
10
10
  * step flips when the store says so; "skip for now" opens the app anyway.
11
11
  */
12
12
 
13
+ /** While the wizard shows its final result, the app's gate waits for "Open the app". */
14
+ export const wizardHold = { on: false };
15
+
13
16
  const KIND_LABEL = { anthropic: "Anthropic (Claude)", openai_compatible: "OpenAI, or any OpenAI-compatible server", gemini: "Google Gemini" };
14
17
 
15
18
  function StepAi({ s, reload, hosted }) {
@@ -58,12 +61,31 @@ function StepAi({ s, reload, hosted }) {
58
61
  </div>`;
59
62
  }
60
63
 
61
- function StepPosting({ reload }) {
64
+ /** Hosted only: the included AI runs on credits, so the first step is having some. */
65
+ function StepCredits({ reload, setErr }) {
66
+ const [bal, setBal] = useState(null);
67
+ const [busy, setBusy] = useState("");
68
+ const check = async () => { setBusy("check"); try { setErr(""); const r = await api.get("/api/credits/balance"); setBal(r); await reload(); } catch (e) { setErr(e.message); } setBusy(""); };
69
+ useEffect(() => { check(); }, []);
70
+ const buy = async (pack) => { setBusy(pack); try { setErr(""); const r = await api.post("/api/credits/checkout", { pack }); location.href = r.url; } catch (e) { setErr(e.message); setBusy(""); } };
71
+ return html`<div>
72
+ <p>Reading your resumes, asking what is missing, and every document you make run on the included AI and cost credits: a few per resume read, a few per document. Buy a pack once; nothing recurs and credits never expire. Stripe takes the card; GAF-J never sees it.</p>
73
+ <div class="ai-options">
74
+ ${[["starter", "Starter", "25 credits", "$9", "enough to read your resumes and make a few documents"], ["campaign", "Campaign", "100 credits", "$29", "a full search: many postings, resumes, and prep"], ["season", "Season", "300 credits", "$69", "a long search, or a career change with many versions"]].map(([k, name, n, price, why]) => html`<button key=${k} disabled=${!!busy} onClick=${() => buy(k)}><b>${name}: ${n}, ${price}</b><span class="small muted">${why}</span></button>`)}
75
+ </div>
76
+ <div class="row" style="margin-top:12px"><span class="small">${bal ? html`<b>${bal.available} credits available</b>` : busy === "check" ? "checking" : "balance unknown"}</span><button class="small" disabled=${!!busy} onClick=${check}>check again</button>
77
+ <span class="small muted">Prefer to use a chat you already pay for? Settings, AI route, Paste; then this step can be skipped.</span></div>
78
+ </div>`;
79
+ }
80
+
81
+ function StepPosting({ reload, direct, onResult }) {
62
82
  const [text, setText] = useState("");
63
83
  const [company, setCompany] = useState("");
64
84
  const [title, setTitle] = useState("");
65
85
  const [g, setG] = useState({ company: true, title: true });
66
86
  const [err, setErr] = useState("");
87
+ const [busy, setBusy] = useState(false);
88
+ const [result, setResult] = useState(null);
67
89
  const onPaste = (raw) => {
68
90
  const t = cleanPosting(raw);
69
91
  setText(t);
@@ -73,13 +95,30 @@ function StepPosting({ reload }) {
73
95
  };
74
96
  const store = async (e) => {
75
97
  e.preventDefault(); setErr("");
76
- try { const r = await api.post("/api/postings", { company, title, raw_text: text }); await api.post(`/api/postings/${r.posting_id}/scan`, {}); await reload(); } catch (e2) { setErr(e2.message); }
98
+ setBusy(true);
99
+ try {
100
+ const r = await api.post("/api/postings", { company, title, raw_text: text });
101
+ await api.post(`/api/postings/${r.posting_id}/scan`, {});
102
+ let note = "";
103
+ if (direct) { setBusy("ai"); try { const c = await api.post(`/api/run/categories?posting_id=${encodeURIComponent(r.posting_id)}`, {}); if (c.status !== "passed") note = "your AI's categories were refused (" + (c.errors || []).slice(0, 1).join("") + "); the local scan's draft is scored instead"; } catch (e3) { note = "your AI could not propose categories (" + e3.message + "); the local scan's draft is scored instead"; } }
104
+ setBusy("score");
105
+ if (onResult) onResult();
106
+ try { const f = await api.post(`/api/postings/${r.posting_id}/freeze`, {}); setResult({ ...f, posting_id: r.posting_id, note }); } catch (e4) { setResult({ posting_id: r.posting_id, note: (note ? note + ". " : "") + "Not scored yet: " + e4.message }); }
107
+ await reload();
108
+ } catch (e2) { setErr(e2.message); }
109
+ setBusy(false);
77
110
  };
111
+ if (result) return html`<div>
112
+ ${result.fit_score != null ? html`<p><b>Fit ${result.fit_score} of 10 · ${String(result.verdict).toUpperCase()}</b> <span class="muted small">${result.verdict_reason}</span></p>
113
+ <ul class="list">${(result.categories || []).map((c, i) => html`<li key=${i}><span class="mono small">weight ${c.weight} · score ${c.score}</span> ${c.category} ${c.is_hard_gate ? html`<span class="tag warn">must have</span>` : ""}</li>`)}</ul>` : ""}
114
+ ${result.note ? html`<p class="small error">${result.note}</p>` : ""}
115
+ <p class="small muted">Weight is how much the employer stressed it; score is how well your confirmed records meet it. Open <a href=${"#/postings/" + result.posting_id}>the posting</a> to edit either and re-score, or to log an application and make the resume.</p>
116
+ </div>`;
78
117
  return html`<form onSubmit=${store}>
79
118
  <p>Paste the text of a job you want, exactly as the site shows it. GAF-J scores it against your record and shows what the posting asks for beside what you have. Everything else (the resume, the prep) starts from there.</p>
80
119
  <textarea required placeholder="the whole posting" value=${text} onInput=${(e) => onPaste(e.target.value)}></textarea>
81
- <div class="row"><input required placeholder="company (guessed; fix if wrong)" value=${company} onInput=${(e) => { setG({ ...g, company: false }); setCompany(e.target.value); }} /><input required placeholder="title (guessed; fix if wrong)" value=${title} onInput=${(e) => { setG({ ...g, title: false }); setTitle(e.target.value); }} /></div>
82
- <div class="row"><button class="primary">Store and score</button>${err ? html`<span class="error small">${err}</span>` : ""}</div>
120
+ <div class="row wide"><input required placeholder="company (guessed; fix if wrong)" value=${company} onInput=${(e) => { setG({ ...g, company: false }); setCompany(e.target.value); }} /><input required placeholder="title (guessed; fix if wrong)" value=${title} onInput=${(e) => { setG({ ...g, title: false }); setTitle(e.target.value); }} /></div>
121
+ <div class="row"><button class="primary" disabled=${!!busy}>${busy === "ai" ? "asking your AI what the employer wants" : busy === "score" ? "scoring your record against it" : busy ? "storing" : "Store and score"}</button>${err ? html`<span class="error small">${err}</span>` : ""}</div>
83
122
  </form>`;
84
123
  }
85
124
 
@@ -93,20 +132,33 @@ export function Welcome({ live, onDone }) {
93
132
  const [answering, setAnswering] = useState(null);
94
133
  const [busy, setBusy] = useState("");
95
134
  const [view, setView] = useState(null); // a step the user went back to; null follows the next undone step
96
- const load = () => Promise.all([api.get("/api/setup"), api.get("/api/onboarding"), api.get("/api/kb"), api.get("/api/me")]).then(([s, o, k, m]) => { setSetup(s); setOb(o); setKb(k); setMe(m); if (s.complete) onDone(); }).catch((e) => setErr(e.message));
135
+ const hold = () => { wizardHold.on = true; };
136
+ const release = () => { wizardHold.on = false; onDone(); };
137
+ const load = () => Promise.all([api.get("/api/setup"), api.get("/api/onboarding"), api.get("/api/kb"), api.get("/api/me")]).then(([s, o, k, m]) => { setSetup(s); setOb(o); setKb(k); setMe(m); if (s.complete && !wizardHold.on) onDone(); }).catch((e) => setErr(e.message));
97
138
  useEffect(() => { load(); }, [live]);
98
139
  const post = async (path, body) => { try { setErr(""); await api.post(path, body || {}); await load(); } catch (e) { setErr(e.message); } };
99
140
  if (!setup || !ob || !kb || !me) return html`<div class="welcome"><p class="muted">loading</p></div>`;
100
141
  const direct = me.route === "byo_key" || me.route === "credits";
101
- const nextIdx = setup.steps.findIndex((x) => x.key === setup.next);
102
- const idx = view !== null && view <= Math.max(nextIdx, 0) ? view : nextIdx;
103
- const step = setup.steps[idx].key;
142
+ // with every step done there is no next; the last step stays on screen with its result
143
+ const last = setup.steps.length - 1;
144
+ const nextIdx = setup.next ? setup.steps.findIndex((x) => x.key === setup.next) : setup.steps.length;
145
+ const idx = view !== null && view <= Math.min(nextIdx, last) ? view : Math.min(nextIdx, last);
146
+ // when the step on screen completes (an extraction lands, a record is confirmed), stay on it so the result is
147
+ // readable; Continue moves on. The pin is by step key, so a reload does not lose it.
148
+ const [pinned, setPinned] = useState(null);
149
+ const pinnedIdx = pinned ? setup.steps.findIndex((x) => x.key === pinned) : -1;
150
+ const stay = view === null && pinnedIdx >= 0 && pinnedIdx < nextIdx && setup.steps[pinnedIdx].done && setup.steps[pinnedIdx].key !== "ai" && setup.steps[pinnedIdx].key !== "credits";
151
+ const at = stay ? pinnedIdx : idx;
152
+ const shown = setup.steps[at];
153
+ useEffect(() => { if (shown && !shown.done) setPinned(shown.key); }, [shown && !shown.done ? shown.key : null]);
154
+ const step = setup.steps[at].key;
104
155
  const discover = async () => {
105
156
  if (!direct) { setModal("discover"); return; }
106
157
  setBusy("asking"); try { const r = await api.post("/api/run/discover", {}); if (r.status !== "passed") setErr("refused: " + (r.errors || []).join("; ")); await load(); } catch (e) { setErr(e.message); } setBusy("");
107
158
  };
108
159
  const body = {
109
160
  ai: html`<${StepAi} s=${setup} reload=${load} hosted=${!!me.hosted} />`,
161
+ credits: html`<${StepCredits} reload=${load} setErr=${setErr} />`,
110
162
  upload: html`<div><p>Your resumes: Word, PDF, Markdown, or plain text. Five to ten of your most different versions is plenty; near-identical copies only add duplicates to merge. The text is read on this PC. Nothing is uploaded anywhere.</p><${Onboarding} d=${ob} reload=${load} setErr=${setErr} route=${me.route} compact=${true} /></div>`,
111
163
  extract: html`<div><p>${direct ? "Your AI reads each file and proposes records where every number and phrase is quoted from the file; anything it cannot quote is refused. One click runs the whole pile." : "For each file, Extract opens a packet: copy it into your chat, paste the JSON reply back. Your AI proposes records where every number and phrase is quoted from the file."}</p><${Onboarding} d=${ob} reload=${load} setErr=${setErr} route=${me.route} compact=${true} /></div>`,
112
164
  positions: html`<div><p>Before the records: the jobs, schools, volunteer roles, awards, and certifications themselves, each once. Your AI quoted them from your resumes. Fix a name, add a city, correct a date, then confirm. Every record under a job follows it.</p>
@@ -121,17 +173,19 @@ export function Welcome({ live, onDone }) {
121
173
  ${kb.pending.length > 1 ? html`<div class="row"><button onClick=${async () => { for (const p of kb.pending) await api.post(`/api/kb/pending/${p.pending_id}/confirm`, {}); await load(); }}>Confirm all ${kb.pending.length}</button><span class="small muted">only if you have read them</span></div>` : ""}</div>`,
122
174
  discover: html`<div><p>Your AI reads the confirmed records and asks what a coach would: the figure a record lacks, the size of a team, the story behind a bare bullet. Each answer lands on the record it is about.</p>
123
175
  ${kb.questions.length ? html`<ul class="list">${kb.questions.map((q) => html`<li key=${q.question_id}><div><span class="tag">${q.kind}</span> <b>${q.title}</b> <span class="muted">${q.company}</span><br />${q.question}
124
- ${answering === q.question_id ? html`<${DraftForm} label="Add answer" initial=${fromDraft(q.draft)} onSubmit=${(draft) => { post(`/api/kb/questions/${q.question_id}/answer`, { draft }); setAnswering(null); }} />` : html`<div class="row"><button class="small" onClick=${() => setAnswering(q.question_id)}>Answer</button><button class="small danger" onClick=${() => post(`/api/kb/questions/${q.question_id}/dismiss`, {})}>Skip</button></div>`}</div></li>`)}</ul>`
176
+ <${AnswerBox} q=${q} onSubmit=${(draft) => post(`/api/kb/questions/${q.question_id}/answer`, { draft })} onSkip=${() => post(`/api/kb/questions/${q.question_id}/dismiss`, {})} /></div></li>`)}</ul>`
125
177
  : html`<div class="row"><button class="primary" disabled=${!!busy} onClick=${discover}>${busy ? "asking" : "Ask what's missing"}</button><button onClick=${() => post("/api/setup/dismiss", { dismissed: true })}>Later</button></div>`}</div>`,
126
- posting: html`<${StepPosting} reload=${load} />`,
178
+ posting: html`<${StepPosting} reload=${load} direct=${direct} onResult=${hold} />`,
127
179
  };
128
180
  return html`<div class="welcome">
129
- <div class="welcome-head"><img src="/icon-192.png" alt="" width="40" height="40" /><div><b>Welcome to GAF-J${me.name ? ", " + me.name : ""}</b><div class="small muted">Seven short steps, then the app is yours.</div></div>
130
- <span class="links"><a class="small" href="#/settings">Settings</a><a class="small" href="#/" onClick=${async (e) => { e.preventDefault(); await api.post("/api/setup/dismiss", {}); onDone(); }}>skip for now</a></span></div>
131
- <ol class="stepper">${setup.steps.map((st, i) => html`<li key=${st.key} class=${(st.done ? "done" : "") + (i === idx ? " now" : "") + (i <= nextIdx ? " can" : "")} onClick=${() => { if (i <= nextIdx) setView(i); }} title=${i <= nextIdx ? "open this step" : ""}><span class="n mono">${st.done ? "✓" : i + 1}</span>${st.title}</li>`)}</ol>
181
+ <div class="welcome-head"><img src="/icon-192.png" alt="" width="40" height="40" /><div><b>Welcome to GAF-J${me.name ? ", " + me.name : ""}</b><div class="small muted">${["", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight"][setup.steps.length] || setup.steps.length} short steps, then the app is yours.</div></div>
182
+ <span class="links"><a class="small" href="#/settings">Settings</a><a class="small" href="#/" onClick=${async (e) => { e.preventDefault(); await api.post("/api/setup/dismiss", {}); release(); }}>skip for now</a></span></div>
183
+ <div class="stepper-compact"><span>Step ${at + 1} of ${setup.steps.length}</span><span class="bar"><span style=${`width:${Math.round((setup.steps.filter((x) => x.done).length / setup.steps.length) * 100)}%`}></span></span></div>
184
+ <ol class="stepper">${setup.steps.map((st, i) => html`<li key=${st.key} class=${(st.done ? "done" : "") + (i === at ? " now" : "") + (i <= nextIdx ? " can" : "")} onClick=${() => { if (i <= nextIdx) setView(i); }} title=${i <= nextIdx ? "open this step" : ""}><span class="n mono">${st.done ? "✓" : i + 1}</span>${st.title}</li>`)}</ol>
132
185
  ${err ? html`<p class="error">${err}</p>` : ""}
133
- <div class="card welcome-body"><h3>${idx + 1}. ${setup.steps[idx].title}</h3>${body[step]}
134
- <div class="row welcome-nav">${idx > 0 ? html`<button class="small" onClick=${() => setView(idx - 1)}>← Back</button>` : ""}${idx < nextIdx ? html`<button class="small" onClick=${() => setView(null)}>Continue →</button>` : ""}</div></div>
186
+ <div class="card welcome-body">
187
+ <div class="row between welcome-nav"><h3>${at + 1}. ${setup.steps[at].title}</h3><div class="row">${at > 0 ? html`<button class="small" onClick=${() => setView(at - 1)}>← Back</button>` : ""}${setup.complete && at === last ? html`<button class="primary small" onClick=${release}>Open the app →</button>` : at < nextIdx ? html`<button class=${stay ? "primary small" : "small"} onClick=${() => { setPinned(null); setView(null); }}>Continue →</button>` : ""}</div></div>
188
+ ${body[step]}</div>
135
189
  ${modal === "discover" ? html`<${PacketModal} op="discover" label="Discovery questions" target=${{}} onClose=${() => setModal(null)} onSaved=${load} />` : ""}
136
190
  </div>`;
137
191
  }