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
2 changes: 1 addition & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ request metadata such as your IP address and browser headers when those assets l
## Optional Explore video provider

Explore's live Copilot dock only projects activity already present in the active
end-to-end encrypted session. Its bundled shuffled Discover deck, saved-card state,
end-to-end encrypted session. Its bundled shuffled Discover deck, reading progress,
Play activities, Unwind activities, and scores remain local to the phone. If a
deployment configures the optional Watch widget, Weft displays a disclosure and does
not contact that provider until you explicitly choose to load the video feed.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ while you use the phone.
- **Explicit authority.** The phone receives the same approval boundaries exposed by the
underlying Copilot session. Weft does not invent an automatic approval policy.
- **Explore stays local by default.** The live Copilot dock projects data already present
in the active encrypted session, while the shuffled Discover deck, saved-card signals,
in the active encrypted session, while the shuffled Discover deck, reading progress,
Play, Unwind, and scores remain on the phone. Attention states return to the canonical
chat controls. The optional Watch provider loads only after an explicit tap and receives
no Weft prompt, repository, filename, tool, or session context.
Expand Down
83 changes: 14 additions & 69 deletions mobile/src/ui/explore/ExploreScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
Expand Down Expand Up @@ -83,7 +82,6 @@ const STORAGE_KEY = 'weft.explore.v1';

interface ExploreStoredState {
lastCategory?: ExploreCategory;
savedCardIds?: string[];
completedCardIds?: string[];
discoverDay?: string;
discoverCycle?: number;
Expand All @@ -95,7 +93,6 @@ interface ExploreStoredState {

const DEFAULT_STORED_STATE: Required<ExploreStoredState> = {
lastCategory: 'discover',
savedCardIds: [],
completedCardIds: [],
discoverDay: '',
discoverCycle: 0,
Expand All @@ -117,11 +114,8 @@ function parseStoredState(): Required<ExploreStoredState> {
parsed.lastCategory === 'unwind'
? parsed.lastCategory
: DEFAULT_STORED_STATE.lastCategory;
return {
const state: Required<ExploreStoredState> = {
lastCategory,
savedCardIds: Array.isArray(parsed.savedCardIds)
? parsed.savedCardIds.filter((value): value is string => typeof value === 'string')
: [],
completedCardIds: Array.isArray(parsed.completedCardIds)
? parsed.completedCardIds.filter((value): value is string => typeof value === 'string')
: [],
Expand All @@ -142,6 +136,11 @@ function parseStoredState(): Required<ExploreStoredState> {
typeof parsed.signalBest === 'number' && parsed.signalBest >= 0 ? Math.floor(parsed.signalBest) : 0,
vibration: parsed.vibration === true,
};
const legacyCardPreferenceKey = 'savedCardIds';
if (legacyCardPreferenceKey in parsed) {
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(state));
}
return state;
} catch {
return DEFAULT_STORED_STATE;
}
Expand Down Expand Up @@ -324,20 +323,6 @@ function WaveGlyph(): JSX.Element {
);
}

function BookmarkGlyph({ filled }: { filled: boolean }): JSX.Element {
return (
<svg
viewBox="0 0 24 24"
fill={filled ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth="1.7"
aria-hidden="true"
>
<path d="M6.5 4.5h11v15l-5.5-3.2-5.5 3.2v-15Z" />
</svg>
);
}

export function ExploreScreen({
active,
onOpenSessions,
Expand Down Expand Up @@ -623,26 +608,7 @@ function DiscoverView({
);
const index = Math.min(persistedIndex, Math.max(0, deck.length - 1));
const card = deck[index];
const saved = new Set(stored.savedCardIds);
const pointerStart = useRef<{ x: number; y: number } | null>(null);
const viewRef = useRef<HTMLElement | null>(null);
const [scrollable, setScrollable] = useState(false);

useLayoutEffect(() => {
const element = viewRef.current;
if (!element) return undefined;
const measure = (): void => {
setScrollable(element.scrollHeight > element.clientHeight + 1);
};
measure();
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
observer?.observe(element);
window.addEventListener('resize', measure);
return () => {
observer?.disconnect();
window.removeEventListener('resize', measure);
};
}, [card.id]);

useEffect(() => {
if (
Expand Down Expand Up @@ -671,43 +637,34 @@ function DiscoverView({
onChange({ ...stored, completedCardIds: completed, discoverDay: today, discoverCycle: cycle + 1, discoverIndex: 0 });
};

const toggleSaved = (): void => {
if (!card) return;
const next = new Set(saved);
if (next.has(card.id)) next.delete(card.id);
else next.add(card.id);
onChange({ ...stored, savedCardIds: [...next] });
};

const onPointerUp = (event: ReactPointerEvent<HTMLElement>): void => {
const start = pointerStart.current;
pointerStart.current = null;
if (!start) return;
const deltaX = event.clientX - start.x;
const deltaY = event.clientY - start.y;
if (Math.abs(deltaY) < 48 || Math.abs(deltaY) <= Math.abs(deltaX) * 1.2) return;
changeCard(deltaY < 0 ? 1 : -1);
if (Math.abs(deltaX) < 48 || Math.abs(deltaX) <= Math.abs(deltaY) * 1.2) return;
changeCard(deltaX < 0 ? 1 : -1);
};

if (!card) return <div className="discover-view">No Discover cards are available.</div>;

return (
<section
ref={viewRef}
className={`discover-view${scrollable ? ' discover-view-scrollable' : ''}`}
className="discover-view"
aria-label="Discover card deck"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'ArrowUp') {
if (event.key === 'ArrowRight') {
event.preventDefault();
changeCard(1);
} else if (event.key === 'ArrowDown') {
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
changeCard(-1);
}
}}
onPointerDown={(event) => {
pointerStart.current = scrollable ? null : { x: event.clientX, y: event.clientY };
pointerStart.current = { x: event.clientX, y: event.clientY };
}}
onPointerCancel={() => {
pointerStart.current = null;
Expand All @@ -727,22 +684,10 @@ function DiscoverView({
<p>{card.insight}</p>
</div>
<div className="discover-deck-actions">
<button
type="button"
className="discover-save"
aria-label={saved.has(card.id) ? `Unsave ${card.title}` : `Save ${card.title}`}
onClick={(event) => {
event.stopPropagation();
toggleSaved();
}}
>
<BookmarkGlyph filled={saved.has(card.id)} />
<span>{saved.has(card.id) ? 'Saved' : 'Save'}</span>
</button>
<span>{index + 1} / {deck.length}</span>
<div>
<button type="button" aria-label="Previous Discover card" disabled={index === 0} onClick={() => changeCard(-1)}>↓</button>
<button type="button" aria-label="Next Discover card" onClick={() => changeCard(1)}>↑</button>
<button type="button" aria-label="Previous Discover card" disabled={index === 0} onClick={() => changeCard(-1)}>←</button>
<button type="button" aria-label="Next Discover card" onClick={() => changeCard(1)}>→</button>
</div>
</div>
</article>
Expand Down
28 changes: 18 additions & 10 deletions mobile/src/ui/explore/__tests__/ExploreScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,6 @@ describe('Explore navigation and Discover deck', () => {

expect(screen.getByRole('heading', { name: 'What do you feel like?' })).toBeInTheDocument();
expect(screen.queryByText(/Pick something useful/)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/saved Discover/i)).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Open sessions' }));
expect(onOpenSessions).toHaveBeenCalledOnce();
});
Expand Down Expand Up @@ -318,35 +317,44 @@ describe('Explore navigation and Discover deck', () => {
expect(document.querySelector('.discover-deck-card h1')?.textContent).not.toBe(firstTitle);
expect(JSON.parse(localStorage.getItem('weft.explore.v1') ?? '{}').discoverIndex).toBe(1);

fireEvent.keyDown(deck, { key: 'ArrowDown' });
fireEvent.keyDown(deck, { key: 'ArrowLeft' });
expect(document.querySelector('.discover-deck-card h1')?.textContent).toBe(firstTitle);
});

it('responds only to dominant vertical swipes and keeps Save local', () => {
it('responds only to dominant horizontal swipes', () => {
renderExplore();
fireEvent.click(screen.getByRole('button', { name: 'Discover: Read something worth knowing' }));
const deck = screen.getByRole('region', { name: 'Discover card deck' });
const firstTitle = document.querySelector('.discover-deck-card h1')?.textContent;

fireEvent.pointerDown(deck, { clientX: 10, clientY: 100 });
fireEvent.pointerUp(deck, { clientX: 100, clientY: 80 });
fireEvent.pointerUp(deck, { clientX: 30, clientY: 20 });
expect(document.querySelector('.discover-deck-card h1')?.textContent).toBe(firstTitle);

fireEvent.pointerDown(deck, { clientX: 10, clientY: 100 });
fireEvent.pointerUp(deck, { clientX: 12, clientY: 20 });
fireEvent.pointerDown(deck, { clientX: 100, clientY: 100 });
fireEvent.pointerUp(deck, { clientX: 20, clientY: 98 });
expect(document.querySelector('.discover-deck-card h1')?.textContent).not.toBe(firstTitle);

fireEvent.click(screen.getByRole('button', { name: /^Save / }));
expect(localStorage.getItem('weft.explore.v1')).toContain('savedCardIds');
expect(screen.queryByRole('group', { name: 'Discover topics' })).not.toBeInTheDocument();
});

it('removes the legacy per-card preference from Explore storage', async () => {
const legacyKey = 'savedCardIds';
localStorage.setItem('weft.explore.v1', JSON.stringify({
[legacyKey]: ['legacy-card'],
lastCategory: 'discover',
}));
const user = userEvent.setup();
renderExplore();
await user.click(screen.getByRole('button', { name: /discover/i }));
expect(localStorage.getItem('weft.explore.v1')).not.toContain(legacyKey);
expect(document.querySelectorAll('.discover-deck-actions button')).toHaveLength(2);
});

it('starts a new shuffled cycle only after the current deck is exhausted', () => {
const today = new Date();
const day = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
localStorage.setItem('weft.explore.v1', JSON.stringify({
lastCategory: 'discover',
savedCardIds: [],
completedCardIds: [],
discoverDay: day,
discoverCycle: 2,
Expand Down
48 changes: 0 additions & 48 deletions mobile/src/ui/styles/explore.css
Original file line number Diff line number Diff line change
Expand Up @@ -85,23 +85,6 @@
white-space: nowrap;
}

.explore-saved-count {
position: absolute;
top: 1px;
right: 0;
display: grid;
min-width: 16px;
height: 16px;
padding: 0 4px;
place-items: center;
border: 2px solid var(--bg);
border-radius: 999px;
color: #fff;
background: var(--accent);
font-size: 9px;
font-weight: 800;
}

.agent-pulse {
display: flex;
position: relative;
Expand Down Expand Up @@ -561,23 +544,6 @@
font-weight: 800;
}

.discover-save {
position: absolute;
top: 10px;
right: 9px;
display: grid;
width: 38px;
height: 38px;
place-items: center;
border: 0;
border-radius: 11px;
color: var(--fg-faint);
background: transparent;
}

.discover-save svg { width: 18px; height: 18px; }
.discover-save:hover { color: var(--accent); background: var(--accent-soft); }

.discover-reader {
max-width: 680px;
margin: 0 auto;
Expand Down Expand Up @@ -1270,10 +1236,6 @@
padding: 10px 12px;
overflow-y: auto;
outline: none;
touch-action: pan-x;
}

.discover-view-scrollable {
touch-action: pan-y;
}

Expand Down Expand Up @@ -1371,16 +1333,6 @@
font: 700 10px var(--mono);
}

.discover-deck-actions .discover-save {
position: static;
display: inline-flex;
width: auto;
height: 38px;
padding: 0 9px;
align-items: center;
gap: 5px;
}

.discover-deck-actions > div {
display: flex;
gap: 5px;
Expand Down
3 changes: 1 addition & 2 deletions openspec/changes/add-explore-hub/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ The pulse row always has a fixed layout height. Tapping a blocked state opens th

### Bundled content uses typed local data

The 50 Discover cards will be stored as typed application data with stable identifiers, category, title, summary, estimated duration, body sections, and optional illustration metadata. Reading and saved state will be persisted locally through the existing settings/storage conventions.
The 50 Discover cards will be stored as typed application data with stable identifiers, category, title, summary, estimated duration, body sections, and optional illustration metadata. Reading progress will be persisted locally through the existing settings/storage conventions.

The initial cards use original text and local assets so Discover works offline and has no licensing or network dependency.

Expand Down Expand Up @@ -88,7 +88,6 @@ Approvals and elicitations render in a Weft-owned overlay above every Explore ca
Explore stores only:

- last selected category,
- saved Discover card IDs,
- per-card reading completion,
- local game progress and daily scores,
- unwind preferences such as vibration,
Expand Down
2 changes: 1 addition & 1 deletion openspec/changes/add-explore-hub/proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Long-running agent turns leave mobile users with little to do beyond watching a
- Add a permanent compass action to the mobile session header without changing the existing read-only Live, Quiet, or Working status line.
- Add a full-screen Explore destination with a compact always-visible Agent Pulse and four entry points: Discover, Watch, Play, and Unwind.
- Add an Agent activity view derived from existing encrypted session state, including current intent, elapsed work, tool progress, completion, approval, and connection states.
- Add 50 original bundled Discover cards with local reading and save state.
- Add 50 original bundled Discover cards with local reading progress.
- Add a lazy, isolated third-party short-video widget surface for Watch, with explicit loading, no session-context sharing, and a safe unavailable/configuration fallback.
- Add two offline games: Threadline and Signal Steps.
- Add two native rest experiences: Weave Breath and Eye Horizon.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,6 @@ Discover SHALL provide 50 original, bundled, offline-readable cards with stable
- **WHEN** the device has no network connection
- **THEN** the user can browse and read every bundled Discover card

#### Scenario: Save a card
- **WHEN** the user saves or unsaves a Discover card
- **THEN** the selection persists locally across application restarts

#### Scenario: Read a card
- **WHEN** the user opens a Discover card
- **THEN** the application presents readable typography, semantic headings, estimated duration, and a route back to the prior card position
Expand Down Expand Up @@ -116,7 +112,7 @@ Explore SHALL preserve Weft-owned approvals, elicitations, errors, and completed
Explore SHALL keep personalization and activity state local and SHALL NOT disclose private Weft session context to content providers.

#### Scenario: Persist local preferences
- **WHEN** the user changes category, saved cards, game progress, or unwind preferences
- **WHEN** the user changes category, reading progress, game progress, or unwind preferences
- **THEN** the application stores only those Explore-specific values in local application storage

#### Scenario: Load external content
Expand Down
4 changes: 2 additions & 2 deletions openspec/changes/add-explore-hub/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@

- [x] 3.1 Define the typed Discover card model and add all 50 original bundled starter cards
- [x] 3.2 Build the Discover card feed and accessible reader with estimated duration and return-position preservation
- [x] 3.3 Add local saved-card and reading-completion persistence using repository-standard storage helpers
- [x] 3.4 Add tests for catalog integrity, offline rendering, reader navigation, and saved state
- [x] 3.3 Add local reading-completion persistence using repository-standard storage helpers
- [x] 3.4 Add tests for catalog integrity, offline rendering, reader navigation, and reading state

## 4. Watch Integration

Expand Down
Loading
Loading