@floless/app 0.8.0 → 0.9.1

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.
@@ -72,7 +72,7 @@ var require_queue = __commonJS({
72
72
  if (!(_concurrency >= 1)) {
73
73
  throw new Error("fastqueue concurrency must be equal to or greater than 1");
74
74
  }
75
- var cache = reusify(Task);
75
+ var cache2 = reusify(Task);
76
76
  var queueHead = null;
77
77
  var queueTail = null;
78
78
  var _running = 0;
@@ -151,7 +151,7 @@ var require_queue = __commonJS({
151
151
  return _running === 0 && self.length() === 0;
152
152
  }
153
153
  function push(value, done) {
154
- var current = cache.get();
154
+ var current = cache2.get();
155
155
  current.context = context;
156
156
  current.release = release;
157
157
  current.value = value;
@@ -172,7 +172,7 @@ var require_queue = __commonJS({
172
172
  }
173
173
  }
174
174
  function unshift(value, done) {
175
- var current = cache.get();
175
+ var current = cache2.get();
176
176
  current.context = context;
177
177
  current.release = release;
178
178
  current.value = value;
@@ -194,7 +194,7 @@ var require_queue = __commonJS({
194
194
  }
195
195
  function release(holder) {
196
196
  if (holder) {
197
- cache.release(holder);
197
+ cache2.release(holder);
198
198
  }
199
199
  var next = queueHead;
200
200
  if (next && _running <= _concurrency) {
@@ -6621,12 +6621,12 @@ var require_levels = __commonJS({
6621
6621
  function genLsCache(instance) {
6622
6622
  const formatter = instance[formattersSym].level;
6623
6623
  const { labels } = instance.levels;
6624
- const cache = {};
6624
+ const cache2 = {};
6625
6625
  for (const label in labels) {
6626
6626
  const level = formatter(labels[label], Number(label));
6627
- cache[label] = JSON.stringify(level).slice(0, -1);
6627
+ cache2[label] = JSON.stringify(level).slice(0, -1);
6628
6628
  }
6629
- instance[lsCacheSym] = cache;
6629
+ instance[lsCacheSym] = cache2;
6630
6630
  return instance;
6631
6631
  }
6632
6632
  function isStandardLevel(level, useOnlyCustomLevels) {
@@ -25430,7 +25430,7 @@ var require_range = __commonJS({
25430
25430
  parseRange(range) {
25431
25431
  const memoOpts = (this.options.includePrerelease && FLAG_INCLUDE_PRERELEASE) | (this.options.loose && FLAG_LOOSE);
25432
25432
  const memoKey = memoOpts + ":" + range;
25433
- const cached = cache.get(memoKey);
25433
+ const cached = cache2.get(memoKey);
25434
25434
  if (cached) {
25435
25435
  return cached;
25436
25436
  }
@@ -25464,7 +25464,7 @@ var require_range = __commonJS({
25464
25464
  rangeMap.delete("");
25465
25465
  }
25466
25466
  const result = [...rangeMap.values()];
25467
- cache.set(memoKey, result);
25467
+ cache2.set(memoKey, result);
25468
25468
  return result;
25469
25469
  }
25470
25470
  intersects(range, options) {
@@ -25503,7 +25503,7 @@ var require_range = __commonJS({
25503
25503
  };
25504
25504
  module2.exports = Range;
25505
25505
  var LRU = require_lrucache();
25506
- var cache = new LRU();
25506
+ var cache2 = new LRU();
25507
25507
  var parseOptions = require_parse_options();
25508
25508
  var Comparator = require_comparator();
25509
25509
  var debug = require_debug2();
@@ -26445,12 +26445,12 @@ var require_plugin_utils = __commonJS({
26445
26445
  if (display) {
26446
26446
  return display;
26447
26447
  }
26448
- const cache = require.cache;
26449
- if (cache) {
26450
- const keys = Object.keys(cache);
26448
+ const cache2 = require.cache;
26449
+ if (cache2) {
26450
+ const keys = Object.keys(cache2);
26451
26451
  for (let i = 0; i < keys.length; i++) {
26452
26452
  const key = keys[i];
26453
- if (cache[key].exports === func) {
26453
+ if (cache2[key].exports === func) {
26454
26454
  return key;
26455
26455
  }
26456
26456
  }
@@ -42463,10 +42463,10 @@ var require_accept_negotiator = __commonJS({
42463
42463
  }
42464
42464
  const {
42465
42465
  supportedValues = [],
42466
- cache
42466
+ cache: cache2
42467
42467
  } = options && typeof options === "object" && options || {};
42468
42468
  this.supportedValues = supportedValues;
42469
- this.cache = cache;
42469
+ this.cache = cache2;
42470
42470
  }
42471
42471
  Negotiator.prototype.negotiate = function(header) {
42472
42472
  if (typeof header !== "string") {
@@ -52093,6 +52093,9 @@ function mapUserStatus(u) {
52093
52093
  function signInUrl() {
52094
52094
  return `${env().webBase}/login?app=floless`;
52095
52095
  }
52096
+ function webBaseUrl() {
52097
+ return env().webBase;
52098
+ }
52096
52099
  function updateApiBase() {
52097
52100
  return `${env().apiBase}/updates/releases`;
52098
52101
  }
@@ -52354,7 +52357,7 @@ function appVersion() {
52354
52357
  return resolveVersion({
52355
52358
  isSea: isSea2(),
52356
52359
  sqVersionXml: readSqVersionXml(),
52357
- define: true ? "0.8.0" : void 0,
52360
+ define: true ? "0.9.1" : void 0,
52358
52361
  pkgVersion: readPkgVersion()
52359
52362
  });
52360
52363
  }
@@ -52364,7 +52367,7 @@ function resolveChannel(s) {
52364
52367
  return "dev";
52365
52368
  }
52366
52369
  function appChannel() {
52367
- return resolveChannel({ isSea: isSea2(), define: true ? "0.8.0" : void 0 });
52370
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.9.1" : void 0 });
52368
52371
  }
52369
52372
 
52370
52373
  // oauth-presets.ts
@@ -52403,13 +52406,23 @@ function managedProfileYaml(preset) {
52403
52406
  function oauthDir() {
52404
52407
  return process.env.AWARE_HOME ? (0, import_node_path6.join)(process.env.AWARE_HOME, "oauth") : (0, import_node_path6.join)((0, import_node_os6.homedir)(), ".aware", "oauth");
52405
52408
  }
52409
+ function isUpgradableLegacyProfile(existing, preset) {
52410
+ if (existing.startsWith(MANAGED_HEADER)) return false;
52411
+ const content = existing.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"));
52412
+ const only = content.length === 1 ? content[0] : void 0;
52413
+ if (only === void 0) return false;
52414
+ const m = /^client_id\s*:\s*(.+?)\s*$/.exec(only);
52415
+ return m !== null && m[1] === preset.clientId;
52416
+ }
52406
52417
  function ensureManagedProfile(id) {
52407
52418
  const preset = OAUTH_PRESETS[id];
52408
52419
  if (!preset) return "not-managed";
52409
52420
  const dir = oauthDir();
52410
52421
  const file = (0, import_node_path6.join)(dir, `${id}.yaml`);
52411
52422
  const existing = (0, import_node_fs7.existsSync)(file) ? (0, import_node_fs7.readFileSync)(file, "utf8") : null;
52412
- if (existing !== null && !existing.startsWith(MANAGED_HEADER)) return "skipped";
52423
+ if (existing !== null && !existing.startsWith(MANAGED_HEADER) && !isUpgradableLegacyProfile(existing, preset)) {
52424
+ return "skipped";
52425
+ }
52413
52426
  const desired = managedProfileYaml(preset);
52414
52427
  if (existing === desired) return "unchanged";
52415
52428
  (0, import_node_fs7.mkdirSync)(dir, { recursive: true });
@@ -53924,6 +53937,87 @@ async function reportIssue(input) {
53924
53937
  return { ok: true, ref };
53925
53938
  }
53926
53939
 
53940
+ // release-notes.ts
53941
+ var FETCH_TIMEOUT_MS = 8e3;
53942
+ var AWARE_REPO = "aware-aeco/aware";
53943
+ var CHANGE_RE = /^[-*]\s+\*\*?(Added|Changed|Fixed|Removed|Security)\*\*?:\s+(.+)$/gim;
53944
+ function parseBulletChanges(body) {
53945
+ const out = [];
53946
+ let m;
53947
+ CHANGE_RE.lastIndex = 0;
53948
+ while ((m = CHANGE_RE.exec(body)) !== null) out.push({ type: m[1].toLowerCase(), description: m[2].trim() });
53949
+ return out;
53950
+ }
53951
+ var cache = /* @__PURE__ */ new Map();
53952
+ var CACHE_TTL_MS = 5 * 6e4;
53953
+ async function getJson(url, headers = {}) {
53954
+ try {
53955
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
53956
+ let json = null;
53957
+ try {
53958
+ json = await res.json();
53959
+ } catch {
53960
+ }
53961
+ return { status: res.status, json };
53962
+ } catch {
53963
+ return null;
53964
+ }
53965
+ }
53966
+ async function getAppReleaseNotes(version, deps = {}) {
53967
+ const useCache = !deps.changelogUrl;
53968
+ const key = `app:${version}`;
53969
+ if (useCache) {
53970
+ const hit = cache.get(key);
53971
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.notes;
53972
+ }
53973
+ const url = deps.changelogUrl ?? `${webBaseUrl()}/changelog.json`;
53974
+ const r = await getJson(url);
53975
+ let notes;
53976
+ if (!r || r.status >= 400 || !Array.isArray(r.json)) {
53977
+ notes = { ok: false, reason: "unavailable" };
53978
+ } else {
53979
+ const e = r.json.find(
53980
+ (x) => typeof x === "object" && x !== null && x.version === version
53981
+ );
53982
+ notes = e ? {
53983
+ ok: true,
53984
+ component: "app",
53985
+ version,
53986
+ title: String(e.title ?? `v${version}`),
53987
+ summary: String(e.description ?? ""),
53988
+ changes: Array.isArray(e.changes) ? e.changes.filter((c) => typeof c === "object" && c !== null).map((c) => ({ type: String(c.type ?? ""), description: String(c.description ?? "") })) : [],
53989
+ url: typeof e.url === "string" ? e.url : `${webBaseUrl()}/changelog#v${version}`
53990
+ } : { ok: false, reason: "not-found" };
53991
+ }
53992
+ if (useCache && (notes.ok || notes.reason === "not-found")) cache.set(key, { at: Date.now(), notes });
53993
+ return notes;
53994
+ }
53995
+ async function getAwareReleaseNotes(version, deps = {}) {
53996
+ const useCache = !deps.apiBase;
53997
+ const key = `aware:${version}`;
53998
+ if (useCache) {
53999
+ const hit = cache.get(key);
54000
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.notes;
54001
+ }
54002
+ const base = deps.apiBase ?? "https://api.github.com";
54003
+ const r = await getJson(`${base}/repos/${AWARE_REPO}/releases/tags/v${version}`, {
54004
+ Accept: "application/vnd.github+json",
54005
+ "User-Agent": "floless.app"
54006
+ });
54007
+ let notes;
54008
+ if (!r || r.status >= 400 || typeof r.json !== "object" || r.json === null) {
54009
+ notes = r && r.status === 404 ? { ok: false, reason: "not-found" } : { ok: false, reason: "unavailable" };
54010
+ } else {
54011
+ const rel = r.json;
54012
+ const body = typeof rel.body === "string" ? rel.body : "";
54013
+ const changes = parseBulletChanges(body);
54014
+ const summary = body.split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("#") && !l.startsWith("-") && !l.startsWith("*")) ?? "";
54015
+ notes = { ok: true, component: "aware", version, title: rel.name ?? `AWARE ${version}`, summary, changes, url: rel.html_url ?? `https://github.com/${AWARE_REPO}/releases/tag/v${version}` };
54016
+ }
54017
+ if (useCache && (notes.ok || notes.reason === "not-found")) cache.set(key, { at: Date.now(), notes });
54018
+ return notes;
54019
+ }
54020
+
53927
54021
  // run-summary.ts
53928
54022
  var clampMsg = (s) => {
53929
54023
  const t = s.trim();
@@ -56432,7 +56526,9 @@ async function startServer() {
56432
56526
  if (req.url.startsWith("/api/health") || req.url.startsWith("/api/license/") || req.url.startsWith("/api/bootstrap/") || req.url.startsWith("/api/autostart") || // Report-an-issue is a support lifeline: a user whose subscription lapsed must still be
56433
56527
  // able to report "I got locked out". It touches no workspace data and the relay still
56434
56528
  // requires a valid SESSION token (so it's not open abuse — see report-relay.ts).
56435
- req.url.startsWith("/api/report-issue") || req.url.startsWith("/api/update") || req.url.startsWith("/api/aware/update")) return;
56529
+ req.url.startsWith("/api/report-issue") || req.url.startsWith("/api/update") || req.url.startsWith("/api/aware/update") || // Release notes are a read-only display surface for the update pills — a local control,
56530
+ // no workspace data, never spawns `aware`. Exempt like the other update routes.
56531
+ req.url.startsWith("/api/release-notes")) return;
56436
56532
  const { state: state2, signInUrl: signInUrl2 } = await getLicenseStatus();
56437
56533
  if (state2 !== "valid" && state2 !== "offline-grace") {
56438
56534
  return reply.status(402).send({ ok: false, error: "subscription required", state: state2, signInUrl: signInUrl2 });
@@ -56451,6 +56547,8 @@ async function startServer() {
56451
56547
  ok: true,
56452
56548
  appVersion: appVersion(),
56453
56549
  // the installed build (sq.version), so it's scriptable
56550
+ webBase: webBaseUrl(),
56551
+ // channel-correct public site base, for the changelog deep-link
56454
56552
  awareVersion: awareInstalledVersion() ?? bs.awareVersion,
56455
56553
  // fresh (TTL-cached) install version; the boot snapshot is only a fallback
56456
56554
  awareReady: bs.awareReady,
@@ -56540,6 +56638,13 @@ async function startServer() {
56540
56638
  awareInstallInFlight = false;
56541
56639
  }
56542
56640
  });
56641
+ app.get("/api/release-notes", async (req, reply) => {
56642
+ const component = req.query.component === "aware" ? "aware" : "app";
56643
+ const version = typeof req.query.version === "string" ? req.query.version.trim().replace(/^v/, "") : "";
56644
+ if (!/^\d+\.\d+\.\d+(?:[-+][\w.]+)?$/.test(version)) return reply.status(400).send({ ok: false, reason: "unavailable" });
56645
+ const notes = component === "aware" ? await getAwareReleaseNotes(version) : await getAppReleaseNotes(version);
56646
+ return notes;
56647
+ });
56543
56648
  app.post("/api/bootstrap/retry", async () => {
56544
56649
  const st = getBootstrapState().status;
56545
56650
  if (st === "failed" || st === "idle") {
package/dist/web/app.css CHANGED
@@ -2265,3 +2265,221 @@ body {
2265
2265
  .req-thumb { width: 46px; height: 46px; padding: 0; border: 1px solid var(--border-strong); border-radius: 4px; background: var(--bg); overflow: hidden; cursor: pointer; flex-shrink: 0; transition: border-color 0.15s; }
2266
2266
  .req-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
2267
2267
  .req-thumb:hover { border-color: var(--accent-dim); }
2268
+
2269
+ /* ============================================================================
2270
+ * Release notes — the click-to-open popover on the footer update pills, and the
2271
+ * relaunch-surviving "what's new" panel after a floless.app self-update.
2272
+ * Baseline tokens only (shadcn dark slate-blue): no new fonts/colours/aesthetics.
2273
+ * The popover's upward shadow IS the elevation cue (no decorative arrow); change
2274
+ * types are typeset, never coloured badges (only `.added` borrows --accent).
2275
+ * ==========================================================================*/
2276
+
2277
+ /* ── Popover (shared by #app-update and #aware-update) ───────────────────── */
2278
+ .relnotes-popover {
2279
+ position: fixed;
2280
+ width: 320px;
2281
+ background: var(--surface);
2282
+ border: 1px solid var(--border-strong);
2283
+ border-radius: 8px;
2284
+ box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.55), 0 0 0 1px var(--border-strong);
2285
+ z-index: 1000;
2286
+ color: var(--text);
2287
+ animation: relnotes-in 0.14s ease-out;
2288
+ }
2289
+ .relnotes-popover[hidden] { display: none; }
2290
+ @keyframes relnotes-in {
2291
+ from { opacity: 0; transform: translateY(6px); }
2292
+ to { opacity: 1; transform: translateY(0); }
2293
+ }
2294
+ .relnotes-header { padding: 16px 16px 0; }
2295
+ .relnotes-title { font-size: 14px; font-weight: 700; color: var(--text); line-height: 1.3; }
2296
+ .relnotes-summary { font-size: 12px; color: var(--text-muted); margin-top: 4px; line-height: 1.45; }
2297
+ .relnotes-body {
2298
+ padding: 12px 16px;
2299
+ max-height: 260px;
2300
+ overflow-y: auto;
2301
+ border-top: 1px solid var(--border);
2302
+ border-bottom: 1px solid var(--border);
2303
+ }
2304
+ .relnotes-actions { padding: 12px 16px; display: flex; gap: 8px; align-items: center; }
2305
+
2306
+ /* Typed change bullets — REUSED verbatim by the what's-new panel (one renderer). */
2307
+ .relnotes-group { margin-bottom: 12px; }
2308
+ .relnotes-group:last-child { margin-bottom: 0; }
2309
+ .relnotes-type {
2310
+ font-family: var(--mono);
2311
+ font-size: 9px;
2312
+ text-transform: uppercase;
2313
+ letter-spacing: 0.16em;
2314
+ font-weight: 600;
2315
+ color: var(--text-dim);
2316
+ margin-bottom: 5px;
2317
+ }
2318
+ .relnotes-type.added { color: var(--accent); }
2319
+ .relnotes-list { list-style: none; display: flex; flex-direction: column; gap: 3px; }
2320
+ .relnotes-item {
2321
+ position: relative;
2322
+ font-size: 12px;
2323
+ color: var(--text-muted);
2324
+ line-height: 1.45;
2325
+ padding-left: 12px;
2326
+ }
2327
+ .relnotes-item::before { content: "–"; position: absolute; left: 0; color: var(--text-dim); }
2328
+
2329
+ /* Skeleton — renders instantly while notes load; the action row is live beneath it. */
2330
+ .relnotes-skel { display: flex; flex-direction: column; gap: 8px; }
2331
+ .relnotes-skel-line {
2332
+ height: 11px;
2333
+ border-radius: 3px;
2334
+ background: var(--surface-2);
2335
+ position: relative;
2336
+ overflow: hidden;
2337
+ }
2338
+ .relnotes-skel-line:nth-child(2) { width: 85%; }
2339
+ .relnotes-skel-line:nth-child(3) { width: 70%; }
2340
+ .relnotes-skel-line::after {
2341
+ content: "";
2342
+ position: absolute;
2343
+ inset: 0;
2344
+ transform: translateX(-100%);
2345
+ background: linear-gradient(90deg, transparent, var(--surface-3), transparent);
2346
+ animation: relnotes-shimmer 1.3s ease-in-out infinite;
2347
+ }
2348
+ @keyframes relnotes-shimmer { to { transform: translateX(100%); } }
2349
+ .relnotes-unavailable { font-size: 12px; color: var(--text-dim); font-style: italic; }
2350
+
2351
+ /* Action row — Update now reuses the modal .primary recipe; the changelog link is a ghost. */
2352
+ .relnotes-update {
2353
+ background: var(--accent);
2354
+ color: #fff;
2355
+ border: 1px solid var(--accent);
2356
+ font-weight: 600;
2357
+ padding: 7px 14px;
2358
+ border-radius: 4px;
2359
+ font-size: 12px;
2360
+ cursor: pointer;
2361
+ transition: background 0.15s, box-shadow 0.15s;
2362
+ }
2363
+ .relnotes-update:hover { background: var(--accent-bright); box-shadow: 0 0 14px var(--accent-glow); }
2364
+ .relnotes-update[disabled] { opacity: 0.6; cursor: default; }
2365
+ .relnotes-update[disabled]:hover { background: var(--accent); box-shadow: none; }
2366
+ .relnotes-changelog {
2367
+ margin-left: auto;
2368
+ background: transparent;
2369
+ border: 1px solid transparent;
2370
+ color: var(--text-dim);
2371
+ padding: 7px 10px;
2372
+ border-radius: 4px;
2373
+ font-size: 12px;
2374
+ text-decoration: none;
2375
+ cursor: pointer;
2376
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
2377
+ }
2378
+ .relnotes-changelog:hover { background: var(--surface-2); border-color: var(--border-strong); color: var(--text-muted); }
2379
+ .relnotes-update:focus-visible,
2380
+ .relnotes-changelog:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2381
+
2382
+ /* The AWARE version label becomes a notes affordance after an in-place upgrade. */
2383
+ .aware-version.relnotes-reopen { cursor: pointer; }
2384
+ .aware-version.relnotes-reopen:hover { color: var(--accent-bright); }
2385
+
2386
+ /* ── What's-new panel (floless.app post-update) ─────────────────────────────
2387
+ A 4th grid row above the footer — NOT absolutely positioned. .has-whats-new
2388
+ inserts an `auto` row + a full-width `whats-new` area between the body and footer. */
2389
+ .app.has-whats-new {
2390
+ grid-template-rows: 60px 1fr auto 44px;
2391
+ grid-template-areas:
2392
+ "header header header"
2393
+ "chat canvas inspect"
2394
+ "whats-new whats-new whats-new"
2395
+ "footer footer footer";
2396
+ }
2397
+ .whats-new {
2398
+ grid-area: whats-new;
2399
+ background: var(--surface);
2400
+ border-top: 1px solid var(--border);
2401
+ border-bottom: 1px solid var(--border);
2402
+ border-left: 3px solid var(--accent);
2403
+ font-size: 12px;
2404
+ padding: 0 18px;
2405
+ overflow: hidden;
2406
+ }
2407
+ .whats-new[hidden] { display: none; }
2408
+ .whats-new-summary { display: flex; align-items: center; gap: 10px; height: 44px; }
2409
+ .whats-new-label {
2410
+ font-family: var(--mono);
2411
+ font-size: 10px;
2412
+ text-transform: uppercase;
2413
+ letter-spacing: 0.14em;
2414
+ color: var(--accent);
2415
+ font-weight: 600;
2416
+ flex-shrink: 0;
2417
+ }
2418
+ .whats-new-summary-text {
2419
+ font-size: 12px;
2420
+ color: var(--text-muted);
2421
+ flex: 1;
2422
+ overflow: hidden;
2423
+ text-overflow: ellipsis;
2424
+ white-space: nowrap;
2425
+ }
2426
+ .whats-new-toggle {
2427
+ flex-shrink: 0;
2428
+ background: transparent;
2429
+ border: 1px solid var(--border-strong);
2430
+ color: var(--text-muted);
2431
+ padding: 4px 10px;
2432
+ border-radius: 4px;
2433
+ font-size: 11px;
2434
+ cursor: pointer;
2435
+ transition: border-color 0.15s, color 0.15s, background 0.15s;
2436
+ }
2437
+ .whats-new-toggle:hover { color: var(--accent); border-color: var(--accent-dim); }
2438
+ .whats-new-link {
2439
+ flex-shrink: 0;
2440
+ color: var(--text-dim);
2441
+ font-size: 11px;
2442
+ text-decoration: none;
2443
+ padding: 4px 6px;
2444
+ border-radius: 4px;
2445
+ transition: color 0.15s;
2446
+ }
2447
+ .whats-new-link:hover { color: var(--accent-bright); }
2448
+ .whats-new-dismiss {
2449
+ flex-shrink: 0;
2450
+ width: 26px;
2451
+ height: 26px;
2452
+ display: inline-flex;
2453
+ align-items: center;
2454
+ justify-content: center;
2455
+ padding: 0;
2456
+ background: transparent;
2457
+ border: 1px solid transparent;
2458
+ border-radius: 4px;
2459
+ color: var(--text-dim);
2460
+ font-size: 12px;
2461
+ line-height: 1;
2462
+ cursor: pointer;
2463
+ transition: color 0.15s, border-color 0.15s, background 0.15s;
2464
+ }
2465
+ .whats-new-dismiss:hover { color: var(--text); border-color: var(--border-strong); background: var(--surface-2); }
2466
+ .whats-new-toggle:focus-visible,
2467
+ .whats-new-link:focus-visible,
2468
+ .whats-new-dismiss:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2469
+ .whats-new-detail {
2470
+ display: grid;
2471
+ grid-template-rows: 0fr;
2472
+ transition: grid-template-rows 0.18s ease-out;
2473
+ overflow: hidden;
2474
+ }
2475
+ .whats-new-detail.open { grid-template-rows: 1fr; }
2476
+ .whats-new-detail-inner { overflow: hidden; padding-bottom: 14px; }
2477
+
2478
+ /* Reduced motion — kill the popover entry, the skeleton shimmer, and the
2479
+ what's-new expand transition; the surfaces still read via their static styling.
2480
+ Mirrors the .rtn-fired-flash / node-running reduced-motion precedents above. */
2481
+ @media (prefers-reduced-motion: reduce) {
2482
+ .relnotes-popover { animation: none; }
2483
+ .relnotes-skel-line::after { animation: none; }
2484
+ .whats-new-detail { transition: none; }
2485
+ }
package/dist/web/aware.js CHANGED
@@ -1585,11 +1585,194 @@
1585
1585
  _handleMenuAction(action);
1586
1586
  };
1587
1587
 
1588
+ // ── Release notes: shared state + popover ─────────────────────────────────────
1589
+ // The channel-correct public site base (e.g. https://floless.io), captured from
1590
+ // /api/health in the health poll. The changelog deep-link is omitted entirely when
1591
+ // this is empty — never hardcode floless.io (it would be wrong on stage/dev).
1592
+ let webBase = '';
1593
+ // The what's-new panel reveals at most once per session (guard against the 5s
1594
+ // health poll re-triggering it after the user expands/dismisses).
1595
+ let whatsNewShown = false;
1596
+
1597
+ const $notesPopover = document.getElementById('notes-popover');
1598
+
1599
+ // ONE renderer for typed change bullets — used by BOTH the popover body and the
1600
+ // what's-new panel detail. Groups bullets by change type (first-seen order); each
1601
+ // group is a typeset label + a bullet list. Only `.added` borrows --accent (CSS).
1602
+ function renderTypedChanges(container, changes) {
1603
+ if (!container) return;
1604
+ const groups = [];
1605
+ const byType = new Map();
1606
+ for (const c of (Array.isArray(changes) ? changes : [])) {
1607
+ const type = String((c && c.type) || '').trim() || 'changed';
1608
+ const desc = String((c && c.description) || '').trim();
1609
+ if (!desc) continue;
1610
+ if (!byType.has(type)) { byType.set(type, []); groups.push(type); }
1611
+ byType.get(type).push(desc);
1612
+ }
1613
+ if (!groups.length) { container.innerHTML = '<p class="relnotes-unavailable">No itemised changes</p>'; return; }
1614
+ container.innerHTML = groups.map((type) => {
1615
+ const items = byType.get(type).map((d) => `<li class="relnotes-item">${escapeHtml(d)}</li>`).join('');
1616
+ return `<div class="relnotes-group">`
1617
+ + `<div class="relnotes-type ${escapeAttr(type)}">${escapeHtml(type)}</div>`
1618
+ + `<ul class="relnotes-list">${items}</ul></div>`;
1619
+ }).join('');
1620
+ }
1621
+
1622
+ // Open the shared release-notes popover for an update pill (or the AWARE version
1623
+ // label). The action row — Update now + the changelog deep-link — renders FIRST,
1624
+ // so "Update now" is live before (and regardless of whether) the notes arrive.
1625
+ // `onUpdate` is optional: the post-AWARE-upgrade label reopen passes none (read-only).
1626
+ let _notesEscHandler = null, _notesOutsideHandler = null, _notesOriginEl = null;
1627
+ function closeNotesPopover() {
1628
+ if (!$notesPopover || $notesPopover.hidden) return;
1629
+ $notesPopover.hidden = true;
1630
+ $notesPopover.innerHTML = '';
1631
+ if (_notesEscHandler) { document.removeEventListener('keydown', _notesEscHandler, true); _notesEscHandler = null; }
1632
+ if (_notesOutsideHandler) { document.removeEventListener('mousedown', _notesOutsideHandler, true); _notesOutsideHandler = null; }
1633
+ const origin = _notesOriginEl; _notesOriginEl = null;
1634
+ if (origin) {
1635
+ origin.setAttribute('aria-expanded', 'false');
1636
+ origin.removeAttribute('aria-controls');
1637
+ try { origin.focus(); } catch { /* origin may have hidden (pill → relaunch) */ }
1638
+ }
1639
+ }
1640
+ function openNotesPopover({ component, version, anchorEl, onUpdate }) {
1641
+ if (!$notesPopover || !version) return;
1642
+ // Clean up a prior open instance's document listeners (reopening from a different
1643
+ // pill) without the focus-restore — the new anchor takes over below.
1644
+ if (_notesEscHandler) { document.removeEventListener('keydown', _notesEscHandler, true); _notesEscHandler = null; }
1645
+ if (_notesOutsideHandler) { document.removeEventListener('mousedown', _notesOutsideHandler, true); _notesOutsideHandler = null; }
1646
+ if (_notesOriginEl && _notesOriginEl !== anchorEl) { _notesOriginEl.setAttribute('aria-expanded', 'false'); _notesOriginEl.removeAttribute('aria-controls'); }
1647
+ const compLabel = component === 'aware' ? 'AWARE' : 'floless.app';
1648
+ $notesPopover.setAttribute('aria-label', compLabel + ' v' + version + ' release notes');
1649
+
1650
+ // Build the "full notes" link, component-aware:
1651
+ // - app → the floless.io changelog (floless.app-only by design). Omit when webBase is empty.
1652
+ // - aware → the PUBLIC AWARE GitHub release. A stable URL built straight from `version`
1653
+ // (no webBase, no dependency on the notes fetch) — AWARE is NOT on floless.io.
1654
+ const changelogHtml = component === 'aware'
1655
+ ? `<a class="relnotes-changelog" target="_blank" rel="noopener" href="${escapeAttr('https://github.com/aware-aeco/aware/releases/tag/v' + version)}">Full release notes ↗</a>`
1656
+ : (webBase
1657
+ ? `<a class="relnotes-changelog" target="_blank" rel="noopener" href="${escapeAttr(webBase + '/changelog#v' + version)}">Full changelog ↗</a>`
1658
+ : '');
1659
+ const updateHtml = onUpdate ? `<button type="button" class="relnotes-update">Update now</button>` : '';
1660
+
1661
+ // Render the shell: header (skeleton title), body (skeleton bars), action row (LIVE).
1662
+ $notesPopover.innerHTML =
1663
+ `<div class="relnotes-header"><div class="relnotes-title">${compLabel} v${escapeHtml(version)}</div></div>`
1664
+ + `<div class="relnotes-body" aria-busy="true">`
1665
+ + `<div class="relnotes-skel"><div class="relnotes-skel-line"></div><div class="relnotes-skel-line"></div><div class="relnotes-skel-line"></div></div>`
1666
+ + `</div>`
1667
+ + `<div class="relnotes-actions">${updateHtml}${changelogHtml}</div>`;
1668
+ $notesPopover.hidden = false;
1669
+
1670
+ // Wire Update now — it never waits on the notes fetch.
1671
+ const $update = $notesPopover.querySelector('.relnotes-update');
1672
+ if ($update && onUpdate) {
1673
+ $update.onclick = () => { closeNotesPopover(); onUpdate(version); };
1674
+ }
1675
+
1676
+ // Position relative to the anchor: hang above it (footer is at the bottom), and
1677
+ // clamp the right edge so a wide pill near the left can't push it off-screen.
1678
+ if (anchorEl) {
1679
+ const rect = anchorEl.getBoundingClientRect();
1680
+ $notesPopover.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
1681
+ $notesPopover.style.right = Math.max(8, window.innerWidth - rect.right) + 'px';
1682
+ }
1683
+
1684
+ // a11y: mark the originating control expanded; move focus into the dialog.
1685
+ _notesOriginEl = anchorEl || null;
1686
+ if (anchorEl) { anchorEl.setAttribute('aria-expanded', 'true'); anchorEl.setAttribute('aria-controls', 'notes-popover'); }
1687
+ try { $notesPopover.focus(); } catch { /* container is tabindex=-1 */ }
1688
+
1689
+ // Tab cycles only the (up to 2) action controls; Escape closes + restores focus.
1690
+ const focusables = () => Array.from($notesPopover.querySelectorAll('.relnotes-update, .relnotes-changelog'));
1691
+ _notesEscHandler = (e) => {
1692
+ if (e.key === 'Escape') { e.preventDefault(); closeNotesPopover(); return; }
1693
+ if (e.key === 'Tab') {
1694
+ const f = focusables();
1695
+ if (!f.length) return;
1696
+ const first = f[0], last = f[f.length - 1];
1697
+ const active = document.activeElement;
1698
+ if (e.shiftKey && (active === first || active === $notesPopover)) { e.preventDefault(); last.focus(); }
1699
+ else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
1700
+ }
1701
+ };
1702
+ document.addEventListener('keydown', _notesEscHandler, true);
1703
+ // Click outside closes — but ignore the opening click on the anchor itself.
1704
+ _notesOutsideHandler = (e) => {
1705
+ if ($notesPopover.contains(e.target) || (anchorEl && anchorEl.contains(e.target))) return;
1706
+ closeNotesPopover();
1707
+ };
1708
+ document.addEventListener('mousedown', _notesOutsideHandler, true);
1709
+
1710
+ // Fetch the notes and fill the body (or the unavailable state). The action row
1711
+ // is untouched — Update now and the changelog link stay live throughout.
1712
+ (async () => {
1713
+ const $body = $notesPopover.querySelector('.relnotes-body');
1714
+ let notes = null;
1715
+ try {
1716
+ const r = await fetch('/api/release-notes?component=' + encodeURIComponent(component) + '&version=' + encodeURIComponent(version));
1717
+ notes = await r.json();
1718
+ } catch { notes = null; }
1719
+ // The popover may have been closed/reopened while the fetch was in flight.
1720
+ if ($notesPopover.hidden || !$body || !$body.isConnected) return;
1721
+ $body.removeAttribute('aria-busy');
1722
+ if (notes && notes.ok) {
1723
+ const title = $notesPopover.querySelector('.relnotes-title');
1724
+ if (title && notes.title) title.textContent = notes.title;
1725
+ const summaryHtml = notes.summary ? `<div class="relnotes-summary">${escapeHtml(notes.summary)}</div>` : '';
1726
+ const header = $notesPopover.querySelector('.relnotes-header');
1727
+ if (header && summaryHtml && !header.querySelector('.relnotes-summary')) header.insertAdjacentHTML('beforeend', summaryHtml);
1728
+ $body.innerHTML = '<div class="relnotes-changes"></div>';
1729
+ renderTypedChanges($body.querySelector('.relnotes-changes'), notes.changes);
1730
+ } else {
1731
+ $body.innerHTML = '<p class="relnotes-unavailable">Release notes unavailable</p>';
1732
+ }
1733
+ })();
1734
+ }
1735
+
1588
1736
  // ── App self-update tag ───────────────────────────────────────────────────────
1589
1737
  // Surface updater.ts: poll GET /api/update (on load + every 6h); when a newer
1590
- // installed build exists, show a footer tagconfirm POST /api/update/apply,
1591
- // which downloads + relaunches into the new version. Hidden in dev/npm (supported:false).
1738
+ // installed build exists, show a footer pillclick opens the release-notes
1739
+ // popover Update now POST /api/update/apply, which downloads + relaunches into
1740
+ // the new version. Hidden in dev/npm (supported:false).
1592
1741
  const $appUpdate = document.getElementById('app-update');
1742
+ // The apply body, extracted so the popover's "Update now" can drive it with the
1743
+ // polled target version (no textContent scrape). Preserves the npm-vs-desktop
1744
+ // branch verbatim: npm copies the command; desktop confirms → relaunch.
1745
+ async function applyAppUpdate(version) {
1746
+ if (!$appUpdate) return;
1747
+ // npm channel can't self-apply (no Update.exe) — copy the command for the user's terminal.
1748
+ if ($appUpdate.dataset.channel === 'npm') {
1749
+ const cmd = $appUpdate.dataset.command || 'npm i -g @floless/app@latest';
1750
+ // Best-effort copy — fire-and-forget so the toast never blocks on clipboard permission.
1751
+ if (navigator.clipboard) navigator.clipboard.writeText(cmd).catch(() => {});
1752
+ showToast('Update in your terminal — ' + cmd, 'info');
1753
+ return;
1754
+ }
1755
+ // desktop (Velopack): one-click download + relaunch
1756
+ const v = version || '';
1757
+ if (!window.confirm('Download v' + v + ' and relaunch floless.app now?')) return;
1758
+ // Record the pending version so the new build's first paint can reveal the
1759
+ // what's-new panel (survives the relaunch via localStorage; cleared once shown).
1760
+ try { localStorage.setItem('floless.whatsNew.pending', v); } catch { /* private mode */ }
1761
+ $appUpdate.disabled = true;
1762
+ $appUpdate.textContent = '↑ Updating…';
1763
+ try {
1764
+ const r = await fetch('/api/update/apply', { method: 'POST', headers: { 'content-type': 'application/json' } });
1765
+ const d = await r.json().catch(() => ({}));
1766
+ if (!r.ok || !d.ok) throw new Error(d.error || 'update failed');
1767
+ // Success: the server is exiting + Update.exe relaunches the new build. The health
1768
+ // poll flips to offline (R1 overlay), then the new version reconnects on its own.
1769
+ } catch (e) {
1770
+ $appUpdate.disabled = false;
1771
+ try { localStorage.removeItem('floless.whatsNew.pending'); } catch { /* private mode */ }
1772
+ showToast('Update failed — ' + String((e && e.message) || e).slice(0, 80), 'warn');
1773
+ refreshUpdate();
1774
+ }
1775
+ }
1593
1776
  async function refreshUpdate() {
1594
1777
  if (!$appUpdate) return;
1595
1778
  try {
@@ -1600,57 +1783,99 @@
1600
1783
  $appUpdate.textContent = '↑ Update to v' + d.targetVersion;
1601
1784
  $appUpdate.dataset.channel = d.channel || 'desktop';
1602
1785
  $appUpdate.dataset.command = (npm && d.command) ? d.command : '';
1603
- $appUpdate.dataset.tip = npm
1604
- ? 'A newer floless.app (v' + d.targetVersion + ') is on npm — click to copy the update command'
1605
- : 'A newer floless.app is available — click to download and relaunch into v' + d.targetVersion;
1786
+ $appUpdate.dataset.target = d.targetVersion;
1787
+ $appUpdate.dataset.tip = "What's new in v" + d.targetVersion;
1788
+ $appUpdate.setAttribute('aria-haspopup', 'dialog');
1606
1789
  $appUpdate.hidden = false;
1607
1790
  } else {
1608
- $appUpdate.hidden = true; // up-to-date / dev / registry-or-feed error no tag
1791
+ // Dev-override (ships inert): an E2E can force the pill visible deterministically
1792
+ // by setting localStorage['floless.dev.forceUpdate'] = {"component":"app","version":"x.y.z"}.
1793
+ // Read on every poll; absent by default → no behaviour change.
1794
+ if (!applyDevForcedUpdate('app', $appUpdate)) $appUpdate.hidden = true; // up-to-date / dev / registry-or-feed error → no tag
1609
1795
  }
1610
- } catch { $appUpdate.hidden = true; }
1796
+ } catch {
1797
+ if (!applyDevForcedUpdate('app', $appUpdate)) $appUpdate.hidden = true;
1798
+ }
1611
1799
  }
1612
1800
  if ($appUpdate) {
1613
- $appUpdate.onclick = async () => {
1614
- // npm channel can't self-apply (no Update.exe) copy the command for the user's terminal.
1615
- if ($appUpdate.dataset.channel === 'npm') {
1616
- const cmd = $appUpdate.dataset.command || 'npm i -g @floless/app@latest';
1617
- // Best-effort copy — fire-and-forget so the toast never blocks on clipboard permission.
1618
- if (navigator.clipboard) navigator.clipboard.writeText(cmd).catch(() => {});
1619
- showToast('Update in your terminal — ' + cmd, 'info');
1620
- return;
1621
- }
1622
- // desktop (Velopack): one-click download + relaunch
1623
- const v = $appUpdate.textContent.replace(/^[^0-9]*/, ''); // "↑ Update to v0.5.2" → "0.5.2"
1624
- if (!window.confirm('Download v' + v + ' and relaunch floless.app now?')) return;
1625
- $appUpdate.disabled = true;
1626
- $appUpdate.textContent = '↑ Updating…';
1627
- try {
1628
- const r = await fetch('/api/update/apply', { method: 'POST', headers: { 'content-type': 'application/json' } });
1629
- const d = await r.json().catch(() => ({}));
1630
- if (!r.ok || !d.ok) throw new Error(d.error || 'update failed');
1631
- // Success: the server is exiting + Update.exe relaunches the new build. The health
1632
- // poll flips to offline (R1 overlay), then the new version reconnects on its own.
1633
- } catch (e) {
1634
- $appUpdate.disabled = false;
1635
- showToast('Update failed — ' + String((e && e.message) || e).slice(0, 80), 'warn');
1636
- refreshUpdate();
1637
- }
1801
+ $appUpdate.onclick = () => {
1802
+ const version = $appUpdate.dataset.target || '';
1803
+ openNotesPopover({ component: 'app', version, anchorEl: $appUpdate, onUpdate: applyAppUpdate });
1638
1804
  };
1639
1805
  refreshUpdate();
1640
1806
  setInterval(refreshUpdate, 6 * 60 * 60 * 1000); // re-check every 6h so a long-running window notices
1641
1807
  }
1642
1808
 
1809
+ // Dev-override helper — the ONLY forced-visible path. Reveals a pill with a fixed
1810
+ // version when localStorage['floless.dev.forceUpdate'] matches the component, so an
1811
+ // E2E can exercise the popover without a real pending update. Returns true if forced.
1812
+ function applyDevForcedUpdate(component, pillEl) {
1813
+ if (!pillEl) return false;
1814
+ let forced = null;
1815
+ try { forced = JSON.parse(localStorage.getItem('floless.dev.forceUpdate') || 'null'); } catch { forced = null; }
1816
+ if (!forced || forced.component !== component || !forced.version) return false;
1817
+ const v = String(forced.version);
1818
+ pillEl.dataset.target = v;
1819
+ pillEl.dataset.tip = "What's new in v" + v;
1820
+ pillEl.setAttribute('aria-haspopup', 'dialog');
1821
+ if (component === 'aware') { pillEl.textContent = '↑ Upgrade AWARE to v' + v; }
1822
+ else { pillEl.textContent = '↑ Update to v' + v; pillEl.dataset.channel = pillEl.dataset.channel || 'desktop'; }
1823
+ pillEl.hidden = false;
1824
+ return true;
1825
+ }
1826
+
1643
1827
  // ── AWARE runtime upgrade tag ─────────────────────────────────────────────────
1644
1828
  // Surface aware-update.ts: poll GET /api/aware/update (on load + every 6h); when a
1645
- // newer @aware-aeco/cli exists, show a footer pill → confirm POST apply, which
1646
- // reinstalls AWARE in place (no relaunch — the app stays open) and re-stamps the
1647
- // version live. Mirrors the app self-update pill; differs in that success is silent
1648
- // (the live version re-stamp IS the confirmation) and there is no relaunch.
1829
+ // newer @aware-aeco/cli exists, show a footer pill → click opens the release-notes
1830
+ // popover → Update now → POST apply, which reinstalls AWARE in place (no relaunch —
1831
+ // the app stays open) and re-stamps the version live. Mirrors the app self-update
1832
+ // pill; differs in that success is silent (the live version re-stamp IS the
1833
+ // confirmation) and there is no relaunch.
1649
1834
  const $awareUpdate = document.getElementById('aware-update');
1650
1835
  // After a successful upgrade, briefly trust the upgraded version: a /api/health request that
1651
1836
  // was in flight before the reinstall finished could resolve with the OLD version and momentarily
1652
1837
  // revert the footer. The live re-stamp (health poll) honors this short settle window.
1653
1838
  let awareUpgradeFloor = null, awareUpgradeFloorUntil = 0;
1839
+ // Extracted apply body — driven by the popover's "Update now" with the polled
1840
+ // target version. Preserves the awareUpgradeFloor settle window + the live
1841
+ // #aware-version re-stamp verbatim; additionally makes the version label a notes
1842
+ // affordance and shows a brief "installed" confirmation before the pill hides.
1843
+ async function applyAwareUpdate(version) {
1844
+ if (!$awareUpdate) return;
1845
+ const v = version || '';
1846
+ if (!window.confirm('Upgrade AWARE runtime to v' + v + '? This reinstalls the npm package in place — the app stays open and the version restamps automatically.')) return;
1847
+ $awareUpdate.disabled = true;
1848
+ $awareUpdate.textContent = '↑ Upgrading…';
1849
+ try {
1850
+ // Cap the wait so the pill never sticks disabled if the global npm install wedges.
1851
+ const r = await fetch('/api/aware/update/apply', { method: 'POST', headers: { 'content-type': 'application/json' }, signal: AbortSignal.timeout(120000) });
1852
+ const d = await r.json().catch(() => ({}));
1853
+ if (!r.ok || !d.ok) throw new Error(d.error || 'aware upgrade failed');
1854
+ // Success is silent: the pill disappears and the AWARE version re-stamps live.
1855
+ $awareUpdate.disabled = false;
1856
+ const wv = document.getElementById('aware-version');
1857
+ if (wv && d.version) {
1858
+ wv.textContent = 'AWARE ' + d.version;
1859
+ awareUpgradeFloor = d.version; awareUpgradeFloorUntil = Date.now() + 10000;
1860
+ // The version label becomes a notes affordance — click to reopen what changed.
1861
+ wv.classList.add('relnotes-reopen');
1862
+ wv.setAttribute('role', 'button');
1863
+ wv.setAttribute('tabindex', '0');
1864
+ wv.dataset.tip = "What's new in v" + d.version + ' — click for notes';
1865
+ wv.dataset.notesVersion = d.version;
1866
+ }
1867
+ // Brief confirmation (~2s) before the pill hides.
1868
+ $awareUpdate.textContent = 'AWARE v' + (d.version || v) + ' installed';
1869
+ setTimeout(() => { $awareUpdate.hidden = true; refreshAwareUpdate(); }, 2000);
1870
+ } catch (e) {
1871
+ $awareUpdate.disabled = false;
1872
+ const msg = (e && e.name === 'TimeoutError')
1873
+ ? 'AWARE upgrade is taking a while — it may still be installing; the version updates when it finishes'
1874
+ : 'AWARE upgrade failed — ' + String((e && e.message) || e).slice(0, 80);
1875
+ showToast(msg, 'warn');
1876
+ refreshAwareUpdate(); // restore the pill text for retry
1877
+ }
1878
+ }
1654
1879
  async function refreshAwareUpdate() {
1655
1880
  if (!$awareUpdate) return;
1656
1881
  try {
@@ -1659,42 +1884,109 @@
1659
1884
  if (r.ok && d.updateAvailable && d.targetVersion) {
1660
1885
  $awareUpdate.textContent = '↑ Upgrade AWARE to v' + d.targetVersion;
1661
1886
  $awareUpdate.dataset.target = d.targetVersion;
1662
- $awareUpdate.dataset.tip = 'A newer AWARE runtime (v' + d.targetVersion + ') is available — click to install it in place (no relaunch)';
1887
+ $awareUpdate.dataset.tip = "What's new in v" + d.targetVersion;
1888
+ $awareUpdate.setAttribute('aria-haspopup', 'dialog');
1663
1889
  $awareUpdate.hidden = false;
1664
1890
  } else {
1665
- $awareUpdate.hidden = true; // up-to-date / absent / registry error no pill
1891
+ // Dev-override (ships inert) see applyDevForcedUpdate. Read on every poll.
1892
+ if (!applyDevForcedUpdate('aware', $awareUpdate)) $awareUpdate.hidden = true; // up-to-date / absent / registry error → no pill
1666
1893
  }
1667
- } catch { $awareUpdate.hidden = true; }
1894
+ } catch {
1895
+ if (!applyDevForcedUpdate('aware', $awareUpdate)) $awareUpdate.hidden = true;
1896
+ }
1668
1897
  }
1669
1898
  if ($awareUpdate) {
1670
- $awareUpdate.onclick = async () => {
1671
- const v = $awareUpdate.dataset.target || '';
1672
- if (!window.confirm('Upgrade AWARE runtime to v' + v + '? This reinstalls the npm package in place — the app stays open and the version restamps automatically.')) return;
1673
- $awareUpdate.disabled = true;
1674
- $awareUpdate.textContent = '↑ Upgrading…';
1675
- try {
1676
- // Cap the wait so the pill never sticks disabled if the global npm install wedges.
1677
- const r = await fetch('/api/aware/update/apply', { method: 'POST', headers: { 'content-type': 'application/json' }, signal: AbortSignal.timeout(120000) });
1678
- const d = await r.json().catch(() => ({}));
1679
- if (!r.ok || !d.ok) throw new Error(d.error || 'aware upgrade failed');
1680
- // Success is silent: the pill disappears and the AWARE version re-stamps live.
1681
- $awareUpdate.hidden = true;
1682
- $awareUpdate.disabled = false;
1683
- const wv = document.getElementById('aware-version');
1684
- if (wv && d.version) { wv.textContent = 'AWARE ' + d.version; awareUpgradeFloor = d.version; awareUpgradeFloorUntil = Date.now() + 10000; }
1685
- } catch (e) {
1686
- $awareUpdate.disabled = false;
1687
- const msg = (e && e.name === 'TimeoutError')
1688
- ? 'AWARE upgrade is taking a while — it may still be installing; the version updates when it finishes'
1689
- : 'AWARE upgrade failed — ' + String((e && e.message) || e).slice(0, 80);
1690
- showToast(msg, 'warn');
1691
- refreshAwareUpdate(); // restore the pill text for retry
1692
- }
1899
+ $awareUpdate.onclick = () => {
1900
+ const version = $awareUpdate.dataset.target || '';
1901
+ openNotesPopover({ component: 'aware', version, anchorEl: $awareUpdate, onUpdate: applyAwareUpdate });
1693
1902
  };
1694
1903
  refreshAwareUpdate();
1695
1904
  setInterval(refreshAwareUpdate, 6 * 60 * 60 * 1000); // re-check every 6h
1696
1905
  }
1697
1906
 
1907
+ // The AWARE version label, once it's a notes affordance (post in-place upgrade),
1908
+ // reopens the popover on click or Enter/Space.
1909
+ {
1910
+ const $awareVersion = document.getElementById('aware-version');
1911
+ if ($awareVersion) {
1912
+ const reopen = () => {
1913
+ const v = $awareVersion.dataset.notesVersion;
1914
+ if (v) openNotesPopover({ component: 'aware', version: v, anchorEl: $awareVersion });
1915
+ };
1916
+ $awareVersion.addEventListener('click', () => { if ($awareVersion.classList.contains('relnotes-reopen')) reopen(); });
1917
+ $awareVersion.addEventListener('keydown', (e) => {
1918
+ if (!$awareVersion.classList.contains('relnotes-reopen')) return;
1919
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); reopen(); }
1920
+ });
1921
+ }
1922
+ }
1923
+
1924
+ // ── What's-new panel (floless.app post-update, relaunch-surviving) ────────────
1925
+ // Called from the health poll AFTER #app-version is stamped (so the version
1926
+ // compare has a real value). Reveals once: if a pending self-update version was
1927
+ // recorded before the relaunch AND the now-running build matches it AND the user
1928
+ // hasn't already dismissed that version, fetch its notes and show the panel.
1929
+ function maybeShowWhatsNew() {
1930
+ if (whatsNewShown) return;
1931
+ let pending, seen;
1932
+ try { pending = localStorage.getItem('floless.whatsNew.pending'); seen = localStorage.getItem('floless.whatsNew.seen'); } catch { return; }
1933
+ if (!pending) return;
1934
+ const appVer = (document.getElementById('app-version')?.textContent || '').replace(/[^0-9.]/g, '');
1935
+ if (appVer !== pending || seen === pending) { try { localStorage.removeItem('floless.whatsNew.pending'); } catch { /* private mode */ } return; }
1936
+
1937
+ const panel = document.getElementById('whats-new-panel');
1938
+ if (!panel) return;
1939
+ whatsNewShown = true; // guard: reveal at most once per session
1940
+
1941
+ const $summaryText = panel.querySelector('.whats-new-summary-text');
1942
+ const $toggle = panel.querySelector('.whats-new-toggle');
1943
+ const $detail = panel.querySelector('.whats-new-detail');
1944
+ const $detailInner = panel.querySelector('.whats-new-detail-inner');
1945
+ const $link = panel.querySelector('.whats-new-link');
1946
+ const $dismiss = panel.querySelector('.whats-new-dismiss');
1947
+
1948
+ // Changelog deep-link — channel-correct base only; omit (hide) when unknown.
1949
+ if ($link) {
1950
+ if (webBase) { $link.href = webBase + '/changelog#v' + pending; $link.hidden = false; }
1951
+ else { $link.removeAttribute('href'); $link.hidden = true; }
1952
+ }
1953
+ if ($summaryText) $summaryText.textContent = 'floless.app v' + pending + ' is now running.';
1954
+
1955
+ // Toggle expands/collapses the detail (grid-rows transition); label + aria flip.
1956
+ if ($toggle && $detail) {
1957
+ $toggle.onclick = () => {
1958
+ const open = $detail.classList.toggle('open');
1959
+ $toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
1960
+ $toggle.textContent = open ? 'Hide' : "What's new";
1961
+ };
1962
+ }
1963
+ // Dismiss — no confirm; persist the seen version so it never re-reveals.
1964
+ if ($dismiss) {
1965
+ $dismiss.onclick = () => {
1966
+ try { localStorage.setItem('floless.whatsNew.seen', pending); localStorage.removeItem('floless.whatsNew.pending'); } catch { /* private mode */ }
1967
+ panel.hidden = true;
1968
+ document.getElementById('app')?.classList.remove('has-whats-new');
1969
+ };
1970
+ }
1971
+
1972
+ // Reveal the panel (adds the 4th grid row), then fill the detail with shared bullets.
1973
+ document.getElementById('app')?.classList.add('has-whats-new');
1974
+ panel.hidden = false;
1975
+ (async () => {
1976
+ let notes = null;
1977
+ try {
1978
+ const r = await fetch('/api/release-notes?component=app&version=' + encodeURIComponent(pending));
1979
+ notes = await r.json();
1980
+ } catch { notes = null; }
1981
+ if (notes && notes.ok) {
1982
+ if ($summaryText && notes.summary) $summaryText.textContent = notes.summary;
1983
+ renderTypedChanges($detailInner, notes.changes);
1984
+ } else if ($detailInner) {
1985
+ $detailInner.innerHTML = '<p class="relnotes-unavailable">Release notes unavailable</p>';
1986
+ }
1987
+ })();
1988
+ }
1989
+
1698
1990
  // ONE Run (the approved single-Run model). "▶ Run workflow" does a REAL run against
1699
1991
  // the live host, using the app inputs. If the app has a report node, it drives
1700
1992
  // the in-app HTML Viewer (renders + caches the returned HTML); otherwise it
@@ -2245,8 +2537,14 @@
2245
2537
  // version CAN change (an out-of-band `npm i -g`, or the in-app upgrade), so
2246
2538
  // re-stamp it whenever /api/health reports a different value — never show a
2247
2539
  // stale runtime version in the footer.
2540
+ // Capture the channel-correct public site base for the changelog deep-links
2541
+ // (popover + what's-new). Empty until /api/health reports it → links omitted.
2542
+ if (h && h.webBase) webBase = h.webBase;
2248
2543
  const av = document.getElementById('app-version');
2249
2544
  if (av && h && h.appVersion && !shownVersion) { av.textContent = 'v' + h.appVersion; shownVersion = true; }
2545
+ // After the build version is stamped, reveal the relaunch-surviving what's-new
2546
+ // panel iff this is the build we just self-updated into (guarded to once).
2547
+ maybeShowWhatsNew();
2250
2548
  const wv = document.getElementById('aware-version');
2251
2549
  if (wv && h && h.awareVersion) {
2252
2550
  const next = 'AWARE ' + h.awareVersion;
@@ -133,6 +133,21 @@
133
133
  <div class="inspect-body" id="inspect-body"></div>
134
134
  </aside>
135
135
 
136
+ <!-- What's-new panel — a relaunch-surviving, calm "what changed" strip shown
137
+ once after a floless.app self-update. A direct grid child of .app (sibling
138
+ to header/body/footer); occupies the extra `whats-new` row that .app.has-whats-new
139
+ inserts above the footer. Dismiss persists in localStorage so it never nags. -->
140
+ <div id="whats-new-panel" class="whats-new" hidden>
141
+ <div class="whats-new-summary">
142
+ <span class="whats-new-label">Updated</span>
143
+ <span class="whats-new-summary-text"></span>
144
+ <button class="whats-new-toggle" type="button" aria-expanded="false">What's new</button>
145
+ <a class="whats-new-link" target="_blank" rel="noopener">Full changelog ↗</a>
146
+ <button class="whats-new-dismiss" type="button" aria-label="Dismiss">✕</button>
147
+ </div>
148
+ <div class="whats-new-detail"><div class="whats-new-detail-inner"></div></div>
149
+ </div>
150
+
136
151
  <footer>
137
152
  <div class="status">
138
153
  <span class="stat"><span class="dot"></span><span class="stat-val">runtime online</span></span>
@@ -155,6 +170,11 @@
155
170
  </footer>
156
171
  </div>
157
172
 
173
+ <!-- Single reusable release-notes popover — anchored to the clicked update pill
174
+ (or the AWARE version label) at open time. Shared by #app-update and
175
+ #aware-update; its body is rendered per-open in aware.js. -->
176
+ <div id="notes-popover" class="relnotes-popover" role="dialog" aria-modal="true" aria-label="Release notes" tabindex="-1" hidden></div>
177
+
158
178
  <div class="menu" id="menu" role="menu">
159
179
  <button class="menu-item" data-action="open" role="menuitem">
160
180
  <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg></span>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {