@krapwoo/ds-viewer 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -400,9 +400,17 @@ markers or its own file.
400
400
 
401
401
  ## Updating
402
402
 
403
- A quiet line appears in the sidebar footer when a new version is published — a banner too, for a
404
- major version, dismissible per version. Either opens the update page (`#ds-viewer-update`), which
405
- shows what would change and one **Update now** button:
403
+ The sidebar footer always shows the installed version ("DS Viewer 0.4.5") and opens the update
404
+ page (`#ds-viewer-update`). With no newer version known, that page says which situation you're in —
405
+ on the latest version, automatic checks off, or npm unreachable — and offers **Check now** (asks npm
406
+ right away, even with automatic checks off) and a **Check for updates automatically** switch. The
407
+ switch saves your own choice for this project on this computer (in your user settings folder, not in
408
+ the project), overriding the project's `updateCheck` for you only; `DS_VIEWER_NO_UPDATE_CHECK=1`
409
+ (CI) still wins over both. `doctor` follows the same setting.
410
+
411
+ When a new version is published, the footer turns into "Update available · 0.5.0" — a banner too,
412
+ for a major version, dismissible per version. Either opens the same page, which then shows what would
413
+ change and one **Update now** button:
406
414
 
407
415
  ```
408
416
  0.4.0 → 0.5.0 Minor
package/dist/cli/dev.js CHANGED
@@ -9,7 +9,7 @@ import { sync } from "./sync.js";
9
9
  import { writeWorkspace } from "./workspace.js";
10
10
  import { findFreePort } from "./port.js";
11
11
  import { globBaseFolder } from "./glob.js";
12
- import { checkForUpdate, isUpdateCheckEnabled, writeUpdateFile } from "./updateCheck.js";
12
+ import { buildVersionStatus, createVersionController, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck, runUpdateCheck, statusFromCheck, writeUpdateFile, writeVersionFile, } from "./updateCheck.js";
13
13
  import { readOwnVersion } from "./packageVersion.js";
14
14
  import { createUpdateEndpoint } from "./endpoint.js";
15
15
  import { buildUpdatePlan } from "./updatePlan.js";
@@ -70,10 +70,20 @@ export function reloadWorkspace(projectRoot, updateEndpoint) {
70
70
  * the same way `reloadWorkspace` already is, so this plan never needs to test `dev()` itself
71
71
  * (which spawns a real Expo process) to prove this one behavior. */
72
72
  export async function refreshUpdateFile(config, ownVersion, options = {}) {
73
- const result = isUpdateCheckEnabled(config, options.env ?? process.env)
74
- ? await (options.checkForUpdate ?? checkForUpdate)(ownVersion)
75
- : undefined;
76
- writeUpdateFile(config.projectRoot, result);
73
+ const env = options.env ?? process.env;
74
+ const personal = readPersonalAutoCheck(options.prefsDir ?? defaultPreferencesDir(), config.projectRoot);
75
+ const autoCheck = resolveAutoCheck(config, env, personal);
76
+ // An injected `checkForUpdate` (tests) stands in for the real check; it can't report "couldn't
77
+ // reach npm", so it always counts as answered.
78
+ const injected = options.checkForUpdate;
79
+ const status = injected
80
+ ? autoCheck.enabled
81
+ ? statusFromCheck(ownVersion, autoCheck, { result: await injected(ownVersion), reached: true })
82
+ : { current: ownVersion, autoCheck, lastOutcome: 'not-run', update: null }
83
+ : await buildVersionStatus(ownVersion, autoCheck);
84
+ writeUpdateFile(config.projectRoot, status.update ? { ...status.update, checkedAt: status.lastCheckedAt ?? new Date().toISOString() } : undefined);
85
+ writeVersionFile(config.projectRoot, status);
86
+ return status;
77
87
  }
78
88
  // Promisified, not `execFileSync` — install/migrate/doctor each take real seconds, and this now
79
89
  // runs from inside the endpoint's own request-handling process, which must keep answering
@@ -284,8 +294,19 @@ export async function performRestart(handoff, deps = {}) {
284
294
  export async function dev(initialConfig, devOptions = {}) {
285
295
  assertLocalInstall(initialConfig.projectRoot);
286
296
  const ownVersion = readOwnVersion(import.meta.dirname);
287
- await refreshUpdateFile(initialConfig, ownVersion);
288
297
  let config = initialConfig;
298
+ const version = createVersionController({
299
+ ownVersion,
300
+ projectRoot: initialConfig.projectRoot,
301
+ prefsDir: defaultPreferencesDir(),
302
+ env: process.env,
303
+ initial: await refreshUpdateFile(initialConfig, ownVersion),
304
+ getConfig: () => config,
305
+ // **Check now** always asks npm, even with automatic checks off: you asked for this one check.
306
+ // The result lives only in this process; `update.json` and `version.json` are rewritten at
307
+ // the next start (rewriting them now would reload the page).
308
+ runCheck: () => runUpdateCheck(ownVersion, { force: true, timeoutMs: 5000 }),
309
+ });
289
310
  sync(config);
290
311
  // Restart handoff step 2/4: a status file left by the *previous* `dev` process (right before it
291
312
  // exited to restart) is read once, then deleted — a plain refresh afterward shows "idle"/"no
@@ -336,6 +357,9 @@ export async function dev(initialConfig, devOptions = {}) {
336
357
  });
337
358
  },
338
359
  getStatus: () => updateStatus,
360
+ getVersionStatus: version.getVersionStatus,
361
+ checkNow: version.checkNow,
362
+ setAutoCheck: version.setAutoCheck,
339
363
  };
340
364
  const endpointServer = createUpdateEndpoint(endpointDeps);
341
365
  await new Promise((resolve) => endpointServer.listen(0, '127.0.0.1', resolve));
@@ -374,6 +398,7 @@ export async function dev(initialConfig, devOptions = {}) {
374
398
  configDebounceTimer = setTimeout(() => {
375
399
  try {
376
400
  config = reloadWorkspace(config.projectRoot, updateEndpoint);
401
+ version.refreshAutoCheck();
377
402
  }
378
403
  catch (error) {
379
404
  console.warn(`Could not reload ds-viewer.config.ts after a change: ${error.message}`);
@@ -14,6 +14,38 @@ function secretMatches(received, expected) {
14
14
  const expectedBuf = Buffer.from(expected);
15
15
  return receivedBuf.length === expectedBuf.length && timingSafeEqual(receivedBuf, expectedBuf);
16
16
  }
17
+ /** Reads a small JSON request body (the switch's `{ enabled }`); anything over 1 KB is refused. */
18
+ function readJsonBody(req) {
19
+ return new Promise((resolve, reject) => {
20
+ let body = '';
21
+ let tooLarge = false;
22
+ req.setEncoding('utf8');
23
+ // Keep draining past the cap (without storing it) so the handler can still answer 400 —
24
+ // destroying the socket would drop that answer.
25
+ req.on('data', (chunk) => {
26
+ if (tooLarge)
27
+ return;
28
+ body += chunk;
29
+ if (body.length > 1024) {
30
+ tooLarge = true;
31
+ body = '';
32
+ }
33
+ });
34
+ req.on('end', () => {
35
+ if (tooLarge) {
36
+ reject(new Error('Body too large.'));
37
+ return;
38
+ }
39
+ try {
40
+ resolve(JSON.parse(body || 'null'));
41
+ }
42
+ catch (error) {
43
+ reject(error);
44
+ }
45
+ });
46
+ req.on('error', reject);
47
+ });
48
+ }
17
49
  function sendJson(res, status, body, allowedOrigin) {
18
50
  res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': allowedOrigin });
19
51
  res.end(JSON.stringify(body));
@@ -33,6 +65,7 @@ export function createUpdateEndpoint(deps) {
33
65
  // every real caller), so resetting this flag right after is safe and lets a later retry (after a
34
66
  // `'failure'`, in the same process) through again.
35
67
  let starting = false;
68
+ let checking = false;
36
69
  return http.createServer(async (req, res) => {
37
70
  // Controller decision 1 (required, not optional): reject a request whose `Host` header is not
38
71
  // this very socket's own `127.0.0.1:<port>` — a DNS-rebinding defence, since `req.socket`'s own
@@ -55,7 +88,7 @@ export function createUpdateEndpoint(deps) {
55
88
  res.writeHead(204, {
56
89
  'Access-Control-Allow-Origin': deps.allowedOrigin,
57
90
  'Access-Control-Allow-Methods': 'POST, GET',
58
- 'Access-Control-Allow-Headers': DS_VIEWER_SECRET_HEADER,
91
+ 'Access-Control-Allow-Headers': `${DS_VIEWER_SECRET_HEADER}, Content-Type`,
59
92
  });
60
93
  res.end();
61
94
  return;
@@ -137,6 +170,48 @@ export function createUpdateEndpoint(deps) {
137
170
  sendJson(res, 200, deps.getStatus(), deps.allowedOrigin);
138
171
  return;
139
172
  }
173
+ if (req.method === 'GET' && req.url === '/version' && deps.getVersionStatus) {
174
+ sendJson(res, 200, deps.getVersionStatus(), deps.allowedOrigin);
175
+ return;
176
+ }
177
+ if (req.method === 'POST' && req.url === '/version/check' && deps.checkNow) {
178
+ if (checking) {
179
+ sendJson(res, 409, { error: 'A check is already running.' }, deps.allowedOrigin);
180
+ return;
181
+ }
182
+ checking = true;
183
+ try {
184
+ sendJson(res, 200, await deps.checkNow(), deps.allowedOrigin);
185
+ }
186
+ catch (error) {
187
+ sendJson(res, 502, { error: error.message }, deps.allowedOrigin);
188
+ }
189
+ finally {
190
+ checking = false;
191
+ }
192
+ return;
193
+ }
194
+ if (req.method === 'POST' && req.url === '/version/auto-check' && deps.setAutoCheck) {
195
+ let body;
196
+ try {
197
+ body = await readJsonBody(req);
198
+ }
199
+ catch {
200
+ body = undefined;
201
+ }
202
+ const enabled = body?.enabled;
203
+ if (typeof enabled !== 'boolean') {
204
+ sendJson(res, 400, { error: 'Expected { "enabled": true | false }.' }, deps.allowedOrigin);
205
+ return;
206
+ }
207
+ try {
208
+ sendJson(res, 200, deps.setAutoCheck(enabled), deps.allowedOrigin);
209
+ }
210
+ catch (error) {
211
+ sendJson(res, 500, { error: error.message }, deps.allowedOrigin);
212
+ }
213
+ return;
214
+ }
140
215
  res.writeHead(404).end('Not found.');
141
216
  });
142
217
  }
package/dist/cli/main.js CHANGED
@@ -8,7 +8,7 @@ import { sync } from "./sync.js";
8
8
  import { formatHuman, runDoctor, toDoctorJson } from "./doctor.js";
9
9
  import { explainPage, formatExplain, parseHeights } from "./explain.js";
10
10
  import { readOwnVersion } from "./packageVersion.js";
11
- import { checkForUpdate, isUpdateCheckEnabled } from "./updateCheck.js";
11
+ import { checkForUpdate, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck } from "./updateCheck.js";
12
12
  import { runKitDiffCommand } from "./kitDiff.js";
13
13
  import { formatMigrateHuman, runMigrate } from "./migrate.js";
14
14
  import { runUpdate } from "./update.js";
@@ -119,7 +119,9 @@ export async function runDoctorCommand(projectRoot, rest, options = {}) {
119
119
  const result = runDoctor(config);
120
120
  // Design §4 "`--ci`: ... no update check." — `--ci` skips this whole block even when
121
121
  // `updateCheck` is otherwise enabled; `result.update` stays `null`.
122
- if (!rest.includes('--ci') && isUpdateCheckEnabled(config, options.env ?? process.env)) {
122
+ // Same precedence as the viewer: CI's environment variable, your own switch, the project config.
123
+ const autoCheck = resolveAutoCheck(config, options.env ?? process.env, readPersonalAutoCheck(options.prefsDir ?? defaultPreferencesDir(), config.projectRoot));
124
+ if (!rest.includes('--ci') && autoCheck.enabled) {
123
125
  const ownVersion = readOwnVersion(path.dirname(fileURLToPath(import.meta.url)));
124
126
  const updateResult = await (options.checkForUpdate ?? checkForUpdate)(ownVersion);
125
127
  if (updateResult) {
@@ -1,5 +1,5 @@
1
1
  // cli/updateCheck.ts
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { compareVersions, isBreakingUpgrade, parseVersion } from "./semver.js";
5
5
  const CACHE_FILE_NAME = 'update-check.json';
@@ -79,59 +79,163 @@ export async function fetchWithTimeout(url, fetchImpl, timeoutMs) {
79
79
  clearTimeout(timer);
80
80
  }
81
81
  }
82
+ /** One update check, with whether npm actually answered — `checkForUpdate` alone can't tell "no
83
+ * newer version" from "couldn't reach npm", which the update page must say differently. Never
84
+ * throws. `cached` is the cache as it stands afterwards (the last successful check on this
85
+ * computer), when there is one. */
86
+ export async function runUpdateCheck(currentVersion, options = {}) {
87
+ const now = options.now ?? Date.now;
88
+ const cacheDir = options.cacheDir ?? cacheDirFor(process.platform, process.env, process.env.HOME ?? process.env.USERPROFILE ?? '');
89
+ const cached = readCache(cacheDir);
90
+ // `parseVersion` (inside `toResultOrUndefined`) throws on a non-semver `latest`, whether from a
91
+ // malformed cache file or an unexpected registry body; this function must never throw.
92
+ const safeResult = (entry) => {
93
+ try {
94
+ return toResultOrUndefined(entry, currentVersion);
95
+ }
96
+ catch {
97
+ return undefined;
98
+ }
99
+ };
100
+ if (cached && !options.force && isCacheFresh(cached, now()))
101
+ return { result: safeResult(cached), reached: true, cached };
102
+ const fetchImpl = options.fetchImpl ?? fetch;
103
+ const timeoutMs = options.timeoutMs ?? 900;
104
+ let latest;
105
+ try {
106
+ const registryResponse = (await fetchWithTimeout(REGISTRY_URL, fetchImpl, timeoutMs));
107
+ latest = registryResponse.version;
108
+ parseVersion(latest);
109
+ }
110
+ catch {
111
+ // A stale cache is still better than nothing once offline — still run through the same
112
+ // not-actually-newer guard, never a raw passthrough.
113
+ return { result: cached ? safeResult(cached) : undefined, reached: false, cached };
114
+ }
115
+ let summary = [];
116
+ let releasedAt;
117
+ try {
118
+ const release = (await fetchWithTimeout(`https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${latest}`, fetchImpl, timeoutMs));
119
+ summary = release.body ? extractSummaryBullets(release.body) : [];
120
+ releasedAt = release.published_at;
121
+ }
122
+ catch {
123
+ // The version comparison is the half that matters for the footer/banner; a missing release
124
+ // summary (private repo hiccup, a tag pushed slightly before its release note) degrades to an
125
+ // empty "What's new" list, never a failed check.
126
+ }
127
+ const fresh = { latest, summary, releasedAt, checkedAt: new Date(now()).toISOString() };
128
+ // A registry that (briefly, during its own propagation) reports an older "latest" than what is
129
+ // already cached never regresses the cache's version, but the check time still moves forward.
130
+ let stored = fresh;
131
+ try {
132
+ if (cached && compareVersions(parseVersion(fresh.latest), parseVersion(cached.latest)) < 0)
133
+ stored = { ...cached, checkedAt: fresh.checkedAt };
134
+ }
135
+ catch {
136
+ // An unreadable cached version is simply replaced.
137
+ }
138
+ try {
139
+ writeCache(cacheDir, stored);
140
+ }
141
+ catch {
142
+ // A read-only cache folder must not turn a successful check into a failure.
143
+ }
144
+ return { result: safeResult(fresh), reached: true, cached: stored };
145
+ }
82
146
  /** Design §5 "Update check" in full. Never throws — every failure (a stale/missing cache with no
83
147
  * reachable registry, a GitHub release that 404s, a request that times out) resolves to either a
84
148
  * usable result or `undefined`, so every caller can treat "no update known" as the one, uniform
85
149
  * outcome of being offline, blocked, or genuinely up to date with nothing cached yet. */
86
150
  export async function checkForUpdate(currentVersion, options = {}) {
87
- // M5 (Minor, Fable correction pass): this function is documented — and relied on by `dev()`,
88
- // which `await`s it before starting anything else — as never throwing. `parseVersion` (inside
89
- // `toResultOrUndefined`) throws on a non-semver `latest`, whether from a parseable-but-malformed
90
- // cache file or an unexpected 200 body with no real `version`; either would otherwise stop `dev`
91
- // from starting at all.
92
151
  try {
93
- const now = options.now ?? Date.now;
94
- const cacheDir = options.cacheDir ?? cacheDirFor(process.platform, process.env, process.env.HOME ?? process.env.USERPROFILE ?? '');
95
- const cached = readCache(cacheDir);
96
- if (cached && isCacheFresh(cached, now()))
97
- return toResultOrUndefined(cached, currentVersion);
98
- const fetchImpl = options.fetchImpl ?? fetch;
99
- const timeoutMs = options.timeoutMs ?? 900;
100
- let latest;
101
- try {
102
- const registryResponse = (await fetchWithTimeout(REGISTRY_URL, fetchImpl, timeoutMs));
103
- latest = registryResponse.version;
104
- }
105
- catch {
106
- // A stale cache is still better than nothing once offline — still run through the same
107
- // not-actually-newer guard, never a raw passthrough.
108
- return cached ? toResultOrUndefined(cached, currentVersion) : undefined;
109
- }
110
- let summary = [];
111
- let releasedAt;
112
- try {
113
- const release = (await fetchWithTimeout(`https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${latest}`, fetchImpl, timeoutMs));
114
- summary = release.body ? extractSummaryBullets(release.body) : [];
115
- releasedAt = release.published_at;
116
- }
117
- catch {
118
- // The version comparison is the half that matters for the footer/banner; a missing release
119
- // summary (private repo hiccup, a tag pushed slightly before its release note) degrades to an
120
- // empty "What's new" list, never a failed check.
121
- }
122
- const cachedResult = { latest, summary, releasedAt, checkedAt: new Date(now()).toISOString() };
123
- // compareVersions is used only to decide *whether* this is worth caching as "the latest" at all
124
- // — a registry that (briefly, during its own propagation) reports an older "latest" than what
125
- // is already cached never regresses the cache.
126
- if (!cached || compareVersions(parseVersion(cachedResult.latest), parseVersion(cached.latest)) >= 0) {
127
- writeCache(cacheDir, cachedResult);
128
- }
129
- return toResultOrUndefined(cachedResult, currentVersion);
152
+ return (await runUpdateCheck(currentVersion, options)).result;
130
153
  }
131
154
  catch {
132
155
  return undefined;
133
156
  }
134
157
  }
158
+ /** A settings folder, not the cache folder (which the OS may clear): the personal switch must
159
+ * survive. */
160
+ export function preferencesDirFor(platform, env, homedir) {
161
+ if (platform === 'darwin')
162
+ return path.posix.join(homedir, 'Library', 'Application Support', 'ds-viewer');
163
+ if (platform === 'win32')
164
+ return path.win32.join(env.APPDATA ?? path.win32.join(homedir, 'AppData', 'Roaming'), 'ds-viewer');
165
+ return path.posix.join(env.XDG_CONFIG_HOME ?? path.posix.join(homedir, '.config'), 'ds-viewer');
166
+ }
167
+ export function defaultPreferencesDir() {
168
+ return preferencesDirFor(process.platform, process.env, process.env.HOME ?? process.env.USERPROFILE ?? '');
169
+ }
170
+ const PREFERENCES_FILE_NAME = 'preferences.json';
171
+ function readPreferences(dir) {
172
+ try {
173
+ const parsed = JSON.parse(readFileSync(path.join(dir, PREFERENCES_FILE_NAME), 'utf8'));
174
+ return parsed && typeof parsed === 'object' ? parsed : {};
175
+ }
176
+ catch {
177
+ return {};
178
+ }
179
+ }
180
+ /** Your own "check automatically" switch for one project on this computer, or `undefined` when you
181
+ * haven't set it (the project config then decides). */
182
+ export function readPersonalAutoCheck(dir, projectRoot) {
183
+ const value = readPreferences(dir).projects?.[path.resolve(projectRoot)]?.autoCheck;
184
+ return typeof value === 'boolean' ? value : undefined;
185
+ }
186
+ export function writePersonalAutoCheck(dir, projectRoot, enabled) {
187
+ const prefs = readPreferences(dir);
188
+ const projects = { ...(prefs.projects ?? {}) };
189
+ projects[path.resolve(projectRoot)] = { ...projects[path.resolve(projectRoot)], autoCheck: enabled };
190
+ mkdirSync(dir, { recursive: true });
191
+ // Write then rename, so a crash mid-write never leaves a half file that would read as "no
192
+ // settings" and silently reset every project's switch.
193
+ const target = path.join(dir, PREFERENCES_FILE_NAME);
194
+ const temp = `${target}.${process.pid}.tmp`;
195
+ writeFileSync(temp, `${JSON.stringify({ ...prefs, projects }, null, 2)}\n`);
196
+ renameSync(temp, target);
197
+ }
198
+ /** Which setting decides automatic checks, strongest first: CI's environment variable, your own
199
+ * switch, the project config, then on by default. */
200
+ export function resolveAutoCheck(config, env, personal) {
201
+ if (env.DS_VIEWER_NO_UPDATE_CHECK === '1')
202
+ return { enabled: false, source: 'env' };
203
+ if (personal !== undefined)
204
+ return { enabled: personal, source: 'personal' };
205
+ if (config.updateCheck !== undefined)
206
+ return { enabled: config.updateCheck, source: 'project' };
207
+ return { enabled: true, source: 'default' };
208
+ }
209
+ function toNotice(result) {
210
+ if (!result)
211
+ return null;
212
+ return { current: result.current, latest: result.latest, breaking: result.breaking, summary: result.summary, releasedAt: result.releasedAt };
213
+ }
214
+ /** The version status `dev` starts with: runs the (cached) check only when automatic checks are on. */
215
+ export async function buildVersionStatus(currentVersion, autoCheck, options = {}) {
216
+ if (!autoCheck.enabled) {
217
+ const cacheDir = options.cacheDir ?? cacheDirFor(process.platform, process.env, process.env.HOME ?? process.env.USERPROFILE ?? '');
218
+ return { current: currentVersion, autoCheck, lastCheckedAt: readCache(cacheDir)?.checkedAt, lastOutcome: 'not-run', update: null };
219
+ }
220
+ return statusFromCheck(currentVersion, autoCheck, await runUpdateCheck(currentVersion, options));
221
+ }
222
+ /** Folds one check's outcome into a status (startup, or **Check now**). */
223
+ export function statusFromCheck(currentVersion, autoCheck, outcome) {
224
+ return {
225
+ current: currentVersion,
226
+ autoCheck,
227
+ lastCheckedAt: outcome.cached?.checkedAt,
228
+ lastOutcome: outcome.reached ? 'ok' : 'unreachable',
229
+ update: toNotice(outcome.result),
230
+ };
231
+ }
232
+ /** `dev` writes this next to `update.json`; the generated entry file imports it. */
233
+ export function writeVersionFile(projectRoot, status) {
234
+ const file = path.join(projectRoot, '.ds-viewer', 'version.json');
235
+ mkdirSync(path.dirname(file), { recursive: true });
236
+ writeFileSync(file, JSON.stringify(status));
237
+ return file;
238
+ }
135
239
  /** Design §5: "`dev` writes the result to `.ds-viewer/update.json`; the viewer reads it." Always
136
240
  * writes something — `null` when there is no result — so the generated entry file (Task 17) can
137
241
  * unconditionally `import updateNotice from './update.json'` without ever hitting a missing file. */
@@ -141,3 +245,30 @@ export function writeUpdateFile(projectRoot, result) {
141
245
  writeFileSync(file, JSON.stringify(result ?? null));
142
246
  return file;
143
247
  }
248
+ /** The live version status behind the local endpoint (`GET /version`, **Check now**, the switch),
249
+ * kept outside `dev()` so its ordering rules are testable. */
250
+ export function createVersionController(deps) {
251
+ let status = deps.initial;
252
+ const resolveNow = () => resolveAutoCheck(deps.getConfig(), deps.env, readPersonalAutoCheck(deps.prefsDir, deps.projectRoot));
253
+ return {
254
+ getVersionStatus: () => status,
255
+ /** **Check now**: asks npm ignoring the cache. Reads the switch *after* the check, so a change
256
+ * made while it ran is kept. */
257
+ checkNow: async () => {
258
+ const outcome = await deps.runCheck();
259
+ status = statusFromCheck(deps.ownVersion, status.autoCheck, outcome);
260
+ return status;
261
+ },
262
+ /** The switch: your own setting for this project on this computer. Takes effect for automatic
263
+ * checks from the next start; the current status keeps any update already found. */
264
+ setAutoCheck: (enabled) => {
265
+ writePersonalAutoCheck(deps.prefsDir, deps.projectRoot, enabled);
266
+ status = { ...status, autoCheck: resolveNow() };
267
+ return status;
268
+ },
269
+ /** After `ds-viewer.config.ts` changes while `dev` runs. */
270
+ refreshAutoCheck: () => {
271
+ status = { ...status, autoCheck: resolveNow() };
272
+ },
273
+ };
274
+ }
@@ -65,13 +65,14 @@ function entryTsx(name, hasSafeArea, groupOrder, logoRequirePath, updateEndpoint
65
65
  // (no reserved words in either key) — the same reasoning the existing `logoRequirePath` baking
66
66
  // already relies on, extended to a whole object instead of one string.
67
67
  const endpointConst = `const updateEndpoint = ${updateEndpoint ? JSON.stringify(updateEndpoint) : 'undefined'};\n`;
68
- const viewer = `<CatalogShell appName={${JSON.stringify(name)}} title="Component Catalog" groups={groups} sections={sections}${logoProp} update={updateNotice} updateEndpoint={updateEndpoint} />`;
68
+ const viewer = `<CatalogShell appName={${JSON.stringify(name)}} title="Component Catalog" groups={groups} sections={sections}${logoProp} update={updateNotice} versionStatus={versionStatus} updateEndpoint={updateEndpoint} />`;
69
69
  const root = hasSafeArea ? `<SafeAreaProvider>${viewer}</SafeAreaProvider>` : viewer;
70
70
  return `import { registerRootComponent } from 'expo';
71
71
  import { CatalogShell, buildCatalogSections } from '@krapwoo/ds-viewer';
72
72
  ${safeAreaImport}${logoImport}import pages from './generated/pages';
73
73
  import components from './generated/components.json';
74
74
  import updateNotice from './update.json';
75
+ import versionStatus from './version.json';
75
76
  ${endpointConst}
76
77
  function App() {
77
78
  const { sections, groups } = buildCatalogSections(pages, components, ${JSON.stringify(groupOrder)});
@@ -129,3 +129,14 @@ export function footerLabel(update) {
129
129
  return undefined;
130
130
  return `Update available · ${update.latest}`;
131
131
  }
132
+ /** The sidebar's footer line: the blue "Update available" notice when a newer version is known
133
+ * (from startup or **Check now**), else the installed version, quietly. `undefined` only for a
134
+ * workspace generated before version status existed, which keeps the old notice-only footer. */
135
+ export function footerLink(update, status) {
136
+ const notice = footerLabel(update) ?? footerLabel(status?.update);
137
+ if (notice)
138
+ return { label: notice, kind: 'update' };
139
+ if (status)
140
+ return { label: `DS Viewer ${status.current}`, kind: 'version' };
141
+ return undefined;
142
+ }
@@ -5,8 +5,8 @@ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_MAX_CONTENT_WIDTH, CATALOG_RADIU
5
5
  import { CatalogSidebar } from './CatalogSidebar';
6
6
  import { SectionBlock } from './SectionBlock';
7
7
  import { UpdatePanel } from './UpdatePanel';
8
- import { groupLabelFor, hashForId, neighbors, orderedIds, resolveActiveFromHash, shouldShowMajorBanner, footerLabel, UPDATE_PAGE_ID } from './catalogNavigation';
9
- import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice } from './types';
8
+ import { DS_VIEWER_SECRET_HEADER, footerLink, groupLabelFor, hashForId, neighbors, orderedIds, resolveActiveFromHash, shouldShowMajorBanner, UPDATE_PAGE_ID } from './catalogNavigation';
9
+ import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice, VersionStatus } from './types';
10
10
 
11
11
  /** `active` can be a real page id, or the reserved update-page id — never both a generic `TId`
12
12
  * constraint and a hardcoded string literal type at once, which is why this is its own alias. */
@@ -111,6 +111,7 @@ export function CatalogShell<TId extends string>({
111
111
  sections,
112
112
  defaultPreviewWidths,
113
113
  update,
114
+ versionStatus,
114
115
  updateEndpoint,
115
116
  }: {
116
117
  /** Short product/app name — the sidebar logo and the breadcrumb root. */
@@ -129,6 +130,12 @@ export function CatalogShell<TId extends string>({
129
130
  * or already up to date; the viewer treats all three identically: no footer, no banner, no
130
131
  * update page content). */
131
132
  update?: UpdateNotice | null;
133
+ /** The installed version, whether automatic checks are on (and why), and the last check —
134
+ * `dev` writes it at startup (`.ds-viewer/version.json`), and the shell refreshes it from the
135
+ * local endpoint. With it, the sidebar always shows a version line and the update page offers
136
+ * **Check now** and the automatic-check switch. Without it (an older generated workspace), the
137
+ * footer appears only when an update is known, as before. */
138
+ versionStatus?: VersionStatus | null;
132
139
  /** The local endpoint's base URL and per-run secret — `dev` generates both (Task 18) and
133
140
  * `writeWorkspace`'s generated entry file bakes them in (Task 17). Absent in a build that
134
141
  * disables the endpoint entirely — `UpdatePanel` degrades to showing the plan/notice with no
@@ -198,28 +205,56 @@ export function CatalogShell<TId extends string>({
198
205
 
199
206
  const activeDef = active !== undefined && active !== UPDATE_PAGE_ID ? sectionsById.get(active) : undefined;
200
207
 
208
+ // The live version status: starts from what `dev` wrote at startup, then follows the local
209
+ // endpoint (a reload after **Check now** must not fall back to the startup snapshot).
210
+ const [liveStatus, setLiveStatus] = useState<VersionStatus | null>(versionStatus ?? null);
211
+ useEffect(() => {
212
+ if (!updateEndpoint || !isWeb()) return;
213
+ let cancelled = false;
214
+ fetch(`${updateEndpoint.baseUrl}/version`, { headers: { [DS_VIEWER_SECRET_HEADER]: updateEndpoint.secret } })
215
+ .then((res) => (res.ok ? (res.json() as Promise<VersionStatus>) : undefined))
216
+ .then((next) => {
217
+ if (next && !cancelled) setLiveStatus(next);
218
+ })
219
+ .catch(() => {
220
+ // The startup snapshot stays; the endpoint may be restarting.
221
+ });
222
+ return () => {
223
+ cancelled = true;
224
+ };
225
+ }, [updateEndpoint]);
226
+ // With a live status, it is the truth (it includes a **Check now** result); otherwise the
227
+ // startup notice, as before.
228
+ const effectiveUpdate: UpdateNotice | null = liveStatus ? liveStatus.update : update ?? null;
229
+
201
230
  const [dismissedMajor, setDismissedMajor] = useState<string | null>(() =>
202
231
  isWeb() ? window.localStorage.getItem('ds-viewer-dismissed-major') : null,
203
232
  );
204
233
  const dismissMajorBanner = useCallback(() => {
205
- if (!update) return;
206
- if (isWeb()) window.localStorage.setItem('ds-viewer-dismissed-major', update.latest);
207
- setDismissedMajor(update.latest);
208
- }, [update]);
209
- const footer = footerLabel(update)
210
- ? { label: footerLabel(update)!, active: active === UPDATE_PAGE_ID, onPress: () => select(UPDATE_PAGE_ID) }
211
- : undefined;
234
+ if (!effectiveUpdate) return;
235
+ if (isWeb()) window.localStorage.setItem('ds-viewer-dismissed-major', effectiveUpdate.latest);
236
+ setDismissedMajor(effectiveUpdate.latest);
237
+ }, [effectiveUpdate]);
238
+ const link = footerLink(effectiveUpdate, liveStatus);
239
+ const footer = link ? { ...link, active: active === UPDATE_PAGE_ID, onPress: () => select(UPDATE_PAGE_ID) } : undefined;
212
240
  // Design: the banner shows on every *normal* page; once you're already looking at the update
213
241
  // page there is nothing left for "Review update" to do, so it's hidden there (confirmed against
214
242
  // the approved mockup's own `frame()`: the banner only ever renders when `!isPanel`).
215
- const showBanner = active !== UPDATE_PAGE_ID && shouldShowMajorBanner(update, dismissedMajor);
243
+ const showBanner = active !== UPDATE_PAGE_ID && shouldShowMajorBanner(effectiveUpdate, dismissedMajor);
216
244
 
217
245
  return (
218
246
  <View style={styles.root}>
219
247
  <CatalogSidebar logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} labels={labels} active={active} onPress={select} footer={footer} />
220
248
  <ScrollView ref={scrollRef} style={styles.main} contentContainerStyle={styles.mainContent}>
221
249
  {active === UPDATE_PAGE_ID ? (
222
- <UpdatePanel update={update ?? null} endpoint={updateEndpoint} appName={appName} headingRef={headingRef} />
250
+ <UpdatePanel
251
+ update={effectiveUpdate}
252
+ versionStatus={liveStatus}
253
+ onVersionStatus={setLiveStatus}
254
+ endpoint={updateEndpoint}
255
+ appName={appName}
256
+ headingRef={headingRef}
257
+ />
223
258
  ) : activeDef ? (
224
259
  <SectionBlock
225
260
  key={activeDef.id}
@@ -229,7 +264,7 @@ export function CatalogShell<TId extends string>({
229
264
  // The approved mockup's own `PAGE_BG` order: crumb, then banner, then title — the
230
265
  // banner used to render above the breadcrumb instead (Important finding, Fable
231
266
  // correction pass).
232
- banner={showBanner && update ? <MajorBanner update={update} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} /> : undefined}
267
+ banner={showBanner && effectiveUpdate ? <MajorBanner update={effectiveUpdate} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} /> : undefined}
233
268
  pager={{
234
269
  previousId: neighbors(order, activeDef.id).previous,
235
270
  nextId: neighbors(order, activeDef.id).next,
@@ -43,7 +43,7 @@ function NavItem<TId extends string>({ id, label, active, onPress }: { id: TId;
43
43
  );
44
44
  }
45
45
 
46
- function FooterLink({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
46
+ function FooterLink({ label, kind, active, onPress }: { label: string; kind: 'update' | 'version'; active: boolean; onPress: () => void }) {
47
47
  const [hovered, setHovered] = useState(false);
48
48
  const [focused, setFocused] = useState(false);
49
49
  return (
@@ -63,7 +63,8 @@ function FooterLink({ label, active, onPress }: { label: string; active: boolean
63
63
  focused && styles.focusRing,
64
64
  ]}
65
65
  >
66
- <View style={styles.footerDot} />
66
+ {/* The dot marks an available update; the everyday version line is quiet. */}
67
+ {kind === 'update' && <View style={styles.footerDot} />}
67
68
  <Text style={[styles.footerLabel, active && styles.footerLabelActive]}>{label}</Text>
68
69
  </Pressable>
69
70
  );
@@ -96,10 +97,10 @@ export function CatalogSidebar<TId extends string>({
96
97
  labels?: ReadonlyMap<string, string>;
97
98
  active: string | undefined;
98
99
  onPress: (id: TId) => void;
99
- /** The quiet update-notice link below the nav list (design §5's footer line), or `undefined`
100
- * when there is no update to show. `active` is true while the update page itself is open — it
101
- * gets the same active styling a selected nav item would. */
102
- footer?: { label: string; active: boolean; onPress: () => void };
100
+ /** The link below the nav list that opens the update page: the installed version (`'version'`),
101
+ * or the update notice with a dot (`'update'`, design §5's footer line). `active` is true while
102
+ * the update page itself is open — it gets the same active styling a selected nav item would. */
103
+ footer?: { label: string; kind?: 'update' | 'version'; active: boolean; onPress: () => void };
103
104
  }) {
104
105
  const [query, setQuery] = useState('');
105
106
  // A bundled local image (the only kind `cli/workspace.ts` ever produces — see its doc comment
@@ -201,7 +202,7 @@ export function CatalogSidebar<TId extends string>({
201
202
  </ScrollView>
202
203
  {footer && (
203
204
  <View style={styles.footerContainer}>
204
- <FooterLink label={footer.label} active={footer.active} onPress={footer.onPress} />
205
+ <FooterLink label={footer.label} kind={footer.kind ?? 'update'} active={footer.active} onPress={footer.onPress} />
205
206
  </View>
206
207
  )}
207
208
  </View>
@@ -1,9 +1,11 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from 'react';
2
- import { Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
2
+ import { ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
3
3
  import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
4
4
  import { DS_VIEWER_SECRET_HEADER } from './catalogNavigation';
5
- import { daysAgoLabel, isAlreadyUpToDateError, resolvePanelPhase, type UpdatePlanView, type UpdateStatusView } from './updatePanelState';
6
- import type { UpdateNotice } from './types';
5
+ import {
6
+ autoCheckHelp, bumpLabel, checkedAgoLabel, daysAgoLabel, isAlreadyUpToDateError, resolvePanelPhase, versionOverview, type UpdatePlanView, type UpdateStatusView,
7
+ } from './updatePanelState';
8
+ import type { UpdateNotice, VersionStatus } from './types';
7
9
 
8
10
  function isWeb(): boolean {
9
11
  return Platform.OS === 'web' && typeof window !== 'undefined';
@@ -25,7 +27,7 @@ function VersionsHeader({ current, latest, breaking, releasedAt }: { current: st
25
27
  <Text style={styles.arrow}>→</Text>
26
28
  <Text style={styles.versionsText}>{latest}</Text>
27
29
  <View style={[styles.pill, breaking && styles.pillMajor]}>
28
- <Text style={[styles.pillText, breaking && styles.pillTextMajor]}>{breaking ? 'Major' : 'Minor'}</Text>
30
+ <Text style={[styles.pillText, breaking && styles.pillTextMajor]}>{bumpLabel(current, latest, breaking)}</Text>
29
31
  </View>
30
32
  </View>
31
33
  <Text style={styles.muted}>{`You’re on ${current}.${releasedAt ? ` ${daysAgoLabel(releasedAt, Date.now())}` : ''}`}</Text>
@@ -190,6 +192,140 @@ function failureCopy(failedStep: string, current: string): { headline: string; b
190
192
  };
191
193
  }
192
194
 
195
+ /**
196
+ * The update page when no newer version is known (approved mockup
197
+ * docs/design/2026-10-10-ds-viewer-update-page-always-approved.html, states 3, 4, 6, 7 and 8): the
198
+ * installed version and which situation applies, when it last checked, the automatic-check
199
+ * switch, and **Check now**. Without an endpoint (a static build) it shows the status only.
200
+ */
201
+ function VersionDetails({
202
+ status,
203
+ endpoint,
204
+ onStatus,
205
+ }: {
206
+ status: VersionStatus;
207
+ endpoint?: Endpoint;
208
+ onStatus: (next: VersionStatus) => void;
209
+ }) {
210
+ const [checking, setChecking] = useState(false);
211
+ const [justChecked, setJustChecked] = useState(false);
212
+ const [saving, setSaving] = useState(false);
213
+ const [localError, setLocalError] = useState<string | undefined>(undefined);
214
+ const live = Boolean(endpoint) && isWeb();
215
+ const overview = versionOverview(status, justChecked, Date.now());
216
+ const help = autoCheckHelp(status.autoCheck);
217
+
218
+ const checkNow = async () => {
219
+ if (!endpoint || checking) return;
220
+ setChecking(true);
221
+ setJustChecked(false);
222
+ setLocalError(undefined);
223
+ try {
224
+ const res = await authedFetch(endpoint, '/version/check', { method: 'POST' });
225
+ if (!res.ok) throw new Error(String(res.status));
226
+ const next = (await res.json()) as VersionStatus;
227
+ // A newer version moves the whole page into the update flow (the parent re-renders with it).
228
+ setJustChecked(next.lastOutcome === 'ok' && !next.update);
229
+ onStatus(next);
230
+ } catch {
231
+ setLocalError('Couldn’t reach the viewer’s local server. If this keeps happening, restart npx ds-viewer dev.');
232
+ } finally {
233
+ setChecking(false);
234
+ }
235
+ };
236
+
237
+ const setAutoCheck = async (enabled: boolean) => {
238
+ if (!endpoint || saving) return;
239
+ setSaving(true);
240
+ setLocalError(undefined);
241
+ try {
242
+ const res = await authedFetch(endpoint, '/version/auto-check', {
243
+ method: 'POST',
244
+ headers: { 'Content-Type': 'application/json' },
245
+ body: JSON.stringify({ enabled }),
246
+ });
247
+ if (!res.ok) throw new Error(String(res.status));
248
+ onStatus((await res.json()) as VersionStatus);
249
+ } catch {
250
+ setLocalError('Couldn’t save the setting. Try again.');
251
+ } finally {
252
+ setSaving(false);
253
+ }
254
+ };
255
+
256
+ const unreachable = status.lastOutcome === 'unreachable';
257
+ return (
258
+ <>
259
+ <View style={styles.versionsRow}>
260
+ <Text style={styles.versionsText}>{status.current}</Text>
261
+ {overview.pill === 'latest' && (
262
+ <View style={[styles.pill, styles.pillSuccess]}>
263
+ <Text style={[styles.pillText, styles.pillTextSuccess]}>Latest</Text>
264
+ </View>
265
+ )}
266
+ {overview.pill === 'off' && (
267
+ <View style={[styles.pill, styles.pillOff]}>
268
+ <Text style={[styles.pillText, styles.pillTextOff]}>Checks off</Text>
269
+ </View>
270
+ )}
271
+ </View>
272
+ <Text style={styles.muted}>{overview.line}</Text>
273
+ {overview.note && (
274
+ <NoteBox variant={overview.note.variant}>
275
+ {overview.note.bold ? <Text style={styles.noteBoxBold}>{overview.note.bold}</Text> : null}
276
+ {overview.note.text}
277
+ </NoteBox>
278
+ )}
279
+ {!unreachable && (
280
+ <View style={styles.facts}>
281
+ <View style={styles.factRow}>
282
+ <Text style={styles.factLabel}>Last checked</Text>
283
+ <Text style={styles.factValue}>{checkedAgoLabel(status.lastCheckedAt, Date.now())}</Text>
284
+ </View>
285
+ <View style={styles.factRow}>
286
+ <View style={styles.switchText}>
287
+ <Text style={styles.switchLabel}>Check for updates automatically</Text>
288
+ <Text style={styles.switchHelp}>{help.text}</Text>
289
+ </View>
290
+ <Switch
291
+ value={status.autoCheck.enabled}
292
+ onValueChange={setAutoCheck}
293
+ disabled={!live || help.locked || saving || checking}
294
+ // React Native web passes only the label to the real input (no description), so the
295
+ // helper text rides in the accessible name: a screen reader hears why it's on or off.
296
+ accessibilityLabel={`Check for updates automatically. ${help.text}`}
297
+ trackColor={{ false: '#c9c9c9', true: CATALOG_COLOR.accent }}
298
+ thumbColor="#ffffff"
299
+ // The approved mockup's 44 × 26 switch; React Native web's default is 40 × 20, below
300
+ // the 24px minimum hit target.
301
+ style={styles.switch}
302
+ {...({ activeThumbColor: '#ffffff' } as Record<string, unknown>)}
303
+ />
304
+ </View>
305
+ </View>
306
+ )}
307
+ {localError && <NoteBox variant="err">{localError}</NoteBox>}
308
+ {live && (
309
+ <View style={styles.actions}>
310
+ <Pressable
311
+ onPress={checkNow}
312
+ disabled={checking}
313
+ accessibilityRole="button"
314
+ accessibilityState={{ disabled: checking, busy: checking }}
315
+ style={[styles.secondaryButton, styles.buttonRow, checking && styles.busyButton]}
316
+ >
317
+ {checking && <ActivityIndicator size="small" color={CATALOG_COLOR.textMuted} />}
318
+ <Text style={[styles.secondaryButtonText, checking && styles.busyButtonText]}>
319
+ {checking ? 'Checking…' : overview.action === 'retry' ? 'Try again' : 'Check now'}
320
+ </Text>
321
+ </Pressable>
322
+ {unreachable && !checking && <Text style={styles.muted}>or run <Code>npm view @krapwoo/ds-viewer version</Code></Text>}
323
+ </View>
324
+ )}
325
+ </>
326
+ );
327
+ }
328
+
193
329
  /**
194
330
  * Design §5's update page (`#ds-viewer-update`), approved mockup direction "C · Update page".
195
331
  * Fetches `/update/status` once on mount, then `/plan` — `resolvePanelPhase` (Task 15) picks which
@@ -203,11 +339,16 @@ function failureCopy(failedStep: string, current: string): { headline: string; b
203
339
  */
204
340
  export function UpdatePanel({
205
341
  update,
342
+ versionStatus,
343
+ onVersionStatus,
206
344
  endpoint,
207
345
  appName,
208
346
  headingRef,
209
347
  }: {
210
348
  update: UpdateNotice | null;
349
+ /** When present, the no-update view shows the version, the switch and **Check now**. */
350
+ versionStatus?: VersionStatus | null;
351
+ onVersionStatus?: (next: VersionStatus) => void;
211
352
  endpoint?: Endpoint;
212
353
  appName: string;
213
354
  headingRef?: React.Ref<View>;
@@ -325,6 +466,8 @@ export function UpdatePanel({
325
466
  content =
326
467
  status.phase === 'success' ? (
327
468
  <SuccessContent status={status} />
469
+ ) : versionStatus ? (
470
+ <VersionDetails status={versionStatus} endpoint={endpoint} onStatus={onVersionStatus ?? (() => {})} />
328
471
  ) : (
329
472
  <NoteBox variant="ok">You’re already up to date.</NoteBox>
330
473
  );
@@ -486,7 +629,7 @@ export function UpdatePanel({
486
629
  {/* react-native-web reads `aria-level`; React Native's own prop types do not declare it —
487
630
  * the same cast `SectionBlock.tsx`'s own heading target already uses. */}
488
631
  <View ref={headingRef} tabIndex={-1} role="heading" {...({ 'aria-level': 1 } as Record<string, unknown>)} style={styles.headingTarget}>
489
- <Text style={styles.title}>Update DS Viewer</Text>
632
+ <Text style={styles.title}>DS Viewer updates</Text>
490
633
  </View>
491
634
  {content}
492
635
  </View>
@@ -517,6 +660,19 @@ const styles = StyleSheet.create({
517
660
  pillText: { fontSize: CATALOG_TYPE.sm, fontWeight: '700', color: CATALOG_COLOR.accent },
518
661
  pillTextMajor: { color: CATALOG_COLOR.warning },
519
662
  pillTextSuccess: { color: CATALOG_COLOR.success },
663
+ pillOff: { backgroundColor: CATALOG_COLOR.chip },
664
+ pillTextOff: { color: CATALOG_COLOR.textMuted },
665
+ facts: { marginTop: 18, borderTopWidth: 1, borderTopColor: CATALOG_COLOR.border },
666
+ factRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: CATALOG_SPACE.md, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: CATALOG_COLOR.border },
667
+ factLabel: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.textMuted },
668
+ factValue: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.text },
669
+ switchText: { flex: 1, gap: 2 },
670
+ switchLabel: { fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
671
+ switch: { width: 44, height: 26 },
672
+ switchHelp: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
673
+ buttonRow: { flexDirection: 'row', gap: CATALOG_SPACE.sm },
674
+ busyButton: { borderColor: CATALOG_COLOR.border },
675
+ busyButtonText: { color: CATALOG_COLOR.textMuted },
520
676
  muted: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, marginTop: 4 },
521
677
  block: { marginTop: 18 },
522
678
  h3: { fontSize: CATALOG_TYPE.panelHeading, textTransform: 'uppercase', letterSpacing: 0.5, color: CATALOG_COLOR.text, marginBottom: 8 },
@@ -3,7 +3,7 @@
3
3
  * runner. CatalogShell, CatalogSidebar, and SectionBlock all derive page order from here, so the
4
4
  * sidebar order, previous/next order, and fragment fallback can never drift apart.
5
5
  */
6
- import type { NavGroup, UpdateNotice } from './types.ts';
6
+ import type { NavGroup, UpdateNotice, VersionStatus } from './types.ts';
7
7
 
8
8
  /** THE canonical within-group ordering of section ids. */
9
9
  export function sortIds<TId extends string>(ids: readonly TId[]): TId[] {
@@ -149,3 +149,16 @@ export function footerLabel(update: UpdateNotice | null | undefined): string | u
149
149
  if (!update || !isNewerVersion(update.latest, update.current)) return undefined;
150
150
  return `Update available · ${update.latest}`;
151
151
  }
152
+
153
+ /** The sidebar's footer line: the blue "Update available" notice when a newer version is known
154
+ * (from startup or **Check now**), else the installed version, quietly. `undefined` only for a
155
+ * workspace generated before version status existed, which keeps the old notice-only footer. */
156
+ export function footerLink(
157
+ update: UpdateNotice | null | undefined,
158
+ status: VersionStatus | null | undefined,
159
+ ): { label: string; kind: 'update' | 'version' } | undefined {
160
+ const notice = footerLabel(update) ?? footerLabel(status?.update);
161
+ if (notice) return { label: notice, kind: 'update' };
162
+ if (status) return { label: `DS Viewer ${status.current}`, kind: 'version' };
163
+ return undefined;
164
+ }
@@ -256,6 +256,26 @@ export interface UpdateNotice {
256
256
  releasedAt?: string;
257
257
  }
258
258
 
259
+ /** Where the "check automatically" setting in effect comes from, strongest first: the
260
+ * `DS_VIEWER_NO_UPDATE_CHECK=1` environment variable (CI), your own switch on the update page
261
+ * (saved per project on this computer), the project's `updateCheck` config, or the default (on). */
262
+ export type AutoCheckSource = 'env' | 'personal' | 'project' | 'default';
263
+
264
+ /** What the update page and the sidebar's version line show. Written by `dev` at startup
265
+ * (`.ds-viewer/version.json`) and kept current by the local endpoint (`GET /version`,
266
+ * `POST /version/check`, `POST /version/auto-check`). */
267
+ export interface VersionStatus {
268
+ /** The installed ds-viewer version. */
269
+ current: string;
270
+ autoCheck: { enabled: boolean; source: AutoCheckSource };
271
+ /** The last successful check on this computer (any project), when known. */
272
+ lastCheckedAt?: string;
273
+ /** This run's most recent check: answered, npm unreachable, or not run (checks off). */
274
+ lastOutcome: 'ok' | 'unreachable' | 'not-run';
275
+ /** A newer version, when one is known. */
276
+ update: UpdateNotice | null;
277
+ }
278
+
259
279
  /** One titled card on a token page (`SectionDef.tokenSections`). */
260
280
  export interface TokenSection {
261
281
  /** The kind of token this card holds, e.g. "Semantic colours", "Palette", "Body". */
@@ -1,3 +1,5 @@
1
+ import type { AutoCheckSource, VersionStatus } from './types.ts';
2
+
1
3
  /** The same shape as `cli/updatePlan.ts`'s `UpdatePlan` — duplicated, not imported: `native/catalog/`
2
4
  * is the published package root and never imports from `cli/` (the reverse is normal: `cli/`
3
5
  * already imports `native/catalog/` throughout). The endpoint's `POST /plan` response is this
@@ -72,3 +74,73 @@ export function daysAgoLabel(releasedAt: string, nowMs: number): string {
72
74
  if (days === 1) return 'Released 1 day ago.';
73
75
  return `Released ${days} days ago.`;
74
76
  }
77
+
78
+ /** "Last checked" on the update page. */
79
+ export function checkedAgoLabel(at: string | undefined, nowMs: number): string {
80
+ if (!at) return 'Never';
81
+ const minutes = Math.floor((nowMs - Date.parse(at)) / 60_000);
82
+ if (minutes < 1) return 'Just now';
83
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
84
+ const hours = Math.floor(minutes / 60);
85
+ if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
86
+ const days = Math.floor(hours / 24);
87
+ return `${days} day${days === 1 ? '' : 's'} ago`;
88
+ }
89
+
90
+ export interface VersionOverview {
91
+ pill?: 'latest' | 'off';
92
+ line: string;
93
+ note?: { variant: 'ok' | 'err'; bold?: string; text: string };
94
+ /** **Check now**, or **Try again** after npm didn't answer. */
95
+ action: 'check' | 'retry';
96
+ }
97
+
98
+ /** The update page when no newer version is known: which of "latest", "checks off", "not checked
99
+ * yet" and "couldn't reach npm" applies. Only a check that actually answered may say "latest".
100
+ * `justChecked` is true right after **Check now** found nothing newer. */
101
+ export function versionOverview(status: VersionStatus, justChecked: boolean, nowMs: number): VersionOverview {
102
+ if (status.lastOutcome === 'unreachable') {
103
+ const last = status.lastCheckedAt ? ` The last successful check, ${checkedAgoLabel(status.lastCheckedAt, nowMs).toLowerCase()}, found no newer version.` : '';
104
+ return { line: `You’re on ${status.current}.`, action: 'retry', note: { variant: 'err', bold: 'Couldn’t reach npm.', text: ` You may be offline.${last}` } };
105
+ }
106
+ if (status.lastOutcome === 'not-run') {
107
+ if (!status.autoCheck.enabled) {
108
+ return { pill: 'off', line: `You’re on ${status.current}. Automatic checks are off, so this can’t tell whether a newer version exists.`, action: 'check' };
109
+ }
110
+ return { line: `You’re on ${status.current}. Not checked yet: automatic checks run when the viewer starts.`, action: 'check' };
111
+ }
112
+ // Turned off after a check answered (approved mockup state 7b); a fresh Check now still wins.
113
+ if (!status.autoCheck.enabled && !justChecked) {
114
+ return { pill: 'off', line: `You’re on ${status.current}. Automatic checks are off.`, action: 'check' };
115
+ }
116
+ return {
117
+ pill: 'latest',
118
+ line: 'You’re on the latest version.',
119
+ action: 'check',
120
+ ...(justChecked ? { note: { variant: 'ok' as const, text: 'Checked just now. No newer version.' } } : {}),
121
+ };
122
+ }
123
+
124
+ /** The switch's helper text: where the setting in effect comes from. Only CI's environment
125
+ * variable locks the switch. */
126
+ export function autoCheckHelp(autoCheck: { enabled: boolean; source: AutoCheckSource }): { text: string; locked: boolean } {
127
+ if (autoCheck.source === 'env') return { text: 'Off in this environment (DS_VIEWER_NO_UPDATE_CHECK=1).', locked: true };
128
+ if (autoCheck.source === 'personal') {
129
+ return { text: autoCheck.enabled ? 'Once a day, when the viewer starts. Saved for you on this computer.' : 'Turned off by you on this computer.', locked: false };
130
+ }
131
+ if (!autoCheck.enabled) {
132
+ return { text: 'Off for this project (updateCheck: false in ds-viewer.config.ts). Turning it on here applies to you only.', locked: false };
133
+ }
134
+ return { text: 'Once a day, when the viewer starts.', locked: false };
135
+ }
136
+
137
+ /** The update's size pill. Breaking updates are always "Major" (the CLI decides that); otherwise
138
+ * a change in the second number is "Minor" and anything smaller "Patch". */
139
+ export function bumpLabel(current: string, latest: string, breaking: boolean): 'Major' | 'Minor' | 'Patch' {
140
+ if (breaking) return 'Major';
141
+ const parts = (v: string) => /^(\d+)\.(\d+)\.(\d+)/.exec(v)?.slice(1, 4).map(Number);
142
+ const a = parts(current);
143
+ const b = parts(latest);
144
+ if (!a || !b) return 'Minor';
145
+ return a[0] === b[0] && a[1] === b[1] ? 'Patch' : 'Minor';
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",