ui-chan-mcp 0.6.0 → 0.7.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
@@ -97,7 +97,7 @@ ui-chan
97
97
  **繋いだら、もう終わり。** 次にそのアプリを開けば、画面の右下にわたしが出てくる。
98
98
  あとは普通に作業して。勝手に喋るから。
99
99
 
100
- ### 右上のつまみ
100
+ ### 足元のつまみ
101
101
 
102
102
  <p align="center">
103
103
  <img src="docs/images/panel.png" alt="接続パネル" width="360">
@@ -112,8 +112,9 @@ ui-chan
112
112
  | ⬇ | 更新する(**更新があるときだけ出る**) |
113
113
  | ⏻ | 終了する(呼ばれても起きない) |
114
114
 
115
- <sub>複数のセッションから繋がってるときは、そこに一覧が出るよ(どのツールの、どのプロジェクトか)。
116
- 更新があるときは、ダウンロードのアイコンが増える。</sub>
115
+ <sub>つまみはわたしの足元(右下)にいる。押すと上に開くよ。
116
+ 複数のセッションから繋がってるときは、そこに一覧が出る(どのツールの、どのプロジェクトか)。
117
+ **行を押すと、そのアプリが前に出てくる。** 更新があるときは、ダウンロードのアイコンが増える。</sub>
117
118
 
118
119
  ---
119
120
 
package/dist/app/main.js CHANGED
@@ -370,8 +370,124 @@ function startWsServer() {
370
370
  * ここを見るので、「起動し直さないと位置が戻らない」ということはない。 */
371
371
  function homePosition() {
372
372
  const { width, height, margin } = config.window;
373
+ // 実ピクセルの余白が分かっていればそれで合わせる。分かるのは PSD を描いた
374
+ // あとなので、起動直後の一発目だけは窓の矩形で置き、測れた時点で
375
+ // `ui-chan:body-box` が置き直す。リセットと吸い付きが別の場所に着地しない
376
+ // ように、両方ここを見る。
377
+ const ins = bodyInsets ?? { left: 0, top: 0, right: 0, bottom: 0 };
378
+ const gap = config.window.snapGap ?? 12;
373
379
  const wa = electron_1.screen.getPrimaryDisplay().workArea;
374
- return { x: wa.x + wa.width - width - margin, y: wa.y + wa.height - height - margin };
380
+ // setPosition は整数しか受け取らない(小数を渡すと main プロセスごと落ちる)。
381
+ // 実ピクセルの計測は dpr 由来で小数になるので、ここで必ず丸める。
382
+ return {
383
+ x: Math.round(wa.x + wa.width - margin - gap - width + ins.right),
384
+ y: Math.round(wa.y + wa.height - margin - height + ins.bottom),
385
+ };
386
+ }
387
+ /** レンダラが測った「彼女に見える範囲」の、窓の内側からの余白(CSS px)。
388
+ * 測れるのは PSD を描いたあとなので、それまでは null=窓の矩形で代用する。 */
389
+ let bodyInsets = null;
390
+ /**
391
+ * ドラッグを離したとき、近ければ画面の下の隅へ吸い付ける。
392
+ *
393
+ * **下2隅だけ**なのは、上に詰められないから。窓の上部120pxは吹き出しの居場所
394
+ * として常時確保されていて(`index.html` の `#bubble-area`)、彼女の頭を画面
395
+ * 上端に付けると、喋った瞬間に吹き出しが画面の外へ出る。置けない場所を候補に
396
+ * 出しても、寄せたのに何も起きないか、変な位置に落ちるかのどちらかになる。
397
+ *
398
+ * **窓の矩形ではなく彼女の実ピクセルで合わせる。** 窓は420x680だが絵はその一部
399
+ * で、左右に90〜110pxの透明な余白がある(→ `PsdStage.contentInsets`)。矩形で
400
+ * 揃えると、画面の隅に吸い付いたはずが彼女は隅から100px浮いて見える——目が見て
401
+ * いるのは彼女であって窓ではないので、しきい値もこの実ピクセルの隅で測る。
402
+ *
403
+ * 見る画面は主ディスプレイではなく **窓がいま乗っている画面**(ドラッグは画面を
404
+ * またげる)。
405
+ */
406
+ function cornerTargets() {
407
+ if (!win)
408
+ return null;
409
+ const { width, height, margin } = config.window;
410
+ const gap = config.window.snapGap ?? 12;
411
+ const ins = bodyInsets ?? { left: 0, top: 0, right: 0, bottom: 0 };
412
+ const wa = electron_1.screen.getDisplayMatching(win.getBounds()).workArea;
413
+ // 「彼女の左端/右端/下端が、画面の縁から margin」になる窓の座標に直す。
414
+ // 整数必須(→ homePosition の同じ注記)。
415
+ const y = Math.round(wa.y + wa.height - margin - height + ins.bottom);
416
+ // 左下に着地すると立ち絵は反転する(画面の内側を向かせるため)ので、そのとき
417
+ // 身体の左の余白は「反転前の右の余白」になる。入れ替えないと、左に吸い付いた
418
+ // ときだけ画面の縁との間隔が19pxずれる。
419
+ return {
420
+ left: { x: Math.round(wa.x + margin + gap - ins.right), y },
421
+ right: { x: Math.round(wa.x + wa.width - margin - gap - width + ins.right), y },
422
+ };
423
+ }
424
+ /** 吸い付きの移動そのもの。`setPosition` を1発撃つと瞬間移動になって「寄せられ
425
+ * た」感が出ず、逆に macOS 任せの `animate: true` は遅くて重い。あいだを取って
426
+ * 自前で easeOutCubic を刻む。 */
427
+ let snapTimer = null;
428
+ function cancelSnap() {
429
+ if (snapTimer) {
430
+ clearInterval(snapTimer);
431
+ snapTimer = null;
432
+ }
433
+ }
434
+ function glideTo(x, y) {
435
+ if (!win)
436
+ return;
437
+ cancelSnap();
438
+ const durationMs = config.window.snapDurationMs ?? 220;
439
+ if (durationMs <= 0) {
440
+ win.setPosition(x, y);
441
+ return;
442
+ }
443
+ const from = win.getBounds();
444
+ const startedAt = Date.now();
445
+ snapTimer = setInterval(() => {
446
+ if (!win)
447
+ return cancelSnap();
448
+ const t = Math.min(1, (Date.now() - startedAt) / durationMs);
449
+ const e = 1 - (1 - t) ** 3;
450
+ win.setPosition(Math.round(from.x + (x - from.x) * e), Math.round(from.y + (y - from.y) * e));
451
+ if (t >= 1)
452
+ cancelSnap();
453
+ }, 16);
454
+ }
455
+ function snapToCorner() {
456
+ if (!win)
457
+ return;
458
+ const { snapDistance = 320 } = config.window;
459
+ if (snapDistance <= 0)
460
+ return;
461
+ const targets = cornerTargets();
462
+ if (!targets)
463
+ return;
464
+ const b = win.getBounds();
465
+ const best = [targets.left, targets.right]
466
+ .map((t) => ({ ...t, d: Math.hypot(b.x - t.x, b.y - t.y) }))
467
+ .sort((a, c) => a.d - c.d)[0];
468
+ if (best.d > snapDistance || (best.x === b.x && best.y === b.y))
469
+ return;
470
+ glideTo(best.x, best.y);
471
+ // 向き・パネル・吹き出しは動き終わりを待たずに切り替える。移動の途中で
472
+ // 反転したほうが「引き寄せられて向き直った」と読める。
473
+ updatePanelSide();
474
+ }
475
+ /** 彼女が画面のどちら側に居るかをレンダラへ伝える(パネルの寄せ・吹き出しの
476
+ * 伸びる向き・立ち絵の反転がこれで決まる)。吸い付いていない位置でも破綻
477
+ * しないよう、判定は「作業領域の左右どちら寄りに身体があるか」で行う。 */
478
+ let panelSide = null;
479
+ function updatePanelSide() {
480
+ if (!win)
481
+ return;
482
+ const b = win.getBounds();
483
+ const wa = electron_1.screen.getDisplayMatching(b).workArea;
484
+ const ins = bodyInsets ?? { left: 0, top: 0, right: 0, bottom: 0 };
485
+ const bodyCenter = b.x + ins.left + (b.width - ins.left - ins.right) / 2;
486
+ const side = bodyCenter < wa.x + wa.width / 2 ? 'left' : 'right';
487
+ if (side === panelSide)
488
+ return;
489
+ panelSide = side;
490
+ sendToRenderer({ type: 'side', side });
375
491
  }
376
492
  /**
377
493
  * セッション行を押されたとき、その相手のアプリを前面に出す。
@@ -589,10 +705,13 @@ else {
589
705
  // fidget input). While the button is held over her body, the window follows
590
706
  // the cursor at a fixed grab offset.
591
707
  let dragTimer = null;
708
+ let dragOrigin = null;
592
709
  electron_1.ipcMain.on('ui-chan:drag-start', () => {
593
710
  if (!win)
594
711
  return;
712
+ cancelSnap(); // 寄っている途中で掴まれたら、そちらが優先
595
713
  const start = electron_1.screen.getCursorScreenPoint();
714
+ dragOrigin = start;
596
715
  const [wx, wy] = win.getPosition();
597
716
  const offX = start.x - wx;
598
717
  const offY = start.y - wy;
@@ -610,6 +729,31 @@ else {
610
729
  clearInterval(dragTimer);
611
730
  dragTimer = null;
612
731
  }
732
+ // drag-start は押した時点で飛んでくるので、つつかれただけ(=動かして
733
+ // いない)のときにまで吸い付くと、触っただけで窓が跳ぶ。実際に動いた
734
+ // ときだけ整える。
735
+ const p = electron_1.screen.getCursorScreenPoint();
736
+ const moved = dragOrigin && Math.hypot(p.x - dragOrigin.x, p.y - dragOrigin.y) > 4;
737
+ dragOrigin = null;
738
+ if (moved)
739
+ snapToCorner();
740
+ });
741
+ electron_1.ipcMain.on('ui-chan:body-box', (_ev, insets) => {
742
+ const wasHome = (() => {
743
+ if (!win)
744
+ return false;
745
+ const h = homePosition(); // まだ bodyInsets 未設定なので「矩形で置いた定位置」
746
+ const b = win.getBounds();
747
+ return b.x === h.x && b.y === h.y;
748
+ })();
749
+ bodyInsets = insets;
750
+ // 起動位置から動かされていなければ、測れた値で置き直す。動かされていたら
751
+ // 触らない——ユーザーが置いた場所を、計測の都合で奪わない。
752
+ if (wasHome && win) {
753
+ const h = homePosition();
754
+ win.setPosition(h.x, h.y);
755
+ }
756
+ updatePanelSide();
613
757
  });
614
758
  electron_1.ipcMain.on('ui-chan:warnings', (_ev, warnings) => {
615
759
  // The renderer reports its full warning set on every applyDirectives()
@@ -12,4 +12,5 @@ electron_1.contextBridge.exposeInMainWorld('uiChan', {
12
12
  setClickThrough: (on) => electron_1.ipcRenderer.send('ui-chan:click-through', on),
13
13
  dragStart: () => electron_1.ipcRenderer.send('ui-chan:drag-start'),
14
14
  dragEnd: () => electron_1.ipcRenderer.send('ui-chan:drag-end'),
15
+ reportBodyBox: (insets) => electron_1.ipcRenderer.send('ui-chan:body-box', insets),
15
16
  });
@@ -17491,12 +17491,17 @@
17491
17491
  /** Alpha (0–255) of the composited image at a client (viewport) point, for
17492
17492
  * hit-testing "is the pointer actually over her body" vs the transparent
17493
17493
  * margins. Maps client → canvas pixel coords via the element's box. */
17494
+ /** 立ち絵を左右反転して描いているか。反転は CSS(`#canvas` の scaleX(-1))で
17495
+ * かけるが、`getBoundingClientRect()` は変換後も同じ箱を返すので、当たり判定
17496
+ * だけは自分で座標を折り返さないと左右が入れ替わったまま当たる。 */
17497
+ mirrored = false;
17494
17498
  alphaAt(clientX, clientY) {
17495
17499
  const rect = this.canvas.getBoundingClientRect();
17496
17500
  if (rect.width === 0 || rect.height === 0 || clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) {
17497
17501
  return 0;
17498
17502
  }
17499
- const px = Math.floor((clientX - rect.left) / rect.width * this.canvas.width);
17503
+ let px = Math.floor((clientX - rect.left) / rect.width * this.canvas.width);
17504
+ if (this.mirrored) px = this.canvas.width - 1 - px;
17500
17505
  const py = Math.floor((clientY - rect.top) / rect.height * this.canvas.height);
17501
17506
  try {
17502
17507
  return this.ctx.getImageData(px, py, 1, 1).data[3];
@@ -17504,6 +17509,50 @@
17504
17509
  return 0;
17505
17510
  }
17506
17511
  }
17512
+ /**
17513
+ * いま描かれている絵の**不透明な部分**が、canvas 要素の箱の中でどこにあるかを
17514
+ * CSS px の内側余白(inset)で返す。読めなければ null。
17515
+ *
17516
+ * これが要る理由:窓(420x680)と「彼女に見える範囲」は全然違う。PSD は
17517
+ * 3541x5016 あるが、実際に絵があるのはその中央あたりだけで、左右に 785px /
17518
+ * 954px の余白がある(万歳など腕を広げるポーズのために取ってある幅)。
17519
+ * 表示スケールに直すと片側 90〜110px。窓の矩形を画面の隅に合わせても、
17520
+ * 彼女は隅から100px浮いて見える——「吸い付いた」と読めない距離。
17521
+ */
17522
+ contentInsets(alphaThreshold = 8) {
17523
+ const { canvas } = this;
17524
+ if (canvas.width === 0 || canvas.height === 0) return null;
17525
+ let data;
17526
+ try {
17527
+ data = this.ctx.getImageData(0, 0, canvas.width, canvas.height).data;
17528
+ } catch {
17529
+ return null;
17530
+ }
17531
+ let minX = canvas.width;
17532
+ let minY = canvas.height;
17533
+ let maxX = -1;
17534
+ let maxY = -1;
17535
+ for (let y = 0; y < canvas.height; y++) {
17536
+ const row = y * canvas.width * 4;
17537
+ for (let x = 0; x < canvas.width; x++) {
17538
+ if (data[row + x * 4 + 3] < alphaThreshold) continue;
17539
+ if (x < minX) minX = x;
17540
+ if (x > maxX) maxX = x;
17541
+ if (y < minY) minY = y;
17542
+ maxY = y;
17543
+ }
17544
+ }
17545
+ if (maxX < 0) return null;
17546
+ const rect = canvas.getBoundingClientRect();
17547
+ const sx = rect.width / canvas.width;
17548
+ const sy = rect.height / canvas.height;
17549
+ return {
17550
+ left: minX * sx,
17551
+ top: minY * sy,
17552
+ right: (canvas.width - 1 - maxX) * sx,
17553
+ bottom: (canvas.height - 1 - maxY) * sy
17554
+ };
17555
+ }
17507
17556
  // ---- temporary-overlay visibility snapshot/restore (used by blink) ----
17508
17557
  // Snapshot the current visibility of every node, draw something temporary
17509
17558
  // (closed eyes) on top, then restore the snapshot.
@@ -17541,7 +17590,49 @@
17541
17590
  function reportWarnings() {
17542
17591
  window.uiChan.reportWarnings(stage.getWarnings());
17543
17592
  }
17593
+ var bodyBoxSent = false;
17594
+ var rawInsets = null;
17595
+ var bodySide = "right";
17596
+ var stageEl = document.getElementById("stage");
17597
+ function applySide() {
17598
+ stageEl.classList.toggle("side-left", bodySide === "left");
17599
+ stageEl.classList.toggle("side-right", bodySide === "right");
17600
+ stage.mirrored = bodySide === "left";
17601
+ if (!rawInsets) return;
17602
+ const left = bodySide === "left" ? rawInsets.right : rawInsets.left;
17603
+ const right = bodySide === "left" ? rawInsets.left : rawInsets.right;
17604
+ stageEl.style.setProperty("--body-left", `${left}px`);
17605
+ stageEl.style.setProperty("--body-right", `${right}px`);
17606
+ positionTail();
17607
+ }
17608
+ function positionTail() {
17609
+ if (!rawInsets) return;
17610
+ const left = bodySide === "left" ? rawInsets.right : rawInsets.left;
17611
+ const right = bodySide === "left" ? rawInsets.left : rawInsets.right;
17612
+ const headX = left + (window.innerWidth - left - right) / 2;
17613
+ const r = bubble.getBoundingClientRect();
17614
+ if (r.width === 0) return;
17615
+ const x = Math.min(Math.max(headX - r.left, 16), r.width - 16);
17616
+ bubble.style.setProperty("--tail-x", `${x}px`);
17617
+ }
17618
+ function reportBodyBoxOnce() {
17619
+ if (bodyBoxSent || !stage.loaded) return;
17620
+ const box = stage.contentInsets();
17621
+ if (!box) return;
17622
+ const rect = canvas.getBoundingClientRect();
17623
+ const insets = {
17624
+ left: rect.left + box.left,
17625
+ top: rect.top + box.top,
17626
+ right: window.innerWidth - rect.right + box.right,
17627
+ bottom: window.innerHeight - rect.bottom + box.bottom
17628
+ };
17629
+ bodyBoxSent = true;
17630
+ rawInsets = insets;
17631
+ applySide();
17632
+ window.uiChan.reportBodyBox(insets);
17633
+ }
17544
17634
  var overlay = document.createElement("canvas");
17635
+ overlay.id = "cue-overlay";
17545
17636
  overlay.style.cssText = "position:absolute;inset:0;width:100%;height:100%;pointer-events:none;opacity:0";
17546
17637
  var overlayCtx = overlay.getContext("2d");
17547
17638
  canvas.parentElement?.insertBefore(overlay, canvas.nextSibling);
@@ -17779,6 +17870,7 @@
17779
17870
  if (text !== null) {
17780
17871
  bubble.textContent = bubbleText(text);
17781
17872
  bubble.classList.add("visible");
17873
+ positionTail();
17782
17874
  } else {
17783
17875
  bubble.classList.remove("visible");
17784
17876
  hideTextTimer = window.setTimeout(() => {
@@ -17952,6 +18044,11 @@
17952
18044
  reportWarnings();
17953
18045
  lipCurrentMouth = null;
17954
18046
  draw();
18047
+ reportBodyBoxOnce();
18048
+ } else if (cmd.type === "side") {
18049
+ bodySide = cmd.side;
18050
+ panelEl.classList.toggle("left", cmd.side === "left");
18051
+ applySide();
17955
18052
  } else if (cmd.type === "backdrop") {
17956
18053
  const PRESETS = {
17957
18054
  white: "#ffffff",
@@ -17491,12 +17491,17 @@
17491
17491
  /** Alpha (0–255) of the composited image at a client (viewport) point, for
17492
17492
  * hit-testing "is the pointer actually over her body" vs the transparent
17493
17493
  * margins. Maps client → canvas pixel coords via the element's box. */
17494
+ /** 立ち絵を左右反転して描いているか。反転は CSS(`#canvas` の scaleX(-1))で
17495
+ * かけるが、`getBoundingClientRect()` は変換後も同じ箱を返すので、当たり判定
17496
+ * だけは自分で座標を折り返さないと左右が入れ替わったまま当たる。 */
17497
+ mirrored = false;
17494
17498
  alphaAt(clientX, clientY) {
17495
17499
  const rect = this.canvas.getBoundingClientRect();
17496
17500
  if (rect.width === 0 || rect.height === 0 || clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) {
17497
17501
  return 0;
17498
17502
  }
17499
- const px = Math.floor((clientX - rect.left) / rect.width * this.canvas.width);
17503
+ let px = Math.floor((clientX - rect.left) / rect.width * this.canvas.width);
17504
+ if (this.mirrored) px = this.canvas.width - 1 - px;
17500
17505
  const py = Math.floor((clientY - rect.top) / rect.height * this.canvas.height);
17501
17506
  try {
17502
17507
  return this.ctx.getImageData(px, py, 1, 1).data[3];
@@ -17504,6 +17509,50 @@
17504
17509
  return 0;
17505
17510
  }
17506
17511
  }
17512
+ /**
17513
+ * いま描かれている絵の**不透明な部分**が、canvas 要素の箱の中でどこにあるかを
17514
+ * CSS px の内側余白(inset)で返す。読めなければ null。
17515
+ *
17516
+ * これが要る理由:窓(420x680)と「彼女に見える範囲」は全然違う。PSD は
17517
+ * 3541x5016 あるが、実際に絵があるのはその中央あたりだけで、左右に 785px /
17518
+ * 954px の余白がある(万歳など腕を広げるポーズのために取ってある幅)。
17519
+ * 表示スケールに直すと片側 90〜110px。窓の矩形を画面の隅に合わせても、
17520
+ * 彼女は隅から100px浮いて見える——「吸い付いた」と読めない距離。
17521
+ */
17522
+ contentInsets(alphaThreshold = 8) {
17523
+ const { canvas } = this;
17524
+ if (canvas.width === 0 || canvas.height === 0) return null;
17525
+ let data;
17526
+ try {
17527
+ data = this.ctx.getImageData(0, 0, canvas.width, canvas.height).data;
17528
+ } catch {
17529
+ return null;
17530
+ }
17531
+ let minX = canvas.width;
17532
+ let minY = canvas.height;
17533
+ let maxX = -1;
17534
+ let maxY = -1;
17535
+ for (let y = 0; y < canvas.height; y++) {
17536
+ const row = y * canvas.width * 4;
17537
+ for (let x = 0; x < canvas.width; x++) {
17538
+ if (data[row + x * 4 + 3] < alphaThreshold) continue;
17539
+ if (x < minX) minX = x;
17540
+ if (x > maxX) maxX = x;
17541
+ if (y < minY) minY = y;
17542
+ maxY = y;
17543
+ }
17544
+ }
17545
+ if (maxX < 0) return null;
17546
+ const rect = canvas.getBoundingClientRect();
17547
+ const sx = rect.width / canvas.width;
17548
+ const sy = rect.height / canvas.height;
17549
+ return {
17550
+ left: minX * sx,
17551
+ top: minY * sy,
17552
+ right: (canvas.width - 1 - maxX) * sx,
17553
+ bottom: (canvas.height - 1 - maxY) * sy
17554
+ };
17555
+ }
17507
17556
  // ---- temporary-overlay visibility snapshot/restore (used by blink) ----
17508
17557
  // Snapshot the current visibility of every node, draw something temporary
17509
17558
  // (closed eyes) on top, then restore the snapshot.
@@ -29,12 +29,26 @@
29
29
  align-items: flex-end;
30
30
  justify-content: center;
31
31
  padding: 8px 10px 0;
32
+ /* 左右の余白は「彼女の実ピクセルの外側」ぶん。隅に吸い付くと窓はその
33
+ ぶん画面外へはみ出しているので、ここを空けておかないと吹き出しが
34
+ 画面の外へ伸びて切れる。--body-* は renderer.ts が計測して入れる。 */
32
35
  box-sizing: border-box;
33
36
  /* キャラクターの頭上(PSDの傘用余白ぶん)まで吹き出しを下げる */
34
37
  transform: translateY(46px);
35
38
  z-index: 1;
36
39
  position: relative;
37
40
  }
41
+ /* 右下に居るなら左へ、左下に居るなら右へ——画面の内側へ向かって伸ばす。
42
+ 伸びる向きと反対側の端を彼女の身体の縁に合わせるので、長い行でも
43
+ 画面からはみ出さない。 */
44
+ #stage.side-right #bubble-area {
45
+ justify-content: flex-end;
46
+ padding-right: calc(10px + var(--body-right, 0px));
47
+ }
48
+ #stage.side-left #bubble-area {
49
+ justify-content: flex-start;
50
+ padding-left: calc(10px + var(--body-left, 0px));
51
+ }
38
52
  #bubble {
39
53
  position: relative;
40
54
  max-width: 100%;
@@ -59,11 +73,14 @@
59
73
  opacity: 1;
60
74
  transform: translateY(0);
61
75
  }
76
+ /* しっぽは吹き出しの中央ではなく**彼女の頭の真上**を指す。吹き出しの箱は
77
+ 画面の内側へ寄るので、中央固定だと指す先が彼女から外れる。位置は
78
+ renderer.ts が測って --tail-x に入れる(既定は従来どおり中央)。 */
62
79
  #bubble::after {
63
80
  content: '';
64
81
  position: absolute;
65
82
  bottom: -9px;
66
- left: 50%;
83
+ left: var(--tail-x, 50%);
67
84
  margin-left: -7px;
68
85
  border-width: 9px 7px 0;
69
86
  border-style: solid;
@@ -82,6 +99,14 @@
82
99
  width: 100%;
83
100
  height: 100%;
84
101
  }
102
+ /* 左下に居るときは、画面の内側(右)を向かせる。当たり判定は CSS の変換を
103
+ 見ていないので、PsdStage.mirrored 側でも折り返している。 */
104
+ /* #cue-overlay は renderer.ts が差し込むクロスフェード用のキャンバス。
105
+ 本体と同じ変換をかけないと、切り替えの一瞬だけ反転前の絵が見える。 */
106
+ #stage.side-left #canvas,
107
+ #stage.side-left #cue-overlay {
108
+ transform: scaleX(-1);
109
+ }
85
110
  #placeholder {
86
111
  display: none;
87
112
  position: absolute;
@@ -116,7 +141,12 @@
116
141
  #panel {
117
142
  position: absolute;
118
143
  bottom: 6px;
119
- right: 6px;
144
+ /* 窓の縁ではなく**彼女の実ピクセルの縁**を基準にする。隅への吸い付きは
145
+ 彼女を画面の縁に合わせるので、窓のほうは画面外に100px以上はみ出す
146
+ (PSDの左右の透明帯ぶん)。窓の右端に貼り付けたままだと、パネルは
147
+ その一緒に画面の外へ出て押せなくなる。余白は renderer.ts が
148
+ 計測して --body-right / --body-left に入れる。 */
149
+ right: calc(6px + var(--body-right, 0px));
120
150
  z-index: 5;
121
151
  display: flex;
122
152
  flex-direction: column-reverse;
@@ -130,6 +160,13 @@
130
160
  いる本体だけ。 */
131
161
  pointer-events: none;
132
162
  }
163
+ /* 彼女が画面の左下に居るときは、パネルも左へ。彼女の立ち位置と同じ側の縁に
164
+ 沿わせるので、開いたパネルがデスクトップの中央側へ張り出さない。 */
165
+ #panel.left {
166
+ right: auto;
167
+ left: calc(6px + var(--body-left, 0px));
168
+ align-items: flex-start;
169
+ }
133
170
  #panel-tab {
134
171
  pointer-events: auto;
135
172
  -webkit-app-region: no-drag;
@@ -196,6 +233,9 @@
196
233
  transform: translateY(0) scale(1);
197
234
  pointer-events: auto;
198
235
  }
236
+ #panel.left #panel-body {
237
+ transform-origin: bottom left;
238
+ }
199
239
  .panel-row {
200
240
  display: flex;
201
241
  align-items: center;
package/docs/CLIENTS.md CHANGED
@@ -51,7 +51,9 @@ ui-chan update # 最新版にする(--check で確認だけ)
51
51
  **登録した時点で完了です。** アプリと VoiSona Talk はセッション開始時に自動起動し、人格は
52
52
  MCP のハンドシェイク(`instructions`)に乗って渡ります。人格ファイルを貼り付ける作業はありません。
53
53
 
54
- マスコット右上のつまみを開くと、接続中のセッション(クライアント名とプロジェクト名)と操作が出ます。
54
+ マスコット足元(右下)のつまみを開くと、接続中のセッション(クライアント名とプロジェクト名)と
55
+ 操作が上に向かって開きます。セッションの行をクリックすると、そのクライアントのアプリが前面に出ます
56
+ (ターミナルの場合はターミナル本体まで。タブの選択まではしません)。
55
57
 
56
58
  ---
57
59
 
@@ -62,7 +62,7 @@ npm で入れたなら要らないよ。ビルド済みのものが届くから
62
62
  <details>
63
63
  <summary><b>マスコットを終了させたい</b></summary>
64
64
 
65
- マスコット右上のつまみを開いて、**電源のアイコン ⏻** を押します。これが最短です。
65
+ マスコット足元(右下)のつまみを開いて、**電源のアイコン ⏻** を押します。これが最短です。
66
66
  **エージェントがツールを呼んでも起き直しません**(次にセッションを開くか、
67
67
  `ui-chan start` で起動するまで停止したままです)。
68
68
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ui-chan-mcp",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "雨衣ちゃんMCP — デスクトップの隅に住む、AI の体になるマスコット(PSD立ち絵+吹き出し+音声。MCP で操作)",
5
5
  "main": "dist/app/main.js",
6
6
  "private": false,
@@ -4,7 +4,10 @@
4
4
  "window": {
5
5
  "width": 420,
6
6
  "height": 680,
7
- "margin": 16
7
+ "margin": 16,
8
+ "snapDistance": 320,
9
+ "snapGap": 12,
10
+ "snapDurationMs": 220
8
11
  },
9
12
  "port": 8123,
10
13
  "idle": {