Merge branch 'dev'
This commit is contained in:
commit
ab38a23d4e
|
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
id: TASK-82
|
||||||
|
title: Sankey-proportional edges + node satisfaction bars in rFunds diagram
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-03 05:32'
|
||||||
|
labels:
|
||||||
|
- rfunds
|
||||||
|
- visualization
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- modules/rfunds/components/folk-funds-app.ts
|
||||||
|
- modules/rfunds/components/funds.css
|
||||||
|
- modules/rfunds/lib/simulation.ts
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Edge widths now reflect actual dollar flow (source rates, overflow excess, spending drain) instead of just allocation percentages. Zero-flow paths render as ghost edges. Edge labels show dollar amounts alongside percentages. Funnel nodes display an inflow satisfaction bar. Outcome progress bars enhanced with dollar labels.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #1 Edge widths proportional to actual dollar flow per edge
|
||||||
|
- [ ] #2 Ghost edges (dashed, low opacity) for zero-flow paths
|
||||||
|
- [ ] #3 Edge labels show dollar amounts: $2.5k (50%)
|
||||||
|
- [ ] #4 Funnel nodes show inflow satisfaction bar (green = received, grey = gap)
|
||||||
|
- [ ] #5 Outcome nodes have enhanced 8px progress bars with dollar labels
|
||||||
|
- [ ] #6 Clean tsc and vite build with no errors
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Implemented in commit e644797 on dev branch. Modified `folk-funds-app.ts` (renderAllEdges two-pass with EdgeInfo, computeInflowSatisfaction, renderFunnelNodeSvg/renderOutcomeNodeSvg updated) and `funds.css` (ghost edge + satisfaction bar styles). Deployed to production.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
id: TASK-83
|
||||||
|
title: Fix tab-cache inline style extraction for canvas toolbar
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-03 07:42'
|
||||||
|
labels:
|
||||||
|
- bugfix
|
||||||
|
- canvas
|
||||||
|
- tab-cache
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- shared/tab-cache.ts
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
The tab-cache system (shared/tab-cache.ts) only extracted <link rel="stylesheet"> tags when switching tabs, missing inline <style> blocks. The canvas toolbar CSS is entirely in inline <style> blocks in canvas.html, causing unstyled toolbar when switching to rSpace via tab cache.
|
||||||
|
|
||||||
|
Fixed extractContent() to also collect inline <style> blocks from fetched page heads, and loadAssets() to inject them with data-tab-style attributes for deduplication.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Added inline <style> block extraction to tab-cache's extractContent() and injection in loadAssets(). Toolbar now renders correctly when switching to rSpace tab via client-side navigation. Committed as 4819852.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
---
|
||||||
|
id: TASK-84
|
||||||
|
title: Fix shape x/y/size preservation on canvas reload
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-03 07:42'
|
||||||
|
labels:
|
||||||
|
- bugfix
|
||||||
|
- canvas
|
||||||
|
- shapes
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- lib/folk-shape.ts
|
||||||
|
- website/canvas.html
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
All canvas shapes stacked at (0,0) on page reload. Root cause: FolkShape.createRenderRoot() unconditionally read x/y/width/height from HTML attributes, but canvas.html sets these as JS properties before DOM insertion. Since attributes don't exist, everything defaulted to 0/auto.
|
||||||
|
|
||||||
|
Fixed to only read from attributes when they actually exist (getAttribute !== null). Also fixed eraser: hardDeleteShape() was only in a click handler that never fired because pointerdown already removed the target element — moved Automerge deletion into pointerdown.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Fixed createRenderRoot() to conditionally read from HTML attributes only when present. Fixed eraser to persist deletions to Automerge in pointerdown handler instead of unreachable click handler. Committed as 4f9b036.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
---
|
||||||
|
id: TASK-85
|
||||||
|
title: 'Fix folk-wrapper crash, service worker API exclusion, fal.ai image-gen'
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-03 07:43'
|
||||||
|
labels:
|
||||||
|
- bugfix
|
||||||
|
- canvas
|
||||||
|
- service-worker
|
||||||
|
- ai
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- lib/folk-wrapper.ts
|
||||||
|
- website/sw.ts
|
||||||
|
- server/index.ts
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Three fixes:
|
||||||
|
|
||||||
|
1. folk-wrapper.ts createRenderRoot() crashed with "Cannot read properties of null (reading 'appendChild')" — innerHTML="" removed the slot from DOM, making parentElement null on the next line. Fixed by saving parent ref before clearing.
|
||||||
|
|
||||||
|
2. Service worker (sw.ts) only excluded /api/ at root path, not module API paths like /jeff/rcal/api/events. These got cached and when network failed, catch handler returned undefined instead of a Response. Fixed with includes("/api/") and proper fallback Response.
|
||||||
|
|
||||||
|
3. Image generation returned 502 "No image returned" — queue.fal.run is async (returns request_id), not the actual image. Changed to synchronous fal.run endpoint for all three fal.ai endpoints (image-gen, t2v, i2v).
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Fixed folk-wrapper parentElement null crash, service worker module API path exclusion with proper offline fallback, and fal.ai endpoint from queue.fal.run to fal.run for synchronous image/video generation. Committed as fef419f.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
---
|
||||||
|
id: TASK-86
|
||||||
|
title: Encrypted server-side account vault for EncryptID
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-03 19:15'
|
||||||
|
updated_date: '2026-03-03 19:15'
|
||||||
|
labels:
|
||||||
|
- encryptid
|
||||||
|
- security
|
||||||
|
- feature
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- src/encryptid/vault.ts
|
||||||
|
- src/encryptid/server.ts
|
||||||
|
- shared/local-first/crypto.ts
|
||||||
|
- server/local-first/backup-routes.ts
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Zero-knowledge vault stores all EncryptID account data (profile, emails, devices, addresses, wallets, preferences) as a single AES-256-GCM encrypted JSON blob via the backup API. Key derived deterministically from WebAuthn PRF via HKDF — same passkey = same key on any device. Server never sees plaintext.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 VaultManager class with AccountVault interface, DocCrypto encryption, backup API storage, localStorage cache
|
||||||
|
- [x] #2 Vault auto-loads on passkey auth (handleLogin + conditionalUI), clears on logout
|
||||||
|
- [x] #3 Dashboard UI: checklist item, vault section with Save/Restore buttons, status display
|
||||||
|
- [x] #4 Save triggers passkey re-auth → AES-256-GCM encrypt → PUT /api/backup/__vault/account-vault
|
||||||
|
- [x] #5 Restore triggers passkey re-auth → GET → decrypt → populate DOM
|
||||||
|
- [x] #6 checkVaultStatus() on profile load updates checklist green check
|
||||||
|
- [x] #7 No new server routes or DB tables — uses existing backup API
|
||||||
|
- [x] #8 tsc --noEmit and vite build pass clean
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
## Files Created\n- `src/encryptid/vault.ts` — VaultManager class, AccountVault interface, singleton pattern\n\n## Files Modified\n- `src/encryptid/index.ts` — Export vault types and functions\n- `src/encryptid/ui/login-button.ts` — Load vault after auth, clear on logout\n- `src/encryptid/server.ts` — Dashboard vault section, checklist item, inline crypto functions (deriveVaultKey, saveVault, restoreVault, checkVaultStatus)\n\n## Key Design\n- Vault key: `Master PRF → HKDF("rspace:__vault") → HKDF("doc:account-vault") → AES-256-GCM`\n- Dashboard uses inline WebCrypto (not VaultManager import) since dashboard auth doesn't initialize DocCrypto\n- Save/restore require biometric re-auth for security\n\nCommit: e2e12af, deployed to production.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
---
|
||||||
|
id: TASK-HIGH.4
|
||||||
|
title: Fix Automerge proxy re-assignment error on canvas load
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-03 19:17'
|
||||||
|
updated_date: '2026-03-03 19:18'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
parent_task_id: TASK-HIGH
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Fix 'Cannot create a reference to an existing document object' RangeError that fires twice on canvas load (initFromCache + WS sync). Root cause: Automerge proxy objects from doc.shapes passed through DOM elements back into Automerge.change(). Fixed by deep-cloning in #shapeToData() and #updateShapeInDoc().
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 No RangeError in console on canvas hard-refresh
|
||||||
|
- [x] #2 Shapes persist correctly after move/resize
|
||||||
|
- [x] #3 Cross-tab sync works without errors
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Fixed in commit 023a883. Deep-cloned shape data in #shapeToData() and #updateShapeInDoc() to break Automerge proxy chain. Deployed to production.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
@ -0,0 +1,723 @@
|
||||||
|
import { FolkShape } from "./folk-shape";
|
||||||
|
import { css, html } from "./tags";
|
||||||
|
|
||||||
|
const USER_ID_KEY = "folk-choice-userid";
|
||||||
|
const USER_NAME_KEY = "folk-choice-username";
|
||||||
|
|
||||||
|
const styles = css`
|
||||||
|
:host {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
min-width: 340px;
|
||||||
|
min-height: 380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #d97706;
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100% - 36px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conviction-bg {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
opacity: 0.12;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1e293b;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stake-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stake-btn {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 1px solid #fbbf24;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stake-btn:hover { background: #fef3c7; }
|
||||||
|
.stake-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
|
.stake-count {
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 14px;
|
||||||
|
text-align: center;
|
||||||
|
color: #92400e;
|
||||||
|
font-weight: 600;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-conviction {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #d97706;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: right;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-time {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
min-width: 32px;
|
||||||
|
text-align: right;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weight-bar {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weight-bar .used {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voters-count {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-input {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-input:focus { border-color: #d97706; }
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
background: #d97706;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn:hover { background: #b45309; }
|
||||||
|
|
||||||
|
.username-prompt {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-prompt p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-input {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-btn {
|
||||||
|
background: #d97706;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper { position: relative; height: 100%; }
|
||||||
|
.results-drawer {
|
||||||
|
position: absolute; top: 0; left: 100%; width: 300px; height: 100%;
|
||||||
|
background: white; border-radius: 0 8px 8px 0;
|
||||||
|
box-shadow: 4px 0 12px rgba(0,0,0,0.08);
|
||||||
|
overflow-y: auto; display: none; flex-direction: column;
|
||||||
|
font-size: 12px; z-index: 10;
|
||||||
|
}
|
||||||
|
.drawer-open .results-drawer { display: flex; }
|
||||||
|
.drawer-section { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; }
|
||||||
|
.drawer-heading {
|
||||||
|
font-size: 10px; font-weight: 600; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px; color: #94a3b8; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.stat-row { display: flex; justify-content: space-between; padding: 3px 0; font-size: 11px; }
|
||||||
|
.stat-label { color: #64748b; }
|
||||||
|
.stat-value { font-weight: 600; color: #1e293b; font-variant-numeric: tabular-nums; }
|
||||||
|
.drawer-bar-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
||||||
|
.drawer-bar-label { font-size: 11px; min-width: 60px; color: #1e293b; }
|
||||||
|
.drawer-bar-bg { flex: 1; height: 12px; background: #f1f5f9; border-radius: 3px; overflow: hidden; }
|
||||||
|
.drawer-bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
|
||||||
|
.drawer-bar-val { font-size: 10px; font-weight: 600; min-width: 24px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.participant-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; color: #475569; }
|
||||||
|
.participant-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.drawer-toggle.active { background: rgba(255,255,255,0.3); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
// -- Data types --
|
||||||
|
|
||||||
|
export interface ConvictionOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConvictionStake {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
optionId: string;
|
||||||
|
weight: number;
|
||||||
|
since: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Pure aggregation functions --
|
||||||
|
|
||||||
|
export function convictionScore(
|
||||||
|
stakes: ConvictionStake[],
|
||||||
|
optionId: string,
|
||||||
|
now: number,
|
||||||
|
): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const s of stakes) {
|
||||||
|
if (s.optionId !== optionId) continue;
|
||||||
|
total += s.weight * Math.max(0, now - s.since) / 3600000;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convictionVelocity(
|
||||||
|
stakes: ConvictionStake[],
|
||||||
|
optionId: string,
|
||||||
|
): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const s of stakes) {
|
||||||
|
if (s.optionId !== optionId) continue;
|
||||||
|
total += s.weight;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Component --
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"folk-choice-conviction": FolkChoiceConviction;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_COLORS = ["#f59e0b", "#3b82f6", "#22c55e", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"];
|
||||||
|
|
||||||
|
export class FolkChoiceConviction extends FolkShape {
|
||||||
|
static override tagName = "folk-choice-conviction";
|
||||||
|
|
||||||
|
static {
|
||||||
|
const sheet = new CSSStyleSheet();
|
||||||
|
const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join("\n");
|
||||||
|
const childRules = Array.from(styles.cssRules).map((r) => r.cssText).join("\n");
|
||||||
|
sheet.replaceSync(`${parentRules}\n${childRules}`);
|
||||||
|
this.styles = sheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
#title = "Conviction Ranking";
|
||||||
|
#options: ConvictionOption[] = [];
|
||||||
|
#stakes: ConvictionStake[] = [];
|
||||||
|
#userId = "";
|
||||||
|
#userName = "";
|
||||||
|
#drawerOpen = false;
|
||||||
|
#tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
// DOM refs
|
||||||
|
#wrapperEl: HTMLElement | null = null;
|
||||||
|
#bodyEl: HTMLElement | null = null;
|
||||||
|
#optionsEl: HTMLElement | null = null;
|
||||||
|
#weightEl: HTMLElement | null = null;
|
||||||
|
#votersEl: HTMLElement | null = null;
|
||||||
|
#drawerEl: HTMLElement | null = null;
|
||||||
|
|
||||||
|
get title() { return this.#title; }
|
||||||
|
set title(v: string) { this.#title = v; this.requestUpdate("title"); }
|
||||||
|
|
||||||
|
get options() { return this.#options; }
|
||||||
|
set options(v: ConvictionOption[]) {
|
||||||
|
this.#options = v;
|
||||||
|
this.#render();
|
||||||
|
this.requestUpdate("options");
|
||||||
|
}
|
||||||
|
|
||||||
|
get stakes() { return this.#stakes; }
|
||||||
|
set stakes(v: ConvictionStake[]) {
|
||||||
|
this.#stakes = v;
|
||||||
|
this.#render();
|
||||||
|
this.requestUpdate("stakes");
|
||||||
|
}
|
||||||
|
|
||||||
|
#ensureIdentity(): boolean {
|
||||||
|
if (this.#userId && this.#userName) return true;
|
||||||
|
this.#userId = localStorage.getItem(USER_ID_KEY) || "";
|
||||||
|
this.#userName = localStorage.getItem(USER_NAME_KEY) || localStorage.getItem("rspace-username") || "";
|
||||||
|
if (!this.#userId) {
|
||||||
|
this.#userId = crypto.randomUUID().slice(0, 8);
|
||||||
|
localStorage.setItem(USER_ID_KEY, this.#userId);
|
||||||
|
}
|
||||||
|
return !!this.#userName;
|
||||||
|
}
|
||||||
|
|
||||||
|
#setUserName(name: string) {
|
||||||
|
this.#userName = name;
|
||||||
|
localStorage.setItem(USER_NAME_KEY, name);
|
||||||
|
localStorage.setItem("rspace-username", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
#getMyStake(optionId: string): ConvictionStake | undefined {
|
||||||
|
return this.#stakes.find((s) => s.userId === this.#userId && s.optionId === optionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#setStake(optionId: string, delta: number) {
|
||||||
|
if (!this.#ensureIdentity()) return;
|
||||||
|
|
||||||
|
const existing = this.#getMyStake(optionId);
|
||||||
|
const currentWeight = existing ? existing.weight : 0;
|
||||||
|
const newWeight = Math.max(0, Math.min(10, currentWeight + delta));
|
||||||
|
|
||||||
|
if (newWeight === 0) {
|
||||||
|
// Remove stake
|
||||||
|
this.#stakes = this.#stakes.filter(
|
||||||
|
(s) => !(s.userId === this.#userId && s.optionId === optionId),
|
||||||
|
);
|
||||||
|
} else if (existing) {
|
||||||
|
// Update — reset conviction timer on weight change
|
||||||
|
existing.weight = newWeight;
|
||||||
|
existing.since = Date.now();
|
||||||
|
} else {
|
||||||
|
// New stake
|
||||||
|
this.#stakes.push({
|
||||||
|
userId: this.#userId,
|
||||||
|
userName: this.#userName,
|
||||||
|
optionId,
|
||||||
|
weight: newWeight,
|
||||||
|
since: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#render();
|
||||||
|
this.dispatchEvent(new CustomEvent("content-change"));
|
||||||
|
}
|
||||||
|
|
||||||
|
override createRenderRoot() {
|
||||||
|
const root = super.createRenderRoot();
|
||||||
|
this.#ensureIdentity();
|
||||||
|
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "wrapper";
|
||||||
|
wrapper.innerHTML = html`
|
||||||
|
<div class="header">
|
||||||
|
<span class="header-title">
|
||||||
|
<span>⏳</span>
|
||||||
|
<span class="title-text">Conviction</span>
|
||||||
|
</span>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="drawer-toggle" title="Results & Stats">📊</button>
|
||||||
|
<button class="close-btn" title="Close">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<div class="options-list"></div>
|
||||||
|
<div class="weight-bar"></div>
|
||||||
|
<div class="voters-count"></div>
|
||||||
|
<div class="add-form">
|
||||||
|
<input type="text" class="add-input" placeholder="Add option..." />
|
||||||
|
<button class="add-btn">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="results-drawer"></div>
|
||||||
|
<div class="username-prompt" style="display: none;">
|
||||||
|
<p>Enter your name to stake:</p>
|
||||||
|
<input type="text" class="username-input" placeholder="Your name..." />
|
||||||
|
<button class="username-btn">Join</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const slot = root.querySelector("slot");
|
||||||
|
const containerDiv = slot?.parentElement as HTMLElement;
|
||||||
|
if (containerDiv) containerDiv.replaceWith(wrapper);
|
||||||
|
|
||||||
|
this.#wrapperEl = wrapper;
|
||||||
|
this.#bodyEl = wrapper.querySelector(".body") as HTMLElement;
|
||||||
|
this.#optionsEl = wrapper.querySelector(".options-list") as HTMLElement;
|
||||||
|
this.#weightEl = wrapper.querySelector(".weight-bar") as HTMLElement;
|
||||||
|
this.#votersEl = wrapper.querySelector(".voters-count") as HTMLElement;
|
||||||
|
this.#drawerEl = wrapper.querySelector(".results-drawer") as HTMLElement;
|
||||||
|
const titleEl = wrapper.querySelector(".title-text") as HTMLElement;
|
||||||
|
|
||||||
|
const drawerToggle = wrapper.querySelector(".drawer-toggle") as HTMLButtonElement;
|
||||||
|
drawerToggle.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.#drawerOpen = !this.#drawerOpen;
|
||||||
|
this.#wrapperEl!.classList.toggle("drawer-open", this.#drawerOpen);
|
||||||
|
drawerToggle.classList.toggle("active", this.#drawerOpen);
|
||||||
|
if (this.#drawerOpen) this.#renderDrawer();
|
||||||
|
});
|
||||||
|
|
||||||
|
const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement;
|
||||||
|
const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement;
|
||||||
|
const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement;
|
||||||
|
const addInput = wrapper.querySelector(".add-input") as HTMLInputElement;
|
||||||
|
const addBtn = wrapper.querySelector(".add-btn") as HTMLButtonElement;
|
||||||
|
|
||||||
|
if (!this.#userName) {
|
||||||
|
this.#bodyEl.style.display = "none";
|
||||||
|
usernamePrompt.style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitName = () => {
|
||||||
|
const name = usernameInput.value.trim();
|
||||||
|
if (name) {
|
||||||
|
this.#setUserName(name);
|
||||||
|
this.#bodyEl!.style.display = "flex";
|
||||||
|
usernamePrompt.style.display = "none";
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
usernameBtn.addEventListener("click", (e) => { e.stopPropagation(); submitName(); });
|
||||||
|
usernameInput.addEventListener("click", (e) => e.stopPropagation());
|
||||||
|
usernameInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") submitName(); });
|
||||||
|
|
||||||
|
// Add option
|
||||||
|
const addOption = () => {
|
||||||
|
const label = addInput.value.trim();
|
||||||
|
if (!label) return;
|
||||||
|
this.#options.push({
|
||||||
|
id: `opt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
|
label,
|
||||||
|
color: DEFAULT_COLORS[this.#options.length % DEFAULT_COLORS.length],
|
||||||
|
});
|
||||||
|
addInput.value = "";
|
||||||
|
this.#render();
|
||||||
|
this.dispatchEvent(new CustomEvent("content-change"));
|
||||||
|
};
|
||||||
|
addBtn.addEventListener("click", (e) => { e.stopPropagation(); addOption(); });
|
||||||
|
addInput.addEventListener("click", (e) => e.stopPropagation());
|
||||||
|
addInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") addOption(); });
|
||||||
|
|
||||||
|
wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.dispatchEvent(new CustomEvent("close"));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.#title) titleEl.textContent = this.#title;
|
||||||
|
|
||||||
|
// Live conviction update
|
||||||
|
this.#tickInterval = setInterval(() => this.#render(), 10000);
|
||||||
|
|
||||||
|
this.#render();
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
if (this.#tickInterval) {
|
||||||
|
clearInterval(this.#tickInterval);
|
||||||
|
this.#tickInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#render() {
|
||||||
|
this.#renderOptions();
|
||||||
|
if (this.#drawerOpen) this.#renderDrawer();
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderOptions() {
|
||||||
|
if (!this.#optionsEl) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const convictions = this.#options.map((opt) => ({
|
||||||
|
id: opt.id,
|
||||||
|
score: convictionScore(this.#stakes, opt.id, now),
|
||||||
|
}));
|
||||||
|
const maxConv = Math.max(1, ...convictions.map((c) => c.score));
|
||||||
|
const uniqueParticipants = new Set(this.#stakes.map((s) => s.userId)).size;
|
||||||
|
|
||||||
|
this.#optionsEl.innerHTML = this.#options
|
||||||
|
.map((opt) => {
|
||||||
|
const conv = convictions.find((c) => c.id === opt.id)!;
|
||||||
|
const barWidth = (conv.score / maxConv) * 100;
|
||||||
|
const myStake = this.#getMyStake(opt.id);
|
||||||
|
const myWeight = myStake ? myStake.weight : 0;
|
||||||
|
const timeHeld = myStake ? this.#formatDuration(now - myStake.since) : "";
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="option-row" data-opt="${opt.id}">
|
||||||
|
<div class="conviction-bg" style="width:${barWidth}%;background:${opt.color};"></div>
|
||||||
|
<span class="option-dot" style="background:${opt.color}"></span>
|
||||||
|
<span class="option-label">${this.#escapeHtml(opt.label)}</span>
|
||||||
|
<div class="stake-controls">
|
||||||
|
<button class="stake-btn stake-minus" data-opt="${opt.id}">−</button>
|
||||||
|
<span class="stake-count">${myWeight}</span>
|
||||||
|
<button class="stake-btn stake-plus" data-opt="${opt.id}">+</button>
|
||||||
|
</div>
|
||||||
|
<span class="option-time">${timeHeld}</span>
|
||||||
|
<span class="option-conviction">${this.#formatConviction(conv.score)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
// Wire buttons
|
||||||
|
this.#optionsEl.querySelectorAll(".stake-plus").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.#setStake((btn as HTMLElement).dataset.opt!, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.#optionsEl.querySelectorAll(".stake-minus").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.#setStake((btn as HTMLElement).dataset.opt!, -1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Weight bar
|
||||||
|
if (this.#weightEl) {
|
||||||
|
const totalWeight = this.#stakes
|
||||||
|
.filter((s) => s.userId === this.#userId)
|
||||||
|
.reduce((sum, s) => sum + s.weight, 0);
|
||||||
|
this.#weightEl.innerHTML = `Your weight: <span class="used">${totalWeight}</span> across ${this.#stakes.filter((s) => s.userId === this.#userId).length} option${this.#stakes.filter((s) => s.userId === this.#userId).length !== 1 ? "s" : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Voters count
|
||||||
|
if (this.#votersEl) {
|
||||||
|
this.#votersEl.textContent = uniqueParticipants === 0
|
||||||
|
? "No stakes yet"
|
||||||
|
: `${uniqueParticipants} participant${uniqueParticipants !== 1 ? "s" : ""}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderDrawer() {
|
||||||
|
if (!this.#drawerEl) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const optMap = new Map(this.#options.map((o) => [o.id, o]));
|
||||||
|
|
||||||
|
// Group Results: conviction leaderboard
|
||||||
|
const convictions = this.#options.map((opt) => ({
|
||||||
|
id: opt.id,
|
||||||
|
label: opt.label,
|
||||||
|
color: opt.color,
|
||||||
|
score: convictionScore(this.#stakes, opt.id, now),
|
||||||
|
velocity: convictionVelocity(this.#stakes, opt.id),
|
||||||
|
rawWeight: this.#stakes.filter((s) => s.optionId === opt.id).reduce((sum, s) => sum + s.weight, 0),
|
||||||
|
}));
|
||||||
|
convictions.sort((a, b) => b.score - a.score);
|
||||||
|
const maxConv = Math.max(1, ...convictions.map((c) => c.score));
|
||||||
|
|
||||||
|
let resultsHtml = '<div class="drawer-section"><div class="drawer-heading">Conviction Leaderboard</div>';
|
||||||
|
for (const c of convictions) {
|
||||||
|
const pct = (c.score / maxConv) * 100;
|
||||||
|
resultsHtml += `<div class="drawer-bar-row">
|
||||||
|
<span class="drawer-bar-label">${this.#escapeHtml(c.label)}</span>
|
||||||
|
<div class="drawer-bar-bg"><div class="drawer-bar-fill" style="width:${pct}%;background:${c.color}"></div></div>
|
||||||
|
<span class="drawer-bar-val">${this.#formatConviction(c.score)}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
resultsHtml += "</div>";
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
const uniqueParticipants = new Set(this.#stakes.map((s) => s.userId)).size;
|
||||||
|
const totalConv = convictions.reduce((sum, c) => sum + c.score, 0);
|
||||||
|
const totalVelocity = convictions.reduce((sum, c) => sum + c.velocity, 0);
|
||||||
|
|
||||||
|
let statsHtml = '<div class="drawer-section"><div class="drawer-heading">Statistics</div>';
|
||||||
|
statsHtml += `<div class="stat-row"><span class="stat-label">Participants</span><span class="stat-value">${uniqueParticipants}</span></div>`;
|
||||||
|
statsHtml += `<div class="stat-row"><span class="stat-label">Total conviction</span><span class="stat-value">${this.#formatConviction(totalConv)}</span></div>`;
|
||||||
|
statsHtml += `<div class="stat-row"><span class="stat-label">Velocity (wt/hr)</span><span class="stat-value">${totalVelocity.toFixed(1)}</span></div>`;
|
||||||
|
|
||||||
|
// Raw weight vs conviction rank comparison
|
||||||
|
const byRawWeight = [...convictions].sort((a, b) => b.rawWeight - a.rawWeight);
|
||||||
|
const byConviction = convictions; // already sorted
|
||||||
|
let rankDiff = false;
|
||||||
|
for (let i = 0; i < convictions.length; i++) {
|
||||||
|
if (byRawWeight[i]?.id !== byConviction[i]?.id) { rankDiff = true; break; }
|
||||||
|
}
|
||||||
|
if (rankDiff && convictions.length >= 2) {
|
||||||
|
statsHtml += '<div style="margin-top:6px"><div class="drawer-heading">Raw Weight vs Conviction</div>';
|
||||||
|
for (const c of convictions) {
|
||||||
|
const rawRank = byRawWeight.findIndex((r) => r.id === c.id) + 1;
|
||||||
|
const convRank = byConviction.findIndex((r) => r.id === c.id) + 1;
|
||||||
|
const diff = rawRank - convRank;
|
||||||
|
const arrow = diff > 0 ? `↑${diff}` : diff < 0 ? `↓${Math.abs(diff)}` : "=";
|
||||||
|
statsHtml += `<div class="stat-row"><span class="stat-label">${this.#escapeHtml(c.label)}</span><span class="stat-value">#${convRank} (${arrow})</span></div>`;
|
||||||
|
}
|
||||||
|
statsHtml += "</div>";
|
||||||
|
}
|
||||||
|
statsHtml += "</div>";
|
||||||
|
|
||||||
|
// Participants
|
||||||
|
const userStakes = new Map<string, { name: string; totalWeight: number; totalConv: number; optCount: number }>();
|
||||||
|
for (const s of this.#stakes) {
|
||||||
|
const u = userStakes.get(s.userId) || { name: s.userName, totalWeight: 0, totalConv: 0, optCount: 0 };
|
||||||
|
u.name = s.userName;
|
||||||
|
u.totalWeight += s.weight;
|
||||||
|
u.totalConv += s.weight * Math.max(0, now - s.since) / 3600000;
|
||||||
|
u.optCount++;
|
||||||
|
userStakes.set(s.userId, u);
|
||||||
|
}
|
||||||
|
|
||||||
|
let participantsHtml = '<div class="drawer-section"><div class="drawer-heading">Participants</div>';
|
||||||
|
for (const [, u] of userStakes) {
|
||||||
|
participantsHtml += `<div class="participant-row">
|
||||||
|
<span class="participant-dot" style="background:#d97706"></span>
|
||||||
|
<span>${this.#escapeHtml(u.name)}</span>
|
||||||
|
<span style="margin-left:auto;color:#94a3b8;font-size:10px">wt:${u.totalWeight} conv:${this.#formatConviction(u.totalConv)}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
participantsHtml += "</div>";
|
||||||
|
|
||||||
|
this.#drawerEl.innerHTML = resultsHtml + statsHtml + participantsHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
#formatConviction(score: number): string {
|
||||||
|
if (score < 1) return score.toFixed(2);
|
||||||
|
if (score < 100) return score.toFixed(1);
|
||||||
|
return Math.round(score).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
#formatDuration(ms: number): string {
|
||||||
|
if (ms < 60000) return "<1m";
|
||||||
|
if (ms < 3600000) return `${Math.floor(ms / 60000)}m`;
|
||||||
|
if (ms < 86400000) return `${Math.floor(ms / 3600000)}h`;
|
||||||
|
return `${Math.floor(ms / 86400000)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#timeAgo(ts: number): string {
|
||||||
|
const diff = Date.now() - ts;
|
||||||
|
if (diff < 60000) return "just now";
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||||
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400000)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#escapeHtml(text: string): string {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
override toJSON() {
|
||||||
|
return {
|
||||||
|
...super.toJSON(),
|
||||||
|
type: "folk-choice-conviction",
|
||||||
|
title: this.#title,
|
||||||
|
options: this.#options,
|
||||||
|
stakes: this.#stakes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -70,6 +70,7 @@ export * from "./folk-social-post";
|
||||||
export * from "./folk-choice-vote";
|
export * from "./folk-choice-vote";
|
||||||
export * from "./folk-choice-rank";
|
export * from "./folk-choice-rank";
|
||||||
export * from "./folk-choice-spider";
|
export * from "./folk-choice-spider";
|
||||||
|
export * from "./folk-choice-conviction";
|
||||||
|
|
||||||
// Nested Space Shape
|
// Nested Space Shape
|
||||||
export * from "./folk-canvas";
|
export * from "./folk-canvas";
|
||||||
|
|
|
||||||
|
|
@ -1173,7 +1173,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
this.navZone.innerHTML = `
|
this.navZone.innerHTML = `
|
||||||
<div class="rapp-nav">
|
<div class="rapp-nav">
|
||||||
<button class="rapp-nav__back" data-back="notebooks">\u2190 Notebooks</button>
|
<button class="rapp-nav__back" data-back="notebooks">\u2190 Notebooks</button>
|
||||||
<span class="rapp-nav__title" style="color:${nb.cover_color}">${this.esc(nb.title)}${syncBadge}</span>
|
<span class="rapp-nav__title">${this.esc(nb.title)}${syncBadge}</span>
|
||||||
<button class="rapp-nav__btn" id="create-note">+ New Note</button>
|
<button class="rapp-nav__btn" id="create-note">+ New Note</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
return;
|
return;
|
||||||
|
|
@ -1364,197 +1364,267 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
|
|
||||||
private getStyles(): string {
|
private getStyles(): string {
|
||||||
return `
|
return `
|
||||||
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
|
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* ── Navigation ── */
|
||||||
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
|
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
|
||||||
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px; }
|
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid var(--rs-border); background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 13px; transition: all 0.15s; }
|
||||||
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
|
.rapp-nav__back:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); }
|
||||||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: var(--rs-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
|
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; transition: background 0.15s; }
|
||||||
.rapp-nav__btn:hover { background: #6366f1; }
|
.rapp-nav__btn:hover { background: var(--rs-primary-hover); }
|
||||||
|
|
||||||
|
/* ── Search ── */
|
||||||
.search-bar {
|
.search-bar {
|
||||||
width: 100%; padding: 10px 14px; border-radius: 8px;
|
width: 100%; padding: 10px 14px; border-radius: 8px;
|
||||||
border: 1px solid #444; background: #2a2a3e; color: #e0e0e0;
|
border: 1px solid var(--rs-input-border); background: var(--rs-input-bg); color: var(--rs-input-text);
|
||||||
font-size: 14px; margin-bottom: 16px;
|
font-size: 14px; margin-bottom: 16px; transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
.search-bar:focus { border-color: #6366f1; outline: none; }
|
.search-bar:focus { border-color: var(--rs-primary); outline: none; }
|
||||||
|
.search-results-info { margin-bottom: 12px; font-size: 13px; color: var(--rs-text-secondary); }
|
||||||
|
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
/* ── Notebook Grid ── */
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
|
||||||
.notebook-card {
|
.notebook-card {
|
||||||
border-radius: 10px; padding: 16px; cursor: pointer;
|
position: relative; overflow: hidden;
|
||||||
border: 2px solid transparent; transition: border-color 0.2s;
|
border-radius: 12px; padding: 16px 16px 16px 20px; cursor: pointer;
|
||||||
|
border: 1px solid var(--rs-card-border); background: var(--rs-card-bg);
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||||
min-height: 120px; display: flex; flex-direction: column; justify-content: space-between;
|
min-height: 120px; display: flex; flex-direction: column; justify-content: space-between;
|
||||||
}
|
}
|
||||||
.notebook-card:hover { border-color: rgba(255,255,255,0.2); }
|
.notebook-card:hover { border-color: var(--rs-border); box-shadow: var(--rs-shadow-sm); transform: translateY(-1px); }
|
||||||
.notebook-title { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
|
.notebook-card__accent { position: absolute; top: 0; left: 0; width: 4px; height: 100%; border-radius: 12px 0 0 12px; }
|
||||||
.notebook-meta { font-size: 12px; opacity: 0.7; }
|
.notebook-card__body { flex: 1; }
|
||||||
|
.notebook-card__title { font-size: 15px; font-weight: 600; margin-bottom: 4px; color: var(--rs-text-primary); }
|
||||||
|
.notebook-card__desc { font-size: 12px; color: var(--rs-text-muted); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.notebook-card__footer { display: flex; justify-content: space-between; font-size: 11px; color: var(--rs-text-muted); margin-top: 8px; }
|
||||||
|
|
||||||
|
/* ── Note Items ── */
|
||||||
.note-item {
|
.note-item {
|
||||||
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
|
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); border-radius: 10px;
|
||||||
padding: 12px 16px; margin-bottom: 8px; cursor: pointer; transition: border-color 0.2s;
|
padding: 14px 16px; margin-bottom: 8px; cursor: pointer;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
display: flex; gap: 12px; align-items: flex-start;
|
display: flex; gap: 12px; align-items: flex-start;
|
||||||
}
|
}
|
||||||
.note-item:hover { border-color: #555; }
|
.note-item:hover { border-color: var(--rs-border); box-shadow: var(--rs-shadow-sm); }
|
||||||
.note-icon { font-size: 20px; flex-shrink: 0; }
|
.note-item__icon {
|
||||||
.note-body { flex: 1; min-width: 0; }
|
font-size: 18px; flex-shrink: 0; width: 32px; height: 32px;
|
||||||
.note-title { font-size: 14px; font-weight: 600; }
|
display: flex; align-items: center; justify-content: center;
|
||||||
.note-preview { font-size: 12px; color: #888; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
background: var(--rs-bg-surface-raised); border-radius: 8px;
|
||||||
.note-meta { font-size: 11px; color: #666; margin-top: 4px; display: flex; gap: 8px; }
|
|
||||||
.tag { display: inline-block; padding: 1px 6px; border-radius: 3px; background: #333; color: #aaa; font-size: 10px; }
|
|
||||||
.pinned { color: #f59e0b; }
|
|
||||||
|
|
||||||
.editable-title {
|
|
||||||
background: transparent; border: none; color: #e2e8f0; font-family: inherit;
|
|
||||||
font-size: 22px; font-weight: 700; width: 100%; outline: none;
|
|
||||||
padding: 8px 0; margin-bottom: 4px;
|
|
||||||
}
|
}
|
||||||
.editable-title:focus { border-bottom: 2px solid #6366f1; }
|
.note-item__body { flex: 1; min-width: 0; }
|
||||||
.editable-title::placeholder { color: #555; }
|
.note-item__title { font-size: 14px; font-weight: 600; color: var(--rs-text-primary); }
|
||||||
|
.note-item__pin { color: var(--rs-warning); }
|
||||||
|
.note-item__preview {
|
||||||
|
font-size: 12px; color: var(--rs-text-muted); margin-top: 3px; line-height: 1.4;
|
||||||
|
overflow: hidden; text-overflow: ellipsis;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
.note-item__meta { font-size: 11px; color: var(--rs-text-muted); margin-top: 6px; display: flex; gap: 8px; align-items: center; }
|
||||||
|
.tag { display: inline-block; padding: 1px 6px; border-radius: 3px; background: var(--rs-bg-surface-raised); color: var(--rs-text-secondary); font-size: 10px; }
|
||||||
|
|
||||||
|
/* ── Editor Title ── */
|
||||||
|
.editable-title {
|
||||||
|
background: transparent; border: none; border-bottom: 2px solid transparent;
|
||||||
|
color: var(--rs-text-primary); font-family: inherit;
|
||||||
|
font-size: 22px; font-weight: 700; width: 100%; outline: none;
|
||||||
|
padding: 8px 0; margin-bottom: 4px; transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.editable-title:focus { border-bottom-color: var(--rs-primary); }
|
||||||
|
.editable-title::placeholder { color: var(--rs-text-muted); }
|
||||||
|
|
||||||
|
/* ── Sync Badge ── */
|
||||||
.sync-badge {
|
.sync-badge {
|
||||||
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
||||||
margin-left: 8px; vertical-align: middle;
|
margin-left: 8px; vertical-align: middle;
|
||||||
}
|
}
|
||||||
.sync-badge.connected { background: #10b981; }
|
.sync-badge.connected { background: var(--rs-success); }
|
||||||
.sync-badge.disconnected { background: #ef4444; }
|
.sync-badge.disconnected { background: var(--rs-error); }
|
||||||
|
|
||||||
.empty { text-align: center; color: #666; padding: 40px; }
|
/* ── State Messages ── */
|
||||||
.loading { text-align: center; color: #888; padding: 40px; }
|
.empty { text-align: center; color: var(--rs-text-muted); padding: 40px; }
|
||||||
.error { text-align: center; color: #ef5350; padding: 20px; }
|
.loading { text-align: center; color: var(--rs-text-secondary); padding: 40px; }
|
||||||
|
.error { text-align: center; color: var(--rs-error); padding: 20px; }
|
||||||
|
|
||||||
|
/* ── Meta Bar ── */
|
||||||
.note-meta-bar {
|
.note-meta-bar {
|
||||||
margin-top: 12px; font-size: 12px; color: #666; display: flex; gap: 12px; padding: 8px 0;
|
margin-top: 12px; font-size: 12px; color: var(--rs-text-muted);
|
||||||
|
display: flex; gap: 12px; padding: 8px 0; align-items: center;
|
||||||
}
|
}
|
||||||
|
.meta-live { color: var(--rs-success); font-weight: 500; }
|
||||||
|
.meta-demo { color: var(--rs-warning); font-weight: 500; }
|
||||||
|
|
||||||
/* ── Editor Toolbar ── */
|
/* ── Editor Toolbar ── */
|
||||||
.editor-toolbar {
|
.editor-toolbar {
|
||||||
display: flex; flex-wrap: wrap; gap: 2px; align-items: center;
|
display: flex; flex-wrap: wrap; gap: 2px; align-items: center;
|
||||||
background: #0f172a; border: 1px solid #1e293b; border-radius: 8px;
|
background: var(--rs-toolbar-bg); border: 1px solid var(--rs-toolbar-panel-border);
|
||||||
padding: 4px 6px; margin-bottom: 2px;
|
border-radius: 8px; padding: 4px 6px; margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
.toolbar-group { display: flex; gap: 1px; }
|
.toolbar-group { display: flex; gap: 1px; }
|
||||||
.toolbar-sep { width: 1px; height: 20px; background: #1e293b; margin: 0 4px; }
|
.toolbar-sep { width: 1px; height: 20px; background: var(--rs-toolbar-sep); margin: 0 4px; }
|
||||||
.toolbar-btn {
|
.toolbar-btn {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
width: 30px; height: 28px; border: none; border-radius: 4px;
|
width: 30px; height: 28px; border: none; border-radius: 4px;
|
||||||
background: transparent; color: #94a3b8; cursor: pointer;
|
background: transparent; color: var(--rs-text-secondary); cursor: pointer;
|
||||||
font-size: 13px; font-family: inherit; transition: all 0.15s;
|
font-size: 13px; font-family: inherit; transition: all 0.15s;
|
||||||
}
|
}
|
||||||
.toolbar-btn:hover { background: #1e293b; color: #e2e8f0; }
|
.toolbar-btn svg { width: 16px; height: 16px; flex-shrink: 0; }
|
||||||
.toolbar-btn.active { background: #312e81; color: #a5b4fc; }
|
.toolbar-btn:hover { background: var(--rs-toolbar-btn-hover); color: var(--rs-toolbar-btn-text); }
|
||||||
|
.toolbar-btn.active { background: var(--rs-primary); color: #fff; }
|
||||||
.toolbar-select {
|
.toolbar-select {
|
||||||
padding: 2px 4px; border-radius: 4px; border: 1px solid #1e293b;
|
padding: 2px 4px; border-radius: 4px; border: 1px solid var(--rs-toolbar-panel-border);
|
||||||
background: #0f172a; color: #94a3b8; font-size: 12px; cursor: pointer;
|
background: var(--rs-toolbar-bg); color: var(--rs-text-secondary); font-size: 12px; cursor: pointer;
|
||||||
font-family: inherit;
|
font-family: inherit; transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
.toolbar-select:focus { outline: none; border-color: #4f46e5; }
|
.toolbar-select:focus { outline: none; border-color: var(--rs-primary); }
|
||||||
|
|
||||||
/* ── Tiptap Editor ── */
|
/* ── Tiptap Editor ── */
|
||||||
.editor-wrapper {
|
.editor-wrapper {
|
||||||
background: #1e1e2e; border: 1px solid #333; border-radius: 10px;
|
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle);
|
||||||
overflow: hidden;
|
border-radius: 10px; overflow: hidden;
|
||||||
}
|
|
||||||
.editor-wrapper .editable-title {
|
|
||||||
padding: 16px 20px 0;
|
|
||||||
}
|
|
||||||
.editor-wrapper .editor-toolbar {
|
|
||||||
margin: 4px 8px; border-radius: 6px;
|
|
||||||
}
|
}
|
||||||
|
.editor-wrapper .editable-title { padding: 16px 20px 0; }
|
||||||
|
.editor-wrapper .editor-toolbar { margin: 4px 8px; border-radius: 6px; }
|
||||||
|
|
||||||
.tiptap-container .tiptap {
|
.tiptap-container .tiptap {
|
||||||
min-height: 300px; padding: 16px 20px; outline: none;
|
min-height: 300px; padding: 20px 24px; outline: none;
|
||||||
font-size: 15px; line-height: 1.7; color: #e0e0e0;
|
font-size: 15px; line-height: 1.75; color: var(--rs-text-primary);
|
||||||
}
|
}
|
||||||
.tiptap-container .tiptap:focus { outline: none; }
|
.tiptap-container .tiptap:focus { outline: none; }
|
||||||
|
|
||||||
/* Prose styles */
|
/* ── Prose Styles ── */
|
||||||
.tiptap-container .tiptap h1 { font-size: 1.8em; font-weight: 700; margin: 1em 0 0.4em; color: #f1f5f9; }
|
.tiptap-container .tiptap h1 {
|
||||||
.tiptap-container .tiptap h2 { font-size: 1.4em; font-weight: 600; margin: 0.8em 0 0.3em; color: #e2e8f0; }
|
font-size: 1.75em; font-weight: 700; margin: 1.2em 0 0.5em; color: var(--rs-text-primary);
|
||||||
.tiptap-container .tiptap h3 { font-size: 1.15em; font-weight: 600; margin: 0.7em 0 0.25em; color: #cbd5e1; }
|
padding-bottom: 0.3em; border-bottom: 1px solid var(--rs-border-subtle);
|
||||||
.tiptap-container .tiptap h4 { font-size: 1em; font-weight: 600; margin: 0.6em 0 0.2em; color: #94a3b8; }
|
}
|
||||||
.tiptap-container .tiptap p { margin: 0.4em 0; }
|
.tiptap-container .tiptap h2 { font-size: 1.35em; font-weight: 600; margin: 1em 0 0.4em; color: var(--rs-text-primary); }
|
||||||
|
.tiptap-container .tiptap h3 { font-size: 1.1em; font-weight: 600; margin: 0.8em 0 0.3em; color: var(--rs-text-secondary); }
|
||||||
|
.tiptap-container .tiptap h4 {
|
||||||
|
font-size: 0.95em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
margin: 0.7em 0 0.25em; color: var(--rs-text-muted);
|
||||||
|
}
|
||||||
|
.tiptap-container .tiptap p { margin: 0.5em 0; }
|
||||||
.tiptap-container .tiptap blockquote {
|
.tiptap-container .tiptap blockquote {
|
||||||
border-left: 3px solid #4f46e5; padding-left: 16px; margin: 0.8em 0;
|
border-left: 3px solid var(--rs-primary); padding: 4px 0 4px 16px; margin: 0.8em 0;
|
||||||
color: #94a3b8; font-style: italic;
|
color: var(--rs-text-secondary); font-style: italic;
|
||||||
|
background: var(--rs-bg-surface-raised); border-radius: 0 6px 6px 0;
|
||||||
}
|
}
|
||||||
.tiptap-container .tiptap code {
|
.tiptap-container .tiptap code {
|
||||||
background: #2a2a3e; padding: 2px 6px; border-radius: 4px;
|
background: var(--rs-bg-surface-raised); padding: 2px 6px; border-radius: 4px;
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.9em; color: #a5b4fc;
|
font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.88em; color: var(--rs-accent);
|
||||||
}
|
}
|
||||||
.tiptap-container .tiptap pre {
|
.tiptap-container .tiptap pre {
|
||||||
background: #0f172a; border: 1px solid #1e293b; border-radius: 8px;
|
background: var(--rs-bg-surface-sunken); border: 1px solid var(--rs-border-subtle);
|
||||||
padding: 12px 16px; margin: 0.8em 0; overflow-x: auto;
|
border-radius: 8px; padding: 14px 16px; margin: 1em 0; overflow-x: auto;
|
||||||
}
|
}
|
||||||
.tiptap-container .tiptap pre code {
|
.tiptap-container .tiptap pre code {
|
||||||
background: none; padding: 0; border-radius: 0; color: #e0e0e0;
|
background: none; padding: 0; border-radius: 0; color: var(--rs-text-primary);
|
||||||
font-size: 13px; line-height: 1.5;
|
font-size: 13px; line-height: 1.6;
|
||||||
}
|
}
|
||||||
.tiptap-container .tiptap ul, .tiptap-container .tiptap ol {
|
.tiptap-container .tiptap ul, .tiptap-container .tiptap ol { padding-left: 24px; margin: 0.5em 0; }
|
||||||
padding-left: 24px; margin: 0.4em 0;
|
.tiptap-container .tiptap li { margin: 0.2em 0; }
|
||||||
}
|
.tiptap-container .tiptap li p { margin: 0.15em 0; }
|
||||||
.tiptap-container .tiptap li { margin: 0.15em 0; }
|
.tiptap-container .tiptap li::marker { color: var(--rs-text-muted); }
|
||||||
.tiptap-container .tiptap li p { margin: 0.1em 0; }
|
|
||||||
|
|
||||||
/* Task list */
|
/* Task list */
|
||||||
.tiptap-container .tiptap ul[data-type="taskList"] {
|
.tiptap-container .tiptap ul[data-type="taskList"] { list-style: none; padding-left: 4px; }
|
||||||
list-style: none; padding-left: 4px;
|
.tiptap-container .tiptap ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 8px; }
|
||||||
}
|
.tiptap-container .tiptap ul[data-type="taskList"] li label { margin-top: 3px; }
|
||||||
.tiptap-container .tiptap ul[data-type="taskList"] li {
|
|
||||||
display: flex; align-items: flex-start; gap: 8px;
|
|
||||||
}
|
|
||||||
.tiptap-container .tiptap ul[data-type="taskList"] li label {
|
|
||||||
margin-top: 3px;
|
|
||||||
}
|
|
||||||
.tiptap-container .tiptap ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
.tiptap-container .tiptap ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
||||||
text-decoration: line-through; color: #666;
|
text-decoration: line-through; color: var(--rs-text-muted);
|
||||||
|
}
|
||||||
|
.tiptap-container .tiptap ul[data-type="taskList"] li label input[type="checkbox"] {
|
||||||
|
accent-color: var(--rs-primary); width: 15px; height: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-container .tiptap img {
|
.tiptap-container .tiptap img {
|
||||||
max-width: 100%; border-radius: 8px; margin: 0.5em 0;
|
max-width: 100%; border-radius: 8px; margin: 0.75em 0;
|
||||||
|
border: 1px solid var(--rs-border-subtle);
|
||||||
}
|
}
|
||||||
.tiptap-container .tiptap a {
|
.tiptap-container .tiptap a {
|
||||||
color: #818cf8; text-decoration: underline; text-underline-offset: 2px;
|
color: var(--rs-primary-hover); text-decoration: underline;
|
||||||
|
text-underline-offset: 2px; text-decoration-color: rgba(99, 102, 241, 0.4);
|
||||||
}
|
}
|
||||||
.tiptap-container .tiptap a:hover { color: #a5b4fc; }
|
.tiptap-container .tiptap a:hover { text-decoration-color: var(--rs-primary-hover); }
|
||||||
.tiptap-container .tiptap hr {
|
.tiptap-container .tiptap hr { border: none; border-top: 1px solid var(--rs-border); margin: 1.5em 0; }
|
||||||
border: none; border-top: 1px solid #333; margin: 1.5em 0;
|
.tiptap-container .tiptap strong { color: var(--rs-text-primary); font-weight: 600; }
|
||||||
}
|
|
||||||
.tiptap-container .tiptap strong { color: #f1f5f9; }
|
|
||||||
.tiptap-container .tiptap em { color: inherit; }
|
.tiptap-container .tiptap em { color: inherit; }
|
||||||
.tiptap-container .tiptap s { color: #666; }
|
.tiptap-container .tiptap s { color: var(--rs-text-muted); }
|
||||||
.tiptap-container .tiptap u { text-underline-offset: 3px; }
|
.tiptap-container .tiptap u { text-underline-offset: 3px; }
|
||||||
|
|
||||||
/* Placeholder */
|
/* Placeholder */
|
||||||
.tiptap-container .tiptap p.is-editor-empty:first-child::before {
|
.tiptap-container .tiptap p.is-editor-empty:first-child::before {
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
float: left; color: #555; pointer-events: none; height: 0;
|
float: left; color: var(--rs-text-muted); pointer-events: none; height: 0;
|
||||||
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── URL Popover ── */
|
||||||
|
.url-popover {
|
||||||
|
position: absolute; z-index: 110;
|
||||||
|
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
|
||||||
|
border-radius: 10px; box-shadow: var(--rs-shadow-md);
|
||||||
|
padding: 8px; min-width: 300px;
|
||||||
|
animation: popover-in 0.15s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes popover-in {
|
||||||
|
from { opacity: 0; transform: translateY(-4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.url-popover__input {
|
||||||
|
width: 100%; padding: 8px 10px; border-radius: 6px;
|
||||||
|
border: 1px solid var(--rs-input-border); background: var(--rs-input-bg);
|
||||||
|
color: var(--rs-input-text); font-size: 13px; font-family: inherit;
|
||||||
|
outline: none; margin-bottom: 6px; transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.url-popover__input:focus { border-color: var(--rs-primary); }
|
||||||
|
.url-popover__actions { display: flex; gap: 6px; justify-content: flex-end; }
|
||||||
|
.url-popover__btn {
|
||||||
|
padding: 5px 12px; border-radius: 6px; font-size: 12px; font-weight: 600;
|
||||||
|
cursor: pointer; border: none; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.url-popover__btn--insert { background: var(--rs-primary); color: #fff; }
|
||||||
|
.url-popover__btn--insert:hover { background: var(--rs-primary-hover); }
|
||||||
|
.url-popover__btn--cancel {
|
||||||
|
background: transparent; color: var(--rs-text-secondary);
|
||||||
|
border: 1px solid var(--rs-border);
|
||||||
|
}
|
||||||
|
.url-popover__btn--cancel:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); }
|
||||||
|
|
||||||
/* ── Slash Menu ── */
|
/* ── Slash Menu ── */
|
||||||
.slash-menu {
|
.slash-menu {
|
||||||
position: absolute; z-index: 100;
|
position: absolute; z-index: 100;
|
||||||
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
|
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
border-radius: 10px; box-shadow: var(--rs-shadow-lg);
|
||||||
max-height: 320px; overflow-y: auto; min-width: 220px;
|
max-height: 360px; overflow-y: auto; min-width: 240px;
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
.slash-menu__header {
|
||||||
|
padding: 8px 12px 6px; font-size: 11px; font-weight: 600;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
color: var(--rs-text-muted); border-bottom: 1px solid var(--rs-border-subtle);
|
||||||
|
}
|
||||||
.slash-menu-item {
|
.slash-menu-item {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 8px 12px; cursor: pointer; transition: background 0.1s;
|
padding: 8px 12px; cursor: pointer; transition: background 0.1s;
|
||||||
}
|
}
|
||||||
.slash-menu-item:hover, .slash-menu-item.selected {
|
.slash-menu-item:last-child { border-radius: 0 0 10px 10px; }
|
||||||
background: #312e81;
|
.slash-menu-item:hover, .slash-menu-item.selected { background: var(--rs-bg-hover); }
|
||||||
}
|
|
||||||
.slash-menu-icon {
|
.slash-menu-icon {
|
||||||
width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
|
width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
|
||||||
background: #2a2a3e; border-radius: 4px; font-size: 13px; font-weight: 600; color: #a5b4fc;
|
background: var(--rs-bg-surface-raised); border-radius: 6px;
|
||||||
|
font-size: 13px; font-weight: 600; color: var(--rs-primary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.slash-menu-icon svg { width: 16px; height: 16px; }
|
||||||
.slash-menu-text { flex: 1; }
|
.slash-menu-text { flex: 1; }
|
||||||
.slash-menu-title { font-size: 13px; font-weight: 500; color: #e2e8f0; }
|
.slash-menu-title { font-size: 13px; font-weight: 500; color: var(--rs-text-primary); }
|
||||||
.slash-menu-desc { font-size: 11px; color: #666; }
|
.slash-menu-desc { font-size: 11px; color: var(--rs-text-muted); }
|
||||||
|
.slash-menu-hint {
|
||||||
|
font-size: 10px; color: var(--rs-text-muted); padding: 1px 6px;
|
||||||
|
background: var(--rs-bg-surface-raised); border-radius: 3px; margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Code highlighting (lowlight) ── */
|
/* ── Code highlighting (lowlight) ── */
|
||||||
.tiptap-container .tiptap .hljs-keyword { color: #c792ea; }
|
.tiptap-container .tiptap .hljs-keyword { color: #c792ea; }
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,21 @@ import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||||
import type { EditorView } from '@tiptap/pm/view';
|
import type { EditorView } from '@tiptap/pm/view';
|
||||||
import type { Editor } from '@tiptap/core';
|
import type { Editor } from '@tiptap/core';
|
||||||
|
|
||||||
|
/** Inline SVG icons for slash menu items (16×16, stroke-based, currentColor) */
|
||||||
|
const SLASH_ICONS: Record<string, string> = {
|
||||||
|
text: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="4" y1="3" x2="12" y2="3"/><line x1="8" y1="3" x2="8" y2="13"/><line x1="6" y1="13" x2="10" y2="13"/></svg>',
|
||||||
|
heading1: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 3v10M8 3v10M2 8h6"/><text x="10.5" y="13" font-size="7" fill="currentColor" stroke="none" font-family="system-ui" font-weight="700">1</text></svg>',
|
||||||
|
heading2: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 3v10M8 3v10M2 8h6"/><text x="10.5" y="13" font-size="7" fill="currentColor" stroke="none" font-family="system-ui" font-weight="700">2</text></svg>',
|
||||||
|
heading3: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 3v10M8 3v10M2 8h6"/><text x="10.5" y="13" font-size="7" fill="currentColor" stroke="none" font-family="system-ui" font-weight="700">3</text></svg>',
|
||||||
|
bulletList: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="6" y1="4" x2="14" y2="4"/><line x1="6" y1="8" x2="14" y2="8"/><line x1="6" y1="12" x2="14" y2="12"/><circle cx="3" cy="4" r="1" fill="currentColor" stroke="none"/><circle cx="3" cy="8" r="1" fill="currentColor" stroke="none"/><circle cx="3" cy="12" r="1" fill="currentColor" stroke="none"/></svg>',
|
||||||
|
orderedList: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="7" y1="4" x2="14" y2="4"/><line x1="7" y1="8" x2="14" y2="8"/><line x1="7" y1="12" x2="14" y2="12"/><text x="1.5" y="5.5" font-size="5" fill="currentColor" stroke="none" font-family="system-ui" font-weight="600">1</text><text x="1.5" y="9.5" font-size="5" fill="currentColor" stroke="none" font-family="system-ui" font-weight="600">2</text><text x="1.5" y="13.5" font-size="5" fill="currentColor" stroke="none" font-family="system-ui" font-weight="600">3</text></svg>',
|
||||||
|
taskList: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="5" height="5" rx="1"/><polyline points="3.5 4.5 4.5 5.5 6 3.5"/><line x1="9" y1="4.5" x2="14" y2="4.5"/><rect x="2" y="9" width="5" height="5" rx="1"/><line x1="9" y1="11.5" x2="14" y2="11.5"/></svg>',
|
||||||
|
codeBlock: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="1.5" width="13" height="13" rx="2"/><polyline points="5 6 3.5 8 5 10"/><polyline points="11 6 12.5 8 11 10"/><line x1="9" y1="5" x2="7" y2="11"/></svg>',
|
||||||
|
blockquote: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="2" x2="3" y2="14"/><line x1="7" y1="4" x2="14" y2="4"/><line x1="7" y1="8" x2="14" y2="8"/><line x1="7" y1="12" x2="12" y2="12"/></svg>',
|
||||||
|
horizontalRule: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="2" y1="8" x2="14" y2="8"/><circle cx="4" cy="8" r="0.5" fill="currentColor"/><circle cx="8" cy="8" r="0.5" fill="currentColor"/><circle cx="12" cy="8" r="0.5" fill="currentColor"/></svg>',
|
||||||
|
image: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="2.5" width="13" height="11" rx="2"/><circle cx="5.5" cy="6" r="1.5"/><path d="M14.5 10.5l-3.5-3.5-5 5"/></svg>',
|
||||||
|
};
|
||||||
|
|
||||||
export interface SlashMenuItem {
|
export interface SlashMenuItem {
|
||||||
title: string;
|
title: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
|
@ -19,71 +34,72 @@ export interface SlashMenuItem {
|
||||||
export const SLASH_ITEMS: SlashMenuItem[] = [
|
export const SLASH_ITEMS: SlashMenuItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Text',
|
title: 'Text',
|
||||||
icon: 'Aa',
|
icon: 'text',
|
||||||
description: 'Plain paragraph text',
|
description: 'Plain paragraph text',
|
||||||
command: (e) => e.chain().focus().setParagraph().run(),
|
command: (e) => e.chain().focus().setParagraph().run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Heading 1',
|
title: 'Heading 1',
|
||||||
icon: 'H1',
|
icon: 'heading1',
|
||||||
description: 'Large section heading',
|
description: 'Large section heading',
|
||||||
command: (e) => e.chain().focus().setHeading({ level: 1 }).run(),
|
command: (e) => e.chain().focus().setHeading({ level: 1 }).run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Heading 2',
|
title: 'Heading 2',
|
||||||
icon: 'H2',
|
icon: 'heading2',
|
||||||
description: 'Medium section heading',
|
description: 'Medium section heading',
|
||||||
command: (e) => e.chain().focus().setHeading({ level: 2 }).run(),
|
command: (e) => e.chain().focus().setHeading({ level: 2 }).run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Heading 3',
|
title: 'Heading 3',
|
||||||
icon: 'H3',
|
icon: 'heading3',
|
||||||
description: 'Small section heading',
|
description: 'Small section heading',
|
||||||
command: (e) => e.chain().focus().setHeading({ level: 3 }).run(),
|
command: (e) => e.chain().focus().setHeading({ level: 3 }).run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Bullet List',
|
title: 'Bullet List',
|
||||||
icon: '•',
|
icon: 'bulletList',
|
||||||
description: 'Unordered bullet list',
|
description: 'Unordered bullet list',
|
||||||
command: (e) => e.chain().focus().toggleBulletList().run(),
|
command: (e) => e.chain().focus().toggleBulletList().run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Numbered List',
|
title: 'Numbered List',
|
||||||
icon: '1.',
|
icon: 'orderedList',
|
||||||
description: 'Ordered numbered list',
|
description: 'Ordered numbered list',
|
||||||
command: (e) => e.chain().focus().toggleOrderedList().run(),
|
command: (e) => e.chain().focus().toggleOrderedList().run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Task List',
|
title: 'Task List',
|
||||||
icon: '☑',
|
icon: 'taskList',
|
||||||
description: 'Checklist with checkboxes',
|
description: 'Checklist with checkboxes',
|
||||||
command: (e) => e.chain().focus().toggleTaskList().run(),
|
command: (e) => e.chain().focus().toggleTaskList().run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Code Block',
|
title: 'Code Block',
|
||||||
icon: '</>',
|
icon: 'codeBlock',
|
||||||
description: 'Syntax-highlighted code block',
|
description: 'Syntax-highlighted code block',
|
||||||
command: (e) => e.chain().focus().toggleCodeBlock().run(),
|
command: (e) => e.chain().focus().toggleCodeBlock().run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Blockquote',
|
title: 'Blockquote',
|
||||||
icon: '“',
|
icon: 'blockquote',
|
||||||
description: 'Indented quote block',
|
description: 'Indented quote block',
|
||||||
command: (e) => e.chain().focus().toggleBlockquote().run(),
|
command: (e) => e.chain().focus().toggleBlockquote().run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Horizontal Rule',
|
title: 'Horizontal Rule',
|
||||||
icon: '—',
|
icon: 'horizontalRule',
|
||||||
description: 'Visual divider line',
|
description: 'Visual divider line',
|
||||||
command: (e) => e.chain().focus().setHorizontalRule().run(),
|
command: (e) => e.chain().focus().setHorizontalRule().run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Image',
|
title: 'Image',
|
||||||
icon: '📷',
|
icon: 'image',
|
||||||
description: 'Insert an image from URL',
|
description: 'Insert an image from URL',
|
||||||
command: (e) => {
|
command: (e) => {
|
||||||
const url = prompt('Image URL:');
|
// Dispatch custom event for parent to show URL popover
|
||||||
if (url) e.chain().focus().setImage({ src: url }).run();
|
const event = new CustomEvent('slash-insert-image', { bubbles: true, composed: true });
|
||||||
|
(e.view.dom as HTMLElement).dispatchEvent(event);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -122,15 +138,17 @@ export function createSlashCommandPlugin(editor: Editor, shadowRoot: ShadowRoot)
|
||||||
|
|
||||||
function updateMenuContent() {
|
function updateMenuContent() {
|
||||||
if (!menuEl) return;
|
if (!menuEl) return;
|
||||||
menuEl.innerHTML = filteredItems
|
menuEl.innerHTML = `<div class="slash-menu__header">Insert block</div>` +
|
||||||
|
filteredItems
|
||||||
.map(
|
.map(
|
||||||
(item, i) =>
|
(item, i) =>
|
||||||
`<div class="slash-menu-item${i === selectedIndex ? ' selected' : ''}" data-index="${i}">
|
`<div class="slash-menu-item${i === selectedIndex ? ' selected' : ''}" data-index="${i}">
|
||||||
<span class="slash-menu-icon">${item.icon}</span>
|
<span class="slash-menu-icon">${SLASH_ICONS[item.icon] || item.icon}</span>
|
||||||
<div class="slash-menu-text">
|
<div class="slash-menu-text">
|
||||||
<div class="slash-menu-title">${item.title}</div>
|
<div class="slash-menu-title">${item.title}</div>
|
||||||
<div class="slash-menu-desc">${item.description}</div>
|
<div class="slash-menu-desc">${item.description}</div>
|
||||||
</div>
|
</div>
|
||||||
|
${i === selectedIndex ? '<span class="slash-menu-hint">Enter</span>' : ''}
|
||||||
</div>`,
|
</div>`,
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
|
||||||
|
|
@ -329,7 +329,7 @@ async function getSpaceConfig(slug: string): Promise<SpaceAuthConfig | null> {
|
||||||
if (!doc) return null;
|
if (!doc) return null;
|
||||||
return {
|
return {
|
||||||
spaceSlug: slug,
|
spaceSlug: slug,
|
||||||
visibility: (doc.meta.visibility || "public_read") as SpaceVisibility,
|
visibility: (doc.meta.visibility || "public") as SpaceVisibility,
|
||||||
ownerDID: doc.meta.ownerDID || undefined,
|
ownerDID: doc.meta.ownerDID || undefined,
|
||||||
app: "rspace",
|
app: "rspace",
|
||||||
};
|
};
|
||||||
|
|
@ -352,9 +352,9 @@ app.post("/api/communities", async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await c.req.json<{ name?: string; slug?: string; visibility?: SpaceVisibility }>();
|
const body = await c.req.json<{ name?: string; slug?: string; visibility?: SpaceVisibility }>();
|
||||||
const { name, slug, visibility = "public_read" } = body;
|
const { name, slug, visibility = "public" } = body;
|
||||||
|
|
||||||
const validVisibilities: SpaceVisibility[] = ["public", "public_read", "authenticated", "members_only"];
|
const validVisibilities: SpaceVisibility[] = ["public", "permissioned", "private"];
|
||||||
if (visibility && !validVisibilities.includes(visibility)) return c.json({ error: "Invalid visibility" }, 400);
|
if (visibility && !validVisibilities.includes(visibility)) return c.json({ error: "Invalid visibility" }, 400);
|
||||||
|
|
||||||
const result = await createSpace({
|
const result = await createSpace({
|
||||||
|
|
@ -381,7 +381,7 @@ app.post("/api/internal/provision", async (c) => {
|
||||||
name: space.charAt(0).toUpperCase() + space.slice(1),
|
name: space.charAt(0).toUpperCase() + space.slice(1),
|
||||||
slug: space,
|
slug: space,
|
||||||
ownerDID: `did:system:${space}`,
|
ownerDID: `did:system:${space}`,
|
||||||
visibility: body.public ? "public" : "public_read",
|
visibility: "public",
|
||||||
source: 'internal',
|
source: 'internal',
|
||||||
});
|
});
|
||||||
if (!result.ok) return c.json({ error: result.error }, result.status);
|
if (!result.ok) return c.json({ error: result.error }, result.status);
|
||||||
|
|
@ -1147,7 +1147,7 @@ app.post("/api/spaces/auto-provision", async (c) => {
|
||||||
name: `${claims.username}'s Space`,
|
name: `${claims.username}'s Space`,
|
||||||
slug: username,
|
slug: username,
|
||||||
ownerDID: claims.sub,
|
ownerDID: claims.sub,
|
||||||
visibility: "members_only",
|
visibility: "private",
|
||||||
source: 'auto-provision',
|
source: 'auto-provision',
|
||||||
});
|
});
|
||||||
if (!result.ok) return c.json({ error: result.error }, result.status);
|
if (!result.ok) return c.json({ error: result.error }, result.status);
|
||||||
|
|
@ -1156,9 +1156,9 @@ app.post("/api/spaces/auto-provision", async (c) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Inject space visibility into HTML responses ──
|
// ── Inject space visibility into HTML responses ──
|
||||||
// Replaces the default data-space-visibility="public_read" rendered by renderShell
|
// Replaces the default data-space-visibility="public" rendered by renderShell
|
||||||
// with the actual visibility from the space config, so the client-side access gate
|
// with the actual visibility from the space config, so the client-side access gate
|
||||||
// can block content for members_only spaces when no session exists.
|
// can block content for private spaces when no session exists.
|
||||||
app.use("/:space/*", async (c, next) => {
|
app.use("/:space/*", async (c, next) => {
|
||||||
await next();
|
await next();
|
||||||
const ct = c.res.headers.get("content-type");
|
const ct = c.res.headers.get("content-type");
|
||||||
|
|
@ -1166,12 +1166,12 @@ app.use("/:space/*", async (c, next) => {
|
||||||
const space = c.req.param("space");
|
const space = c.req.param("space");
|
||||||
if (!space || space === "api" || space.includes(".")) return;
|
if (!space || space === "api" || space.includes(".")) return;
|
||||||
const config = await getSpaceConfig(space);
|
const config = await getSpaceConfig(space);
|
||||||
const vis = config?.visibility || "public_read";
|
const vis = config?.visibility || "public";
|
||||||
if (vis === "public_read" || vis === "public") return;
|
if (vis === "public") return;
|
||||||
const html = await c.res.text();
|
const html = await c.res.text();
|
||||||
c.res = new Response(
|
c.res = new Response(
|
||||||
html.replace(
|
html.replace(
|
||||||
'data-space-visibility="public_read"',
|
'data-space-visibility="public"',
|
||||||
`data-space-visibility="${vis}"`,
|
`data-space-visibility="${vis}"`,
|
||||||
),
|
),
|
||||||
{ status: c.res.status, headers: c.res.headers },
|
{ status: c.res.status, headers: c.res.headers },
|
||||||
|
|
@ -1378,7 +1378,7 @@ app.get("/admin-data", async (c) => {
|
||||||
spacesList.push({
|
spacesList.push({
|
||||||
slug: data.meta.slug,
|
slug: data.meta.slug,
|
||||||
name: data.meta.name,
|
name: data.meta.name,
|
||||||
visibility: data.meta.visibility || "public_read",
|
visibility: data.meta.visibility || "public",
|
||||||
createdAt: data.meta.createdAt,
|
createdAt: data.meta.createdAt,
|
||||||
ownerDID: data.meta.ownerDID,
|
ownerDID: data.meta.ownerDID,
|
||||||
shapeCount,
|
shapeCount,
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
modules,
|
modules,
|
||||||
theme = "dark",
|
theme = "dark",
|
||||||
head = "",
|
head = "",
|
||||||
spaceVisibility = "public_read",
|
spaceVisibility = "public",
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
// Auto-populate from space data when not explicitly provided
|
// Auto-populate from space data when not explicitly provided
|
||||||
|
|
@ -201,10 +201,10 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Private space access gate ──
|
// ── Private space access gate ──
|
||||||
// If the space is members_only and no session exists, show a sign-in gate
|
// If the space is private and no session exists, show a sign-in gate
|
||||||
(function() {
|
(function() {
|
||||||
var vis = document.body.getAttribute('data-space-visibility');
|
var vis = document.body.getAttribute('data-space-visibility');
|
||||||
if (vis !== 'members_only') return;
|
if (vis !== 'private') return;
|
||||||
try {
|
try {
|
||||||
var raw = localStorage.getItem('encryptid_session');
|
var raw = localStorage.getItem('encryptid_session');
|
||||||
if (raw) {
|
if (raw) {
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ export type CreateSpaceResult =
|
||||||
* All creation endpoints should call this instead of duplicating logic.
|
* All creation endpoints should call this instead of duplicating logic.
|
||||||
*/
|
*/
|
||||||
export async function createSpace(opts: CreateSpaceOpts): Promise<CreateSpaceResult> {
|
export async function createSpace(opts: CreateSpaceOpts): Promise<CreateSpaceResult> {
|
||||||
const { name, slug, ownerDID, visibility = 'public_read', enabledModules, source = 'api' } = opts;
|
const { name, slug, ownerDID, visibility = 'public', enabledModules, source = 'api' } = opts;
|
||||||
|
|
||||||
if (!name || !slug) return { ok: false, error: "Name and slug are required", status: 400 };
|
if (!name || !slug) return { ok: false, error: "Name and slug are required", status: 400 };
|
||||||
if (!/^[a-z0-9-]+$/.test(slug)) return { ok: false, error: "Slug must contain only lowercase letters, numbers, and hyphens", status: 400 };
|
if (!/^[a-z0-9-]+$/.test(slug)) return { ok: false, error: "Slug must contain only lowercase letters, numbers, and hyphens", status: 400 };
|
||||||
|
|
@ -197,18 +197,18 @@ spaces.get("/", async (c) => {
|
||||||
await loadCommunity(slug);
|
await loadCommunity(slug);
|
||||||
const data = getDocumentData(slug);
|
const data = getDocumentData(slug);
|
||||||
if (data?.meta) {
|
if (data?.meta) {
|
||||||
const vis = data.meta.visibility || "public_read";
|
const vis = data.meta.visibility || "public";
|
||||||
const isOwner = !!(claims && data.meta.ownerDID === claims.sub);
|
const isOwner = !!(claims && data.meta.ownerDID === claims.sub);
|
||||||
const memberEntry = claims ? data.members?.[claims.sub] : undefined;
|
const memberEntry = claims ? data.members?.[claims.sub] : undefined;
|
||||||
const isMember = !!memberEntry;
|
const isMember = !!memberEntry;
|
||||||
|
|
||||||
// Determine accessibility
|
// Determine accessibility
|
||||||
const isPublic = vis === "public" || vis === "public_read";
|
const isPublicSpace = vis === "public";
|
||||||
const isAuthenticated = vis === "authenticated";
|
const isPermissioned = vis === "permissioned";
|
||||||
const accessible = isPublic || isOwner || isMember || (isAuthenticated && !!claims);
|
const accessible = isPublicSpace || isOwner || isMember || (isPermissioned && !!claims);
|
||||||
|
|
||||||
// For unauthenticated: only show public spaces
|
// For unauthenticated: only show public spaces
|
||||||
if (!claims && !isPublic) continue;
|
if (!claims && !isPublicSpace) continue;
|
||||||
|
|
||||||
// Determine relationship
|
// Determine relationship
|
||||||
const relationship = isOwner
|
const relationship = isOwner
|
||||||
|
|
@ -271,9 +271,9 @@ spaces.post("/", async (c) => {
|
||||||
enabledModules?: string[];
|
enabledModules?: string[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { name, slug, visibility = "public_read", enabledModules } = body;
|
const { name, slug, visibility = "public", enabledModules } = body;
|
||||||
|
|
||||||
const validVisibilities: SpaceVisibility[] = ["public", "public_read", "authenticated", "members_only"];
|
const validVisibilities: SpaceVisibility[] = ["public", "permissioned", "private"];
|
||||||
if (visibility && !validVisibilities.includes(visibility)) {
|
if (visibility && !validVisibilities.includes(visibility)) {
|
||||||
return c.json({ error: `Invalid visibility. Must be one of: ${validVisibilities.join(", ")}` }, 400);
|
return c.json({ error: `Invalid visibility. Must be one of: ${validVisibilities.join(", ")}` }, 400);
|
||||||
}
|
}
|
||||||
|
|
@ -440,7 +440,7 @@ spaces.get("/admin", async (c) => {
|
||||||
spacesList.push({
|
spacesList.push({
|
||||||
slug: data.meta.slug,
|
slug: data.meta.slug,
|
||||||
name: data.meta.name,
|
name: data.meta.name,
|
||||||
visibility: data.meta.visibility || "public_read",
|
visibility: data.meta.visibility || "public",
|
||||||
createdAt: data.meta.createdAt,
|
createdAt: data.meta.createdAt,
|
||||||
ownerDID: data.meta.ownerDID,
|
ownerDID: data.meta.ownerDID,
|
||||||
shapeCount,
|
shapeCount,
|
||||||
|
|
@ -622,7 +622,8 @@ spaces.patch("/:slug", async (c) => {
|
||||||
const body = await c.req.json<{ name?: string; visibility?: SpaceVisibility; description?: string }>();
|
const body = await c.req.json<{ name?: string; visibility?: SpaceVisibility; description?: string }>();
|
||||||
|
|
||||||
if (body.visibility) {
|
if (body.visibility) {
|
||||||
const valid: SpaceVisibility[] = ["public", "public_read", "authenticated", "members_only"];
|
const valid: SpaceVisibility[] = ["public", "permissioned", "private"];
|
||||||
|
// Note: the create endpoint (line ~276) already validates correctly
|
||||||
if (!valid.includes(body.visibility)) {
|
if (!valid.includes(body.visibility)) {
|
||||||
return c.json({ error: `Invalid visibility. Must be one of: ${valid.join(", ")}` }, 400);
|
return c.json({ error: `Invalid visibility. Must be one of: ${valid.join(", ")}` }, 400);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -423,7 +423,7 @@ app.post('/api/register/complete', async (c) => {
|
||||||
|
|
||||||
const spaceSlug = username.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
const spaceSlug = username.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||||
if (!await communityExists(spaceSlug)) {
|
if (!await communityExists(spaceSlug)) {
|
||||||
await createCommunity(username, spaceSlug, did, 'members_only', {
|
await createCommunity(username, spaceSlug, did, 'private', {
|
||||||
enabledModules: DEFAULT_USER_MODULES,
|
enabledModules: DEFAULT_USER_MODULES,
|
||||||
nestPolicy: DEFAULT_USER_NEST_POLICY,
|
nestPolicy: DEFAULT_USER_NEST_POLICY,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1136,6 +1136,7 @@
|
||||||
folk-choice-vote,
|
folk-choice-vote,
|
||||||
folk-choice-rank,
|
folk-choice-rank,
|
||||||
folk-choice-spider,
|
folk-choice-spider,
|
||||||
|
folk-choice-conviction,
|
||||||
folk-social-post,
|
folk-social-post,
|
||||||
folk-splat,
|
folk-splat,
|
||||||
folk-blender,
|
folk-blender,
|
||||||
|
|
@ -1153,7 +1154,7 @@
|
||||||
folk-video-chat, folk-obs-note, folk-workflow-block,
|
folk-video-chat, folk-obs-note, folk-workflow-block,
|
||||||
folk-itinerary, folk-destination, folk-budget, folk-packing-list,
|
folk-itinerary, folk-destination, folk-budget, folk-packing-list,
|
||||||
folk-booking, folk-token-mint, folk-token-ledger,
|
folk-booking, folk-token-mint, folk-token-ledger,
|
||||||
folk-choice-vote, folk-choice-rank, folk-choice-spider,
|
folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-choice-conviction,
|
||||||
folk-social-post, folk-splat, folk-blender, folk-drawfast,
|
folk-social-post, folk-splat, folk-blender, folk-drawfast,
|
||||||
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp) {
|
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp) {
|
||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
|
|
@ -1165,7 +1166,7 @@
|
||||||
folk-video-chat, folk-obs-note, folk-workflow-block,
|
folk-video-chat, folk-obs-note, folk-workflow-block,
|
||||||
folk-itinerary, folk-destination, folk-budget, folk-packing-list,
|
folk-itinerary, folk-destination, folk-budget, folk-packing-list,
|
||||||
folk-booking, folk-token-mint, folk-token-ledger,
|
folk-booking, folk-token-mint, folk-token-ledger,
|
||||||
folk-choice-vote, folk-choice-rank, folk-choice-spider,
|
folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-choice-conviction,
|
||||||
folk-social-post, folk-splat, folk-blender, folk-drawfast,
|
folk-social-post, folk-splat, folk-blender, folk-drawfast,
|
||||||
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp):hover {
|
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp):hover {
|
||||||
outline: 2px dashed #3b82f6;
|
outline: 2px dashed #3b82f6;
|
||||||
|
|
@ -1639,6 +1640,7 @@
|
||||||
<button id="new-choice-vote" title="New Poll">☑ Poll</button>
|
<button id="new-choice-vote" title="New Poll">☑ Poll</button>
|
||||||
<button id="new-choice-rank" title="New Ranking">📊 Ranking</button>
|
<button id="new-choice-rank" title="New Ranking">📊 Ranking</button>
|
||||||
<button id="new-choice-spider" title="New Scoring">🕸 Scoring</button>
|
<button id="new-choice-spider" title="New Scoring">🕸 Scoring</button>
|
||||||
|
<button id="new-conviction" title="Conviction Ranking">⏳ Conviction</button>
|
||||||
<button id="new-token" title="New Token">🪙 Token</button>
|
<button id="new-token" title="New Token">🪙 Token</button>
|
||||||
<button id="embed-funds" title="Embed rFunds">🌊 rFunds</button>
|
<button id="embed-funds" title="Embed rFunds">🌊 rFunds</button>
|
||||||
<button id="embed-wallet" title="Embed rWallet">💰 rWallet</button>
|
<button id="embed-wallet" title="Embed rWallet">💰 rWallet</button>
|
||||||
|
|
@ -1773,6 +1775,7 @@
|
||||||
FolkChoiceVote,
|
FolkChoiceVote,
|
||||||
FolkChoiceRank,
|
FolkChoiceRank,
|
||||||
FolkChoiceSpider,
|
FolkChoiceSpider,
|
||||||
|
FolkChoiceConviction,
|
||||||
FolkSocialPost,
|
FolkSocialPost,
|
||||||
FolkSplat,
|
FolkSplat,
|
||||||
FolkBlender,
|
FolkBlender,
|
||||||
|
|
@ -1891,6 +1894,7 @@
|
||||||
FolkChoiceVote.define();
|
FolkChoiceVote.define();
|
||||||
FolkChoiceRank.define();
|
FolkChoiceRank.define();
|
||||||
FolkChoiceSpider.define();
|
FolkChoiceSpider.define();
|
||||||
|
FolkChoiceConviction.define();
|
||||||
FolkSocialPost.define();
|
FolkSocialPost.define();
|
||||||
FolkSplat.define();
|
FolkSplat.define();
|
||||||
FolkBlender.define();
|
FolkBlender.define();
|
||||||
|
|
@ -2115,7 +2119,7 @@
|
||||||
"folk-budget", "folk-packing-list", "folk-booking",
|
"folk-budget", "folk-packing-list", "folk-booking",
|
||||||
"folk-token-mint", "folk-token-ledger",
|
"folk-token-mint", "folk-token-ledger",
|
||||||
"folk-choice-vote", "folk-choice-rank", "folk-choice-spider",
|
"folk-choice-vote", "folk-choice-rank", "folk-choice-spider",
|
||||||
"folk-social-post",
|
"folk-choice-conviction", "folk-social-post",
|
||||||
"folk-splat", "folk-blender", "folk-drawfast",
|
"folk-splat", "folk-blender", "folk-drawfast",
|
||||||
"folk-freecad", "folk-kicad",
|
"folk-freecad", "folk-kicad",
|
||||||
"folk-rapp",
|
"folk-rapp",
|
||||||
|
|
@ -2790,6 +2794,12 @@
|
||||||
if (data.criteria) shape.criteria = data.criteria;
|
if (data.criteria) shape.criteria = data.criteria;
|
||||||
if (data.scores) shape.scores = data.scores;
|
if (data.scores) shape.scores = data.scores;
|
||||||
break;
|
break;
|
||||||
|
case "folk-choice-conviction":
|
||||||
|
shape = document.createElement("folk-choice-conviction");
|
||||||
|
if (data.title) shape.title = data.title;
|
||||||
|
if (data.options) shape.options = data.options;
|
||||||
|
if (data.stakes) shape.stakes = data.stakes;
|
||||||
|
break;
|
||||||
case "folk-social-post":
|
case "folk-social-post":
|
||||||
shape = document.createElement("folk-social-post");
|
shape = document.createElement("folk-social-post");
|
||||||
if (data.platform) shape.platform = data.platform;
|
if (data.platform) shape.platform = data.platform;
|
||||||
|
|
@ -2948,6 +2958,7 @@
|
||||||
"folk-choice-vote": { width: 360, height: 400 },
|
"folk-choice-vote": { width: 360, height: 400 },
|
||||||
"folk-choice-rank": { width: 380, height: 480 },
|
"folk-choice-rank": { width: 380, height: 480 },
|
||||||
"folk-choice-spider": { width: 440, height: 540 },
|
"folk-choice-spider": { width: 440, height: 540 },
|
||||||
|
"folk-choice-conviction": { width: 380, height: 480 },
|
||||||
"folk-social-post": { width: 300, height: 380 },
|
"folk-social-post": { width: 300, height: 380 },
|
||||||
"folk-splat": { width: 480, height: 420 },
|
"folk-splat": { width: 480, height: 420 },
|
||||||
"folk-blender": { width: 420, height: 520 },
|
"folk-blender": { width: 420, height: 520 },
|
||||||
|
|
@ -3422,6 +3433,18 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById("new-conviction").addEventListener("click", () => {
|
||||||
|
setPendingTool("folk-choice-conviction", {
|
||||||
|
title: "Conviction Ranking",
|
||||||
|
options: [
|
||||||
|
{ id: "opt-1", label: "Option A", color: "#f59e0b" },
|
||||||
|
{ id: "opt-2", label: "Option B", color: "#3b82f6" },
|
||||||
|
{ id: "opt-3", label: "Option C", color: "#22c55e" },
|
||||||
|
],
|
||||||
|
stakes: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Social media post
|
// Social media post
|
||||||
document.getElementById("new-social-post").addEventListener("click", () => {
|
document.getElementById("new-social-post").addEventListener("click", () => {
|
||||||
setPendingTool("folk-social-post", {
|
setPendingTool("folk-social-post", {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue