feat: add ViewHistory for in-app back navigation, rename rWork to rTasks

Add shared ViewHistory<V> utility class that provides a proper navigation
stack for rApps with hierarchical views. Replaces hardcoded data-back
targets with stack-based back navigation across 10 rApps: rtrips, rmaps,
rtasks, rforum, rphotos, rvote, rnotes, rinbox, rschedule, rcart.

Rename rWork module to rTasks — directory, component (folk-tasks-board),
CSS, exports, domains, and all cross-module references updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 14:04:13 -07:00
parent 9de37d7405
commit 31b088543e
70 changed files with 2041 additions and 380 deletions

1
.gitignore vendored
View File

@ -23,6 +23,7 @@ Thumbs.db
.env
.env.local
.env.*.local
open-notebook.env
# Bun
bun.lockb

View File

@ -264,7 +264,7 @@ redirects to the unified server with subdomain-based space routing.
| **rCal** | rcal.online | Spatio-temporal calendar with map + lunar overlay |
| **rMaps** | rmaps.online | Geographic mapping & location hierarchy |
| **rTrips** | rtrips.online | Trip planning with itineraries |
| **rWork** | rwork.online | Task boards & project management |
| **rTasks** | rtasks.online | Task boards & project management |
| **rSchedule** | rschedule.online | Persistent cron-based job scheduling with email, webhooks & briefings |
### Communication
@ -332,7 +332,7 @@ The canvas is the primary interface. Modules render as shapes that can
be positioned, connected, resized, and composed spatially. This enables
non-linear knowledge work: a funding proposal (rFunds) sits next to
the vote (rVote), the budget spreadsheet (rData), and the project
plan (rWork) — all on one canvas, all synced in real-time.
plan (rTasks) — all on one canvas, all synced in real-time.
### Offline-First
Every interaction works offline. Changes queue locally and merge

View File

@ -197,16 +197,16 @@ services:
traefik.http.routers.rmaps-sa.entrypoints: web
traefik.http.services.rmaps-sa.loadbalancer.server.port: "3000"
# ── rWork ──
rwork-standalone:
# ── rTasks ──
rtasks-standalone:
<<: *standalone-base
container_name: rwork-standalone
command: ["bun", "run", "modules/rwork/standalone.ts"]
container_name: rtasks-standalone
command: ["bun", "run", "modules/rtasks/standalone.ts"]
labels:
<<: *traefik-enabled
traefik.http.routers.rwork-sa.rule: Host(`rwork.online`)
traefik.http.routers.rwork-sa.entrypoints: web
traefik.http.services.rwork-sa.loadbalancer.server.port: "3000"
traefik.http.routers.rtasks-sa.rule: Host(`rtasks.online`)
traefik.http.routers.rtasks-sa.entrypoints: web
traefik.http.services.rtasks-sa.loadbalancer.server.port: "3000"
# ── rTrips ──
rtrips-standalone:

View File

@ -49,6 +49,7 @@ services:
- INFISICAL_AI_PROJECT_SLUG=claude-ops
- INFISICAL_AI_SECRET_PATH=/ai
- LISTMONK_URL=https://newsletter.cosmolocal.world
- NOTEBOOK_API_URL=http://open-notebook:5055
depends_on:
rspace-db:
condition: service_healthy
@ -88,10 +89,10 @@ services:
- "traefik.http.routers.rspace-rvote.entrypoints=web"
- "traefik.http.routers.rspace-rvote.priority=120"
- "traefik.http.routers.rspace-rvote.service=rspace-online"
- "traefik.http.routers.rspace-rwork.rule=Host(`rwork.online`) || HostRegexp(`{sub:[a-z0-9-]+}.rwork.online`)"
- "traefik.http.routers.rspace-rwork.entrypoints=web"
- "traefik.http.routers.rspace-rwork.priority=120"
- "traefik.http.routers.rspace-rwork.service=rspace-online"
- "traefik.http.routers.rspace-rtasks.rule=Host(`rtasks.online`) || HostRegexp(`{sub:[a-z0-9-]+}.rtasks.online`)"
- "traefik.http.routers.rspace-rtasks.entrypoints=web"
- "traefik.http.routers.rspace-rtasks.priority=120"
- "traefik.http.routers.rspace-rtasks.service=rspace-online"
- "traefik.http.routers.rspace-rcal.rule=Host(`rcal.online`) || HostRegexp(`{sub:[a-z0-9-]+}.rcal.online`)"
- "traefik.http.routers.rspace-rcal.entrypoints=web"
- "traefik.http.routers.rspace-rcal.priority=120"
@ -252,6 +253,32 @@ services:
retries: 5
start_period: 10s
# ── Open Notebook (NotebookLM-like RAG service) ──
open-notebook:
image: ghcr.io/lfnovo/open-notebook:v1-latest-single
container_name: open-notebook
restart: always
env_file: ./open-notebook.env
volumes:
- open-notebook-data:/app/data
- open-notebook-db:/mydata
networks:
- traefik-public
- ai-internal
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
# Frontend UI
- "traefik.http.routers.rspace-notebook.rule=Host(`notebook.rspace.online`)"
- "traefik.http.routers.rspace-notebook.entrypoints=web"
- "traefik.http.routers.rspace-notebook.tls.certresolver=letsencrypt"
- "traefik.http.services.rspace-notebook.loadbalancer.server.port=8502"
# API endpoint (used by rNotes integration)
- "traefik.http.routers.rspace-notebook-api.rule=Host(`notebook-api.rspace.online`)"
- "traefik.http.routers.rspace-notebook-api.entrypoints=web"
- "traefik.http.routers.rspace-notebook-api.tls.certresolver=letsencrypt"
- "traefik.http.services.rspace-notebook-api.loadbalancer.server.port=5055"
volumes:
rspace-data:
rspace-books:
@ -262,6 +289,8 @@ volumes:
rspace-backups:
rspace-pgdata:
encryptid-pgdata:
open-notebook-data:
open-notebook-db:
networks:
traefik-public:

View File

@ -22,7 +22,7 @@ const MODULE_META: Record<string, { badge: string; color: string; name: string;
rbooks: { badge: "rB", color: "#fda4af", name: "rBooks", icon: "📚" },
rpubs: { badge: "rP", color: "#fda4af", name: "rPubs", icon: "📖" },
rfiles: { badge: "rFi", color: "#67e8f9", name: "rFiles", icon: "📁" },
rwork: { badge: "rWo", color: "#cbd5e1", name: "rWork", icon: "📋" },
rtasks: { badge: "rTa", color: "#cbd5e1", name: "rTasks", icon: "📋" },
rforum: { badge: "rFo", color: "#fcd34d", name: "rForum", icon: "💬" },
rinbox: { badge: "rI", color: "#a5b4fc", name: "rInbox", icon: "📧" },
rtube: { badge: "rTu", color: "#f9a8d4", name: "rTube", icon: "🎬" },
@ -454,7 +454,7 @@ const WIDGET_API: Record<string, { path: string; transform: (data: any) => Widge
};
},
},
rwork: {
rtasks: {
path: "/api/spaces",
transform: (data) => {
const spaces = Array.isArray(data) ? data : [];

View File

@ -7,6 +7,7 @@
import { booksCatalogSchema, booksCatalogDocId, type BooksCatalogDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
interface BookData {
id: string;
@ -31,6 +32,13 @@ export class FolkBookShelf extends HTMLElement {
private _searchTerm = "";
private _selectedTag: string | null = null;
private _offlineUnsub: (() => void) | null = null;
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '.search-input', title: "Search", message: "Search books by title, author, or description.", advanceOnClick: false },
{ target: '.tag', title: "Filter by Tag", message: "Click a tag to filter the library by category.", advanceOnClick: true },
{ target: '.upload-btn', title: "Add Book", message: "Upload a new book as a PDF to share with the community.", advanceOnClick: false },
{ target: '.book-card', title: "Read a Book", message: "Click any book to open it in the flipbook reader.", advanceOnClick: false },
];
static get observedAttributes() {
return ["space-slug"];
@ -52,6 +60,12 @@ export class FolkBookShelf extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadowRoot!,
FolkBookShelf.TOUR_STEPS,
"rbooks_tour_done",
() => this.shadowRoot!.host as HTMLElement,
);
this._spaceSlug = this.getAttribute("space-slug") || this._spaceSlug;
this.render();
if (this._spaceSlug === "demo" || this.getAttribute("space") === "demo") {
@ -59,6 +73,9 @@ export class FolkBookShelf extends HTMLElement {
} else {
this.subscribeOffline();
}
if (!localStorage.getItem("rbooks_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() {
@ -426,6 +443,7 @@ export class FolkBookShelf extends HTMLElement {
<span class="rapp-nav__title">Library</span>
<div class="rapp-nav__actions">
<button class="rapp-nav__btn upload-btn">+ Add Book</button>
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px">Tour</button>
</div>
</div>
@ -446,7 +464,7 @@ export class FolkBookShelf extends HTMLElement {
</div>`
: `<div class="grid">
${books.map((b) => `
<a class="book-card" href="/${this._spaceSlug}/rbooks/read/${b.slug}">
<a class="book-card" data-collab-id="book:${b.id}" href="/${this._spaceSlug}/rbooks/read/${b.slug}">
<div class="book-cover" style="background:${b.cover_color}">
<span class="book-cover-title">${this.escapeHtml(b.title)}</span>
${b.featured ? '<span class="featured-badge">Featured</span>' : ""}
@ -498,11 +516,18 @@ export class FolkBookShelf extends HTMLElement {
`;
this.bindEvents();
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
}
private bindEvents() {
if (!this.shadowRoot) return;
this.shadowRoot.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
// Search
const searchInput = this.shadowRoot.querySelector(".search-input") as HTMLInputElement;
searchInput?.addEventListener("input", () => {

View File

@ -18,6 +18,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rbooks" class="rl-cta-primary" id="ml-primary">Browse Library</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-book-shelf')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- Features -->

View File

@ -104,6 +104,7 @@ function leafletZoomToSpatial(zoom: number): number {
// ── Offline-first imports ──
import { calendarSchema, calendarDocId, type CalendarDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
// ── Component ──
@ -155,19 +156,35 @@ class FolkCalendarView extends HTMLElement {
private mapMarkerLayer: any = null;
private transitLineLayer: any = null;
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '#prev', title: "Navigate", message: "Use the arrow buttons to move between time periods. Keyboard shortcuts: left/right arrows.", advanceOnClick: false },
{ target: '#calendar-pane', title: "Calendar View", message: "Click any day cell to add an event. Drag events to reschedule. Use keyboard 1-5 to switch views.", advanceOnClick: false },
{ target: '#toggle-lunar', title: "Lunar Phases", message: "Toggle lunar phase display to see moon cycles alongside your events. Press 'L' as a shortcut.", advanceOnClick: true },
{ target: '#map-fab, .map-panel', title: "Map View", message: "Events with locations appear on the map. Expand to see spatial context alongside your calendar.", advanceOnClick: false },
];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkCalendarView.TOUR_STEPS,
"rcal_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e);
document.addEventListener("keydown", this.boundKeyHandler);
if (this.space === "demo") { this.loadDemoData(); return; }
this.subscribeOffline();
this.loadMonth();
this.render();
if (this.space === "demo") { this.loadDemoData(); }
else { this.subscribeOffline(); this.loadMonth(); this.render(); }
if (!localStorage.getItem("rcal_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() {
@ -784,6 +801,7 @@ class FolkCalendarView extends HTMLElement {
<button class="nav-btn" id="today">Today</button>
<span class="nav-title">${this.getViewLabel()}</span>
<button class="nav-btn" id="next">\u2192</button>
<button class="nav-btn" id="btn-tour" style="margin-left:auto;font-size:0.78rem">Tour</button>
</div>
${this.renderSources()}
@ -806,6 +824,7 @@ class FolkCalendarView extends HTMLElement {
`;
this.attachListeners();
this._tour.renderOverlay();
// Play transition if pending
if (this._pendingTransition && this._ghostHtml) {
@ -1499,7 +1518,7 @@ class FolkCalendarView extends HTMLElement {
</div>
${allDay.length > 0 ? `<div class="day-allday">
<div class="day-allday-label">All Day</div>
${allDay.map(e => `<div class="dd-event" data-event-id="${e.id}">
${allDay.map(e => `<div class="dd-event" data-event-id="${e.id}" data-collab-id="event:${e.id}">
<div class="dd-color" style="background:${e.source_color || "#6366f1"}"></div>
<div class="dd-info"><div class="dd-title">${this.esc(e.title)}</div></div>
</div>`).join("")}
@ -1607,7 +1626,7 @@ class FolkCalendarView extends HTMLElement {
const srcTag = e.source_name ? `<span class="dd-source" style="border-color:${e.source_color || '#666'};color:${e.source_color || '#aaa'}">${this.esc(e.source_name)}</span>` : "";
const es = this.getEventStyles(e);
const likelihoodBadge = es.isTentative ? `<span class="dd-likelihood">${es.likelihoodLabel}</span>` : "";
return `<div class="dd-event" data-event-id="${e.id}" style="opacity:${es.opacity}">
return `<div class="dd-event" data-event-id="${e.id}" data-collab-id="event:${e.id}" style="opacity:${es.opacity}">
<div class="dd-color" style="background:${e.source_color || "#6366f1"};${es.isTentative ? "border:1px dashed " + (e.source_color || "#6366f1") + ";background:transparent" : ""}"></div>
<div class="dd-info">
<div class="dd-title">${this.esc(e.title)}${likelihoodBadge}${srcTag}</div>
@ -1705,12 +1724,16 @@ class FolkCalendarView extends HTMLElement {
}
}
startTour() { this._tour.start(); }
// ── Attach Listeners ──
private attachListeners() {
const $ = (id: string) => this.shadow.getElementById(id);
const $$ = (sel: string) => this.shadow.querySelectorAll(sel);
$("btn-tour")?.addEventListener("click", () => this.startTour());
// Nav
$("prev")?.addEventListener("click", () => this.navigate(-1));
$("next")?.addEventListener("click", () => this.navigate(1));

View File

@ -27,6 +27,11 @@ export function renderLanding(): string {
</a>
<a href="#features" class="rl-cta-secondary">Learn More</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-calendar-view')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- Principles (4-card grid) -->

View File

@ -10,6 +10,8 @@ import {
shoppingCartSchema, shoppingCartDocId, type ShoppingCartDoc,
} from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
class FolkCartShop extends HTMLElement {
private shadow: ShadowRoot;
@ -26,10 +28,26 @@ class FolkCartShop extends HTMLElement {
private extensionInstalled = false;
private bannerDismissed = false;
private _offlineUnsubs: (() => void)[] = [];
private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "orders">("carts");
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '[data-view="carts"]', title: "Carts", message: "View and manage group shopping carts. Each cart collects items from multiple contributors.", advanceOnClick: true },
{ target: "[data-action='new-cart']", title: "Create a Cart", message: "Start a new group cart — add a name and invite contributors to add items.", advanceOnClick: true },
{ target: '[data-view="catalog"]', title: "Catalog", message: "Browse the cosmolocal catalog of available products and add them to your carts.", advanceOnClick: true },
{ target: '[data-view="orders"]', title: "Orders", message: "Track submitted orders and their status. Click to finish the tour!", advanceOnClick: true },
];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkCartShop.TOUR_STEPS,
"rcart_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
@ -46,13 +64,16 @@ class FolkCartShop extends HTMLElement {
if (this.space === "demo") {
this.loadDemoData();
return;
}
} else {
this.render();
this.subscribeOffline();
this.loadData();
}
// Auto-start tour on first visit
if (!localStorage.getItem("rcart_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub();
@ -342,11 +363,23 @@ class FolkCartShop extends HTMLElement {
<button class="tab ${this.view === 'catalog' ? 'active' : ''}" data-view="catalog">📦 Catalog (${this.catalog.length})</button>
<button class="tab ${this.view === 'orders' ? 'active' : ''}" data-view="orders">📋 Orders (${this.orders.length})</button>
</div>
<button class="tab" id="btn-tour" style="margin-left:auto;font-size:0.8rem">Tour</button>
</div>
${content}
`;
this.bindEvents();
this._tour.renderOverlay();
}
startTour() { this._tour.start(); }
private goBack() {
const prev = this._history.back();
if (!prev) return;
this.view = prev.view;
if (prev.view !== "cart-detail") this.selectedCartId = null;
this.render();
}
private bindEvents() {
@ -354,12 +387,19 @@ class FolkCartShop extends HTMLElement {
this.shadow.querySelectorAll(".tab").forEach((el) => {
el.addEventListener("click", () => {
const v = (el as HTMLElement).dataset.view as any;
if (v && v !== this.view) {
this._history.push(this.view);
this._history.push(v);
}
this.view = v;
if (v === 'carts') this.selectedCartId = null;
this.render();
});
});
// Tour button
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
// Extension banner dismiss
this.shadow.querySelector("[data-action='dismiss-banner']")?.addEventListener("click", () => {
this.bannerDismissed = true;
@ -370,15 +410,15 @@ class FolkCartShop extends HTMLElement {
// Cart card clicks
this.shadow.querySelectorAll("[data-cart-id]").forEach((el) => {
el.addEventListener("click", () => {
this._history.push(this.view);
this._history.push("cart-detail", { cartId: (el as HTMLElement).dataset.cartId });
this.loadCartDetail((el as HTMLElement).dataset.cartId!);
});
});
// Back button
this.shadow.querySelector("[data-action='back']")?.addEventListener("click", () => {
this.view = "carts";
this.selectedCartId = null;
this.render();
this.goBack();
});
// New cart form
@ -527,9 +567,7 @@ class FolkCartShop extends HTMLElement {
`).join("") : `<div class="card-meta">No contributions yet.</div>`;
return `
<div style="margin-bottom:1rem">
<button data-action="back" class="btn btn-sm"> Back to Carts</button>
</div>
${this._history.canGoBack ? '<div style="margin-bottom:1rem"><button data-action="back" class="btn btn-sm">\u2190 Back</button></div>' : ''}
<div class="detail-layout">
<div class="detail-left">
@ -582,7 +620,7 @@ class FolkCartShop extends HTMLElement {
return `<div class="grid">
${this.catalog.map((entry) => `
<div class="card">
<div class="card" data-collab-id="product:${entry.id || entry.title}">
<h3 class="card-title">${this.esc(entry.title || "Untitled")}</h3>
<div class="card-meta">
${entry.product_type ? `<span class="tag tag-type">${this.esc(entry.product_type)}</span>` : ""}
@ -607,7 +645,7 @@ class FolkCartShop extends HTMLElement {
return `<div class="grid">
${this.orders.map((order) => `
<div class="card">
<div class="card" data-collab-id="order:${order.id}">
<div class="order-card">
<div class="order-info">
<h3 class="card-title">${this.esc(order.artifact_title || "Order")}</h3>

View File

@ -16,6 +16,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rcart" class="rl-cta-primary" id="ml-primary">Start Shopping</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-cart-shop')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- Features -->

View File

@ -3,6 +3,8 @@
* from the current space and links to the canvas to create/interact with them.
*/
import { TourEngine } from "../../../shared/tour-engine";
class FolkChoicesDashboard extends HTMLElement {
private shadow: ShadowRoot;
private choices: any[] = [];
@ -19,20 +21,37 @@ class FolkChoicesDashboard extends HTMLElement {
private votedId: string | null = null;
private simTimer: number | null = null;
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '[data-tab="spider"]', title: "Spider Charts", message: "Compare multiple criteria on a radar chart. Each participant's scores overlay in real time.", advanceOnClick: true },
{ target: '[data-tab="ranking"]', title: "Rankings", message: "Drag items to rank them. Rankings aggregate across all participants for a collective order.", advanceOnClick: true },
{ target: '[data-tab="voting"]', title: "Voting", message: "Cast your vote and watch results update live with animated bars and totals.", advanceOnClick: true },
];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this.space = this.getAttribute("space") || "demo";
this._tour = new TourEngine(
this.shadow,
FolkChoicesDashboard.TOUR_STEPS,
"rchoices_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
if (this.space === "demo") {
this.loadDemoData();
return;
}
} else {
this.render();
this.loadChoices();
}
if (!localStorage.getItem("rchoices_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() {
if (this.simTimer !== null) {
@ -124,7 +143,7 @@ class FolkChoicesDashboard extends HTMLElement {
private renderGrid(icons: Record<string, string>, labels: Record<string, string>): string {
return `<div class="grid">
${this.choices.map((ch) => `
<a class="card" href="/${this.space}/rspace">
<a class="card" data-collab-id="choice:${ch.id}" href="/${this.space}/rspace">
<div class="card-icon">${icons[ch.type] || "☑"}</div>
<div class="card-type">${labels[ch.type] || ch.type}</div>
<h3 class="card-title">${this.esc(ch.title)}</h3>
@ -247,6 +266,7 @@ class FolkChoicesDashboard extends HTMLElement {
<div class="rapp-nav">
<span class="rapp-nav__title">Choices</span>
<span class="demo-badge">DEMO</span>
<button class="demo-tab" id="btn-tour" style="margin-left:auto;font-size:0.78rem">Tour</button>
</div>
<div class="demo-tabs">
@ -259,8 +279,11 @@ class FolkChoicesDashboard extends HTMLElement {
`;
this.bindDemoEvents();
this._tour.renderOverlay();
}
startTour() { this._tour.start(); }
/* -- Spider Chart -- */
private polarToXY(cx: number, cy: number, radius: number, angleDeg: number): { x: number; y: number } {
@ -383,6 +406,7 @@ class FolkChoicesDashboard extends HTMLElement {
/* -- Demo event binding -- */
private bindDemoEvents() {
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
// Tab switching
this.shadow.querySelectorAll<HTMLButtonElement>(".demo-tab").forEach((btn) => {
btn.addEventListener("click", () => {

View File

@ -17,6 +17,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rchoices" class="rl-cta-primary" id="ml-primary">Create a Choice Room</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-choices-dashboard')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- Three tools -->

View File

@ -29,7 +29,7 @@ class FolkAnalyticsView extends HTMLElement {
cookiesSet: 0,
scriptSize: "~2KB",
selfHosted: true,
apps: ["rSpace", "rBooks", "rCart", "rFlows", "rVote", "rWork", "rCal", "rTrips", "rPubs", "rForum", "rInbox", "rMaps", "rTube", "rChoices", "rWallet", "rData", "rNotes"],
apps: ["rSpace", "rBooks", "rCart", "rFlows", "rVote", "rTasks", "rCal", "rTrips", "rPubs", "rForum", "rInbox", "rMaps", "rTube", "rChoices", "rWallet", "rData", "rNotes"],
dashboardUrl: "https://analytics.rspace.online",
};
this.render();

View File

@ -19,7 +19,7 @@ const UMAMI_WEBSITE_ID = process.env.UMAMI_WEBSITE_ID || "292f6ac6-79f8-497b-ba6
const TRACKED_APPS = [
"rSpace", "rNotes", "rVote", "rFlows", "rCart", "rWallet",
"rPubs", "rChoices", "rCal", "rForum", "rInbox", "rFiles",
"rTrips", "rTube", "rWork", "rNetwork", "rData",
"rTrips", "rTube", "rTasks", "rNetwork", "rData",
];
// ── API routes ──

View File

@ -7,6 +7,7 @@
import { filesSchema, type FilesDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
class FolkFileBrowser extends HTMLElement {
private shadow: ShadowRoot;
@ -16,10 +17,22 @@ class FolkFileBrowser extends HTMLElement {
private tab: "files" | "cards" = "files";
private loading = false;
private _offlineUnsubs: (() => void)[] = [];
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '.tab-btn[data-tab="files"]', title: "Files Tab", message: "Browse uploaded files — download, share, or delete them.", advanceOnClick: true },
{ target: '.tab-btn[data-tab="cards"]', title: "Memory Cards", message: "Create and manage memory cards to organise your knowledge.", advanceOnClick: true },
{ target: '#upload-form', title: "Upload Files", message: "Upload files to your space. They're stored locally and synced when online.", advanceOnClick: false },
];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkFileBrowser.TOUR_STEPS,
"rfiles_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
@ -27,14 +40,16 @@ class FolkFileBrowser extends HTMLElement {
if (this.space === "demo") {
this.loadDemoData();
return;
}
} else {
this.render();
this.subscribeOffline();
this.loadFiles();
this.loadCards();
}
if (!localStorage.getItem("rfiles_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub();
@ -429,11 +444,14 @@ class FolkFileBrowser extends HTMLElement {
<div class="tabs">
<div class="tab-btn" data-tab="files">📁 Files</div>
<div class="tab-btn" data-tab="cards">🎴 Memory Cards</div>
<button class="tab-btn" id="btn-tour" style="margin-left:auto;font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
</div>
${filesActive ? this.renderFilesTab() : this.renderCardsTab()}
`;
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.querySelectorAll(".tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
this.tab = (btn as HTMLElement).dataset.tab as "files" | "cards";
@ -464,6 +482,12 @@ class FolkFileBrowser extends HTMLElement {
}
});
});
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
}
private renderFilesTab(): string {
@ -487,7 +511,7 @@ class FolkFileBrowser extends HTMLElement {
${this.files
.map(
(f) => `
<div class="file-card">
<div class="file-card" data-collab-id="file:${f.id}">
<div class="file-icon">${this.mimeIcon(f.mime_type)}</div>
<div class="file-name" title="${this.esc(f.original_filename)}">${this.esc(f.title || f.original_filename)}</div>
<div class="file-meta">${this.formatSize(f.file_size)} &middot; ${this.formatDate(f.created_at)}</div>
@ -533,7 +557,7 @@ class FolkFileBrowser extends HTMLElement {
${this.cards
.map(
(c) => `
<div class="memory-card">
<div class="memory-card" data-collab-id="card:${c.id}">
<div class="card-header">
<span class="card-title">${this.cardTypeIcon(c.card_type)} ${this.esc(c.title)}</span>
<span class="card-type">${c.card_type}</span>

View File

@ -18,6 +18,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rfiles" class="rl-cta-primary" id="ml-primary">Start Sharing</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-file-browser')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- Features -->

View File

@ -13,6 +13,7 @@
import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation } from "../lib/types";
import { PORT_DEFS, deriveThresholds } from "../lib/types";
import { TourEngine } from "../../../shared/tour-engine";
import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets";
import { mapFlowToNodes } from "../lib/map-flow";
@ -154,9 +155,8 @@ class FolkFlowsApp extends HTMLElement {
private flowManagerOpen = false;
private _lfcUnsub: (() => void) | null = null;
// Tour state
private tourActive = false;
private tourStep = 0;
// Tour engine
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '[data-canvas-action="add-source"]', title: "Add a Source", message: "Sources represent inflows of resources. Click the + Source button to add one.", advanceOnClick: true },
{ target: '[data-canvas-action="add-funnel"]', title: "Add a Funnel", message: "Funnels allocate resources between spending and overflow. Click + Funnel to add one.", advanceOnClick: true },
@ -168,6 +168,12 @@ class FolkFlowsApp extends HTMLElement {
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkFlowsApp.TOUR_STEPS,
"rflows_tour_done",
() => this.shadow.getElementById("canvas-container"),
);
}
connectedCallback() {
@ -1777,7 +1783,7 @@ class FolkFlowsApp extends HTMLElement {
}).join("");
}
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})">
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="8" fill="transparent" stroke="none"/>
<rect class="faucet-pipe" x="0" y="${pipeY}" width="${w}" height="${pipeH}" rx="${pipeRx}" fill="url(#faucet-pipe-grad)" stroke="${selected ? "var(--rflows-selected)" : "var(--rs-text-secondary)"}" stroke-width="${selected ? 2 : 1}"/>
<text x="${w / 2}" y="${pipeY + pipeH / 2 + 1}" text-anchor="middle" dominant-baseline="central" fill="white" font-size="11" font-weight="600" pointer-events="none">${this.esc(d.label)}</text>
@ -1908,7 +1914,7 @@ class FolkFlowsApp extends HTMLElement {
const statusBadgeBg = isCritical ? "rgba(239,68,68,0.15)" : isOverflow ? "rgba(16,185,129,0.15)" : "rgba(245,158,11,0.15)";
const statusBadgeColor = isCritical ? "#ef4444" : isOverflow ? "#10b981" : "#f59e0b";
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})" style="${glowStyle}">
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})" style="${glowStyle}">
<defs>
<clipPath id="${clipId}"><path d="${tankPath}"/></clipPath>
</defs>
@ -1982,7 +1988,7 @@ class FolkFlowsApp extends HTMLElement {
const dollarLabel = `${this.formatDollar(d.fundingReceived)} / ${this.formatDollar(d.fundingTarget)}`;
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})">
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="12" fill="var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : statusColor}" stroke-width="${selected ? 3 : 2}"/>
<foreignObject x="0" y="0" width="${w}" height="${h}">
<div xmlns="http://www.w3.org/1999/xhtml" class="node-card outcome-card ${selected ? "selected" : ""}">
@ -4973,105 +4979,7 @@ class FolkFlowsApp extends HTMLElement {
// ── Guided Tour ──
startTour() {
this.tourActive = true;
this.tourStep = 0;
this.renderTourOverlay();
}
private advanceTour() {
this.tourStep++;
if (this.tourStep >= FolkFlowsApp.TOUR_STEPS.length) {
this.endTour();
} else {
this.renderTourOverlay();
}
}
private endTour() {
this.tourActive = false;
this.tourStep = 0;
localStorage.setItem("rflows_tour_done", "1");
const overlay = this.shadow.getElementById("flows-tour-overlay");
if (overlay) overlay.remove();
}
private renderTourOverlay() {
// Remove existing overlay
let overlay = this.shadow.getElementById("flows-tour-overlay");
if (!overlay) {
overlay = document.createElement("div");
overlay.id = "flows-tour-overlay";
overlay.className = "flows-tour-overlay";
const container = this.shadow.getElementById("canvas-container");
if (container) container.appendChild(overlay);
else this.shadow.appendChild(overlay);
}
const step = FolkFlowsApp.TOUR_STEPS[this.tourStep];
const targetEl = this.shadow.querySelector(step.target) as HTMLElement | null;
// Compute spotlight position
let spotX = 0, spotY = 0, spotW = 120, spotH = 40;
if (targetEl) {
const containerEl = this.shadow.getElementById("canvas-container") || this.shadow.host as HTMLElement;
const containerRect = containerEl.getBoundingClientRect();
const rect = targetEl.getBoundingClientRect();
spotX = rect.left - containerRect.left - 6;
spotY = rect.top - containerRect.top - 6;
spotW = rect.width + 12;
spotH = rect.height + 12;
}
const isLast = this.tourStep >= FolkFlowsApp.TOUR_STEPS.length - 1;
const stepNum = this.tourStep + 1;
const totalSteps = FolkFlowsApp.TOUR_STEPS.length;
// Position tooltip below target
const tooltipTop = spotY + spotH + 12;
const tooltipLeft = Math.max(8, spotX);
overlay.innerHTML = `
<div class="flows-tour-backdrop" style="clip-path: polygon(
0% 0%, 0% 100%, ${spotX}px 100%, ${spotX}px ${spotY}px,
${spotX + spotW}px ${spotY}px, ${spotX + spotW}px ${spotY + spotH}px,
${spotX}px ${spotY + spotH}px, ${spotX}px 100%, 100% 100%, 100% 0%
)"></div>
<div class="flows-tour-spotlight" style="left:${spotX}px;top:${spotY}px;width:${spotW}px;height:${spotH}px"></div>
<div class="flows-tour-tooltip" style="top:${tooltipTop}px;left:${tooltipLeft}px">
<div class="flows-tour-tooltip__step">${stepNum} / ${totalSteps}</div>
<div class="flows-tour-tooltip__title">${step.title}</div>
<div class="flows-tour-tooltip__msg">${step.message}</div>
<div class="flows-tour-tooltip__nav">
${this.tourStep > 0 ? '<button class="flows-tour-tooltip__btn flows-tour-tooltip__btn--prev" data-tour="prev">Back</button>' : ''}
${step.advanceOnClick
? `<span class="flows-tour-tooltip__hint">or click the button above</span>`
: `<button class="flows-tour-tooltip__btn flows-tour-tooltip__btn--next" data-tour="next">${isLast ? 'Finish' : 'Next'}</button>`
}
<button class="flows-tour-tooltip__btn flows-tour-tooltip__btn--skip" data-tour="skip">Skip</button>
</div>
</div>
`;
// Wire tour navigation
overlay.querySelectorAll("[data-tour]").forEach(btn => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const action = (btn as HTMLElement).dataset.tour;
if (action === "next") this.advanceTour();
else if (action === "prev") { this.tourStep = Math.max(0, this.tourStep - 1); this.renderTourOverlay(); }
else if (action === "skip") this.endTour();
});
});
// For advanceOnClick steps, listen for the target button click
if (step.advanceOnClick && targetEl) {
const handler = () => {
targetEl.removeEventListener("click", handler);
// Delay slightly so the action completes first
setTimeout(() => this.advanceTour(), 300);
};
targetEl.addEventListener("click", handler);
}
this._tour.start();
}
private esc(s: string): string {

View File

@ -7,6 +7,8 @@
import { forumSchema, FORUM_DOC_ID, type ForumDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
class FolkForumDashboard extends HTMLElement {
private shadow: ShadowRoot;
@ -18,19 +20,37 @@ class FolkForumDashboard extends HTMLElement {
private pollTimer: number | null = null;
private space = "";
private _offlineUnsub: (() => void) | null = null;
private _history = new ViewHistory<"list" | "detail" | "create">("list");
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '.instance-card', title: "Forum Instances", message: "View your deployed Discourse forums — click one for status and logs.", advanceOnClick: false },
{ target: '[data-action="show-create"]', title: "New Forum", message: "Deploy a new self-hosted Discourse forum in minutes.", advanceOnClick: true },
{ target: '.price-card', title: "Choose a Plan", message: "Select a hosting tier based on your community size and needs.", advanceOnClick: false },
];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkForumDashboard.TOUR_STEPS,
"rforum_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
this.space = this.getAttribute("space") || "";
if (this.space === "demo") { this.loadDemoData(); return; }
if (this.space === "demo") { this.loadDemoData(); }
else {
this.subscribeOffline();
this.render();
this.loadInstances();
}
if (!localStorage.getItem("rforum_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
private loadDemoData() {
this.instances = [
@ -318,6 +338,11 @@ class FolkForumDashboard extends HTMLElement {
`;
this.attachEvents();
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
}
private renderList(): string {
@ -325,6 +350,7 @@ class FolkForumDashboard extends HTMLElement {
<div class="rapp-nav">
<span class="rapp-nav__title">Forum Instances</span>
<button class="rapp-nav__btn" data-action="show-create">+ New Forum</button>
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
</div>
${this.loading ? '<div class="loading">Loading...</div>' : ""}
@ -332,7 +358,7 @@ class FolkForumDashboard extends HTMLElement {
<div class="instance-list">
${this.instances.map((inst) => `
<div class="instance-card" data-action="detail" data-id="${inst.id}">
<div class="instance-card" data-action="detail" data-id="${inst.id}" data-collab-id="forum:${inst.id}">
<div class="instance-header">
<span class="instance-name">${this.esc(inst.name)}</span>
${this.statusBadge(inst.status)}
@ -353,7 +379,7 @@ class FolkForumDashboard extends HTMLElement {
return `
<div class="rapp-nav">
<button class="rapp-nav__back" data-action="back"> Forums</button>
${this._history.canGoBack ? '<button class="rapp-nav__back" data-action="back">← Forums</button>' : ''}
<span class="rapp-nav__title">${this.esc(inst.name)}</span>
${inst.status !== "destroyed" ? `<button class="danger" data-action="destroy" data-id="${inst.id}">Destroy</button>` : ""}
</div>
@ -398,7 +424,7 @@ class FolkForumDashboard extends HTMLElement {
private renderCreate(): string {
return `
<div class="rapp-nav">
<button class="rapp-nav__back" data-action="back"> Forums</button>
${this._history.canGoBack ? '<button class="rapp-nav__back" data-action="back">← Forums</button>' : ''}
<span class="rapp-nav__title">Deploy New Forum</span>
</div>
@ -466,16 +492,17 @@ class FolkForumDashboard extends HTMLElement {
}
private attachEvents() {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.querySelectorAll("[data-action]").forEach((el) => {
const action = (el as HTMLElement).dataset.action!;
const id = (el as HTMLElement).dataset.id;
el.addEventListener("click", () => {
if (action === "show-create") { this.view = "create"; this.render(); }
if (action === "show-create") { this._history.push(this.view); this._history.push("create"); this.view = "create"; this.render(); }
else if (action === "back") {
if (this.pollTimer) clearInterval(this.pollTimer);
this.view = "list"; this.loadInstances();
this.goBack();
}
else if (action === "detail" && id) { this.loadInstanceDetail(id); }
else if (action === "detail" && id) { this._history.push(this.view); this._history.push("detail", { id }); this.loadInstanceDetail(id); }
else if (action === "destroy" && id) { this.handleDestroy(id); }
});
});
@ -493,6 +520,15 @@ class FolkForumDashboard extends HTMLElement {
if (form) form.addEventListener("submit", (e) => this.handleCreate(e));
}
private goBack() {
if (this.pollTimer) clearInterval(this.pollTimer);
const prev = this._history.back();
if (!prev) return;
this.view = prev.view;
if (prev.view === "list") this.loadInstances();
else this.render();
}
private formatStep(step: string): string {
const labels: Record<string, string> = {
create_vps: "Create Server",

View File

@ -17,6 +17,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rforum" class="rl-cta-primary" id="ml-primary">Get Started</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-forum-dashboard')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- How It Works -->

View File

@ -20,6 +20,11 @@ export function renderLanding(): string {
</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-inbox-client')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- What rInbox Does -->

View File

@ -12,6 +12,8 @@
import { RoomSync, type RoomState, type ParticipantState, type LocationState } from "./map-sync";
import { loadRoomHistory, saveRoomVisit, updateRoomThumbnail } from "./map-room-history";
import { MapPushManager } from "./map-push";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
// MapLibre loaded via CDN — use window access with type assertion
@ -88,6 +90,16 @@ class FolkMapViewer extends HTMLElement {
private pushManager: MapPushManager | null = null;
private thumbnailTimer: ReturnType<typeof setTimeout> | null = null;
private _themeObserver: MutationObserver | null = null;
private _history = new ViewHistory<"lobby" | "map">("lobby");
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '#create-room', title: "Create a Room", message: "Start a new map room to share locations with others in real time.", advanceOnClick: true },
{ target: '.room-card, .history-card', title: "Join a Room", message: "Click any room card to enter and see participants on the map.", advanceOnClick: false },
{ target: '#share-location', title: "Share Location", message: "Toggle location sharing so others in the room can see where you are.", advanceOnClick: false },
{ target: '#drop-waypoint', title: "Drop a Pin", message: "Drop a waypoint pin on the map to mark a point of interest for everyone.", advanceOnClick: false },
];
private isDarkTheme(): boolean {
const theme = document.documentElement.getAttribute("data-theme");
@ -98,6 +110,12 @@ class FolkMapViewer extends HTMLElement {
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkMapViewer.TOUR_STEPS,
"rmaps_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
@ -105,17 +123,20 @@ class FolkMapViewer extends HTMLElement {
this.room = this.getAttribute("room") || "";
if (this.space === "demo") {
this.loadDemoData();
return;
}
} else {
this.loadUserProfile();
this.pushManager = new MapPushManager(this.getApiBase());
if (this.room) {
this.joinRoom(this.room);
return;
}
} else {
this.checkSyncHealth();
this.render();
}
}
if (!localStorage.getItem("rmaps_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() {
this.leaveRoom();
@ -1447,15 +1468,18 @@ class FolkMapViewer extends HTMLElement {
`;
this.attachListeners();
this._tour.renderOverlay();
}
startTour() { this._tour.start(); }
private renderLobby(): string {
const history = loadRoomHistory();
const historyCards = history.length > 0 ? `
<div class="section-label">Recent Rooms</div>
<div class="room-history-grid">
${history.map((h) => `
<div class="history-card" data-room="${this.esc(h.slug)}">
<div class="history-card" data-room="${this.esc(h.slug)}" data-collab-id="room:${this.esc(h.slug)}">
${h.thumbnail
? `<img class="history-thumb" src="${h.thumbnail}" alt="">`
: `<div class="history-thumb-placeholder">&#127760;</div>`
@ -1476,12 +1500,13 @@ class FolkMapViewer extends HTMLElement {
<span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span>
<span style="font-size:12px;color:var(--rs-text-muted);margin-right:12px">${this.syncStatus === "connected" ? "Sync online" : "Sync offline"}</span>
<button class="rapp-nav__btn" id="create-room">+ New Room</button>
<button class="rapp-nav__btn" id="btn-tour" style="background:transparent;border:1px solid var(--rs-border,#334155);color:var(--rs-text-secondary,#94a3b8);font-weight:500">Tour</button>
</div>
${this.rooms.length > 0 ? `
<div class="section-label">Active Rooms</div>
${this.rooms.map((r) => `
<div class="room-card" data-room="${r}">
<div class="room-card" data-room="${r}" data-collab-id="room:${r}">
<span class="room-icon">&#128506;</span>
<span class="room-name">${this.esc(r)}</span>
</div>
@ -1501,7 +1526,7 @@ class FolkMapViewer extends HTMLElement {
const shareUrl = `${window.location.origin}/${this.space}/maps/${this.room}`;
return `
<div class="rapp-nav">
<button class="rapp-nav__back" data-back="lobby">&#8592; Rooms</button>
${this._history.canGoBack ? '<button class="rapp-nav__back" data-back="lobby">&#8592; Rooms</button>' : ''}
<span class="rapp-nav__title">&#128506; ${this.esc(this.room)}</span>
<span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span>
</div>
@ -1532,20 +1557,21 @@ class FolkMapViewer extends HTMLElement {
}
private attachListeners() {
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.getElementById("create-room")?.addEventListener("click", () => this.createRoom());
this.shadow.querySelectorAll("[data-room]").forEach((el) => {
el.addEventListener("click", () => {
const room = (el as HTMLElement).dataset.room!;
this._history.push("lobby");
this._history.push("map", { room });
this.joinRoom(room);
});
});
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
el.addEventListener("click", () => {
this.leaveRoom();
this.view = "lobby";
this.loadStats();
this.goBack();
});
});
@ -1578,6 +1604,16 @@ class FolkMapViewer extends HTMLElement {
});
}
private goBack() {
const prev = this._history.back();
if (!prev) return;
if (prev.view === "lobby" && this.view === "map") {
this.leaveRoom();
}
this.view = prev.view;
if (prev.view === "lobby") this.loadStats();
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";

View File

@ -16,6 +16,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rmaps" class="rl-cta-primary" id="ml-primary">Open Maps</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-map-viewer')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- Features -->

View File

@ -83,7 +83,7 @@ export function renderLanding(): string {
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128196;</div>
<h3>Meeting Notes &amp; Docs</h3>
<p>Capture notes in rNotes, attach files in rFiles, link action items to rWork &mdash; all within the same space, all self-hosted.</p>
<p>Capture notes in rNotes, attach files in rFiles, link action items to rTasks &mdash; all within the same space, all self-hosted.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128197;</div>
@ -121,7 +121,7 @@ export function renderLanding(): string {
<div class="rl-icon-box">&#128268;</div>
<h3>Data Integrations</h3>
<span class="rl-badge">Coming Soon</span>
<p>Auto-link recordings to documents, push summaries to rNotes, and sync action items to rWork task boards.</p>
<p>Auto-link recordings to documents, push summaries to rNotes, and sync action items to rTasks task boards.</p>
</div>
</div>
</div>

View File

@ -53,6 +53,8 @@ const STAGE_LABELS: Record<string, string> = {
CLOSED_LOST: "Lost",
};
import { TourEngine } from "../../../shared/tour-engine";
class FolkCrmView extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
@ -67,15 +69,34 @@ class FolkCrmView extends HTMLElement {
private loading = true;
private error = "";
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '[data-tab="pipeline"]', title: "Pipeline", message: "Track deals through stages — from incoming leads to closed-won. Drag cards to update their stage.", advanceOnClick: true },
{ target: '[data-tab="contacts"]', title: "Contacts", message: "View and search your contact directory. Click a contact to see their details.", advanceOnClick: true },
{ target: '#crm-search', title: "Search", message: "Search across contacts and companies by name, email, or city. Results filter in real time.", advanceOnClick: false },
{ target: '[data-tab="graph"]', title: "Relationship Graph", message: "Visualise connections between people and companies as an interactive network graph.", advanceOnClick: true },
];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkCrmView.TOUR_STEPS,
"rnetwork_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.render();
this.loadData();
// Auto-start tour on first visit
if (!localStorage.getItem("rnetwork_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
private getApiBase(): string {
@ -165,7 +186,7 @@ class FolkCrmView extends HTMLElement {
</div>
${total > 0 ? `<div class="pipeline-total">${this.formatAmount({ amountMicros: total * 1_000_000, currencyCode: "USD" })}</div>` : ""}
<div class="pipeline-cards">
${opps.map(opp => `<div class="pipeline-card">
${opps.map(opp => `<div class="pipeline-card" data-collab-id="opp:${opp.id}">
<div class="card-name">${this.esc(opp.name)}</div>
<div class="card-amount">${this.formatAmount(opp.amount)}</div>
${opp.company?.name ? `<div class="card-company">${this.esc(opp.company.name)}</div>` : ""}
@ -220,7 +241,7 @@ class FolkCrmView extends HTMLElement {
<th>Phone</th>
</tr></thead>
<tbody>
${filtered.map(p => `<tr>
${filtered.map(p => `<tr data-collab-id="contact:${p.id}">
<td class="cell-name">${this.esc(this.personName(p.name))}</td>
<td class="cell-email">${p.email?.primaryEmail ? this.esc(p.email.primaryEmail) : "-"}</td>
<td>${this.esc(this.companyName(p.company))}</td>
@ -273,7 +294,7 @@ class FolkCrmView extends HTMLElement {
<th>Country</th>
</tr></thead>
<tbody>
${filtered.map(c => `<tr>
${filtered.map(c => `<tr data-collab-id="company:${c.id}">
<td class="cell-name">${this.esc(c.name || "-")}</td>
<td class="cell-domain">${c.domainName?.primaryLinkUrl ? `<a href="${this.esc(c.domainName.primaryLinkUrl)}" target="_blank" rel="noopener">${this.esc(c.domainName.primaryLinkUrl)}</a>` : "-"}</td>
<td>${c.employees ?? "-"}</td>
@ -432,6 +453,7 @@ class FolkCrmView extends HTMLElement {
<div class="crm-header">
<span class="crm-title">CRM</span>
<button class="tab" id="btn-tour" style="margin-left:auto;font-size:0.78rem;padding:4px 10px">Tour</button>
</div>
<div class="tabs">
@ -448,9 +470,15 @@ class FolkCrmView extends HTMLElement {
`;
this.attachListeners();
this._tour.renderOverlay();
}
startTour() { this._tour.start(); }
private attachListeners() {
// Tour button
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
// Tab switching
this.shadow.querySelectorAll("[data-tab]").forEach(el => {
el.addEventListener("click", () => {

View File

@ -14,10 +14,168 @@ export function renderLanding(): string {
</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rnotes" class="rl-cta-primary" id="ml-primary">Open Notebook</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
<a href="#transcription-demo" class="rl-cta-secondary">Transcribe</a>
<a href="#extension-download" class="rl-cta-secondary">Get Extension</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-notes-app')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- Live Transcription Demo -->
<section class="rl-section" id="transcription-demo">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Live Transcription Demo</h2>
<p class="rl-subtext" style="text-align:center">Try it right here &mdash; click the mic and start speaking.</p>
<div style="max-width:640px;margin:2rem auto;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);border-radius:1rem;padding:1.5rem;position:relative">
<!-- Unsupported fallback (hidden by default, shown via JS) -->
<div id="transcription-unsupported" style="display:none;text-align:center;padding:2rem 1rem;color:#94a3b8">
<div style="font-size:2rem;margin-bottom:0.75rem">&#9888;&#65039;</div>
<p style="margin:0 0 0.5rem">Live transcription requires <strong>Chrome</strong> or <strong>Edge</strong> (Web Speech API).</p>
<p style="margin:0;font-size:0.85rem;color:#64748b">Try opening this page in a Chromium-based browser to test the demo.</p>
</div>
<!-- Demo UI (hidden if unsupported) -->
<div id="transcription-ui">
<!-- Controls -->
<div style="display:flex;align-items:center;justify-content:center;gap:1rem;margin-bottom:1.25rem">
<button id="mic-btn" style="width:56px;height:56px;border-radius:50%;border:2px solid rgba(245,158,11,0.4);background:rgba(245,158,11,0.1);color:#f59e0b;font-size:1.5rem;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all 0.2s" title="Start transcription">
&#127908;
</button>
<div style="text-align:left">
<div id="mic-status" style="font-size:0.9rem;color:#94a3b8">Click mic to start</div>
<div id="mic-timer" style="font-size:0.75rem;color:#64748b;font-variant-numeric:tabular-nums">00:00</div>
</div>
<div id="live-indicator" style="display:none;background:rgba(239,68,68,0.15);color:#ef4444;font-size:0.7rem;font-weight:600;padding:0.2rem 0.6rem;border-radius:9999px;text-transform:uppercase;letter-spacing:0.05em">
&#9679; Live
</div>
</div>
<!-- Transcript area -->
<div id="transcript-area" style="min-height:120px;max-height:240px;overflow-y:auto;background:rgba(0,0,0,0.2);border-radius:0.5rem;padding:1rem;font-size:0.9rem;line-height:1.6;color:#e2e8f0">
<span style="color:#64748b;font-style:italic">Your transcript will appear here&hellip;</span>
</div>
</div>
<!-- Capability badges -->
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;justify-content:center;margin-top:1.25rem">
<span class="rl-badge" style="background:rgba(34,197,94,0.15);color:#22c55e">&#9679; Live streaming</span>
<span class="rl-badge" style="background:rgba(59,130,246,0.15);color:#3b82f6">&#127925; Audio file upload</span>
<span class="rl-badge" style="background:rgba(168,85,247,0.15);color:#a855f7">&#127909; Video transcription</span>
<span class="rl-badge" style="background:rgba(245,158,11,0.15);color:#f59e0b">&#128268; Offline (Parakeet.js)</span>
</div>
</div>
</div>
</section>
<!-- Transcription Demo Script -->
<script>
(function() {
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
var ui = document.getElementById('transcription-ui');
var unsupported = document.getElementById('transcription-unsupported');
if (!SpeechRecognition) {
if (ui) ui.style.display = 'none';
if (unsupported) unsupported.style.display = 'block';
return;
}
var recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'en-US';
var micBtn = document.getElementById('mic-btn');
var micStatus = document.getElementById('mic-status');
var micTimer = document.getElementById('mic-timer');
var liveIndicator = document.getElementById('live-indicator');
var transcriptArea = document.getElementById('transcript-area');
var isListening = false;
var timerInterval = null;
var seconds = 0;
var finalTranscript = '';
function formatTime(s) {
var m = Math.floor(s / 60);
var sec = s % 60;
return (m < 10 ? '0' : '') + m + ':' + (sec < 10 ? '0' : '') + sec;
}
function startTimer() {
seconds = 0;
micTimer.textContent = '00:00';
timerInterval = setInterval(function() {
seconds++;
micTimer.textContent = formatTime(seconds);
}, 1000);
}
function stopTimer() {
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
}
micBtn.addEventListener('click', function() {
if (!isListening) {
finalTranscript = '';
transcriptArea.innerHTML = '';
recognition.start();
} else {
recognition.stop();
}
});
recognition.onstart = function() {
isListening = true;
micBtn.style.background = 'rgba(239,68,68,0.2)';
micBtn.style.borderColor = '#ef4444';
micBtn.style.color = '#ef4444';
micBtn.title = 'Stop transcription';
micStatus.textContent = 'Listening...';
micStatus.style.color = '#ef4444';
liveIndicator.style.display = 'block';
startTimer();
};
recognition.onend = function() {
isListening = false;
micBtn.style.background = 'rgba(245,158,11,0.1)';
micBtn.style.borderColor = 'rgba(245,158,11,0.4)';
micBtn.style.color = '#f59e0b';
micBtn.title = 'Start transcription';
micStatus.textContent = 'Click mic to start';
micStatus.style.color = '#94a3b8';
liveIndicator.style.display = 'none';
stopTimer();
};
recognition.onresult = function(event) {
var interim = '';
for (var i = event.resultIndex; i < event.results.length; i++) {
var transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
finalTranscript += transcript + ' ';
} else {
interim += transcript;
}
}
transcriptArea.innerHTML = finalTranscript +
(interim ? '<span style="color:#94a3b8">' + interim + '</span>' : '');
transcriptArea.scrollTop = transcriptArea.scrollHeight;
};
recognition.onerror = function(event) {
if (event.error === 'not-allowed') {
micStatus.textContent = 'Microphone access denied';
micStatus.style.color = '#ef4444';
}
};
})();
</script>
<!-- Features -->
<section class="rl-section">
<div class="rl-container">
@ -42,32 +200,75 @@ export function renderLanding(): string {
</div>
</section>
<!-- Chrome Extension -->
<section class="rl-section rl-section--alt" id="extension-download">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Chrome Extension</h2>
<p class="rl-subtext" style="text-align:center">Clip pages, record voice notes, and transcribe &mdash; right from the toolbar.</p>
<div class="rl-grid-2" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:1.5rem;max-width:860px;margin:2rem auto">
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128203;</div>
<h3>Web Clipper</h3>
<p>Save any page as a note with one click &mdash; article text, selection, or full HTML.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#127908;</div>
<h3>Voice Recording</h3>
<p>Press <kbd style="background:rgba(255,255,255,0.1);padding:0.1rem 0.4rem;border-radius:4px;font-size:0.8rem">Ctrl+Shift+V</kbd> to start recording and transcribing from any tab.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128275;</div>
<h3>Article Unlock</h3>
<p>Bypass soft paywalls by fetching archived versions &mdash; read the article, then save it to your notebook.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128268;</div>
<h3>Offline Transcription</h3>
<p>Parakeet.js runs entirely in-browser &mdash; your audio never leaves the device.</p>
</div>
</div>
<div style="text-align:center;margin-top:1.5rem">
<a href="/rnotes/extension/download" class="rl-cta-primary" style="display:inline-flex;align-items:center;gap:0.5rem">
&#11015; Download Extension
</a>
<p style="margin-top:0.75rem;font-size:0.8rem;color:#64748b">
Unzip, then load unpacked at <code style="font-size:0.75rem;color:#94a3b8">chrome://extensions</code>
</p>
</div>
</div>
</section>
<!-- How It Works -->
<section class="rl-section rl-section--alt">
<section class="rl-section">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">How It Works</h2>
<div class="rl-grid-3">
<div class="rl-grid-4" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1.5rem">
<div class="rl-step">
<div class="rl-step__num">1</div>
<h3>Create a Notebook</h3>
<p>Name your notebook and start capturing. Organize by project, topic, or however you think.</p>
<h3>Live Transcribe</h3>
<p>Speak and watch words appear in real time via the Web Speech API. No uploads, no waiting.</p>
</div>
<div class="rl-step">
<div class="rl-step__num">2</div>
<h3>Capture Notes, Voice, or Clips</h3>
<p>Type rich text, record voice with live transcription, or drop in audio and video files to transcribe offline.</p>
<h3>Audio &amp; Video</h3>
<p>Drop files and get full transcripts via Parakeet, running entirely in-browser. Supports MP3, WAV, MP4, and more.</p>
</div>
<div class="rl-step">
<div class="rl-step__num">3</div>
<h3>Search, Tag, and Share</h3>
<p>Find anything instantly with full-text search. Tag and filter your cards, then share notebooks with your team.</p>
<h3>Notebooks &amp; Tags</h3>
<p>Organize transcripts alongside notes, clips, code, and files. Tag, search, and filter across everything.</p>
</div>
<div class="rl-step">
<div class="rl-step__num">4</div>
<h3>Private &amp; Offline</h3>
<p>Parakeet.js runs in-browser &mdash; audio never leaves your device. Works offline once the model is cached.</p>
</div>
</div>
</div>
</section>
<!-- Memory Cards -->
<section class="rl-section">
<section class="rl-section rl-section--alt">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Memory Cards</h2>
<p class="rl-subtext" style="text-align:center">
@ -160,19 +361,23 @@ export function renderLanding(): string {
</section>
<!-- Built on Open Source -->
<section class="rl-section rl-section--alt">
<section class="rl-section">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Built on Open Source</h2>
<p class="rl-subtext" style="text-align:center">The libraries and tools that power rNotes.</p>
<div class="rl-grid-3">
<div class="rl-grid-4" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1.5rem">
<div class="rl-card rl-card--center">
<h3>PostgreSQL</h3>
<p>Notebooks, notes, and tags stored in a battle-tested relational database with full-text search.</p>
<h3>Automerge</h3>
<p>Local-first CRDT for conflict-free real-time collaboration. Your notes sync across devices without a central server.</p>
</div>
<div class="rl-card rl-card--center">
<h3>Web Speech API</h3>
<p>Browser-native live transcription &mdash; speak and watch your words appear in real time.</p>
</div>
<div class="rl-card rl-card--center">
<h3>Parakeet.js</h3>
<p>NVIDIA&rsquo;s in-browser speech recognition. Transcribe audio and video files offline &mdash; nothing leaves your device.</p>
</div>
<div class="rl-card rl-card--center">
<h3>Hono</h3>
<p>Ultra-fast, lightweight API framework powering the rNotes backend.</p>
@ -182,7 +387,7 @@ export function renderLanding(): string {
</section>
<!-- Your Data, Protected -->
<section class="rl-section">
<section class="rl-section rl-section--alt">
<div class="rl-container" style="text-align:center">
<h2 class="rl-heading">Your Data, Protected</h2>
<p class="rl-subtext">How rNotes keeps your information safe.</p>
@ -209,12 +414,13 @@ export function renderLanding(): string {
</section>
<!-- CTA -->
<section class="rl-section rl-section--alt">
<section class="rl-section">
<div class="rl-container" style="text-align:center">
<h2 class="rl-heading">(You)rNotes, your thoughts unbound.</h2>
<p class="rl-subtext">Try the demo or create a space to get started.</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rnotes" class="rl-cta-primary">Open Notebook</a>
<a href="#transcription-demo" class="rl-cta-secondary">Transcribe</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
</div>

View File

@ -8,6 +8,9 @@
* space space slug (default: "demo")
*/
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
interface Album {
id: string;
albumName: string;
@ -44,20 +47,36 @@ class FolkPhotoGallery extends HTMLElement {
private loading = false;
private error = "";
private showingSampleData = false;
private _tour!: TourEngine;
private _history = new ViewHistory<"gallery" | "album" | "lightbox">("gallery");
private static readonly TOUR_STEPS = [
{ target: '.album-card', title: "Albums", message: "Browse shared photo albums — click one to see its photos.", advanceOnClick: false },
{ target: '.photo-cell', title: "Photo Grid", message: "Click any photo to open it in the lightbox viewer.", advanceOnClick: false },
{ target: '.rapp-nav__btn', title: "Open Immich", message: "Launch the full Immich interface for uploads and management.", advanceOnClick: false },
];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkPhotoGallery.TOUR_STEPS,
"rphotos_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
if (this.space === "demo") {
this.loadDemoData();
return;
}
} else {
this.loadGallery();
}
if (!localStorage.getItem("rphotos_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
private loadDemoData() {
this.albums = [
@ -189,6 +208,18 @@ class FolkPhotoGallery extends HTMLElement {
this.render();
}
private goBack() {
const prev = this._history.back();
if (!prev) return;
this.view = prev.view;
if (prev.view === "gallery") {
this.selectedAlbum = null;
this.albumAssets = [];
}
this.lightboxAsset = null;
this.render();
}
private thumbUrl(assetId: string): string {
const base = this.getApiBase();
return `${base}/api/assets/${assetId}/thumbnail`;
@ -353,6 +384,11 @@ class FolkPhotoGallery extends HTMLElement {
`;
this.attachListeners();
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
}
private renderView(): string {
@ -370,6 +406,7 @@ class FolkPhotoGallery extends HTMLElement {
<a class="rapp-nav__btn" href="${this.getImmichUrl()}">
Open Immich
</a>
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
</div>
</div>
@ -391,7 +428,7 @@ class FolkPhotoGallery extends HTMLElement {
<div class="section-title">Shared Albums</div>
<div class="albums-grid">
${this.albums.map((a) => `
<div class="album-card" data-album-id="${a.id}">
<div class="album-card" data-album-id="${a.id}" data-collab-id="album:${a.id}">
<div class="album-thumb">
${this.isDemo()
? `<div class="demo-thumb" style="background:${this.getDemoAlbumColor(a.id)}">${this.esc(a.albumName)}</div>`
@ -413,7 +450,7 @@ class FolkPhotoGallery extends HTMLElement {
<div class="section-title">Recent Photos</div>
<div class="photo-grid">
${this.assets.map((a) => `
<div class="photo-cell" data-asset-id="${a.id}">
<div class="photo-cell" data-asset-id="${a.id}" data-collab-id="photo:${a.id}">
${this.isDemo()
? `<div class="demo-thumb" style="background:${this.getDemoAssetMeta(a.id).color}">${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}</div>`
: `<img src="${this.thumbUrl(a.id)}" alt="${this.esc(a.originalFileName)}" loading="lazy">`}
@ -428,7 +465,7 @@ class FolkPhotoGallery extends HTMLElement {
const album = this.selectedAlbum!;
return `
<div class="rapp-nav">
<button class="rapp-nav__back" data-back="gallery"> Photos</button>
${this._history.canGoBack ? '<button class="rapp-nav__back" data-back="gallery">\u2190 Photos</button>' : ''}
<span class="rapp-nav__title">${this.esc(album.albumName)}</span>
<div class="rapp-nav__actions">
<a class="rapp-nav__btn rapp-nav__btn--secondary" href="${this.getImmichUrl()}">
@ -446,7 +483,7 @@ class FolkPhotoGallery extends HTMLElement {
` : `
<div class="photo-grid">
${this.albumAssets.map((a) => `
<div class="photo-cell" data-asset-id="${a.id}">
<div class="photo-cell" data-asset-id="${a.id}" data-collab-id="photo:${a.id}">
${this.isDemo()
? `<div class="demo-thumb" style="background:${this.getDemoAssetMeta(a.id).color}">${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}</div>`
: `<img src="${this.thumbUrl(a.id)}" alt="${this.esc(a.originalFileName)}" loading="lazy">`}
@ -483,12 +520,18 @@ class FolkPhotoGallery extends HTMLElement {
}
private attachListeners() {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
// Album cards
this.shadow.querySelectorAll("[data-album-id]").forEach((el) => {
el.addEventListener("click", () => {
const id = (el as HTMLElement).dataset.albumId!;
const album = this.albums.find((a) => a.id === id);
if (album) this.loadAlbum(album);
if (album) {
this._history.push(this.view);
this._history.push("album", { albumId: id });
this.loadAlbum(album);
}
});
});
@ -498,24 +541,23 @@ class FolkPhotoGallery extends HTMLElement {
const id = (el as HTMLElement).dataset.assetId!;
const assets = this.view === "album" ? this.albumAssets : this.assets;
const asset = assets.find((a) => a.id === id);
if (asset) this.openLightbox(asset);
if (asset) {
this._history.push(this.view);
this._history.push("lightbox", { assetId: id });
this.openLightbox(asset);
}
});
});
// Back button
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
el.addEventListener("click", () => {
this.selectedAlbum = null;
this.albumAssets = [];
this.view = "gallery";
this.render();
});
el.addEventListener("click", () => this.goBack());
});
// Lightbox close
this.shadow.querySelector("[data-close-lightbox]")?.addEventListener("click", () => this.closeLightbox());
this.shadow.querySelector("[data-close-lightbox]")?.addEventListener("click", () => this.goBack());
this.shadow.querySelector("[data-lightbox]")?.addEventListener("click", (e) => {
if ((e.target as HTMLElement).matches("[data-lightbox]")) this.closeLightbox();
if ((e.target as HTMLElement).matches("[data-lightbox]")) this.goBack();
});
}

View File

@ -17,6 +17,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rphotos" class="rl-cta-primary" id="ml-primary">Browse Photos</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-photo-gallery')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- Features -->

View File

@ -9,6 +9,7 @@
import { pubsDraftSchema, pubsDocId } from '../schemas';
import type { PubsDoc } from '../schemas';
import type { DocumentId } from '../../../shared/local-first/document';
import { TourEngine } from '../../../shared/tour-engine';
interface BookFormat {
id: string;
@ -78,6 +79,13 @@ export class FolkPubsEditor extends HTMLElement {
private _isRemoteUpdate = false;
private _syncTimer: ReturnType<typeof setTimeout> | null = null;
private _syncConnected = false;
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '.content-area', title: "Editor", message: "Write or paste markdown content here. Drag-and-drop text files also works.", advanceOnClick: false },
{ target: '.format-btn', title: "Format", message: "Choose a pocket-book format — digest, half-letter, A6, and more.", advanceOnClick: false },
{ target: '.btn-generate', title: "Generate PDF", message: "Generate a print-ready PDF in the selected format.", advanceOnClick: false },
{ target: '.btn-new-draft', title: "Drafts", message: "Save multiple drafts with real-time collaborative sync.", advanceOnClick: false },
];
set formats(val: BookFormat[]) {
this._formats = val;
@ -90,6 +98,12 @@ export class FolkPubsEditor extends HTMLElement {
async connectedCallback() {
this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadowRoot!,
FolkPubsEditor.TOUR_STEPS,
"rpubs_tour_done",
() => this.shadowRoot!.host as HTMLElement,
);
this.render();
const space = this.getAttribute("space") || "";
@ -101,6 +115,9 @@ export class FolkPubsEditor extends HTMLElement {
if (space === "demo" && !this._activeDocId) {
this.loadDemoContent();
}
if (!localStorage.getItem("rpubs_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
private async initRuntime() {
@ -381,6 +398,7 @@ export class FolkPubsEditor extends HTMLElement {
<input type="file" accept=".md,.txt,.markdown" style="display:none" />
Open File
</label>
<button class="btn-sample" id="btn-tour" style="font-size:0.78rem;padding:2px 8px;opacity:0.7">Tour</button>
</div>
</div>
<textarea class="content-area" placeholder="Drop in your markdown or plain text here..."></textarea>
@ -440,6 +458,7 @@ export class FolkPubsEditor extends HTMLElement {
`;
this.bindEvents();
this._tour.renderOverlay();
// Populate fields from current doc after render
if (this._runtime && this._activeDocId) {
@ -448,9 +467,15 @@ export class FolkPubsEditor extends HTMLElement {
}
}
startTour() {
this._tour.start();
}
private bindEvents() {
if (!this.shadowRoot) return;
this.shadowRoot.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement;
const titleInput = this.shadowRoot.querySelector(".title-input") as HTMLInputElement;
const authorInput = this.shadowRoot.querySelector(".author-input") as HTMLInputElement;

View File

@ -22,6 +22,11 @@ export function renderLanding(): string {
</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-pubs-editor')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- How it works -->

View File

@ -7,6 +7,8 @@
import { scheduleSchema, scheduleDocId, type ScheduleDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
interface JobData {
id: string;
@ -82,11 +84,19 @@ class FolkScheduleApp extends HTMLElement {
private log: LogEntry[] = [];
private reminders: ReminderData[] = [];
private view: "jobs" | "log" | "form" | "reminders" | "reminder-form" = "jobs";
private _history = new ViewHistory<"jobs" | "log" | "form" | "reminders" | "reminder-form">("jobs");
private editingJob: JobData | null = null;
private editingReminder: ReminderData | null = null;
private loading = false;
private runningJobId: string | null = null;
private _offlineUnsub: (() => void) | null = null;
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '[data-view="jobs"]', title: "Scheduled Jobs", message: "View and manage automated jobs that run on a cron schedule — email alerts, webhooks, and more.", advanceOnClick: true },
{ target: '[data-view="reminders"]', title: "Reminders", message: "Set personal reminders with optional email notifications and calendar sync.", advanceOnClick: true },
{ target: '[data-view="log"]', title: "Execution Log", message: "Review the history of all job runs with status, duration, and error details.", advanceOnClick: true },
{ target: '[data-action="create"]', title: "Create Job", message: "Create a new scheduled job with cron expressions and configurable actions.", advanceOnClick: false },
];
// Reminder form state
private rFormTitle = "";
@ -109,12 +119,21 @@ class FolkScheduleApp extends HTMLElement {
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkScheduleApp.TOUR_STEPS,
"rschedule_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.subscribeOffline();
this.loadJobs();
if (!localStorage.getItem("rschedule_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() {
@ -309,7 +328,9 @@ class FolkScheduleApp extends HTMLElement {
this.rFormAllDay = true;
this.rFormEmail = "";
this.rFormSyncCal = true;
this._history.push(this.view);
this.view = "reminder-form";
this._history.push("reminder-form");
this.render();
}
@ -323,7 +344,9 @@ class FolkScheduleApp extends HTMLElement {
this.rFormAllDay = r.allDay;
this.rFormEmail = r.notifyEmail || "";
this.rFormSyncCal = true;
this._history.push(this.view);
this.view = "reminder-form";
this._history.push("reminder-form");
this.render();
}
@ -401,7 +424,9 @@ class FolkScheduleApp extends HTMLElement {
this.formActionType = "email";
this.formEnabled = true;
this.formConfig = {};
this._history.push(this.view);
this.view = "form";
this._history.push("form");
this.render();
}
@ -419,10 +444,21 @@ class FolkScheduleApp extends HTMLElement {
this.formConfig[k] = String(v);
}
}
this._history.push(this.view);
this.view = "form";
this._history.push("form");
this.render();
}
private goBack() {
const prev = this._history.back();
if (!prev) return;
this.view = prev.view;
if (prev.view === "reminders") this.loadReminders();
else if (prev.view === "log") this.loadLog();
else this.render();
}
private formatTime(ts: number | null): string {
if (!ts) return "—";
const d = new Date(ts);
@ -584,12 +620,18 @@ class FolkScheduleApp extends HTMLElement {
<button class="s-tab ${activeTab === "reminders" ? "active" : ""}" data-view="reminders">Reminders</button>
<button class="s-tab ${activeTab === "log" ? "active" : ""}" data-view="log">Execution Log</button>
</div>
<button class="s-btn s-btn-secondary s-btn-sm" id="btn-tour">Tour</button>
${headerAction}
</div>
${content}
`;
this.attachListeners();
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
}
private renderJobList(): string {
@ -598,7 +640,7 @@ class FolkScheduleApp extends HTMLElement {
}
const rows = this.jobs.map((j) => `
<tr>
<tr data-collab-id="job:${j.id}">
<td>
<label class="s-toggle">
<input type="checkbox" ${j.enabled ? "checked" : ""} data-toggle="${j.id}">
@ -707,7 +749,7 @@ class FolkScheduleApp extends HTMLElement {
</div>
<div style="display:flex;gap:8px;margin-top:24px">
<button class="s-btn s-btn-primary" data-action="submit">${isEdit ? "Update Job" : "Create Job"}</button>
<button class="s-btn s-btn-secondary" data-action="cancel">Cancel</button>
<button class="s-btn s-btn-secondary" data-action="cancel">\u2190 Back</button>
</div>
</div>
`;
@ -727,7 +769,7 @@ class FolkScheduleApp extends HTMLElement {
? '<span style="background:rgba(59,130,246,0.15);color:#3b82f6;padding:2px 8px;border-radius:4px;font-size:12px">Sent</span>'
: '<span style="background:rgba(245,158,11,0.15);color:#f59e0b;padding:2px 8px;border-radius:4px;font-size:12px">Pending</span>';
return `<tr>
return `<tr data-collab-id="reminder:${r.id}">
<td>
<div style="display:flex;align-items:center;gap:8px">
<span style="width:8px;height:8px;border-radius:50%;background:${r.sourceColor || "#f59e0b"};flex-shrink:0"></span>
@ -797,19 +839,24 @@ class FolkScheduleApp extends HTMLElement {
</div>
<div style="display:flex;gap:8px;margin-top:24px">
<button class="s-btn s-btn-primary" data-action="submit-reminder">${isEdit ? "Update Reminder" : "Create Reminder"}</button>
<button class="s-btn s-btn-secondary" data-action="cancel-reminder">Cancel</button>
<button class="s-btn s-btn-secondary" data-action="cancel-reminder">\u2190 Back</button>
</div>
</div>
`;
}
private attachListeners() {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
// Tab switching
this.shadow.querySelectorAll<HTMLButtonElement>("[data-view]").forEach((btn) => {
btn.addEventListener("click", () => {
this.view = btn.dataset.view as "jobs" | "log" | "reminders";
if (this.view === "log") this.loadLog();
else if (this.view === "reminders") this.loadReminders();
const newView = btn.dataset.view as "jobs" | "log" | "reminders";
this._history.push(this.view);
this.view = newView;
this._history.push(newView);
if (newView === "log") this.loadLog();
else if (newView === "reminders") this.loadReminders();
else this.render();
});
});
@ -844,10 +891,9 @@ class FolkScheduleApp extends HTMLElement {
btn.addEventListener("click", () => this.deleteJob(btn.dataset.delete!));
});
// Form: cancel
// Form: cancel / back
this.shadow.querySelector<HTMLButtonElement>("[data-action='cancel']")?.addEventListener("click", () => {
this.view = "jobs";
this.render();
this.goBack();
});
// Form: submit
@ -906,10 +952,9 @@ class FolkScheduleApp extends HTMLElement {
btn.addEventListener("click", () => this.deleteReminder(btn.dataset.rDelete!));
});
// Reminder form: cancel
// Reminder form: cancel / back
this.shadow.querySelector<HTMLButtonElement>("[data-action='cancel-reminder']")?.addEventListener("click", () => {
this.view = "reminders";
this.loadReminders();
this.goBack();
});
// Reminder form: submit

View File

@ -31,6 +31,11 @@ export function renderLanding(): string {
</a>
<a href="#features" class="rl-cta-secondary">Learn More</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-schedule-app')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- Features (4-card grid) -->

View File

@ -59,9 +59,9 @@ export interface Reminder {
completed: boolean; // dismissed by user?
// Cross-module reference (null for free-form reminders)
sourceModule: string | null; // "rwork", "rnotes", etc.
sourceModule: string | null; // "rtasks", "rnotes", etc.
sourceEntityId: string | null;
sourceLabel: string | null; // "rWork Task"
sourceLabel: string | null; // "rTasks Task"
sourceColor: string | null; // "#f97316"
// Optional recurrence
@ -172,7 +172,7 @@ export const NODE_CATALOG: AutomationNodeDef[] = [
inputs: [],
outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
configSchema: [
{ key: 'module', label: 'Module', type: 'select', options: ['rnotes', 'rwork', 'rcal', 'rnetwork', 'rfiles', 'rvote', 'rflows'] },
{ key: 'module', label: 'Module', type: 'select', options: ['rnotes', 'rtasks', 'rcal', 'rnetwork', 'rfiles', 'rvote', 'rflows'] },
{ key: 'field', label: 'Field to Watch', type: 'text', placeholder: 'status' },
],
},
@ -315,7 +315,7 @@ export const NODE_CATALOG: AutomationNodeDef[] = [
category: 'action',
label: 'Create Task',
icon: '✅',
description: 'Create a task in rWork',
description: 'Create a task in rTasks',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
outputs: [{ name: 'done', type: 'trigger' }, { name: 'taskId', type: 'data' }],
configSchema: [
@ -347,7 +347,7 @@ export const NODE_CATALOG: AutomationNodeDef[] = [
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }],
configSchema: [
{ key: 'module', label: 'Target Module', type: 'select', options: ['rnotes', 'rwork', 'rcal', 'rnetwork'] },
{ key: 'module', label: 'Target Module', type: 'select', options: ['rnotes', 'rtasks', 'rcal', 'rnetwork'] },
{ key: 'operation', label: 'Operation', type: 'select', options: ['create', 'update', 'delete'] },
{ key: 'template', label: 'Data Template (JSON)', type: 'textarea', placeholder: '{"field": "{{value}}"}' },
],

View File

@ -9,16 +9,32 @@ import { socialsSchema, socialsDocId } from '../schemas';
import type { SocialsDoc, Campaign, CampaignPost } from '../schemas';
import type { DocumentId } from '../../../shared/local-first/document';
import { MYCOFI_CAMPAIGN, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data';
import { TourEngine } from '../../../shared/tour-engine';
export class FolkCampaignManager extends HTMLElement {
private _space = 'demo';
private _campaigns: Campaign[] = [];
private _offlineUnsub: (() => void) | null = null;
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '.campaign-header', title: "Campaign Overview", message: "This is your campaign dashboard — see the title, description, platforms, and post count at a glance.", advanceOnClick: false },
{ target: '.phase:first-of-type', title: "View Posts by Phase", message: "Posts are organised into phases. Each phase has a timeline and its own set of scheduled posts.", advanceOnClick: false },
{ target: 'a[href*="thread-editor"]', title: "Open Thread Editor", message: "Jump to the thread editor to compose and preview tweet threads with live card preview.", advanceOnClick: true },
{ target: '#import-md-btn', title: "Import from Markdown", message: "Paste tweets separated by --- to bulk-import content into the campaign.", advanceOnClick: true },
];
static get observedAttributes() { return ['space']; }
connectedCallback() {
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
this._tour = new TourEngine(
this.shadowRoot!,
FolkCampaignManager.TOUR_STEPS,
"rsocials_tour_done",
() => this.shadowRoot!.querySelector('.container') as HTMLElement,
);
this._space = this.getAttribute('space') || 'demo';
// Start with demo campaign
this._campaigns = [{ ...MYCOFI_CAMPAIGN, createdAt: Date.now(), updatedAt: Date.now() }];
@ -26,6 +42,10 @@ export class FolkCampaignManager extends HTMLElement {
if (this._space !== 'demo') {
this.subscribeOffline();
}
// Auto-start tour on first visit
if (!localStorage.getItem("rsocials_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() {
@ -141,6 +161,7 @@ export class FolkCampaignManager extends HTMLElement {
<div class="actions">
<a href="${this.basePath}thread-editor" class="btn btn--outline">Open Thread Editor</a>
<button class="btn btn--primary" id="import-md-btn">Import from Markdown</button>
<button class="btn btn--outline" id="btn-tour" style="margin-left:auto">Tour</button>
</div>
${phaseHTML}
<div id="imported-posts"></div>`;
@ -250,11 +271,17 @@ export class FolkCampaignManager extends HTMLElement {
`;
this.bindEvents();
this._tour.renderOverlay();
}
startTour() { this._tour.start(); }
private bindEvents() {
if (!this.shadowRoot) return;
// Tour button
this.shadowRoot.getElementById('btn-tour')?.addEventListener('click', () => this.startTour());
const modal = this.shadowRoot.getElementById('import-modal') as HTMLElement;
const openBtn = this.shadowRoot.getElementById('import-md-btn');
const closeBtn = this.shadowRoot.getElementById('import-modal-close');

View File

@ -115,7 +115,7 @@ export class FolkThreadGallery extends HTMLElement {
const href = this._isDemoFallback
? `${this.basePath}thread-editor`
: `${this.basePath}thread-editor/${this.esc(t.id)}/edit`;
return `<a href="${href}" class="card">
return `<a href="${href}" class="card" data-collab-id="thread:${this.esc(t.id)}">
${imageTag}
<h3 class="card__title">${this.esc(t.title || 'Untitled Thread')}</h3>
<p class="card__preview">${preview}</p>

View File

@ -19,6 +19,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rsocials" class="rl-cta-primary" id="ml-primary">Try Demo</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-campaign-manager')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- Features -->

View File

@ -150,7 +150,7 @@ export class FolkSplatViewer extends HTMLElement {
: `<span>${s.view_count} views</span>`;
return `
<${tag} class="splat-card${statusClass}"${href}>
<${tag} class="splat-card${statusClass}" data-collab-id="splat:${s.id}"${href}>
<div class="splat-card__preview">
${overlay}
<span>${isReady ? "🔮" : "📸"}</span>

View File

@ -147,6 +147,8 @@ function posterMockupSvg(): string {
// --- Component ---
import { TourEngine } from "../../../shared/tour-engine";
class FolkSwagDesigner extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
@ -162,10 +164,23 @@ class FolkSwagDesigner extends HTMLElement {
private demoStep: 1 | 2 | 3 | 4 = 1;
private progressStep = 0;
private usedSampleDesign = false;
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 },
{ target: '.steps-bar', title: "Design Flow", message: "Follow the 4-step flow: Product, Design, Generate, Pipeline.", advanceOnClick: false },
{ target: '.sample-btn', title: "Sample Design", message: "Try the demo with a pre-made sample design to see the full pipeline.", advanceOnClick: true },
{ target: '.generate-btn', title: "Generate", message: "Generate print-ready files and see provider matching + revenue splits.", advanceOnClick: false },
];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkSwagDesigner.TOUR_STEPS,
"rswag_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
@ -177,10 +192,13 @@ class FolkSwagDesigner extends HTMLElement {
this.designTitle = "Cosmolocal Network Tee";
this.demoStep = 1;
this.render();
return;
}
} else {
this.render();
}
if (!localStorage.getItem("rswag_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
private getApiBase(): string {
const path = window.location.pathname;
@ -334,6 +352,11 @@ class FolkSwagDesigner extends HTMLElement {
} else {
this.renderFull();
}
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
}
// ---- Demo mode rendering (4-step flow) ----
@ -345,6 +368,8 @@ class FolkSwagDesigner extends HTMLElement {
this.shadow.innerHTML = `
<style>${this.getDemoStyles()}</style>
<div style="display:flex;justify-content:flex-end;margin-bottom:4px"><button id="btn-tour" style="background:transparent;border:1px solid rgba(255,255,255,0.15);color:var(--rs-text-secondary,#94a3b8);border-radius:6px;padding:2px 10px;font-size:0.78rem;cursor:pointer">Tour</button></div>
<!-- Step indicators -->
<div class="steps-bar">
${[
@ -362,7 +387,7 @@ class FolkSwagDesigner extends HTMLElement {
<section class="step-section ${this.demoStep >= 1 ? 'visible' : ''}">
<div class="products">
${DEMO_PRODUCTS.map(dp => `
<div class="product ${this.selectedProduct === dp.id ? 'active' : ''}" data-product="${dp.id}">
<div class="product ${this.selectedProduct === dp.id ? 'active' : ''}" data-product="${dp.id}" data-collab-id="product:${dp.id}">
<div class="product-icon">${this.productIcon(dp.id)}</div>
<div class="product-name">${dp.name}</div>
<div class="product-specs">${dp.printArea}</div>
@ -541,6 +566,8 @@ class FolkSwagDesigner extends HTMLElement {
}
private bindDemoEvents() {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
// Product selection
this.shadow.querySelectorAll(".product").forEach(el => {
el.addEventListener("click", () => this.demoSelectProduct((el as HTMLElement).dataset.product || "tee"));

View File

@ -17,6 +17,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rswag" class="rl-cta-primary" id="ml-primary">Start Designing</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-swag-designer')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- Features -->

View File

@ -1,5 +1,5 @@
/**
* <folk-work-board> kanban board for workspace task management.
* <folk-tasks-board> kanban board for workspace task management.
*
* Views: workspace list board with draggable columns.
* Supports task creation, status changes, and priority labels.
@ -7,8 +7,10 @@
import { boardSchema, type BoardDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
class FolkWorkBoard extends HTMLElement {
class FolkTasksBoard extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private view: "list" | "board" = "list";
@ -24,19 +26,38 @@ class FolkWorkBoard extends HTMLElement {
private showCreateForm = false;
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
private _offlineUnsubs: (() => void)[] = [];
private _history = new ViewHistory<"list" | "board">("list");
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '.workspace-card', title: "Workspaces", message: "Select a workspace to open its kanban board.", advanceOnClick: true },
{ target: '#create-task', title: "New Task", message: "Create a new task with title, priority, and description.", advanceOnClick: false },
{ target: '.board', title: "Kanban Board", message: "Drag tasks between columns — TODO, In Progress, Review, Done.", advanceOnClick: false },
{ target: '.badge.clickable', title: "Priority", message: "Click the priority badge on a task to cycle through levels.", advanceOnClick: false },
];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkTasksBoard.TOUR_STEPS,
"rtasks_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
if (this.space === "demo") { this.loadDemoData(); return; }
if (this.space === "demo") { this.loadDemoData(); }
else {
this.subscribeOffline();
this.loadWorkspaces();
this.render();
}
if (!localStorage.getItem("rtasks_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub();
@ -48,7 +69,7 @@ class FolkWorkBoard extends HTMLElement {
if (!runtime?.isInitialized) return;
try {
const docs = await runtime.subscribeModule('work', 'boards', boardSchema);
const docs = await runtime.subscribeModule('tasks', 'boards', boardSchema);
// Build workspace list from cached boards
if (docs.size > 0 && this.workspaces.length === 0) {
const boards: any[] = [];
@ -70,7 +91,7 @@ class FolkWorkBoard extends HTMLElement {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime || !this.workspaceSlug) return;
// Reload tasks for current board from runtime
const docId = runtime.makeDocId('work', 'boards', this.workspaceSlug);
const docId = runtime.makeDocId('tasks', 'boards', this.workspaceSlug);
const doc = runtime.get(docId) as BoardDoc | undefined;
if (doc?.tasks && Object.keys(doc.tasks).length > 0) {
this.tasks = Object.values(doc.tasks).map(t => ({
@ -105,7 +126,7 @@ class FolkWorkBoard extends HTMLElement {
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rwork/);
const match = path.match(/^(\/[^/]+)?\/rtasks/);
return match ? match[0] : "";
}
@ -225,6 +246,14 @@ class FolkWorkBoard extends HTMLElement {
this.loadTasks();
}
private goBack() {
const prev = this._history.back();
if (!prev) return;
this.view = prev.view;
if (prev.view === "list") this.loadWorkspaces();
else this.render();
}
private render() {
this.shadow.innerHTML = `
<style>
@ -307,6 +336,11 @@ class FolkWorkBoard extends HTMLElement {
${this.view === "list" ? this.renderList() : this.renderBoard()}
`;
this.attachListeners();
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
}
private renderList(): string {
@ -314,6 +348,7 @@ class FolkWorkBoard extends HTMLElement {
<div class="rapp-nav">
<span class="rapp-nav__title">Workspaces</span>
<button class="rapp-nav__btn" id="create-ws">+ New Workspace</button>
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
</div>
${this.workspaces.length > 0 ? `<div class="workspace-grid">
${this.workspaces.map(ws => `
@ -351,7 +386,7 @@ class FolkWorkBoard extends HTMLElement {
private renderBoard(): string {
return `
<div class="rapp-nav">
<button class="rapp-nav__back" data-back="list">\u2190 Workspaces</button>
${this._history.canGoBack ? '<button class="rapp-nav__back" data-back="list">\u2190 Workspaces</button>' : ''}
<span class="rapp-nav__title">${this.esc(this.workspaceSlug)}</span>
<button class="rapp-nav__btn" id="create-task">+ New Task</button>
</div>
@ -381,7 +416,7 @@ class FolkWorkBoard extends HTMLElement {
return map[p] ? `<span class="badge clickable ${map[p]}" data-cycle-priority="${task.id}">${this.esc(p.toLowerCase())}</span>` : "";
};
return `
<div class="task-card" draggable="${isEditing ? "false" : "true"}" data-task-id="${task.id}">
<div class="task-card" draggable="${isEditing ? "false" : "true"}" data-task-id="${task.id}" data-collab-id="task:${task.id}">
${isEditing
? `<input class="task-title-input" data-edit-title="${task.id}" value="${this.esc(task.title)}">`
: `<div class="task-title" data-start-edit="${task.id}" style="cursor:text">${this.esc(task.title)}</div>`}
@ -398,6 +433,7 @@ class FolkWorkBoard extends HTMLElement {
}
private attachListeners() {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.getElementById("create-ws")?.addEventListener("click", () => this.createWorkspace());
this.shadow.getElementById("create-task")?.addEventListener("click", () => {
this.showCreateForm = !this.showCreateForm;
@ -461,10 +497,14 @@ class FolkWorkBoard extends HTMLElement {
});
this.shadow.querySelectorAll("[data-ws]").forEach(el => {
el.addEventListener("click", () => this.openBoard((el as HTMLElement).dataset.ws!));
el.addEventListener("click", () => {
this._history.push("list");
this._history.push("board", { ws: (el as HTMLElement).dataset.ws });
this.openBoard((el as HTMLElement).dataset.ws!);
});
});
this.shadow.querySelectorAll("[data-back]").forEach(el => {
el.addEventListener("click", () => { this.view = "list"; this.loadWorkspaces(); });
el.addEventListener("click", () => this.goBack());
});
this.shadow.querySelectorAll("[data-move]").forEach(el => {
el.addEventListener("click", (e) => {
@ -485,10 +525,10 @@ class FolkWorkBoard extends HTMLElement {
const task = this.tasks.find(t => t.id === this.dragTaskId);
dt.setData("text/plain", task?.title || this.dragTaskId);
dt.setData("application/rspace-item", JSON.stringify({
module: "rwork",
module: "rtasks",
entityId: this.dragTaskId,
title: task?.title || "",
label: "rWork Task",
label: "rTasks",
color: "#f97316",
}));
}
@ -526,4 +566,4 @@ class FolkWorkBoard extends HTMLElement {
}
}
customElements.define("folk-work-board", FolkWorkBoard);
customElements.define("folk-tasks-board", FolkTasksBoard);

View File

@ -1,5 +1,5 @@
/* Work module — dark theme */
folk-work-board {
/* Tasks module — dark theme */
folk-tasks-board {
display: block;
min-height: 400px;
padding: 20px;

View File

@ -1,14 +1,14 @@
-- rWork module schema
CREATE SCHEMA IF NOT EXISTS rwork;
-- rTasks module schema
CREATE SCHEMA IF NOT EXISTS rtasks;
CREATE TABLE IF NOT EXISTS rwork.users (
CREATE TABLE IF NOT EXISTS rtasks.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT UNIQUE NOT NULL,
username TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS rwork.spaces (
CREATE TABLE IF NOT EXISTS rtasks.spaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
@ -21,40 +21,40 @@ CREATE TABLE IF NOT EXISTS rwork.spaces (
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS rwork.space_members (
CREATE TABLE IF NOT EXISTS rtasks.space_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
space_id UUID NOT NULL REFERENCES rwork.spaces(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES rwork.users(id) ON DELETE CASCADE,
space_id UUID NOT NULL REFERENCES rtasks.spaces(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES rtasks.users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'MEMBER' CHECK (role IN ('ADMIN','MODERATOR','MEMBER','VIEWER')),
joined_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(space_id, user_id)
);
CREATE TABLE IF NOT EXISTS rwork.tasks (
CREATE TABLE IF NOT EXISTS rtasks.tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
space_id UUID NOT NULL REFERENCES rwork.spaces(id) ON DELETE CASCADE,
space_id UUID NOT NULL REFERENCES rtasks.spaces(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'TODO',
priority TEXT DEFAULT 'MEDIUM' CHECK (priority IN ('LOW','MEDIUM','HIGH','URGENT')),
labels TEXT[] DEFAULT '{}',
assignee_id UUID REFERENCES rwork.users(id),
created_by UUID REFERENCES rwork.users(id),
assignee_id UUID REFERENCES rtasks.users(id),
created_by UUID REFERENCES rtasks.users(id),
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS rwork.activity_log (
CREATE TABLE IF NOT EXISTS rtasks.activity_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
space_id UUID REFERENCES rwork.spaces(id) ON DELETE CASCADE,
user_id UUID REFERENCES rwork.users(id),
space_id UUID REFERENCES rtasks.spaces(id) ON DELETE CASCADE,
user_id UUID REFERENCES rtasks.users(id),
action TEXT NOT NULL,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_rwork_tasks_space ON rwork.tasks(space_id);
CREATE INDEX IF NOT EXISTS idx_rwork_tasks_status ON rwork.tasks(space_id, status);
CREATE INDEX IF NOT EXISTS idx_rwork_activity_space ON rwork.activity_log(space_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_rwork_members_user ON rwork.space_members(user_id);
CREATE INDEX IF NOT EXISTS idx_rtasks_tasks_space ON rtasks.tasks(space_id);
CREATE INDEX IF NOT EXISTS idx_rtasks_tasks_status ON rtasks.tasks(space_id, status);
CREATE INDEX IF NOT EXISTS idx_rtasks_activity_space ON rtasks.activity_log(space_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_rtasks_members_user ON rtasks.space_members(user_id);

View File

@ -1,6 +1,6 @@
/**
* rWork landing page collective task management.
* Ported from rwork-online Next.js page.tsx (shadcn/ui + Lucide).
* rTasks landing page collective task management.
* Ported from rtasks-online Next.js page.tsx (shadcn/ui + Lucide).
*/
export function renderLanding(): string {
return `
@ -16,11 +16,16 @@ export function renderLanding(): string {
by markdown-native task management.
</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rwork" class="rl-cta-primary" id="ml-primary">
<a href="https://demo.rspace.online/rtasks" class="rl-cta-primary" id="ml-primary">
Get Started &rarr;
</a>
<a href="https://demo.rspace.online/rwork" class="rl-cta-secondary">View Dashboard</a>
<a href="https://demo.rspace.online/rtasks" class="rl-cta-secondary">View Dashboard</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-tasks-board')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- How It Works -->
@ -28,7 +33,7 @@ export function renderLanding(): string {
<div class="rl-container">
<div style="text-align:center;margin-bottom:1.5rem">
<span class="rl-badge">How It Works</span>
<h2 class="rl-heading" style="margin-top:0.75rem">rWork in 30 Seconds</h2>
<h2 class="rl-heading" style="margin-top:0.75rem">rTasks in 30 Seconds</h2>
<p class="rl-subtext">
<strong style="color:#3b82f6">Create a Space</strong> for your community,
<strong style="color:#14b8a6">add tasks</strong> to your pipeline, and
@ -173,7 +178,7 @@ export function renderLanding(): string {
Invite members, configure your pipeline, and ship faster as a team.
</p>
<div class="rl-cta-row" style="margin-top:1.5rem">
<a href="https://demo.rspace.online/rwork" class="rl-cta-primary">
<a href="https://demo.rspace.online/rtasks" class="rl-cta-primary">
Create a Space &rarr;
</a>
</div>

View File

@ -1,7 +1,7 @@
/**
* rWork Local-First Client
* rTasks Local-First Client
*
* Wraps the shared local-first stack into a work/kanban-specific API.
* Wraps the shared local-first stack into a tasks/kanban-specific API.
*/
import * as Automerge from '@automerge/automerge';
@ -13,7 +13,7 @@ import { DocCrypto } from '../../shared/local-first/crypto';
import { boardSchema, boardDocId } from './schemas';
import type { BoardDoc, TaskItem, BoardMeta } from './schemas';
export class WorkLocalFirstClient {
export class TasksLocalFirstClient {
#space: string;
#documents: DocumentManager;
#store: EncryptedDocStore;
@ -37,7 +37,7 @@ export class WorkLocalFirstClient {
async init(): Promise<void> {
if (this.#initialized) return;
await this.#store.open();
const cachedIds = await this.#store.listByModule('work', 'boards');
const cachedIds = await this.#store.listByModule('tasks', 'boards');
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) {
this.#documents.open<BoardDoc>(docId, boardSchema, binary);
@ -45,7 +45,7 @@ export class WorkLocalFirstClient {
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('[WorkClient] Working offline'); }
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[TasksClient] Working offline'); }
this.#initialized = true;
}

View File

@ -1,5 +1,5 @@
/**
* Work module kanban workspace boards.
* Tasks module kanban workspace boards.
*
* Multi-tenant collaborative workspace with drag-and-drop kanban,
* configurable statuses, and activity logging.
@ -51,7 +51,7 @@ function ensureDoc(space: string, boardId?: string): BoardDoc {
* Get all board doc IDs for a given space.
*/
function getBoardDocIds(space: string): string[] {
return _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:work:boards:`));
return _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:tasks:boards:`));
}
/**
@ -59,15 +59,15 @@ function getBoardDocIds(space: string): string[] {
*/
function seedDemoIfEmpty(space: string = 'rspace-dev') {
if (!_syncServer) return;
// Check if this space already has work boards
const spaceWorkDocs = _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:work:boards:`));
// Check if this space already has tasks boards
const spaceWorkDocs = _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:tasks:boards:`));
if (spaceWorkDocs.length > 0) return;
const docId = boardDocId(space, space);
const doc = Automerge.change(Automerge.init<BoardDoc>(), 'seed demo board', (d) => {
const now = Date.now();
d.meta = { module: 'work', collection: 'boards', version: 1, spaceSlug: space, createdAt: now };
d.meta = { module: 'tasks', collection: 'boards', version: 1, spaceSlug: space, createdAt: now };
d.board = {
id: space,
name: 'rSpace Development',
@ -109,14 +109,14 @@ function seedDemoIfEmpty(space: string = 'rspace-dev') {
});
_syncServer!.setDoc(docId, doc);
console.log(`[Work] Demo data seeded for "${space}": 1 board, 11 tasks`);
console.log(`[Tasks] Demo data seeded for "${space}": 1 board, 11 tasks`);
}
// ── API: Spaces (Boards) ──
// GET /api/spaces — list workspaces (boards)
routes.get("/api/spaces", async (c) => {
const allIds = _syncServer!.getDocIds().filter((id) => id.includes(':work:boards:'));
const allIds = _syncServer!.getDocIds().filter((id) => id.includes(':tasks:boards:'));
const rows = allIds.map((docId) => {
const doc = _syncServer!.getDoc<BoardDoc>(docId);
if (!doc) return null;
@ -161,7 +161,7 @@ routes.post("/api/spaces", async (c) => {
const now = Date.now();
const doc = Automerge.change(Automerge.init<BoardDoc>(), 'create board', (d) => {
d.meta = { module: 'work', collection: 'boards', version: 1, spaceSlug: slug, createdAt: now };
d.meta = { module: 'tasks', collection: 'boards', version: 1, spaceSlug: slug, createdAt: now };
d.board = {
id: slug,
name: name.trim(),
@ -314,7 +314,7 @@ routes.patch("/api/tasks/:id", async (c) => {
}
// Find which board doc contains this task
const allBoardIds = _syncServer!.getDocIds().filter((docId) => docId.includes(':work:boards:'));
const allBoardIds = _syncServer!.getDocIds().filter((docId) => docId.includes(':tasks:boards:'));
let targetDocId: string | null = null;
for (const docId of allBoardIds) {
const doc = _syncServer!.getDoc<BoardDoc>(docId);
@ -362,7 +362,7 @@ routes.delete("/api/tasks/:id", async (c) => {
const id = c.req.param("id");
// Find which board doc contains this task
const allBoardIds = _syncServer!.getDocIds().filter((docId) => docId.includes(':work:boards:'));
const allBoardIds = _syncServer!.getDocIds().filter((docId) => docId.includes(':tasks:boards:'));
let targetDocId: string | null = null;
for (const docId of allBoardIds) {
const doc = _syncServer!.getDoc<BoardDoc>(docId);
@ -395,26 +395,26 @@ routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderShell({
title: `${space}Work | rSpace`,
moduleId: "rwork",
title: `${space}Tasks | rSpace`,
moduleId: "rtasks",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-work-board space="${space}"></folk-work-board>`,
scripts: `<script type="module" src="/modules/rwork/folk-work-board.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rwork/work.css">`,
body: `<folk-tasks-board space="${space}"></folk-tasks-board>`,
scripts: `<script type="module" src="/modules/rtasks/folk-tasks-board.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtasks/tasks.css">`,
}));
});
export const workModule: RSpaceModule = {
id: "rwork",
name: "rWork",
export const tasksModule: RSpaceModule = {
id: "rtasks",
name: "rTasks",
icon: "📋",
description: "Kanban workspace boards for collaborative task management",
scoping: { defaultScope: 'space', userConfigurable: false },
docSchemas: [{ pattern: '{space}:work:boards:{boardId}', description: 'Kanban board with tasks', init: boardSchema.init }],
docSchemas: [{ pattern: '{space}:tasks:boards:{boardId}', description: 'Kanban board with tasks', init: boardSchema.init }],
routes,
standaloneDomain: "rwork.online",
standaloneDomain: "rtasks.online",
landingPage: renderLanding,
seedTemplate: seedDemoIfEmpty,
async onInit(ctx) {
@ -426,7 +426,7 @@ export const workModule: RSpaceModule = {
const docId = boardDocId(ctx.spaceSlug, ctx.spaceSlug);
const doc = Automerge.init<BoardDoc>();
const initialized = Automerge.change(doc, 'Init board', (d) => {
d.meta = { module: 'work', collection: 'boards', version: 1, spaceSlug: ctx.spaceSlug, createdAt: Date.now() };
d.meta = { module: 'tasks', collection: 'boards', version: 1, spaceSlug: ctx.spaceSlug, createdAt: Date.now() };
d.board = { id: ctx.spaceSlug, name: ctx.spaceSlug, slug: ctx.spaceSlug, description: '', icon: null, ownerDid: ctx.ownerDID, statuses: ['TODO', 'IN_PROGRESS', 'DONE'], labels: [], createdAt: Date.now(), updatedAt: Date.now() };
d.tasks = {};
});

View File

@ -1,8 +1,8 @@
/**
* rWork Automerge document schemas.
* rTasks Automerge document schemas.
*
* Granularity: one Automerge document per board/workspace.
* DocId format: {space}:work:boards:{boardId}
* DocId format: {space}:tasks:boards:{boardId}
*/
import type { DocSchema } from '../../shared/local-first/document';
@ -52,12 +52,12 @@ export interface BoardDoc {
// ── Schema registration ──
export const boardSchema: DocSchema<BoardDoc> = {
module: 'work',
module: 'tasks',
collection: 'boards',
version: 1,
init: (): BoardDoc => ({
meta: {
module: 'work',
module: 'tasks',
collection: 'boards',
version: 1,
spaceSlug: '',
@ -82,7 +82,7 @@ export const boardSchema: DocSchema<BoardDoc> = {
// ── Helpers ──
export function boardDocId(space: string, boardId: string) {
return `${space}:work:boards:${boardId}` as const;
return `${space}:tasks:boards:${boardId}` as const;
}
export function createTaskItem(

View File

@ -8,6 +8,8 @@
import { tripSchema, type TripDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
class FolkTripsPlanner extends HTMLElement {
private shadow: ShadowRoot;
@ -18,19 +20,37 @@ class FolkTripsPlanner extends HTMLElement {
private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview";
private error = "";
private _offlineUnsubs: (() => void)[] = [];
private _history = new ViewHistory<"list" | "detail">("list");
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '#create-trip', title: "Plan a Trip", message: "Start planning a new trip — add destinations, itinerary, and budget.", advanceOnClick: false },
{ target: '.trip-card', title: "Trip Cards", message: "View trip details, status, budget progress, and collaborators.", advanceOnClick: true },
{ target: '.tab', title: "Detail Tabs", message: "Explore destinations, itinerary, bookings, expenses, and packing lists.", advanceOnClick: false },
];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkTripsPlanner.TOUR_STEPS,
"rtrips_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
if (this.space === "demo") { this.loadDemoData(); return; }
if (this.space === "demo") { this.loadDemoData(); }
else {
this.subscribeOffline();
this.loadTrips();
this.render();
}
if (!localStorage.getItem("rtrips_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub();
@ -472,6 +492,11 @@ class FolkTripsPlanner extends HTMLElement {
${this.view === "list" ? this.renderList() : this.renderDetail()}
`;
this.attachListeners();
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
}
private renderList(): string {
@ -479,6 +504,7 @@ class FolkTripsPlanner extends HTMLElement {
<div class="rapp-nav">
<span class="rapp-nav__title">My Trips</span>
<button class="rapp-nav__btn" id="create-trip">+ Plan a Trip</button>
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
</div>
${this.trips.length > 0 ? `<div class="trip-grid">
${this.trips.map(t => {
@ -488,7 +514,7 @@ class FolkTripsPlanner extends HTMLElement {
const pct = budget > 0 ? Math.min(100, (spent / budget) * 100) : 0;
const budgetColor = pct > 90 ? "#ef4444" : pct > 70 ? "#fbbf24" : "#14b8a6";
return `
<div class="trip-card" data-trip="${t.id}">
<div class="trip-card" data-trip="${t.id}" data-collab-id="trip:${t.id}">
<div class="trip-card-header">
<span class="trip-name">${this.esc(t.title)}</span>
<span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</span>
@ -518,7 +544,7 @@ class FolkTripsPlanner extends HTMLElement {
const st = this.getStatusStyle(t.status || "PLANNING");
return `
<div class="rapp-nav">
<button class="rapp-nav__back" data-back="list">\u2190 Trips</button>
${this._history.canGoBack ? `<button class="rapp-nav__back" data-back="list">\u2190 Trips</button>` : ""}
<span class="rapp-nav__title">${this.esc(t.title)}</span>
<span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</span>
</div>
@ -573,7 +599,7 @@ class FolkTripsPlanner extends HTMLElement {
case "destinations":
return (t.destinations || []).length > 0
? (t.destinations || []).map((d: any, i: number) => `
<div class="dest-card">
<div class="dest-card" data-collab-id="dest:${d.id || i}">
<span class="dest-pin">${i === 0 ? "\u{1F6EB}" : i === (t.destinations.length - 1) ? "\u{1F6EC}" : "\u{1F4CD}"}</span>
<div class="dest-info">
<div class="dest-name">${this.esc(d.name)}</div>
@ -650,20 +676,20 @@ class FolkTripsPlanner extends HTMLElement {
}
private attachListeners() {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.getElementById("create-trip")?.addEventListener("click", () => this.createTrip());
this.shadow.querySelectorAll("[data-trip]").forEach(el => {
el.addEventListener("click", () => {
this._history.push("list");
this.view = "detail";
this.tab = "overview";
this._history.push("detail", { tripId: (el as HTMLElement).dataset.trip });
this.loadTrip((el as HTMLElement).dataset.trip!);
});
});
this.shadow.querySelectorAll("[data-back]").forEach(el => {
el.addEventListener("click", () => {
this.view = "list";
if (this.space === "demo") { this.loadDemoData(); } else { this.loadTrips(); }
});
el.addEventListener("click", () => this.goBack());
});
this.shadow.querySelectorAll("[data-tab]").forEach(el => {
el.addEventListener("click", () => {
@ -686,6 +712,17 @@ class FolkTripsPlanner extends HTMLElement {
});
}
private goBack() {
const prev = this._history.back();
if (!prev) return;
this.view = prev.view;
if (this.view === "list") {
if (this.space === "demo") { this.loadDemoData(); } else { this.loadTrips(); }
} else {
this.render();
}
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";

View File

@ -18,6 +18,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rtrips" class="rl-cta-primary" id="ml-primary">Start Planning</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-trips-planner')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- How It Works -->

View File

@ -5,6 +5,8 @@
* and provides HLS live stream viewing.
*/
import { TourEngine } from "../../../shared/tour-engine";
class FolkVideoPlayer extends HTMLElement {
private shadow: ShadowRoot;
private space = "demo";
@ -14,16 +16,31 @@ class FolkVideoPlayer extends HTMLElement {
private streamKey = "";
private searchTerm = "";
private isDemo = false;
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '[data-mode="library"]', title: "Video Library", message: "Browse your recorded videos — search, select, and play.", advanceOnClick: false },
{ target: '[data-input="search"]', title: "Search Videos", message: "Filter videos by name to quickly find what you need.", advanceOnClick: false },
{ target: '.player-area', title: "Video Player", message: "Select a video from the sidebar to start playback.", advanceOnClick: false },
];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkVideoPlayer.TOUR_STEPS,
"rtube_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
if (this.space === "demo") { this.loadDemoData(); return; }
this.loadVideos();
if (this.space === "demo") { this.loadDemoData(); }
else { this.loadVideos(); }
if (!localStorage.getItem("rtube_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
private loadDemoData() {
@ -112,11 +129,17 @@ class FolkVideoPlayer extends HTMLElement {
<button class="tab ${this.mode === "library" ? "active" : ""}" data-mode="library">Video Library</button>
${!this.isDemo ? `<button class="tab ${this.mode === "live" ? "active" : ""}" data-mode="live">Live Stream</button>` : ""}
<span class="rapp-nav__title">${this.isDemo ? `${this.videos.length} recordings` : ""}</span>
<button class="tab" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
</div>
${this.mode === "library" ? this.renderLibrary() : this.renderLive()}
</div>
`;
this.bindEvents();
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
}
private renderLibrary(): string {
@ -129,7 +152,7 @@ class FolkVideoPlayer extends HTMLElement {
const videoList = filteredVideos.length === 0
? `<div class="empty">${this.videos.length === 0 ? "No videos yet" : "No matches"}</div>`
: filteredVideos.map((v) => `
<div class="video-item ${this.currentVideo === v.name ? "active" : ""}" data-name="${v.name}">
<div class="video-item ${this.currentVideo === v.name ? "active" : ""}" data-name="${v.name}" data-collab-id="video:${v.name}">
<div class="video-name">${v.name}</div>
<div class="video-meta">${this.getExtension(v.name).toUpperCase()} &middot; ${this.formatSize(v.size)}${v.duration ? ` &middot; ${v.duration}` : ""}${v.date ? `<br>${v.date}` : ""}</div>
</div>
@ -202,6 +225,8 @@ class FolkVideoPlayer extends HTMLElement {
}
private bindEvents() {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.querySelectorAll(".tab").forEach((btn) => {
btn.addEventListener("click", () => {
this.mode = (btn as HTMLElement).dataset.mode as "library" | "live";

View File

@ -21,6 +21,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rtube" class="rl-cta-secondary" id="ml-primary">Browse Videos</a>
<a href="https://demo.rspace.online/rtube" class="rl-cta-primary" style="background:#dc2626">Start Streaming</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-video-player')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- How It Works -->

View File

@ -7,6 +7,8 @@
import { proposalSchema, type ProposalDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
interface VoteSpace {
slug: string;
@ -54,17 +56,35 @@ class FolkVoteDashboard extends HTMLElement {
private showTrendChart = true;
private scoreHistory: ScoreSnapshot[] = [];
private _offlineUnsubs: (() => void)[] = [];
private _history = new ViewHistory<"spaces" | "proposals" | "proposal">("spaces");
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '[data-space]', title: "Voting Spaces", message: "Each space has its own proposals and conviction parameters. Click a space to explore.", advanceOnClick: true },
{ target: '[data-toggle-create]', title: "Create Proposal", message: "Submit a new proposal for the community to vote on with conviction-weighted ranking.", advanceOnClick: true },
{ target: '[data-proposal]', title: "View Proposal", message: "Click a proposal to see its details, cast your vote, and watch conviction accumulate over time.", advanceOnClick: true },
{ target: '[data-toggle-trend]', title: "Trend Chart", message: "Toggle the conviction trend chart to see how proposal scores evolve as votes accumulate.", advanceOnClick: false },
];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkVoteDashboard.TOUR_STEPS,
"rvote_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
if (this.space === "demo") { this.loadDemoData(); return; }
this.subscribeOffline();
this.loadSpaces();
if (this.space === "demo") { this.loadDemoData(); }
else { this.subscribeOffline(); this.loadSpaces(); }
if (!localStorage.getItem("rvote_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() {
@ -593,8 +613,11 @@ class FolkVoteDashboard extends HTMLElement {
`;
this.attachListeners();
this._tour.renderOverlay();
}
startTour() { this._tour.start(); }
private renderView(): string {
if (this.view === "proposal" && this.selectedProposal) {
return this.renderProposal();
@ -609,6 +632,7 @@ class FolkVoteDashboard extends HTMLElement {
return `
<div class="header">
<span class="header-title">Voting Spaces</span>
<button class="header-back" id="btn-tour">Tour</button>
</div>
${this.spaces.length === 0 ? '<div class="empty"><div class="empty-icon">🗳</div><div class="empty-text">No voting spaces yet. Create one to get started.</div></div>' : ""}
${this.spaces.map((s) => `
@ -658,7 +682,7 @@ class FolkVoteDashboard extends HTMLElement {
return `
<div class="header">
${this.spaces.length > 1 ? `<button class="header-back" data-back="spaces">&larr;</button>` : ""}
${this._history.canGoBack && this.spaces.length > 1 ? '<button class="header-back" data-back="spaces">&larr;</button>' : ''}
<span class="header-title">${this.esc(s.name)}</span>
<span class="header-sub">${this.proposals.length} proposal${this.proposals.length !== 1 ? "s" : ""}</span>
<button class="btn-new" data-toggle-create>+ New Proposal</button>
@ -710,7 +734,7 @@ class FolkVoteDashboard extends HTMLElement {
const downChevron = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`;
return `
<div class="proposal" data-pid="${p.id}">
<div class="proposal" data-pid="${p.id}" data-collab-id="proposal:${p.id}">
${p.status === "RANKING" ? `
<div class="vote-col">
<button class="vote-chevron up" data-vote-weight="1" data-vote-id="${p.id}" title="Upvote (+1 credit)">${upChevron}</button>
@ -892,7 +916,7 @@ class FolkVoteDashboard extends HTMLElement {
return `
<div class="header">
<button class="header-back" data-back="proposals">&larr; Proposals</button>
${this._history.canGoBack ? '<button class="header-back" data-back="proposals">&larr; Proposals</button>' : ''}
<span class="header-title">${this.esc(p.title)}</span>
<span class="badge" style="background:${this.getStatusColor(p.status)}18;color:${this.getStatusColor(p.status)}">${this.getStatusIcon(p.status)} ${p.status}</span>
</div>
@ -966,10 +990,13 @@ class FolkVoteDashboard extends HTMLElement {
}
private attachListeners() {
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
// Space cards
this.shadow.querySelectorAll("[data-space]").forEach((el) => {
el.addEventListener("click", () => {
const slug = (el as HTMLElement).dataset.space!;
this._history.push(this.view);
this._history.push("proposals", { space: slug });
this.selectedSpace = this.spaces.find((s) => s.slug === slug) || null;
this.view = "proposals";
if (this.space === "demo") this.render();
@ -982,6 +1009,8 @@ class FolkVoteDashboard extends HTMLElement {
el.addEventListener("click", (e) => {
e.stopPropagation();
const id = (el as HTMLElement).dataset.proposal!;
this._history.push(this.view);
this._history.push("proposal", { proposalId: id });
this.view = "proposal";
if (this.space === "demo") {
this.selectedProposal = this.proposals.find((p) => p.id === id) || null;
@ -996,9 +1025,7 @@ class FolkVoteDashboard extends HTMLElement {
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const target = (el as HTMLElement).dataset.back;
if (target === "spaces") { this.view = "spaces"; this.render(); }
else if (target === "proposals") { this.view = "proposals"; this.render(); }
this.goBack();
});
});
@ -1051,6 +1078,13 @@ class FolkVoteDashboard extends HTMLElement {
});
}
private goBack() {
const prev = this._history.back();
if (!prev) return;
this.view = prev.view;
this.render();
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";

View File

@ -27,6 +27,11 @@ export function renderLanding(): string {
</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-vote-dashboard')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- ELI5 Section: rVote in 30 Seconds -->

View File

@ -10,6 +10,7 @@ import { transformToTimelineData, transformToSankeyData, transformToMultichainDa
import type { TimelineEntry, SankeyData, MultichainData } from "../lib/data-transform";
import { loadD3, renderTimeline, renderFlowChart, renderSankey } from "../lib/wallet-viz";
import { DEMO_TIMELINE_DATA, DEMO_SANKEY_DATA, DEMO_MULTICHAIN_DATA } from "../lib/wallet-demo-data";
import { TourEngine } from "../../../shared/tour-engine";
interface ChainInfo {
chainId: string;
@ -108,24 +109,41 @@ class FolkWalletViewer extends HTMLElement {
multichain?: MultichainData;
} = {};
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '#address-input', title: "Enter Address", message: "Paste any wallet or Safe multisig address to load balances across all supported chains.", advanceOnClick: false },
{ target: '#testnet-toggle', title: "Testnet Toggle", message: "Include testnet chains in the scan. Useful for checking Safe deployments on Sepolia or Goerli.", advanceOnClick: true },
{ target: '.chain-btn', title: "Chain Selector", message: "Click a chain to load balances for that specific network. Active chains are highlighted.", advanceOnClick: false },
{ target: '.view-tab', title: "Dashboard Views", message: "Switch between balance view, transfer timeline, and flow visualisations for deeper analysis.", advanceOnClick: false },
];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkWalletViewer.TOUR_STEPS,
"rwallet_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
const space = this.getAttribute("space") || "";
if (space === "demo") {
this.loadDemoData();
return;
}
// Check URL params for initial address
} else {
const params = new URLSearchParams(window.location.search);
this.address = params.get("address") || "";
this.checkAuthState();
this.render();
if (this.address) this.detectChains();
}
if (!localStorage.getItem("rwallet_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
private checkAuthState() {
try {
@ -979,7 +997,7 @@ class FolkWalletViewer extends HTMLElement {
</div>
<div class="wallet-list">
${this.passKeyEOA ? `
<div class="wallet-item" data-view-address="${this.esc(this.passKeyEOA)}">
<div class="wallet-item" data-view-address="${this.esc(this.passKeyEOA)}" data-collab-id="wallet:${this.esc(this.passKeyEOA)}">
<div class="wallet-item-info">
<div class="wallet-item-label">
<span class="wallet-badge encryptid">EncryptID</span>
@ -990,7 +1008,7 @@ class FolkWalletViewer extends HTMLElement {
</div>
` : ""}
${this.linkedWallets.map(w => `
<div class="wallet-item" data-view-address="${this.esc(w.address)}">
<div class="wallet-item" data-view-address="${this.esc(w.address)}" data-collab-id="wallet:${this.esc(w.address)}">
<div class="wallet-item-info">
<div class="wallet-item-label">
<span class="wallet-badge ${w.type}">${this.esc(w.providerName || w.type.toUpperCase())}</span>
@ -1205,6 +1223,7 @@ class FolkWalletViewer extends HTMLElement {
<div class="toggle-track"><div class="toggle-thumb"></div></div>
<span>Include testnets</span>
</div>
<button class="view-tab" id="btn-tour" style="margin-left:auto;font-size:0.78rem;padding:4px 10px">Tour</button>
${this.walletType ? `
<div class="wallet-badge ${this.walletType}">
${this.walletType === "safe" ? "&#9939; Safe Multisig" : "&#128100; EOA Wallet"}
@ -1296,10 +1315,18 @@ class FolkWalletViewer extends HTMLElement {
});
});
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
// Draw visualization if active
if (this.activeView !== "balances" && this.hasData()) {
requestAnimationFrame(() => this.drawActiveVisualization());
}
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
}
private esc(s: string): string {

View File

@ -19,6 +19,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rwallet" class="rl-cta-primary" id="ml-primary">View Treasury</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-wallet-viewer')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- Features -->

View File

@ -56,7 +56,7 @@ import { walletModule } from "../modules/rwallet/mod";
import { voteModule } from "../modules/rvote/mod";
import { notesModule } from "../modules/rnotes/mod";
import { mapsModule } from "../modules/rmaps/mod";
import { workModule } from "../modules/rwork/mod";
import { tasksModule } from "../modules/rtasks/mod";
import { tripsModule } from "../modules/rtrips/mod";
import { calModule } from "../modules/rcal/mod";
import { networkModule } from "../modules/rnetwork/mod";
@ -99,7 +99,7 @@ registerModule(walletModule);
registerModule(voteModule);
registerModule(notesModule);
registerModule(mapsModule);
registerModule(workModule);
registerModule(tasksModule);
registerModule(tripsModule);
registerModule(calModule);
registerModule(networkModule);

View File

@ -329,7 +329,7 @@ export const notesMigration: ModuleMigration = {
// ============================================================================
/**
* Work: rwork.spaces + rwork.tasks
* Tasks: rtasks.spaces + rtasks.tasks
* Granularity: 1 doc per space/board ({space}:work:boards:{spaceId})
*/
export const workMigration: ModuleMigration = {
@ -340,14 +340,14 @@ export const workMigration: ModuleMigration = {
try {
const { rows: spaces } = await pool.query(
`SELECT * FROM rwork.spaces WHERE slug = $1`,
`SELECT * FROM rtasks.spaces WHERE slug = $1`,
[space]
);
for (const ws of spaces) {
try {
const { rows: tasks } = await pool.query(
`SELECT * FROM rwork.tasks WHERE space_id = $1 ORDER BY sort_order, created_at`,
`SELECT * FROM rtasks.tasks WHERE space_id = $1 ORDER BY sort_order, created_at`,
[ws.id]
);

View File

@ -615,10 +615,10 @@ const DEMO_SHAPES: Record<string, unknown>[] = [
ordersFulfilled: 127,
},
// ─── rWork: Task Board ─────────────────────────────────────
// ─── rTasks: Task Board ─────────────────────────────────────
{
id: "demo-work-board",
type: "folk-work-board",
id: "demo-tasks-board",
type: "folk-tasks-board",
x: 750, y: 1350, width: 500, height: 280, rotation: 0,
boardTitle: "Trip Preparation Tasks",
columns: [

View File

@ -277,10 +277,10 @@ const TEMPLATE_SHAPES: Record<string, unknown>[] = [
ordersFulfilled: 0,
},
// ─── rWork: Task Board ──────────────────────────────────────
// ─── rTasks: Task Board ──────────────────────────────────────
{
id: "tmpl-work-board",
type: "folk-work-board",
id: "tmpl-tasks-board",
type: "folk-tasks-board",
x: 750, y: 780, width: 500, height: 280, rotation: 0,
boardTitle: "Getting Started Tasks",
columns: [

View File

@ -578,6 +578,17 @@ export function renderShell(opts: ShellOptions): string {
}
});
tabBar.addEventListener('layer-reorder', (e) => {
const { layerId, newIndex } = e.detail;
const oldIdx = layers.findIndex(l => l.id === layerId);
if (oldIdx === -1 || oldIdx === newIndex) return;
const [moved] = layers.splice(oldIdx, 1);
layers.splice(newIndex, 0, moved);
layers.forEach((l, i) => l.order = i);
saveTabs();
tabBar.setLayers(layers);
});
// ── Dashboard navigate: user clicked a space/action on the dashboard ──
document.addEventListener('dashboard-navigate', (e) => {
const { moduleId: targetModule, spaceSlug: targetSpace } = e.detail;
@ -782,9 +793,12 @@ export function renderShell(opts: ShellOptions): string {
});
tabBar.addEventListener('layer-reorder', (e) => {
const { layerId, newIndex } = e.detail;
sync.updateLayer(layerId, { order: newIndex });
const all = sync.getLayers();
all.forEach((l, i) => { if (l.order !== i) sync.updateLayer(l.id, { order: i }); });
const all = sync.getLayers(); // already sorted by order
const oldIdx = all.findIndex(l => l.id === layerId);
if (oldIdx === -1 || oldIdx === newIndex) return;
const [moved] = all.splice(oldIdx, 1);
all.splice(newIndex, 0, moved);
all.forEach((l, i) => sync.updateLayer(l.id, { order: i }));
});
tabBar.addEventListener('flow-create', (e) => { sync.addFlow(e.detail.flow); });
tabBar.addEventListener('flow-remove', (e) => { sync.removeFlow(e.detail.flowId); });
@ -927,6 +941,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
tabBar.addEventListener('layer-switch', (e) => { saveTabs(); window.location.href = window.__rspaceNavUrl(spaceSlug, e.detail.moduleId); });
tabBar.addEventListener('layer-add', (e) => { const { moduleId } = e.detail; if (!layers.find(l => l.moduleId === moduleId)) layers.push(makeLayer(moduleId, layers.length)); saveTabs(); window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); });
tabBar.addEventListener('layer-close', (e) => { const { layerId } = e.detail; tabBar.removeLayer(layerId); layers = layers.filter(l => l.id !== layerId); saveTabs(); if (layerId === 'layer-' + currentModuleId && layers.length > 0) window.location.href = window.__rspaceNavUrl(spaceSlug, layers[0].moduleId); });
tabBar.addEventListener('layer-reorder', (e) => { const { layerId, newIndex } = e.detail; const oldIdx = layers.findIndex(l => l.id === layerId); if (oldIdx === -1 || oldIdx === newIndex) return; const [moved] = layers.splice(oldIdx, 1); layers.splice(newIndex, 0, moved); layers.forEach((l, i) => l.order = i); saveTabs(); tabBar.setLayers(layers); });
}
</script>
</body>

View File

@ -53,7 +53,7 @@ const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
// Observing
rdata: { badge: "r📊", color: "#d8b4fe" }, // purple-300
// Work & Productivity
rwork: { badge: "r📋", color: "#cbd5e1" }, // slate-300
rtasks: { badge: "r📋", color: "#cbd5e1" }, // slate-300
rschedule: { badge: "r⏱", color: "#a5b4fc" }, // indigo-200
// Identity & Infrastructure
rids: { badge: "r🪪", color: "#6ee7b7" }, // emerald-300
@ -88,7 +88,7 @@ const MODULE_CATEGORIES: Record<string, string> = {
rfiles: "Sharing",
rbooks: "Sharing",
rdata: "Observing",
rwork: "Work & Productivity",
rtasks: "Tasks & Productivity",
rschedule: "Work & Productivity",
rids: "Identity & Infrastructure",
rstack: "Identity & Infrastructure",

View File

@ -0,0 +1,487 @@
/**
* <rstack-collab-overlay> drop-in multiplayer presence for all rApps.
*
* Features:
* - "N online" badge (top-right pill with colored dots)
* - Remote cursors (SVG arrows with username labels, viewport-relative)
* - Focus highlighting (colored outline rings on data-collab-id elements)
* - Auto-discovery via rspace-doc-subscribe events from runtime
* - Hides on canvas page (rSpace has its own CommunitySync)
*
* Attributes:
* module-id current module identifier
* space current space slug
* doc-id explicit doc ID (fallback if auto-discovery misses)
* mode "badge-only" for iframe/proxy modules (no cursors)
*/
import type { AwarenessMessage } from '../local-first/sync';
// ── Peer color palette (same as canvas PresenceManager) ──
const PEER_COLORS = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
'#ec4899', '#06b6d4', '#f97316', '#14b8a6', '#6366f1',
];
interface PeerState {
peerId: string;
username: string;
color: string;
cursor: { x: number; y: number } | null;
selection: string | null;
lastSeen: number;
}
export class RStackCollabOverlay extends HTMLElement {
#shadow: ShadowRoot;
#peers = new Map<string, PeerState>();
#docId: string | null = null;
#moduleId: string | null = null;
#localPeerId: string | null = null;
#localColor = PEER_COLORS[0];
#localUsername = 'Anonymous';
#unsubAwareness: (() => void) | null = null;
#mouseMoveTimer: ReturnType<typeof setInterval> | null = null;
#lastCursor = { x: 0, y: 0 };
#gcInterval: ReturnType<typeof setInterval> | null = null;
#badgeOnly = false;
#hidden = false; // true on canvas page
constructor() {
super();
this.#shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#moduleId = this.getAttribute('module-id');
this.#badgeOnly = this.getAttribute('mode') === 'badge-only';
// Hide on canvas page — it has its own CommunitySync + PresenceManager
if (this.#moduleId === 'rspace') {
this.#hidden = true;
return;
}
// Explicit doc-id attribute (fallback)
const explicitDocId = this.getAttribute('doc-id');
if (explicitDocId) this.#docId = explicitDocId;
// Listen for runtime doc subscriptions (auto-discovery)
window.addEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
// Resolve local identity
this.#resolveIdentity();
// Render initial (empty badge)
this.#render();
// Try connecting to runtime
this.#tryConnect();
// GC stale peers every 5s
this.#gcInterval = setInterval(() => this.#gcPeers(), 5000);
}
disconnectedCallback() {
if (this.#hidden) return;
window.removeEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
this.#unsubAwareness?.();
this.#stopMouseTracking();
this.#stopFocusTracking();
if (this.#gcInterval) clearInterval(this.#gcInterval);
this.#gcInterval = null;
}
// ── Auto-discovery ──
#onDocSubscribe = (e: Event) => {
const { docId } = (e as CustomEvent).detail;
if (!this.#docId && docId) {
this.#docId = docId;
this.#connectToDoc();
}
};
// ── Runtime connection ──
#tryConnect() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (runtime?.isInitialized) {
this.#onRuntimeReady(runtime);
} else {
// Retry until runtime is ready
const check = setInterval(() => {
const rt = (window as any).__rspaceOfflineRuntime;
if (rt?.isInitialized) {
clearInterval(check);
this.#onRuntimeReady(rt);
}
}, 500);
// Give up after 15s
setTimeout(() => clearInterval(check), 15000);
}
}
#onRuntimeReady(runtime: any) {
this.#localPeerId = runtime.peerId;
// Assign a deterministic color from peer ID
this.#localColor = this.#colorForPeer(this.#localPeerId!);
if (this.#docId) {
this.#connectToDoc();
}
}
#connectToDoc() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime || !this.#docId) return;
// Unsubscribe from previous
this.#unsubAwareness?.();
// Listen for remote awareness
this.#unsubAwareness = runtime.onAwareness(this.#docId, (msg: AwarenessMessage) => {
if (msg.peer === this.#localPeerId) return; // ignore self
this.#handleRemoteAwareness(msg);
});
// Start broadcasting local presence
if (!this.#badgeOnly) {
this.#startMouseTracking();
this.#startFocusTracking();
}
// Send initial presence (announce we're here)
this.#broadcastPresence();
}
// ── Identity ──
#resolveIdentity() {
try {
const raw = localStorage.getItem('encryptid_session');
if (raw) {
const session = JSON.parse(raw);
if (session?.username) this.#localUsername = session.username;
else if (session?.displayName) this.#localUsername = session.displayName;
}
} catch { /* no session */ }
}
// ── Remote awareness handling ──
#handleRemoteAwareness(msg: AwarenessMessage) {
const existing = this.#peers.get(msg.peer);
const peer: PeerState = {
peerId: msg.peer,
username: msg.username || existing?.username || 'Anonymous',
color: msg.color || existing?.color || this.#colorForPeer(msg.peer),
cursor: msg.cursor ?? existing?.cursor ?? null,
selection: msg.selection ?? existing?.selection ?? null,
lastSeen: Date.now(),
};
this.#peers.set(msg.peer, peer);
this.#renderBadge();
if (!this.#badgeOnly) {
this.#renderCursors();
this.#renderFocusRings();
}
}
// ── Local broadcasting ──
#broadcastPresence(cursor?: { x: number; y: number }, selection?: string) {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime || !this.#docId) return;
runtime.sendAwareness(this.#docId, {
cursor,
selection,
username: this.#localUsername,
color: this.#localColor,
});
}
// ── Mouse tracking (15Hz throttle) ──
#mouseHandler = (e: MouseEvent) => {
this.#lastCursor = { x: e.clientX, y: e.clientY };
};
#startMouseTracking() {
document.addEventListener('mousemove', this.#mouseHandler, { passive: true });
// Broadcast at 15Hz
this.#mouseMoveTimer = setInterval(() => {
this.#broadcastPresence(this.#lastCursor, undefined);
}, 67); // ~15Hz
}
#stopMouseTracking() {
document.removeEventListener('mousemove', this.#mouseHandler);
if (this.#mouseMoveTimer) clearInterval(this.#mouseMoveTimer);
this.#mouseMoveTimer = null;
}
// ── Focus tracking on data-collab-id elements ──
#focusHandler = (e: FocusEvent) => {
const target = (e.target as HTMLElement)?.closest?.('[data-collab-id]');
if (target) {
const collabId = target.getAttribute('data-collab-id');
if (collabId) this.#broadcastPresence(undefined, collabId);
}
};
#blurHandler = () => {
this.#broadcastPresence(undefined, '');
};
#startFocusTracking() {
document.addEventListener('focusin', this.#focusHandler, { passive: true });
document.addEventListener('click', this.#clickHandler, { passive: true });
document.addEventListener('focusout', this.#blurHandler, { passive: true });
}
#stopFocusTracking() {
document.removeEventListener('focusin', this.#focusHandler);
document.removeEventListener('click', this.#clickHandler);
document.removeEventListener('focusout', this.#blurHandler);
}
// Also track clicks on data-collab-id (many elements aren't focusable)
#clickHandler = (e: MouseEvent) => {
const target = (e.target as HTMLElement)?.closest?.('[data-collab-id]');
if (target) {
const collabId = target.getAttribute('data-collab-id');
if (collabId) this.#broadcastPresence(undefined, collabId);
}
};
// ── GC stale peers ──
#gcPeers() {
const now = Date.now();
let changed = false;
for (const [id, peer] of this.#peers) {
if (now - peer.lastSeen > 15000) {
this.#peers.delete(id);
changed = true;
}
}
if (changed) {
this.#renderBadge();
if (!this.#badgeOnly) {
this.#renderCursors();
this.#renderFocusRings();
}
}
}
// ── Color assignment ──
#colorForPeer(peerId: string): string {
let hash = 0;
for (let i = 0; i < peerId.length; i++) {
hash = ((hash << 5) - hash + peerId.charCodeAt(i)) | 0;
}
return PEER_COLORS[Math.abs(hash) % PEER_COLORS.length];
}
// ── Rendering ──
#render() {
this.#shadow.innerHTML = `
<style>${OVERLAY_CSS}</style>
<div class="collab-badge" id="badge"></div>
<div class="collab-cursors" id="cursors"></div>
`;
}
#renderBadge() {
const badge = this.#shadow.getElementById('badge');
if (!badge) return;
const count = this.#peers.size + 1; // +1 for self
if (count <= 1) {
badge.innerHTML = '';
badge.classList.remove('visible');
return;
}
const dots = Array.from(this.#peers.values())
.slice(0, 5) // show max 5 dots
.map(p => `<span class="dot" style="background:${p.color}"></span>`)
.join('');
badge.innerHTML = `
<span class="dot" style="background:${this.#localColor}"></span>
${dots}
<span class="count">${count} online</span>
`;
badge.classList.add('visible');
}
#renderCursors() {
const container = this.#shadow.getElementById('cursors');
if (!container) return;
const now = Date.now();
const fragments: string[] = [];
for (const peer of this.#peers.values()) {
if (!peer.cursor) continue;
const age = now - peer.lastSeen;
const opacity = age > 5000 ? 0.3 : 1;
fragments.push(`
<div class="cursor" style="left:${peer.cursor.x}px;top:${peer.cursor.y}px;opacity:${opacity}">
<svg width="16" height="20" viewBox="0 0 16 20" fill="none">
<path d="M1 1L6.5 18L8.5 11L15 9L1 1Z" fill="${peer.color}" stroke="white" stroke-width="1.5"/>
</svg>
<span class="cursor-label" style="background:${peer.color}">${this.#escHtml(peer.username)}</span>
</div>
`);
}
container.innerHTML = fragments.join('');
}
#renderFocusRings() {
// Remove all existing focus rings from the document
document.querySelectorAll('.rstack-collab-focus-ring').forEach(el => el.remove());
for (const peer of this.#peers.values()) {
if (!peer.selection) continue;
const target = document.querySelector(`[data-collab-id="${CSS.escape(peer.selection)}"]`);
if (!target) continue;
const rect = target.getBoundingClientRect();
const ring = document.createElement('div');
ring.className = 'rstack-collab-focus-ring';
ring.style.cssText = `
position: fixed;
left: ${rect.left - 3}px;
top: ${rect.top - 3}px;
width: ${rect.width + 6}px;
height: ${rect.height + 6}px;
border: 2px solid ${peer.color};
border-radius: 6px;
pointer-events: none;
z-index: 9998;
box-shadow: 0 0 0 1px ${peer.color}33;
transition: all 0.15s ease;
`;
// Username label on the ring
const label = document.createElement('span');
label.textContent = peer.username;
label.style.cssText = `
position: absolute;
top: -18px;
left: 4px;
font-size: 10px;
color: white;
background: ${peer.color};
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
line-height: 14px;
`;
ring.appendChild(label);
document.body.appendChild(ring);
}
}
#escHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
static define() {
if (!customElements.get('rstack-collab-overlay')) {
customElements.define('rstack-collab-overlay', RStackCollabOverlay);
}
}
}
// ── Styles (inside shadow DOM) ──
const OVERLAY_CSS = `
:host {
display: block;
position: fixed;
top: 0;
left: 0;
width: 0;
height: 0;
z-index: 9999;
pointer-events: none;
}
.collab-badge {
position: fixed;
top: 8px;
right: 80px;
display: none;
align-items: center;
gap: 4px;
padding: 4px 10px 4px 6px;
border-radius: 16px;
background: var(--rs-bg-secondary, rgba(30, 30, 30, 0.85));
backdrop-filter: blur(8px);
font-size: 11px;
color: var(--rs-text-secondary, #ccc);
pointer-events: auto;
cursor: default;
user-select: none;
z-index: 10000;
border: 1px solid var(--rs-border, rgba(255,255,255,0.08));
}
.collab-badge.visible {
display: inline-flex;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.count {
margin-left: 2px;
font-weight: 500;
}
.collab-cursors {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 9999;
overflow: hidden;
}
.cursor {
position: fixed;
pointer-events: none;
transition: left 0.1s linear, top 0.1s linear, opacity 0.3s ease;
z-index: 9999;
}
.cursor-label {
position: absolute;
left: 14px;
top: 14px;
font-size: 10px;
color: white;
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
line-height: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
`;

View File

@ -51,7 +51,7 @@ const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
rfiles: { badge: "r📁", color: "#67e8f9" },
rbooks: { badge: "r📚", color: "#fda4af" },
rdata: { badge: "r📊", color: "#d8b4fe" },
rwork: { badge: "r📋", color: "#cbd5e1" },
rtasks: { badge: "r📋", color: "#cbd5e1" },
rschedule: { badge: "r⏱", color: "#a5b4fc" },
rids: { badge: "r🪪", color: "#6ee7b7" },
rstack: { badge: "r✨", color: "" },
@ -67,7 +67,7 @@ const MODULE_CATEGORIES: Record<string, string> = {
rflows: "Funding & Commerce", rwallet: "Funding & Commerce", rcart: "Funding & Commerce", rauctions: "Funding & Commerce",
rphotos: "Sharing", rnetwork: "Sharing", rsocials: "Sharing", rfiles: "Sharing", rbooks: "Sharing",
rdata: "Observing",
rwork: "Work & Productivity",
rtasks: "Tasks & Productivity",
rids: "Identity & Infrastructure", rstack: "Identity & Infrastructure",
};
@ -1501,7 +1501,8 @@ const STYLES = `
/* ── Drag states ── */
.tab.dragging { opacity: 0.4; }
.tab { cursor: grab; }
.tab.dragging { opacity: 0.4; cursor: grabbing; }
.tab.drag-over { box-shadow: inset 2px 0 0 #22d3ee; }
/* ── Add button ── */

View File

@ -20,7 +20,7 @@ import {
DocumentManager,
} from './document';
import { EncryptedDocStore } from './storage';
import { DocSyncManager } from './sync';
import { DocSyncManager, type AwarenessMessage } from './sync';
import { DocCrypto } from './crypto';
import {
getStorageInfo,
@ -72,6 +72,7 @@ export class RSpaceOfflineRuntime {
get isInitialized(): boolean { return this.#initialized; }
get isOnline(): boolean { return this.#sync.isConnected; }
get status(): RuntimeStatus { return this.#status; }
get peerId(): string { return this.#sync.peerId; }
// ── Lifecycle ──
@ -131,6 +132,11 @@ export class RSpaceOfflineRuntime {
// Subscribe for sync (sends subscribe + initial sync to server)
await this.#sync.subscribe([docId]);
// Notify overlay / other listeners about this subscription
window.dispatchEvent(new CustomEvent('rspace-doc-subscribe', {
detail: { docId, module: docId.split(':')[1] ?? '' },
}));
return doc;
}
@ -178,6 +184,21 @@ export class RSpaceOfflineRuntime {
return () => { unsub1(); unsub2(); };
}
/**
* Send awareness/presence data for a document (cursor, selection, etc.).
*/
sendAwareness(docId: DocumentId, data: Partial<AwarenessMessage>): void {
this.#sync.sendAwareness(docId, data);
}
/**
* Listen for awareness updates on a document from remote peers.
* Returns an unsubscribe function.
*/
onAwareness(docId: DocumentId, cb: (msg: AwarenessMessage) => void): () => void {
return this.#sync.onAwareness(docId, cb);
}
/**
* Configure module scope information from the page's module list.
* Call once after init with the modules config for this space.

277
shared/tour-engine.ts Normal file
View File

@ -0,0 +1,277 @@
/**
* Shared guided-tour engine for shadow-DOM rApp components.
*
* Designed to survive full innerHTML replacement call `renderOverlay()`
* at the end of each render cycle to re-inject the tour UI.
*/
export interface TourStep {
target: string; // CSS selector within shadow root
title: string;
message: string;
advanceOnClick?: boolean; // auto-advance when target is clicked
}
export class TourEngine {
private shadowRoot: ShadowRoot;
private steps: TourStep[];
private storageKey: string;
private getContainer: () => HTMLElement | null;
private _active = false;
private _step = 0;
private _clickHandler: (() => void) | null = null;
private _clickTarget: HTMLElement | null = null;
get isActive() { return this._active; }
constructor(
shadowRoot: ShadowRoot,
steps: TourStep[],
storageKey: string,
getContainer: () => HTMLElement | null,
) {
this.shadowRoot = shadowRoot;
this.steps = steps;
this.storageKey = storageKey;
this.getContainer = getContainer;
}
/** Start (or restart) the tour from step 0. */
start() {
this._active = true;
this._step = 0;
this.renderOverlay();
}
/** Advance to next step or finish if at the end. */
advance() {
this._detachClickHandler();
this._step++;
if (this._step >= this.steps.length) {
this.end();
} else {
this.renderOverlay();
}
}
/** End the tour and remove the overlay. */
end() {
this._detachClickHandler();
this._active = false;
this._step = 0;
localStorage.setItem(this.storageKey, "1");
this.shadowRoot.getElementById("rspace-tour-overlay")?.remove();
}
/**
* Re-inject / update the tour overlay.
* Call this at the end of every host render() so the overlay survives
* full innerHTML replacement. Safe to call when tour is inactive (no-op).
*/
renderOverlay() {
if (!this._active) return;
// Ensure styles exist
this._ensureStyles();
// Skip steps whose target doesn't exist (e.g. mic button when unsupported)
const step = this.steps[this._step];
const targetEl = this.shadowRoot.querySelector(step.target) as HTMLElement | null;
if (!targetEl && this._step < this.steps.length - 1) {
this._step++;
this.renderOverlay();
return;
}
// Get or create overlay element
let overlay = this.shadowRoot.getElementById("rspace-tour-overlay");
if (!overlay) {
overlay = document.createElement("div");
overlay.id = "rspace-tour-overlay";
overlay.className = "rspace-tour-overlay";
const container = this.getContainer();
if (container) container.appendChild(overlay);
else this.shadowRoot.appendChild(overlay);
}
// Compute spotlight position relative to container
let spotX = 0, spotY = 0, spotW = 120, spotH = 40;
if (targetEl) {
const container = this.getContainer() || this.shadowRoot.host as HTMLElement;
const containerRect = container.getBoundingClientRect();
const rect = targetEl.getBoundingClientRect();
spotX = rect.left - containerRect.left - 6;
spotY = rect.top - containerRect.top - 6;
spotW = rect.width + 12;
spotH = rect.height + 12;
}
const isLast = this._step >= this.steps.length - 1;
const stepNum = this._step + 1;
const totalSteps = this.steps.length;
// Position tooltip below target, clamped within container
const tooltipTop = spotY + spotH + 12;
const tooltipLeft = Math.max(8, spotX);
overlay.innerHTML = `
<div class="rspace-tour-backdrop" style="clip-path: polygon(
0% 0%, 0% 100%, ${spotX}px 100%, ${spotX}px ${spotY}px,
${spotX + spotW}px ${spotY}px, ${spotX + spotW}px ${spotY + spotH}px,
${spotX}px ${spotY + spotH}px, ${spotX}px 100%, 100% 100%, 100% 0%
)"></div>
<div class="rspace-tour-spotlight" style="left:${spotX}px;top:${spotY}px;width:${spotW}px;height:${spotH}px"></div>
<div class="rspace-tour-tooltip" style="top:${tooltipTop}px;left:${tooltipLeft}px">
<div class="rspace-tour-tooltip__step">${stepNum} / ${totalSteps}</div>
<div class="rspace-tour-tooltip__title">${step.title}</div>
<div class="rspace-tour-tooltip__msg">${step.message}</div>
<div class="rspace-tour-tooltip__nav">
${this._step > 0 ? '<button class="rspace-tour-tooltip__btn rspace-tour-tooltip__btn--prev" data-tour="prev">Back</button>' : ''}
${step.advanceOnClick
? `<span class="rspace-tour-tooltip__hint">or click the button above</span>`
: `<button class="rspace-tour-tooltip__btn rspace-tour-tooltip__btn--next" data-tour="next">${isLast ? 'Finish' : 'Next'}</button>`
}
<button class="rspace-tour-tooltip__btn rspace-tour-tooltip__btn--skip" data-tour="skip">Skip</button>
</div>
</div>
`;
// Wire tour navigation buttons
overlay.querySelectorAll("[data-tour]").forEach(btn => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const action = (btn as HTMLElement).dataset.tour;
if (action === "next") this.advance();
else if (action === "prev") {
this._detachClickHandler();
this._step = Math.max(0, this._step - 1);
this.renderOverlay();
}
else if (action === "skip") this.end();
});
});
// For advanceOnClick steps, attach listener on the target
this._detachClickHandler();
if (step.advanceOnClick && targetEl) {
this._clickHandler = () => {
this._detachClickHandler();
setTimeout(() => this.advance(), 300);
};
this._clickTarget = targetEl;
targetEl.addEventListener("click", this._clickHandler);
}
}
/** Remove the click-to-advance listener from the previous target. */
private _detachClickHandler() {
if (this._clickHandler && this._clickTarget) {
this._clickTarget.removeEventListener("click", this._clickHandler);
}
this._clickHandler = null;
this._clickTarget = null;
}
/** Inject the shared tour CSS once into the shadow root. */
private _ensureStyles() {
if (this.shadowRoot.querySelector("[data-tour-styles]")) return;
const style = document.createElement("style");
style.setAttribute("data-tour-styles", "");
style.textContent = TOUR_CSS;
this.shadowRoot.appendChild(style);
}
}
const TOUR_CSS = `
.rspace-tour-overlay {
position: absolute; inset: 0; z-index: 10000;
pointer-events: none;
}
.rspace-tour-backdrop {
position: absolute; inset: 0;
background: rgba(0, 0, 0, 0.55);
pointer-events: auto;
transition: clip-path 0.3s ease;
}
.rspace-tour-spotlight {
position: absolute;
border: 2px solid var(--rs-primary, #06b6d4);
border-radius: 8px;
box-shadow: 0 0 0 4px rgba(6, 182, 212, 0.25);
pointer-events: none;
transition: all 0.3s ease;
}
.rspace-tour-tooltip {
position: absolute;
width: min(320px, calc(100% - 24px));
background: var(--rs-bg-surface, #1e293b);
border: 1px solid var(--rs-border, #334155);
border-radius: 12px;
padding: 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
color: var(--rs-text-primary, #f1f5f9);
pointer-events: auto;
animation: rspace-tour-pop 0.25s ease-out;
}
@keyframes rspace-tour-pop {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.rspace-tour-tooltip__step {
font-size: 0.7rem;
color: var(--rs-text-muted, #64748b);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rspace-tour-tooltip__title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 6px;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.rspace-tour-tooltip__msg {
font-size: 0.85rem;
color: var(--rs-text-secondary, #94a3b8);
line-height: 1.5;
margin-bottom: 12px;
}
.rspace-tour-tooltip__nav {
display: flex;
align-items: center;
gap: 8px;
}
.rspace-tour-tooltip__btn {
padding: 6px 14px;
border-radius: 6px;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
border: none;
transition: background 0.15s, transform 0.1s;
}
.rspace-tour-tooltip__btn:hover { transform: translateY(-1px); }
.rspace-tour-tooltip__btn--next {
background: linear-gradient(135deg, #06b6d4, #7c3aed);
color: white;
}
.rspace-tour-tooltip__btn--prev {
background: var(--rs-btn-secondary-bg, #334155);
color: var(--rs-text-secondary, #94a3b8);
}
.rspace-tour-tooltip__btn--skip {
background: none;
color: var(--rs-text-muted, #64748b);
margin-left: auto;
}
.rspace-tour-tooltip__btn--skip:hover { color: var(--rs-text-primary, #f1f5f9); }
.rspace-tour-tooltip__hint {
font-size: 0.72rem;
color: var(--rs-text-muted, #64748b);
font-style: italic;
}
`;

58
shared/view-history.ts Normal file
View File

@ -0,0 +1,58 @@
/**
* ViewHistory lightweight in-app navigation stack for rApps.
*
* Each rApp with hierarchical views instantiates one, calls push()
* on forward navigation, and back() from the back button. Replaces
* hardcoded data-back targets with a proper history stack.
*/
export interface ViewEntry<V extends string> {
view: V;
context?: Record<string, unknown>;
}
const MAX_DEPTH = 20;
export class ViewHistory<V extends string> {
private stack: ViewEntry<V>[] = [];
private root: V;
constructor(rootView: V) {
this.root = rootView;
}
/** Record a forward navigation. Skips if top of stack is same view+context. */
push(view: V, context?: Record<string, unknown>): void {
const top = this.stack[this.stack.length - 1];
if (top && top.view === view) return; // skip duplicate
this.stack.push({ view, context });
if (this.stack.length > MAX_DEPTH) this.stack.shift();
}
/** Pop and return the previous entry, or null if at root. */
back(): ViewEntry<V> | null {
if (this.stack.length <= 1) {
this.stack = [];
return { view: this.root };
}
this.stack.pop(); // remove current
return this.stack[this.stack.length - 1] ?? { view: this.root };
}
/** True when there's history to go back to. */
get canGoBack(): boolean {
return this.stack.length > 1;
}
/** Peek at the previous entry without popping. */
peekBack(): ViewEntry<V> | null {
if (this.stack.length <= 1) return { view: this.root };
return this.stack[this.stack.length - 2] ?? null;
}
/** Clear the stack and reset to a root view (e.g. on space switch). */
reset(rootView?: V): void {
if (rootView !== undefined) this.root = rootView;
this.stack = [];
}
}

View File

@ -170,7 +170,7 @@ const CONFIG = {
'https://demo.rsocials.online',
'https://socials.crypto-commons.org',
'https://socials.p2pfoundation.net',
'https://rwork.online',
'https://rtasks.online',
'https://rforum.online',
'https://rchoices.online',
'https://rswag.online',

View File

@ -472,31 +472,31 @@ export default defineConfig({
resolve(__dirname, "dist/modules/rmaps/maps.css"),
);
// Build work module component
// Build tasks module component
await build({
configFile: false,
root: resolve(__dirname, "modules/rwork/components"),
root: resolve(__dirname, "modules/rtasks/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rwork"),
outDir: resolve(__dirname, "dist/modules/rtasks"),
lib: {
entry: resolve(__dirname, "modules/rwork/components/folk-work-board.ts"),
entry: resolve(__dirname, "modules/rtasks/components/folk-tasks-board.ts"),
formats: ["es"],
fileName: () => "folk-work-board.js",
fileName: () => "folk-tasks-board.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-work-board.js",
entryFileNames: "folk-tasks-board.js",
},
},
},
});
// Copy work CSS
mkdirSync(resolve(__dirname, "dist/modules/rwork"), { recursive: true });
// Copy tasks CSS
mkdirSync(resolve(__dirname, "dist/modules/rtasks"), { recursive: true });
copyFileSync(
resolve(__dirname, "modules/rwork/components/work.css"),
resolve(__dirname, "dist/modules/rwork/work.css"),
resolve(__dirname, "modules/rtasks/components/tasks.css"),
resolve(__dirname, "dist/modules/rtasks/tasks.css"),
);
// Build trips module component

View File

@ -2210,7 +2210,7 @@
<button id="embed-books" title="Embed rBooks">📚 rBooks</button>
<button id="embed-forum" title="Embed rForum">💬 rForum</button>
<button id="embed-files" title="Embed rFiles">📁 rFiles</button>
<button id="embed-work" title="Embed rWork">📋 rWork</button>
<button id="embed-tasks" title="Embed rTasks">📋 rTasks</button>
<button id="embed-inbox" title="Embed rInbox">📧 rInbox</button>
<button id="embed-cart" title="Embed rCart">🛒 rCart</button>
<button id="embed-data" title="Embed rData">📊 rData</button>
@ -2794,9 +2794,12 @@
});
tabBar.addEventListener('layer-reorder', (e) => {
const { layerId, newIndex } = e.detail;
sync.updateLayer?.(layerId, { order: newIndex });
const all = sync.getLayers?.() || [];
all.forEach((l, i) => { if (l.order !== i) sync.updateLayer?.(l.id, { order: i }); });
const oldIdx = all.findIndex(l => l.id === layerId);
if (oldIdx === -1 || oldIdx === newIndex) return;
const [moved] = all.splice(oldIdx, 1);
all.splice(newIndex, 0, moved);
all.forEach((l, i) => sync.updateLayer?.(l.id, { order: i }));
});
tabBar.addEventListener('flow-create', (e) => { sync.addFlow?.(e.detail.flow); });
tabBar.addEventListener('flow-remove', (e) => { sync.removeFlow?.(e.detail.flowId); });
@ -4403,7 +4406,7 @@
{ btnId: "embed-books", moduleId: "rbooks" },
{ btnId: "embed-pubs", moduleId: "rpubs" },
{ btnId: "embed-files", moduleId: "rfiles" },
{ btnId: "embed-work", moduleId: "rwork" },
{ btnId: "embed-tasks", moduleId: "rtasks" },
{ btnId: "embed-forum", moduleId: "rforum" },
{ btnId: "embed-inbox", moduleId: "rinbox" },
{ btnId: "embed-tube", moduleId: "rtube" },

File diff suppressed because one or more lines are too long

View File

@ -17,6 +17,7 @@ import { RStackSpaceSettings } from "../shared/components/rstack-space-settings"
import { RStackModuleSetup } from "../shared/components/rstack-module-setup";
import { RStackHistoryPanel } from "../shared/components/rstack-history-panel";
import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator";
import { RStackCollabOverlay } from "../shared/components/rstack-collab-overlay";
import { RStackUserDashboard } from "../shared/components/rstack-user-dashboard";
import { rspaceNavUrl } from "../shared/url-helpers";
import { TabCache } from "../shared/tab-cache";
@ -39,6 +40,7 @@ RStackSpaceSettings.define();
RStackModuleSetup.define();
RStackHistoryPanel.define();
RStackOfflineIndicator.define();
RStackCollabOverlay.define();
RStackUserDashboard.define();
// ── Offline Runtime ──