dsh-sidebar-organizer 0.4.1 → 0.4.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/CHANGELOG.md CHANGED
@@ -3,6 +3,29 @@
3
3
  版本格式遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
4
4
  本文件的中文条目为默认语言;英文条目见英文段落(同一版本内)。
5
5
 
6
+ ## 0.4.2 — 2026-11-02
7
+
8
+ - 拖动到「更多」按钮上时新增收纳动画:按钮呼吸高亮、图标下沉,拖影微微缩小
9
+ 变淡;松手后拖影飞向按钮并缩小消失,随后该行被收纳,按钮播放一次「吞入」
10
+ 回弹。只剩最后一行可见时不再静默失败,改为按钮左右轻抖提示不可收纳。
11
+ - 动画只使用 `transform` / `opacity`(拖影飞入用 WAAPI,其余为 CSS),拖动热路
12
+ 径不新增布局读取;同一时刻只允许一个飞入动画,连续快速收纳不会叠加拖影。
13
+ - 尊重 `prefers-reduced-motion: reduce`:关闭呼吸、回弹与抖动,飞入改为立即
14
+ 收纳。
15
+
16
+ ### English
17
+
18
+ - Added the tuck-away animation on the “More” anchor: the button breathes, its
19
+ glyph dips, and the carried ghost shrinks slightly while armed; on release the
20
+ ghost flies into the anchor and fades, the row is tucked away, and the button
21
+ plays a short “swallow” bounce. When only one row is visible the anchor shakes
22
+ instead of silently refusing to hide it.
23
+ - Only `transform` / `opacity` animate (the flight uses WAAPI, the rest is CSS),
24
+ the drag hot path gains no layout reads, and a single flight runs at a time so
25
+ rapid drops cannot pile ghosts up.
26
+ - Honors `prefers-reduced-motion: reduce`: no breathing, bounce or shake, and the
27
+ row is tucked away immediately.
28
+
6
29
  ## 0.4.1 — 2026-11-02
7
30
 
8
31
  - 修复反复拖动时排序“抽搐”:落点改为按当前视觉顺序(`layout.order` +
package/README.en.md CHANGED
@@ -6,8 +6,8 @@ Adds **custom ordering** and **tucking away** to the "global panels" strip at th
6
6
  top-left of the DSH (DeepSeek Harness) sidebar — the column of icons for plugins,
7
7
  marketplace, skills manager, scheduled tasks, and so on.
8
8
 
9
- > Version **0.4.1**: fixes jittery ordering after repeated drags and tightens
10
- > the “More” drop zone.
9
+ > Version **0.4.2**: adds the tuck-away animation when dragging onto “More”
10
+ > (breathing highlight, ghost flight, receive bounce).
11
11
 
12
12
  ## What it does
13
13
 
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  给 DSH(DeepSeek Harness)左侧栏左上角那块「全局面板」栏——插件、市场、技能管理、定时任务……那一列图标——加上**自定义排序**与**收纳**能力。
6
6
 
7
- > 当前版本 **0.4.1**:修复反复拖动时的排序抖动,并收窄「更多」的投放判定。
7
+ > 当前版本 **0.4.2**:拖动到「更多」时新增收纳动画(按钮呼吸高亮、拖影飞入收纳)。
8
8
 
9
9
  ## 现在能做什么
10
10
 
@@ -132,8 +132,9 @@ npm run check # 语法检查
132
132
  - [x] 0.3.4 — 图标内嵌 id 加前缀;「已收纳」内排序
133
133
  - [x] 0.3.5 — 指针捕获延后到拖动成立,修复收纳条目无法点击
134
134
  - [x] 0.3.6 — 点击收纳条目只打开面板,不再自动放回侧栏
135
- - [x] 0.4.0 — 源码模块化与构建脚本、多语言文档、AI 使用与代码来源声明
135
+ - [x] 0.4.0 — 源码模块化与构建脚本、多语言文档、AI 使用与代码来源声明
136
136
  - [x] 0.4.1 — 修复反复拖动时的排序抖动与「更多」误收纳
137
+ - [x] 0.4.2 — 拖动到「更多」的收纳动画(呼吸高亮、拖影飞入、回弹与拒绝抖动)
137
138
  - [ ] 0.5.0 — 无障碍补强(菜单内上移 / 下移按钮、屏幕阅读器朗读顺序变化)
138
139
 
139
140
  ## 声明与引用
package/lib/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dsh-sidebar-organizer — client half (v0.4.1).
2
+ * dsh-sidebar-organizer — client half (v0.4.2).
3
3
  *
4
4
  * GENERATED FILE — do not edit by hand.
5
5
  * Sources live in src/; run `npm run build` to regenerate.
@@ -15,7 +15,7 @@ window.__ModuleLoader__.load({
15
15
  /* Constants, locale strings and the module-level shared handles. */
16
16
 
17
17
  // Replaced at build time from package.json by scripts/build.mjs.
18
- const VERSION = "0.4.1";
18
+ const VERSION = "0.4.2";
19
19
 
20
20
  const PANEL_SLOT = "sidebar.panellist";
21
21
  const ACTIONS_SLOT = "sidebar.panellist.actions";
@@ -92,10 +92,51 @@ const CSS = `
92
92
  button[data-dso-key].dso-dragging { opacity: 0.35; }
93
93
  button[data-dso-key].dso-pressing { transform: scale(0.94); transition: transform 120ms ease; }
94
94
  button[data-dso-key] { -webkit-user-select: none; user-select: none; }
95
- button[data-dso-key].dso-drop-more {
95
+ /* The "More" anchor, armed: it breathes a ring and dips its glyph to read as
96
+ "release here and this row drops into me". */
97
+ button.dso-drop-more {
96
98
  outline: 2px dashed var(--dsw-alias-state-business-primary, #4d6bfe);
97
99
  outline-offset: -2px;
98
100
  background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.08));
101
+ animation: dso-more-breathe 1100ms ease-in-out infinite;
102
+ }
103
+ button.dso-drop-more span[aria-hidden="true"] {
104
+ transform: translateY(2px) scale(0.88);
105
+ transition: transform 160ms ease;
106
+ }
107
+ @keyframes dso-more-breathe {
108
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(77, 107, 254, 0.38); }
109
+ 55% { box-shadow: 0 0 0 8px rgba(77, 107, 254, 0); }
110
+ 100% { box-shadow: 0 0 0 0 rgba(77, 107, 254, 0); }
111
+ }
112
+ @keyframes dso-more-receive {
113
+ 0% { transform: scale(1); }
114
+ 35% { transform: scale(0.86); }
115
+ 70% { transform: scale(1.06); }
116
+ 100% { transform: scale(1); }
117
+ }
118
+ button.dso-drop-receive { animation: dso-more-receive 260ms cubic-bezier(0.2, 0.7, 0.3, 1); }
119
+ @keyframes dso-drop-reject {
120
+ 0%, 100% { transform: translateX(0); }
121
+ 20% { transform: translateX(-4px); }
122
+ 40% { transform: translateX(4px); }
123
+ 60% { transform: translateX(-3px); }
124
+ 80% { transform: translateX(2px); }
125
+ }
126
+ button.dso-drop-reject { animation: dso-drop-reject 240ms ease; }
127
+ /* Carrying a row towards the anchor: the ghost dims and shrinks a little, so
128
+ its eventual absorption does not feel like a jump. */
129
+ .dso-ghost.dso-ghost-armed {
130
+ opacity: 0.82;
131
+ transform: scale(0.94);
132
+ transition: transform 140ms ease, opacity 140ms ease;
133
+ }
134
+ @media (prefers-reduced-motion: reduce) {
135
+ button.dso-drop-more,
136
+ button.dso-drop-receive,
137
+ button.dso-drop-reject { animation: none !important; }
138
+ .dso-ghost,
139
+ .dso-ghost.dso-ghost-armed { transition: none !important; }
99
140
  }
100
141
  body.dso-dragging,
101
142
  body.dso-dragging * { cursor: grabbing !important; }
@@ -312,6 +353,7 @@ function createRuntime(ctx) {
312
353
  passDeferred: false,
313
354
  menuListeners: new Set(),
314
355
  dismissMenu: null,
356
+ absorbing: null,
315
357
  };
316
358
 
317
359
  function log(...args) {
@@ -694,9 +736,10 @@ function createRuntime(ctx) {
694
736
 
695
737
  /* --- drag gesture ------------------------------------------------ */
696
738
 
697
- function clearGesture() {
739
+ function clearGesture(options) {
698
740
  const gesture = state.gesture;
699
741
  if (!gesture) return;
742
+ const keepGhost = Boolean(options && options.keepGhost);
700
743
  state.gesture = null;
701
744
  if (gesture.timer) window.clearTimeout(gesture.timer);
702
745
  if (gesture.pressTimer) window.clearTimeout(gesture.pressTimer);
@@ -717,7 +760,11 @@ function createRuntime(ctx) {
717
760
  /* the element may already be detached */
718
761
  }
719
762
  }
720
- if (gesture.ghost && gesture.ghost.parentNode) gesture.ghost.remove();
763
+ if (gesture.ghost) {
764
+ gesture.ghost.classList.remove("dso-ghost-armed");
765
+ // A drop on the "More" anchor keeps the ghost alive for the absorb flight.
766
+ if (!keepGhost && gesture.ghost.parentNode) gesture.ghost.remove();
767
+ }
721
768
  if (gesture.indicator && gesture.indicator.parentNode) gesture.indicator.remove();
722
769
  if (gesture.element) {
723
770
  gesture.element.classList.remove("dso-dragging");
@@ -937,6 +984,96 @@ function createRuntime(ctx) {
937
984
  );
938
985
  }
939
986
 
987
+ function prefersReducedMotion() {
988
+ try {
989
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
990
+ } catch (error) {
991
+ return false;
992
+ }
993
+ }
994
+
995
+ /** Replay a one-shot CSS animation class, even if it is already on. */
996
+ function pulseClass(element, className, duration) {
997
+ if (!element) return;
998
+ element.classList.remove(className);
999
+ // force a style flush so a repeated pulse restarts instead of being ignored
1000
+ void element.offsetWidth;
1001
+ element.classList.add(className);
1002
+ window.setTimeout(() => element.classList.remove(className), duration);
1003
+ }
1004
+
1005
+ /** The carried row leans towards the anchor: dim and shrink the ghost. */
1006
+ function setGhostArmed(gesture, armed) {
1007
+ const ghost = gesture.ghost;
1008
+ if (!ghost || !ghost.isConnected) return;
1009
+ if (ghost.classList.contains("dso-ghost-armed") === armed) return;
1010
+ ghost.classList.toggle("dso-ghost-armed", armed);
1011
+ }
1012
+
1013
+ /**
1014
+ * Fly the carried ghost into the "More" anchor, then tuck the row away.
1015
+ * Only transform/opacity animate, and only one flight runs at a time; a drop
1016
+ * that arrives mid-flight lands immediately so ghosts can never pile up.
1017
+ */
1018
+ function absorbIntoMore(gesture) {
1019
+ const moreButton =
1020
+ gesture.moreButton && gesture.moreButton.isConnected ? gesture.moreButton : null;
1021
+ const ghost = gesture.ghost;
1022
+ if (!ghost || !ghost.isConnected || !moreButton || prefersReducedMotion() || state.absorbing) {
1023
+ if (moreButton) pulseClass(moreButton, "dso-drop-receive", 260);
1024
+ if (ghost && ghost.parentNode) ghost.remove();
1025
+ if (alive()) hideKey(gesture.key);
1026
+ return;
1027
+ }
1028
+
1029
+ const ghostRect = ghost.getBoundingClientRect();
1030
+ const moreRect = moreButton.getBoundingClientRect();
1031
+ const dx = moreRect.left + moreRect.width / 2 - (ghostRect.left + ghostRect.width / 2);
1032
+ const dy = moreRect.top + moreRect.height / 2 - (ghostRect.top + ghostRect.height / 2);
1033
+
1034
+ let animation = null;
1035
+ try {
1036
+ animation = ghost.animate(
1037
+ [
1038
+ { transform: "translate(0px, 0px) scale(1)", opacity: 0.96, offset: 0 },
1039
+ {
1040
+ transform: `translate(${dx * 0.55}px, ${dy * 0.55}px) scale(0.7)`,
1041
+ opacity: 0.9,
1042
+ offset: 0.55,
1043
+ },
1044
+ {
1045
+ transform: `translate(${dx}px, ${dy}px) scale(0.12)`,
1046
+ opacity: 0,
1047
+ offset: 1,
1048
+ },
1049
+ ],
1050
+ { duration: 180, easing: "cubic-bezier(.2,.7,.3,1)", fill: "forwards" }
1051
+ );
1052
+ } catch (error) {
1053
+ animation = null;
1054
+ }
1055
+
1056
+ pulseClass(moreButton, "dso-drop-receive", 260);
1057
+
1058
+ if (!animation) {
1059
+ if (ghost.parentNode) ghost.remove();
1060
+ if (alive()) hideKey(gesture.key);
1061
+ return;
1062
+ }
1063
+
1064
+ state.absorbing = animation;
1065
+ const finish = () => {
1066
+ if (state.absorbing === animation) state.absorbing = null;
1067
+ if (ghost.parentNode) ghost.remove();
1068
+ if (alive()) hideKey(gesture.key);
1069
+ };
1070
+ if (animation.finished && typeof animation.finished.then === "function") {
1071
+ animation.finished.then(finish, finish);
1072
+ } else {
1073
+ window.setTimeout(finish, 200);
1074
+ }
1075
+ }
1076
+
940
1077
  function updateTarget(gesture) {
941
1078
  const indicator = gesture.indicator;
942
1079
  if (!indicator) return;
@@ -998,6 +1135,7 @@ function createRuntime(ctx) {
998
1135
  gesture.moreButton = snapshot.moreButton;
999
1136
  if (gesture.moreButton) gesture.moreButton.classList.add("dso-drop-more");
1000
1137
  }
1138
+ setGhostArmed(gesture, true);
1001
1139
  if (indicator.dataset.visible !== "0") indicator.dataset.visible = "0";
1002
1140
  gesture.target = { kind: "hide" };
1003
1141
  return;
@@ -1007,6 +1145,7 @@ function createRuntime(ctx) {
1007
1145
  gesture.moreButton.classList.remove("dso-drop-more");
1008
1146
  gesture.moreButton = null;
1009
1147
  }
1148
+ setGhostArmed(gesture, false);
1010
1149
 
1011
1150
  if (!insideRect(snapshot.navRect, x, y, 24) || others.length === 0) {
1012
1151
  if (indicator.dataset.visible !== "0") indicator.dataset.visible = "0";
@@ -1055,7 +1194,15 @@ function createRuntime(ctx) {
1055
1194
  const { key, target, source } = gesture;
1056
1195
  if (!target) return;
1057
1196
  if (target.kind === "hide") {
1058
- hideKey(key);
1197
+ // Last-visible-row protection lives in hideKey; bounce the anchor instead
1198
+ // of starting an absorb flight that would end in nothing.
1199
+ const { rows } = visibleRows();
1200
+ const visible = visibleKeys(rows);
1201
+ if (!visible.includes(key) || visible.length <= 1) {
1202
+ pulseClass(gesture.moreButton, "dso-drop-reject", 240);
1203
+ return;
1204
+ }
1205
+ absorbIntoMore(gesture);
1059
1206
  return;
1060
1207
  }
1061
1208
  if (target.kind === "place-menu") {
@@ -1115,7 +1262,10 @@ function createRuntime(ctx) {
1115
1262
  if (!gesture) return;
1116
1263
  if (gesture.phase === "dragging") {
1117
1264
  const pending = gesture;
1118
- clearGesture();
1265
+ // A drop on the anchor keeps the ghost for the absorb flight; every other
1266
+ // drop tears the drag down first so commits never see stale classes.
1267
+ const absorbing = Boolean(pending.target && pending.target.kind === "hide");
1268
+ clearGesture({ keepGhost: absorbing });
1119
1269
  commitDrag(pending);
1120
1270
  state.guardUntil = Date.now() + CLICK_GUARD_MS;
1121
1271
  return;
@@ -1254,6 +1404,21 @@ function createRuntime(ctx) {
1254
1404
  function stop() {
1255
1405
  state.stopped = true;
1256
1406
  clearGesture();
1407
+ if (state.absorbing) {
1408
+ try {
1409
+ state.absorbing.cancel();
1410
+ } catch (error) {
1411
+ /* ignore */
1412
+ }
1413
+ state.absorbing = null;
1414
+ }
1415
+ // the absorb flight may have left a ghost or a one-shot class behind
1416
+ for (const ghost of document.querySelectorAll(".dso-ghost")) ghost.remove();
1417
+ for (const node of document.querySelectorAll(
1418
+ ".dso-drop-more, .dso-drop-receive, .dso-drop-reject"
1419
+ )) {
1420
+ node.classList.remove("dso-drop-more", "dso-drop-receive", "dso-drop-reject");
1421
+ }
1257
1422
  if (state.pollTimer) {
1258
1423
  window.clearInterval(state.pollTimer);
1259
1424
  state.pollTimer = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidebar-organizer",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "private": false,
5
5
  "description": "DSH 侧栏面板整理器:长按拖动排序、拖入/拖出「更多」收纳、键盘排序",
6
6
  "type": "module",
package/src/runtime.js CHANGED
@@ -17,6 +17,7 @@ function createRuntime(ctx) {
17
17
  passDeferred: false,
18
18
  menuListeners: new Set(),
19
19
  dismissMenu: null,
20
+ absorbing: null,
20
21
  };
21
22
 
22
23
  function log(...args) {
@@ -399,9 +400,10 @@ function createRuntime(ctx) {
399
400
 
400
401
  /* --- drag gesture ------------------------------------------------ */
401
402
 
402
- function clearGesture() {
403
+ function clearGesture(options) {
403
404
  const gesture = state.gesture;
404
405
  if (!gesture) return;
406
+ const keepGhost = Boolean(options && options.keepGhost);
405
407
  state.gesture = null;
406
408
  if (gesture.timer) window.clearTimeout(gesture.timer);
407
409
  if (gesture.pressTimer) window.clearTimeout(gesture.pressTimer);
@@ -422,7 +424,11 @@ function createRuntime(ctx) {
422
424
  /* the element may already be detached */
423
425
  }
424
426
  }
425
- if (gesture.ghost && gesture.ghost.parentNode) gesture.ghost.remove();
427
+ if (gesture.ghost) {
428
+ gesture.ghost.classList.remove("dso-ghost-armed");
429
+ // A drop on the "More" anchor keeps the ghost alive for the absorb flight.
430
+ if (!keepGhost && gesture.ghost.parentNode) gesture.ghost.remove();
431
+ }
426
432
  if (gesture.indicator && gesture.indicator.parentNode) gesture.indicator.remove();
427
433
  if (gesture.element) {
428
434
  gesture.element.classList.remove("dso-dragging");
@@ -642,6 +648,96 @@ function createRuntime(ctx) {
642
648
  );
643
649
  }
644
650
 
651
+ function prefersReducedMotion() {
652
+ try {
653
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
654
+ } catch (error) {
655
+ return false;
656
+ }
657
+ }
658
+
659
+ /** Replay a one-shot CSS animation class, even if it is already on. */
660
+ function pulseClass(element, className, duration) {
661
+ if (!element) return;
662
+ element.classList.remove(className);
663
+ // force a style flush so a repeated pulse restarts instead of being ignored
664
+ void element.offsetWidth;
665
+ element.classList.add(className);
666
+ window.setTimeout(() => element.classList.remove(className), duration);
667
+ }
668
+
669
+ /** The carried row leans towards the anchor: dim and shrink the ghost. */
670
+ function setGhostArmed(gesture, armed) {
671
+ const ghost = gesture.ghost;
672
+ if (!ghost || !ghost.isConnected) return;
673
+ if (ghost.classList.contains("dso-ghost-armed") === armed) return;
674
+ ghost.classList.toggle("dso-ghost-armed", armed);
675
+ }
676
+
677
+ /**
678
+ * Fly the carried ghost into the "More" anchor, then tuck the row away.
679
+ * Only transform/opacity animate, and only one flight runs at a time; a drop
680
+ * that arrives mid-flight lands immediately so ghosts can never pile up.
681
+ */
682
+ function absorbIntoMore(gesture) {
683
+ const moreButton =
684
+ gesture.moreButton && gesture.moreButton.isConnected ? gesture.moreButton : null;
685
+ const ghost = gesture.ghost;
686
+ if (!ghost || !ghost.isConnected || !moreButton || prefersReducedMotion() || state.absorbing) {
687
+ if (moreButton) pulseClass(moreButton, "dso-drop-receive", 260);
688
+ if (ghost && ghost.parentNode) ghost.remove();
689
+ if (alive()) hideKey(gesture.key);
690
+ return;
691
+ }
692
+
693
+ const ghostRect = ghost.getBoundingClientRect();
694
+ const moreRect = moreButton.getBoundingClientRect();
695
+ const dx = moreRect.left + moreRect.width / 2 - (ghostRect.left + ghostRect.width / 2);
696
+ const dy = moreRect.top + moreRect.height / 2 - (ghostRect.top + ghostRect.height / 2);
697
+
698
+ let animation = null;
699
+ try {
700
+ animation = ghost.animate(
701
+ [
702
+ { transform: "translate(0px, 0px) scale(1)", opacity: 0.96, offset: 0 },
703
+ {
704
+ transform: `translate(${dx * 0.55}px, ${dy * 0.55}px) scale(0.7)`,
705
+ opacity: 0.9,
706
+ offset: 0.55,
707
+ },
708
+ {
709
+ transform: `translate(${dx}px, ${dy}px) scale(0.12)`,
710
+ opacity: 0,
711
+ offset: 1,
712
+ },
713
+ ],
714
+ { duration: 180, easing: "cubic-bezier(.2,.7,.3,1)", fill: "forwards" }
715
+ );
716
+ } catch (error) {
717
+ animation = null;
718
+ }
719
+
720
+ pulseClass(moreButton, "dso-drop-receive", 260);
721
+
722
+ if (!animation) {
723
+ if (ghost.parentNode) ghost.remove();
724
+ if (alive()) hideKey(gesture.key);
725
+ return;
726
+ }
727
+
728
+ state.absorbing = animation;
729
+ const finish = () => {
730
+ if (state.absorbing === animation) state.absorbing = null;
731
+ if (ghost.parentNode) ghost.remove();
732
+ if (alive()) hideKey(gesture.key);
733
+ };
734
+ if (animation.finished && typeof animation.finished.then === "function") {
735
+ animation.finished.then(finish, finish);
736
+ } else {
737
+ window.setTimeout(finish, 200);
738
+ }
739
+ }
740
+
645
741
  function updateTarget(gesture) {
646
742
  const indicator = gesture.indicator;
647
743
  if (!indicator) return;
@@ -703,6 +799,7 @@ function createRuntime(ctx) {
703
799
  gesture.moreButton = snapshot.moreButton;
704
800
  if (gesture.moreButton) gesture.moreButton.classList.add("dso-drop-more");
705
801
  }
802
+ setGhostArmed(gesture, true);
706
803
  if (indicator.dataset.visible !== "0") indicator.dataset.visible = "0";
707
804
  gesture.target = { kind: "hide" };
708
805
  return;
@@ -712,6 +809,7 @@ function createRuntime(ctx) {
712
809
  gesture.moreButton.classList.remove("dso-drop-more");
713
810
  gesture.moreButton = null;
714
811
  }
812
+ setGhostArmed(gesture, false);
715
813
 
716
814
  if (!insideRect(snapshot.navRect, x, y, 24) || others.length === 0) {
717
815
  if (indicator.dataset.visible !== "0") indicator.dataset.visible = "0";
@@ -760,7 +858,15 @@ function createRuntime(ctx) {
760
858
  const { key, target, source } = gesture;
761
859
  if (!target) return;
762
860
  if (target.kind === "hide") {
763
- hideKey(key);
861
+ // Last-visible-row protection lives in hideKey; bounce the anchor instead
862
+ // of starting an absorb flight that would end in nothing.
863
+ const { rows } = visibleRows();
864
+ const visible = visibleKeys(rows);
865
+ if (!visible.includes(key) || visible.length <= 1) {
866
+ pulseClass(gesture.moreButton, "dso-drop-reject", 240);
867
+ return;
868
+ }
869
+ absorbIntoMore(gesture);
764
870
  return;
765
871
  }
766
872
  if (target.kind === "place-menu") {
@@ -820,7 +926,10 @@ function createRuntime(ctx) {
820
926
  if (!gesture) return;
821
927
  if (gesture.phase === "dragging") {
822
928
  const pending = gesture;
823
- clearGesture();
929
+ // A drop on the anchor keeps the ghost for the absorb flight; every other
930
+ // drop tears the drag down first so commits never see stale classes.
931
+ const absorbing = Boolean(pending.target && pending.target.kind === "hide");
932
+ clearGesture({ keepGhost: absorbing });
824
933
  commitDrag(pending);
825
934
  state.guardUntil = Date.now() + CLICK_GUARD_MS;
826
935
  return;
@@ -959,6 +1068,21 @@ function createRuntime(ctx) {
959
1068
  function stop() {
960
1069
  state.stopped = true;
961
1070
  clearGesture();
1071
+ if (state.absorbing) {
1072
+ try {
1073
+ state.absorbing.cancel();
1074
+ } catch (error) {
1075
+ /* ignore */
1076
+ }
1077
+ state.absorbing = null;
1078
+ }
1079
+ // the absorb flight may have left a ghost or a one-shot class behind
1080
+ for (const ghost of document.querySelectorAll(".dso-ghost")) ghost.remove();
1081
+ for (const node of document.querySelectorAll(
1082
+ ".dso-drop-more, .dso-drop-receive, .dso-drop-reject"
1083
+ )) {
1084
+ node.classList.remove("dso-drop-more", "dso-drop-receive", "dso-drop-reject");
1085
+ }
962
1086
  if (state.pollTimer) {
963
1087
  window.clearInterval(state.pollTimer);
964
1088
  state.pollTimer = 0;
package/src/styles.js CHANGED
@@ -31,10 +31,51 @@ const CSS = `
31
31
  button[data-dso-key].dso-dragging { opacity: 0.35; }
32
32
  button[data-dso-key].dso-pressing { transform: scale(0.94); transition: transform 120ms ease; }
33
33
  button[data-dso-key] { -webkit-user-select: none; user-select: none; }
34
- button[data-dso-key].dso-drop-more {
34
+ /* The "More" anchor, armed: it breathes a ring and dips its glyph to read as
35
+ "release here and this row drops into me". */
36
+ button.dso-drop-more {
35
37
  outline: 2px dashed var(--dsw-alias-state-business-primary, #4d6bfe);
36
38
  outline-offset: -2px;
37
39
  background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.08));
40
+ animation: dso-more-breathe 1100ms ease-in-out infinite;
41
+ }
42
+ button.dso-drop-more span[aria-hidden="true"] {
43
+ transform: translateY(2px) scale(0.88);
44
+ transition: transform 160ms ease;
45
+ }
46
+ @keyframes dso-more-breathe {
47
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(77, 107, 254, 0.38); }
48
+ 55% { box-shadow: 0 0 0 8px rgba(77, 107, 254, 0); }
49
+ 100% { box-shadow: 0 0 0 0 rgba(77, 107, 254, 0); }
50
+ }
51
+ @keyframes dso-more-receive {
52
+ 0% { transform: scale(1); }
53
+ 35% { transform: scale(0.86); }
54
+ 70% { transform: scale(1.06); }
55
+ 100% { transform: scale(1); }
56
+ }
57
+ button.dso-drop-receive { animation: dso-more-receive 260ms cubic-bezier(0.2, 0.7, 0.3, 1); }
58
+ @keyframes dso-drop-reject {
59
+ 0%, 100% { transform: translateX(0); }
60
+ 20% { transform: translateX(-4px); }
61
+ 40% { transform: translateX(4px); }
62
+ 60% { transform: translateX(-3px); }
63
+ 80% { transform: translateX(2px); }
64
+ }
65
+ button.dso-drop-reject { animation: dso-drop-reject 240ms ease; }
66
+ /* Carrying a row towards the anchor: the ghost dims and shrinks a little, so
67
+ its eventual absorption does not feel like a jump. */
68
+ .dso-ghost.dso-ghost-armed {
69
+ opacity: 0.82;
70
+ transform: scale(0.94);
71
+ transition: transform 140ms ease, opacity 140ms ease;
72
+ }
73
+ @media (prefers-reduced-motion: reduce) {
74
+ button.dso-drop-more,
75
+ button.dso-drop-receive,
76
+ button.dso-drop-reject { animation: none !important; }
77
+ .dso-ghost,
78
+ .dso-ghost.dso-ghost-armed { transition: none !important; }
38
79
  }
39
80
  body.dso-dragging,
40
81
  body.dso-dragging * { cursor: grabbing !important; }