feat(rswag): add multiplayer design sync via Automerge CRDT

Shared design metadata syncs across space members in real-time.
"Space Designs" gallery shows all designs with download links.
Artifact generation auto-publishes design metadata to peers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-15 17:34:06 -07:00
parent b67f30ac0a
commit 0db93695d8
3 changed files with 305 additions and 1 deletions

View File

@ -148,6 +148,23 @@ function posterMockupSvg(): string {
// --- Component ---
import { TourEngine } from "../../../shared/tour-engine";
import { SwagLocalFirstClient } from "../local-first-client";
import type { SwagDoc, SwagDesign } from "../schemas";
// ── Auth helpers ──
function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null {
try {
const raw = localStorage.getItem("encryptid_session");
if (!raw) return null;
const s = JSON.parse(raw);
return s?.accessToken ? s : null;
} catch { return null; }
}
function getMyDid(): string | null {
const s = getSession();
if (!s) return null;
return (s.claims as any).did || s.claims.sub;
}
class FolkSwagDesigner extends HTMLElement {
private shadow: ShadowRoot;
@ -164,6 +181,11 @@ class FolkSwagDesigner extends HTMLElement {
private demoStep: 1 | 2 | 3 | 4 = 1;
private progressStep = 0;
private usedSampleDesign = false;
/* Multiplayer state */
private lfClient: SwagLocalFirstClient | null = null;
private _lfcUnsub: (() => void) | null = null;
private sharedDesigns: SwagDesign[] = [];
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '.product', title: "Choose Product", message: "Select a product type — tee, sticker, poster, or hoodie.", advanceOnClick: true },
@ -193,6 +215,7 @@ class FolkSwagDesigner extends HTMLElement {
this.demoStep = 1;
this.render();
} else {
this.initMultiplayer();
this.render();
}
if (!localStorage.getItem("rswag_tour_done")) {
@ -200,6 +223,58 @@ class FolkSwagDesigner extends HTMLElement {
}
}
disconnectedCallback() {
this._lfcUnsub?.();
this._lfcUnsub = null;
this.lfClient?.disconnect();
}
private async initMultiplayer() {
try {
this.lfClient = new SwagLocalFirstClient(this.space);
await this.lfClient.init();
await this.lfClient.subscribe();
this._lfcUnsub = this.lfClient.onChange((doc) => {
this.extractDesigns(doc);
this.render();
});
const doc = this.lfClient.getDoc();
if (doc) this.extractDesigns(doc);
this.render();
} catch (err) {
console.warn('[rSwag] Local-first init failed:', err);
}
}
private extractDesigns(doc: SwagDoc) {
this.sharedDesigns = doc.designs
? Object.values(doc.designs).sort((a, b) => b.updatedAt - a.updatedAt)
: [];
}
private saveDesignToSync(artifactId: string) {
if (!this.lfClient) return;
const design: SwagDesign = {
id: crypto.randomUUID(),
title: this.designTitle || 'Untitled Design',
productType: this.selectedProduct as SwagDesign['productType'],
artifactId,
createdBy: getMyDid(),
createdAt: Date.now(),
updatedAt: Date.now(),
};
this.lfClient.saveDesign(design);
}
private deleteSharedDesign(designId: string) {
if (!this.lfClient) return;
if (confirm('Delete this design?')) {
this.lfClient.deleteDesign(designId);
}
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rswag/);
@ -338,6 +413,8 @@ class FolkSwagDesigner extends HTMLElement {
}
this.artifact = await res.json();
// Sync design metadata to other space members
if (this.artifact?.id) this.saveDesignToSync(this.artifact.id);
} catch (e) {
this.error = e instanceof Error ? e.message : "Generation failed";
} finally {
@ -601,9 +678,27 @@ class FolkSwagDesigner extends HTMLElement {
{ id: "hoodie", name: "Hoodie", icon: "🧥", desc: '14x16" DTG print' },
];
const isLive = this.lfClient?.isConnected ?? false;
const productIcons: Record<string, string> = { sticker: '📋', poster: '🖼', tee: '👕', hoodie: '🧥' };
this.shadow.innerHTML = `
<style>
:host { display: block; padding: 1.5rem; max-width: 900px; margin: 0 auto; }
.live-header { display: flex; align-items: center; gap: 8px; margin-bottom: 1rem; }
.live-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #22c55e; animation: pulse-dot 2s infinite; }
@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.live-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 999px; background: rgba(34,197,94,0.15); color: #22c55e; font-weight: 500; display: flex; align-items: center; gap: 2px; }
.section-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--rs-text-muted); margin-bottom: 0.75rem; font-weight: 600; }
.designs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; margin-bottom: 1.5rem; }
.design-card { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; padding: 0.75rem; transition: border-color 0.15s; }
.design-card:hover { border-color: var(--rs-primary); }
.design-card-icon { font-size: 1.5rem; margin-bottom: 4px; }
.design-card-title { font-weight: 600; font-size: 0.875rem; color: var(--rs-text-primary); margin-bottom: 2px; }
.design-card-meta { font-size: 0.75rem; color: var(--rs-text-secondary); }
.design-card-actions { margin-top: 6px; }
.design-delete { font-size: 0.7rem; color: var(--rs-text-muted); cursor: pointer; border: none; background: none; padding: 2px 6px; border-radius: 4px; font-family: inherit; }
.design-delete:hover { color: #fca5a5; background: rgba(239,68,68,0.1); }
.divider { border: none; border-top: 1px solid var(--rs-border); margin: 1.5rem 0; }
.products { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; margin-bottom: 1.5rem; }
.product { padding: 1rem; border-radius: 12px; border: 2px solid var(--rs-border); background: var(--rs-bg-surface); cursor: pointer; text-align: center; transition: all 0.15s; }
.product:hover { border-color: var(--rs-border-strong); }
@ -618,7 +713,7 @@ class FolkSwagDesigner extends HTMLElement {
.preview-img { max-width: 200px; max-height: 200px; border-radius: 8px; }
.title-input { width: 100%; padding: 0.625rem 0.75rem; border: 1px solid var(--rs-input-border); border-radius: 8px; background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 0.875rem; margin-bottom: 1rem; box-sizing: border-box; }
.title-input:focus { outline: none; border-color: var(--rs-primary); }
.generate-btn { width: 100%; padding: 0.75rem; border: none; border-radius: 8px; background: var(--rs-primary-hover); color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; margin-bottom: 1rem; }
.generate-btn { width: 100%; padding: 0.75rem; border: none; border-radius: 8px; background: var(--rs-primary-hover); color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; margin-bottom: 1rem; font-family: inherit; }
.generate-btn:hover { background: #4338ca; }
.generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.error { background: rgba(239,68,68,0.1); border: 1px solid var(--rs-error); border-radius: 8px; padding: 0.75rem; color: #fca5a5; font-size: 0.875rem; margin-bottom: 1rem; }
@ -637,9 +732,31 @@ class FolkSwagDesigner extends HTMLElement {
input[type="file"] { display: none; }
@media (max-width: 768px) {
.products { grid-template-columns: repeat(2, 1fr); }
.designs-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
}
</style>
${isLive ? `<div class="live-header"><span class="live-badge"><span class="live-dot"></span>LIVE</span></div>` : ''}
${this.sharedDesigns.length > 0 ? `
<div class="section-label">Space Designs</div>
<div class="designs-grid">
${this.sharedDesigns.map(d => `
<div class="design-card">
<div class="design-card-icon">${productIcons[d.productType] || '📋'}</div>
<div class="design-card-title">${this.esc(d.title)}</div>
<div class="design-card-meta">${d.productType}${d.artifactId ? ' &bull; ready' : ''}</div>
<div class="design-card-actions">
${d.artifactId ? `<a class="result-btn result-btn-primary" style="font-size:0.7rem;padding:3px 8px" href="${this.getApiBase()}/api/artifact/${d.artifactId}/file" target="_blank">Download</a>` : ''}
${d.createdBy === getMyDid() ? `<button class="design-delete" data-delete-design="${d.id}">Delete</button>` : ''}
</div>
</div>`).join('')}
</div>
<hr class="divider">
` : ''}
<div class="section-label">New Design</div>
<div class="products">
${products.map((p) => `
<div class="product ${this.selectedProduct === p.id ? 'active' : ''}" data-product="${p.id}">
@ -718,6 +835,14 @@ class FolkSwagDesigner extends HTMLElement {
this.shadow.querySelector('[data-action="copy-json"]')?.addEventListener("click", () => {
navigator.clipboard.writeText(JSON.stringify(this.artifact, null, 2));
});
// Shared design delete buttons
this.shadow.querySelectorAll<HTMLElement>('[data-delete-design]').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.deleteDesign;
if (id) this.deleteSharedDesign(id);
});
});
}
private getDemoStyles(): string {

View File

@ -0,0 +1,111 @@
/**
* rSwag Local-First Client
*
* Wraps the shared local-first stack for collaborative design management.
*/
import { DocumentManager } from '../../shared/local-first/document';
import type { DocumentId } from '../../shared/local-first/document';
import { EncryptedDocStore } from '../../shared/local-first/storage';
import { DocSyncManager } from '../../shared/local-first/sync';
import { DocCrypto } from '../../shared/local-first/crypto';
import { swagSchema, swagDocId } from './schemas';
import type { SwagDoc, SwagDesign } from './schemas';
export class SwagLocalFirstClient {
#space: string;
#documents: DocumentManager;
#store: EncryptedDocStore;
#sync: DocSyncManager;
#initialized = false;
constructor(space: string, docCrypto?: DocCrypto) {
this.#space = space;
this.#documents = new DocumentManager();
this.#store = new EncryptedDocStore(space, docCrypto);
this.#sync = new DocSyncManager({
documents: this.#documents,
store: this.#store,
});
this.#documents.registerSchema(swagSchema);
}
get isConnected(): boolean { return this.#sync.isConnected; }
async init(): Promise<void> {
if (this.#initialized) return;
await this.#store.open();
const cachedIds = await this.#store.listByModule('swag', 'designs');
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) {
this.#documents.open<SwagDoc>(docId, swagSchema, binary);
}
await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[SwagClient] Working offline'); }
this.#initialized = true;
}
async subscribe(): Promise<SwagDoc | null> {
const docId = swagDocId(this.#space) as DocumentId;
let doc = this.#documents.get<SwagDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<SwagDoc>(docId, swagSchema, binary)
: this.#documents.open<SwagDoc>(docId, swagSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getDoc(): SwagDoc | undefined {
return this.#documents.get<SwagDoc>(swagDocId(this.#space) as DocumentId);
}
onChange(cb: (doc: SwagDoc) => void): () => void {
return this.#sync.onChange(swagDocId(this.#space) as DocumentId, cb as (doc: any) => void);
}
onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); }
// ── Design CRUD ──
saveDesign(design: SwagDesign): void {
const docId = swagDocId(this.#space) as DocumentId;
this.#sync.change<SwagDoc>(docId, `Save design ${design.title}`, (d) => {
d.designs[design.id] = design;
});
}
updateDesignArtifact(designId: string, artifactId: string): void {
const docId = swagDocId(this.#space) as DocumentId;
this.#sync.change<SwagDoc>(docId, `Link artifact`, (d) => {
if (d.designs[designId]) {
d.designs[designId].artifactId = artifactId;
d.designs[designId].updatedAt = Date.now();
}
});
}
deleteDesign(designId: string): void {
const docId = swagDocId(this.#space) as DocumentId;
this.#sync.change<SwagDoc>(docId, `Delete design`, (d) => {
delete d.designs[designId];
if (d.activeDesignId === designId) d.activeDesignId = null;
});
}
setActiveDesign(designId: string | null): void {
const docId = swagDocId(this.#space) as DocumentId;
this.#sync.change<SwagDoc>(docId, `Set active design`, (d) => {
d.activeDesignId = designId;
});
}
async disconnect(): Promise<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}

68
modules/rswag/schemas.ts Normal file
View File

@ -0,0 +1,68 @@
/**
* rSwag Automerge document schemas.
*
* Stores shared design metadata for collaborative artifact management.
* Actual image files remain server-side; this doc syncs titles, product
* types, status, and artifact references across space members.
*
* DocId format: {space}:swag:designs
*/
import type { DocSchema } from '../../shared/local-first/document';
// ── Document types ──
export interface SwagDesign {
id: string;
title: string;
productType: 'sticker' | 'poster' | 'tee' | 'hoodie';
/** Server artifact ID (if generated) */
artifactId: string | null;
createdBy: string | null;
createdAt: number;
updatedAt: number;
}
export interface SwagDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
designs: Record<string, SwagDesign>;
/** Currently active design ID for the space */
activeDesignId: string | null;
}
// ── Schema registration ──
export const swagSchema: DocSchema<SwagDoc> = {
module: 'swag',
collection: 'designs',
version: 1,
init: (): SwagDoc => ({
meta: {
module: 'swag',
collection: 'designs',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
designs: {},
activeDesignId: null,
}),
migrate: (doc: any, _fromVersion: number) => {
if (!doc.designs) doc.designs = {};
if (!('activeDesignId' in doc)) doc.activeDesignId = null;
doc.meta.version = 1;
return doc;
},
};
// ── Helpers ──
export function swagDocId(space: string) {
return `${space}:swag:designs` as const;
}