@linxin666/dsh-client-ui-task-board 0.2.9 → 0.3.1

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/lib/index.js CHANGED
@@ -204,8 +204,19 @@ function resolveDshHome(env = process.env, home = homedir()) {
204
204
  function dshHome() {
205
205
  return resolveDshHome();
206
206
  }
207
- //#endregion
208
- //#region src/core/tasks.ts
207
+ /**
208
+ * Trim an execution list to at most {@link EXECUTION_HISTORY_LIMIT} records,
209
+ * most recent last. A running (unsettled) execution is never trimmed: the
210
+ * Host monitor and restart recovery depend on the active record, and a task
211
+ * cannot start a new run while one is still open.
212
+ */
213
+ function retainRecentExecutions(executions) {
214
+ if (executions.length <= 20) return [...executions];
215
+ const open = executions.filter((execution) => execution.endedAt === void 0);
216
+ const settled = executions.filter((execution) => execution.endedAt !== void 0);
217
+ const keepSettled = Math.max(20 - open.length, 0);
218
+ return [...settled.slice(Math.max(settled.length - keepSettled, 0)), ...open];
219
+ }
209
220
  /** Statuses a settled task may be archived from. */
210
221
  const ARCHIVABLE_STATUSES = ["done", "failed"];
211
222
  /** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
@@ -307,7 +318,7 @@ function startExecution(task, now, executionId) {
307
318
  ...task,
308
319
  status: "running",
309
320
  updatedAt: now,
310
- executions: [...task.executions, execution]
321
+ executions: retainRecentExecutions([...task.executions, execution])
311
322
  },
312
323
  execution
313
324
  };
@@ -1014,9 +1025,10 @@ function mergeTask(a, b) {
1014
1025
  const previous = byId.get(entry.id);
1015
1026
  byId.set(entry.id, previous === void 0 ? entry : betterExecution(previous, entry));
1016
1027
  }
1028
+ const executions = [...byId.values()].sort((x, y) => x.startedAt - y.startedAt);
1017
1029
  return {
1018
1030
  ...newer,
1019
- executions: [...byId.values()].sort((x, y) => x.startedAt - y.startedAt)
1031
+ executions: retainRecentExecutions(executions)
1020
1032
  };
1021
1033
  }
1022
1034
  function parseHostTasks(values) {
@@ -1360,7 +1372,10 @@ var HostTaskLedger = class {
1360
1372
  try {
1361
1373
  const parsed = JSON.parse(readFileSync(this.file, "utf8"));
1362
1374
  if (parsed.schemaVersion !== 2 || !Array.isArray(parsed.tasks)) throw new Error("unsupported ledger schema");
1363
- const tasks = parseHostTasks(parsed.tasks);
1375
+ const tasks = parseHostTasks(parsed.tasks).map((task) => ({
1376
+ ...task,
1377
+ executions: retainRecentExecutions(task.executions)
1378
+ }));
1364
1379
  const invalidScheduleIds = parsed.tasks.flatMap((value) => {
1365
1380
  if (typeof value !== "object" || value === null) return [];
1366
1381
  const row = value;
@@ -2194,6 +2209,26 @@ var TaskBoardHostService = class {
2194
2209
  }
2195
2210
  };
2196
2211
  //#endregion
2212
+ //#region src/http.ts
2213
+ /** Family-default JSON response headers; callers may append or override. */
2214
+ const JSON_HEADERS = {
2215
+ "content-type": "application/json; charset=utf-8",
2216
+ "referrer-policy": "no-referrer"
2217
+ };
2218
+ /**
2219
+ * Write one JSON response. Default headers are the family defaults
2220
+ * (content-type and referrer-policy); caller headers are appended or
2221
+ * override them.
2222
+ */
2223
+ function writeJson(res, status, body, headers = {}) {
2224
+ const payload = JSON.stringify(body);
2225
+ res.writeHead(status, {
2226
+ ...JSON_HEADERS,
2227
+ ...headers
2228
+ });
2229
+ res.end(payload);
2230
+ }
2231
+ //#endregion
2197
2232
  //#region src/loopback.ts
2198
2233
  /** IPv4 127/8 predicate (four decimal octets, first == 127). */
2199
2234
  function isIPv4Loopback(v4) {
@@ -2245,13 +2280,6 @@ const IMPORT_LIMIT = 2 * 1024 * 1024;
2245
2280
  const HEARTBEAT_MS = 15e3;
2246
2281
  /** Header replaced by an authenticated same-host reverse proxy. */
2247
2282
  const TASK_BOARD_PROXY_TOKEN_HEADER = "x-dsh-task-board-proxy-token";
2248
- function json(res, status, body) {
2249
- res.writeHead(status, {
2250
- "content-type": "application/json; charset=utf-8",
2251
- "cache-control": "no-store"
2252
- });
2253
- res.end(JSON.stringify(body));
2254
- }
2255
2283
  function parseAuthority(authority) {
2256
2284
  if (authority.trim() !== authority) return void 0;
2257
2285
  const match = authority.startsWith("[") ? /^\[[^\]]+\](?::([0-9]+))?$/.exec(authority) : /^[^:@/?#\s]+(?::([0-9]+))?$/.exec(authority);
@@ -2346,10 +2374,10 @@ function makeTaskBoardRoutes(service, access = {}) {
2346
2374
  const resolvedAccess = resolveAccess(access);
2347
2375
  const guard = (req, res) => {
2348
2376
  if (isTrustedTaskBoardRequest(req, resolvedAccess)) return true;
2349
- json(res, 403, {
2377
+ writeJson(res, 403, {
2350
2378
  ok: false,
2351
2379
  error: "forbidden"
2352
- });
2380
+ }, { "cache-control": "no-store" });
2353
2381
  return false;
2354
2382
  };
2355
2383
  return [
@@ -2357,45 +2385,45 @@ function makeTaskBoardRoutes(service, access = {}) {
2357
2385
  kind: "exact",
2358
2386
  path: `${TASK_BOARD_API_PREFIX}/state`,
2359
2387
  handler: (req, res) => {
2360
- if (req.method !== "GET") return json(res, 405, {
2388
+ if (req.method !== "GET") return writeJson(res, 405, {
2361
2389
  ok: false,
2362
2390
  error: "method-not-allowed"
2363
- });
2391
+ }, { "cache-control": "no-store" });
2364
2392
  if (!guard(req, res)) return;
2365
- json(res, 200, service.snapshot());
2393
+ writeJson(res, 200, service.snapshot(), { "cache-control": "no-store" });
2366
2394
  }
2367
2395
  },
2368
2396
  {
2369
2397
  kind: "exact",
2370
2398
  path: `${TASK_BOARD_API_PREFIX}/action`,
2371
2399
  handler: async (req, res) => {
2372
- if (req.method !== "POST") return json(res, 405, {
2400
+ if (req.method !== "POST") return writeJson(res, 405, {
2373
2401
  ok: false,
2374
2402
  error: "method-not-allowed"
2375
- });
2403
+ }, { "cache-control": "no-store" });
2376
2404
  if (!guard(req, res)) return;
2377
- if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return json(res, 415, {
2405
+ if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return writeJson(res, 415, {
2378
2406
  ok: false,
2379
2407
  error: "json-required"
2380
- });
2408
+ }, { "cache-control": "no-store" });
2381
2409
  try {
2382
2410
  const body = await readBody(req);
2383
2411
  const parsed = parseActionEnvelope(body.value);
2384
- if (parsed === void 0) return json(res, 400, {
2412
+ if (parsed === void 0) return writeJson(res, 400, {
2385
2413
  ok: false,
2386
2414
  error: "invalid-action"
2387
- });
2388
- if (parsed.action.kind !== "import" && Buffer.byteLength(body.raw) > ACTION_LIMIT) return json(res, 413, {
2415
+ }, { "cache-control": "no-store" });
2416
+ if (parsed.action.kind !== "import" && Buffer.byteLength(body.raw) > ACTION_LIMIT) return writeJson(res, 413, {
2389
2417
  ok: false,
2390
2418
  error: "body-too-large"
2391
- });
2392
- json(res, 200, service.apply(parsed.requestId, parsed.action));
2419
+ }, { "cache-control": "no-store" });
2420
+ writeJson(res, 200, service.apply(parsed.requestId, parsed.action), { "cache-control": "no-store" });
2393
2421
  } catch (error) {
2394
2422
  const message = error instanceof Error ? error.message : String(error);
2395
- json(res, message === "body-too-large" ? 413 : 400, {
2423
+ writeJson(res, message === "body-too-large" ? 413 : 400, {
2396
2424
  ok: false,
2397
2425
  error: message
2398
- });
2426
+ }, { "cache-control": "no-store" });
2399
2427
  }
2400
2428
  }
2401
2429
  },
@@ -2,17 +2,15 @@
2
2
  * Board controller: the single owner of task-ledger state and view state.
3
3
  *
4
4
  * In production it projects the Host ledger and submits confirmed actions;
5
- * the legacy store/execution seams remain for v1 migration tests. It also
6
- * closes the board whenever the user navigates to a session.
5
+ * the legacy store seam remains for v1 migration tests. It also closes the
6
+ * board whenever the user navigates to a session.
7
7
  * Framework-free (structural runtime faces) so the whole orchestration is
8
8
  * unit-testable with fakes.
9
9
  *
10
10
  * The per use-case domain transitions (create/update/delete/schedule) live in
11
11
  * dedicated modules under core/use-cases and are applied here; the controller
12
- * owns only the orchestration seam (state, persistence, notify, execution,
13
- * navigation, reconciliation).
12
+ * owns only the orchestration seam (state, persistence, notify, navigation).
14
13
  */
15
- import type { ExecutionService } from './execution.ts';
16
14
  import type { TaskStore } from './store.ts';
17
15
  import { type NewTaskInput, type TaskRecord, type TaskStatus } from './tasks.ts';
18
16
  import { type TaskUpdatePatch } from './use-cases/task-update.ts';
@@ -37,15 +35,11 @@ export interface SessionsControllerFace {
37
35
  /** Controller dependencies (all swappable in tests). */
38
36
  export interface ControllerDeps {
39
37
  store: TaskStore;
40
- /** Legacy browser execution seam used only by isolated v1 tests. */
41
- exec?: ExecutionService;
42
38
  sessions: SessionsControllerFace;
43
39
  /** Clock; defaults to Date.now. */
44
40
  now?: () => number;
45
41
  /** Id minting; defaults to a random-uuid. */
46
42
  uuid?: () => string;
47
- /** Debounce (ms) for session-list-changed reconciles; defaults to 350. */
48
- reconcileDebounceMs?: number;
49
43
  /** Host-authoritative transport; absent keeps the legacy in-memory test path. */
50
44
  transport?: TaskBoardTransport;
51
45
  }
@@ -105,7 +99,7 @@ export declare class BoardController {
105
99
  private hostState;
106
100
  private remoteSubscribed;
107
101
  private remoteInitialization;
108
- /** @param deps - store, execution service, and the sessions navigation face. */
102
+ /** @param deps - store and the sessions navigation face. */
109
103
  constructor(deps: ControllerDeps);
110
104
  /** Load the persisted ledger and start the navigation/status subscriptions. */
111
105
  start(): void;
@@ -178,32 +172,17 @@ export declare class BoardController {
178
172
  */
179
173
  openSession(sessionId: string): void;
180
174
  /**
181
- * Execute a task for real: move it to 'running', open an execution record,
182
- * and hand off to the ExecutionService. A second call while the task is
183
- * already running is ignored.
175
+ * Request a Host execution for a task: the Host ledger owns the running
176
+ * transition, the execution record, and the settlement. A second call
177
+ * while the task is already running is ignored; without a Host transport
178
+ * the run is refused (returns false).
184
179
  */
185
180
  runTask(id: string): Promise<boolean>;
186
- /** Re-run a settled task: move it back to 'todo' first, then execute. */
181
+ /** Re-run a settled task through the Host (the Host replans and executes). */
187
182
  rerunTask(id: string): Promise<void>;
188
- private handleExecutionEvent;
189
- /** Reconcile running tasks and close the board when the user navigates. */
183
+ /** Close the board when the user navigates to another session. */
190
184
  private onSessionsChanged;
191
185
  private lastCurrent;
192
- /** Execution ids launched on this page; they settle via their live watch, never list reconciliation. */
193
- private readonly activeExecutionIds;
194
- /** Debounce timer for {@link reconcileRunningTasks}. */
195
- private reconcileTimer;
196
- /** Whether a reconcile pass is underway (single-flight guard). */
197
- private reconcileInFlight;
198
- /**
199
- * Debounce + single-flight trigger for the running-task reconciliation.
200
- * Session-list notifications arrive in bursts (one per session status
201
- * change); both guards together keep a burst from reading the history API
202
- * once per running task.
203
- */
204
- private scheduleReconcile;
205
- /** Settle tasks left 'running' whose sessions already finished. */
206
- private reconcileRunningTasks;
207
186
  private persistAndNotify;
208
187
  private commitRemote;
209
188
  private performRemote;
@@ -25,6 +25,19 @@ export interface ExecutionRecord {
25
25
  /** Human failure text when the run failed (prompt rejection or agent error). */
26
26
  error: string | undefined;
27
27
  }
28
+ /**
29
+ * Maximum number of execution records retained per task. Older settled runs
30
+ * are trimmed when a new execution starts so per-action ledger cost stays
31
+ * bounded regardless of how often a task ran before.
32
+ */
33
+ export declare const EXECUTION_HISTORY_LIMIT = 20;
34
+ /**
35
+ * Trim an execution list to at most {@link EXECUTION_HISTORY_LIMIT} records,
36
+ * most recent last. A running (unsettled) execution is never trimmed: the
37
+ * Host monitor and restart recovery depend on the active record, and a task
38
+ * cannot start a new run while one is still open.
39
+ */
40
+ export declare function retainRecentExecutions(executions: readonly ExecutionRecord[]): ExecutionRecord[];
28
41
  /**
29
42
  * A scheduled-run rule attached to a task. The Host scheduler triggers the
30
43
  * task when `nextRunAt` is due and persists the rule in the Host ledger.
@@ -55,7 +68,10 @@ export interface TaskRecord {
55
68
  createdAt: number;
56
69
  /** Last mutation instant (ms epoch). */
57
70
  updatedAt: number;
58
- /** Every execution attempt, most recent last. */
71
+ /**
72
+ * Execution history retained on the task, most recent last: the latest
73
+ * {@link EXECUTION_HISTORY_LIMIT} attempts, oldest trimmed on append.
74
+ */
59
75
  executions: ExecutionRecord[];
60
76
  /** Optional scheduled-run rule (absent on tasks without a schedule). */
61
77
  schedule?: ScheduleRule;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shared JSON body/response helpers for the host route families: one strict
3
+ * bounded body reader, one lenient bounded body reader, one JSON object
4
+ * narrow, and one JSON writer. Previously these were copy-pasted across the
5
+ * package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
6
+ * family's route module) with drifting contracts: body caps ranging 4 KiB to
7
+ * 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
8
+ *
9
+ * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
10
+ * edit this shared source and re-run the sync instead of editing a copy.
11
+ * Consumer code is migrated onto it in follow-up waves; no call site changes
12
+ * belong in the same change as its introduction.
13
+ * @module dsh-web-ui-shared/host/http
14
+ */
15
+ import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http';
16
+ /**
17
+ * Strict bounded body reader: parse a request body of at most maxBytes as
18
+ * JSON.
19
+ * @throws 'body too large' past the cap, or the JSON.parse error for an
20
+ * invalid or empty payload.
21
+ */
22
+ export declare function readBoundedJson(req: IncomingMessage, maxBytes: number): Promise<unknown>;
23
+ /**
24
+ * Lenient bounded body reader: parse a request body as JSON, or null on an
25
+ * empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
26
+ * Overflow destroys the request instead of draining the remainder (no drain
27
+ * call, matching the current repo-wide behavior); callers must not keep
28
+ * reading the request afterwards. With objectOnly, non-JSON-object payloads
29
+ * also yield null.
30
+ */
31
+ export declare function readJsonBody(req: IncomingMessage, opts?: {
32
+ maxBytes?: number;
33
+ objectOnly?: boolean;
34
+ }): Promise<unknown | null>;
35
+ /** Narrow a value to a JSON object, or undefined when it is not one. */
36
+ export declare function asJsonObject(value: unknown): Record<string, unknown> | undefined;
37
+ /**
38
+ * Write one JSON response. Default headers are the family defaults
39
+ * (content-type and referrer-policy); caller headers are appended or
40
+ * override them.
41
+ */
42
+ export declare function writeJson(res: ServerResponse, status: number, body: unknown, headers?: OutgoingHttpHeaders): void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-task-board",
3
3
  "description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
4
- "version": "0.2.9",
4
+ "version": "0.3.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -52,9 +52,11 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
52
52
  return (
53
53
  <div className={css.board} data-dsh-taskboard-board="" data-dsh-plugin="task-board">
54
54
  <header className={css.boardHeader}>
55
+ {/* Shared hook: dsh-web-ui-all offsets center-view back controls beside the collapsed mobile sidebar. */}
55
56
  <button
56
57
  type="button"
57
58
  className={`${css.ghostButton} ${css.backButton}`}
59
+ data-dsh-center-view-back=""
58
60
  aria-label={t('board.close')}
59
61
  onClick={() => { controller.closeBoard() }}
60
62
  >
@@ -51,12 +51,13 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
51
51
  /* --- sidebar entry row ------------------------------------------------------- */
52
52
 
53
53
  .entry {
54
+ box-sizing: border-box;
54
55
  display: flex;
55
56
  align-items: center;
56
57
  gap: 8px;
57
58
  width: 100%;
58
- height: 32px;
59
- padding: 0 12px;
59
+ height: 36px;
60
+ padding: 0 10px;
60
61
  background: transparent;
61
62
  border: none;
62
63
  border-radius: 8px;
@@ -67,12 +68,12 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
67
68
  }
68
69
 
69
70
  .entry:hover {
70
- background: var(--dsw-specific-sidebar-nav-item-hover);
71
+ background: var(--dsw-alias-interactive-bg-hover);
71
72
  color: var(--dsw-alias-label-primary);
72
73
  }
73
74
 
74
75
  .entry[data-active] {
75
- background: var(--dsw-specific-sidebar-nav-item-active);
76
+ background: var(--dsw-alias-interactive-bg-active);
76
77
  color: var(--dsw-alias-label-primary);
77
78
  font-weight: 600;
78
79
  }
@@ -81,9 +82,17 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
81
82
  display: inline-flex;
82
83
  align-items: center;
83
84
  justify-content: center;
85
+ width: 24px;
86
+ height: 24px;
84
87
  flex: none;
85
88
  }
86
89
 
90
+ .entryIcon svg {
91
+ display: block;
92
+ width: 18px;
93
+ height: 18px;
94
+ }
95
+
87
96
  .entryLabel {
88
97
  overflow: hidden;
89
98
  text-overflow: ellipsis;
@@ -93,7 +102,10 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
93
102
  [data-dsh-frame][data-sidebar-collapsed] .entry {
94
103
  justify-content: center;
95
104
  padding: 0;
96
- width: 100%;
105
+ width: 36px;
106
+ height: 36px;
107
+ margin: 0 auto 12px;
108
+ border-radius: 50%;
97
109
  }
98
110
 
99
111
  [data-dsh-frame][data-sidebar-collapsed] .entryLabel {
@@ -809,31 +821,191 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
809
821
 
810
822
  /* --- constrained board containers ------------------------------------------ */
811
823
 
812
- @container task-board-view (max-width: 720px) {
824
+ @container task-board-view (max-width: 768px) {
813
825
  .board {
814
826
  gap: 10px;
815
827
  padding: 10px;
816
828
  }
817
829
 
830
+ .boardHeader {
831
+ align-items: center;
832
+ flex-wrap: wrap;
833
+ gap: 8px;
834
+ }
835
+
836
+ .backButton {
837
+ flex: 0 0 auto;
838
+ order: 1;
839
+ }
840
+
841
+ .boardTitle {
842
+ flex: 1 1 auto;
843
+ order: 2;
844
+ }
845
+
846
+ .boardHeader > .detailMeta {
847
+ flex: 1 0 100%;
848
+ margin-left: 0;
849
+ order: 3;
850
+ }
851
+
852
+ .search {
853
+ flex: 1 0 100%;
854
+ min-width: 0;
855
+ order: 4;
856
+ }
857
+
858
+ .boardHeader > button:not(.backButton) {
859
+ flex: 1 1 0;
860
+ min-width: 0;
861
+ order: 5;
862
+ }
863
+
818
864
  .columns {
865
+ grid-auto-columns: 86cqw;
819
866
  gap: 10px;
867
+ padding-inline: 2px 14cqw;
868
+ scroll-padding-inline: 2px;
869
+ scroll-snap-type: inline mandatory;
870
+ scrollbar-width: none;
871
+ -webkit-overflow-scrolling: touch;
872
+ }
873
+
874
+ .columns::-webkit-scrollbar {
875
+ display: none;
876
+ }
877
+
878
+ .column {
879
+ scroll-snap-align: start;
880
+ scroll-snap-stop: always;
881
+ }
882
+ }
883
+
884
+ @container task-board-view (max-width: 720px) {
885
+ .boardHeader > .detailMeta {
886
+ overflow: hidden;
887
+ text-overflow: ellipsis;
888
+ white-space: nowrap;
820
889
  }
821
890
  }
822
891
 
823
892
  @container task-board-view (max-width: 600px) {
824
- .boardHeader {
893
+ .board {
894
+ padding-inline: 8px;
895
+ }
896
+ }
897
+
898
+ /* Viewport-specific mobile treatment: overlays occupy the dynamic viewport,
899
+ preserve notches/home indicators, and keep their action rows reachable. */
900
+ @media (max-width: 768px) {
901
+ [data-dsh-taskboard-view] {
902
+ height: 100vh;
903
+ height: 100dvh;
904
+ }
905
+
906
+ .entry,
907
+ .card,
908
+ .primaryButton,
909
+ .ghostButton,
910
+ .dangerButton,
911
+ .iconButton,
912
+ .linkButton,
913
+ .search,
914
+ .input,
915
+ .select,
916
+ .schedulePreset,
917
+ .scheduleToggle {
918
+ min-height: 44px;
919
+ }
920
+
921
+ .search,
922
+ .input,
923
+ .select,
924
+ .schedulePreset {
925
+ box-sizing: border-box;
926
+ font-size: 16px;
927
+ }
928
+
929
+ .modalBackdrop {
930
+ align-items: stretch;
931
+ justify-content: stretch;
932
+ width: 100vw;
933
+ height: 100vh;
934
+ height: 100dvh;
935
+ }
936
+
937
+ .modal,
938
+ .detail {
939
+ box-sizing: border-box;
940
+ width: 100vw;
941
+ height: 100vh;
942
+ height: 100dvh;
943
+ max-height: none;
944
+ border: 0;
945
+ border-radius: 0;
946
+ }
947
+
948
+ .modal {
949
+ padding-top: max(16px, env(safe-area-inset-top));
950
+ padding-right: max(16px, env(safe-area-inset-right));
951
+ padding-bottom: max(16px, env(safe-area-inset-bottom));
952
+ padding-left: max(16px, env(safe-area-inset-left));
953
+ }
954
+
955
+ .modalFooter {
956
+ position: sticky;
957
+ bottom: 0;
958
+ z-index: 1;
825
959
  flex-wrap: wrap;
826
- gap: 8px;
960
+ padding-top: 8px;
961
+ background: var(--dsw-alias-bg-base);
827
962
  }
828
963
 
829
- .search {
830
- flex: 1 1 calc(100% - 72px);
964
+ .modalFooter > button {
965
+ flex: 1 1 120px;
966
+ }
967
+
968
+ .detailHeader {
969
+ flex-wrap: wrap;
970
+ padding-top: max(12px, env(safe-area-inset-top));
971
+ padding-right: max(14px, env(safe-area-inset-right));
972
+ padding-left: max(14px, env(safe-area-inset-left));
973
+ }
974
+
975
+ .detailTitle {
831
976
  min-width: 0;
832
977
  }
833
978
 
834
- .boardHeader > button {
835
- flex: 1 1 0;
836
- min-width: max-content;
979
+ .detailBody {
980
+ overscroll-behavior-y: contain;
981
+ padding-right: max(14px, env(safe-area-inset-right));
982
+ padding-left: max(14px, env(safe-area-inset-left));
983
+ }
984
+
985
+ .detailFooter {
986
+ flex-wrap: wrap;
987
+ padding-right: max(14px, env(safe-area-inset-right));
988
+ padding-bottom: max(12px, env(safe-area-inset-bottom));
989
+ padding-left: max(14px, env(safe-area-inset-left));
990
+ }
991
+
992
+ .detailFooter > button {
993
+ flex: 1 1 96px;
994
+ }
995
+
996
+ .detailFooter > .detailMeta {
997
+ flex: 1 0 100%;
998
+ margin-left: 0;
999
+ text-align: end;
1000
+ }
1001
+
1002
+ .scheduleRow {
1003
+ align-items: stretch;
1004
+ flex-direction: column;
1005
+ }
1006
+
1007
+ .schedulePreset {
1008
+ width: 100%;
837
1009
  }
838
1010
  }
839
1011
 
@@ -84,7 +84,13 @@ export function apply(ctx: ClientContext): void {
84
84
  // page instead of being silently dropped.
85
85
  ctx.effect(() => releaseTaskboardApply, 'task-board: apply claim')
86
86
 
87
- ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'task-board: dictionaries')
87
+ ctx.effect(() => {
88
+ try {
89
+ return ctx.locale.register(NS, { zh, en })
90
+ } catch {
91
+ return () => {}
92
+ }
93
+ }, 'task-board: dictionaries')
88
94
 
89
95
  // Plugin configuration card: one staged form over the `task-board` settings
90
96
  // namespace, contributed to the Web UI plugin group.
@@ -92,16 +98,20 @@ export function apply(ctx: ClientContext): void {
92
98
  const settingsScope = binder.bind<TaskBoardSettings>({ namespace: TASK_BOARD_NS })
93
99
  const settingsCard = new TaskBoardSettingsCardController(settingsScope)
94
100
  ctx.slots.inject('web-ui.plugin.item', () => {
95
- const unregister = ctx.slots.register({
96
- name: 'web-ui.plugin.item',
97
- id: 'task-board',
98
- order: 110,
99
- locale: NS,
100
- inject: () => settingsCard.inject(),
101
- }, TaskBoardSettingsCard)
102
- return () => {
103
- settingsCard.dispose()
104
- unregister()
101
+ try {
102
+ const unregister = ctx.slots.register({
103
+ name: 'web-ui.plugin.item',
104
+ id: 'task-board',
105
+ order: 110,
106
+ locale: NS,
107
+ inject: () => settingsCard.inject(),
108
+ }, TaskBoardSettingsCard)
109
+ return () => {
110
+ settingsCard.dispose()
111
+ unregister()
112
+ }
113
+ } catch {
114
+ return () => {}
105
115
  }
106
116
  })
107
117
 
@@ -19,8 +19,8 @@ import { mountSidebarEntry as mountSharedSidebarEntry } from './sidebar-entry-co
19
19
  /** Stable data attribute identifying the injected entry row. */
20
20
  export const ENTRY_SELECTOR = '[data-dsh-taskboard-entry]'
21
21
 
22
- /** Inline icon (matches the shell's 16px nav-icon look). */
23
- const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M2 6.5h12M6.5 6.5v7"/></svg>'
22
+ /** Inline icon normalized to the shell's 18px navigation glyph size. */
23
+ const ICON = '<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M2 6.5h12M6.5 6.5v7"/></svg>'
24
24
 
25
25
  /**
26
26
  * Mount the sidebar entry, waiting for the shell to render and self-healing