@1agh/maude 0.60.6 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/acp/index.ts +1 -0
- package/apps/studio/ai-banner.tsx +1 -0
- package/apps/studio/annotations-context-toolbar.tsx +3 -1
- package/apps/studio/annotations-layer.tsx +33 -16
- package/apps/studio/api.ts +108 -18
- package/apps/studio/artboard-guides-overlay.tsx +5 -1
- package/apps/studio/assets-s3.ts +6 -1
- package/apps/studio/bin/_import-figma.mjs +8 -3
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-artifacts.ts +21 -0
- package/apps/studio/canvas-build.ts +14 -9
- package/apps/studio/canvas-comment-mount.tsx +27 -30
- package/apps/studio/canvas-icons.tsx +1 -1
- package/apps/studio/canvas-lib.tsx +94 -5
- package/apps/studio/canvas-list-watch.ts +14 -1
- package/apps/studio/canvas-shell.tsx +8 -0
- package/apps/studio/client/app.jsx +224 -45
- package/apps/studio/client/panels/GitPanel.jsx +121 -13
- package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
- package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
- package/apps/studio/client/panels/SyncPanel.jsx +595 -1
- package/apps/studio/client/styles/3-shell-maude.css +38 -0
- package/apps/studio/clip-ops.ts +8 -1
- package/apps/studio/cloud/endpoints.ts +265 -3
- package/apps/studio/collab/origins.ts +3 -1
- package/apps/studio/comments-overlay.tsx +5 -0
- package/apps/studio/config.schema.json +24 -0
- package/apps/studio/context-menu.tsx +40 -27
- package/apps/studio/context.ts +57 -8
- package/apps/studio/cursors-overlay.tsx +25 -13
- package/apps/studio/dist/client.bundle.js +686 -686
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/jobs.ts +77 -15
- package/apps/studio/exporters/remote.ts +190 -0
- package/apps/studio/exporters/video-encode-lib.ts +10 -4
- package/apps/studio/figma/to-strokes.ts +11 -9
- package/apps/studio/gifenc.d.ts +51 -0
- package/apps/studio/git/log-format.ts +88 -0
- package/apps/studio/git/safe-rel.ts +96 -0
- package/apps/studio/git/service.ts +46 -27
- package/apps/studio/hmr-broadcast.ts +10 -0
- package/apps/studio/http.ts +384 -6
- package/apps/studio/participants-chrome.tsx +1 -0
- package/apps/studio/photo-store.ts +7 -0
- package/apps/studio/react-augment.d.ts +17 -0
- package/apps/studio/runtime-bundle.ts +6 -1
- package/apps/studio/server.ts +43 -8
- package/apps/studio/sync/agent.ts +70 -82
- package/apps/studio/sync/asset-push.ts +28 -6
- package/apps/studio/sync/autocommit.ts +106 -5
- package/apps/studio/sync/cell-file-events.ts +117 -0
- package/apps/studio/sync/cell-pairing.ts +20 -5
- package/apps/studio/sync/cell-write-nudge.ts +244 -0
- package/apps/studio/sync/codec.ts +155 -3
- package/apps/studio/sync/cold-start-apply.ts +211 -0
- package/apps/studio/sync/ctl-heal.ts +253 -0
- package/apps/studio/sync/ctl-provider.ts +217 -0
- package/apps/studio/sync/decide-file.ts +335 -0
- package/apps/studio/sync/file-ledger.ts +581 -0
- package/apps/studio/sync/file-membership.ts +32 -0
- package/apps/studio/sync/file-plane.ts +1400 -0
- package/apps/studio/sync/file-pull.ts +41 -4
- package/apps/studio/sync/hub-link.ts +16 -1
- package/apps/studio/sync/hub-listing.ts +46 -0
- package/apps/studio/sync/hubs-config.ts +16 -0
- package/apps/studio/sync/index.ts +919 -214
- package/apps/studio/sync/journal-client.ts +200 -0
- package/apps/studio/sync/migrate-seed.ts +99 -67
- package/apps/studio/sync/poke.ts +50 -0
- package/apps/studio/sync/projection.ts +13 -0
- package/apps/studio/sync/pull-budget.ts +86 -0
- package/apps/studio/sync/settings.ts +110 -0
- package/apps/studio/sync/status.ts +68 -0
- package/apps/studio/sync/trash.ts +243 -0
- package/apps/studio/sync/untrusted.ts +30 -10
- package/apps/studio/test/_helpers.ts +8 -0
- package/apps/studio/test/canvas-build.test.ts +63 -0
- package/apps/studio/test/canvas-list-watch.test.ts +17 -0
- package/apps/studio/test/canvas-move-api.test.ts +31 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
- package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
- package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
- package/apps/studio/test/cloud-history-posture.test.ts +230 -0
- package/apps/studio/test/cloud-session-role.test.ts +30 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
- package/apps/studio/test/cold-start-apply.test.ts +303 -0
- package/apps/studio/test/collab-stress.test.ts +9 -1
- package/apps/studio/test/export-lane.test.ts +245 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
- package/apps/studio/test/git-log-format.test.ts +95 -0
- package/apps/studio/test/git-safe-rel.test.ts +132 -0
- package/apps/studio/test/hmr-broadcast.test.ts +26 -0
- package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
- package/apps/studio/test/sync-agent.test.ts +78 -0
- package/apps/studio/test/sync-asset-push.test.ts +90 -16
- package/apps/studio/test/sync-autocommit.test.ts +80 -0
- package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
- package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
- package/apps/studio/test/sync-decide-file.test.ts +420 -0
- package/apps/studio/test/sync-file-ledger.test.ts +334 -0
- package/apps/studio/test/sync-file-membership.test.ts +17 -1
- package/apps/studio/test/sync-file-plane.test.ts +976 -0
- package/apps/studio/test/sync-hub-listing.test.ts +46 -0
- package/apps/studio/test/sync-meta-codec.test.ts +76 -0
- package/apps/studio/test/sync-move-retirement.test.ts +231 -0
- package/apps/studio/test/sync-panel-surface.test.ts +20 -0
- package/apps/studio/test/sync-path-pull.test.ts +67 -1
- package/apps/studio/test/sync-pull-budget.test.ts +169 -0
- package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
- package/apps/studio/test/sync-settings-routes.test.ts +195 -0
- package/apps/studio/test/sync-settings.test.ts +151 -0
- package/apps/studio/test/sync-status.test.ts +69 -0
- package/apps/studio/test/sync-trash.test.ts +132 -0
- package/apps/studio/test/workspace-containment.test.ts +45 -9
- package/apps/studio/tsconfig.json +9 -10
- package/apps/studio/use-annotation-resize.tsx +14 -3
- package/apps/studio/use-collab.tsx +3 -1
- package/apps/studio/whats-new.json +99 -0
- package/apps/studio/workspace-mode.ts +110 -62
- package/apps/studio/ws.ts +22 -1
- package/cli/bin/claude-design-server.mjs +19 -0
- package/cli/commands/design.mjs +25 -6
- package/cli/commands/hub-workspace.mjs +243 -22
- package/cli/commands/hub-workspace.test.mjs +171 -0
- package/cli/commands/hub.mjs +71 -1
- package/cli/lib/design-link.mjs +186 -1
- package/cli/lib/design-ownership.mjs +330 -0
- package/cli/lib/design-ownership.test.mjs +329 -0
- package/cli/lib/hubs-config.mjs +21 -0
- package/cli/lib/hubs-config.test.mjs +47 -1
- package/cli/lib/workspace-plan.mjs +298 -5
- package/cli/lib/workspace-plan.test.mjs +215 -1
- package/package.json +10 -10
- package/plugins/design/templates/_shell.html +43 -2
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
- package/apps/studio/server.mjs +0 -1312
- package/apps/studio/sync/asset-pull.ts +0 -210
- package/apps/studio/sync/asset-push-worker.ts +0 -84
- package/apps/studio/sync/asset-sweep.ts +0 -262
- package/apps/studio/test/sync-asset-pull.test.ts +0 -161
- package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
- package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
|
@@ -98,6 +98,123 @@ function readFiles(raw) {
|
|
|
98
98
|
return raw;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/** Consent-class notices (feature-before-first-external-users Task 1), or []
|
|
102
|
+
* when the shape can't be trusted. Absent is the norm — the payload carries
|
|
103
|
+
* `notices` only when a boot raised one (shared-doc ON, TSX bodies). */
|
|
104
|
+
function readNotices(raw) {
|
|
105
|
+
if (!Array.isArray(raw)) return [];
|
|
106
|
+
return raw.filter((n) => n && typeof n.id === 'string' && typeof n.text === 'string');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** DeliveryState → plain words for the doručenka rows (file-ledger.ts owns the
|
|
110
|
+
* union; unknown states render verbatim so a NEWER producer stays readable). */
|
|
111
|
+
const DELIVERY_WORD = {
|
|
112
|
+
conflict: 'conflict — older copy in Trash below',
|
|
113
|
+
stuck: 'stuck',
|
|
114
|
+
'referenced-but-unoffered': 'referenced, never received',
|
|
115
|
+
'local-only': 'only on this machine',
|
|
116
|
+
pushing: 'uploading…',
|
|
117
|
+
'on-hub': 'on the workspace',
|
|
118
|
+
durable: 'backed up',
|
|
119
|
+
'at-peer': 'reached a teammate',
|
|
120
|
+
'ui-healed': 'healed',
|
|
121
|
+
everywhere: 'everywhere',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** States where a person has to look — always-visible rows; the rest folds. */
|
|
125
|
+
const DELIVERY_ATTENTION = new Set(['conflict', 'stuck', 'referenced-but-unoffered', 'local-only']);
|
|
126
|
+
|
|
127
|
+
/** The per-file doručenka map split into attention/fine [rel, state] lists,
|
|
128
|
+
* fail-closed like every other reader here. */
|
|
129
|
+
function readDelivery(raw) {
|
|
130
|
+
const attention = [];
|
|
131
|
+
const fine = [];
|
|
132
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
133
|
+
for (const [rel, state] of Object.entries(raw)) {
|
|
134
|
+
if (typeof rel !== 'string' || typeof state !== 'string') continue;
|
|
135
|
+
(DELIVERY_ATTENTION.has(state) ? attention : fine).push([rel, state]);
|
|
136
|
+
}
|
|
137
|
+
attention.sort((a, b) => a[0].localeCompare(b[0]));
|
|
138
|
+
fine.sort((a, b) => a[0].localeCompare(b[0]));
|
|
139
|
+
}
|
|
140
|
+
return { attention, fine };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** TrashEntry.reason → plain words. */
|
|
144
|
+
const TRASH_REASON_WORD = {
|
|
145
|
+
conflict: 'conflict loser',
|
|
146
|
+
removed: 'removed by sync',
|
|
147
|
+
moved: 'moved elsewhere',
|
|
148
|
+
deleted: 'deleted',
|
|
149
|
+
migration: 'migration backup',
|
|
150
|
+
unknown: 'parked',
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
function fmtBytes(n) {
|
|
154
|
+
if (!Number.isFinite(n) || n < 0) return '0 B';
|
|
155
|
+
if (n < 1024) return `${n} B`;
|
|
156
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} kB`;
|
|
157
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function fmtAge(at) {
|
|
161
|
+
if (!Number.isFinite(at) || at <= 0) return 'unknown age';
|
|
162
|
+
const days = Math.floor((Date.now() - at) / (24 * 60 * 60 * 1000));
|
|
163
|
+
if (days <= 0) return 'today';
|
|
164
|
+
if (days === 1) return 'yesterday';
|
|
165
|
+
if (days < 30) return `${days} d ago`;
|
|
166
|
+
const months = Math.floor(days / 30);
|
|
167
|
+
return `${months} mo ago`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Display host of a hub url ('workspace' when unparseable). */
|
|
171
|
+
function hostOf(url) {
|
|
172
|
+
try {
|
|
173
|
+
return new URL(url).host;
|
|
174
|
+
} catch {
|
|
175
|
+
return 'workspace';
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** safeDetail's sanitation with a paragraph-sized cap — a consent notice is a
|
|
180
|
+
* full explanation by design and `MAX_DETAIL_LEN`'s 160 chars would cut it
|
|
181
|
+
* mid-sentence. Still bounded: the payload is read back off `_sync.json`. */
|
|
182
|
+
function safeNoticeText(raw) {
|
|
183
|
+
const s = String(raw ?? '')
|
|
184
|
+
.replace(/[\p{Cc}\p{Cf}]/gu, '')
|
|
185
|
+
.replace(/\s+/g, ' ')
|
|
186
|
+
.trim();
|
|
187
|
+
return s.length > 600 ? `${s.slice(0, 600)}…` : s;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Machine-local dismiss ack, keyed per (hub url) → list of dismissed notice
|
|
192
|
+
* ids — the `mdcc-whatsnew-seen` convention. Per-hub on purpose: dismissing
|
|
193
|
+
* "shared-doc is ON for hub A" must not silence the same notice when the
|
|
194
|
+
* project is re-linked to hub B; what leaves this machine changed again.
|
|
195
|
+
*/
|
|
196
|
+
const NOTICE_ACK_KEY = 'maude-sync-notice-ack';
|
|
197
|
+
function readNoticeAcks(url) {
|
|
198
|
+
try {
|
|
199
|
+
const all = JSON.parse(localStorage.getItem(NOTICE_ACK_KEY) || '{}');
|
|
200
|
+
const ids = all[url];
|
|
201
|
+
return Array.isArray(ids) ? ids.filter((i) => typeof i === 'string') : [];
|
|
202
|
+
} catch {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function writeNoticeAck(url, id) {
|
|
207
|
+
try {
|
|
208
|
+
const all = JSON.parse(localStorage.getItem(NOTICE_ACK_KEY) || '{}');
|
|
209
|
+
const ids = Array.isArray(all[url]) ? all[url] : [];
|
|
210
|
+
if (!ids.includes(id)) ids.push(id);
|
|
211
|
+
all[url] = ids;
|
|
212
|
+
localStorage.setItem(NOTICE_ACK_KEY, JSON.stringify(all));
|
|
213
|
+
} catch {
|
|
214
|
+
/* private mode etc. — the notice simply reappears next boot */
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
101
218
|
export default function SyncPanel({
|
|
102
219
|
status, // the live `sync:status` payload (never null while mounted)
|
|
103
220
|
project, // display name for the header sentence (hub-supplied → safeName'd)
|
|
@@ -161,6 +278,171 @@ export default function SyncPanel({
|
|
|
161
278
|
const assetFailures = assets?.failures || [];
|
|
162
279
|
const files = readFiles(status?.files);
|
|
163
280
|
|
|
281
|
+
const delivery = useMemo(() => readDelivery(status?.files?.delivery), [status?.files?.delivery]);
|
|
282
|
+
|
|
283
|
+
// Sync settings (Task 2) — fetched once on mount; `settings` stays null when
|
|
284
|
+
// no hub is linked, which is also the render gate for the whole section.
|
|
285
|
+
const [settings, setSettings] = useState(null);
|
|
286
|
+
const [settingsBusy, setSettingsBusy] = useState(false);
|
|
287
|
+
const [settingsNote, setSettingsNote] = useState('');
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
let gone = false;
|
|
290
|
+
fetch('/_api/sync/settings')
|
|
291
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
292
|
+
.then((j) => {
|
|
293
|
+
if (!gone && j && typeof j === 'object') setSettings(j.settings ?? null);
|
|
294
|
+
})
|
|
295
|
+
.catch(() => {
|
|
296
|
+
/* no server / old server — the section simply doesn't render */
|
|
297
|
+
});
|
|
298
|
+
return () => {
|
|
299
|
+
gone = true;
|
|
300
|
+
};
|
|
301
|
+
}, []);
|
|
302
|
+
const changeSetting = useCallback(async (patch) => {
|
|
303
|
+
setSettingsBusy(true);
|
|
304
|
+
setSettingsNote('');
|
|
305
|
+
let json = null;
|
|
306
|
+
try {
|
|
307
|
+
const res = await fetch('/_api/sync/settings', {
|
|
308
|
+
method: 'POST',
|
|
309
|
+
headers: { 'Content-Type': 'application/json' },
|
|
310
|
+
body: JSON.stringify(patch),
|
|
311
|
+
});
|
|
312
|
+
json = await res.json().catch(() => null);
|
|
313
|
+
} catch {
|
|
314
|
+
/* said below */
|
|
315
|
+
}
|
|
316
|
+
setSettingsBusy(false);
|
|
317
|
+
if (!json || json.ok !== true) {
|
|
318
|
+
setSettingsNote(safeDetail(json?.detail, 'The setting could not be saved.'));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
setSettings(json.settings);
|
|
322
|
+
// Saved is a fact; whether it took effect NOW depends on the supervisor —
|
|
323
|
+
// say which of the two happened rather than letting "saved" imply "live".
|
|
324
|
+
setSettingsNote(json.applied ? 'Saved — sync is restarting with the new setting.' : 'Saved — applies the next time sync restarts.');
|
|
325
|
+
}, []);
|
|
326
|
+
|
|
327
|
+
// Ownership (Task 2) — repo-owned vs hub-owned, previously CLI-only against
|
|
328
|
+
// DDR-177's own posture. Mutations go through an explicit two-step confirm:
|
|
329
|
+
// the confirm row IS the asking B11 found missing in non-TTY settleOwnership.
|
|
330
|
+
const [ownership, setOwnership] = useState(null);
|
|
331
|
+
const [ownershipConfirm, setOwnershipConfirm] = useState(null); // 'adopt' | 'detach' | null
|
|
332
|
+
const [ownershipBusy, setOwnershipBusy] = useState(false);
|
|
333
|
+
const [ownershipNote, setOwnershipNote] = useState('');
|
|
334
|
+
useEffect(() => {
|
|
335
|
+
let gone = false;
|
|
336
|
+
fetch('/_api/sync/ownership')
|
|
337
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
338
|
+
.then((j) => {
|
|
339
|
+
if (!gone && j && typeof j.mode === 'string') setOwnership(j);
|
|
340
|
+
})
|
|
341
|
+
.catch(() => {
|
|
342
|
+
/* old server — the section doesn't render */
|
|
343
|
+
});
|
|
344
|
+
return () => {
|
|
345
|
+
gone = true;
|
|
346
|
+
};
|
|
347
|
+
}, []);
|
|
348
|
+
const changeOwnership = useCallback(async (action) => {
|
|
349
|
+
setOwnershipBusy(true);
|
|
350
|
+
setOwnershipNote('');
|
|
351
|
+
let json = null;
|
|
352
|
+
try {
|
|
353
|
+
const res = await fetch('/_api/sync/ownership', {
|
|
354
|
+
method: 'POST',
|
|
355
|
+
headers: { 'Content-Type': 'application/json' },
|
|
356
|
+
body: JSON.stringify({ action }),
|
|
357
|
+
});
|
|
358
|
+
json = await res.json().catch(() => null);
|
|
359
|
+
} catch {
|
|
360
|
+
/* said below */
|
|
361
|
+
}
|
|
362
|
+
setOwnershipBusy(false);
|
|
363
|
+
setOwnershipConfirm(null);
|
|
364
|
+
if (!json || json.ok !== true) {
|
|
365
|
+
setOwnershipNote(safeDetail(json?.detail, 'The ownership change did not complete.'));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
setOwnershipNote(
|
|
369
|
+
action === 'adopt'
|
|
370
|
+
? `Done — .design/ is workspace-owned now. ${json.untracked ?? 0} file(s) untracked (still on disk, staged as deletions — commit when ready).`
|
|
371
|
+
: 'Done — unlinked and repo-owned again. Commit .design/ when you are ready.'
|
|
372
|
+
);
|
|
373
|
+
// Re-read rather than guess — the server is the source for mode.
|
|
374
|
+
try {
|
|
375
|
+
const r = await fetch('/_api/sync/ownership');
|
|
376
|
+
const j = r.ok ? await r.json() : null;
|
|
377
|
+
if (j && typeof j.mode === 'string') setOwnership(j);
|
|
378
|
+
} catch {
|
|
379
|
+
/* keep the stale mode; the note already said what happened */
|
|
380
|
+
}
|
|
381
|
+
}, []);
|
|
382
|
+
|
|
383
|
+
// Trash (Task 3, F-6) — quarantine-not-delete is only safe if a person can
|
|
384
|
+
// FIND the quarantine. Until this section, the product's copy pointed at a
|
|
385
|
+
// hidden gitignored folder.
|
|
386
|
+
const [trash, setTrash] = useState(null); // { entries, total, bytes } | null
|
|
387
|
+
const [trashBusy, setTrashBusy] = useState(false);
|
|
388
|
+
const [trashNote, setTrashNote] = useState('');
|
|
389
|
+
const [pruneConfirm, setPruneConfirm] = useState(false);
|
|
390
|
+
const refreshTrash = useCallback(async () => {
|
|
391
|
+
try {
|
|
392
|
+
const r = await fetch('/_api/sync/trash');
|
|
393
|
+
const j = r.ok ? await r.json() : null;
|
|
394
|
+
if (j && Array.isArray(j.entries)) setTrash(j);
|
|
395
|
+
} catch {
|
|
396
|
+
/* old server — the section doesn't render */
|
|
397
|
+
}
|
|
398
|
+
}, []);
|
|
399
|
+
useEffect(() => {
|
|
400
|
+
refreshTrash();
|
|
401
|
+
}, [refreshTrash]);
|
|
402
|
+
const trashAction = useCallback(
|
|
403
|
+
async (body, doneNote) => {
|
|
404
|
+
setTrashBusy(true);
|
|
405
|
+
setTrashNote('');
|
|
406
|
+
let json = null;
|
|
407
|
+
try {
|
|
408
|
+
const res = await fetch('/_api/sync/trash', {
|
|
409
|
+
method: 'POST',
|
|
410
|
+
headers: { 'Content-Type': 'application/json' },
|
|
411
|
+
body: JSON.stringify(body),
|
|
412
|
+
});
|
|
413
|
+
json = await res.json().catch(() => null);
|
|
414
|
+
} catch {
|
|
415
|
+
/* said below */
|
|
416
|
+
}
|
|
417
|
+
setTrashBusy(false);
|
|
418
|
+
setPruneConfirm(false);
|
|
419
|
+
if (!json || json.ok !== true) {
|
|
420
|
+
setTrashNote(safeDetail(json?.detail, 'That did not complete.'));
|
|
421
|
+
} else {
|
|
422
|
+
setTrashNote(doneNote(json));
|
|
423
|
+
}
|
|
424
|
+
refreshTrash();
|
|
425
|
+
},
|
|
426
|
+
[refreshTrash]
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
// Consent notices minus this machine's dismissals. `ackTick` only forces the
|
|
430
|
+
// re-read after a dismiss — the acks themselves live in localStorage.
|
|
431
|
+
const hubUrl = typeof status?.url === 'string' ? status.url : '';
|
|
432
|
+
const [ackTick, setAckTick] = useState(0);
|
|
433
|
+
const notices = useMemo(() => {
|
|
434
|
+
const acked = readNoticeAcks(hubUrl);
|
|
435
|
+
return readNotices(status?.notices).filter((n) => !acked.includes(n.id));
|
|
436
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- ackTick invalidates the localStorage read
|
|
437
|
+
}, [status?.notices, hubUrl, ackTick]);
|
|
438
|
+
const dismissNotice = useCallback(
|
|
439
|
+
(id) => {
|
|
440
|
+
writeNoticeAck(hubUrl, id);
|
|
441
|
+
setAckTick((t) => t + 1);
|
|
442
|
+
},
|
|
443
|
+
[hubUrl]
|
|
444
|
+
);
|
|
445
|
+
|
|
164
446
|
const { attention, byGroup } = useMemo(() => {
|
|
165
447
|
const attention = items.filter((i) => i.state === 'auth-rejected');
|
|
166
448
|
const rest = items.filter((i) => i.state !== 'auth-rejected');
|
|
@@ -249,6 +531,29 @@ export default function SyncPanel({
|
|
|
249
531
|
</div>
|
|
250
532
|
|
|
251
533
|
<div className="sp-body">
|
|
534
|
+
{/* Consent-class notices come FIRST: they say what leaves this machine
|
|
535
|
+
(shared-doc buffer, TSX bodies) and used to live only in a
|
|
536
|
+
console.warn a desktop user never sees. Dismiss is per (notice,
|
|
537
|
+
hub) on this machine — a new hub resurfaces them by design. */}
|
|
538
|
+
{notices.length > 0 && (
|
|
539
|
+
<section aria-label="Notices" data-testid="sync-notices">
|
|
540
|
+
{notices.map((n) => (
|
|
541
|
+
<div key={n.id} className="sp-notice" data-testid={`sync-notice-${n.id}`}>
|
|
542
|
+
<p className="sp-notice-text">{safeNoticeText(n.text)}</p>
|
|
543
|
+
<button
|
|
544
|
+
type="button"
|
|
545
|
+
className="sp-notice-dismiss"
|
|
546
|
+
data-testid={`sync-notice-dismiss-${n.id}`}
|
|
547
|
+
onClick={() => dismissNotice(n.id)}
|
|
548
|
+
title="Got it — don't show this again for this hub on this machine"
|
|
549
|
+
>
|
|
550
|
+
Got it
|
|
551
|
+
</button>
|
|
552
|
+
</div>
|
|
553
|
+
))}
|
|
554
|
+
</section>
|
|
555
|
+
)}
|
|
556
|
+
|
|
252
557
|
{items.length === 0 && !assets && (
|
|
253
558
|
<div className="gp-empty">
|
|
254
559
|
<p>No per-file detail yet — it arrives with the first sync report after this panel shipped. If this persists, restart Maude.</p>
|
|
@@ -351,7 +656,296 @@ export default function SyncPanel({
|
|
|
351
656
|
{files.conflicts > 0 && (
|
|
352
657
|
<div className="sp-assets-retry" data-testid="sync-files-conflicts">
|
|
353
658
|
{files.conflicts} conflict{files.conflicts === 1 ? '' : 's'} — the older copies are
|
|
354
|
-
kept in
|
|
659
|
+
kept in Trash, below.
|
|
660
|
+
</div>
|
|
661
|
+
)}
|
|
662
|
+
{/*
|
|
663
|
+
A HELD BREAKER, said out loud. These used to exist only as a
|
|
664
|
+
console.warn, which on a machine whose user never opens a
|
|
665
|
+
terminal is the same as not existing — while the release leaned
|
|
666
|
+
on them as its reason for shipping deletion without a soak.
|
|
667
|
+
*/}
|
|
668
|
+
{Array.isArray(files.held) &&
|
|
669
|
+
files.held.map((h) => (
|
|
670
|
+
<div key={h.kind} className="sp-assets-retry" data-testid={`sync-held-${h.kind}`}>
|
|
671
|
+
<strong>sync paused — nothing was removed.</strong> {h.detail}
|
|
672
|
+
{h.paths?.length > 0 && (
|
|
673
|
+
<details className="sp-held-paths">
|
|
674
|
+
<summary>
|
|
675
|
+
{h.count} file{h.count === 1 ? '' : 's'}
|
|
676
|
+
</summary>
|
|
677
|
+
<ul>
|
|
678
|
+
{h.paths.slice(0, 50).map((rel) => (
|
|
679
|
+
<li key={rel}>{rel}</li>
|
|
680
|
+
))}
|
|
681
|
+
{h.paths.length > 50 && <li>…and {h.paths.length - 50} more</li>}
|
|
682
|
+
</ul>
|
|
683
|
+
</details>
|
|
684
|
+
)}
|
|
685
|
+
</div>
|
|
686
|
+
))}
|
|
687
|
+
{/* THE DORUČENKA, per file (DDR-226 §7). The counts above say how
|
|
688
|
+
many are fine; they cannot point at the one that is not — which
|
|
689
|
+
is the question three days of dogfood kept asking. Rows needing
|
|
690
|
+
a person come first and are always visible; the delivered rest
|
|
691
|
+
stays behind a fold so a healthy project reads as one line. */}
|
|
692
|
+
{delivery.attention.length > 0 && (
|
|
693
|
+
<ul className="sp-list" data-testid="sync-delivery-attention">
|
|
694
|
+
{delivery.attention.slice(0, 50).map(([rel, state]) => (
|
|
695
|
+
<li className="sp-row is-auth-rejected" key={rel}>
|
|
696
|
+
<span className="sp-row-dot" aria-hidden="true" />
|
|
697
|
+
<span className="sp-row-name" title={safeName(rel, '(unnamed)')}>
|
|
698
|
+
{safeName(rel, '(unnamed)')}
|
|
699
|
+
</span>
|
|
700
|
+
<span className="sp-row-state">{DELIVERY_WORD[state] || state}</span>
|
|
701
|
+
</li>
|
|
702
|
+
))}
|
|
703
|
+
{delivery.attention.length > 50 && (
|
|
704
|
+
<li className="sp-truncated">…and {delivery.attention.length - 50} more</li>
|
|
705
|
+
)}
|
|
706
|
+
</ul>
|
|
707
|
+
)}
|
|
708
|
+
{delivery.fine.length > 0 && (
|
|
709
|
+
<details className="sp-held-paths" data-testid="sync-delivery-fine">
|
|
710
|
+
<summary>
|
|
711
|
+
{delivery.fine.length} file{delivery.fine.length === 1 ? '' : 's'} delivered
|
|
712
|
+
</summary>
|
|
713
|
+
<ul>
|
|
714
|
+
{delivery.fine.slice(0, 200).map(([rel, state]) => (
|
|
715
|
+
<li key={rel}>
|
|
716
|
+
{safeName(rel, '(unnamed)')} — {DELIVERY_WORD[state] || state}
|
|
717
|
+
</li>
|
|
718
|
+
))}
|
|
719
|
+
{delivery.fine.length > 200 && <li>…and {delivery.fine.length - 200} more</li>}
|
|
720
|
+
</ul>
|
|
721
|
+
</details>
|
|
722
|
+
)}
|
|
723
|
+
</section>
|
|
724
|
+
)}
|
|
725
|
+
|
|
726
|
+
{/* Trash (Task 3, F-6) — everything sync parked instead of deleting,
|
|
727
|
+
with a way back. Rendered only when something is actually parked. */}
|
|
728
|
+
{trash && trash.total > 0 && (
|
|
729
|
+
<section aria-label="Trash" data-testid="sync-trash">
|
|
730
|
+
<div className="gp-sect-label">
|
|
731
|
+
trash <span className="gp-group-count">{trash.total}</span>
|
|
732
|
+
</div>
|
|
733
|
+
<div className="sp-assets-line">
|
|
734
|
+
{trash.total} file{trash.total === 1 ? '' : 's'} kept instead of deleted (
|
|
735
|
+
{fmtBytes(trash.bytes)}) — replaced copies, conflict losers and remote deletions
|
|
736
|
+
land here, in .design/_trash/.
|
|
737
|
+
</div>
|
|
738
|
+
<ul className="sp-list" data-testid="sync-trash-list">
|
|
739
|
+
{trash.entries.slice(0, 50).map((e) => (
|
|
740
|
+
<li className="sp-row" key={e.trashRel}>
|
|
741
|
+
<span className="sp-row-name" title={safeName(e.trashRel, '(unnamed)')}>
|
|
742
|
+
{safeName(e.sourceRel ?? e.trashRel.replace(/^_trash\//, ''), '(unnamed)')}
|
|
743
|
+
</span>
|
|
744
|
+
<span className="sp-row-state">
|
|
745
|
+
{TRASH_REASON_WORD[e.reason] || e.reason} · {fmtAge(e.at)}
|
|
746
|
+
</span>
|
|
747
|
+
{e.sourceRel && (
|
|
748
|
+
<button
|
|
749
|
+
type="button"
|
|
750
|
+
className="sp-assets-cancel"
|
|
751
|
+
data-testid={`sync-trash-restore-${e.sourceRel}`}
|
|
752
|
+
disabled={trashBusy}
|
|
753
|
+
onClick={() =>
|
|
754
|
+
trashAction({ action: 'restore', trashRel: e.trashRel }, (j) =>
|
|
755
|
+
j.restoredTo ? `Restored to ${j.restoredTo}.` : 'Restored.'
|
|
756
|
+
)
|
|
757
|
+
}
|
|
758
|
+
>
|
|
759
|
+
Restore
|
|
760
|
+
</button>
|
|
761
|
+
)}
|
|
762
|
+
</li>
|
|
763
|
+
))}
|
|
764
|
+
{trash.total > 50 && (
|
|
765
|
+
<li className="sp-truncated">…and {trash.total - 50} more in .design/_trash/</li>
|
|
766
|
+
)}
|
|
767
|
+
</ul>
|
|
768
|
+
{!pruneConfirm ? (
|
|
769
|
+
<div className="sp-assets-line">
|
|
770
|
+
<button
|
|
771
|
+
type="button"
|
|
772
|
+
className="sp-assets-cancel"
|
|
773
|
+
data-testid="sync-trash-prune"
|
|
774
|
+
disabled={trashBusy}
|
|
775
|
+
onClick={() => setPruneConfirm(true)}
|
|
776
|
+
>
|
|
777
|
+
Remove copies older than 30 days…
|
|
778
|
+
</button>
|
|
779
|
+
</div>
|
|
780
|
+
) : (
|
|
781
|
+
<div className="sp-assets-retry" data-testid="sync-trash-prune-confirm">
|
|
782
|
+
Permanently delete every parked copy older than 30 days? This cannot be undone.{' '}
|
|
783
|
+
<button
|
|
784
|
+
type="button"
|
|
785
|
+
className="sp-assets-cancel"
|
|
786
|
+
data-testid="sync-trash-prune-yes"
|
|
787
|
+
disabled={trashBusy}
|
|
788
|
+
onClick={() =>
|
|
789
|
+
trashAction(
|
|
790
|
+
{ action: 'prune', olderThanDays: 30 },
|
|
791
|
+
(j) => `Removed ${j.pruned} file${j.pruned === 1 ? '' : 's'} (${fmtBytes(j.bytes)}); ${j.kept} kept.`
|
|
792
|
+
)
|
|
793
|
+
}
|
|
794
|
+
>
|
|
795
|
+
{trashBusy ? 'Removing…' : 'Delete them'}
|
|
796
|
+
</button>{' '}
|
|
797
|
+
<button
|
|
798
|
+
type="button"
|
|
799
|
+
className="sp-assets-cancel"
|
|
800
|
+
disabled={trashBusy}
|
|
801
|
+
onClick={() => setPruneConfirm(false)}
|
|
802
|
+
>
|
|
803
|
+
Cancel
|
|
804
|
+
</button>
|
|
805
|
+
</div>
|
|
806
|
+
)}
|
|
807
|
+
{trashNote && (
|
|
808
|
+
<div className="sp-resync-note" role="status" aria-live="polite">
|
|
809
|
+
{trashNote}
|
|
810
|
+
</div>
|
|
811
|
+
)}
|
|
812
|
+
</section>
|
|
813
|
+
)}
|
|
814
|
+
|
|
815
|
+
{/* Settings (feature-before-first-external-users Task 2) — the three
|
|
816
|
+
toggles every breaker remediation used to hand the user as "edit
|
|
817
|
+
linkedHub.* JSON". Rendered only when a hub is linked (the route
|
|
818
|
+
answers settings: null otherwise — dead controls teach nothing). */}
|
|
819
|
+
{settings && (
|
|
820
|
+
<section aria-label="Sync settings" data-testid="sync-settings">
|
|
821
|
+
<div className="gp-sect-label">settings</div>
|
|
822
|
+
<label className="sp-setting" data-testid="sync-setting-syncFiles">
|
|
823
|
+
<input
|
|
824
|
+
type="checkbox"
|
|
825
|
+
checked={settings.syncFiles}
|
|
826
|
+
disabled={settingsBusy}
|
|
827
|
+
onChange={(e) => changeSetting({ syncFiles: e.target.checked })}
|
|
828
|
+
/>
|
|
829
|
+
<span>
|
|
830
|
+
Sync project files
|
|
831
|
+
<small>The whole design folder mirrors both ways, not just canvases.</small>
|
|
832
|
+
</span>
|
|
833
|
+
</label>
|
|
834
|
+
<label className="sp-setting" data-testid="sync-setting-propagateDeletes">
|
|
835
|
+
<input
|
|
836
|
+
type="checkbox"
|
|
837
|
+
checked={settings.propagateDeletes}
|
|
838
|
+
disabled={settingsBusy}
|
|
839
|
+
onChange={(e) => changeSetting({ propagateDeletes: e.target.checked })}
|
|
840
|
+
/>
|
|
841
|
+
<span>
|
|
842
|
+
Propagate deletions
|
|
843
|
+
<small>
|
|
844
|
+
Removing a file here removes it everywhere; replaced copies are kept in Trash.
|
|
845
|
+
</small>
|
|
846
|
+
</span>
|
|
847
|
+
</label>
|
|
848
|
+
<label className="sp-setting sp-setting-select" data-testid="sync-setting-firstAnchor">
|
|
849
|
+
<span>
|
|
850
|
+
First-link conflicts
|
|
851
|
+
<small>When both sides have content the first time a project links.</small>
|
|
852
|
+
</span>
|
|
853
|
+
<select
|
|
854
|
+
value={settings.resolveFirstAnchor}
|
|
855
|
+
disabled={settingsBusy}
|
|
856
|
+
onChange={(e) => changeSetting({ resolveFirstAnchor: e.target.value })}
|
|
857
|
+
>
|
|
858
|
+
<option value="ask">Keep asking</option>
|
|
859
|
+
<option value="keep-local">Keep this machine's</option>
|
|
860
|
+
<option value="keep-cloud">Keep the workspace's</option>
|
|
861
|
+
</select>
|
|
862
|
+
</label>
|
|
863
|
+
{settingsNote && (
|
|
864
|
+
<div className="sp-resync-note" role="status" aria-live="polite">
|
|
865
|
+
{settingsNote}
|
|
866
|
+
</div>
|
|
867
|
+
)}
|
|
868
|
+
</section>
|
|
869
|
+
)}
|
|
870
|
+
|
|
871
|
+
{/* Ownership (Task 2) — who owns .design/: this repo's git, or the
|
|
872
|
+
linked workspace. CLI parity for `maude design adopt` / `detach`,
|
|
873
|
+
behind an explicit confirm. Hidden on a cell (a member's browser
|
|
874
|
+
must not re-own the operator's checkout) and without git. */}
|
|
875
|
+
{ownership && !cloud && ownership.git && (
|
|
876
|
+
<section aria-label="Ownership" data-testid="sync-ownership">
|
|
877
|
+
<div className="gp-sect-label">ownership</div>
|
|
878
|
+
<div className="sp-assets-line" data-testid="sync-ownership-mode">
|
|
879
|
+
{ownership.mode === 'hub-owned' &&
|
|
880
|
+
'Workspace-owned — .design/ is gitignored and mirrored by the workspace.'}
|
|
881
|
+
{ownership.mode === 'repo-owned' &&
|
|
882
|
+
'Repo-owned — .design/ is committed with this repository.'}
|
|
883
|
+
{ownership.mode === 'hybrid' &&
|
|
884
|
+
`Two owners — git commits .design/ (${ownership.trackedCount} file${ownership.trackedCount === 1 ? '' : 's'}) AND a workspace mirrors it. A git pull and a sync pass can each undo the other.`}
|
|
885
|
+
</div>
|
|
886
|
+
{ownershipConfirm === null && ownership.linked && ownership.mode !== 'hub-owned' && (
|
|
887
|
+
<div className="sp-assets-line">
|
|
888
|
+
<button
|
|
889
|
+
type="button"
|
|
890
|
+
className="sp-assets-cancel"
|
|
891
|
+
data-testid="sync-ownership-adopt"
|
|
892
|
+
disabled={ownershipBusy}
|
|
893
|
+
onClick={() => setOwnershipConfirm('adopt')}
|
|
894
|
+
>
|
|
895
|
+
Hand .design/ to the workspace…
|
|
896
|
+
</button>
|
|
897
|
+
</div>
|
|
898
|
+
)}
|
|
899
|
+
{ownershipConfirm === null && ownership.mode === 'hub-owned' && (
|
|
900
|
+
<div className="sp-assets-line">
|
|
901
|
+
<button
|
|
902
|
+
type="button"
|
|
903
|
+
className="sp-assets-cancel"
|
|
904
|
+
data-testid="sync-ownership-detach"
|
|
905
|
+
disabled={ownershipBusy}
|
|
906
|
+
onClick={() => setOwnershipConfirm('detach')}
|
|
907
|
+
>
|
|
908
|
+
Take .design/ back into this repo…
|
|
909
|
+
</button>
|
|
910
|
+
</div>
|
|
911
|
+
)}
|
|
912
|
+
{ownershipConfirm && (
|
|
913
|
+
<div className="sp-assets-retry" data-testid="sync-ownership-confirm">
|
|
914
|
+
{ownershipConfirm === 'adopt' ? (
|
|
915
|
+
<>
|
|
916
|
+
Stop committing .design/ and let the workspace mirror it. Nothing is deleted —
|
|
917
|
+
every file stays on disk; git stops tracking them (staged as deletions, commit
|
|
918
|
+
when ready).
|
|
919
|
+
</>
|
|
920
|
+
) : (
|
|
921
|
+
<>
|
|
922
|
+
Disconnect from the workspace ({hostOf(ownership.hubUrl)}) and take .design/
|
|
923
|
+
back into git. Every file is already on disk in full; commit the folder when
|
|
924
|
+
you are ready.
|
|
925
|
+
</>
|
|
926
|
+
)}{' '}
|
|
927
|
+
<button
|
|
928
|
+
type="button"
|
|
929
|
+
className="sp-assets-cancel"
|
|
930
|
+
data-testid="sync-ownership-confirm-yes"
|
|
931
|
+
disabled={ownershipBusy}
|
|
932
|
+
onClick={() => changeOwnership(ownershipConfirm)}
|
|
933
|
+
>
|
|
934
|
+
{ownershipBusy ? 'Working…' : 'Do it'}
|
|
935
|
+
</button>{' '}
|
|
936
|
+
<button
|
|
937
|
+
type="button"
|
|
938
|
+
className="sp-assets-cancel"
|
|
939
|
+
disabled={ownershipBusy}
|
|
940
|
+
onClick={() => setOwnershipConfirm(null)}
|
|
941
|
+
>
|
|
942
|
+
Cancel
|
|
943
|
+
</button>
|
|
944
|
+
</div>
|
|
945
|
+
)}
|
|
946
|
+
{ownershipNote && (
|
|
947
|
+
<div className="sp-resync-note" role="status" aria-live="polite">
|
|
948
|
+
{ownershipNote}
|
|
355
949
|
</div>
|
|
356
950
|
)}
|
|
357
951
|
</section>
|
|
@@ -614,6 +614,10 @@
|
|
|
614
614
|
.st-fmt { display: flex; flex-direction: column; gap: var(--space-2); align-items: flex-start; padding: var(--space-3) var(--space-4); border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-2); cursor: pointer; color: var(--fg-1); appearance: none; text-align: left; }
|
|
615
615
|
.st-fmt:hover { border-color: var(--border-strong); }
|
|
616
616
|
.st-fmt.is-on { border-color: var(--accent); background: var(--accent-tint); color: var(--fg-0); box-shadow: 0 0 0 2px var(--accent-tint); }
|
|
617
|
+
.st-fmt:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
618
|
+
.st-fmt:disabled:hover { border-color: var(--border-default); }
|
|
619
|
+
/* feature-cloud-export-render-workers — the "why is this disabled" line under the format grid. */
|
|
620
|
+
.st-dialog-note { margin-top: var(--space-3); font-size: var(--type-xs); line-height: 1.5; color: var(--fg-2); background: var(--bg-2); border: 1px solid var(--border-default); border-radius: var(--radius-md); padding: var(--space-2) var(--space-3); }
|
|
617
621
|
.st-fmt-name { font-family: var(--font-mono); font-size: var(--type-sm); font-weight: 600; }
|
|
618
622
|
.st-fmt-sub { font-size: var(--type-xs); color: var(--fg-2); }
|
|
619
623
|
.st-dialog-row { display: flex; align-items: center; gap: var(--space-3); }
|
|
@@ -2692,6 +2696,12 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
2692
2696
|
.sp-assets-line { padding: var(--space-1) var(--space-4); font-size: var(--type-sm); color: var(--fg-1); }
|
|
2693
2697
|
.sp-assets-active { padding: 0 var(--space-4); font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2694
2698
|
.sp-assets-retry { padding: var(--space-1) var(--space-4); font-size: var(--type-xs); color: var(--fg-2); }
|
|
2699
|
+
/* A held breaker is not an error and not routine — it is waiting for a person. */
|
|
2700
|
+
.sp-assets-retry strong { color: var(--fg-0); }
|
|
2701
|
+
.sp-held-paths { margin-top: var(--space-1); }
|
|
2702
|
+
.sp-held-paths summary { cursor: pointer; color: var(--fg-1); }
|
|
2703
|
+
.sp-held-paths ul { margin: var(--space-1) 0 0; padding-left: var(--space-4); max-height: 12rem; overflow-y: auto; }
|
|
2704
|
+
.sp-held-paths li { font-family: var(--font-mono, monospace); word-break: break-all; }
|
|
2695
2705
|
/* feature-sync-resync-and-out-of-process-sweep — Resync (header) re-runs the
|
|
2696
2706
|
* whole sync; Cancel (assets line) stops only the upload sweep. */
|
|
2697
2707
|
.sp-resync { appearance: none; flex: none; font-family: var(--font-body); font-size: var(--type-xs); color: var(--fg-1); background: var(--bg-2); border: 1px solid var(--border-default); border-radius: var(--radius-sm); padding: 2px var(--space-2); cursor: pointer; transition: color var(--dur-soft) var(--ease-out), border-color var(--dur-soft) var(--ease-out); }
|
|
@@ -2699,6 +2709,34 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
2699
2709
|
.sp-resync:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
2700
2710
|
.sp-resync:disabled { color: var(--fg-3); cursor: default; }
|
|
2701
2711
|
.sp-resync-note { padding: 0 var(--space-4) var(--space-2); font-size: var(--type-xs); color: var(--fg-2); line-height: var(--lh-base); }
|
|
2712
|
+
/* Consent-class notices (feature-before-first-external-users Task 1) — a warm
|
|
2713
|
+
card at the top of the panel body: says what leaves this machine, dismissed
|
|
2714
|
+
per (notice, hub) on this machine. */
|
|
2715
|
+
.sp-notice { margin: var(--space-2) var(--space-4); padding: var(--space-2) var(--space-3); border: 1px solid var(--status-warn); border-radius: var(--radius-md); background: color-mix(in oklab, var(--status-warn) 8%, transparent); }
|
|
2716
|
+
.sp-notice-text { margin: 0 0 var(--space-1); font-size: var(--type-xs); color: var(--fg-1); line-height: var(--lh-base); }
|
|
2717
|
+
.sp-notice-dismiss { appearance: none; font-family: var(--font-body); font-size: var(--type-xs); color: var(--fg-2); background: transparent; border: 0; padding: 0; cursor: pointer; text-decoration: underline; }
|
|
2718
|
+
.sp-notice-dismiss:hover { color: var(--fg-0); }
|
|
2719
|
+
.sp-notice-dismiss:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
2720
|
+
|
|
2721
|
+
/* First-upgrade sync consent (feature-before-first-external-users Task 2) —
|
|
2722
|
+
rides the shared .st-scrim/.st-dialog shell. */
|
|
2723
|
+
.st-consent .st-dialog-bd { font-size: var(--type-sm); color: var(--fg-1); line-height: var(--lh-base); }
|
|
2724
|
+
.st-consent .st-dialog-bd p { margin: 0 0 var(--space-2); }
|
|
2725
|
+
.st-consent-list { margin: 0 0 var(--space-2); padding-left: var(--space-4); display: flex; flex-direction: column; gap: var(--space-1); }
|
|
2726
|
+
.st-consent-fine { font-size: var(--type-xs); color: var(--fg-2); }
|
|
2727
|
+
.st-consent-note { font-size: var(--type-xs); color: var(--status-error); }
|
|
2728
|
+
.st-consent .st-dialog-ft { display: flex; justify-content: flex-end; gap: var(--space-2); }
|
|
2729
|
+
|
|
2730
|
+
/* Sync settings (feature-before-first-external-users Task 2) — the three
|
|
2731
|
+
toggles that used to mean "edit linkedHub.* JSON by hand". */
|
|
2732
|
+
.sp-setting { display: flex; align-items: flex-start; gap: var(--space-2); padding: var(--space-1) var(--space-4); font-size: var(--type-sm); color: var(--fg-1); cursor: pointer; }
|
|
2733
|
+
.sp-setting input[type='checkbox'] { margin-top: 2px; accent-color: var(--accent); flex: none; }
|
|
2734
|
+
.sp-setting > span { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
2735
|
+
.sp-setting small { font-size: var(--type-xs); color: var(--fg-2); line-height: var(--lh-base); }
|
|
2736
|
+
.sp-setting-select { align-items: center; justify-content: space-between; }
|
|
2737
|
+
.sp-setting-select select { font-family: var(--font-body); font-size: var(--type-xs); color: var(--fg-1); background: var(--bg-2); border: 1px solid var(--stroke-1); border-radius: var(--radius-sm); padding: 2px var(--space-1); flex: none; }
|
|
2738
|
+
.sp-setting-select select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
2739
|
+
|
|
2702
2740
|
.sp-assets-cancel { appearance: none; margin-left: var(--space-2); font-family: var(--font-body); font-size: var(--type-xs); color: var(--fg-2); background: transparent; border: 0; padding: 0; cursor: pointer; text-decoration: underline; }
|
|
2703
2741
|
.sp-assets-cancel:hover { color: var(--status-error); }
|
|
2704
2742
|
.sp-assets-cancel:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|