@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.
- package/README.md +4 -3
- package/lib/client.js +166 -27
- 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
|
|
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
|
|
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
|
|
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
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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", {
|
|
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", {
|
|
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", {
|
|
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.
|
|
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",
|