feat: Add Private Workspace zone for data sovereignty (Phase 3)

- PrivateWorkspaceShapeUtil: Frosted glass container shape with:
  - Dashed indigo border for visual distinction
  - Pin/collapse/close buttons in header
  - Dark mode support
  - Position/size persistence to localStorage
  - Helper functions for zone detection

- PrivateWorkspaceTool: Tool for creating workspace zones

- usePrivateWorkspace hook:
  - Creates/toggles workspace visibility
  - Listens for 'add-google-items-to-canvas' events
  - Places items inside the private zone
  - Persists visibility state

- PrivateWorkspaceManager: Headless component that manages
  workspace lifecycle inside Tldraw context

Items added from GoogleExportBrowser will now appear in the
Private Workspace zone as placeholder text shapes (Phase 4
will add proper GoogleItemShape with visual badges).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-04 16:54:27 -08:00
parent 33f5dc7e7f
commit 052c98417d
5 changed files with 582 additions and 0 deletions

View File

@ -0,0 +1,22 @@
import { useEditor } from 'tldraw'
import { usePrivateWorkspace } from '../hooks/usePrivateWorkspace'
/**
* Component that manages the Private Workspace zone for Google Export data.
* Listens for 'add-google-items-to-canvas' events and creates items in the workspace.
*
* Must be rendered inside a Tldraw context.
*/
export function PrivateWorkspaceManager() {
const editor = useEditor()
// This hook handles:
// - Creating/showing the private workspace zone
// - Listening for 'add-google-items-to-canvas' events
// - Adding items to the workspace when triggered
usePrivateWorkspace({ editor })
// This component doesn't render anything visible
// It just manages the workspace logic
return null
}

View File

@ -0,0 +1,172 @@
import { useCallback, useEffect, useState } from 'react'
import { Editor, createShapeId, TLShapeId } from 'tldraw'
import { IPrivateWorkspaceShape, findPrivateWorkspace } from '../shapes/PrivateWorkspaceShapeUtil'
import type { ShareableItem } from '../lib/google'
const WORKSPACE_STORAGE_KEY = 'private-workspace-visible'
export interface UsePrivateWorkspaceOptions {
editor: Editor | null
}
export function usePrivateWorkspace({ editor }: UsePrivateWorkspaceOptions) {
const [workspaceId, setWorkspaceId] = useState<TLShapeId | null>(null)
const [isVisible, setIsVisible] = useState(() => {
try {
return localStorage.getItem(WORKSPACE_STORAGE_KEY) === 'true'
} catch {
return false
}
})
// Find existing workspace on mount or when editor changes
useEffect(() => {
if (!editor) return
const existing = findPrivateWorkspace(editor)
if (existing) {
setWorkspaceId(existing.id)
setIsVisible(true)
}
}, [editor])
// Create or show the private workspace
const showWorkspace = useCallback(() => {
if (!editor) return null
// Check if workspace already exists
const existing = findPrivateWorkspace(editor)
if (existing) {
setWorkspaceId(existing.id)
setIsVisible(true)
localStorage.setItem(WORKSPACE_STORAGE_KEY, 'true')
return existing.id
}
// Get viewport center for placement
const viewport = editor.getViewportScreenBounds()
const center = editor.screenToPage({
x: viewport.x + viewport.width * 0.15, // Position on left side
y: viewport.y + viewport.height * 0.2,
})
// Create new workspace
const id = createShapeId()
editor.createShape<IPrivateWorkspaceShape>({
id,
type: 'PrivateWorkspace',
x: center.x,
y: center.y,
props: {
w: 350,
h: 450,
pinnedToView: false,
isCollapsed: false,
},
})
setWorkspaceId(id)
setIsVisible(true)
localStorage.setItem(WORKSPACE_STORAGE_KEY, 'true')
return id
}, [editor])
// Hide/delete the workspace
const hideWorkspace = useCallback(() => {
if (!editor || !workspaceId) return
const shape = editor.getShape(workspaceId)
if (shape) {
editor.deleteShape(workspaceId)
}
setWorkspaceId(null)
setIsVisible(false)
localStorage.setItem(WORKSPACE_STORAGE_KEY, 'false')
}, [editor, workspaceId])
// Toggle workspace visibility
const toggleWorkspace = useCallback(() => {
if (isVisible && workspaceId) {
hideWorkspace()
} else {
showWorkspace()
}
}, [isVisible, workspaceId, showWorkspace, hideWorkspace])
// Add items to the workspace (from GoogleExportBrowser)
const addItemsToWorkspace = useCallback((
items: ShareableItem[],
_position?: { x: number; y: number }
) => {
if (!editor) return
// Ensure workspace exists
let wsId = workspaceId
if (!wsId) {
wsId = showWorkspace()
}
if (!wsId) return
const workspace = editor.getShape(wsId) as IPrivateWorkspaceShape | undefined
if (!workspace) return
// Calculate starting position inside workspace
const startX = workspace.x + 20
const startY = workspace.y + 60 // Below header
const itemSpacing = 100
// Create placeholder shapes for each item
// In Phase 4, these will be proper PrivateItemShape with visibility tracking
items.forEach((item, index) => {
const itemId = createShapeId()
const col = index % 3
const row = Math.floor(index / 3)
// For now, create text shapes as placeholders
// Phase 4 will replace with proper GoogleItemShape
editor.createShape({
id: itemId,
type: 'text',
x: startX + col * itemSpacing,
y: startY + row * itemSpacing,
props: {
text: `🔒 ${item.title}`,
size: 's',
font: 'sans',
color: 'violet',
autoSize: true,
},
})
})
// Focus on workspace
editor.select(wsId)
editor.zoomToSelection({ animation: { duration: 300 } })
}, [editor, workspaceId, showWorkspace])
// Listen for add-google-items-to-canvas events
useEffect(() => {
const handleAddItems = (event: CustomEvent<{
items: ShareableItem[]
position: { x: number; y: number }
}>) => {
const { items, position } = event.detail
addItemsToWorkspace(items, position)
}
window.addEventListener('add-google-items-to-canvas', handleAddItems as EventListener)
return () => {
window.removeEventListener('add-google-items-to-canvas', handleAddItems as EventListener)
}
}, [addItemsToWorkspace])
return {
workspaceId,
isVisible,
showWorkspace,
hideWorkspace,
toggleWorkspace,
addItemsToWorkspace,
}
}

View File

@ -49,6 +49,10 @@ import { MultmuxTool } from "@/tools/MultmuxTool"
import { MultmuxShape } from "@/shapes/MultmuxShapeUtil"
// MycelialIntelligence moved to permanent UI bar - shape kept for backwards compatibility
import { MycelialIntelligenceShape } from "@/shapes/MycelialIntelligenceShapeUtil"
// Private Workspace for Google Export data sovereignty
import { PrivateWorkspaceShape } from "@/shapes/PrivateWorkspaceShapeUtil"
import { PrivateWorkspaceTool } from "@/tools/PrivateWorkspaceTool"
import { PrivateWorkspaceManager } from "@/components/PrivateWorkspaceManager"
import {
lockElement,
unlockElement,
@ -141,6 +145,7 @@ const customShapeUtils = [
VideoGenShape,
MultmuxShape,
MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility
PrivateWorkspaceShape, // Private zone for Google Export data sovereignty
]
const customTools = [
ChatBoxTool,
@ -158,6 +163,7 @@ const customTools = [
ImageGenTool,
VideoGenTool,
MultmuxTool,
PrivateWorkspaceTool,
]
// Debug: Log tool and shape registration info
@ -1100,6 +1106,7 @@ export function Board() {
}}
>
<CmdK />
<PrivateWorkspaceManager />
</Tldraw>
</div>
</AutomergeHandleProvider>

View File

@ -0,0 +1,370 @@
import { useState, useEffect } from "react"
import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer, TLShapeId } from "tldraw"
import { usePinnedToView } from "../hooks/usePinnedToView"
export type IPrivateWorkspaceShape = TLBaseShape<
"PrivateWorkspace",
{
w: number
h: number
pinnedToView: boolean
isCollapsed: boolean
}
>
// Storage key for persisting workspace position/size
const STORAGE_KEY = 'private-workspace-state'
interface WorkspaceState {
x: number
y: number
w: number
h: number
}
function saveWorkspaceState(state: WorkspaceState) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
} catch (e) {
console.warn('Failed to save workspace state:', e)
}
}
function loadWorkspaceState(): WorkspaceState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
return JSON.parse(stored)
}
} catch (e) {
console.warn('Failed to load workspace state:', e)
}
return null
}
export class PrivateWorkspaceShape extends BaseBoxShapeUtil<IPrivateWorkspaceShape> {
static override type = "PrivateWorkspace" as const
// Privacy zone color: Indigo
static readonly PRIMARY_COLOR = "#6366f1"
getDefaultProps(): IPrivateWorkspaceShape["props"] {
const saved = loadWorkspaceState()
return {
w: saved?.w ?? 400,
h: saved?.h ?? 500,
pinnedToView: false,
isCollapsed: false,
}
}
override canResize() {
return true
}
override canBind() {
return false
}
indicator(shape: IPrivateWorkspaceShape) {
return (
<rect
x={0}
y={0}
width={shape.props.w}
height={shape.props.h}
rx={12}
ry={12}
strokeDasharray="8 4"
/>
)
}
component(shape: IPrivateWorkspaceShape) {
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
// Use the pinning hook to keep the shape fixed to viewport when pinned
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
// Save position/size when shape changes
useEffect(() => {
const shapeData = this.editor.getShape(shape.id)
if (shapeData) {
saveWorkspaceState({
x: shapeData.x,
y: shapeData.y,
w: shape.props.w,
h: shape.props.h,
})
}
}, [shape.props.w, shape.props.h, shape.id])
const handleClose = () => {
this.editor.deleteShape(shape.id)
}
const handlePinToggle = () => {
this.editor.updateShape<IPrivateWorkspaceShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
pinnedToView: !shape.props.pinnedToView,
},
})
}
const handleCollapse = () => {
this.editor.updateShape<IPrivateWorkspaceShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
isCollapsed: !shape.props.isCollapsed,
},
})
}
// Detect dark mode
const isDarkMode = typeof document !== 'undefined' &&
document.documentElement.classList.contains('dark')
const colors = isDarkMode ? {
bg: 'rgba(99, 102, 241, 0.12)',
headerBg: 'rgba(99, 102, 241, 0.25)',
border: 'rgba(99, 102, 241, 0.4)',
text: '#e4e4e7',
textMuted: '#a1a1aa',
btnHover: 'rgba(255, 255, 255, 0.1)',
} : {
bg: 'rgba(99, 102, 241, 0.06)',
headerBg: 'rgba(99, 102, 241, 0.15)',
border: 'rgba(99, 102, 241, 0.3)',
text: '#3730a3',
textMuted: '#6366f1',
btnHover: 'rgba(99, 102, 241, 0.1)',
}
const collapsedHeight = 44
return (
<HTMLContainer
style={{
width: shape.props.w,
height: shape.props.isCollapsed ? collapsedHeight : shape.props.h,
pointerEvents: 'all',
}}
>
<div
style={{
width: '100%',
height: '100%',
backgroundColor: colors.bg,
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
borderRadius: '12px',
border: `2px dashed ${colors.border}`,
boxShadow: isSelected
? `0 0 0 2px ${PrivateWorkspaceShape.PRIMARY_COLOR}, 0 8px 32px rgba(99, 102, 241, 0.15)`
: '0 4px 24px rgba(0, 0, 0, 0.08)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
transition: 'box-shadow 0.2s ease, height 0.2s ease',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 14px',
backgroundColor: colors.headerBg,
borderBottom: shape.props.isCollapsed ? 'none' : `1px solid ${colors.border}`,
cursor: 'grab',
userSelect: 'none',
}}
onPointerDown={(e) => {
// Allow dragging from header
e.stopPropagation()
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '16px' }}>🔒</span>
<span
style={{
fontSize: '14px',
fontWeight: '600',
color: colors.text,
letterSpacing: '-0.01em',
}}
>
Private Workspace
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{/* Pin button */}
<button
onClick={(e) => {
e.stopPropagation()
handlePinToggle()
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
background: 'none',
border: 'none',
padding: '4px 6px',
cursor: 'pointer',
fontSize: '14px',
borderRadius: '4px',
opacity: shape.props.pinnedToView ? 1 : 0.6,
transition: 'opacity 0.15s ease',
}}
title={shape.props.pinnedToView ? 'Unpin from viewport' : 'Pin to viewport'}
>
📌
</button>
{/* Collapse button */}
<button
onClick={(e) => {
e.stopPropagation()
handleCollapse()
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
background: 'none',
border: 'none',
padding: '4px 6px',
cursor: 'pointer',
fontSize: '14px',
borderRadius: '4px',
opacity: 0.6,
transition: 'opacity 0.15s ease',
}}
title={shape.props.isCollapsed ? 'Expand' : 'Collapse'}
>
{shape.props.isCollapsed ? '▼' : '▲'}
</button>
{/* Close button */}
<button
onClick={(e) => {
e.stopPropagation()
handleClose()
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
background: 'none',
border: 'none',
padding: '4px 6px',
cursor: 'pointer',
fontSize: '14px',
borderRadius: '4px',
opacity: 0.6,
transition: 'opacity 0.15s ease',
}}
title="Close workspace"
>
</button>
</div>
</div>
{/* Content area */}
{!shape.props.isCollapsed && (
<div
style={{
flex: 1,
padding: '16px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: colors.textMuted,
fontSize: '13px',
textAlign: 'center',
gap: '12px',
}}
>
<div
style={{
width: '48px',
height: '48px',
borderRadius: '12px',
backgroundColor: colors.headerBg,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '24px',
}}
>
🔐
</div>
<div>
<p style={{ margin: '0 0 4px 0', fontWeight: '500', color: colors.text }}>
Drop items here to keep them private
</p>
<p style={{ margin: 0, fontSize: '12px', opacity: 0.8 }}>
Encrypted in your browser Only you can see these
</p>
</div>
</div>
)}
{/* Footer hint */}
{!shape.props.isCollapsed && (
<div
style={{
padding: '8px 14px',
backgroundColor: colors.headerBg,
borderTop: `1px solid ${colors.border}`,
fontSize: '11px',
color: colors.textMuted,
textAlign: 'center',
}}
>
Drag items outside to share with collaborators
</div>
)}
</div>
</HTMLContainer>
)
}
}
// Helper function to check if a shape is inside the private workspace
export function isShapeInPrivateWorkspace(
editor: any,
shapeId: TLShapeId,
workspaceId: TLShapeId
): boolean {
const shape = editor.getShape(shapeId)
const workspace = editor.getShape(workspaceId)
if (!shape || !workspace || workspace.type !== 'PrivateWorkspace') {
return false
}
const shapeBounds = editor.getShapeGeometry(shape).bounds
const workspaceBounds = editor.getShapeGeometry(workspace).bounds
// Check if shape center is within workspace bounds
const shapeCenterX = shape.x + shapeBounds.width / 2
const shapeCenterY = shape.y + shapeBounds.height / 2
return (
shapeCenterX >= workspace.x &&
shapeCenterX <= workspace.x + workspaceBounds.width &&
shapeCenterY >= workspace.y &&
shapeCenterY <= workspace.y + workspaceBounds.height
)
}
// Helper to find the private workspace shape on the canvas
export function findPrivateWorkspace(editor: any): IPrivateWorkspaceShape | null {
const shapes = editor.getCurrentPageShapes()
return shapes.find((s: any) => s.type === 'PrivateWorkspace') || null
}

View File

@ -0,0 +1,11 @@
import { BaseBoxShapeTool, TLEventHandlers } from "tldraw"
export class PrivateWorkspaceTool extends BaseBoxShapeTool {
static override id = "PrivateWorkspace"
shapeType = "PrivateWorkspace"
override initial = "idle"
override onComplete: TLEventHandlers["onComplete"] = () => {
this.editor.setCurrentTool('select')
}
}