@dennisrongo/dsh-weather 0.3.1 → 0.4.0
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 +381 -29
- package/lib/index.js +83 -1
- package/package.json +2 -2
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:** drag the pill anywhere. Until you move it, it stays centred on the space the shell has actually left it, not on the viewport — it 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. After a drag the spot is remembered (cookie `dsh-weather-pos`, with `localStorage["dsh-weather:pos"]` as a fallback) so it survives a web UI or DSH Desktop restart. Click the temperature to toggle units; that does not start a drag.
|
|
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
|
@@ -44,6 +44,81 @@ __export(client_exports, {
|
|
|
44
44
|
});
|
|
45
45
|
module.exports = __toCommonJS(client_exports);
|
|
46
46
|
var import_react = __toESM(require("react"), 1);
|
|
47
|
+
|
|
48
|
+
// src/position.ts
|
|
49
|
+
var POS_COOKIE = "dsh-weather-pos";
|
|
50
|
+
var POS_KEY = "dsh-weather:pos";
|
|
51
|
+
var MAX_AGE = 31536e4;
|
|
52
|
+
function formatPos(pos) {
|
|
53
|
+
return `${pos.x.toFixed(4)},${pos.y.toFixed(4)}`;
|
|
54
|
+
}
|
|
55
|
+
function parsePos(raw) {
|
|
56
|
+
if (raw == null || raw === "") return null;
|
|
57
|
+
const comma = raw.indexOf(",");
|
|
58
|
+
if (comma === -1) return null;
|
|
59
|
+
const x = Number(raw.slice(0, comma));
|
|
60
|
+
const y = Number(raw.slice(comma + 1));
|
|
61
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
|
62
|
+
if (x < 0 || x > 1 || y < 0 || y > 1) return null;
|
|
63
|
+
return { x, y };
|
|
64
|
+
}
|
|
65
|
+
function rangeOf(box) {
|
|
66
|
+
const minLeft = box.pad;
|
|
67
|
+
const maxLeft = Math.max(minLeft, box.viewW - box.width - box.pad);
|
|
68
|
+
const minTop = Math.max(box.pad, box.minTop);
|
|
69
|
+
const maxTop = Math.max(minTop, box.viewH - box.height - box.pad);
|
|
70
|
+
return { minLeft, maxLeft, minTop, maxTop };
|
|
71
|
+
}
|
|
72
|
+
function clampPx(left, top, box) {
|
|
73
|
+
const { minLeft, maxLeft, minTop, maxTop } = rangeOf(box);
|
|
74
|
+
return {
|
|
75
|
+
left: Math.min(maxLeft, Math.max(minLeft, left)),
|
|
76
|
+
top: Math.min(maxTop, Math.max(minTop, top))
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function posToPx(pos, box) {
|
|
80
|
+
const { minLeft, maxLeft, minTop, maxTop } = rangeOf(box);
|
|
81
|
+
return {
|
|
82
|
+
left: minLeft + pos.x * (maxLeft - minLeft),
|
|
83
|
+
top: minTop + pos.y * (maxTop - minTop)
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function pxToPos(left, top, box) {
|
|
87
|
+
const { minLeft, maxLeft, minTop, maxTop } = rangeOf(box);
|
|
88
|
+
const spanX = maxLeft - minLeft;
|
|
89
|
+
const spanY = maxTop - minTop;
|
|
90
|
+
return {
|
|
91
|
+
x: spanX <= 0 ? 0 : (left - minLeft) / spanX,
|
|
92
|
+
y: spanY <= 0 ? 0 : (top - minTop) / spanY
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function readCookie(jar, name) {
|
|
96
|
+
for (const part of jar.split(";")) {
|
|
97
|
+
const at = part.indexOf("=");
|
|
98
|
+
if (at === -1) continue;
|
|
99
|
+
if (part.slice(0, at).trim() !== name) continue;
|
|
100
|
+
return decodeURIComponent(part.slice(at + 1).trim());
|
|
101
|
+
}
|
|
102
|
+
return void 0;
|
|
103
|
+
}
|
|
104
|
+
function posCookieWrite(pos) {
|
|
105
|
+
return `${POS_COOKIE}=${encodeURIComponent(formatPos(pos))}; Path=/; Max-Age=${MAX_AGE}; SameSite=Lax`;
|
|
106
|
+
}
|
|
107
|
+
function loadPosFromStores(jar, storageGet) {
|
|
108
|
+
try {
|
|
109
|
+
const cookie = readCookie(jar, POS_COOKIE);
|
|
110
|
+
const fromCookie = parsePos(cookie);
|
|
111
|
+
if (fromCookie) return fromCookie;
|
|
112
|
+
} catch {
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
return parsePos(storageGet(POS_KEY));
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/client.tsx
|
|
47
122
|
var import_jsx_runtime = require("react/jsx-runtime");
|
|
48
123
|
var inject = ["slots"];
|
|
49
124
|
function describeCode(code, isDay = true) {
|
|
@@ -94,6 +169,24 @@ function saveUnit(unit) {
|
|
|
94
169
|
} catch {
|
|
95
170
|
}
|
|
96
171
|
}
|
|
172
|
+
function loadPos() {
|
|
173
|
+
try {
|
|
174
|
+
return loadPosFromStores(document.cookie, (key) => window.localStorage.getItem(key));
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function savePos(pos) {
|
|
180
|
+
try {
|
|
181
|
+
document.cookie = posCookieWrite(pos);
|
|
182
|
+
} catch {
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
window.localStorage.setItem(POS_KEY, formatPos(pos));
|
|
186
|
+
} catch {
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
var DRAG_THRESHOLD_PX = 4;
|
|
97
190
|
var DEFAULT_LOCATION = { latitude: 40.7128, longitude: -74.006, label: "New York" };
|
|
98
191
|
var GEO_TIMEOUT_MS = 4e3;
|
|
99
192
|
var GEO_PROVIDERS = [
|
|
@@ -191,6 +284,9 @@ async function fetchWeather() {
|
|
|
191
284
|
var BAR_STYLES = `
|
|
192
285
|
.dshwx {
|
|
193
286
|
position: fixed;
|
|
287
|
+
/* Centred on the viewport only until the bar has measured the shell (see
|
|
288
|
+
useBandFit): the real centre is the middle of the span no docked overlay
|
|
289
|
+
has claimed, written inline. */
|
|
194
290
|
left: 50%;
|
|
195
291
|
transform: translateX(-50%);
|
|
196
292
|
top: 8px;
|
|
@@ -208,9 +304,10 @@ var BAR_STYLES = `
|
|
|
208
304
|
font: 400 13px/1.4 var(--dsw-font-family, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif);
|
|
209
305
|
font-variant-numeric: tabular-nums;
|
|
210
306
|
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
|
-
cursor:
|
|
307
|
+
cursor: grab;
|
|
212
308
|
user-select: none;
|
|
213
309
|
white-space: nowrap;
|
|
310
|
+
touch-action: none;
|
|
214
311
|
/* DSH Desktop on Windows overlays a 36px window-drag strip at the top of the
|
|
215
312
|
viewport (#dsh-desktop-windows-drag-region: -webkit-app-region: drag,
|
|
216
313
|
z-index 2147483644, pointer-events: none) that the compositor resolves
|
|
@@ -226,9 +323,12 @@ var BAR_STYLES = `
|
|
|
226
323
|
body.dsh-desktop-windows-titlebar-layout .dshwx {
|
|
227
324
|
/* Clear the desktop drag strip: 36px strip + the usual 8px gap. The body
|
|
228
325
|
class is added by DSH Desktop's preload on Windows only, so the browser
|
|
229
|
-
and non-Windows builds keep top: 8px.
|
|
326
|
+
and non-Windows builds keep top: 8px. A user-placed inline top overrides
|
|
327
|
+
this; clampPx's minTop keeps a drag from parking back inside the strip. */
|
|
230
328
|
top: 44px;
|
|
231
329
|
}
|
|
330
|
+
.dshwx[data-placed] { transform: none; }
|
|
331
|
+
.dshwx[data-dragging] { cursor: grabbing; }
|
|
232
332
|
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); }
|
|
233
333
|
.dshwx[hidden] { display: none; }
|
|
234
334
|
.dshwx-icon { font-size: 16px; }
|
|
@@ -242,6 +342,13 @@ body[data-ds-dark-theme] .dshwx { box-shadow: 0 0 0 1px rgba(0,0,0,0.5), 0 8px 2
|
|
|
242
342
|
.dshwx-temp:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.08)); }
|
|
243
343
|
.dshwx-temp:focus-visible { outline: 2px solid var(--dsw-alias-label-caption, #81858c); outline-offset: 1px; }
|
|
244
344
|
.dshwx-label { color: var(--dsw-alias-label-secondary, #cfd3d6); }
|
|
345
|
+
/* The bar is a ~200px pill in the shell's top band \u2014 a small surface, so the
|
|
346
|
+
loading state stays TEXT rather than becoming a skeleton, which would be
|
|
347
|
+
heavier than the string it replaced. It takes the dim caption tone the other
|
|
348
|
+
plugins use for the same rung; a MODIFIER rather than a change to
|
|
349
|
+
.dshwx-label, which is shared with the loaded condition text and must keep
|
|
350
|
+
its secondary weight. */
|
|
351
|
+
.dshwx-label.loading { color: var(--dsw-alias-label-tertiary, #adb2b8); }
|
|
245
352
|
.dshwx-where {
|
|
246
353
|
color: var(--dsw-alias-label-tertiary, #adb2b8);
|
|
247
354
|
max-width: 160px; overflow: hidden; text-overflow: ellipsis;
|
|
@@ -262,37 +369,62 @@ body[data-ds-dark-theme] .dshwx { box-shadow: 0 0 0 1px rgba(0,0,0,0.5), 0 8px 2
|
|
|
262
369
|
@keyframes dshwx-spin { to { transform: rotate(360deg); } }
|
|
263
370
|
.dshwx-error { color: var(--dsw-alias-state-error-primary, #ef4444); font-size: 12px; }
|
|
264
371
|
/* --- Responsive tiers ---------------------------------------------------
|
|
265
|
-
The bar is a single nowrap pill, so
|
|
266
|
-
|
|
267
|
-
|
|
372
|
+
The bar is a single nowrap pill, so it sheds detail rather than wrap. Each
|
|
373
|
+
tier also drops the separator that preceded the hidden group, otherwise
|
|
374
|
+
stray dividers float with nothing between them.
|
|
375
|
+
|
|
376
|
+
Keyed on the MEASURED band (data-fit), not on a viewport media query. The
|
|
377
|
+
space this bar actually gets is the shell's content box minus whatever a
|
|
378
|
+
docked overlay has claimed, so a 2400px window with a plan panel open can
|
|
379
|
+
leave the bar less room than a phone \u2014 a media query would call that "full"
|
|
380
|
+
and let the pill run under the panel. The measurement falls back to the
|
|
381
|
+
viewport when there is no shell frame, so the tiers still work standalone. */
|
|
268
382
|
|
|
269
383
|
/* Tablet: drop the hourly outlook and the humidity/wind readout. */
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
384
|
+
.dshwx[data-fit="tablet"] .dshwx-hours,
|
|
385
|
+
.dshwx[data-fit="tablet"] .dshwx-meta,
|
|
386
|
+
.dshwx[data-fit="tablet"] .dshwx-sep-hours,
|
|
387
|
+
.dshwx[data-fit="tablet"] .dshwx-sep-meta,
|
|
388
|
+
.dshwx[data-fit="phone"] .dshwx-hours,
|
|
389
|
+
.dshwx[data-fit="phone"] .dshwx-meta,
|
|
390
|
+
.dshwx[data-fit="phone"] .dshwx-sep-hours,
|
|
391
|
+
.dshwx[data-fit="phone"] .dshwx-sep-meta,
|
|
392
|
+
.dshwx[data-fit="tiny"] .dshwx-hours,
|
|
393
|
+
.dshwx[data-fit="tiny"] .dshwx-meta,
|
|
394
|
+
.dshwx[data-fit="tiny"] .dshwx-sep-hours,
|
|
395
|
+
.dshwx[data-fit="tiny"] .dshwx-sep-meta { display: none; }
|
|
274
396
|
|
|
275
397
|
/* Phone: tighten spacing, shrink the place name, and give the controls
|
|
276
398
|
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; }
|
|
399
|
+
.dshwx[data-fit="phone"],
|
|
400
|
+
.dshwx[data-fit="tiny"] {
|
|
401
|
+
gap: 7px;
|
|
402
|
+
padding: 4px 10px;
|
|
403
|
+
font-size: 12px;
|
|
288
404
|
}
|
|
405
|
+
.dshwx[data-fit="phone"] .dshwx-where,
|
|
406
|
+
.dshwx[data-fit="tiny"] .dshwx-where { max-width: 92px; }
|
|
407
|
+
.dshwx[data-fit="phone"] .dshwx-icon,
|
|
408
|
+
.dshwx[data-fit="tiny"] .dshwx-icon { font-size: 14px; }
|
|
409
|
+
.dshwx[data-fit="phone"] .dshwx-temp,
|
|
410
|
+
.dshwx[data-fit="tiny"] .dshwx-temp { font-size: 13px; padding: 5px 7px; margin: -4px -3px; }
|
|
411
|
+
.dshwx[data-fit="phone"] .dshwx-refresh,
|
|
412
|
+
.dshwx[data-fit="tiny"] .dshwx-refresh { padding: 7px; margin: -5px; }
|
|
289
413
|
|
|
290
414
|
/* Very narrow: the place name is the least load-bearing text \u2014 the icon,
|
|
291
415
|
temperature and condition carry the meaning. */
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
416
|
+
.dshwx[data-fit="tiny"] .dshwx-where,
|
|
417
|
+
.dshwx[data-fit="tiny"] .dshwx-sep-where { display: none; }
|
|
418
|
+
.dshwx[data-fit="tiny"] { gap: 6px; }
|
|
419
|
+
|
|
420
|
+
/* Nowhere left to stand. Better absent for the moment a full-width overlay is
|
|
421
|
+
up than a clipped stub sliding under it.
|
|
422
|
+
|
|
423
|
+
visibility, NOT display. A display:none bar has no box, so
|
|
424
|
+
getBoundingClientRect reports zero height, the band measurement has no rows
|
|
425
|
+
to test claimants against and bails \u2014 and the bar would stay hidden forever
|
|
426
|
+
after the overlay that squeezed it went away. A hidden box is still a box. */
|
|
427
|
+
.dshwx[data-fit="none"] { visibility: hidden; pointer-events: none; }
|
|
296
428
|
|
|
297
429
|
/* Coarse pointers (touch) get the larger hit areas at any width. */
|
|
298
430
|
@media (pointer: coarse) {
|
|
@@ -313,10 +445,230 @@ function injectStyles() {
|
|
|
313
445
|
tag.textContent = BAR_STYLES;
|
|
314
446
|
document.head.appendChild(tag);
|
|
315
447
|
}
|
|
448
|
+
var CLAIM_SELECTOR = '[data-dsh-overlay-claim="right"]';
|
|
449
|
+
var BAND_GUTTER = 16;
|
|
450
|
+
function zoomOf(el) {
|
|
451
|
+
const own = el.currentCSSZoom;
|
|
452
|
+
if (typeof own === "number" && own > 0) return own;
|
|
453
|
+
const width = el.getBoundingClientRect().width;
|
|
454
|
+
return el.offsetWidth > 0 && width > 0 ? width / el.offsetWidth : 1;
|
|
455
|
+
}
|
|
456
|
+
function desktopMinTop() {
|
|
457
|
+
return document.body.classList.contains("dsh-desktop-windows-titlebar-layout") ? 44 : 8;
|
|
458
|
+
}
|
|
459
|
+
function clampBoxOf(el, zoom) {
|
|
460
|
+
const rect = el.getBoundingClientRect();
|
|
461
|
+
return {
|
|
462
|
+
width: rect.width / zoom,
|
|
463
|
+
height: rect.height / zoom,
|
|
464
|
+
viewW: window.innerWidth / zoom,
|
|
465
|
+
viewH: window.innerHeight / zoom,
|
|
466
|
+
minTop: desktopMinTop(),
|
|
467
|
+
pad: 8
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
function tierOf(width) {
|
|
471
|
+
if (width === void 0) return "full";
|
|
472
|
+
if (width < 200) return "none";
|
|
473
|
+
if (width <= 380) return "tiny";
|
|
474
|
+
if (width <= 520) return "phone";
|
|
475
|
+
if (width <= 720) return "tablet";
|
|
476
|
+
return "full";
|
|
477
|
+
}
|
|
478
|
+
function measureBand(band) {
|
|
479
|
+
const layer = document.querySelector("[data-shell-overlay]");
|
|
480
|
+
const frame = layer?.parentElement ?? null;
|
|
481
|
+
let left = 0;
|
|
482
|
+
let right = window.innerWidth;
|
|
483
|
+
if (frame !== null) {
|
|
484
|
+
const rect = frame.getBoundingClientRect();
|
|
485
|
+
const style = getComputedStyle(frame);
|
|
486
|
+
const zoom = zoomOf(frame);
|
|
487
|
+
left = rect.left + (parseFloat(style.paddingLeft) || 0) * zoom;
|
|
488
|
+
right = rect.right - (parseFloat(style.paddingRight) || 0) * zoom;
|
|
489
|
+
}
|
|
490
|
+
for (const claim of Array.from(document.querySelectorAll(CLAIM_SELECTOR))) {
|
|
491
|
+
const rect = claim.getBoundingClientRect();
|
|
492
|
+
if (rect.width === 0 || rect.height === 0) continue;
|
|
493
|
+
if (rect.bottom <= band.top || rect.top >= band.bottom) continue;
|
|
494
|
+
if (rect.right < right - 1) continue;
|
|
495
|
+
right = Math.min(right, rect.left);
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
centre: left + (right - left) / 2,
|
|
499
|
+
width: right - left - BAND_GUTTER * 2,
|
|
500
|
+
zoom: frame === null ? 1 : zoomOf(frame)
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
function useBandFit(ref) {
|
|
504
|
+
const [fit, setFit] = import_react.default.useState(null);
|
|
505
|
+
import_react.default.useLayoutEffect(() => {
|
|
506
|
+
const el = ref.current;
|
|
507
|
+
if (el === null) return;
|
|
508
|
+
const measure = () => {
|
|
509
|
+
const self = el.getBoundingClientRect();
|
|
510
|
+
if (self.height === 0) return;
|
|
511
|
+
const next = measureBand({ top: self.top, bottom: self.bottom });
|
|
512
|
+
setFit(
|
|
513
|
+
(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
|
|
514
|
+
);
|
|
515
|
+
};
|
|
516
|
+
measure();
|
|
517
|
+
const layer = document.querySelector("[data-shell-overlay]");
|
|
518
|
+
const frame = layer?.parentElement ?? null;
|
|
519
|
+
const resize = new ResizeObserver(measure);
|
|
520
|
+
if (frame !== null) resize.observe(frame);
|
|
521
|
+
if (layer !== null) resize.observe(layer);
|
|
522
|
+
const scaleWatch = new MutationObserver(measure);
|
|
523
|
+
scaleWatch.observe(document.body, { attributes: true, attributeFilter: ["style", "class"] });
|
|
524
|
+
const mutation = new MutationObserver((records) => {
|
|
525
|
+
if (records.every((record) => el.contains(record.target))) return;
|
|
526
|
+
measure();
|
|
527
|
+
});
|
|
528
|
+
if (layer !== null) {
|
|
529
|
+
mutation.observe(layer, {
|
|
530
|
+
subtree: true,
|
|
531
|
+
childList: true,
|
|
532
|
+
attributes: true,
|
|
533
|
+
attributeFilter: ["style", "data-dsh-overlay-claim", "hidden"]
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
if (frame !== null) mutation.observe(frame, { attributes: true, attributeFilter: ["style"] });
|
|
537
|
+
window.addEventListener("resize", measure);
|
|
538
|
+
return () => {
|
|
539
|
+
resize.disconnect();
|
|
540
|
+
scaleWatch.disconnect();
|
|
541
|
+
mutation.disconnect();
|
|
542
|
+
window.removeEventListener("resize", measure);
|
|
543
|
+
};
|
|
544
|
+
}, [ref]);
|
|
545
|
+
return fit;
|
|
546
|
+
}
|
|
316
547
|
function WeatherBar() {
|
|
317
548
|
const [state, setState] = import_react.default.useState({ status: "loading" });
|
|
318
549
|
const [busy, setBusy] = import_react.default.useState(false);
|
|
319
550
|
const [unit, setUnit] = import_react.default.useState(loadUnit);
|
|
551
|
+
const [placed, setPlaced] = import_react.default.useState(loadPos);
|
|
552
|
+
const [livePx, setLivePx] = import_react.default.useState(null);
|
|
553
|
+
const [dragging, setDragging] = import_react.default.useState(false);
|
|
554
|
+
const drag = import_react.default.useRef(null);
|
|
555
|
+
const ref = import_react.default.useRef(null);
|
|
556
|
+
const fit = useBandFit(ref);
|
|
557
|
+
const parked = placed !== null || dragging;
|
|
558
|
+
const measured = tierOf(fit?.width);
|
|
559
|
+
const fitTier = parked && measured === "none" ? "tiny" : measured;
|
|
560
|
+
import_react.default.useLayoutEffect(() => {
|
|
561
|
+
if (placed === null || dragging) return;
|
|
562
|
+
const el = ref.current;
|
|
563
|
+
if (el === null) return;
|
|
564
|
+
const apply2 = () => {
|
|
565
|
+
const zoom = zoomOf(el);
|
|
566
|
+
if (el.getBoundingClientRect().height === 0) return;
|
|
567
|
+
const next = posToPx(placed, clampBoxOf(el, zoom));
|
|
568
|
+
setLivePx(
|
|
569
|
+
(prev) => prev !== null && Math.abs(prev.left - next.left) < 0.5 && Math.abs(prev.top - next.top) < 0.5 ? prev : next
|
|
570
|
+
);
|
|
571
|
+
};
|
|
572
|
+
apply2();
|
|
573
|
+
const resize = new ResizeObserver(apply2);
|
|
574
|
+
resize.observe(el);
|
|
575
|
+
const scaleWatch = new MutationObserver(apply2);
|
|
576
|
+
scaleWatch.observe(document.body, { attributes: true, attributeFilter: ["style", "class"] });
|
|
577
|
+
window.addEventListener("resize", apply2);
|
|
578
|
+
return () => {
|
|
579
|
+
resize.disconnect();
|
|
580
|
+
scaleWatch.disconnect();
|
|
581
|
+
window.removeEventListener("resize", apply2);
|
|
582
|
+
};
|
|
583
|
+
}, [placed, dragging]);
|
|
584
|
+
const onPointerDown = (event) => {
|
|
585
|
+
if (event.button !== 0) return;
|
|
586
|
+
const node = event.target;
|
|
587
|
+
if (node instanceof Element && node.closest("button")) return;
|
|
588
|
+
const el = ref.current;
|
|
589
|
+
if (el === null) return;
|
|
590
|
+
const zoom = zoomOf(el);
|
|
591
|
+
const rect = el.getBoundingClientRect();
|
|
592
|
+
el.setPointerCapture(event.pointerId);
|
|
593
|
+
drag.current = {
|
|
594
|
+
pointerId: event.pointerId,
|
|
595
|
+
startX: event.clientX,
|
|
596
|
+
startY: event.clientY,
|
|
597
|
+
origLeft: rect.left / zoom,
|
|
598
|
+
origTop: rect.top / zoom,
|
|
599
|
+
zoom,
|
|
600
|
+
moved: false
|
|
601
|
+
};
|
|
602
|
+
};
|
|
603
|
+
const onPointerMove = (event) => {
|
|
604
|
+
const session = drag.current;
|
|
605
|
+
if (session === null || event.pointerId !== session.pointerId) return;
|
|
606
|
+
const el = ref.current;
|
|
607
|
+
if (el === null) return;
|
|
608
|
+
const dx = event.clientX - session.startX;
|
|
609
|
+
const dy = event.clientY - session.startY;
|
|
610
|
+
if (!session.moved) {
|
|
611
|
+
if (dx * dx + dy * dy < DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) return;
|
|
612
|
+
session.moved = true;
|
|
613
|
+
setDragging(true);
|
|
614
|
+
}
|
|
615
|
+
const next = clampPx(
|
|
616
|
+
session.origLeft + dx / session.zoom,
|
|
617
|
+
session.origTop + dy / session.zoom,
|
|
618
|
+
clampBoxOf(el, session.zoom)
|
|
619
|
+
);
|
|
620
|
+
setLivePx(next);
|
|
621
|
+
};
|
|
622
|
+
const endDrag = (event) => {
|
|
623
|
+
const session = drag.current;
|
|
624
|
+
if (session === null || event.pointerId !== session.pointerId) return;
|
|
625
|
+
drag.current = null;
|
|
626
|
+
const el = ref.current;
|
|
627
|
+
if (!session.moved || el === null) {
|
|
628
|
+
setDragging(false);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const box = clampBoxOf(el, session.zoom);
|
|
632
|
+
const next = clampPx(
|
|
633
|
+
session.origLeft + (event.clientX - session.startX) / session.zoom,
|
|
634
|
+
session.origTop + (event.clientY - session.startY) / session.zoom,
|
|
635
|
+
box
|
|
636
|
+
);
|
|
637
|
+
const pos = pxToPos(next.left, next.top, box);
|
|
638
|
+
setPlaced(pos);
|
|
639
|
+
setLivePx(next);
|
|
640
|
+
setDragging(false);
|
|
641
|
+
savePos(pos);
|
|
642
|
+
};
|
|
643
|
+
const bandStyle = fit === null || fit.width <= 0 ? {} : {
|
|
644
|
+
left: `${fit.centre / fit.zoom}px`,
|
|
645
|
+
maxWidth: `${fit.width / fit.zoom}px`
|
|
646
|
+
};
|
|
647
|
+
const parkedStyle = livePx === null ? { transform: "none" } : {
|
|
648
|
+
left: `${livePx.left}px`,
|
|
649
|
+
top: `${livePx.top}px`,
|
|
650
|
+
transform: "none"
|
|
651
|
+
};
|
|
652
|
+
const shell = {
|
|
653
|
+
ref,
|
|
654
|
+
className: "dshwx",
|
|
655
|
+
// A parked bar must not vanish when a dock claims the top band — the user
|
|
656
|
+
// put it somewhere on purpose. Map the squeezed-out tier up to tiny so it
|
|
657
|
+
// still sheds rather than hiding.
|
|
658
|
+
"data-fit": fitTier,
|
|
659
|
+
// A non-positive width is the squeezed-out case: the `none` tier hides the
|
|
660
|
+
// bar, and writing a negative max-width would be an ignored declaration
|
|
661
|
+
// that left it at full size behind the overlay.
|
|
662
|
+
//
|
|
663
|
+
// Both lengths are divided by the zoom: `fit` is measured in viewport px
|
|
664
|
+
// and these are author px the zoom scales again (see zoomOf).
|
|
665
|
+
...parked ? { "data-placed": "", style: parkedStyle } : fit === null || fit.width <= 0 ? {} : { style: bandStyle },
|
|
666
|
+
...dragging ? { "data-dragging": "" } : {},
|
|
667
|
+
onPointerDown,
|
|
668
|
+
onPointerMove,
|
|
669
|
+
onPointerUp: endDrag,
|
|
670
|
+
onPointerCancel: endDrag
|
|
671
|
+
};
|
|
320
672
|
const toggleUnit = () => {
|
|
321
673
|
setUnit((prev) => {
|
|
322
674
|
const next = prev === "C" ? "F" : "C";
|
|
@@ -350,13 +702,13 @@ function WeatherBar() {
|
|
|
350
702
|
fetchWeather().then(setState).catch((e) => setState({ status: "error", error: String(e?.message ?? e) })).finally(() => setBusy(false));
|
|
351
703
|
};
|
|
352
704
|
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" })
|
|
705
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { ...shell, role: "status", "aria-live": "polite", "aria-busy": "true", children: [
|
|
706
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dshwx-icon", "aria-hidden": "true", children: "\u{1F321}\uFE0F" }),
|
|
707
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dshwx-label loading", children: "Loading weather\u2026" })
|
|
356
708
|
] });
|
|
357
709
|
}
|
|
358
710
|
if (state.status === "error" || !state.now) {
|
|
359
|
-
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
711
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { ...shell, "aria-live": "polite", children: [
|
|
360
712
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dshwx-icon", children: "\u26A0\uFE0F" }),
|
|
361
713
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dshwx-error", title: state.error, children: "Weather unavailable" }),
|
|
362
714
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: `dshwx-refresh${busy ? " busy" : ""}`, "data-dsh-no-drag": "", title: "Retry", onClick: reload, children: "\u27F3" })
|
|
@@ -365,7 +717,7 @@ function WeatherBar() {
|
|
|
365
717
|
const { icon, label } = describeCode(state.now.weatherCode, state.now.isDay);
|
|
366
718
|
const other = unit === "C" ? "F" : "C";
|
|
367
719
|
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", {
|
|
720
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { ...shell, title, "aria-live": "polite", children: [
|
|
369
721
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dshwx-icon", children: icon }),
|
|
370
722
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
371
723
|
"button",
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,88 @@
|
|
|
1
|
+
// src/position.ts
|
|
2
|
+
var POS_COOKIE = "dsh-weather-pos";
|
|
3
|
+
var POS_KEY = "dsh-weather:pos";
|
|
4
|
+
var MAX_AGE = 31536e4;
|
|
5
|
+
function formatPos(pos) {
|
|
6
|
+
return `${pos.x.toFixed(4)},${pos.y.toFixed(4)}`;
|
|
7
|
+
}
|
|
8
|
+
function parsePos(raw) {
|
|
9
|
+
if (raw == null || raw === "") return null;
|
|
10
|
+
const comma = raw.indexOf(",");
|
|
11
|
+
if (comma === -1) return null;
|
|
12
|
+
const x = Number(raw.slice(0, comma));
|
|
13
|
+
const y = Number(raw.slice(comma + 1));
|
|
14
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
|
15
|
+
if (x < 0 || x > 1 || y < 0 || y > 1) return null;
|
|
16
|
+
return { x, y };
|
|
17
|
+
}
|
|
18
|
+
function rangeOf(box) {
|
|
19
|
+
const minLeft = box.pad;
|
|
20
|
+
const maxLeft = Math.max(minLeft, box.viewW - box.width - box.pad);
|
|
21
|
+
const minTop = Math.max(box.pad, box.minTop);
|
|
22
|
+
const maxTop = Math.max(minTop, box.viewH - box.height - box.pad);
|
|
23
|
+
return { minLeft, maxLeft, minTop, maxTop };
|
|
24
|
+
}
|
|
25
|
+
function clampPx(left, top, box) {
|
|
26
|
+
const { minLeft, maxLeft, minTop, maxTop } = rangeOf(box);
|
|
27
|
+
return {
|
|
28
|
+
left: Math.min(maxLeft, Math.max(minLeft, left)),
|
|
29
|
+
top: Math.min(maxTop, Math.max(minTop, top))
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function posToPx(pos, box) {
|
|
33
|
+
const { minLeft, maxLeft, minTop, maxTop } = rangeOf(box);
|
|
34
|
+
return {
|
|
35
|
+
left: minLeft + pos.x * (maxLeft - minLeft),
|
|
36
|
+
top: minTop + pos.y * (maxTop - minTop)
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function pxToPos(left, top, box) {
|
|
40
|
+
const { minLeft, maxLeft, minTop, maxTop } = rangeOf(box);
|
|
41
|
+
const spanX = maxLeft - minLeft;
|
|
42
|
+
const spanY = maxTop - minTop;
|
|
43
|
+
return {
|
|
44
|
+
x: spanX <= 0 ? 0 : (left - minLeft) / spanX,
|
|
45
|
+
y: spanY <= 0 ? 0 : (top - minTop) / spanY
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function readCookie(jar, name) {
|
|
49
|
+
for (const part of jar.split(";")) {
|
|
50
|
+
const at = part.indexOf("=");
|
|
51
|
+
if (at === -1) continue;
|
|
52
|
+
if (part.slice(0, at).trim() !== name) continue;
|
|
53
|
+
return decodeURIComponent(part.slice(at + 1).trim());
|
|
54
|
+
}
|
|
55
|
+
return void 0;
|
|
56
|
+
}
|
|
57
|
+
function posCookieWrite(pos) {
|
|
58
|
+
return `${POS_COOKIE}=${encodeURIComponent(formatPos(pos))}; Path=/; Max-Age=${MAX_AGE}; SameSite=Lax`;
|
|
59
|
+
}
|
|
60
|
+
function loadPosFromStores(jar, storageGet) {
|
|
61
|
+
try {
|
|
62
|
+
const cookie = readCookie(jar, POS_COOKIE);
|
|
63
|
+
const fromCookie = parsePos(cookie);
|
|
64
|
+
if (fromCookie) return fromCookie;
|
|
65
|
+
} catch {
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
return parsePos(storageGet(POS_KEY));
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
1
74
|
// src/index.ts
|
|
2
75
|
function apply() {
|
|
3
76
|
}
|
|
4
77
|
export {
|
|
5
|
-
|
|
78
|
+
POS_COOKIE,
|
|
79
|
+
POS_KEY,
|
|
80
|
+
apply,
|
|
81
|
+
clampPx,
|
|
82
|
+
formatPos,
|
|
83
|
+
loadPosFromStores,
|
|
84
|
+
parsePos,
|
|
85
|
+
posCookieWrite,
|
|
86
|
+
posToPx,
|
|
87
|
+
pxToPos
|
|
6
88
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dennisrongo/dsh-weather",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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 build/build.mjs && node test/smoke.mjs"
|
|
32
|
+
"test": "node build/build.mjs && node test/smoke.mjs && node test/position.mjs"
|
|
33
33
|
},
|
|
34
34
|
"dsh": {
|
|
35
35
|
"bundle": {
|