refactor: normalize space visibility enums + inline space create form

Align visibility values across server and UI to the canonical set:
public, permissioned, private (replacing public_read, authenticated,
members_only). Add inline space creation form to the space switcher
dropdown and tab bar instead of navigating to /new.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-03 13:27:31 -08:00
parent fb26324929
commit eb2859d849
4 changed files with 262 additions and 43 deletions

View File

@ -1682,9 +1682,9 @@ const server = Bun.serve<WSData>({
if (spaceConfig) {
const vis = spaceConfig.visibility;
if (vis === "authenticated" || vis === "members_only") {
if (vis === "permissioned" || vis === "private") {
if (!claims) return new Response("Authentication required", { status: 401 });
} else if (vis === "public_read") {
} else if (vis === "public") {
readOnly = !claims;
}
}
@ -1701,8 +1701,7 @@ const server = Bun.serve<WSData>({
// Non-member defaults by visibility
const vis = spaceConfig?.visibility;
if (vis === 'public' && claims) spaceRole = 'member';
else if (vis === 'public_read' && claims) spaceRole = 'member';
else if (vis === 'authenticated' && claims) spaceRole = 'viewer';
else if (vis === 'permissioned' && claims) spaceRole = 'viewer';
else if (claims) spaceRole = 'viewer';
else spaceRole = 'viewer'; // anonymous
}
@ -1781,7 +1780,7 @@ const server = Bun.serve<WSData>({
name: `${claims.username}'s Space`,
slug: subdomain,
ownerDID: claims.sub,
visibility: "members_only",
visibility: "private",
source: 'subdomain',
});
}

View File

@ -623,7 +623,6 @@ spaces.patch("/:slug", async (c) => {
if (body.visibility) {
const valid: SpaceVisibility[] = ["public", "permissioned", "private"];
// Note: the create endpoint (line ~276) already validates correctly
if (!valid.includes(body.visibility)) {
return c.json({ error: `Invalid visibility. Must be one of: ${valid.join(", ")}` }, 400);
}

View File

@ -115,20 +115,20 @@ export class RStackSpaceSwitcher extends HTMLElement {
}
#visibilityInfo(s: SpaceInfo): { cls: string; label: string } {
const v = s.visibility || "public_read";
if (v === "members_only") return { cls: "vis-private", label: "🔒" };
if (v === "authenticated") return { cls: "vis-permissioned", label: "🔑" };
const v = s.visibility || "public";
if (v === "private") return { cls: "vis-private", label: "🔒" };
if (v === "permissioned") return { cls: "vis-permissioned", label: "🔑" };
return { cls: "vis-public", label: "👁" };
}
/** Format display name based on visibility type */
#displayName(s: SpaceInfo): string {
const v = s.visibility || "public_read";
if (v === "members_only") {
const v = s.visibility || "public";
if (v === "private") {
const username = getUsername();
return username ? `${username}'s (you)rSpace` : "(you)rSpace";
return username ? `${username}'s Space` : "My Space";
}
return `${s.name} rSpace`;
return s.name;
}
#renderMenu(menu: HTMLElement, current: string) {
@ -140,7 +140,7 @@ export class RStackSpaceSwitcher extends HTMLElement {
cta = this.#yourSpaceCTAhtml("Sign in to create →");
} else {
const username = getUsername();
const label = username ? `Create ${username}'s Space →` : "Create (you)rSpace →";
const label = username ? `Create ${username}'s Space →` : "Create My Space →";
cta = this.#yourSpaceCTAhtml(label);
}
menu.innerHTML = `
@ -149,9 +149,11 @@ export class RStackSpaceSwitcher extends HTMLElement {
<div class="menu-empty">
${auth ? "No spaces yet" : "Sign in to see your spaces"}
</div>
<a class="item item--create" href="/new">+ Create new space</a>
<button class="item item--create" id="create-toggle">+ Create new space</button>
${this.#createFormHTML()}
`;
this.#attachYourSpaceCTA(menu);
this.#attachCreatePopout(menu);
return;
}
@ -166,13 +168,13 @@ export class RStackSpaceSwitcher extends HTMLElement {
let html = "";
// ── Create (you)rSpace CTA — only if user has no owned spaces ──
// ── Create personal space CTA — only if user has no owned spaces ──
if (!auth) {
html += this.#yourSpaceCTAhtml("Sign in to create →");
html += `<div class="divider"></div>`;
} else if (!hasOwnedSpace) {
const username = getUsername();
const label = username ? `Create ${username}'s Space →` : "Create (you)rSpace →";
const label = username ? `Create ${username}'s Space →` : "Create My Space →";
html += this.#yourSpaceCTAhtml(label);
html += `<div class="divider"></div>`;
}
@ -237,7 +239,8 @@ export class RStackSpaceSwitcher extends HTMLElement {
}
html += `<div class="divider"></div>`;
html += `<a class="item item--create" href="/new">+ Create new space</a>`;
html += `<button class="item item--create" id="create-toggle">+ Create new space</button>`;
html += this.#createFormHTML();
menu.innerHTML = html;
@ -260,19 +263,41 @@ export class RStackSpaceSwitcher extends HTMLElement {
});
});
// Attach "(you)rSpace" CTA listener
// Attach personal space CTA listener
this.#attachYourSpaceCTA(menu);
// Attach create-space popout
this.#attachCreatePopout(menu);
}
#yourSpaceCTAhtml(buttonLabel: string): string {
const username = getUsername();
const title = username ? `${username}'s Space` : "My Space";
return `
<div class="item item--yourspace vis-private">
<span class="item-icon">🔒</span>
<span class="item-name">(you)rSpace</span>
<span class="item-name">${title}</span>
<button class="yourspace-btn" id="yourspace-cta">${buttonLabel}</button>
</div>`;
}
#createFormHTML(): string {
return `<div class="create-popout hidden" id="create-popout">
<input class="create-input" id="create-name" placeholder="Space name" maxlength="60" />
<div class="create-slug" id="create-slug"></div>
<div class="create-vis-row">
<label class="create-vis-opt"><input type="radio" name="create-vis" value="public" checked /><span class="vis-dot vis-dot--public"></span> Public</label>
<label class="create-vis-opt"><input type="radio" name="create-vis" value="permissioned" /><span class="vis-dot vis-dot--permissioned"></span> Permissioned</label>
<label class="create-vis-opt"><input type="radio" name="create-vis" value="private" /><span class="vis-dot vis-dot--private"></span> Private</label>
</div>
<div class="create-actions">
<button class="create-btn" id="create-submit">Create</button>
<button class="create-cancel" id="create-cancel">Cancel</button>
</div>
<div class="create-status" id="create-status"></div>
</div>`;
}
#attachYourSpaceCTA(menu: HTMLElement) {
const btn = menu.querySelector("#yourspace-cta");
if (!btn) return;
@ -294,6 +319,79 @@ export class RStackSpaceSwitcher extends HTMLElement {
});
}
#attachCreatePopout(menu: HTMLElement) {
const toggle = menu.querySelector("#create-toggle") as HTMLElement;
const popout = menu.querySelector("#create-popout") as HTMLElement;
const nameInput = menu.querySelector("#create-name") as HTMLInputElement;
const slugPreview = menu.querySelector("#create-slug") as HTMLElement;
const submitBtn = menu.querySelector("#create-submit") as HTMLButtonElement;
const cancelBtn = menu.querySelector("#create-cancel") as HTMLElement;
const status = menu.querySelector("#create-status") as HTMLElement;
if (!toggle || !popout) return;
const toSlug = (name: string) =>
name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
toggle.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
const showing = popout.classList.toggle("hidden");
if (!showing) nameInput.focus();
});
nameInput.addEventListener("input", () => {
const slug = toSlug(nameInput.value);
slugPreview.textContent = slug ? `slug: ${slug}` : "";
});
nameInput.addEventListener("click", (e) => e.stopPropagation());
cancelBtn.addEventListener("click", (e) => {
e.stopPropagation();
popout.classList.add("hidden");
nameInput.value = "";
slugPreview.textContent = "";
status.textContent = "";
});
submitBtn.addEventListener("click", async (e) => {
e.stopPropagation();
const name = nameInput.value.trim();
if (!name) { status.textContent = "Name required"; return; }
const token = getAccessToken();
if (!token) {
const identity = document.querySelector("rstack-identity") as any;
if (identity?.showAuthModal) identity.showAuthModal();
return;
}
const slug = toSlug(name);
const vis = (menu.querySelector('input[name="create-vis"]:checked') as HTMLInputElement)?.value || "public";
submitBtn.disabled = true;
status.textContent = "Creating...";
try {
const res = await fetch("/api/spaces", {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ name, slug, visibility: vis }),
});
const data = await res.json();
if (res.ok && data.slug) {
window.location.href = rspaceNavUrl(data.slug, "canvas");
} else {
status.textContent = data.error || "Failed to create space";
submitBtn.disabled = false;
}
} catch {
status.textContent = "Network error";
submitBtn.disabled = false;
}
});
}
async #autoProvision() {
const token = getAccessToken();
if (!token) return;
@ -407,10 +505,9 @@ export class RStackSpaceSwitcher extends HTMLElement {
<input class="input" id="es-name" value="${spaceName.replace(/"/g, "&quot;")}" />
<label class="field-label">Visibility</label>
<select class="input" id="es-visibility">
<option value="public">Public (read + write)</option>
<option value="public_read">Public (read only)</option>
<option value="authenticated">Authenticated users</option>
<option value="members_only">Members only</option>
<option value="public">👁 Public anyone can read, sign in to write</option>
<option value="permissioned">🔑 Permissioned sign in to read & write</option>
<option value="private">🔒 Private invite-only</option>
</select>
<label class="field-label">Description</label>
<textarea class="input" id="es-description" rows="3" placeholder="Optional description..." style="resize:vertical"></textarea>
@ -910,10 +1007,58 @@ const STYLES = `
.item--create {
font-size: 0.85rem; font-weight: 600; color: #06b6d4 !important;
border-left-color: transparent !important;
background: none; border: none; width: 100%; text-align: left; cursor: pointer;
font-family: inherit;
}
.item--create:hover { background: rgba(6,182,212,0.08) !important; }
/* (you)rSpace CTA */
/* Create-space popout */
.create-popout { padding: 8px 12px; }
.create-popout.hidden { display: none; }
.create-input {
width: 100%; padding: 8px 10px; border-radius: 6px;
border: 1px solid var(--rs-border); background: var(--rs-bg-surface-sunken);
color: var(--rs-text-primary); font-size: 0.85rem; outline: none;
box-sizing: border-box; font-family: inherit;
}
.create-input:focus { border-color: #06b6d4; }
.create-slug {
font-size: 0.7rem; color: var(--rs-text-muted); padding: 2px 2px 6px;
min-height: 1em;
}
.create-vis-row {
display: flex; gap: 10px; margin-bottom: 8px; flex-wrap: wrap;
}
.create-vis-opt {
display: flex; align-items: center; gap: 4px;
font-size: 0.78rem; color: var(--rs-text-secondary); cursor: pointer;
}
.create-vis-opt input { margin: 0; }
.vis-dot {
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
}
.vis-dot--public { background: #34d399; }
.vis-dot--permissioned { background: #fbbf24; }
.vis-dot--private { background: #f87171; }
.create-actions { display: flex; gap: 8px; }
.create-btn {
padding: 6px 16px; border-radius: 6px; border: none;
font-size: 0.8rem; font-weight: 600; cursor: pointer;
background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white;
}
.create-btn:hover { opacity: 0.85; }
.create-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.create-cancel {
padding: 6px 12px; border-radius: 6px; border: none;
font-size: 0.8rem; cursor: pointer;
background: var(--rs-btn-secondary-bg); color: var(--rs-text-secondary);
}
.create-cancel:hover { background: var(--rs-bg-hover); }
.create-status {
font-size: 0.75rem; color: #f87171; margin-top: 4px; min-height: 1em;
}
/* Personal space CTA */
.item--yourspace {
border-left-color: #f87171; padding: 12px 14px;
background: var(--rs-bg-hover);

View File

@ -110,6 +110,8 @@ export class RStackTabBar extends HTMLElement {
#wiringSourceFeedId = "";
#wiringSourceKind: FlowKind | null = null;
#escHandler: ((e: KeyboardEvent) => void) | null = null;
// Recent apps: moduleId → last-used timestamp
#recentApps: Map<string, number> = new Map();
constructor() {
super();
@ -141,6 +143,7 @@ export class RStackTabBar extends HTMLElement {
}
connectedCallback() {
this.#loadRecentApps();
this.#render();
}
@ -152,6 +155,12 @@ export class RStackTabBar extends HTMLElement {
/** Set the layer list (call from outside) */
setLayers(layers: Layer[]) {
this.#layers = [...layers].sort((a, b) => a.order - b.order);
// Seed recent apps from existing layers
for (const l of layers) {
if (!this.#recentApps.has(l.moduleId)) {
this.#recentApps.set(l.moduleId, l.createdAt || 0);
}
}
this.#render();
}
@ -160,6 +169,36 @@ export class RStackTabBar extends HTMLElement {
this.#modules = modules;
}
// ── Recent apps persistence ──
#recentAppsKey(): string {
return `rspace_recent_apps_${this.space || "default"}`;
}
#loadRecentApps() {
try {
const raw = localStorage.getItem(this.#recentAppsKey());
if (raw) {
const obj = JSON.parse(raw) as Record<string, number>;
this.#recentApps = new Map(Object.entries(obj));
}
} catch { /* ignore */ }
}
#saveRecentApps() {
try {
const obj: Record<string, number> = {};
for (const [k, v] of this.#recentApps) obj[k] = v;
localStorage.setItem(this.#recentAppsKey(), JSON.stringify(obj));
} catch { /* ignore */ }
}
/** Record a module as recently used (call from outside or internally) */
trackRecent(moduleId: string) {
this.#recentApps.set(moduleId, Date.now());
this.#saveRecentApps();
}
/** Set the inter-layer flows (for stack view) */
setFlows(flows: LayerFlow[]) {
this.#flows = flows;
@ -302,7 +341,23 @@ export class RStackTabBar extends HTMLElement {
return `<div class="add-menu" id="add-menu"><div class="add-menu-empty">No rApps available</div></div>`;
}
// Group by category
let html = "";
// ── Recent apps section (top of menu) ──
if (this.#recentApps.size > 0) {
const recentEntries = [...this.#recentApps.entries()]
.sort((a, b) => b[1] - a[1]) // newest first
.slice(0, 6); // show up to 6 recent
const recentModules = recentEntries
.map(([id]) => allModules.find(m => m.id === id))
.filter((m): m is typeof allModules[0] => !!m);
if (recentModules.length > 0) {
html += `<div class="add-menu-category">Recent</div>`;
html += recentModules.map(m => this.#renderAddMenuItem(m, existingModuleIds.has(m.id))).join("");
}
}
// ── Group by category ──
const groups = new Map<string, typeof allModules>();
const uncategorized: typeof allModules = [];
for (const m of allModules) {
@ -315,7 +370,6 @@ export class RStackTabBar extends HTMLElement {
}
}
let html = "";
for (const cat of CATEGORY_ORDER) {
const items = groups.get(cat);
if (!items || items.length === 0) continue;
@ -741,6 +795,7 @@ export class RStackTabBar extends HTMLElement {
const layerId = tab.dataset.layerId!;
const moduleId = tab.dataset.moduleId!;
this.active = layerId;
this.trackRecent(moduleId);
this.dispatchEvent(new CustomEvent("layer-switch", {
detail: { layerId, moduleId },
bubbles: true,
@ -794,21 +849,26 @@ export class RStackTabBar extends HTMLElement {
});
});
// Add button
// Add button — click + touch support
const addBtn = this.#shadow.getElementById("add-btn");
addBtn?.addEventListener("click", (e) => {
const toggleAddMenu = (e: Event) => {
e.stopPropagation();
e.preventDefault();
this.#addMenuOpen = !this.#addMenuOpen;
this.#render();
});
};
addBtn?.addEventListener("click", toggleAddMenu);
addBtn?.addEventListener("touchend", toggleAddMenu);
// Add menu items
// Add menu items — click + touch support
this.#shadow.querySelectorAll<HTMLElement>(".add-menu-item").forEach(item => {
item.addEventListener("click", (e) => {
const handleSelect = (e: Event) => {
e.stopPropagation();
e.preventDefault();
const moduleId = item.dataset.addModule!;
const isOpen = item.dataset.moduleOpen === "true";
this.#addMenuOpen = false;
this.trackRecent(moduleId);
if (isOpen) {
// Surface existing tab instead of adding a duplicate
@ -826,17 +886,23 @@ export class RStackTabBar extends HTMLElement {
bubbles: true,
}));
}
});
};
item.addEventListener("click", handleSelect);
item.addEventListener("touchend", handleSelect);
});
// Close add menu on outside click
// Close add menu on outside click/touch
if (this.#addMenuOpen) {
const handler = () => {
this.#addMenuOpen = false;
this.#render();
document.removeEventListener("click", handler);
document.removeEventListener("touchend", handler);
};
setTimeout(() => document.addEventListener("click", handler), 0);
setTimeout(() => {
document.addEventListener("click", handler);
document.addEventListener("touchend", handler);
}, 0);
}
// View toggle
@ -1114,9 +1180,9 @@ const STYLES = `
display: flex;
align-items: center;
gap: 0;
height: 36px;
min-height: 36px;
padding: 0 8px;
overflow: hidden;
overflow: visible;
}
.tabs-scroll {
@ -1126,6 +1192,7 @@ const STYLES = `
flex: 1;
min-width: 0;
overflow-x: auto;
overflow-y: visible;
scrollbar-width: none;
position: relative;
}
@ -1229,19 +1296,25 @@ const STYLES = `
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
width: 28px;
height: 28px;
min-width: 44px;
min-height: 44px;
border: 1px dashed rgba(148,163,184,0.3);
border-radius: 5px;
background: transparent;
color: #64748b;
font-size: 0.9rem;
font-size: 1rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
flex-shrink: 0;
margin-left: 4px;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
position: relative;
z-index: 2;
}
.tab-add:hover {
.tab-add:hover, .tab-add:active {
border-color: #22d3ee;
color: #22d3ee;
background: rgba(34,211,238,0.08);
@ -1287,7 +1360,8 @@ const STYLES = `
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
padding: 8px 10px;
min-height: 40px;
border: none;
border-radius: 6px;
background: transparent;
@ -1296,8 +1370,10 @@ const STYLES = `
cursor: pointer;
text-align: left;
transition: background 0.12s;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.add-menu-item:hover { background: var(--rs-bg-hover); }
.add-menu-item:hover, .add-menu-item:active { background: var(--rs-bg-hover); }
.add-menu-badge {
display: inline-flex;