@dennisrongo/dsh-weather 0.3.1 → 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 +4 -3
  2. package/lib/client.js +166 -27
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -5,10 +5,11 @@
5
5
  **npm:** [`@dennisrongo/dsh-weather`](https://www.npmjs.com/package/@dennisrongo/dsh-weather) ·
6
6
  **source:** [dennisrongo/dsh-plugins](https://github.com/dennisrongo/dsh-plugins/tree/main/plugins/dsh-weather)
7
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 to the bottom-center of the page.
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.
9
9
 
10
10
  - **Data:** [Open-Meteo](https://open-meteo.com) (free, no API key) via the browser.
11
- - **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.
12
13
  - **Mount point:** additive `shell.overlay` slot — pure-consumer client plugin, empty host half.
13
14
  - **Units:** Fahrenheit by default — click the temperature to toggle °F/°C. The choice is remembered in `localStorage["dsh-weather:unit"]`.
14
15
  - **Refresh:** every 15 min, plus a manual ⟳ button.
@@ -34,4 +35,4 @@ pnpm test
34
35
  name: '@dennisrongo/dsh-weather'
35
36
  ```
36
37
 
37
- 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;
@@ -242,6 +245,13 @@ body[data-ds-dark-theme] .dshwx { box-shadow: 0 0 0 1px rgba(0,0,0,0.5), 0 8px 2
242
245
  .dshwx-temp:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.08)); }
243
246
  .dshwx-temp:focus-visible { outline: 2px solid var(--dsw-alias-label-caption, #81858c); outline-offset: 1px; }
244
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); }
245
255
  .dshwx-where {
246
256
  color: var(--dsw-alias-label-tertiary, #adb2b8);
247
257
  max-width: 160px; overflow: hidden; text-overflow: ellipsis;
@@ -262,37 +272,62 @@ body[data-ds-dark-theme] .dshwx { box-shadow: 0 0 0 1px rgba(0,0,0,0.5), 0 8px 2
262
272
  @keyframes dshwx-spin { to { transform: rotate(360deg); } }
263
273
  .dshwx-error { color: var(--dsw-alias-state-error-primary, #ef4444); font-size: 12px; }
264
274
  /* --- Responsive tiers ---------------------------------------------------
265
- The bar is a single nowrap pill, so narrow screens shed detail rather than
266
- wrap. Each tier also drops the separator that preceded the hidden group,
267
- 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. */
268
285
 
269
286
  /* Tablet: drop the hourly outlook and the humidity/wind readout. */
270
- @media (max-width: 720px) {
271
- .dshwx-hours, .dshwx-meta { display: none; }
272
- .dshwx-sep-hours, .dshwx-sep-meta { display: none; }
273
- }
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; }
274
299
 
275
300
  /* Phone: tighten spacing, shrink the place name, and give the controls
276
301
  touch-sized hit areas without changing the pill's visual weight. */
277
- @media (max-width: 520px) {
278
- .dshwx {
279
- gap: 7px;
280
- padding: 4px 10px;
281
- max-width: calc(100vw - 16px);
282
- font-size: 12px;
283
- }
284
- .dshwx-where { max-width: 92px; }
285
- .dshwx-icon { font-size: 14px; }
286
- .dshwx-temp { font-size: 13px; padding: 5px 7px; margin: -4px -3px; }
287
- .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;
288
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; }
289
316
 
290
317
  /* Very narrow: the place name is the least load-bearing text \u2014 the icon,
291
318
  temperature and condition carry the meaning. */
292
- @media (max-width: 380px) {
293
- .dshwx-where, .dshwx-sep-where { display: none; }
294
- .dshwx { gap: 6px; }
295
- }
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; }
296
331
 
297
332
  /* Coarse pointers (touch) get the larger hit areas at any width. */
298
333
  @media (pointer: coarse) {
@@ -313,10 +348,114 @@ function injectStyles() {
313
348
  tag.textContent = BAR_STYLES;
314
349
  document.head.appendChild(tag);
315
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
+ }
316
436
  function WeatherBar() {
317
437
  const [state, setState] = import_react.default.useState({ status: "loading" });
318
438
  const [busy, setBusy] = import_react.default.useState(false);
319
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
+ };
320
459
  const toggleUnit = () => {
321
460
  setUnit((prev) => {
322
461
  const next = prev === "C" ? "F" : "C";
@@ -350,13 +489,13 @@ function WeatherBar() {
350
489
  fetchWeather().then(setState).catch((e) => setState({ status: "error", error: String(e?.message ?? e) })).finally(() => setBusy(false));
351
490
  };
352
491
  if (state.status === "loading") {
353
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshwx", "aria-live": "polite", children: [
354
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dshwx-icon", children: "\u{1F321}\uFE0F" }),
355
- /* @__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" })
356
495
  ] });
357
496
  }
358
497
  if (state.status === "error" || !state.now) {
359
- 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: [
360
499
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dshwx-icon", children: "\u26A0\uFE0F" }),
361
500
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dshwx-error", title: state.error, children: "Weather unavailable" }),
362
501
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: `dshwx-refresh${busy ? " busy" : ""}`, "data-dsh-no-drag": "", title: "Retry", onClick: reload, children: "\u27F3" })
@@ -365,7 +504,7 @@ function WeatherBar() {
365
504
  const { icon, label } = describeCode(state.now.weatherCode, state.now.isDay);
366
505
  const other = unit === "C" ? "F" : "C";
367
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}`;
368
- 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: [
369
508
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dshwx-icon", children: icon }),
370
509
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
371
510
  "button",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dennisrongo/dsh-weather",
3
- "version": "0.3.1",
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",