@ericrisco/rsc 3.3.1 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/manifest.json +43 -22
- package/package.json +1 -1
- package/scripts/lib/default-skill-floor.js +5 -1
- package/scripts/lib/domains.js +2 -2
- package/scripts/lib/retired-skills.js +4 -0
- package/skills/design/SKILL.md +4 -3
- package/skills/design/evals/cases.yaml +2 -2
- package/skills/react/SKILL.md +2 -1
- package/skills/ux/SKILL.md +149 -0
- package/skills/ux/THIRD_PARTY.md +32 -0
- package/skills/ux/evals/cases.yaml +40 -0
- package/skills/ux/references/flows.md +124 -0
- package/skills/ux/upstream/UPSTREAM.json +59 -0
- package/skills/ux/upstream/ux-designer/GUIDE.md +180 -0
- package/skills/ux/upstream/ux-designer/LICENSE +21 -0
- package/skills/ux/upstream/ux-designer/references/01-core-principles.md +251 -0
- package/skills/ux/upstream/ux-designer/references/02-laws-of-ux.md +330 -0
- package/skills/ux/upstream/ux-designer/references/03-accessibility.md +411 -0
- package/skills/ux/upstream/ux-designer/references/04-visual-design.md +433 -0
- package/skills/ux/upstream/ux-designer/references/05-information-architecture.md +432 -0
- package/skills/ux/upstream/ux-designer/references/06-interaction-design.md +495 -0
- package/skills/ux/upstream/ux-designer/references/07-forms-and-inputs.md +491 -0
- package/skills/ux/upstream/ux-designer/references/08-mobile-ux.md +539 -0
- package/skills/ux/upstream/ux-designer/references/09-ux-writing.md +493 -0
- package/skills/ux/upstream/ux-designer/references/10-user-research.md +533 -0
- package/skills/ux/upstream/ux-designer/references/11-design-systems.md +618 -0
- package/skills/ux/upstream/ux-designer/references/12a-presence-awareness.md +395 -0
- package/skills/ux/upstream/ux-designer/references/12b-conflict-resolution-sync.md +420 -0
- package/skills/ux/upstream/ux-designer/references/13a-canvas-navigation.md +388 -0
- package/skills/ux/upstream/ux-designer/references/13b-canvas-objects-performance.md +559 -0
- package/skills/ux/upstream/ux-designer/references/14-ai-ux-patterns.md +511 -0
- package/skills/ux/upstream/ux-designer/references/15-ethical-design.md +419 -0
- package/skills/ux/upstream/ux-designer/references/16-onboarding.md +430 -0
- package/skills/ux/upstream/ux-designer/references/17-notifications.md +461 -0
- package/skills/ux/upstream/ux-designer/references/18-data-visualization.md +453 -0
- package/skills/ux/upstream/ux-designer/references/19-search-ux.md +479 -0
- package/skills/ux/upstream/ux-designer/references/20-emotional-design.md +382 -0
- package/skills/ux/upstream/ux-designer/references/21-data-tables.md +629 -0
- package/skills/ux/upstream/ux-designer/references/22-performance-ux.md +626 -0
- package/skills/ux/upstream/ux-designer/references/23-internationalization.md +347 -0
- package/skills/ux/upstream/ux-designer/references/24-voice-and-multimodal.md +308 -0
- package/skills/ux/upstream/ux-designer/references/25a-barcode-camera-scanning.md +342 -0
- package/skills/ux/upstream/ux-designer/references/25b-barcode-scanner-workflows.md +412 -0
- package/skills/ux/upstream/ux-designer/references/25c-barcode-display.md +277 -0
- package/skills/ux/upstream/ux-usability-foundations/GUIDE.md +285 -0
- package/skills/ux/upstream/ux-usability-foundations/LICENSE +21 -0
- package/skills/ux/upstream/ux-usability-foundations/references/anti-patterns.md +267 -0
- package/skills/ux/upstream/ux-usability-foundations/references/checklists.md +130 -0
- package/skills/ux/upstream/ux-usability-foundations/references/decision-prompts.md +340 -0
- package/skills/ux/upstream/ux-usability-foundations/references/principle-cards.md +449 -0
- package/targets/clone-bootstrap.mjs +1 -1
- package/skills/ui-engineering/SKILL.md +0 -113
- package/skills/ui-engineering/evals/cases.yaml +0 -38
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
# Collaborative UX: Conflict Resolution & Sync
|
|
2
|
+
|
|
3
|
+
This reference covers the technical and UX aspects of concurrent editing, conflict resolution, version control, sharing permissions, and offline synchronization in collaborative applications.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- Concurrent Editing
|
|
10
|
+
- Version Control & History
|
|
11
|
+
- Sharing & Permissions
|
|
12
|
+
- Offline & Sync
|
|
13
|
+
- Key Metrics
|
|
14
|
+
- Anti-Patterns
|
|
15
|
+
- Sources
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Concurrent Editing
|
|
20
|
+
|
|
21
|
+
### Conflict Resolution Approaches
|
|
22
|
+
|
|
23
|
+
#### Operational Transformation (OT)
|
|
24
|
+
|
|
25
|
+
Used by Google Docs. Server-centric, transforms operations to maintain consistency.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
User A types "Hello" at position 0
|
|
29
|
+
User B types "World" at position 0 (simultaneously)
|
|
30
|
+
|
|
31
|
+
Server transforms B's operation:
|
|
32
|
+
- B intended position 0
|
|
33
|
+
- A inserted 5 chars before B's cursor
|
|
34
|
+
- B's operation becomes: insert "World" at position 5
|
|
35
|
+
|
|
36
|
+
Result: "HelloWorld" (consistent for both)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**UX Implications:**
|
|
40
|
+
- Low-latency feel with optimistic updates
|
|
41
|
+
- Requires constant server connection
|
|
42
|
+
- Complex to implement correctly for rich content
|
|
43
|
+
|
|
44
|
+
#### CRDTs (Conflict-free Replicated Data Types)
|
|
45
|
+
|
|
46
|
+
Used by Figma, Notion. Mathematically guaranteed to converge.
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
Benefits:
|
|
50
|
+
- Works offline (sync when reconnected)
|
|
51
|
+
- No central server required
|
|
52
|
+
- Automatic conflict resolution
|
|
53
|
+
|
|
54
|
+
UX Benefits:
|
|
55
|
+
- True offline-first capability
|
|
56
|
+
- Peer-to-peer collaboration possible
|
|
57
|
+
- Seamless reconnection experience
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Component Locking
|
|
61
|
+
|
|
62
|
+
Prevent simultaneous edits on the same element.
|
|
63
|
+
|
|
64
|
+
```css
|
|
65
|
+
/* Locked component indicator */
|
|
66
|
+
.component--locked {
|
|
67
|
+
outline: 2px dashed #6b7280;
|
|
68
|
+
pointer-events: none;
|
|
69
|
+
position: relative;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.component--locked::after {
|
|
73
|
+
content: "🔒 Editing: " attr(data-locked-by);
|
|
74
|
+
position: absolute;
|
|
75
|
+
top: -24px;
|
|
76
|
+
left: 0;
|
|
77
|
+
background: #374151;
|
|
78
|
+
color: white;
|
|
79
|
+
padding: 2px 8px;
|
|
80
|
+
border-radius: 4px;
|
|
81
|
+
font-size: 12px;
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
#### Lock Timeout Pattern
|
|
86
|
+
|
|
87
|
+
```javascript
|
|
88
|
+
// Auto-release lock after inactivity
|
|
89
|
+
const LOCK_TIMEOUT = 60000; // 60 seconds
|
|
90
|
+
let lockTimer;
|
|
91
|
+
|
|
92
|
+
function refreshLock() {
|
|
93
|
+
clearTimeout(lockTimer);
|
|
94
|
+
lockTimer = setTimeout(() => {
|
|
95
|
+
showDialog("Are you still editing? Lock will release in 30 seconds.");
|
|
96
|
+
// If no response, release lock
|
|
97
|
+
}, LOCK_TIMEOUT);
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Optimistic Updates
|
|
102
|
+
|
|
103
|
+
Apply changes locally before server confirmation.
|
|
104
|
+
|
|
105
|
+
```javascript
|
|
106
|
+
async function updateDocument(change) {
|
|
107
|
+
// 1. Apply immediately (optimistic)
|
|
108
|
+
applyLocalChange(change);
|
|
109
|
+
updateUI();
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
// 2. Send to server
|
|
113
|
+
await sendToServer(change);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// 3. Rollback if failed
|
|
116
|
+
revertLocalChange(change);
|
|
117
|
+
showError("Failed to save. Your change was reverted.");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Merge Conflict UI
|
|
123
|
+
|
|
124
|
+
When automatic resolution isn't possible, provide clear conflict resolution.
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
128
|
+
│ Conflict Detected │
|
|
129
|
+
├─────────────────────────────────────────────────────────────┤
|
|
130
|
+
│ Your version │ Server version │ Merged │
|
|
131
|
+
│ ─────────────────────│────────────────────│───────────────│
|
|
132
|
+
│ The quick brown fox │ The slow brown fox │ │
|
|
133
|
+
│ ↓ │ ↓ │ ↓ │
|
|
134
|
+
│ [Accept] │ [Accept] │ [Edit & Save]│
|
|
135
|
+
└─────────────────────────────────────────────────────────────┘
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**Best Practice:** Show differences side-by-side with accept/reject buttons for each conflicting section.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Version Control & History
|
|
143
|
+
|
|
144
|
+
### Version History UI
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
┌──────────────────────────────────────────────────────────┐
|
|
148
|
+
│ Version History [x] │
|
|
149
|
+
├──────────────────────────────────────────────────────────┤
|
|
150
|
+
│ Current version │
|
|
151
|
+
│ ├── Today, 3:45 PM - Alice (auto-saved) │
|
|
152
|
+
│ ├── Today, 2:30 PM - Bob "Added new section" │
|
|
153
|
+
│ ├── Today, 11:00 AM - Alice "Initial draft" │
|
|
154
|
+
│ ├── Yesterday, 4:00 PM - Carol (auto-saved) │
|
|
155
|
+
│ └── Dec 15, 2024 - Document created │
|
|
156
|
+
│ │
|
|
157
|
+
│ [Restore Selected] [Compare with Current] │
|
|
158
|
+
└──────────────────────────────────────────────────────────┘
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Multiplayer Undo/Redo
|
|
162
|
+
|
|
163
|
+
In collaborative environments, undo must be client-specific.
|
|
164
|
+
|
|
165
|
+
```javascript
|
|
166
|
+
// Each user has their own undo stack
|
|
167
|
+
class CollaborativeUndoManager {
|
|
168
|
+
constructor(userId) {
|
|
169
|
+
this.userId = userId;
|
|
170
|
+
this.undoStack = [];
|
|
171
|
+
this.redoStack = [];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
execute(action) {
|
|
175
|
+
// Record only this user's actions
|
|
176
|
+
if (action.userId === this.userId) {
|
|
177
|
+
this.undoStack.push(action);
|
|
178
|
+
this.redoStack = []; // Clear redo on new action
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
undo() {
|
|
183
|
+
const action = this.undoStack.pop();
|
|
184
|
+
if (action) {
|
|
185
|
+
const inverseAction = action.inverse();
|
|
186
|
+
this.redoStack.push(action);
|
|
187
|
+
return inverseAction; // Apply to document
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Key Principle:** Users can only undo their own actions, not others'.
|
|
194
|
+
|
|
195
|
+
### Change Attribution
|
|
196
|
+
|
|
197
|
+
Show who changed what and when.
|
|
198
|
+
|
|
199
|
+
```css
|
|
200
|
+
/* Inline attribution on hover */
|
|
201
|
+
.text-block[data-author]::before {
|
|
202
|
+
content: attr(data-author) " • " attr(data-edited);
|
|
203
|
+
position: absolute;
|
|
204
|
+
top: -20px;
|
|
205
|
+
left: 0;
|
|
206
|
+
font-size: 11px;
|
|
207
|
+
color: #6b7280;
|
|
208
|
+
opacity: 0;
|
|
209
|
+
transition: opacity 150ms;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.text-block:hover::before {
|
|
213
|
+
opacity: 1;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/* Author highlight colors */
|
|
217
|
+
.text-block[data-author="alice"] { border-left: 3px solid #e63946; }
|
|
218
|
+
.text-block[data-author="bob"] { border-left: 3px solid #2a9d8f; }
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## Sharing & Permissions
|
|
224
|
+
|
|
225
|
+
### Invite Flow Patterns
|
|
226
|
+
|
|
227
|
+
#### Email Invitation
|
|
228
|
+
|
|
229
|
+
```
|
|
230
|
+
┌─────────────────────────────────────────────────────┐
|
|
231
|
+
│ Invite people │
|
|
232
|
+
├─────────────────────────────────────────────────────┤
|
|
233
|
+
│ Email address │
|
|
234
|
+
│ ┌─────────────────────────────────────────────────┐ │
|
|
235
|
+
│ │ alice@example.com │ │
|
|
236
|
+
│ └─────────────────────────────────────────────────┘ │
|
|
237
|
+
│ │
|
|
238
|
+
│ Permission level │
|
|
239
|
+
│ ┌─────────────────────────────────────────────────┐ │
|
|
240
|
+
│ │ Can edit ▼ │ │
|
|
241
|
+
│ └─────────────────────────────────────────────────┘ │
|
|
242
|
+
│ │
|
|
243
|
+
│ Add a message (optional) │
|
|
244
|
+
│ ┌─────────────────────────────────────────────────┐ │
|
|
245
|
+
│ │ Here's the project we discussed... │ │
|
|
246
|
+
│ └─────────────────────────────────────────────────┘ │
|
|
247
|
+
│ │
|
|
248
|
+
│ [Cancel] [Send Invite] │
|
|
249
|
+
└─────────────────────────────────────────────────────┘
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
#### Link Sharing
|
|
253
|
+
|
|
254
|
+
```
|
|
255
|
+
┌─────────────────────────────────────────────────────┐
|
|
256
|
+
│ Share via link │
|
|
257
|
+
├─────────────────────────────────────────────────────┤
|
|
258
|
+
│ Anyone with the link │
|
|
259
|
+
│ ┌─────────────────────────────────────────────────┐ │
|
|
260
|
+
│ │ Can view ▼ │ │
|
|
261
|
+
│ └─────────────────────────────────────────────────┘ │
|
|
262
|
+
│ │
|
|
263
|
+
│ ┌─────────────────────────────────────────────────┐ │
|
|
264
|
+
│ │ https://app.example.com/doc/abc123... [Copy] │ │
|
|
265
|
+
│ └─────────────────────────────────────────────────┘ │
|
|
266
|
+
│ │
|
|
267
|
+
│ ⚠️ Anyone with this link can access the document │
|
|
268
|
+
└─────────────────────────────────────────────────────┘
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Permission Levels
|
|
272
|
+
|
|
273
|
+
| Level | Capabilities |
|
|
274
|
+
|-------|-------------|
|
|
275
|
+
| Owner | Full control, transfer ownership, delete |
|
|
276
|
+
| Admin | Manage members, edit settings, edit content |
|
|
277
|
+
| Editor | Edit content, add comments |
|
|
278
|
+
| Commenter | View content, add comments |
|
|
279
|
+
| Viewer | View only |
|
|
280
|
+
|
|
281
|
+
### Permission Dialog Pattern
|
|
282
|
+
|
|
283
|
+
```
|
|
284
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
285
|
+
│ Share "Project Alpha" │
|
|
286
|
+
├─────────────────────────────────────────────────────────────┤
|
|
287
|
+
│ People with access │
|
|
288
|
+
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
289
|
+
│ │ 👤 Alice (you) Owner │ │ │
|
|
290
|
+
│ │ 👤 Bob Editor [▼] │ │ │
|
|
291
|
+
│ │ 👤 Carol Viewer [▼] │ │ │
|
|
292
|
+
│ │ 🔗 Anyone with link Viewer [▼] │ │ │
|
|
293
|
+
│ └─────────────────────────────────────────────────────────┘ │
|
|
294
|
+
│ │
|
|
295
|
+
│ [+ Add people] │
|
|
296
|
+
└─────────────────────────────────────────────────────────────┘
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## Offline & Sync
|
|
302
|
+
|
|
303
|
+
### Connection Status Indicators
|
|
304
|
+
|
|
305
|
+
```css
|
|
306
|
+
/* Connection status banner */
|
|
307
|
+
.connection-banner {
|
|
308
|
+
position: fixed;
|
|
309
|
+
top: 0;
|
|
310
|
+
left: 0;
|
|
311
|
+
right: 0;
|
|
312
|
+
padding: 8px 16px;
|
|
313
|
+
text-align: center;
|
|
314
|
+
font-size: 14px;
|
|
315
|
+
z-index: 9999;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.connection-banner--offline {
|
|
319
|
+
background: #fef3c7;
|
|
320
|
+
color: #92400e;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.connection-banner--syncing {
|
|
324
|
+
background: #dbeafe;
|
|
325
|
+
color: #1e40af;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.connection-banner--error {
|
|
329
|
+
background: #fee2e2;
|
|
330
|
+
color: #991b1b;
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Offline State Communication
|
|
335
|
+
|
|
336
|
+
```
|
|
337
|
+
┌─────────────────────────────────────────────────────────┐
|
|
338
|
+
│ ⚠️ You're offline │
|
|
339
|
+
│ Changes will sync when you reconnect. │
|
|
340
|
+
│ Last synced: 2 minutes ago [Dismiss] │
|
|
341
|
+
└─────────────────────────────────────────────────────────┘
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
**Key Principles:**
|
|
345
|
+
- Avoid technical jargon ("no network connection" vs "offline")
|
|
346
|
+
- Show last sync time
|
|
347
|
+
- Indicate that work is safe
|
|
348
|
+
- Provide manual sync option when reconnected
|
|
349
|
+
|
|
350
|
+
### Sync Queue Visualization
|
|
351
|
+
|
|
352
|
+
```
|
|
353
|
+
┌─────────────────────────────────────────────────────────┐
|
|
354
|
+
│ Syncing 3 changes... │
|
|
355
|
+
│ ████████████░░░░░░░░░░░░░░░░░░░ │
|
|
356
|
+
│ │
|
|
357
|
+
│ ✓ "Header updated" │
|
|
358
|
+
│ ⟳ "Added new section" │
|
|
359
|
+
│ ○ "Changed footer color" │
|
|
360
|
+
└─────────────────────────────────────────────────────────┘
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Reconnection Flow
|
|
364
|
+
|
|
365
|
+
```javascript
|
|
366
|
+
// Graceful reconnection
|
|
367
|
+
async function handleReconnection() {
|
|
368
|
+
showBanner("Reconnecting...");
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
// 1. Fetch remote changes
|
|
372
|
+
const remoteChanges = await fetchChanges(lastSyncTimestamp);
|
|
373
|
+
|
|
374
|
+
// 2. Merge with local changes
|
|
375
|
+
const merged = mergeChanges(localQueue, remoteChanges);
|
|
376
|
+
|
|
377
|
+
// 3. Apply merged state
|
|
378
|
+
applyChanges(merged);
|
|
379
|
+
|
|
380
|
+
// 4. Push local changes
|
|
381
|
+
await pushLocalChanges(localQueue);
|
|
382
|
+
|
|
383
|
+
showBanner("You're back online!", { duration: 3000 });
|
|
384
|
+
} catch (error) {
|
|
385
|
+
showBanner("Reconnection failed. Retrying...");
|
|
386
|
+
scheduleRetry();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
---
|
|
392
|
+
|
|
393
|
+
## Key Metrics
|
|
394
|
+
|
|
395
|
+
| Metric | Value | Context |
|
|
396
|
+
|--------|-------|---------|
|
|
397
|
+
| Lock inactivity timeout | 60s | Before prompting release |
|
|
398
|
+
| Reconnection retry | 1s, 2s, 4s, 8s... | Exponential backoff |
|
|
399
|
+
| Optimistic timeout | 5-10s | Before showing sync error |
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
## Anti-Patterns
|
|
404
|
+
|
|
405
|
+
1. **No conflict prevention** - Simultaneous edits overwrite each other
|
|
406
|
+
2. **Silent failures** - Sync errors without user notification
|
|
407
|
+
3. **No offline indication** - Users think they're connected
|
|
408
|
+
4. **Permission confusion** - Unclear what each role can do
|
|
409
|
+
5. **Blocking on sync** - UI freezes during synchronization
|
|
410
|
+
6. **No undo attribution** - Users undo others' work accidentally
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
## Sources
|
|
415
|
+
|
|
416
|
+
- [Figma - Multiplayer Editing](https://www.figma.com/blog/multiplayer-editing-in-figma/)
|
|
417
|
+
- [CKEditor - Real-Time Collaboration Lessons](https://ckeditor.com/blog/lessons-learned-from-creating-a-rich-text-editor-with-real-time-collaboration/)
|
|
418
|
+
- [DEV - Building Collaborative Interfaces: OT vs CRDTs](https://dev.to/puritanic/building-collaborative-interfaces-operational-transforms-vs-crdts-2obo)
|
|
419
|
+
- [Liveblocks - Undo/Redo in Multiplayer](https://liveblocks.io/blog/how-to-build-undo-redo-in-a-multiplayer-environment)
|
|
420
|
+
- [A List Apart - Designing Offline-First](https://alistapart.com/article/offline-first/)
|