@allshift/ui 0.5.1 → 0.6.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/dist/index.cjs CHANGED
@@ -36,11 +36,14 @@ __export(index_exports, {
36
36
  CanvasProvider: () => CanvasProvider,
37
37
  CanvasTransition: () => CanvasTransition,
38
38
  DEFAULT_H: () => DEFAULT_H,
39
+ DUO_FOCUS_SHARE: () => DUO_FOCUS_SHARE,
39
40
  GAP: () => GAP,
40
41
  GenerativeView: () => GenerativeView,
41
42
  HolographicShine: () => HolographicShine,
42
43
  LiveCard: () => LiveCard,
43
44
  LiveStat: () => LiveStat,
45
+ MAX_WINDOWS: () => MAX_WINDOWS,
46
+ MAX_WINDOWS_PHONE: () => MAX_WINDOWS_PHONE,
44
47
  MIN_H: () => MIN_H,
45
48
  MIN_W: () => MIN_W,
46
49
  PageTransition: () => PageTransition,
@@ -49,6 +52,8 @@ __export(index_exports, {
49
52
  ShiftCard: () => ShiftCard,
50
53
  TiltCard: () => TiltCard,
51
54
  WIDTH_SPAN: () => WIDTH_SPAN,
55
+ detectMode: () => detectMode,
56
+ maxWindowsFor: () => maxWindowsFor,
52
57
  tallH: () => tallH,
53
58
  useCanvas: () => useCanvas,
54
59
  useGenerate: () => useGenerate,
@@ -824,7 +829,7 @@ function PageTransition({ children, className }) {
824
829
  import_framer_motion11.motion.div,
825
830
  {
826
831
  initial: { opacity: 0, x: dir * 22, filter: "blur(3px)" },
827
- animate: { opacity: 1, x: 0, filter: "blur(0px)" },
832
+ animate: { opacity: 1, x: 0, filter: "blur(0px)", transitionEnd: { filter: "none" } },
828
833
  transition: { type: "spring", stiffness: 400, damping: 34 },
829
834
  className: className ?? "flex flex-col flex-1 min-h-0",
830
835
  children
@@ -835,6 +840,59 @@ function PageTransition({ children, className }) {
835
840
 
836
841
  // src/canvas/CanvasProvider.tsx
837
842
  var import_react10 = require("react");
843
+
844
+ // src/canvas/duo.ts
845
+ var MAX_WINDOWS = 4;
846
+ var MAX_WINDOWS_PHONE = 2;
847
+ var DUO_FOCUS_SHARE = 0.62;
848
+ var PHONE_CHROME = 220;
849
+ var DUO_GAP = 12;
850
+ var DUO_MIN_H = 160;
851
+ function detectMode(innerWidth, innerHeight, coarsePointer) {
852
+ if (innerWidth < 720) return "phone";
853
+ if (coarsePointer && Math.min(innerWidth, innerHeight) < 600) return "phone";
854
+ return "wide";
855
+ }
856
+ function maxWindowsFor(mode, limits) {
857
+ const n = mode === "phone" ? limits?.phone ?? MAX_WINDOWS_PHONE : limits?.wide ?? MAX_WINDOWS;
858
+ return Math.max(1, Math.floor(n));
859
+ }
860
+ function capList(list, max, keepId) {
861
+ let out = list;
862
+ while (out.length > max) {
863
+ const victim = out.filter((b) => !b.locked && b.id !== keepId).sort((a, b) => a.z - b.z)[0];
864
+ if (!victim) break;
865
+ out = out.filter((b) => b.id !== victim.id);
866
+ }
867
+ return out;
868
+ }
869
+ function tileDuo(items, boardW, boardH) {
870
+ const n = items.length;
871
+ if (n === 0) return items;
872
+ const sorted = [...items].sort((a, b) => b.z - a.z);
873
+ const availH = Math.max(DUO_MIN_H * 2 + DUO_GAP, boardH - PHONE_CHROME);
874
+ if (n === 1) return [{ ...sorted[0], x: 0, y: 0, w: boardW, h: availH, placed: false }];
875
+ const minRest = (DUO_MIN_H + DUO_GAP) * (n - 1);
876
+ const focusH = Math.max(DUO_MIN_H, Math.min(Math.round(availH * DUO_FOCUS_SHARE), availH - minRest));
877
+ const restH = Math.max(DUO_MIN_H, Math.round((availH - focusH - DUO_GAP * (n - 1)) / (n - 1)));
878
+ let y = 0;
879
+ return sorted.map((it, i) => {
880
+ const h = i === 0 ? focusH : restH;
881
+ const out = { ...it, x: 0, y, w: boardW, h, placed: false };
882
+ y += h + DUO_GAP;
883
+ return out;
884
+ });
885
+ }
886
+ function sameGeometry(a, b) {
887
+ if (a.length !== b.length) return false;
888
+ const byId = new Map(b.map((x) => [x.id, x]));
889
+ return a.every((x) => {
890
+ const y = byId.get(x.id);
891
+ return !!y && x.x === y.x && x.y === y.y && x.w === y.w && x.h === y.h;
892
+ });
893
+ }
894
+
895
+ // src/canvas/CanvasProvider.tsx
838
896
  var import_jsx_runtime11 = require("react/jsx-runtime");
839
897
  var GAP = 20;
840
898
  var DEFAULT_H = 420;
@@ -983,9 +1041,10 @@ function computeSnap(blocks, id, layout, boardW, H) {
983
1041
  }
984
1042
  return placements;
985
1043
  }
986
- function tileItems(items, boardW, boardH) {
1044
+ function tileItems(items, boardW, boardH, mode = "wide") {
987
1045
  const n = items.length;
988
1046
  if (n === 0) return items;
1047
+ if (mode === "phone") return tileDuo(items, boardW, boardH);
989
1048
  const availH = Math.max(MIN_H * 2 + GAP, boardH - 150);
990
1049
  const rows = Math.ceil(n / 2);
991
1050
  const rowH = Math.max(300, Math.round((availH - GAP * (rows - 1)) / rows));
@@ -1004,6 +1063,20 @@ function tileItems(items, boardW, boardH) {
1004
1063
  return { ...it, x, y, w, h: rowH };
1005
1064
  });
1006
1065
  }
1066
+ function fitToBoard(list, boardW, boardH) {
1067
+ if (list.length === 0) return list;
1068
+ const extent = Math.max(...list.map((b) => b.x + b.w));
1069
+ if (extent > boardW * 1.5) return tileItems(list, boardW, boardH);
1070
+ let changed = false;
1071
+ const next = list.map((b) => {
1072
+ const w = Math.min(b.w, boardW);
1073
+ const x = Math.max(0, Math.min(b.x, boardW - w));
1074
+ if (w === b.w && x === b.x) return b;
1075
+ changed = true;
1076
+ return { ...b, w, x };
1077
+ });
1078
+ return changed ? next : list;
1079
+ }
1007
1080
  function overlaps(a, b) {
1008
1081
  return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
1009
1082
  }
@@ -1024,7 +1097,8 @@ function CanvasProvider({
1024
1097
  initialLayout,
1025
1098
  addKey = null,
1026
1099
  persist = false,
1027
- savedSession
1100
+ savedSession,
1101
+ limits
1028
1102
  }) {
1029
1103
  const resolveRef = (0, import_react10.useRef)(resolveBlock);
1030
1104
  (0, import_react10.useEffect)(() => {
@@ -1032,14 +1106,22 @@ function CanvasProvider({
1032
1106
  }, [resolveBlock]);
1033
1107
  const resolve = (key) => resolveRef.current(key);
1034
1108
  const initial = (0, import_react10.useMemo)(() => {
1035
- let z = 1;
1036
- const base = initialLayout ? initialLayout.filter((b) => b && typeof b.key === "string" && resolveRef.current(b.key)).map((b) => blockFromSaved(b, z++)) : initialKeys.filter((k) => resolveRef.current(k)).map((k) => blockFromSaved(k, z++));
1037
- if (addKey && resolveRef.current(addKey) && !base.some((b) => b.key === addKey)) {
1038
- base.unshift(blockFromSaved(addKey, z++));
1109
+ const max = maxWindowsFor("wide", limits);
1110
+ const entries = initialLayout ? initialLayout.filter((b) => b && typeof b.key === "string" && resolveRef.current(b.key)) : initialKeys.filter((k) => resolveRef.current(k));
1111
+ if (addKey && resolveRef.current(addKey) && !entries.some((b) => (typeof b === "string" ? b : b.key) === addKey)) {
1112
+ entries.unshift(addKey);
1039
1113
  }
1114
+ const kept = entries.slice(0, max);
1115
+ let z = 1;
1116
+ const base = kept.map(() => null);
1117
+ for (let i = kept.length - 1; i >= 0; i--) base[i] = blockFromSaved(kept[i], z++);
1040
1118
  return { base, nextZ: z };
1041
1119
  }, []);
1042
1120
  const zRef = (0, import_react10.useRef)(initial.nextZ);
1121
+ const limitsRef = (0, import_react10.useRef)(limits);
1122
+ (0, import_react10.useEffect)(() => {
1123
+ limitsRef.current = limits;
1124
+ }, [limits]);
1043
1125
  const [blocks, setBlocks] = (0, import_react10.useState)(initial.base);
1044
1126
  const [interacting, setInteracting] = (0, import_react10.useState)(false);
1045
1127
  const [activeId, setActiveId] = (0, import_react10.useState)(null);
@@ -1047,26 +1129,39 @@ function CanvasProvider({
1047
1129
  const [boardWidth, setBoardWidth] = (0, import_react10.useState)(1280);
1048
1130
  const boardRef = (0, import_react10.useRef)(1280);
1049
1131
  const boardHRef = (0, import_react10.useRef)(820);
1132
+ const [mode, setMode] = (0, import_react10.useState)("wide");
1133
+ const modeRef = (0, import_react10.useRef)("wide");
1134
+ const maxFor = (m) => maxWindowsFor(m, limitsRef.current);
1050
1135
  const persistRef = (0, import_react10.useRef)(persist);
1051
1136
  (0, import_react10.useEffect)(() => {
1052
1137
  persistRef.current = persist;
1053
1138
  }, [persist]);
1139
+ const fitAdjustRef = (0, import_react10.useRef)(false);
1140
+ const hadUserChangeRef = (0, import_react10.useRef)(false);
1054
1141
  (0, import_react10.useEffect)(() => {
1055
1142
  const measure = () => {
1056
- const w = Math.max(360, window.innerWidth - 40);
1143
+ const vw = Math.min(window.innerWidth, window.visualViewport?.width || Infinity);
1144
+ const w = Math.max(320, vw - 40);
1057
1145
  boardRef.current = w;
1058
1146
  boardHRef.current = Math.max(480, window.innerHeight);
1059
1147
  setBoardWidth(w);
1148
+ const coarse = !!window.matchMedia?.("(pointer: coarse)").matches;
1149
+ const m = detectMode(vw, window.innerHeight, coarse);
1150
+ const modeChanged = m !== modeRef.current;
1151
+ modeRef.current = m;
1152
+ setMode(m);
1060
1153
  setBlocks((prev) => {
1061
- let changed = false;
1062
- const next = prev.map((b) => {
1063
- const bw = Math.min(b.w, w);
1064
- const bx = Math.max(0, Math.min(b.x, w - bw));
1065
- if (bw === b.w && bx === b.x) return b;
1066
- changed = true;
1067
- return { ...b, w: bw, x: bx };
1068
- });
1069
- return changed ? next : prev;
1154
+ let next;
1155
+ if (m === "phone") {
1156
+ next = tileDuo(capList(prev, maxFor("phone")), w, boardHRef.current);
1157
+ if (sameGeometry(prev, next)) return prev;
1158
+ } else if (modeChanged) {
1159
+ next = tileItems(capList(prev, maxFor("wide")), w, boardHRef.current);
1160
+ } else {
1161
+ next = fitToBoard(prev, w, boardHRef.current);
1162
+ }
1163
+ if (next !== prev) fitAdjustRef.current = true;
1164
+ return next;
1070
1165
  });
1071
1166
  };
1072
1167
  measure();
@@ -1074,22 +1169,23 @@ function CanvasProvider({
1074
1169
  return () => window.removeEventListener("resize", measure);
1075
1170
  }, []);
1076
1171
  const layoutDefault = (0, import_react10.useCallback)(
1077
- (list) => tileItems(list, boardRef.current, boardHRef.current),
1172
+ (list) => tileItems(list, boardRef.current, boardHRef.current, modeRef.current),
1078
1173
  []
1079
1174
  );
1175
+ const customizedIn = (list) => modeRef.current !== "phone" && list.some((b) => b.placed);
1080
1176
  const tiledOnce = (0, import_react10.useRef)(false);
1081
1177
  (0, import_react10.useEffect)(() => {
1082
1178
  if (tiledOnce.current) return;
1083
1179
  tiledOnce.current = true;
1084
- setBlocks((prev) => prev.length === 0 || prev.some((b) => b.placed) ? prev : layoutDefault(prev));
1180
+ setBlocks((prev) => prev.length === 0 || customizedIn(prev) ? prev : layoutDefault(prev));
1085
1181
  }, [boardWidth]);
1086
1182
  const addBlock = (0, import_react10.useCallback)((key, context, width, append = false) => {
1087
1183
  if (!resolve(key)) return;
1088
1184
  const w = width ?? resolve(key)?.defaultWidth;
1089
1185
  setBlocks((prev) => {
1090
1186
  const existing = prev.find((b) => b.key === key);
1091
- const customized = prev.some((b) => b.placed);
1092
- const without = prev.filter((b) => b.key !== key);
1187
+ const customized = customizedIn(prev);
1188
+ const without = capList(prev.filter((b) => b.key !== key), maxFor(modeRef.current) - 1);
1093
1189
  if (customized) {
1094
1190
  if (append) {
1095
1191
  const startY = without.length ? Math.max(...without.map((b) => b.y + b.h)) + GAP : 0;
@@ -1129,13 +1225,15 @@ function CanvasProvider({
1129
1225
  const removeBlock = (0, import_react10.useCallback)((id) => {
1130
1226
  setBlocks((prev) => {
1131
1227
  const remaining = prev.filter((b) => b.id !== id);
1132
- return remaining.length > 0 && !remaining.some((b) => b.placed) ? layoutDefault(remaining) : remaining;
1228
+ return remaining.length > 0 && !customizedIn(remaining) ? layoutDefault(remaining) : remaining;
1133
1229
  });
1134
1230
  }, [layoutDefault]);
1135
1231
  const resizeBlock = (0, import_react10.useCallback)((id, width) => {
1232
+ if (modeRef.current === "phone") return;
1136
1233
  setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, width, w: pxWidth(WIDTH_SPAN[width], boardRef.current), placed: true, z: zRef.current++ } : b));
1137
1234
  }, []);
1138
1235
  const moveTo = (0, import_react10.useCallback)((id, x, y) => {
1236
+ if (modeRef.current === "phone") return;
1139
1237
  const boardW = boardRef.current;
1140
1238
  setBlocks((prev) => prev.map((b) => {
1141
1239
  if (b.id !== id || b.locked) return b;
@@ -1145,6 +1243,7 @@ function CanvasProvider({
1145
1243
  }));
1146
1244
  }, []);
1147
1245
  const sizeTo = (0, import_react10.useCallback)((id, w, h) => {
1246
+ if (modeRef.current === "phone") return;
1148
1247
  const boardW = boardRef.current;
1149
1248
  setBlocks((prev) => prev.map((b) => {
1150
1249
  if (b.id !== id || b.locked) return b;
@@ -1154,6 +1253,7 @@ function CanvasProvider({
1154
1253
  }));
1155
1254
  }, []);
1156
1255
  const setRect = (0, import_react10.useCallback)((id, r) => {
1256
+ if (modeRef.current === "phone") return;
1157
1257
  const boardW = boardRef.current;
1158
1258
  setBlocks((prev) => prev.map((b) => {
1159
1259
  if (b.id !== id || b.locked) return b;
@@ -1166,6 +1266,7 @@ function CanvasProvider({
1166
1266
  }));
1167
1267
  }, []);
1168
1268
  const snapTo = (0, import_react10.useCallback)((id, layout) => {
1269
+ if (modeRef.current === "phone") return;
1169
1270
  const boardW = boardRef.current;
1170
1271
  const H = tallH(boardHRef.current);
1171
1272
  setSnapGhost(null);
@@ -1213,6 +1314,7 @@ function CanvasProvider({
1213
1314
  }, [blocks]);
1214
1315
  const clearSnapPreview = (0, import_react10.useCallback)(() => setSnapGhost(null), []);
1215
1316
  const resizeRow = (0, import_react10.useCallback)((id, x, w, h, origin) => {
1317
+ if (modeRef.current === "phone") return;
1216
1318
  const boardW = boardRef.current;
1217
1319
  const M0 = origin.find((b) => b.id === id);
1218
1320
  if (!M0 || M0.locked) return;
@@ -1262,9 +1364,14 @@ function CanvasProvider({
1262
1364
  }));
1263
1365
  }, []);
1264
1366
  const bringToFront = (0, import_react10.useCallback)((id) => {
1265
- setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, z: zRef.current++ } : b));
1266
- }, []);
1367
+ setBlocks((prev) => {
1368
+ if (prev.find((b) => b.id === id)?.z === zRef.current - 1) return prev;
1369
+ const next = prev.map((b) => b.id === id ? { ...b, z: zRef.current++ } : b);
1370
+ return modeRef.current === "phone" ? layoutDefault(next) : next;
1371
+ });
1372
+ }, [layoutDefault]);
1267
1373
  const resolveCollisions = (0, import_react10.useCallback)((id) => {
1374
+ if (modeRef.current === "phone") return;
1268
1375
  setBlocks((prev) => {
1269
1376
  const moved = prev.find((b) => b.id === id);
1270
1377
  if (!moved) return prev;
@@ -1307,8 +1414,13 @@ function CanvasProvider({
1307
1414
  if (!saved || saved.length === 0) return;
1308
1415
  restoredRef.current = true;
1309
1416
  setCanRestore(false);
1310
- const built = saved.filter((b) => b && typeof b.key === "string" && resolve(b.key)).map((b) => blockFromSaved(b, zRef.current++));
1311
- setBlocks(built.some((b) => b.placed) ? built : layoutDefault(built));
1417
+ const kept = saved.filter((b) => b && typeof b.key === "string" && resolve(b.key)).slice(0, maxFor(modeRef.current));
1418
+ const built = kept.map(() => null);
1419
+ for (let i = kept.length - 1; i >= 0; i--) built[i] = blockFromSaved(kept[i], zRef.current++);
1420
+ const arranged = customizedIn(built) ? built : layoutDefault(built);
1421
+ const fitted = fitToBoard(arranged, boardRef.current, boardHRef.current);
1422
+ if (fitted !== arranged) fitAdjustRef.current = true;
1423
+ setBlocks(fitted);
1312
1424
  }, [savedSession, layoutDefault]);
1313
1425
  const clear = (0, import_react10.useCallback)((keepKey) => {
1314
1426
  setBlocks((prev) => keepKey ? prev.filter((b) => b.key === keepKey) : []);
@@ -1328,7 +1440,7 @@ function CanvasProvider({
1328
1440
  const width = d.width;
1329
1441
  setBlocks((prev) => {
1330
1442
  const list = prev.map((b) => b.key === d.key ? { ...b, width } : b);
1331
- return prev.some((b) => b.placed) ? list : layoutDefault(list);
1443
+ return customizedIn(prev) ? list : layoutDefault(list);
1332
1444
  });
1333
1445
  }
1334
1446
  function onPlace(e) {
@@ -1342,7 +1454,7 @@ function CanvasProvider({
1342
1454
  const [moved] = next.splice(srcIdx, 1);
1343
1455
  const insertAt = next.findIndex((b) => b.key === d.afterKey) + 1;
1344
1456
  next.splice(insertAt, 0, moved);
1345
- return prev.some((b) => b.placed) ? next : layoutDefault(next);
1457
+ return customizedIn(prev) ? next : layoutDefault(next);
1346
1458
  });
1347
1459
  }
1348
1460
  function onSwap(e) {
@@ -1350,7 +1462,7 @@ function CanvasProvider({
1350
1462
  const keys = d?.keys ?? [];
1351
1463
  const contexts = d?.contexts ?? [];
1352
1464
  const widths = d?.widths ?? [];
1353
- const built = keys.filter((k) => resolve(k)).map((key, i) => ({
1465
+ const built = keys.filter((k) => resolve(k)).slice(0, maxFor(modeRef.current)).map((key, i) => ({
1354
1466
  id: nextId(),
1355
1467
  key,
1356
1468
  context: contexts[i],
@@ -1407,12 +1519,13 @@ function CanvasProvider({
1407
1519
  }, [blocks.length, canRestore]);
1408
1520
  const firstRun = (0, import_react10.useRef)(true);
1409
1521
  const saveTimer = (0, import_react10.useRef)(null);
1410
- const layoutSignature = blocks.map((b) => `${b.key}:${b.width ?? ""}:${Math.round(b.x)}:${Math.round(b.y)}:${Math.round(b.w)}:${Math.round(b.h)}`).join("|");
1522
+ const layoutSignature = blocks.map((b) => mode === "phone" ? `${b.key}:${b.width ?? ""}` : `${b.key}:${b.width ?? ""}:${Math.round(b.x)}:${Math.round(b.y)}:${Math.round(b.w)}:${Math.round(b.h)}`).join("|");
1411
1523
  const payload = (0, import_react10.useMemo)(() => layoutSignature ? {
1412
1524
  savedDay: localDay(),
1413
1525
  blocks: layoutSignature.split("|").map((seg) => {
1414
1526
  const [key, width, x, y, w, h] = seg.split(":");
1415
- return { key, ...width ? { width } : {}, x: Number(x), y: Number(y), w: Number(w), h: Number(h) };
1527
+ const base = { key, ...width ? { width } : {} };
1528
+ return x === void 0 ? base : { ...base, x: Number(x), y: Number(y), w: Number(w), h: Number(h) };
1416
1529
  })
1417
1530
  } : null, [layoutSignature]);
1418
1531
  const payloadRef = (0, import_react10.useRef)(payload);
@@ -1425,6 +1538,12 @@ function CanvasProvider({
1425
1538
  firstRun.current = false;
1426
1539
  return;
1427
1540
  }
1541
+ if (fitAdjustRef.current) {
1542
+ fitAdjustRef.current = false;
1543
+ if (!hadUserChangeRef.current) return;
1544
+ } else {
1545
+ hadUserChangeRef.current = true;
1546
+ }
1428
1547
  if (!payloadRef.current) return;
1429
1548
  if (saveTimer.current) clearTimeout(saveTimer.current);
1430
1549
  const body = JSON.stringify(payloadRef.current);
@@ -1443,6 +1562,7 @@ function CanvasProvider({
1443
1562
  (0, import_react10.useEffect)(() => {
1444
1563
  if (!persist) return;
1445
1564
  const flush = () => {
1565
+ if (!hadUserChangeRef.current) return;
1446
1566
  if (!payloadRef.current) return;
1447
1567
  try {
1448
1568
  void fetch("/api/canvas-layout", {
@@ -1487,8 +1607,10 @@ function CanvasProvider({
1487
1607
  setInteracting,
1488
1608
  activeId,
1489
1609
  setActiveId,
1490
- boardWidth
1491
- }), [blocks, addBlock, removeBlock, resizeBlock, snapTo, snapPreview, previewSnap, clearSnapPreview, snapGhost, moveTo, sizeTo, setRect, resizeRow, bringToFront, resolveCollisions, toggleLock, clear, interacting, activeId, boardWidth]);
1610
+ boardWidth,
1611
+ mode,
1612
+ maxBlocks: maxWindowsFor(mode, limits)
1613
+ }), [blocks, addBlock, removeBlock, resizeBlock, snapTo, snapPreview, previewSnap, clearSnapPreview, snapGhost, moveTo, sizeTo, setRect, resizeRow, bringToFront, resolveCollisions, toggleLock, clear, interacting, activeId, boardWidth, mode, limits]);
1492
1614
  return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(CanvasContext.Provider, { value: api, children });
1493
1615
  }
1494
1616
 
@@ -1717,11 +1839,14 @@ function ShiftBar({ state, dots = 3, size = 7, className = "" }) {
1717
1839
  CanvasProvider,
1718
1840
  CanvasTransition,
1719
1841
  DEFAULT_H,
1842
+ DUO_FOCUS_SHARE,
1720
1843
  GAP,
1721
1844
  GenerativeView,
1722
1845
  HolographicShine,
1723
1846
  LiveCard,
1724
1847
  LiveStat,
1848
+ MAX_WINDOWS,
1849
+ MAX_WINDOWS_PHONE,
1725
1850
  MIN_H,
1726
1851
  MIN_W,
1727
1852
  PageTransition,
@@ -1730,6 +1855,8 @@ function ShiftBar({ state, dots = 3, size = 7, className = "" }) {
1730
1855
  ShiftCard,
1731
1856
  TiltCard,
1732
1857
  WIDTH_SPAN,
1858
+ detectMode,
1859
+ maxWindowsFor,
1733
1860
  tallH,
1734
1861
  useCanvas,
1735
1862
  useGenerate,
package/dist/index.d.cts CHANGED
@@ -263,6 +263,17 @@ interface PageTransitionProps {
263
263
  */
264
264
  declare function PageTransition({ children, className }: PageTransitionProps): react.JSX.Element;
265
265
 
266
+ declare const MAX_WINDOWS = 4;
267
+ declare const MAX_WINDOWS_PHONE = 2;
268
+ type CanvasMode = "wide" | "phone";
269
+ type CanvasLimits = {
270
+ wide?: number;
271
+ phone?: number;
272
+ };
273
+ declare const DUO_FOCUS_SHARE = 0.62;
274
+ declare function detectMode(innerWidth: number, innerHeight: number, coarsePointer: boolean): CanvasMode;
275
+ declare function maxWindowsFor(mode: CanvasMode, limits?: CanvasLimits): number;
276
+
266
277
  declare const GAP = 20;
267
278
  declare const DEFAULT_H = 420;
268
279
  declare const MIN_W = 240;
@@ -354,16 +365,19 @@ interface CanvasApi {
354
365
  activeId: string | null;
355
366
  setActiveId: (id: string | null) => void;
356
367
  boardWidth: number;
368
+ mode: CanvasMode;
369
+ maxBlocks: number;
357
370
  }
358
371
  declare function useCanvas(): CanvasApi;
359
372
  declare function tallH(boardH: number): number;
360
- declare function CanvasProvider({ children, resolveBlock, initialKeys, initialLayout, addKey, persist, savedSession, }: {
373
+ declare function CanvasProvider({ children, resolveBlock, initialKeys, initialLayout, addKey, persist, savedSession, limits, }: {
361
374
  children: ReactNode;
362
375
  resolveBlock: ResolveBlock;
363
376
  initialKeys?: string[];
364
377
  initialLayout?: SavedBlock[];
365
378
  addKey?: string | null;
366
379
  persist?: boolean;
380
+ limits?: CanvasLimits;
367
381
  savedSession?: {
368
382
  blocks?: SavedBlock[];
369
383
  savedDay?: string | null;
@@ -399,4 +413,4 @@ interface ShiftBarProps {
399
413
  }
400
414
  declare function ShiftBar({ state, dots, size, className }: ShiftBarProps): react.JSX.Element;
401
415
 
402
- export { ACCENT_RGB, type Accent, type BlockWidth, type CanvasBlock, CanvasLauncher, type CanvasLauncherProps, CanvasProvider, CanvasTransition, type CanvasTransitionProps, DEFAULT_H, GAP, type GenPhase, GenerativeView, type GenerativeViewProps, type HeyShiftConfig, type HeyShiftReturn, HolographicShine, type HolographicShineProps, type LauncherGroup, type LauncherItem, LiveCard, type LiveCardProps, LiveStat, type LiveStatProps, MIN_H, MIN_W, PageTransition, type PageTransitionProps, type ResolveBlock, type SavedBlock, ScrollGenerateCard, type ScrollGenerateCardProps, ShiftBar, type ShiftBarProps, type ShiftBarState, ShiftCard, type ShiftCardProps, type SnapBox, type SnapGhost, type SnapLayout, type SnapPreview, TiltCard, type TiltCardProps, type UseGenerateOptions, WIDTH_SPAN, tallH, useCanvas, useGenerate, useHeyShift };
416
+ export { ACCENT_RGB, type Accent, type BlockWidth, type CanvasBlock, CanvasLauncher, type CanvasLauncherProps, type CanvasLimits, type CanvasMode, CanvasProvider, CanvasTransition, type CanvasTransitionProps, DEFAULT_H, DUO_FOCUS_SHARE, GAP, type GenPhase, GenerativeView, type GenerativeViewProps, type HeyShiftConfig, type HeyShiftReturn, HolographicShine, type HolographicShineProps, type LauncherGroup, type LauncherItem, LiveCard, type LiveCardProps, LiveStat, type LiveStatProps, MAX_WINDOWS, MAX_WINDOWS_PHONE, MIN_H, MIN_W, PageTransition, type PageTransitionProps, type ResolveBlock, type SavedBlock, ScrollGenerateCard, type ScrollGenerateCardProps, ShiftBar, type ShiftBarProps, type ShiftBarState, ShiftCard, type ShiftCardProps, type SnapBox, type SnapGhost, type SnapLayout, type SnapPreview, TiltCard, type TiltCardProps, type UseGenerateOptions, WIDTH_SPAN, detectMode, maxWindowsFor, tallH, useCanvas, useGenerate, useHeyShift };
package/dist/index.d.ts CHANGED
@@ -263,6 +263,17 @@ interface PageTransitionProps {
263
263
  */
264
264
  declare function PageTransition({ children, className }: PageTransitionProps): react.JSX.Element;
265
265
 
266
+ declare const MAX_WINDOWS = 4;
267
+ declare const MAX_WINDOWS_PHONE = 2;
268
+ type CanvasMode = "wide" | "phone";
269
+ type CanvasLimits = {
270
+ wide?: number;
271
+ phone?: number;
272
+ };
273
+ declare const DUO_FOCUS_SHARE = 0.62;
274
+ declare function detectMode(innerWidth: number, innerHeight: number, coarsePointer: boolean): CanvasMode;
275
+ declare function maxWindowsFor(mode: CanvasMode, limits?: CanvasLimits): number;
276
+
266
277
  declare const GAP = 20;
267
278
  declare const DEFAULT_H = 420;
268
279
  declare const MIN_W = 240;
@@ -354,16 +365,19 @@ interface CanvasApi {
354
365
  activeId: string | null;
355
366
  setActiveId: (id: string | null) => void;
356
367
  boardWidth: number;
368
+ mode: CanvasMode;
369
+ maxBlocks: number;
357
370
  }
358
371
  declare function useCanvas(): CanvasApi;
359
372
  declare function tallH(boardH: number): number;
360
- declare function CanvasProvider({ children, resolveBlock, initialKeys, initialLayout, addKey, persist, savedSession, }: {
373
+ declare function CanvasProvider({ children, resolveBlock, initialKeys, initialLayout, addKey, persist, savedSession, limits, }: {
361
374
  children: ReactNode;
362
375
  resolveBlock: ResolveBlock;
363
376
  initialKeys?: string[];
364
377
  initialLayout?: SavedBlock[];
365
378
  addKey?: string | null;
366
379
  persist?: boolean;
380
+ limits?: CanvasLimits;
367
381
  savedSession?: {
368
382
  blocks?: SavedBlock[];
369
383
  savedDay?: string | null;
@@ -399,4 +413,4 @@ interface ShiftBarProps {
399
413
  }
400
414
  declare function ShiftBar({ state, dots, size, className }: ShiftBarProps): react.JSX.Element;
401
415
 
402
- export { ACCENT_RGB, type Accent, type BlockWidth, type CanvasBlock, CanvasLauncher, type CanvasLauncherProps, CanvasProvider, CanvasTransition, type CanvasTransitionProps, DEFAULT_H, GAP, type GenPhase, GenerativeView, type GenerativeViewProps, type HeyShiftConfig, type HeyShiftReturn, HolographicShine, type HolographicShineProps, type LauncherGroup, type LauncherItem, LiveCard, type LiveCardProps, LiveStat, type LiveStatProps, MIN_H, MIN_W, PageTransition, type PageTransitionProps, type ResolveBlock, type SavedBlock, ScrollGenerateCard, type ScrollGenerateCardProps, ShiftBar, type ShiftBarProps, type ShiftBarState, ShiftCard, type ShiftCardProps, type SnapBox, type SnapGhost, type SnapLayout, type SnapPreview, TiltCard, type TiltCardProps, type UseGenerateOptions, WIDTH_SPAN, tallH, useCanvas, useGenerate, useHeyShift };
416
+ export { ACCENT_RGB, type Accent, type BlockWidth, type CanvasBlock, CanvasLauncher, type CanvasLauncherProps, type CanvasLimits, type CanvasMode, CanvasProvider, CanvasTransition, type CanvasTransitionProps, DEFAULT_H, DUO_FOCUS_SHARE, GAP, type GenPhase, GenerativeView, type GenerativeViewProps, type HeyShiftConfig, type HeyShiftReturn, HolographicShine, type HolographicShineProps, type LauncherGroup, type LauncherItem, LiveCard, type LiveCardProps, LiveStat, type LiveStatProps, MAX_WINDOWS, MAX_WINDOWS_PHONE, MIN_H, MIN_W, PageTransition, type PageTransitionProps, type ResolveBlock, type SavedBlock, ScrollGenerateCard, type ScrollGenerateCardProps, ShiftBar, type ShiftBarProps, type ShiftBarState, ShiftCard, type ShiftCardProps, type SnapBox, type SnapGhost, type SnapLayout, type SnapPreview, TiltCard, type TiltCardProps, type UseGenerateOptions, WIDTH_SPAN, detectMode, maxWindowsFor, tallH, useCanvas, useGenerate, useHeyShift };
package/dist/index.js CHANGED
@@ -784,7 +784,7 @@ function PageTransition({ children, className }) {
784
784
  motion10.div,
785
785
  {
786
786
  initial: { opacity: 0, x: dir * 22, filter: "blur(3px)" },
787
- animate: { opacity: 1, x: 0, filter: "blur(0px)" },
787
+ animate: { opacity: 1, x: 0, filter: "blur(0px)", transitionEnd: { filter: "none" } },
788
788
  transition: { type: "spring", stiffness: 400, damping: 34 },
789
789
  className: className ?? "flex flex-col flex-1 min-h-0",
790
790
  children
@@ -795,6 +795,59 @@ function PageTransition({ children, className }) {
795
795
 
796
796
  // src/canvas/CanvasProvider.tsx
797
797
  import { createContext, useCallback, useContext, useEffect as useEffect6, useMemo as useMemo2, useRef as useRef6, useState as useState6 } from "react";
798
+
799
+ // src/canvas/duo.ts
800
+ var MAX_WINDOWS = 4;
801
+ var MAX_WINDOWS_PHONE = 2;
802
+ var DUO_FOCUS_SHARE = 0.62;
803
+ var PHONE_CHROME = 220;
804
+ var DUO_GAP = 12;
805
+ var DUO_MIN_H = 160;
806
+ function detectMode(innerWidth, innerHeight, coarsePointer) {
807
+ if (innerWidth < 720) return "phone";
808
+ if (coarsePointer && Math.min(innerWidth, innerHeight) < 600) return "phone";
809
+ return "wide";
810
+ }
811
+ function maxWindowsFor(mode, limits) {
812
+ const n = mode === "phone" ? limits?.phone ?? MAX_WINDOWS_PHONE : limits?.wide ?? MAX_WINDOWS;
813
+ return Math.max(1, Math.floor(n));
814
+ }
815
+ function capList(list, max, keepId) {
816
+ let out = list;
817
+ while (out.length > max) {
818
+ const victim = out.filter((b) => !b.locked && b.id !== keepId).sort((a, b) => a.z - b.z)[0];
819
+ if (!victim) break;
820
+ out = out.filter((b) => b.id !== victim.id);
821
+ }
822
+ return out;
823
+ }
824
+ function tileDuo(items, boardW, boardH) {
825
+ const n = items.length;
826
+ if (n === 0) return items;
827
+ const sorted = [...items].sort((a, b) => b.z - a.z);
828
+ const availH = Math.max(DUO_MIN_H * 2 + DUO_GAP, boardH - PHONE_CHROME);
829
+ if (n === 1) return [{ ...sorted[0], x: 0, y: 0, w: boardW, h: availH, placed: false }];
830
+ const minRest = (DUO_MIN_H + DUO_GAP) * (n - 1);
831
+ const focusH = Math.max(DUO_MIN_H, Math.min(Math.round(availH * DUO_FOCUS_SHARE), availH - minRest));
832
+ const restH = Math.max(DUO_MIN_H, Math.round((availH - focusH - DUO_GAP * (n - 1)) / (n - 1)));
833
+ let y = 0;
834
+ return sorted.map((it, i) => {
835
+ const h = i === 0 ? focusH : restH;
836
+ const out = { ...it, x: 0, y, w: boardW, h, placed: false };
837
+ y += h + DUO_GAP;
838
+ return out;
839
+ });
840
+ }
841
+ function sameGeometry(a, b) {
842
+ if (a.length !== b.length) return false;
843
+ const byId = new Map(b.map((x) => [x.id, x]));
844
+ return a.every((x) => {
845
+ const y = byId.get(x.id);
846
+ return !!y && x.x === y.x && x.y === y.y && x.w === y.w && x.h === y.h;
847
+ });
848
+ }
849
+
850
+ // src/canvas/CanvasProvider.tsx
798
851
  import { jsx as jsx11 } from "react/jsx-runtime";
799
852
  var GAP = 20;
800
853
  var DEFAULT_H = 420;
@@ -943,9 +996,10 @@ function computeSnap(blocks, id, layout, boardW, H) {
943
996
  }
944
997
  return placements;
945
998
  }
946
- function tileItems(items, boardW, boardH) {
999
+ function tileItems(items, boardW, boardH, mode = "wide") {
947
1000
  const n = items.length;
948
1001
  if (n === 0) return items;
1002
+ if (mode === "phone") return tileDuo(items, boardW, boardH);
949
1003
  const availH = Math.max(MIN_H * 2 + GAP, boardH - 150);
950
1004
  const rows = Math.ceil(n / 2);
951
1005
  const rowH = Math.max(300, Math.round((availH - GAP * (rows - 1)) / rows));
@@ -964,6 +1018,20 @@ function tileItems(items, boardW, boardH) {
964
1018
  return { ...it, x, y, w, h: rowH };
965
1019
  });
966
1020
  }
1021
+ function fitToBoard(list, boardW, boardH) {
1022
+ if (list.length === 0) return list;
1023
+ const extent = Math.max(...list.map((b) => b.x + b.w));
1024
+ if (extent > boardW * 1.5) return tileItems(list, boardW, boardH);
1025
+ let changed = false;
1026
+ const next = list.map((b) => {
1027
+ const w = Math.min(b.w, boardW);
1028
+ const x = Math.max(0, Math.min(b.x, boardW - w));
1029
+ if (w === b.w && x === b.x) return b;
1030
+ changed = true;
1031
+ return { ...b, w, x };
1032
+ });
1033
+ return changed ? next : list;
1034
+ }
967
1035
  function overlaps(a, b) {
968
1036
  return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
969
1037
  }
@@ -984,7 +1052,8 @@ function CanvasProvider({
984
1052
  initialLayout,
985
1053
  addKey = null,
986
1054
  persist = false,
987
- savedSession
1055
+ savedSession,
1056
+ limits
988
1057
  }) {
989
1058
  const resolveRef = useRef6(resolveBlock);
990
1059
  useEffect6(() => {
@@ -992,14 +1061,22 @@ function CanvasProvider({
992
1061
  }, [resolveBlock]);
993
1062
  const resolve = (key) => resolveRef.current(key);
994
1063
  const initial = useMemo2(() => {
995
- let z = 1;
996
- const base = initialLayout ? initialLayout.filter((b) => b && typeof b.key === "string" && resolveRef.current(b.key)).map((b) => blockFromSaved(b, z++)) : initialKeys.filter((k) => resolveRef.current(k)).map((k) => blockFromSaved(k, z++));
997
- if (addKey && resolveRef.current(addKey) && !base.some((b) => b.key === addKey)) {
998
- base.unshift(blockFromSaved(addKey, z++));
1064
+ const max = maxWindowsFor("wide", limits);
1065
+ const entries = initialLayout ? initialLayout.filter((b) => b && typeof b.key === "string" && resolveRef.current(b.key)) : initialKeys.filter((k) => resolveRef.current(k));
1066
+ if (addKey && resolveRef.current(addKey) && !entries.some((b) => (typeof b === "string" ? b : b.key) === addKey)) {
1067
+ entries.unshift(addKey);
999
1068
  }
1069
+ const kept = entries.slice(0, max);
1070
+ let z = 1;
1071
+ const base = kept.map(() => null);
1072
+ for (let i = kept.length - 1; i >= 0; i--) base[i] = blockFromSaved(kept[i], z++);
1000
1073
  return { base, nextZ: z };
1001
1074
  }, []);
1002
1075
  const zRef = useRef6(initial.nextZ);
1076
+ const limitsRef = useRef6(limits);
1077
+ useEffect6(() => {
1078
+ limitsRef.current = limits;
1079
+ }, [limits]);
1003
1080
  const [blocks, setBlocks] = useState6(initial.base);
1004
1081
  const [interacting, setInteracting] = useState6(false);
1005
1082
  const [activeId, setActiveId] = useState6(null);
@@ -1007,26 +1084,39 @@ function CanvasProvider({
1007
1084
  const [boardWidth, setBoardWidth] = useState6(1280);
1008
1085
  const boardRef = useRef6(1280);
1009
1086
  const boardHRef = useRef6(820);
1087
+ const [mode, setMode] = useState6("wide");
1088
+ const modeRef = useRef6("wide");
1089
+ const maxFor = (m) => maxWindowsFor(m, limitsRef.current);
1010
1090
  const persistRef = useRef6(persist);
1011
1091
  useEffect6(() => {
1012
1092
  persistRef.current = persist;
1013
1093
  }, [persist]);
1094
+ const fitAdjustRef = useRef6(false);
1095
+ const hadUserChangeRef = useRef6(false);
1014
1096
  useEffect6(() => {
1015
1097
  const measure = () => {
1016
- const w = Math.max(360, window.innerWidth - 40);
1098
+ const vw = Math.min(window.innerWidth, window.visualViewport?.width || Infinity);
1099
+ const w = Math.max(320, vw - 40);
1017
1100
  boardRef.current = w;
1018
1101
  boardHRef.current = Math.max(480, window.innerHeight);
1019
1102
  setBoardWidth(w);
1103
+ const coarse = !!window.matchMedia?.("(pointer: coarse)").matches;
1104
+ const m = detectMode(vw, window.innerHeight, coarse);
1105
+ const modeChanged = m !== modeRef.current;
1106
+ modeRef.current = m;
1107
+ setMode(m);
1020
1108
  setBlocks((prev) => {
1021
- let changed = false;
1022
- const next = prev.map((b) => {
1023
- const bw = Math.min(b.w, w);
1024
- const bx = Math.max(0, Math.min(b.x, w - bw));
1025
- if (bw === b.w && bx === b.x) return b;
1026
- changed = true;
1027
- return { ...b, w: bw, x: bx };
1028
- });
1029
- return changed ? next : prev;
1109
+ let next;
1110
+ if (m === "phone") {
1111
+ next = tileDuo(capList(prev, maxFor("phone")), w, boardHRef.current);
1112
+ if (sameGeometry(prev, next)) return prev;
1113
+ } else if (modeChanged) {
1114
+ next = tileItems(capList(prev, maxFor("wide")), w, boardHRef.current);
1115
+ } else {
1116
+ next = fitToBoard(prev, w, boardHRef.current);
1117
+ }
1118
+ if (next !== prev) fitAdjustRef.current = true;
1119
+ return next;
1030
1120
  });
1031
1121
  };
1032
1122
  measure();
@@ -1034,22 +1124,23 @@ function CanvasProvider({
1034
1124
  return () => window.removeEventListener("resize", measure);
1035
1125
  }, []);
1036
1126
  const layoutDefault = useCallback(
1037
- (list) => tileItems(list, boardRef.current, boardHRef.current),
1127
+ (list) => tileItems(list, boardRef.current, boardHRef.current, modeRef.current),
1038
1128
  []
1039
1129
  );
1130
+ const customizedIn = (list) => modeRef.current !== "phone" && list.some((b) => b.placed);
1040
1131
  const tiledOnce = useRef6(false);
1041
1132
  useEffect6(() => {
1042
1133
  if (tiledOnce.current) return;
1043
1134
  tiledOnce.current = true;
1044
- setBlocks((prev) => prev.length === 0 || prev.some((b) => b.placed) ? prev : layoutDefault(prev));
1135
+ setBlocks((prev) => prev.length === 0 || customizedIn(prev) ? prev : layoutDefault(prev));
1045
1136
  }, [boardWidth]);
1046
1137
  const addBlock = useCallback((key, context, width, append = false) => {
1047
1138
  if (!resolve(key)) return;
1048
1139
  const w = width ?? resolve(key)?.defaultWidth;
1049
1140
  setBlocks((prev) => {
1050
1141
  const existing = prev.find((b) => b.key === key);
1051
- const customized = prev.some((b) => b.placed);
1052
- const without = prev.filter((b) => b.key !== key);
1142
+ const customized = customizedIn(prev);
1143
+ const without = capList(prev.filter((b) => b.key !== key), maxFor(modeRef.current) - 1);
1053
1144
  if (customized) {
1054
1145
  if (append) {
1055
1146
  const startY = without.length ? Math.max(...without.map((b) => b.y + b.h)) + GAP : 0;
@@ -1089,13 +1180,15 @@ function CanvasProvider({
1089
1180
  const removeBlock = useCallback((id) => {
1090
1181
  setBlocks((prev) => {
1091
1182
  const remaining = prev.filter((b) => b.id !== id);
1092
- return remaining.length > 0 && !remaining.some((b) => b.placed) ? layoutDefault(remaining) : remaining;
1183
+ return remaining.length > 0 && !customizedIn(remaining) ? layoutDefault(remaining) : remaining;
1093
1184
  });
1094
1185
  }, [layoutDefault]);
1095
1186
  const resizeBlock = useCallback((id, width) => {
1187
+ if (modeRef.current === "phone") return;
1096
1188
  setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, width, w: pxWidth(WIDTH_SPAN[width], boardRef.current), placed: true, z: zRef.current++ } : b));
1097
1189
  }, []);
1098
1190
  const moveTo = useCallback((id, x, y) => {
1191
+ if (modeRef.current === "phone") return;
1099
1192
  const boardW = boardRef.current;
1100
1193
  setBlocks((prev) => prev.map((b) => {
1101
1194
  if (b.id !== id || b.locked) return b;
@@ -1105,6 +1198,7 @@ function CanvasProvider({
1105
1198
  }));
1106
1199
  }, []);
1107
1200
  const sizeTo = useCallback((id, w, h) => {
1201
+ if (modeRef.current === "phone") return;
1108
1202
  const boardW = boardRef.current;
1109
1203
  setBlocks((prev) => prev.map((b) => {
1110
1204
  if (b.id !== id || b.locked) return b;
@@ -1114,6 +1208,7 @@ function CanvasProvider({
1114
1208
  }));
1115
1209
  }, []);
1116
1210
  const setRect = useCallback((id, r) => {
1211
+ if (modeRef.current === "phone") return;
1117
1212
  const boardW = boardRef.current;
1118
1213
  setBlocks((prev) => prev.map((b) => {
1119
1214
  if (b.id !== id || b.locked) return b;
@@ -1126,6 +1221,7 @@ function CanvasProvider({
1126
1221
  }));
1127
1222
  }, []);
1128
1223
  const snapTo = useCallback((id, layout) => {
1224
+ if (modeRef.current === "phone") return;
1129
1225
  const boardW = boardRef.current;
1130
1226
  const H = tallH(boardHRef.current);
1131
1227
  setSnapGhost(null);
@@ -1173,6 +1269,7 @@ function CanvasProvider({
1173
1269
  }, [blocks]);
1174
1270
  const clearSnapPreview = useCallback(() => setSnapGhost(null), []);
1175
1271
  const resizeRow = useCallback((id, x, w, h, origin) => {
1272
+ if (modeRef.current === "phone") return;
1176
1273
  const boardW = boardRef.current;
1177
1274
  const M0 = origin.find((b) => b.id === id);
1178
1275
  if (!M0 || M0.locked) return;
@@ -1222,9 +1319,14 @@ function CanvasProvider({
1222
1319
  }));
1223
1320
  }, []);
1224
1321
  const bringToFront = useCallback((id) => {
1225
- setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, z: zRef.current++ } : b));
1226
- }, []);
1322
+ setBlocks((prev) => {
1323
+ if (prev.find((b) => b.id === id)?.z === zRef.current - 1) return prev;
1324
+ const next = prev.map((b) => b.id === id ? { ...b, z: zRef.current++ } : b);
1325
+ return modeRef.current === "phone" ? layoutDefault(next) : next;
1326
+ });
1327
+ }, [layoutDefault]);
1227
1328
  const resolveCollisions = useCallback((id) => {
1329
+ if (modeRef.current === "phone") return;
1228
1330
  setBlocks((prev) => {
1229
1331
  const moved = prev.find((b) => b.id === id);
1230
1332
  if (!moved) return prev;
@@ -1267,8 +1369,13 @@ function CanvasProvider({
1267
1369
  if (!saved || saved.length === 0) return;
1268
1370
  restoredRef.current = true;
1269
1371
  setCanRestore(false);
1270
- const built = saved.filter((b) => b && typeof b.key === "string" && resolve(b.key)).map((b) => blockFromSaved(b, zRef.current++));
1271
- setBlocks(built.some((b) => b.placed) ? built : layoutDefault(built));
1372
+ const kept = saved.filter((b) => b && typeof b.key === "string" && resolve(b.key)).slice(0, maxFor(modeRef.current));
1373
+ const built = kept.map(() => null);
1374
+ for (let i = kept.length - 1; i >= 0; i--) built[i] = blockFromSaved(kept[i], zRef.current++);
1375
+ const arranged = customizedIn(built) ? built : layoutDefault(built);
1376
+ const fitted = fitToBoard(arranged, boardRef.current, boardHRef.current);
1377
+ if (fitted !== arranged) fitAdjustRef.current = true;
1378
+ setBlocks(fitted);
1272
1379
  }, [savedSession, layoutDefault]);
1273
1380
  const clear = useCallback((keepKey) => {
1274
1381
  setBlocks((prev) => keepKey ? prev.filter((b) => b.key === keepKey) : []);
@@ -1288,7 +1395,7 @@ function CanvasProvider({
1288
1395
  const width = d.width;
1289
1396
  setBlocks((prev) => {
1290
1397
  const list = prev.map((b) => b.key === d.key ? { ...b, width } : b);
1291
- return prev.some((b) => b.placed) ? list : layoutDefault(list);
1398
+ return customizedIn(prev) ? list : layoutDefault(list);
1292
1399
  });
1293
1400
  }
1294
1401
  function onPlace(e) {
@@ -1302,7 +1409,7 @@ function CanvasProvider({
1302
1409
  const [moved] = next.splice(srcIdx, 1);
1303
1410
  const insertAt = next.findIndex((b) => b.key === d.afterKey) + 1;
1304
1411
  next.splice(insertAt, 0, moved);
1305
- return prev.some((b) => b.placed) ? next : layoutDefault(next);
1412
+ return customizedIn(prev) ? next : layoutDefault(next);
1306
1413
  });
1307
1414
  }
1308
1415
  function onSwap(e) {
@@ -1310,7 +1417,7 @@ function CanvasProvider({
1310
1417
  const keys = d?.keys ?? [];
1311
1418
  const contexts = d?.contexts ?? [];
1312
1419
  const widths = d?.widths ?? [];
1313
- const built = keys.filter((k) => resolve(k)).map((key, i) => ({
1420
+ const built = keys.filter((k) => resolve(k)).slice(0, maxFor(modeRef.current)).map((key, i) => ({
1314
1421
  id: nextId(),
1315
1422
  key,
1316
1423
  context: contexts[i],
@@ -1367,12 +1474,13 @@ function CanvasProvider({
1367
1474
  }, [blocks.length, canRestore]);
1368
1475
  const firstRun = useRef6(true);
1369
1476
  const saveTimer = useRef6(null);
1370
- const layoutSignature = blocks.map((b) => `${b.key}:${b.width ?? ""}:${Math.round(b.x)}:${Math.round(b.y)}:${Math.round(b.w)}:${Math.round(b.h)}`).join("|");
1477
+ const layoutSignature = blocks.map((b) => mode === "phone" ? `${b.key}:${b.width ?? ""}` : `${b.key}:${b.width ?? ""}:${Math.round(b.x)}:${Math.round(b.y)}:${Math.round(b.w)}:${Math.round(b.h)}`).join("|");
1371
1478
  const payload = useMemo2(() => layoutSignature ? {
1372
1479
  savedDay: localDay(),
1373
1480
  blocks: layoutSignature.split("|").map((seg) => {
1374
1481
  const [key, width, x, y, w, h] = seg.split(":");
1375
- return { key, ...width ? { width } : {}, x: Number(x), y: Number(y), w: Number(w), h: Number(h) };
1482
+ const base = { key, ...width ? { width } : {} };
1483
+ return x === void 0 ? base : { ...base, x: Number(x), y: Number(y), w: Number(w), h: Number(h) };
1376
1484
  })
1377
1485
  } : null, [layoutSignature]);
1378
1486
  const payloadRef = useRef6(payload);
@@ -1385,6 +1493,12 @@ function CanvasProvider({
1385
1493
  firstRun.current = false;
1386
1494
  return;
1387
1495
  }
1496
+ if (fitAdjustRef.current) {
1497
+ fitAdjustRef.current = false;
1498
+ if (!hadUserChangeRef.current) return;
1499
+ } else {
1500
+ hadUserChangeRef.current = true;
1501
+ }
1388
1502
  if (!payloadRef.current) return;
1389
1503
  if (saveTimer.current) clearTimeout(saveTimer.current);
1390
1504
  const body = JSON.stringify(payloadRef.current);
@@ -1403,6 +1517,7 @@ function CanvasProvider({
1403
1517
  useEffect6(() => {
1404
1518
  if (!persist) return;
1405
1519
  const flush = () => {
1520
+ if (!hadUserChangeRef.current) return;
1406
1521
  if (!payloadRef.current) return;
1407
1522
  try {
1408
1523
  void fetch("/api/canvas-layout", {
@@ -1447,8 +1562,10 @@ function CanvasProvider({
1447
1562
  setInteracting,
1448
1563
  activeId,
1449
1564
  setActiveId,
1450
- boardWidth
1451
- }), [blocks, addBlock, removeBlock, resizeBlock, snapTo, snapPreview, previewSnap, clearSnapPreview, snapGhost, moveTo, sizeTo, setRect, resizeRow, bringToFront, resolveCollisions, toggleLock, clear, interacting, activeId, boardWidth]);
1565
+ boardWidth,
1566
+ mode,
1567
+ maxBlocks: maxWindowsFor(mode, limits)
1568
+ }), [blocks, addBlock, removeBlock, resizeBlock, snapTo, snapPreview, previewSnap, clearSnapPreview, snapGhost, moveTo, sizeTo, setRect, resizeRow, bringToFront, resolveCollisions, toggleLock, clear, interacting, activeId, boardWidth, mode, limits]);
1452
1569
  return /* @__PURE__ */ jsx11(CanvasContext.Provider, { value: api, children });
1453
1570
  }
1454
1571
 
@@ -1676,11 +1793,14 @@ export {
1676
1793
  CanvasProvider,
1677
1794
  CanvasTransition,
1678
1795
  DEFAULT_H,
1796
+ DUO_FOCUS_SHARE,
1679
1797
  GAP,
1680
1798
  GenerativeView,
1681
1799
  HolographicShine,
1682
1800
  LiveCard,
1683
1801
  LiveStat,
1802
+ MAX_WINDOWS,
1803
+ MAX_WINDOWS_PHONE,
1684
1804
  MIN_H,
1685
1805
  MIN_W,
1686
1806
  PageTransition,
@@ -1689,6 +1809,8 @@ export {
1689
1809
  ShiftCard,
1690
1810
  TiltCard,
1691
1811
  WIDTH_SPAN,
1812
+ detectMode,
1813
+ maxWindowsFor,
1692
1814
  tallH,
1693
1815
  useCanvas,
1694
1816
  useGenerate,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allshift/ui",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Shift family shared FEEL layer — glass morphism, 3D tilt physics, holographic shine, scroll-triggered generation, and the living-canvas engine (LiveCard, LiveStat, CanvasTransition, CanvasLauncher). Brand-neutral by design.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -27,7 +27,8 @@
27
27
  "scripts": {
28
28
  "build": "tsup",
29
29
  "dev": "tsup --watch",
30
- "type-check": "tsc --noEmit"
30
+ "type-check": "tsc --noEmit",
31
+ "test": "vitest run"
31
32
  },
32
33
  "peerDependencies": {
33
34
  "framer-motion": ">=10",
@@ -36,8 +37,12 @@
36
37
  "next": ">=14"
37
38
  },
38
39
  "peerDependenciesMeta": {
39
- "lucide-react": { "optional": true },
40
- "next": { "optional": true }
40
+ "lucide-react": {
41
+ "optional": true
42
+ },
43
+ "next": {
44
+ "optional": true
45
+ }
41
46
  },
42
47
  "devDependencies": {
43
48
  "@types/react": "^19.2.17",
@@ -46,7 +51,8 @@
46
51
  "next": "^16.2.7",
47
52
  "react": "^19.2.7",
48
53
  "tsup": "^8.5.1",
49
- "typescript": "^6.0.3"
54
+ "typescript": "^6.0.3",
55
+ "vitest": "^4.1.11"
50
56
  },
51
57
  "publishConfig": {
52
58
  "access": "public"