@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 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:** 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 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
@@ -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: default;
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 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. */
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
- @media (max-width: 720px) {
271
- .dshwx-hours, .dshwx-meta { display: none; }
272
- .dshwx-sep-hours, .dshwx-sep-meta { display: none; }
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
- @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; }
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
- @media (max-width: 380px) {
293
- .dshwx-where, .dshwx-sep-where { display: none; }
294
- .dshwx { gap: 6px; }
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", { 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" })
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", { className: "dshwx", "aria-live": "polite", children: [
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", { className: "dshwx", title, "aria-live": "polite", children: [
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
- apply
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.1",
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": {