@dennisrongo/dsh-weather 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +9 -3
  2. package/lib/client.js +193 -36
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,9 +1,15 @@
1
1
  # dsh-weather
2
2
 
3
- Weather bar for the [DeepSeek Harness](https://github.com/deepseek-ai/dsh) web UI — current conditions, a short hourly outlook, humidity and wind, pinned to the bottom-center of the page.
3
+ [![npm](https://img.shields.io/npm/v/@dennisrongo/dsh-weather)](https://www.npmjs.com/package/@dennisrongo/dsh-weather)
4
+
5
+ **npm:** [`@dennisrongo/dsh-weather`](https://www.npmjs.com/package/@dennisrongo/dsh-weather) ·
6
+ **source:** [dennisrongo/dsh-plugins](https://github.com/dennisrongo/dsh-plugins/tree/main/plugins/dsh-weather)
7
+
8
+ Weather bar for the [DeepSeek Harness](https://github.com/deepseek-ai/dsh) web UI — current conditions, a short hourly outlook, humidity and wind, pinned across the top of the page.
4
9
 
5
10
  - **Data:** [Open-Meteo](https://open-meteo.com) (free, no API key) via the browser.
6
- - **Location:** `localStorage["dsh-weather:location"] = "Your City"` if set, else coarse IP geolocation (ipapi.co), else New York.
11
+ - **Location:** `localStorage["dsh-weather:location"] = "Your City"` if set, else a coarse IP-geolocation provider chain, else New York.
12
+ - **Placement:** centred on the space the shell has actually left it, not on the viewport. The bar measures the shell frame's content box — which already excludes `dsh-mission-control`'s docked rail — and subtracts any overlay flying a `data-dsh-overlay-claim="right"` marker, today `dsh-plan-board`'s plan panel. So it slides aside and sheds detail as the free span narrows instead of sitting underneath them.
7
13
  - **Mount point:** additive `shell.overlay` slot — pure-consumer client plugin, empty host half.
8
14
  - **Units:** Fahrenheit by default — click the temperature to toggle °F/°C. The choice is remembered in `localStorage["dsh-weather:unit"]`.
9
15
  - **Refresh:** every 15 min, plus a manual ⟳ button.
@@ -29,4 +35,4 @@ pnpm test
29
35
  name: '@dennisrongo/dsh-weather'
30
36
  ```
31
37
 
32
- Restart the profile; the bar appears at the bottom of the web UI.
38
+ Restart the profile; the bar appears across the top of the web UI.
package/lib/client.js CHANGED
@@ -191,6 +191,9 @@ async function fetchWeather() {
191
191
  var BAR_STYLES = `
192
192
  .dshwx {
193
193
  position: fixed;
194
+ /* Centred on the viewport only until the bar has measured the shell (see
195
+ useBandFit): the real centre is the middle of the span no docked overlay
196
+ has claimed, written inline. */
194
197
  left: 50%;
195
198
  transform: translateX(-50%);
196
199
  top: 8px;
@@ -205,19 +208,36 @@ var BAR_STYLES = `
205
208
  border: 1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.12));
206
209
  background: var(--dsw-specific-sidebar-fill, #1b1b1c);
207
210
  color: var(--dsw-alias-label-secondary, #cfd3d6);
208
- font: 400 12px/1.4 var(--dsw-font-family, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif);
211
+ font: 400 13px/1.4 var(--dsw-font-family, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif);
209
212
  font-variant-numeric: tabular-nums;
210
213
  box-shadow: var(--dsw-shadow-lv3, 0 0 1px rgba(0,0,0,0.2), 0 8px 24px rgba(0,0,0,0.12));
211
214
  cursor: default;
212
215
  user-select: none;
213
216
  white-space: nowrap;
217
+ /* DSH Desktop on Windows overlays a 36px window-drag strip at the top of the
218
+ viewport (#dsh-desktop-windows-drag-region: -webkit-app-region: drag,
219
+ z-index 2147483644, pointer-events: none) that the compositor resolves
220
+ BEFORE hit-testing. no-drag here is belt-and-braces only \u2014 the desktop
221
+ preload already grants every button no-drag !important and the bar was
222
+ still unclickable, so a covered element's no-drag does not punch a hole
223
+ in an overlapping drag element (the same failure dsh-mission-control
224
+ documented for its stage bar). The real fix is the layout rule below,
225
+ which drops the bar clear of the strip. In a plain browser all of this
226
+ is inert. */
227
+ -webkit-app-region: no-drag;
228
+ }
229
+ body.dsh-desktop-windows-titlebar-layout .dshwx {
230
+ /* Clear the desktop drag strip: 36px strip + the usual 8px gap. The body
231
+ class is added by DSH Desktop's preload on Windows only, so the browser
232
+ and non-Windows builds keep top: 8px. */
233
+ top: 44px;
214
234
  }
215
235
  body[data-ds-dark-theme] .dshwx { box-shadow: 0 0 0 1px rgba(0,0,0,0.5), 0 8px 24px rgba(0,0,0,0.5); }
216
236
  .dshwx[hidden] { display: none; }
217
- .dshwx-icon { font-size: 15px; }
237
+ .dshwx-icon { font-size: 16px; }
218
238
  .dshwx-temp {
219
239
  color: var(--dsw-alias-label-primary, #f9fafb);
220
- font-weight: 600; font-size: 13px;
240
+ font-weight: 600; font-size: 14px;
221
241
  font-family: inherit; font-variant-numeric: tabular-nums;
222
242
  border: 0; background: transparent; padding: 1px 4px; margin: 0 -2px;
223
243
  border-radius: 6px; cursor: pointer; line-height: inherit;
@@ -225,57 +245,89 @@ body[data-ds-dark-theme] .dshwx { box-shadow: 0 0 0 1px rgba(0,0,0,0.5), 0 8px 2
225
245
  .dshwx-temp:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.08)); }
226
246
  .dshwx-temp:focus-visible { outline: 2px solid var(--dsw-alias-label-caption, #81858c); outline-offset: 1px; }
227
247
  .dshwx-label { color: var(--dsw-alias-label-secondary, #cfd3d6); }
248
+ /* The bar is a ~200px pill in the shell's top band \u2014 a small surface, so the
249
+ loading state stays TEXT rather than becoming a skeleton, which would be
250
+ heavier than the string it replaced. It takes the dim caption tone the other
251
+ plugins use for the same rung; a MODIFIER rather than a change to
252
+ .dshwx-label, which is shared with the loaded condition text and must keep
253
+ its secondary weight. */
254
+ .dshwx-label.loading { color: var(--dsw-alias-label-tertiary, #adb2b8); }
228
255
  .dshwx-where {
229
256
  color: var(--dsw-alias-label-tertiary, #adb2b8);
230
257
  max-width: 160px; overflow: hidden; text-overflow: ellipsis;
231
258
  }
232
259
  .dshwx-sep { width: 1px; height: 14px; background: var(--dsw-alias-border-l2, rgba(255,255,255,0.12)); flex: none; }
233
- .dshwx-meta { color: var(--dsw-alias-label-caption, #81858c); font-size: 11px; }
260
+ .dshwx-meta { color: var(--dsw-alias-label-caption, #81858c); font-size: 12px; }
234
261
  .dshwx-hours { display: flex; gap: 8px; align-items: center; }
235
- .dshwx-hour { display: flex; align-items: center; gap: 3px; color: var(--dsw-alias-label-caption, #81858c); font-size: 11px; }
262
+ .dshwx-hour { display: flex; align-items: center; gap: 3px; color: var(--dsw-alias-label-caption, #81858c); font-size: 12px; }
236
263
  .dshwx-hour b { color: var(--dsw-alias-label-secondary, #cfd3d6); font-weight: 500; }
237
264
  .dshwx-refresh {
238
265
  border: 0; background: transparent; cursor: pointer;
239
266
  color: var(--dsw-alias-label-caption, #81858c);
240
- font-size: 12px; padding: 2px; border-radius: 50%;
267
+ font-size: 14px; padding: 2px; border-radius: 50%;
241
268
  display: grid; place-items: center;
242
269
  }
243
270
  .dshwx-refresh:hover { color: var(--dsw-alias-label-primary, #f9fafb); background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.08)); }
244
271
  .dshwx-refresh.busy { animation: dshwx-spin 0.9s linear infinite; }
245
272
  @keyframes dshwx-spin { to { transform: rotate(360deg); } }
246
- .dshwx-error { color: var(--dsw-alias-state-error-primary, #ef4444); font-size: 11.5px; }
273
+ .dshwx-error { color: var(--dsw-alias-state-error-primary, #ef4444); font-size: 12px; }
247
274
  /* --- Responsive tiers ---------------------------------------------------
248
- The bar is a single nowrap pill, so narrow screens shed detail rather than
249
- wrap. Each tier also drops the separator that preceded the hidden group,
250
- otherwise stray dividers float with nothing between them. */
275
+ The bar is a single nowrap pill, so it sheds detail rather than wrap. Each
276
+ tier also drops the separator that preceded the hidden group, otherwise
277
+ stray dividers float with nothing between them.
278
+
279
+ Keyed on the MEASURED band (data-fit), not on a viewport media query. The
280
+ space this bar actually gets is the shell's content box minus whatever a
281
+ docked overlay has claimed, so a 2400px window with a plan panel open can
282
+ leave the bar less room than a phone \u2014 a media query would call that "full"
283
+ and let the pill run under the panel. The measurement falls back to the
284
+ viewport when there is no shell frame, so the tiers still work standalone. */
251
285
 
252
286
  /* Tablet: drop the hourly outlook and the humidity/wind readout. */
253
- @media (max-width: 720px) {
254
- .dshwx-hours, .dshwx-meta { display: none; }
255
- .dshwx-sep-hours, .dshwx-sep-meta { display: none; }
256
- }
287
+ .dshwx[data-fit="tablet"] .dshwx-hours,
288
+ .dshwx[data-fit="tablet"] .dshwx-meta,
289
+ .dshwx[data-fit="tablet"] .dshwx-sep-hours,
290
+ .dshwx[data-fit="tablet"] .dshwx-sep-meta,
291
+ .dshwx[data-fit="phone"] .dshwx-hours,
292
+ .dshwx[data-fit="phone"] .dshwx-meta,
293
+ .dshwx[data-fit="phone"] .dshwx-sep-hours,
294
+ .dshwx[data-fit="phone"] .dshwx-sep-meta,
295
+ .dshwx[data-fit="tiny"] .dshwx-hours,
296
+ .dshwx[data-fit="tiny"] .dshwx-meta,
297
+ .dshwx[data-fit="tiny"] .dshwx-sep-hours,
298
+ .dshwx[data-fit="tiny"] .dshwx-sep-meta { display: none; }
257
299
 
258
300
  /* Phone: tighten spacing, shrink the place name, and give the controls
259
301
  touch-sized hit areas without changing the pill's visual weight. */
260
- @media (max-width: 520px) {
261
- .dshwx {
262
- gap: 7px;
263
- padding: 4px 10px;
264
- max-width: calc(100vw - 16px);
265
- font-size: 11.5px;
266
- }
267
- .dshwx-where { max-width: 92px; }
268
- .dshwx-icon { font-size: 14px; }
269
- .dshwx-temp { font-size: 12.5px; padding: 5px 7px; margin: -4px -3px; }
270
- .dshwx-refresh { padding: 7px; margin: -5px; }
302
+ .dshwx[data-fit="phone"],
303
+ .dshwx[data-fit="tiny"] {
304
+ gap: 7px;
305
+ padding: 4px 10px;
306
+ font-size: 12px;
271
307
  }
308
+ .dshwx[data-fit="phone"] .dshwx-where,
309
+ .dshwx[data-fit="tiny"] .dshwx-where { max-width: 92px; }
310
+ .dshwx[data-fit="phone"] .dshwx-icon,
311
+ .dshwx[data-fit="tiny"] .dshwx-icon { font-size: 14px; }
312
+ .dshwx[data-fit="phone"] .dshwx-temp,
313
+ .dshwx[data-fit="tiny"] .dshwx-temp { font-size: 13px; padding: 5px 7px; margin: -4px -3px; }
314
+ .dshwx[data-fit="phone"] .dshwx-refresh,
315
+ .dshwx[data-fit="tiny"] .dshwx-refresh { padding: 7px; margin: -5px; }
272
316
 
273
317
  /* Very narrow: the place name is the least load-bearing text \u2014 the icon,
274
318
  temperature and condition carry the meaning. */
275
- @media (max-width: 380px) {
276
- .dshwx-where, .dshwx-sep-where { display: none; }
277
- .dshwx { gap: 6px; }
278
- }
319
+ .dshwx[data-fit="tiny"] .dshwx-where,
320
+ .dshwx[data-fit="tiny"] .dshwx-sep-where { display: none; }
321
+ .dshwx[data-fit="tiny"] { gap: 6px; }
322
+
323
+ /* Nowhere left to stand. Better absent for the moment a full-width overlay is
324
+ up than a clipped stub sliding under it.
325
+
326
+ visibility, NOT display. A display:none bar has no box, so
327
+ getBoundingClientRect reports zero height, the band measurement has no rows
328
+ to test claimants against and bails \u2014 and the bar would stay hidden forever
329
+ after the overlay that squeezed it went away. A hidden box is still a box. */
330
+ .dshwx[data-fit="none"] { visibility: hidden; pointer-events: none; }
279
331
 
280
332
  /* Coarse pointers (touch) get the larger hit areas at any width. */
281
333
  @media (pointer: coarse) {
@@ -296,10 +348,114 @@ function injectStyles() {
296
348
  tag.textContent = BAR_STYLES;
297
349
  document.head.appendChild(tag);
298
350
  }
351
+ var CLAIM_SELECTOR = '[data-dsh-overlay-claim="right"]';
352
+ var BAND_GUTTER = 16;
353
+ function zoomOf(el) {
354
+ const own = el.currentCSSZoom;
355
+ if (typeof own === "number" && own > 0) return own;
356
+ const width = el.getBoundingClientRect().width;
357
+ return el.offsetWidth > 0 && width > 0 ? width / el.offsetWidth : 1;
358
+ }
359
+ function tierOf(width) {
360
+ if (width === void 0) return "full";
361
+ if (width < 200) return "none";
362
+ if (width <= 380) return "tiny";
363
+ if (width <= 520) return "phone";
364
+ if (width <= 720) return "tablet";
365
+ return "full";
366
+ }
367
+ function measureBand(band) {
368
+ const layer = document.querySelector("[data-shell-overlay]");
369
+ const frame = layer?.parentElement ?? null;
370
+ let left = 0;
371
+ let right = window.innerWidth;
372
+ if (frame !== null) {
373
+ const rect = frame.getBoundingClientRect();
374
+ const style = getComputedStyle(frame);
375
+ const zoom = zoomOf(frame);
376
+ left = rect.left + (parseFloat(style.paddingLeft) || 0) * zoom;
377
+ right = rect.right - (parseFloat(style.paddingRight) || 0) * zoom;
378
+ }
379
+ for (const claim of Array.from(document.querySelectorAll(CLAIM_SELECTOR))) {
380
+ const rect = claim.getBoundingClientRect();
381
+ if (rect.width === 0 || rect.height === 0) continue;
382
+ if (rect.bottom <= band.top || rect.top >= band.bottom) continue;
383
+ if (rect.right < right - 1) continue;
384
+ right = Math.min(right, rect.left);
385
+ }
386
+ return {
387
+ centre: left + (right - left) / 2,
388
+ width: right - left - BAND_GUTTER * 2,
389
+ zoom: frame === null ? 1 : zoomOf(frame)
390
+ };
391
+ }
392
+ function useBandFit(ref) {
393
+ const [fit, setFit] = import_react.default.useState(null);
394
+ import_react.default.useLayoutEffect(() => {
395
+ const el = ref.current;
396
+ if (el === null) return;
397
+ const measure = () => {
398
+ const self = el.getBoundingClientRect();
399
+ if (self.height === 0) return;
400
+ const next = measureBand({ top: self.top, bottom: self.bottom });
401
+ setFit(
402
+ (prev) => prev !== null && Math.abs(prev.centre - next.centre) < 0.5 && Math.abs(prev.width - next.width) < 0.5 && prev.zoom === next.zoom ? prev : next
403
+ );
404
+ };
405
+ measure();
406
+ const layer = document.querySelector("[data-shell-overlay]");
407
+ const frame = layer?.parentElement ?? null;
408
+ const resize = new ResizeObserver(measure);
409
+ if (frame !== null) resize.observe(frame);
410
+ if (layer !== null) resize.observe(layer);
411
+ const scaleWatch = new MutationObserver(measure);
412
+ scaleWatch.observe(document.body, { attributes: true, attributeFilter: ["style", "class"] });
413
+ const mutation = new MutationObserver((records) => {
414
+ if (records.every((record) => el.contains(record.target))) return;
415
+ measure();
416
+ });
417
+ if (layer !== null) {
418
+ mutation.observe(layer, {
419
+ subtree: true,
420
+ childList: true,
421
+ attributes: true,
422
+ attributeFilter: ["style", "data-dsh-overlay-claim", "hidden"]
423
+ });
424
+ }
425
+ if (frame !== null) mutation.observe(frame, { attributes: true, attributeFilter: ["style"] });
426
+ window.addEventListener("resize", measure);
427
+ return () => {
428
+ resize.disconnect();
429
+ scaleWatch.disconnect();
430
+ mutation.disconnect();
431
+ window.removeEventListener("resize", measure);
432
+ };
433
+ }, [ref]);
434
+ return fit;
435
+ }
299
436
  function WeatherBar() {
300
437
  const [state, setState] = import_react.default.useState({ status: "loading" });
301
438
  const [busy, setBusy] = import_react.default.useState(false);
302
439
  const [unit, setUnit] = import_react.default.useState(loadUnit);
440
+ const ref = import_react.default.useRef(null);
441
+ const fit = useBandFit(ref);
442
+ const shell = {
443
+ ref,
444
+ className: "dshwx",
445
+ "data-fit": tierOf(fit?.width),
446
+ // A non-positive width is the squeezed-out case: the `none` tier hides the
447
+ // bar, and writing a negative max-width would be an ignored declaration
448
+ // that left it at full size behind the overlay.
449
+ //
450
+ // Both lengths are divided by the zoom: `fit` is measured in viewport px
451
+ // and these are author px the zoom scales again (see zoomOf).
452
+ ...fit === null || fit.width <= 0 ? {} : {
453
+ style: {
454
+ left: `${fit.centre / fit.zoom}px`,
455
+ maxWidth: `${fit.width / fit.zoom}px`
456
+ }
457
+ }
458
+ };
303
459
  const toggleUnit = () => {
304
460
  setUnit((prev) => {
305
461
  const next = prev === "C" ? "F" : "C";
@@ -333,28 +489,29 @@ function WeatherBar() {
333
489
  fetchWeather().then(setState).catch((e) => setState({ status: "error", error: String(e?.message ?? e) })).finally(() => setBusy(false));
334
490
  };
335
491
  if (state.status === "loading") {
336
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshwx", "aria-live": "polite", children: [
337
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dshwx-icon", children: "\u{1F321}\uFE0F" }),
338
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dshwx-label", children: "Loading weather\u2026" })
492
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { ...shell, role: "status", "aria-live": "polite", "aria-busy": "true", children: [
493
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dshwx-icon", "aria-hidden": "true", children: "\u{1F321}\uFE0F" }),
494
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dshwx-label loading", children: "Loading weather\u2026" })
339
495
  ] });
340
496
  }
341
497
  if (state.status === "error" || !state.now) {
342
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshwx", "aria-live": "polite", children: [
498
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { ...shell, "aria-live": "polite", children: [
343
499
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dshwx-icon", children: "\u26A0\uFE0F" }),
344
500
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dshwx-error", title: state.error, children: "Weather unavailable" }),
345
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: `dshwx-refresh${busy ? " busy" : ""}`, title: "Retry", onClick: reload, children: "\u27F3" })
501
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: `dshwx-refresh${busy ? " busy" : ""}`, "data-dsh-no-drag": "", title: "Retry", onClick: reload, children: "\u27F3" })
346
502
  ] });
347
503
  }
348
504
  const { icon, label } = describeCode(state.now.weatherCode, state.now.isDay);
349
505
  const other = unit === "C" ? "F" : "C";
350
506
  const title = `${label} in ${state.where ?? ""} \u2014 feels like ${fmtTemp(state.now.apparentC, unit)}, humidity ${state.now.humidity}%, wind ${Math.round(state.now.windKph)} km/h \xB7 click the temperature for \xB0${other}`;
351
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshwx", title, "aria-live": "polite", children: [
507
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { ...shell, title, "aria-live": "polite", children: [
352
508
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dshwx-icon", children: icon }),
353
509
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
354
510
  "button",
355
511
  {
356
512
  type: "button",
357
513
  className: "dshwx-temp",
514
+ "data-dsh-no-drag": "",
358
515
  onClick: toggleUnit,
359
516
  title: `Switch to \xB0${other}`,
360
517
  "aria-label": `Temperature ${fmtTemp(state.now.temperatureC, unit)}. Switch to degrees ${other === "F" ? "Fahrenheit" : "Celsius"}.`,
@@ -384,7 +541,7 @@ function WeatherBar() {
384
541
  Math.round(state.now.windKph),
385
542
  "km/h"
386
543
  ] }),
387
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: `dshwx-refresh${busy ? " busy" : ""}`, title: "Refresh weather", onClick: reload, children: "\u27F3" })
544
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: `dshwx-refresh${busy ? " busy" : ""}`, "data-dsh-no-drag": "", title: "Refresh weather", onClick: reload, children: "\u27F3" })
388
545
  ] });
389
546
  }
390
547
  function apply(ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dennisrongo/dsh-weather",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Weather bar for DeepSeek Harness (dsh) — current conditions at the bottom of the web UI via Open-Meteo, rendered into shell.overlay",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,7 +29,7 @@
29
29
  "postbuild": "node build/postbuild.mjs",
30
30
  "deploy": "node build/postbuild.mjs",
31
31
  "typecheck": "tsc --noEmit",
32
- "test": "node test/smoke.mjs"
32
+ "test": "node build/build.mjs && node test/smoke.mjs"
33
33
  },
34
34
  "dsh": {
35
35
  "bundle": {