@songsid/agend 2.3.0-beta.20 → 2.3.0-beta.22

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.
Files changed (101) hide show
  1. package/dist/agent-endpoint.d.ts +8 -0
  2. package/dist/agent-endpoint.js +11 -0
  3. package/dist/agent-endpoint.js.map +1 -1
  4. package/dist/auth-api.js +2 -1
  5. package/dist/auth-api.js.map +1 -1
  6. package/dist/backend/grok.js +56 -3
  7. package/dist/backend/grok.js.map +1 -1
  8. package/dist/channel/mcp-tools.js +2 -2
  9. package/dist/channel/mcp-tools.js.map +1 -1
  10. package/dist/config-validator.js +8 -1
  11. package/dist/config-validator.js.map +1 -1
  12. package/dist/config.d.ts +2 -2
  13. package/dist/config.js +14 -2
  14. package/dist/config.js.map +1 -1
  15. package/dist/daemon.d.ts +6 -0
  16. package/dist/daemon.js +14 -0
  17. package/dist/daemon.js.map +1 -1
  18. package/dist/fleet-manager.d.ts +28 -2
  19. package/dist/fleet-manager.js +102 -15
  20. package/dist/fleet-manager.js.map +1 -1
  21. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +1 -1
  22. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +3 -1
  23. package/dist/instance-config-impact.js +3 -0
  24. package/dist/instance-config-impact.js.map +1 -1
  25. package/dist/instance-lifecycle.js +2 -2
  26. package/dist/instance-lifecycle.js.map +1 -1
  27. package/dist/instructions.js +2 -1
  28. package/dist/instructions.js.map +1 -1
  29. package/dist/locale.js +19 -6
  30. package/dist/locale.js.map +1 -1
  31. package/dist/outbound-handlers.js +25 -10
  32. package/dist/outbound-handlers.js.map +1 -1
  33. package/dist/provider-secret-registry.d.ts +5 -3
  34. package/dist/provider-secret-registry.js +8 -2
  35. package/dist/provider-secret-registry.js.map +1 -1
  36. package/dist/public-tunnel-config.d.ts +6 -0
  37. package/dist/public-tunnel-config.js +24 -0
  38. package/dist/public-tunnel-config.js.map +1 -0
  39. package/dist/public-web-gateway.d.ts +2 -0
  40. package/dist/public-web-gateway.js +3 -2
  41. package/dist/public-web-gateway.js.map +1 -1
  42. package/dist/public-web-link.d.ts +13 -2
  43. package/dist/public-web-link.js +35 -9
  44. package/dist/public-web-link.js.map +1 -1
  45. package/dist/secret-store.d.ts +2 -0
  46. package/dist/secret-store.js +21 -0
  47. package/dist/secret-store.js.map +1 -1
  48. package/dist/settings-api.d.ts +1 -0
  49. package/dist/settings-api.js +13 -9
  50. package/dist/settings-api.js.map +1 -1
  51. package/dist/settings-change.js +2 -2
  52. package/dist/settings-change.js.map +1 -1
  53. package/dist/settings-effect.js +4 -1
  54. package/dist/settings-effect.js.map +1 -1
  55. package/dist/settings-http-confirmation.js +1 -1
  56. package/dist/settings-http-confirmation.js.map +1 -1
  57. package/dist/tunnel/cloudflared.d.ts +6 -15
  58. package/dist/tunnel/cloudflared.js +20 -9
  59. package/dist/tunnel/cloudflared.js.map +1 -1
  60. package/dist/types.d.ts +3 -0
  61. package/dist/ui/app.html +2 -0
  62. package/dist/ui/chat-render.js +39 -0
  63. package/dist/ui/chat-store.js +8 -0
  64. package/dist/ui/chat-strings.js +2 -2
  65. package/dist/ui/chat-thread.js +1 -1
  66. package/dist/ui/fleet-canvas.js +14 -6
  67. package/dist/ui/fleet-route-scene.js +86 -21
  68. package/dist/ui/fleet-traffic.js +57 -10
  69. package/dist/ui/icons/icon-192.png +0 -0
  70. package/dist/ui/icons/icon-512.png +0 -0
  71. package/dist/ui/panel-settings.js +57 -4
  72. package/dist/ui/settings-appearance.js +126 -1
  73. package/dist/ui/settings-learn.js +1 -0
  74. package/dist/ui/settings-strings.js +78 -0
  75. package/dist/ui/shared/app-stream.js +2 -1
  76. package/dist/ui/shared/app.css +48 -21
  77. package/dist/ui/signin.html +2 -0
  78. package/dist/web-api.d.ts +6 -2
  79. package/dist/web-api.js +114 -4
  80. package/dist/web-api.js.map +1 -1
  81. package/dist/web-app-icon-worker.d.ts +1 -0
  82. package/dist/web-app-icon-worker.js +15 -0
  83. package/dist/web-app-icon-worker.js.map +1 -0
  84. package/dist/web-app-icon.d.ts +107 -0
  85. package/dist/web-app-icon.js +256 -0
  86. package/dist/web-app-icon.js.map +1 -0
  87. package/dist/web-chat-history.d.ts +23 -0
  88. package/dist/web-chat-history.js +32 -2
  89. package/dist/web-chat-history.js.map +1 -1
  90. package/dist/web-guest-access.js +7 -1
  91. package/dist/web-guest-access.js.map +1 -1
  92. package/dist/web-icons.d.ts +33 -1
  93. package/dist/web-icons.js +83 -11
  94. package/dist/web-icons.js.map +1 -1
  95. package/dist/web-png.d.ts +29 -0
  96. package/dist/web-png.js +321 -0
  97. package/dist/web-png.js.map +1 -0
  98. package/dist/web-reactions.d.ts +34 -0
  99. package/dist/web-reactions.js +47 -0
  100. package/dist/web-reactions.js.map +1 -0
  101. package/package.json +1 -1
@@ -7,7 +7,10 @@
7
7
  // the box's padding and the gutters between its rows and columns (L.pad, L.gap: wide enough for a few lines each)
8
8
  // — are the only doors, so a line crosses a border at right angles, through a door, and never runs along it.
9
9
  // - Ends: pins on each card's sides (several on the long sides, one on the short), facing out; a line ends on a card,
10
- // never on a box. A card with more lines than pins gets more, so two lines never start at one point.
10
+ // never on a box. A card with more lines than pins gets more, so two lines never start at one point. A short side
11
+ // facing a neighbour across less than ROUTE.SIDE (a column gutter, a phone's box padding) has no pin (#2329): a line
12
+ // into it would turn in that narrow gap with no room left for its arrowhead. libavoid turns a line into its pin right
13
+ // at the card's buffer, which leaves a head no straight run: lengthen() moves that turn out where there is room.
11
14
  // - Separation: libavoid nudges lines that share a run ROUTE.NUDGE apart; the same two ends and kind are one line (×N)
12
15
  // before they get here (fleet-traffic.js).
13
16
  // A route that comes back otherwise (not orthogonal, not ending on its two shapes) is not drawn: that line keeps the
@@ -15,10 +18,13 @@
15
18
  import { OC } from "./fleet-canvas-layout.js";
16
19
 
17
20
  /** Buffer around every obstacle, the distance nudging keeps between lines, the cost of a bend, pins on a long side. */
18
- export const ROUTE = { BUF: 5, NUDGE: 8, SEG: 60, PINS: 4, MAX_PINS: 64, MAX_POINTS: 64, MAX_SHAPES: 600, MAX_CONNS: 120 };
21
+ export const ROUTE = { BUF: 5, NUDGE: 8, SEG: 60, PINS: 4, MAX_PINS: 64, MAX_POINTS: 64, MAX_SHAPES: 600, MAX_CONNS: 120, SIDE: 30, RUN: 16 };
19
22
  /** libavoid's ConnDirFlags: the way a line leaves a pin. */
20
23
  const UP = 1, DOWN = 2, LEFT = 4, RIGHT = 8;
21
- const PINS_PER_CARD = ROUTE.PINS * 2 + 2;
24
+ /** A shape's `sides`: which short sides have a pin (absent: both). */
25
+ const SIDE_L = 1, SIDE_R = 2;
26
+ const sidesOf = (sh) => sh.sides ?? SIDE_L | SIDE_R;
27
+ const pinsOn = (sh) => ROUTE.PINS * 2 + ((sidesOf(sh) & SIDE_L) ? 1 : 0) + ((sidesOf(sh) & SIDE_R) ? 1 : 0);
22
28
 
23
29
  /** The shape a line's end is on: a card (General's or a member's) or a folded team's box. Null: not routed here. */
24
30
  function endShape(end) {
@@ -30,8 +36,9 @@ function endShape(end) {
30
36
 
31
37
  /**
32
38
  * The scene for layout `L` and the lines `lines` ([{ key, a, z }], a and z ends as fleet-traffic.js endOf gives them):
33
- * { shapes: [{ x, y, w, h, pins }], conns: [{ key, a, z }], boxes: [{ x, y, w, h }] } — `pins` 0 none (an obstacle
34
- * only), 1 the usual pins, more: that many on each long side (a busy card); every pin takes one line; `a`/`z` index
39
+ * { shapes: [{ x, y, w, h, pins, sides? }], conns: [{ key, a, z }], boxes: [{ x, y, w, h }] } — `pins` 0 none (an
40
+ * obstacle only), 1 the usual pins, more: that many on each long side (a busy card); `sides` the short sides with a pin
41
+ * (1 left, 2 right; absent: both); every pin takes one line; `a`/`z` index
35
42
  * shapes; `boxes` the open teams' outlines (separate() keeps lines off them). A line whose end is not a card or a folded box is left out (it keeps its lane).
36
43
  */
37
44
  export function routeScene(L, lines) {
@@ -57,9 +64,22 @@ export function routeScene(L, lines) {
57
64
  conns.push({ key: ln.key, a: ia, z: iz });
58
65
  for (const i of [ia, iz]) demand.set(i, (demand.get(i) || 0) + 1);
59
66
  }
67
+ // A short side with a pin only where a line has room to turn into it with its head: ROUTE.SIDE to the nearest shape
68
+ // beside it (a neighbour card, a box's wall piece).
69
+ for (const sh of shapes) {
70
+ if (!sh.pins) continue;
71
+ let left = Infinity, right = Infinity;
72
+ for (const o of shapes) {
73
+ if (o === sh || o.y >= sh.y + sh.h || o.y + o.h <= sh.y) continue;
74
+ if (o.x + o.w <= sh.x) left = Math.min(left, sh.x - o.x - o.w);
75
+ if (o.x >= sh.x + sh.w) right = Math.min(right, o.x - sh.x - sh.w);
76
+ }
77
+ const sides = (left >= ROUTE.SIDE ? SIDE_L : 0) | (right >= ROUTE.SIDE ? SIDE_R : 0);
78
+ if (sides !== (SIDE_L | SIDE_R)) sh.sides = sides;
79
+ }
60
80
  // A line each: a shape with more lines than pins gets more pins on its long sides (two lines never leave one point:
61
81
  // libavoid cannot nudge apart runs that start at the same point).
62
- for (const [i, n] of demand) if (n > PINS_PER_CARD) shapes[i].pins = Math.min(ROUTE.MAX_PINS, Math.ceil((n - 2) / 2));
82
+ for (const [i, n] of demand) if (n > pinsOn(shapes[i])) shapes[i].pins = Math.min(ROUTE.MAX_PINS, Math.ceil((n - (pinsOn(shapes[i]) - ROUTE.PINS * 2)) / 2));
63
83
  return { shapes, conns, boxes };
64
84
  }
65
85
 
@@ -69,7 +89,8 @@ export function sceneOk(s) {
69
89
  || s.boxes.length > ROUTE.MAX_SHAPES) return null;
70
90
  const num = (v) => typeof v === "number" && Number.isFinite(v) && Math.abs(v) < 1e6;
71
91
  for (const b of s.boxes) if (!b || ![b.x, b.y, b.w, b.h].every(num) || b.w <= 0 || b.h <= 0) return null;
72
- for (const sh of s.shapes) if (!sh || ![sh.x, sh.y, sh.w, sh.h].every(num) || sh.w <= 0 || sh.h <= 0 || !Number.isInteger(sh.pins) || sh.pins < 0 || sh.pins > ROUTE.MAX_PINS) return null;
92
+ for (const sh of s.shapes) if (!sh || ![sh.x, sh.y, sh.w, sh.h].every(num) || sh.w <= 0 || sh.h <= 0 || !Number.isInteger(sh.pins) || sh.pins < 0 || sh.pins > ROUTE.MAX_PINS
93
+ || (sh.sides !== undefined && (!Number.isInteger(sh.sides) || sh.sides < 0 || sh.sides > (SIDE_L | SIDE_R)))) return null;
73
94
  for (const c of s.conns) {
74
95
  if (!c || typeof c.key !== "string" || c.key.length > 2048) return null;
75
96
  for (const i of [c.a, c.z]) if (!Number.isInteger(i) || i < 0 || i >= s.shapes.length || !s.shapes[i].pins) return null;
@@ -97,15 +118,8 @@ export function answerRoute(A, m) {
97
118
  return { type: "routes", id: m.id, routes: scene ? [...routeWith(A, scene)] : null };
98
119
  }
99
120
 
100
- /**
101
- * libavoid nudges lines that share a run apart, but not always: in a channel shared by many lines it packs them closer
102
- * than ROUTE.NUDGE, and lines fanning out of one card can come back on the very same run. This pass moves the middle
103
- * segments of such lines (never a line's first or last, which end on a pin) sideways to the nearest place at least
104
- * SEPARATE from every parallel line along them — only where the moved segment and the two it joins stay clear of
105
- * every shape's buffer and keep their direction. What cannot move stays as libavoid left it.
106
- */
107
- export const SEPARATE = 6;
108
- export function separate(routes, scene) {
121
+ /** What separate() and lengthen() share, on `scene` and the routes' points `pts` (which they move in place). */
122
+ function mover(scene, pts) {
109
123
  const B = ROUTE.BUF - 0.5, OVER = 8;
110
124
  const segsOf = (r, p) => p.slice(1).map((q, i) => ({ r, i, h: p[i].y === q.y }));
111
125
  const coord = (p, s) => (s.h ? p[s.i].y : p[s.i].x);
@@ -120,9 +134,7 @@ export function separate(routes, scene) {
120
134
  && (scene.boxes ?? []).every((bx) => (h ? [bx.y, bx.y + bx.h].every((v) => Math.abs(v - y0) >= SEPARATE + 1) || Math.min(x1, bx.x + bx.w) - Math.max(x0, bx.x) <= 12
121
135
  : [bx.x, bx.x + bx.w].every((v) => Math.abs(v - x0) >= SEPARATE + 1) || Math.min(y1, bx.y + bx.h) - Math.max(y0, bx.y) <= 12));
122
136
  };
123
- const keys = [...routes.keys()];
124
137
  const connOf = new Map(scene.conns.map((c) => [c.key, c]));
125
- const pts = keys.map((k) => routes.get(k).map((p) => ({ x: p.x, y: p.y })));
126
138
  /** The segments of other lines (and this one's) parallel to segment s of line r, overlapping it, closer than SEPARATE. */
127
139
  const near = (r, s, c, lo, hi) => {
128
140
  const out = [];
@@ -131,8 +143,23 @@ export function separate(routes, scene) {
131
143
  const [l2, h2] = span(p, t);
132
144
  if (Math.min(hi, h2) - Math.max(lo, l2) > OVER) out.push(coord(p, t));
133
145
  }));
134
- return out.filter((c2) => Math.abs(c2 - c) < SEPARATE);
146
+ return out.filter((c2) => Math.abs(c2 - c) < SEPARATE - 1e-6); // libavoid's points carry float noise
135
147
  };
148
+ return { segsOf, coord, span, clear, near, connOf };
149
+ }
150
+
151
+ /**
152
+ * libavoid nudges lines that share a run apart, but not always: in a channel shared by many lines it packs them closer
153
+ * than ROUTE.NUDGE, and lines fanning out of one card can come back on the very same run. This pass moves the middle
154
+ * segments of such lines (never a line's first or last, which end on a pin) sideways to the nearest place at least
155
+ * SEPARATE from every parallel line along them — only where the moved segment and the two it joins stay clear of
156
+ * every shape's buffer and keep their direction. What cannot move stays as libavoid left it.
157
+ */
158
+ export const SEPARATE = 6;
159
+ export function separate(routes, scene) {
160
+ const keys = [...routes.keys()];
161
+ const pts = keys.map((k) => routes.get(k).map((p) => ({ x: p.x, y: p.y })));
162
+ const { segsOf, coord, span, clear, near, connOf } = mover(scene, pts);
136
163
  for (let pass = 0; pass < 8; pass++) {
137
164
  let moved = false;
138
165
  pts.forEach((p, r) => segsOf(r, p).forEach((s) => {
@@ -163,6 +190,43 @@ export function separate(routes, scene) {
163
190
  return new Map(keys.map((k, i) => [k, pts[i]]));
164
191
  }
165
192
 
193
+ /**
194
+ * #2329: room for each line's arrowhead. libavoid turns a line into its last pin right at the card's buffer, so the
195
+ * last run (where the head goes) is a few px long. This pass moves the turn before it out from the card until the last
196
+ * run is ROUTE.RUN long (or up to 3 × SEPARATE more; where none fits, the longest that does): only where the moved segment and the two it joins stay clear of
197
+ * every shape's buffer, at least SEPARATE from every parallel line, and the run before the turn keeps its direction and
198
+ * 4 px. A line whose turn cannot move keeps its short run (fleet-traffic.js draws it with no head).
199
+ */
200
+ export function lengthen(routes, scene) {
201
+ const keys = [...routes.keys()];
202
+ const pts = keys.map((k) => routes.get(k).map((p) => ({ x: p.x, y: p.y })));
203
+ const { coord, span, clear, near, connOf } = mover(scene, pts);
204
+ pts.forEach((p, r) => {
205
+ const n = p.length;
206
+ if (n < 4) return; // the turn's other end is the first pin
207
+ const z = p[n - 1], y = p[n - 2], x = p[n - 3], w = p[n - 4], h = y.y === z.y;
208
+ const at = (q) => (h ? q.x : q.y);
209
+ const u = Math.sign(at(y) - at(z)); // out from the card
210
+ if (!u || Math.abs(at(y) - at(z)) >= ROUTE.RUN) return;
211
+ const c0 = connOf.get(keys[r]);
212
+ const run = Math.abs(at(y) - at(z)), tries = [];
213
+ for (let t = ROUTE.RUN; t <= ROUTE.RUN + 3 * SEPARATE; t++) tries.push(t);
214
+ for (let t = ROUTE.RUN - 1; t > run; t--) tries.push(t); // short of RUN (a phone's gutter): the longest that fits
215
+ for (const t of tries) {
216
+ const c2 = at(z) + u * t;
217
+ const a = h ? { x: c2, y: x.y } : { x: x.x, y: c2 }, b = h ? { x: c2, y: y.y } : { x: y.x, y: c2 };
218
+ if (Math.sign(at(a) - at(w)) !== Math.sign(at(x) - at(w)) || Math.abs(at(a) - at(w)) < 4) continue;
219
+ if (!clear(a, b) || !clear(w, a, n - 4 === 0 && c0 ? c0.a : -1) || !clear(b, z, c0 ? c0.z : -1)) continue;
220
+ const old = [p[n - 3], p[n - 2]];
221
+ p[n - 3] = a; p[n - 2] = b;
222
+ const ok = [n - 4, n - 3, n - 2].every((i) => { const s = { r, i, h: i === n - 3 ? !h : h }; const [lo, hi] = span(p, s); return !near(r, s, coord(p, s), lo, hi).length; });
223
+ if (ok) return;
224
+ [p[n - 3], p[n - 2]] = old;
225
+ }
226
+ });
227
+ return new Map(keys.map((k, i) => [k, pts[i]]));
228
+ }
229
+
166
230
  /**
167
231
  * Route `scene` with libavoid instance `A` (libavoid-js 0.4.5's: its constants are numbers on `A`). A Map of
168
232
  * connection key → points, for the routes that pass routeOk only.
@@ -186,7 +250,8 @@ export function routeWith(A, scene) {
186
250
  const k = s.pins === 1 ? ROUTE.PINS : s.pins;
187
251
  const pin = (x, y, dir) => new A.ShapeConnectionPin(ref, 1, x, y, true, 0, dir).setExclusive(true);
188
252
  for (let i = 1; i <= k; i++) { pin(i / (k + 1), 0, UP); pin(i / (k + 1), 1, DOWN); }
189
- pin(0, 0.5, LEFT); pin(1, 0.5, RIGHT);
253
+ if (sidesOf(s) & SIDE_L) pin(0, 0.5, LEFT);
254
+ if (sidesOf(s) & SIDE_R) pin(1, 0.5, RIGHT);
190
255
  }
191
256
  return ref;
192
257
  });
@@ -200,7 +265,7 @@ export function routeWith(A, scene) {
200
265
  const ok = routeOk(pts, scene, c);
201
266
  if (ok) out.set(c.key, ok);
202
267
  });
203
- return ROUTE.SEPARATE === false ? out : separate(out, scene);
268
+ return ROUTE.SEPARATE === false ? out : lengthen(separate(out, scene), scene);
204
269
  } finally {
205
270
  A.destroy(r);
206
271
  }
@@ -178,8 +178,15 @@ function endOf(L, name) {
178
178
 
179
179
  // ── Routing: lanes the layout keeps empty ──
180
180
 
181
- /** A side lane's offset from the middle of the box's padding: at least 6 px from the box's border and from its cards. */
181
+ /**
182
+ * A side lane's offset from the middle of the box's padding: at least 6 px from the box's border and from its cards.
183
+ * Negative: toward the border, on either side (#2329) — the first lines (o < 0) leave the cards a long last run for a head.
184
+ */
182
185
  const laneOffset = (o, PAD = OC.PAD) => Math.max(-PAD / 2 + 6, Math.min(PAD / 2 - 6, o * 0.5));
186
+ const leftLaneOf = (b, io, PAD) => b.x + PAD / 2 + io;
187
+ const rightLaneOf = (b, io, PAD) => b.x + b.w - PAD / 2 - io;
188
+ /** Where a line meets a card's side, from the side's middle (#2329): its full offset, a head's width from its neighbours. */
189
+ const sideOffset = (o) => Math.max(-OC.NH / 2 + 7, Math.min(OC.NH / 2 - 7, o));
183
190
  /** The layout's own box padding and gutter (a phone's are narrower). */
184
191
  const spacing = (L) => ({ PAD: L.pad ?? OC.PAD, GAP: L.gap ?? OC.GAP });
185
192
 
@@ -199,14 +206,14 @@ function outward(L, end, o) {
199
206
  const { NW, NH } = OC, { PAD, GAP } = spacing(L);
200
207
  const card = end.c, col = end.i % b.cols, rowI = Math.floor(end.i / b.cols), lines = Math.ceil(b.cells.length / b.cols);
201
208
  const io = laneOffset(o, PAD);
202
- const leftLane = b.x + PAD / 2 + io, rightLane = b.x + b.w - PAD / 2 + io;
209
+ const leftLane = leftLaneOf(b, io, PAD), rightLane = rightLaneOf(b, io, PAD);
203
210
  let pts;
204
211
  if (col === 0 || col === b.cols - 1) {
205
- const left = col === 0, lane = left ? leftLane : rightLane, cy = card.y + NH / 2 + io;
212
+ const left = col === 0, lane = left ? leftLane : rightLane, cy = card.y + NH / 2 + sideOffset(o);
206
213
  pts = [{ x: left ? card.x : card.x + NW, y: cy }, { x: lane, y: cy }, { x: lane, y: b.y }];
207
214
  } else {
208
215
  // A middle column: down into the gutter below its row, across to the nearer side lane.
209
- const gy = card.y + NH + (rowI === lines - 1 ? PAD : GAP) / 2 + io / 2, cx = card.x + NW / 2 + o;
216
+ const gy = card.y + NH + (rowI === lines - 1 ? PAD : GAP) / 2 - io / 2, cx = card.x + NW / 2 + o;
210
217
  const lane = cx - b.x < b.w / 2 ? leftLane : rightLane;
211
218
  pts = [{ x: cx, y: card.y + NH }, { x: cx, y: gy }, { x: lane, y: gy }, { x: lane, y: b.y }];
212
219
  }
@@ -224,10 +231,10 @@ function toLane(L, end, lane, o) {
224
231
  const io = laneOffset(o, PAD);
225
232
  const left = lane < b.x + b.w / 2;
226
233
  if ((left && col === 0) || (!left && col === b.cols - 1)) {
227
- const cy = card.y + NH / 2 + io;
234
+ const cy = card.y + NH / 2 + sideOffset(o);
228
235
  return [{ x: left ? card.x : card.x + NW, y: cy }, { x: lane, y: cy }];
229
236
  }
230
- const gy = card.y + NH + (rowI === lines - 1 ? PAD : GAP) / 2 + io / 2, cx = card.x + NW / 2 + o;
237
+ const gy = card.y + NH + (rowI === lines - 1 ? PAD : GAP) / 2 - io / 2, cx = card.x + NW / 2 + o;
231
238
  return [{ x: cx, y: card.y + NH }, { x: cx, y: gy }, { x: lane, y: gy }];
232
239
  }
233
240
  /** Two cards in one open box: joined inside it, through the side lane nearer to them both (never out to the bus). */
@@ -235,7 +242,7 @@ function inBox(L, a, z, o) {
235
242
  const b = a.b, { PAD } = spacing(L);
236
243
  const io = laneOffset(o, PAD);
237
244
  const mid = ((a.i % b.cols) + (z.i % b.cols)) / 2;
238
- const lane = mid <= (b.cols - 1) / 2 ? b.x + PAD / 2 + io : b.x + b.w - PAD / 2 + io;
245
+ const lane = mid <= (b.cols - 1) / 2 ? leftLaneOf(b, io, PAD) : rightLaneOf(b, io, PAD);
239
246
  return [...toLane(L, a, lane, o), ...toLane(L, z, lane, o).reverse()];
240
247
  }
241
248
  /** Two of General's cards: under them, through the gap above the first row (or the canvas's empty space below). */
@@ -280,6 +287,42 @@ export function badgeAt(pts) {
280
287
  return best;
281
288
  }
282
289
 
290
+ // ── Arrowheads (#2329) ──
291
+
292
+ /** A line's stroke width (px) by its kind and phase: the work's and its report's are heavier, a team pair's the most. */
293
+ export const strokeOf = (hue, phase) => (phase === "work" || phase === "return" ? 3 : hue === "team" ? 3.5 : 2.5);
294
+ /**
295
+ * The arrowhead and the ends. In stroke widths (the markers' units, fleet-canvas.js): a head LEN long and WIDE across,
296
+ * the line's own stroke ending BACK inside it (its round cap under the head, never out past the tip); the source dot
297
+ * DOT across. In px: the tip GAP short of the card, the dot's centre START off its card; a head only on a last straight
298
+ * run that holds it and ROOM of line before it — else the line ends GAP short with none (its dot and pulse show the way).
299
+ */
300
+ export const HEAD = { LEN: 2.8, WIDE: 2.3, BACK: 1.4, DOT: 2, GAP: 3.5, START: 5, ROOM: 1 };
301
+ /** Whether a line of stroke `w` px whose last straight run is `run` px long gets a head. */
302
+ export const headFits = (run, w) => run >= HEAD.GAP + HEAD.LEN * w + HEAD.ROOM;
303
+ /** An orthogonal polyline without repeated points or points in the middle of a straight run. */
304
+ function corners(pts) {
305
+ const q = pts.filter((pt, i) => !i || pt.x !== pts[i - 1].x || pt.y !== pts[i - 1].y).map((pt) => ({ x: pt.x, y: pt.y }));
306
+ return q.filter((pt, i) => i === 0 || i === q.length - 1 || !((q[i - 1].x === pt.x && pt.x === q[i + 1].x) || (q[i - 1].y === pt.y && pt.y === q[i + 1].y)));
307
+ }
308
+ /** Route `pts` as drawn: its start moved `a` px along its first run, its end `b` px back along its last (each keeps 1 px). */
309
+ export function trimEnds(pts, a, b) {
310
+ const q = corners(pts);
311
+ if (q.length < 2) return q;
312
+ const pull = (i, j, by) => {
313
+ const l = Math.hypot(q[j].x - q[i].x, q[j].y - q[i].y), t = Math.max(0, Math.min(by, l - 1)) / l;
314
+ q[i] = { x: q[i].x + (q[j].x - q[i].x) * t, y: q[i].y + (q[j].y - q[i].y) * t };
315
+ };
316
+ pull(0, 1, a);
317
+ pull(q.length - 1, q.length - 2, b);
318
+ return q;
319
+ }
320
+ /** The length of a route's last straight run. */
321
+ export function lastRun(pts) {
322
+ const q = corners(pts);
323
+ return q.length < 2 ? 0 : Math.hypot(q[q.length - 1].x - q[q.length - 2].x, q[q.length - 1].y - q[q.length - 2].y);
324
+ }
325
+
283
326
  // ── Edges ──
284
327
 
285
328
  /** The colour family of a kind: task, report, broadcast, and the rest (send, query, update). */
@@ -291,8 +334,10 @@ const settled = (phase) => (phase === "fading" || phase === "stale" ? "fading" :
291
334
 
292
335
  /**
293
336
  * The lines to draw from the store's items on layout `L`. `far`: zoomed out (one line per team pair). Each edge:
294
- * { key, hue, phase, n, from, to, d, pts, badge, a, z, routed } — `from`/`to` the instance (or team) names it joins, for
295
- * search; `a`/`z` its two ends (fleet-route-scene.js routes the ones on cards). `routed` (#2182): the router's points by
337
+ * { key, hue, phase, n, from, to, d, pts, w, head, badge, a, z, routed } — `from`/`to` the instance (or team) names it
338
+ * joins, for search; `pts` its route, end to end (on the cards); `d` the path drawn, its ends trimmed off the cards for
339
+ * the dot and the head (#2329); `w` its stroke width; `head` whether it has a head (headFits);
340
+ * `a`/`z` its two ends (fleet-route-scene.js routes the ones on cards). `routed` (#2182): the router's points by
296
341
  * line key (fleet-route.js); a line it has none for keeps its lane route — or, while a new answer is on its way, the
297
342
  * points the same two ends and kind had in the last one (a line changing phase keeps its place).
298
343
  */
@@ -339,7 +384,9 @@ export function trafficEdges(items, L, { far = false, max = MAX_EDGES, routed =
339
384
  const o = ((i % 7) - 3) * 7; // neighbours 7 px apart (within the ±22 px bus lane)
340
385
  const viaRouter = routedBy(key);
341
386
  const pts = viaRouter ?? route(L, g.a, g.z, o);
342
- return { key, hue: g.hue, phase: g.phase, n: g.n, names: [...new Set(g.names)], d: pathOf(pts), pts, badge: g.n > 1 ? badgeAt(pts) : null,
387
+ const w = strokeOf(g.hue, g.phase), head = headFits(lastRun(pts), w);
388
+ const d = pathOf(trimEnds(pts, HEAD.START, HEAD.GAP + (head ? HEAD.BACK * w : 0)));
389
+ return { key, hue: g.hue, phase: g.phase, n: g.n, names: [...new Set(g.names)], d, pts, w, head, badge: g.n > 1 ? badgeAt(pts) : null,
343
390
  a: g.a, z: g.z, routed: !!viaRouter };
344
391
  });
345
392
  }
Binary file
Binary file
@@ -813,13 +813,13 @@ function WebSection({ ctx, staged }) {
813
813
  echo: web.echo_to_channel ?? true,
814
814
  log: web.log_tab === true, // #1218: off by default
815
815
  usagePub: web.usage_report_on_public_link === true, // #2122: off by default
816
- pub: { allow_public: (web.public_link && web.public_link.allow_public) ?? true, ttl_minutes: (web.public_link && web.public_link.ttl_minutes) ?? 120, protocol: (web.public_link && web.public_link.protocol) ?? "http2" },
816
+ pub: { allow_public: (web.public_link && web.public_link.allow_public) ?? true, ttl_minutes: (web.public_link && web.public_link.ttl_minutes) ?? 120, protocol: (web.public_link && web.public_link.protocol) ?? "http2", hostname: (web.public_link && web.public_link.hostname) ?? "", tunnel_token_env: (web.public_link && web.public_link.tunnel_token_env) ?? "", origin_port: (web.public_link && web.public_link.origin_port) ?? 19282 },
817
817
  session: { absolute_hours: local.absolute_hours ?? null, idle_hours: local.idle_hours ?? null },
818
818
  terminal: { enabled: term.enabled ?? null, ttl_minutes: term.ttl_minutes ?? null },
819
819
  }), []);
820
820
  const text = (v) => (v === null || v === undefined ? "" : String(v));
821
821
  const [f, setF] = useState(() => ({
822
- echo: original.echo, log: original.log, usagePub: original.usagePub, pub: original.pub.allow_public, ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol,
822
+ echo: original.echo, log: original.log, usagePub: original.usagePub, pub: original.pub.allow_public, ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol, hostname: original.pub.hostname, originPort: String(original.pub.origin_port),
823
823
  abs: text(original.session.absolute_hours), idle: text(original.session.idle_hours),
824
824
  termOn: original.terminal.enabled !== false, termTtl: text(original.terminal.ttl_minutes),
825
825
  }));
@@ -832,6 +832,8 @@ function WebSection({ ctx, staged }) {
832
832
  // the store and the validator apply it — so only an idle time that is typed can exceed the session length.
833
833
  const abs = value(f.abs) ?? 48, idle = value(f.idle) ?? Math.min(48, abs);
834
834
  const errors = {
835
+ hostname: f.hostname && !/^(?:https:\/\/)?[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+\/?$/.test(f.hostname) ? "namedHostnameInvalid" : "",
836
+ originPort: !Number.isInteger(Number(f.originPort)) || Number(f.originPort) < 1024 || Number(f.originPort) > 65535 ? "namedPortInvalid" : "",
835
837
  ttl: !Number.isInteger(Number(f.ttl)) || Number(f.ttl) < 1 || Number(f.ttl) > 480 ? "publicTtl" : "",
836
838
  abs: hours(f.abs),
837
839
  idle: hours(f.idle) || (!hours(f.abs) && idle > abs ? "sessionIdleOver" : ""),
@@ -840,7 +842,7 @@ function WebSection({ ctx, staged }) {
840
842
  const ready = !overPublicLink && !Object.values(errors).some(Boolean);
841
843
  const review = () => {
842
844
  if (!ready) return;
843
- const publicPatch = changedFields({ allow_public: f.pub, ttl_minutes: Number(f.ttl), protocol: f.protocol }, original.pub);
845
+ const publicPatch = changedFields({ allow_public: f.pub, ttl_minutes: Number(f.ttl), protocol: f.protocol, hostname: f.hostname, tunnel_token_env: f.hostname ? "AGEND_CLOUDFLARE_TUNNEL_TOKEN" : "", origin_port: Number(f.originPort) }, original.pub);
844
846
  const sessionPatch = changedFields({ absolute_hours: value(f.abs), idle_hours: value(f.idle) }, original.session);
845
847
  // The checkbox shows the default (on) for an unset key: only a real toggle is written; on again = back to the default.
846
848
  const terminalPatch = changedFields({ enabled: f.termOn === (original.terminal.enabled !== false) ? original.terminal.enabled : f.termOn ? null : false,
@@ -902,13 +904,18 @@ function WebSection({ ctx, staged }) {
902
904
  <p class="note">${tn("webUsagePublicHint")}</p>
903
905
  <label class="check"><input id="publicWebLink" type="checkbox" checked=${f.pub} onChange=${(e) => set("pub")(e.target.checked)} /><span>${tn("publicLink")}</span><${Impact} schema=${schema} field="web.public_link.allow_public" /></label>
904
906
  <p class="note">${tn("publicLinkHint")}</p>
907
+ <div class="grid2">
908
+ <div class="field"><label for="publicWebHostname">${tn("namedHostname")}</label><input id="publicWebHostname" type="text" value=${f.hostname} placeholder="agend.example.com" class=${errors.hostname ? "invalid" : ""} onInput=${(e) => set("hostname")(e.target.value)} /><${Feedback} error=${errors.hostname} /></div>
909
+ <div class="field"><label for="publicWebOriginPort">${tn("namedOriginPort")}</label><input id="publicWebOriginPort" type="number" min="1024" max="65535" value=${f.originPort} class=${errors.originPort ? "invalid" : ""} onInput=${(e) => set("originPort")(e.target.value)} /><${Feedback} error=${errors.originPort} /></div>
910
+ </div>
911
+ <p class="note">${tn("namedTunnelHint")} <a href="https://github.com/songsid/AgEnD/blob/main/docs/named-tunnel.md" target="_blank" rel="noopener noreferrer">${tn("namedTunnelSetup")}</a></p>
905
912
  <div class="grid2">
906
913
  <div class="field"><label for="publicWebTtl">${tn("publicTtl")}</label><input id="publicWebTtl" type="number" min="1" max="480" class=${errors.ttl ? "invalid" : ""} value=${f.ttl} onInput=${(e) => set("ttl")(e.target.value)} /><${Feedback} error=${errors.ttl} /></div>
907
914
  <div class="field"><label for="publicWebProtocol">${tn("publicProtocol")}</label><${Select} id="publicWebProtocol" value=${f.protocol} onChange=${set("protocol")} options=${["http2", "quic", "auto"]} /></div>
908
915
  </div>
909
916
  </section>
910
917
  </fieldset>
911
- ${overPublicLink ? null : html`<${GuestsCard} ctx=${ctx} staged=${staged} /><${WebAuthorityCard} ctx=${ctx} staged=${staged} />`}
918
+ ${overPublicLink ? null : html`<${PublicTunnelToken} /><${GuestsCard} ctx=${ctx} staged=${staged} /><${WebAuthorityCard} ctx=${ctx} staged=${staged} />`}
912
919
  <section class="card s-web-readonly"><h3>${tn("webReadOnlyTitle")}</h3>
913
920
  <p class="note">${tn("webReadOnlyLead")}</p>
914
921
  <dl class="s-web-ro">${WEB_READ_ONLY.map((k) => html`<div key=${k}><dt><code>${k}</code></dt><dd>${shown(k)}</dd></div>`)}</dl>
@@ -1033,3 +1040,49 @@ function HelpDialog({ onClose }) {
1033
1040
  }
1034
1041
 
1035
1042
  export { worstImpact, ACCESS_MODES };
1043
+
1044
+ function PublicTunnelToken() {
1045
+ const lease = useLease("public-tunnel-token");
1046
+ const [secret, setSecret] = useState("");
1047
+ const [present, setPresent] = useState(false);
1048
+ const [busy, setBusy] = useState(false);
1049
+ const inFlight = useRef(false);
1050
+ const [message, setMessage] = useState("");
1051
+ const base = "/api/settings/public-tunnel-token";
1052
+ const refresh = async () => {
1053
+ const result = await api(base);
1054
+ if (lease.current() && result.ok) setPresent(result.body?.[0]?.token_present === true);
1055
+ };
1056
+ useEffect(() => { refresh().catch(() => {}); }, []);
1057
+ const go = async (clear) => {
1058
+ if (inFlight.current || !clear && !secret) return;
1059
+ const value = secret;
1060
+ setSecret(""); inFlight.current = true; setBusy(true); setMessage("");
1061
+ const key = newKey("tunnel-token");
1062
+ try {
1063
+ const prepared = await api(`${base}/verify`, { method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify(clear ? { clear: true } : { secret: value }) });
1064
+ if (!lease.current()) return;
1065
+ if (!prepared.ok) throw Error("refused");
1066
+ const applied = await confirmedWrite(`${base}/apply`, { method: "POST", key, label: tn(clear ? "namedTokenClear" : "namedTokenSet"), body: { verification_id: prepared.body?.verification_id, idempotency_key: key } });
1067
+ let body = applied.body || {};
1068
+ for (let n = 0; applied.ok && body.job_id && body.result === "applying" && n < 60; n++) {
1069
+ await new Promise(r => setTimeout(r, 250));
1070
+ const status = await api(`${base}/apply/${encodeURIComponent(body.job_id)}`);
1071
+ if (status.ok) body = status.body || body;
1072
+ }
1073
+ if (lease.current()) {
1074
+ setMessage(tn(applied.ok && body.result === "reloaded" ? "namedTokenSaved" : body.result === "pending_confirmation" ? "awaitingConfirmation" : "applyFailed"));
1075
+ await refresh();
1076
+ }
1077
+ } catch { if (lease.current()) setMessage(tn("applyFailed")); }
1078
+ finally { inFlight.current = false; if (lease.current()) setBusy(false); }
1079
+ };
1080
+ return html`<section class="card"><h3>${tn("namedToken")}</h3>
1081
+ <p class="note">${tn("namedTokenHint")}</p>
1082
+ <span class="tag">${present ? tn("keyConfigured") : tn("keyNotConfigured")}</span>
1083
+ <input id="publicTunnelToken" type="password" autocomplete="new-password" aria-label=${tn("namedToken")} value=${secret} onInput=${e => setSecret(e.target.value)} />
1084
+ <button type="button" class="btn btn-sm" disabled=${busy || !secret} onClick=${() => go(false)}>${tn("namedTokenSet")}</button>
1085
+ <button type="button" class="btn btn-sm" disabled=${busy || !present} onClick=${() => go(true)}>${tn("namedTokenClear")}</button>
1086
+ ${message ? html`<p class="note" role="status">${message}</p>` : null}
1087
+ </section>`;
1088
+ }
@@ -45,7 +45,7 @@ export function AppearanceSection() {
45
45
  const publicLink = typeof document !== "undefined" && document.body && document.body.dataset.publicLink === "1";
46
46
  if (publicLink) return html`<section class="card s-appearance"><h3>${tn("cssTitle")}</h3><p class="sub">${tn("cssPublicLink")}</p></section>`;
47
47
  const group = (key) => html`<h2 class="s-ap-group">${tn(key)}</h2>`;
48
- return html`${group("groupLook")}<${ThemesCard} /><${IdentityColorCard} /><${CardSizeCard} />
48
+ return html`${group("groupLook")}<${ThemesCard} /><${IdentityColorCard} /><${CardSizeCard} /><${AppIconCard} />
49
49
  ${group("groupLayout")}<${SidebarWidgetCard} />
50
50
  ${group("groupCss")}<${OwnerCssCard} /><${DeviceCard} />
51
51
  ${group("groupMods")}<${ModsCard} /><${EffectsCard} />
@@ -127,6 +127,131 @@ function CardSizeCard() {
127
127
  </section>`;
128
128
  }
129
129
 
130
+ /**
131
+ * #2308: the image as one square PNG the host makes the icon sizes from — centre-cropped, drawn at 512 px if smaller
132
+ * (marked low-resolution), as it is up to 1024 px, else 1024. The browser decodes whatever it can show (PNG, JPG, WebP);
133
+ * the host takes only that PNG.
134
+ */
135
+ export async function appIconPng(file) {
136
+ const bmp = await createImageBitmap(file);
137
+ try {
138
+ const side = Math.min(bmp.width, bmp.height);
139
+ const out = Math.min(1024, Math.max(512, side));
140
+ const canvas = document.createElement("canvas");
141
+ canvas.width = canvas.height = out;
142
+ const g = canvas.getContext("2d");
143
+ g.imageSmoothingQuality = "high";
144
+ g.drawImage(bmp, (bmp.width - side) / 2, (bmp.height - side) / 2, side, side, 0, 0, out, out);
145
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
146
+ if (!blob) throw new Error("no PNG");
147
+ return { blob, low: side < 512 };
148
+ } finally {
149
+ if (bmp.close) bmp.close();
150
+ }
151
+ }
152
+
153
+ /** #2308: this page's icon links follow a change at once (the next pages carry the version from the host). */
154
+ export function refreshAppIconLinks(version, custom) {
155
+ if (typeof document === "undefined") return;
156
+ const v = encodeURIComponent(version);
157
+ const head = document.head;
158
+ const links = [...head.querySelectorAll("link")];
159
+ const byPath = (path) => links.find((l) => (l.getAttribute("href") || "").split("?")[0] === path);
160
+ for (const [path, rel] of [["/favicon.ico", "icon"], ["/apple-touch-icon.png", "apple-touch-icon"], ["/manifest.webmanifest", "manifest"]]) {
161
+ const el = byPath(path);
162
+ if (el && el.getAttribute("rel") === rel) el.setAttribute("href", `${path}?v=${v}`);
163
+ }
164
+ const svg = byPath("/favicon.svg");
165
+ if (custom && svg) svg.remove();
166
+ else if (!custom && !svg) {
167
+ const link = document.createElement("link");
168
+ for (const [k, val] of [["rel", "icon"], ["href", `/favicon.svg?v=${v}`], ["type", "image/svg+xml"], ["sizes", "any"]]) link.setAttribute(k, val);
169
+ head.appendChild(link);
170
+ }
171
+ }
172
+
173
+ /**
174
+ * #2308: the app icon — what a browser tab and a home screen show. One picture for every page (sign-in and the public
175
+ * link too); an admin changes it, everyone else sees it.
176
+ */
177
+ function AppIconCard() {
178
+ const [state, setState] = useState(null);
179
+ const [canWrite, setCanWrite] = useState(false);
180
+ const [picked, setPicked] = useState(null); // { blob, url, low }
181
+ const [busy, setBusy] = useState(false);
182
+ const [error, setError] = useState("");
183
+ const file = useRef(null);
184
+ const load = async () => {
185
+ const r = await call("/ui/app-icon");
186
+ setState(r.ok && r.body && typeof r.body.version === "string" ? r.body : { failed: true });
187
+ };
188
+ useEffect(() => {
189
+ load();
190
+ const auth = typeof window !== "undefined" ? window.AgendAuth : null;
191
+ if (auth && auth.canWrite) Promise.resolve(auth.canWrite()).then((v) => setCanWrite(v === true), () => {});
192
+ }, []);
193
+ useEffect(() => () => { if (picked) URL.revokeObjectURL(picked.url); }, [picked]);
194
+ const title = html`<${CardTitle} title=${tn("appIconTitle")} topic="app-icon" />`;
195
+ if (!state) return html`<section class="card s-appearance s-app-icon">${title}<p class="sub">…</p></section>`;
196
+ if (state.failed) return html`<section class="card s-appearance s-app-icon">${title}<p class="err">${tn("appIconLoadFailed")}</p></section>`;
197
+
198
+ const choose = async () => {
199
+ const f = file.current && file.current.files && file.current.files[0];
200
+ if (file.current) file.current.value = "";
201
+ if (!f) return;
202
+ setError("");
203
+ try {
204
+ const made = await appIconPng(f);
205
+ setPicked({ ...made, url: URL.createObjectURL(made.blob) });
206
+ } catch {
207
+ setError(tn("appIconNotAnImage"));
208
+ }
209
+ };
210
+ const answered = (r, done) => {
211
+ setBusy(false);
212
+ if (!r.ok) {
213
+ const known = ["app_icon_too_large", "app_icon_not_png", "app_icon_unreadable", "app_icon_admin_only", "app_icon_needs_a_signed_in_person", "app_icon_busy", "app_icon_superseded", "app_icon_not_reset"];
214
+ setError(known.includes(r.body && r.body.error) ? tn(`appIconError_${r.body.error}`, (r.body && r.body.detail) || "") : tn("appIconError_other", (r.body && r.body.error) || String(r.status)));
215
+ return;
216
+ }
217
+ setState(r.body);
218
+ setPicked(null);
219
+ refreshAppIconLinks(r.body.version, r.body.custom);
220
+ toast(done);
221
+ };
222
+ const use = async () => {
223
+ setBusy(true); setError("");
224
+ answered(await call("/ui/app-icon", { method: "PUT", headers: { "Content-Type": "image/png" }, body: picked.blob }), tn("appIconSaved"));
225
+ };
226
+ const reset = async () => {
227
+ setBusy(true); setError("");
228
+ answered(await call("/ui/app-icon", { method: "DELETE" }), tn("appIconResetDone"));
229
+ };
230
+ const v = encodeURIComponent(state.version);
231
+ return html`<section class="card s-appearance s-app-icon">${title}
232
+ <p class="sub">${tn("appIconLead")}</p>
233
+ <div class="s-app-icon-row">
234
+ <figure class="s-app-icon-now"><img class="s-app-icon-big" src=${`/apple-touch-icon.png?v=${v}`} width="96" height="96" alt=${tn("appIconCurrent")} />
235
+ <img class="s-app-icon-small" src=${`/icon-192.png?v=${v}`} width="32" height="32" alt="" />
236
+ <figcaption>${state.custom ? tn("appIconCustom") : tn("appIconDefault")}</figcaption></figure>
237
+ ${picked ? html`<figure class="s-app-icon-new"><img class="s-app-icon-big" src=${picked.url} width="96" height="96" alt=${tn("appIconPreview")} />
238
+ <figcaption>${tn("appIconPreview")}</figcaption></figure>` : null}
239
+ </div>
240
+ ${picked && picked.low ? html`<p class="sub s-app-icon-low">${tn("appIconLow")}</p>` : null}
241
+ ${error ? html`<p class="err" role="alert">${error}</p>` : null}
242
+ ${canWrite ? html`<div class="s-css-actions">
243
+ <input ref=${file} type="file" class="sr-only" id="s-app-icon-file" accept="image/png,image/jpeg,image/webp" onChange=${choose} />
244
+ ${picked ? html`
245
+ <button type="button" class="btn btn-primary" disabled=${busy} onClick=${use}><${Icon} name="check" size=${16} />${tn("appIconUse")}</button>
246
+ <button type="button" class="btn" disabled=${busy} onClick=${() => setPicked(null)}>${tn("appIconCancel")}</button>`
247
+ : html`<button type="button" class="btn" disabled=${busy} onClick=${() => file.current && file.current.click()}>${tn("appIconChoose")}</button>`}
248
+ ${state.custom && !picked ? html`<button type="button" class="btn" disabled=${busy} onClick=${reset}>${tn("appIconReset")}</button>` : null}
249
+ </div>` : html`<p class="sub">${tn("appIconReadOnly")}</p>`}
250
+ <p class="sub">${tn("appIconWho")}</p>
251
+ <p class="sub">${tn("appIconIos")}</p>
252
+ </section>`;
253
+ }
254
+
130
255
  const LIMIT_FIELDS = [["portrait", "max_width"], ["portrait", "max_height"], ["portrait", "max_kib"], ["banner", "max_width"], ["banner", "max_height"], ["banner", "max_kib"],
131
256
  ["instance_quota", "files"], ["instance_quota", "mib"]];
132
257
  /**
@@ -13,6 +13,7 @@ export const LEARN_ANCHORS = {
13
13
  colors: ["identity-colours", "識別色"], css: ["custom-css-settings--web-appearance", "自訂-css設定--網頁外觀"], themes: ["agents-themes", "agent-的主題"],
14
14
  cards: ["identity-cards", "身分卡"], limits: ["card-upload-limits", "身分卡上傳限制"], widgets: ["widgets", "小工具"], mods: ["mods", "mod"],
15
15
  effects: ["effects", "效果"], restore: ["restore-defaults", "還原預設值"],
16
+ "app-icon": ["app-icon", "app-圖示"],
16
17
  };
17
18
  export function learnHref(topic, language = lang()) {
18
19
  const zh = language === "zh-TW";