Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/architecture/diagrams/08-yjs-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
```mermaid
flowchart TB
subgraph Client["Client (Local-First)"]
UI[UI Components]
YDoc[Y.Doc]
IDB[(IndexedDB<br/>y-indexeddb)]
Cache[PDF Cache]
end

subgraph Server["Server (Authoritative)"]
DO[ProjectDoc DO<br/>Y.Doc State]
D1[(D1<br/>Metadata)]
R2[(R2<br/>PDFs)]
end

YDoc <-->|"Local First"| IDB
YDoc <-->|"WebSocket Sync"| DO
UI -->|"Read/Write"| YDoc
Cache -->|"Cache"| R2
DO -->|"Read"| D1
```
2 changes: 1 addition & 1 deletion packages/ui/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ if (!global.crypto) {
global.crypto = {} as Crypto;
}
global.crypto.randomUUID = vi.fn(
() => 'test-uuid-' + Math.random().toString(36).slice(2)
() => 'test-uuid-' + Math.random().toString(36).slice(2),
) as () => `${string}-${string}-${string}-${string}-${string}`;

// Mock requestAnimationFrame
Expand Down
1 change: 0 additions & 1 deletion packages/ui/src/components/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ const ComboboxComponent: Component<ComboboxProps> = props => {
set(items);
});


const handleValueChange = (details: { value: string[] }) => {
if (machineProps.onValueChange) {
// Find the items that match the selected values
Expand Down
18 changes: 9 additions & 9 deletions packages/ui/src/components/Drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const DrawerComponent: Component<DrawerProps> = props => {
{/* Backdrop */}
<Show when={showBackdrop()}>
<ArkDialog.Backdrop
class={`fixed inset-0 ${Z_INDEX.BACKDROP} bg-black/50 transition-opacity duration-300 data-[state=open]:opacity-100 data-[state=closed]:opacity-0`}
class={`fixed inset-0 ${Z_INDEX.BACKDROP} bg-black/50 transition-opacity duration-300 data-[state=closed]:opacity-0 data-[state=open]:opacity-100`}
/>
</Show>

Expand All @@ -97,27 +97,27 @@ const DrawerComponent: Component<DrawerProps> = props => {
{/* Drawer Content */}
<ArkDialog.Content
class={`h-screen ${getSizeClass()} flex flex-col bg-white shadow-xl transition-transform duration-300 ease-out ${getSideClasses()}`}
role="dialog"
aria-modal="true"
role='dialog'
aria-modal='true'
aria-labelledby={title() ? 'drawer-title' : undefined}
>
{/* Header */}
<div class="flex shrink-0 items-center justify-between border-b border-gray-200 p-4">
<div class='flex shrink-0 items-center justify-between border-b border-gray-200 p-4'>
<Show when={title()}>
<ArkDialog.Title id="drawer-title" class="text-lg font-semibold text-gray-900">
<ArkDialog.Title id='drawer-title' class='text-lg font-semibold text-gray-900'>
{title()}
</ArkDialog.Title>
</Show>
<ArkDialog.CloseTrigger
class="ml-auto rounded-md p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-500"
aria-label="Close drawer"
class='ml-auto rounded-md p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-500'
aria-label='Close drawer'
>
<FiX class="h-5 w-5" />
<FiX class='h-5 w-5' />
</ArkDialog.CloseTrigger>
</div>

{/* Body */}
<div class="min-h-0 flex-1 overflow-auto">{children()}</div>
<div class='min-h-0 flex-1 overflow-auto'>{children()}</div>
</ArkDialog.Content>
</ArkDialog.Positioner>
</Portal>
Expand Down
26 changes: 18 additions & 8 deletions packages/ui/src/components/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/

import { Tabs } from '@ark-ui/solid/tabs';
import { Component, For, Show, JSX } from 'solid-js';
import { Component, For, Show, JSX, createMemo } from 'solid-js';

export interface TabDefinition {
value: string;
Expand Down Expand Up @@ -41,25 +41,35 @@ const TabsComponent: Component<TabsProps> = props => {
}
};

// Conditionally provide value or defaultValue (but not both)
// When value is provided, use controlled mode
// Otherwise, use defaultValue for uncontrolled mode
const rootProps = createMemo(() => {
const currentValue = value();
if (currentValue !== undefined && currentValue !== null) {
return { value: currentValue, onValueChange: handleValueChange };
}
return {
defaultValue: defaultValue() || tabsList()[0]?.value,
onValueChange: handleValueChange,
};
});

return (
<Tabs.Root
value={value()}
defaultValue={defaultValue() || tabsList()[0]?.value}
onValueChange={handleValueChange}
>
<Tabs.Root {...rootProps()}>
<Tabs.List class='flex overflow-x-auto rounded-t-lg border-b border-gray-200 bg-white'>
<For each={tabsList()}>
{tab => (
<Tabs.Trigger
value={tab.value}
class='flex items-center gap-2 border-b-2 border-transparent px-4 py-3 text-sm font-medium whitespace-nowrap text-gray-600 transition-colors hover:bg-gray-50 hover:text-gray-900 data-[selected]:border-blue-600 data-[selected]:bg-blue-50/50 data-[selected]:text-blue-600'
class='flex items-center gap-2 border-b-2 border-transparent px-4 py-3 text-sm font-medium whitespace-nowrap text-gray-600 transition-colors hover:bg-gray-50 hover:text-gray-900'
>
<Show when={tab.icon}>
<span class='h-4 w-4'>{tab.icon}</span>
</Show>
{tab.label}
<Show when={tab.count !== undefined || tab.getCount}>
<span class='ml-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600 data-[selected]:bg-blue-100 data-[selected]:text-blue-700'>
<span class='ml-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600'>
{tab.getCount ? tab.getCount() : tab.count}
</span>
</Show>
Expand Down
22 changes: 15 additions & 7 deletions packages/ui/src/components/Tour.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,13 +150,21 @@ export interface TourProviderProps {
export const TourProvider: Component<TourProviderProps> = props => {
const [local, machineProps] = splitProps(props, ['children']);

const service = useMachine(tour.machine, () => ({
id: createUniqueId(),
closeOnInteractOutside: true,
closeOnEscape: true,
keyboardNavigation: true,
...machineProps,
}) as Parameters<typeof useMachine<typeof tour.machine>>[1] extends (..._args: any[]) => infer R ? R : never);
const service = useMachine(
tour.machine,
() =>
({
id: createUniqueId(),
closeOnInteractOutside: true,
closeOnEscape: true,
keyboardNavigation: true,
...machineProps,
}) as Parameters<typeof useMachine<typeof tour.machine>>[1] extends (
(..._args: any[]) => infer R
) ?
R
: never,
);

const api = createMemo(() => tour.connect(service, normalizeProps) as unknown as TourApi);

Expand Down
10 changes: 5 additions & 5 deletions packages/ui/src/components/__tests__/RadioGroup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ describe('RadioGroup', () => {

const inputs = container.querySelectorAll('input[type="radio"]');
const option2Input = Array.from(inputs).find(
input => (input as HTMLInputElement).value === 'option2'
input => (input as HTMLInputElement).value === 'option2',
) as HTMLInputElement;
expect(option2Input).toBeDefined();
expect(option2Input.checked).toBe(true);
Expand All @@ -79,7 +79,7 @@ describe('RadioGroup', () => {

const inputs = container.querySelectorAll('input[type="radio"]');
const option2Input = Array.from(inputs).find(
input => (input as HTMLInputElement).value === 'option2'
input => (input as HTMLInputElement).value === 'option2',
) as HTMLInputElement;
expect(option2Input).toBeDefined();
expect(option2Input.checked).toBe(true);
Expand All @@ -94,7 +94,7 @@ describe('RadioGroup', () => {

const inputs = container.querySelectorAll('input[type="radio"]');
const option3Input = Array.from(inputs).find(
input => (input as HTMLInputElement).value === 'option3'
input => (input as HTMLInputElement).value === 'option3',
) as HTMLInputElement;
expect(option3Input).toBeDefined();
expect(option3Input.checked).toBe(true);
Expand Down Expand Up @@ -158,7 +158,7 @@ describe('RadioGroup', () => {

const inputs = container.querySelectorAll('input[type="radio"]');
const opt2Input = Array.from(inputs).find(
input => (input as HTMLInputElement).value === 'opt2'
input => (input as HTMLInputElement).value === 'opt2',
) as HTMLInputElement;
expect(opt2Input).toBeDefined();
expect(opt2Input.disabled).toBe(true);
Expand Down Expand Up @@ -230,7 +230,7 @@ describe('RadioGroup', () => {

const inputs = container.querySelectorAll('input[type="radio"]');
const option2Input = Array.from(inputs).find(
input => (input as HTMLInputElement).value === 'option2'
input => (input as HTMLInputElement).value === 'option2',
) as HTMLInputElement;
expect(option2Input).toBeDefined();
expect(option2Input.checked).toBe(true);
Expand Down
13 changes: 13 additions & 0 deletions packages/web/src/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,19 @@
text-align: left;
}

/* Tabs - Selected tab styling */
[data-scope='tabs'][data-part='trigger'][data-selected] {
border-bottom-color: rgb(37 99 235); /* blue-600 */
background-color: rgb(239 246 255 / 0.5); /* blue-50/50 */
color: rgb(37 99 235); /* blue-600 */
}

/* Tabs - Selected tab badge/count styling (badge inside selected trigger) */
[data-scope='tabs'][data-part='trigger'][data-selected] span.rounded-full {
background-color: rgb(219 234 254) !important; /* blue-100 */
color: rgb(29 78 216) !important; /* blue-700 */
}

/* PDF.js text layer styles for text selection */
.pdf-text-layer {
position: absolute;
Expand Down
48 changes: 2 additions & 46 deletions packages/web/src/primitives/__tests__/useProject.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,26 +190,6 @@ describe('useProject - Study CRUD Operations', () => {
});
});

it('should not create study if not synced', async () => {
projectStore.getConnectionState.mockReturnValue({
connected: false,
connecting: false,
synced: false,
error: null,
});

createRoot(async dispose => {
cleanup = dispose;
const project = useProject('local-test');

await new Promise(resolve => setTimeout(resolve, 10));

const studyId = project.createStudy('Test Study');

expect(studyId).toBeNull();
});
});

it('should update a study', async () => {
await new Promise(resolveTest => {
createRoot(async dispose => {
Expand Down Expand Up @@ -288,19 +268,18 @@ describe('useProject - PDF Operations', () => {
cleanup = dispose;
const project = useProject('local-test');

await new Promise(resolve => setTimeout(resolve, 10));

const studyId = project.createStudy('Test Study');
projectStore.setProjectData.mockClear();

project.addPdfToStudy(studyId, {
const pdfId = project.addPdfToStudy(studyId, {
fileName: 'test.pdf',
key: 'r2-storage-key',
size: 123456,
uploadedBy: 'user-1',
uploadedAt: Date.now(),
});

expect(pdfId).toBeTruthy();
expect(projectStore.setProjectData).toHaveBeenCalled();
});
});
Expand All @@ -310,8 +289,6 @@ describe('useProject - PDF Operations', () => {
cleanup = dispose;
const project = useProject('local-test');

await new Promise(resolve => setTimeout(resolve, 10));

const studyId = project.createStudy('Test Study');

project.addPdfToStudy(studyId, {
Expand Down Expand Up @@ -363,27 +340,6 @@ describe('useProject - Checklist Operations', () => {
});
});

it('should not create checklist if not synced', async () => {
projectStore.getConnectionState.mockReturnValue({
connected: false,
connecting: false,
synced: false,
error: null,
});

createRoot(async dispose => {
cleanup = dispose;
const project = useProject('local-test');

await new Promise(resolve => setTimeout(resolve, 10));

const studyId = project.createStudy('Test Study');
const checklistId = project.createChecklist(studyId);

expect(checklistId).toBeNull();
});
});

it('should update checklist', async () => {
createRoot(async dispose => {
cleanup = dispose;
Expand Down
18 changes: 7 additions & 11 deletions packages/web/src/primitives/useProject/checklists.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function createChecklistOperations(projectId, getYDoc, isSynced) {
*/
function createChecklist(studyId, type = 'AMSTAR2', assignedTo = null) {
const ydoc = getYDoc();
if (!ydoc || !isSynced()) return null;
if (!ydoc) return null;

const studiesMap = ydoc.getMap('reviews');
const studyYMap = studiesMap.get(studyId);
Expand Down Expand Up @@ -204,7 +204,7 @@ export function createChecklistOperations(projectId, getYDoc, isSynced) {
*/
function updateChecklist(studyId, checklistId, updates) {
const ydoc = getYDoc();
if (!ydoc || !isSynced()) return;
if (!ydoc) return;

const studiesMap = ydoc.getMap('reviews');
const studyYMap = studiesMap.get(studyId);
Expand All @@ -229,7 +229,7 @@ export function createChecklistOperations(projectId, getYDoc, isSynced) {
*/
function deleteChecklist(studyId, checklistId) {
const ydoc = getYDoc();
if (!ydoc || !isSynced()) return;
if (!ydoc) return;

const studiesMap = ydoc.getMap('reviews');
const studyYMap = studiesMap.get(studyId);
Expand Down Expand Up @@ -370,7 +370,7 @@ export function createChecklistOperations(projectId, getYDoc, isSynced) {
*/
function updateChecklistAnswer(studyId, checklistId, key, data) {
const ydoc = getYDoc();
if (!ydoc || !isSynced()) return;
if (!ydoc) return;

const studiesMap = ydoc.getMap('reviews');
const studyYMap = studiesMap.get(studyId);
Expand Down Expand Up @@ -521,13 +521,9 @@ export function createChecklistOperations(projectId, getYDoc, isSynced) {
}

// Create note if it doesn't exist (backward compatibility)
if (isSynced()) {
const newNote = new Y.Text();
questionYMap.set('note', newNote);
return newNote;
}

return null;
const newNote = new Y.Text();
questionYMap.set('note', newNote);
return newNote;
}

return {
Expand Down
Loading