From e822f6f58f42ac7e9425a0dc31b7cdaa2bfe7e1b Mon Sep 17 00:00:00 2001 From: byGalax Date: Tue, 2 Jun 2026 20:22:28 +0200 Subject: [PATCH] docs(plan): message-list / scroll rewrite implementation plan Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-02-message-list-scroll-rewrite.md | 673 ++++++++++++++++++ 1 file changed, 673 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-message-list-scroll-rewrite.md diff --git a/docs/superpowers/plans/2026-06-02-message-list-scroll-rewrite.md b/docs/superpowers/plans/2026-06-02-message-list-scroll-rewrite.md new file mode 100644 index 0000000..8c7b56c --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-message-list-scroll-rewrite.md @@ -0,0 +1,673 @@ +# Message-List / Scroll Rewrite — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the `react-virtuoso` message list with a TanStack-Virtual list that opens/switches chats flicker-free (Discord-like), preserving every existing behavior. + +**Architecture:** Pure scroll-decision logic (`scrollController.ts`, unit-tested) + an isolated virtualization component (`MessageList.tsx`, TanStack Virtual, deferred reveal) + `ConversationPage` wiring. The flicker is killed by keeping the list hidden until messages+reactions+divider are stable, then anchoring before paint. + +**Tech Stack:** React 18, TypeScript, `@tanstack/react-virtual` (new), vitest, electron-vite. + +Spec: `docs/superpowers/specs/2026-06-02-message-list-scroll-rewrite-design.md` + +--- + +## File Structure + +- Create: `apps/desktop/src/lib/scrollController.ts` — pure scroll math (no DOM/React). +- Create: `apps/desktop/src/lib/scrollController.test.ts` — vitest unit tests. +- Create: `apps/desktop/src/components/MessageList.tsx` — TanStack virtual list + reveal/stick/load-older. Exports `MessageList`, `MessageListHandle`, `VirtuosoRow` is imported from ConversationPage's shared type (moved in Task 5). +- Modify: `apps/desktop/src/lib/useMessageReactions.ts` — add `ready` flag for the reveal gate. +- Modify: `apps/desktop/src/pages/ConversationPage.tsx` — export the row type, swap `` for ``, drive the handle, pass `ready`. +- Modify: `apps/desktop/package.json` — add `@tanstack/react-virtual`; remove `react-virtuoso` (Task 8). + +--- + +## Task 1: Add the TanStack Virtual dependency + +**Files:** +- Modify: `apps/desktop/package.json` + +- [ ] **Step 1: Install** + +Run (from repo root `chat-app/`): +```bash +pnpm --filter @chat-app/desktop add @tanstack/react-virtual@^3.10.0 +``` +Expected: adds `@tanstack/react-virtual` to `apps/desktop/package.json` dependencies; lockfile updated. + +- [ ] **Step 2: Verify it resolves** + +Run: `pnpm --filter @chat-app/desktop exec node -e "require.resolve('@tanstack/react-virtual'); console.log('ok')"` +Expected: `ok` + +- [ ] **Step 3: Commit** + +```bash +git add apps/desktop/package.json pnpm-lock.yaml +git commit -m "build(desktop): add @tanstack/react-virtual" +``` + +--- + +## Task 2: Pure scroll-decision logic (TDD) + +**Files:** +- Create: `apps/desktop/src/lib/scrollController.ts` +- Test: `apps/desktop/src/lib/scrollController.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```ts +// apps/desktop/src/lib/scrollController.test.ts +import { describe, expect, it } from 'vitest'; +import { isNearBottom, isNearTop, resolveInitialAnchor } from './scrollController'; + +const m = (scrollTop: number, scrollHeight: number, clientHeight: number) => ({ + scrollTop, + scrollHeight, + clientHeight, +}); + +describe('isNearBottom', () => { + it('true exactly at the bottom', () => { + expect(isNearBottom(m(900, 1000, 100), 64)).toBe(true); + }); + it('true within threshold', () => { + expect(isNearBottom(m(860, 1000, 100), 64)).toBe(true); + }); + it('false beyond threshold', () => { + expect(isNearBottom(m(800, 1000, 100), 64)).toBe(false); + }); +}); + +describe('isNearTop', () => { + it('true at top', () => { + expect(isNearTop(m(0, 1000, 100), 64)).toBe(true); + }); + it('false past threshold', () => { + expect(isNearTop(m(200, 1000, 100), 64)).toBe(false); + }); +}); + +describe('resolveInitialAnchor', () => { + it('anchors to last row at end by default (no saved position)', () => { + expect(resolveInitialAnchor(null, 50)).toEqual({ index: 49, align: 'end' }); + }); + it('anchors to bottom when saved position stuck to bottom', () => { + expect(resolveInitialAnchor({ topmostIndex: 10, stickToBottom: true }, 50)).toEqual({ + index: 49, + align: 'end', + }); + }); + it('restores the saved row at the top when scrolled up', () => { + expect(resolveInitialAnchor({ topmostIndex: 12, stickToBottom: false }, 50)).toEqual({ + index: 12, + align: 'start', + }); + }); + it('clamps a stale saved index to the current row count', () => { + expect(resolveInitialAnchor({ topmostIndex: 999, stickToBottom: false }, 50)).toEqual({ + index: 49, + align: 'start', + }); + }); + it('handles an empty list', () => { + expect(resolveInitialAnchor(null, 0)).toEqual({ index: 0, align: 'end' }); + }); +}); +``` + +- [ ] **Step 2: Run, verify FAIL** + +Run: `pnpm --filter @chat-app/desktop exec vitest run src/lib/scrollController.test.ts` +Expected: FAIL — "Failed to resolve import './scrollController'". + +- [ ] **Step 3: Implement** + +```ts +// apps/desktop/src/lib/scrollController.ts +// Pure, DOM-free scroll-decision logic for MessageList. Unit-tested so the +// tricky math is verified without a browser (jsdom has no layout). + +export interface ScrollMetrics { + scrollTop: number; + scrollHeight: number; + clientHeight: number; +} + +/** Distance from the bottom edge is within `threshold` px. */ +export function isNearBottom(m: ScrollMetrics, threshold: number): boolean { + return m.scrollHeight - (m.scrollTop + m.clientHeight) <= threshold; +} + +/** Scroll offset is within `threshold` px of the top. */ +export function isNearTop(m: ScrollMetrics, threshold: number): boolean { + return m.scrollTop <= threshold; +} + +export interface SavedPosition { + topmostIndex: number; + stickToBottom: boolean; +} + +export interface Anchor { + index: number; + align: 'start' | 'end'; +} + +/** + * Where a freshly-opened chat should start. + * - default / "left at bottom" → last row, aligned to the viewport bottom. + * - "left scrolled up" → the saved top-most row, aligned to the viewport top + * (clamped in case the cached row count shrank). + */ +export function resolveInitialAnchor(saved: SavedPosition | null, rowCount: number): Anchor { + if (rowCount <= 0) return { index: 0, align: 'end' }; + if (saved && !saved.stickToBottom) { + const index = Math.max(0, Math.min(saved.topmostIndex, rowCount - 1)); + return { index, align: 'start' }; + } + return { index: rowCount - 1, align: 'end' }; +} +``` + +- [ ] **Step 4: Run, verify PASS** + +Run: `pnpm --filter @chat-app/desktop exec vitest run src/lib/scrollController.test.ts` +Expected: PASS (11 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/desktop/src/lib/scrollController.ts apps/desktop/src/lib/scrollController.test.ts +git commit -m "feat(desktop): pure scroll-decision logic for new message list" +``` + +--- + +## Task 3: Reveal-gate flag on `useMessageReactions` + +**Files:** +- Modify: `apps/desktop/src/lib/useMessageReactions.ts` + +Reactions are the main post-paint height changer. The list reveal waits on their first +fetch, so add a `ready` flag that is true once reactions for the current message-id set +have been fetched (or there are no messages). + +- [ ] **Step 1: Add `ready` to the result type + state** + +In `UseMessageReactionsResult` add: +```ts + ready: boolean; +``` +After `const [rows, setRows] = useState([]);` add: +```ts + const [readyKey, setReadyKey] = useState(null); +``` + +- [ ] **Step 2: Set the key after each fetch** + +Replace the `refresh` callback body so both branches stamp `readyKey`: +```ts + const refresh = useCallback(async () => { + if (messageIds.length === 0) { + setRows([]); + setReadyKey(idsKey); + return; + } + try { + const data = await listReactionsForMessages(supabase, messageIds); + setRows(data); + } catch (err: unknown) { + console.error('listReactionsForMessages failed', err); + } finally { + setReadyKey(idsKey); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [idsKey]); +``` + +- [ ] **Step 3: Derive + return `ready`** + +Before the `return`: +```ts + const ready = readyKey === idsKey; +``` +And add `ready` to the returned object: +```ts + return { byMessage, toggle, voteExclusive, ready }; +``` + +- [ ] **Step 4: Typecheck** + +Run: `pnpm --filter @chat-app/desktop typecheck` +Expected: PASS (no output). + +- [ ] **Step 5: Commit** + +```bash +git add apps/desktop/src/lib/useMessageReactions.ts +git commit -m "feat(desktop): expose reactions reveal-gate flag (ready)" +``` + +--- + +## Task 4: Export the shared row type from ConversationPage + +**Files:** +- Modify: `apps/desktop/src/pages/ConversationPage.tsx` + +`MessageList` needs the row union. Export it from ConversationPage (smallest change; +the type already lives there). + +- [ ] **Step 1: Export the type** + +Change the `type VirtuosoRow = …` declaration (near the top of the file) to: +```ts +export type VirtuosoRow = + | { kind: 'loader'; key: string } + | { kind: 'message'; key: string; message: DecryptedMessage; idx: number } + | { kind: 'pending'; key: string; item: OutboxItem }; +``` + +- [ ] **Step 2: Typecheck** + +Run: `pnpm --filter @chat-app/desktop typecheck` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add apps/desktop/src/pages/ConversationPage.tsx +git commit -m "refactor(desktop): export VirtuosoRow type for MessageList" +``` + +--- + +## Task 5: The `MessageList` component + +**Files:** +- Create: `apps/desktop/src/components/MessageList.tsx` + +This is the integration unit. It is verified by typecheck here and **visually in dev** +in Task 7 (jsdom can't layout-test it). The TanStack specifics (scrollToIndex timing, +prepend offset) are the parts to refine during dev iteration. + +- [ ] **Step 1: Implement** + +```tsx +// apps/desktop/src/components/MessageList.tsx +import { useVirtualizer } from '@tanstack/react-virtual'; +import { + forwardRef, + useCallback as _unused, // placeholder removed below +} from 'react'; +``` +> NOTE for the implementer: write the file with the imports below (the line above is +> illustrative only — do not keep it). Full file: + +```tsx +import { useVirtualizer } from '@tanstack/react-virtual'; +import { + forwardRef, + useCallback, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from 'react'; + +import { isNearBottom, isNearTop, resolveInitialAnchor, type Anchor } from '../lib/scrollController'; +import type { VirtuosoRow } from '../pages/ConversationPage'; + +export interface MessageListHandle { + scrollToBottom(behavior?: ScrollBehavior): void; + scrollToRow(index: number, align?: 'center' | 'end', behavior?: ScrollBehavior): void; +} + +export interface MessageListProps { + rows: VirtuosoRow[]; + renderRow: (index: number, row: VirtuosoRow) => ReactNode; + computeKey: (row: VirtuosoRow) => string; + initialAnchor: { type: 'bottom' } | { type: 'row'; index: number }; + /** Reveal gate — list stays hidden behind a spinner until true (no flicker). */ + ready: boolean; + estimateRowHeight?: number; + atBottomThreshold?: number; + onReachTop?: () => void; + onAtBottomChange?: (atBottom: boolean) => void; + onTopRowChange?: (topIndex: number) => void; +} + +export const MessageList = forwardRef(function MessageList( + { + rows, + renderRow, + computeKey, + initialAnchor, + ready, + estimateRowHeight = 64, + atBottomThreshold = 64, + onReachTop, + onAtBottomChange, + onTopRowChange, + }, + ref, +) { + const scrollElRef = useRef(null); + const [revealed, setRevealed] = useState(false); + const atBottomRef = useRef(true); + // Load-older preservation: remember scrollHeight + first key across renders. + const prevFirstKeyRef = useRef(null); + const prevScrollHeightRef = useRef(0); + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollElRef.current, + estimateSize: () => estimateRowHeight, + overscan: 8, + getItemKey: (index) => computeKey(rows[index]!), + }); + + const metrics = () => { + const el = scrollElRef.current; + return el + ? { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight } + : { scrollTop: 0, scrollHeight: 0, clientHeight: 0 }; + }; + + const applyAnchor = useCallback( + (anchor: Anchor) => { + virtualizer.scrollToIndex(anchor.index, { align: anchor.align }); + // Re-apply on the next frame: dynamic measurement settles after the first + // paint, so a single scrollToIndex can land a few px off. The list is still + // hidden here, so this correction is never visible. + requestAnimationFrame(() => virtualizer.scrollToIndex(anchor.index, { align: anchor.align })); + }, + [virtualizer], + ); + + // Deferred reveal: when ready, anchor (before paint) then reveal. + useLayoutEffect(() => { + if (!ready || revealed || rows.length === 0) return; + const anchor: Anchor = + initialAnchor.type === 'bottom' + ? { index: rows.length - 1, align: 'end' } + : { index: Math.max(0, Math.min(initialAnchor.index, rows.length - 1)), align: 'start' }; + applyAnchor(anchor); + atBottomRef.current = initialAnchor.type === 'bottom'; + onAtBottomChange?.(atBottomRef.current); + requestAnimationFrame(() => setRevealed(true)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ready, rows.length]); + + // Stick-to-bottom: when content grows and we were at the bottom, re-pin. + useLayoutEffect(() => { + if (!revealed) return; + if (atBottomRef.current) { + virtualizer.scrollToIndex(rows.length - 1, { align: 'end' }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rows.length, virtualizer.getTotalSize()]); + + // Load-older preservation: if rows were prepended (first key changed and count + // grew), restore scrollTop by the height delta so the viewport stays put. + useLayoutEffect(() => { + const firstKey = rows.length > 0 ? computeKey(rows[0]!) : null; + const el = scrollElRef.current; + if (el && revealed && prevFirstKeyRef.current && firstKey !== prevFirstKeyRef.current) { + const delta = el.scrollHeight - prevScrollHeightRef.current; + if (delta > 0 && el.scrollTop < atBottomThreshold) { + el.scrollTop += delta; + } + } + prevFirstKeyRef.current = firstKey; + prevScrollHeightRef.current = el?.scrollHeight ?? 0; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rows]); + + const handleScroll = useCallback(() => { + const m = metrics(); + const atBottom = isNearBottom(m, atBottomThreshold); + if (atBottom !== atBottomRef.current) { + atBottomRef.current = atBottom; + onAtBottomChange?.(atBottom); + } + if (isNearTop(m, atBottomThreshold * 4)) onReachTop?.(); + const first = virtualizer.getVirtualItems()[0]; + if (first) onTopRowChange?.(first.index); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [atBottomThreshold, onAtBottomChange, onReachTop, onTopRowChange, virtualizer]); + + useImperativeHandle( + ref, + () => ({ + scrollToBottom: () => { + atBottomRef.current = true; + virtualizer.scrollToIndex(rows.length - 1, { align: 'end' }); + }, + scrollToRow: (index, align = 'center') => { + virtualizer.scrollToIndex(index, { align }); + }, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [virtualizer, rows.length], + ); + + const items = virtualizer.getVirtualItems(); + + return ( +
+
+ {items.map((vi) => ( +
+ {renderRow(vi.index, rows[vi.index]!)} +
+ ))} +
+ {/* 12px bottom breathing space (matches the old Footer). */} +
+
+ ); +}); +``` +> Implementer note: delete the illustrative first `import` snippet; keep only the full +> file. The `requestAnimationFrame` timing in `applyAnchor`/reveal is the most likely +> spot to refine during dev (Task 7). + +- [ ] **Step 2: Typecheck** + +Run: `pnpm --filter @chat-app/desktop typecheck` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add apps/desktop/src/components/MessageList.tsx +git commit -m "feat(desktop): TanStack-Virtual MessageList with deferred reveal" +``` + +--- + +## Task 6: Wire `MessageList` into `ConversationPage` + +**Files:** +- Modify: `apps/desktop/src/pages/ConversationPage.tsx` + +- [ ] **Step 1: Imports + reveal gate** + +Replace the `react-virtuoso` import with: +```ts +import { MessageList, type MessageListHandle } from '../components/MessageList'; +``` +Capture the reactions `ready` flag — change the `useMessageReactions` destructure to also pull `ready`: +```ts + const { + byMessage: reactionsByMessage, + toggle: toggleReaction, + voteExclusive: votePoll, + ready: reactionsReady, + } = useMessageReactions(messageIds, session?.user.id); +``` +Add a reveal gate with a 300ms max-timeout fallback (so empty/slow reactions never hang): +```ts + const [revealTimedOut, setRevealTimedOut] = useState(false); + useEffect(() => { + if (!id || loading || messages.length === 0) return; + const t = window.setTimeout(() => setRevealTimedOut(true), 300); + return () => window.clearTimeout(t); + }, [id, loading, messages.length]); + const listReady = !loading && messages.length > 0 && (reactionsReady || revealTimedOut); +``` + +- [ ] **Step 2: Replace the `virtuosoRef` type + handle** + +Change: +```ts + const virtuosoRef = useRef(null); +``` +to: +```ts + const listRef = useRef(null); +``` +Replace every `virtuosoRef.current?.scrollToIndex({ index: 'LAST', align: 'end', behavior })` +call (in `jumpToBottom`, the pending-snap effect, `snapToBottom`) with: +```ts + listRef.current?.scrollToBottom('auto'); +``` +Replace the `jumpToMessage` scroll (`virtuosoRef.current?.scrollToIndex({ index: rowIndex, align: 'center', behavior: 'smooth' })`) with: +```ts + listRef.current?.scrollToRow(rowIndex, 'center', 'smooth'); +``` + +- [ ] **Step 3: Compute `initialAnchor`** + +Replace the `initialTopMostIndex` `useMemo` (the `IndexLocationWithAlign` one from the +earlier hotfix) with: +```ts + const initialAnchor = useMemo<{ type: 'bottom' } | { type: 'row'; index: number }>(() => { + const saved = savedPositionRef.current; + if (saved && !saved.stickToBottom) return { type: 'row', index: saved.topmostIndex }; + return { type: 'bottom' }; + }, []); +``` +Remove the now-unused `IndexLocationWithAlign` import. + +- [ ] **Step 4: Swap the JSX** + +Replace the entire `` element with: +```tsx + row.key} + initialAnchor={initialAnchor} + atBottomThreshold={250} + onReachTop={handleStartReached} + onAtBottomChange={handleAtBottomStateChange} + onTopRowChange={(topIndex) => handleRangeChanged({ startIndex: topIndex, endIndex: topIndex })} + renderRow={(_index, row) => { + // ...exact same body the old `itemContent` had (loader / pending / + // message branches) — move it verbatim from the deleted . + return renderConversationRow(row); + }} + /> +``` +Move the old `itemContent` body into a local `renderConversationRow(row)` helper (or inline it) so the message/loader/pending branches are unchanged. `handleRangeChanged` already accepts `{ startIndex, endIndex }`. + +- [ ] **Step 5: Typecheck + unit tests** + +Run: `pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/desktop test` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/desktop/src/pages/ConversationPage.tsx +git commit -m "feat(desktop): use MessageList in ConversationPage (replace react-virtuoso)" +``` + +--- + +## Task 7: Dev verification (with the user) — iterate until smooth + +**Files:** none (runtime verification) + +- [ ] **Step 1: Run the dev build** + +User runs (in `chat-app/`): `pnpm desktop:dev` + +- [ ] **Step 2: Verify behaviors live** + +Switch between several chats repeatedly and confirm, using the `SCROLL_DEBUG` console +output where helpful: +- No jump and no multi-flicker on chat switch (opens cleanly at the bottom / saved row). +- New message while at bottom auto-scrolls; while scrolled up shows the pill. +- Unread divider present without a later shift. +- Scroll to top loads older without the viewport jumping. +- Jump-to-message (reply tap / pinned / search) scrolls to the target. +- Sent/pending message snaps to bottom. + +- [ ] **Step 3: Refine** + +If any behavior is off, adjust `MessageList.tsx` (most likely the `applyAnchor`/reveal +`requestAnimationFrame` timing or the stick-to-bottom effect) and re-verify. Commit each +refinement: +```bash +git commit -am "fix(desktop): refine MessageList " +``` + +--- + +## Task 8: Cleanup + release 0.21.6 + +**Files:** +- Modify: `apps/desktop/src/pages/ConversationPage.tsx` (remove instrumentation) +- Modify: `apps/desktop/package.json` (remove `react-virtuoso`) + +- [ ] **Step 1: Remove the `SCROLL_DEBUG` instrumentation** + +Delete the `SCROLL_DEBUG`/`dbgNow`/`dbgLog` block, the render-logger `useEffect`, and the +`dbgLog(...)` calls inside `handleRangeChanged` / `handleAtBottomStateChange`. + +- [ ] **Step 2: Remove the old dependency** + +Run: `pnpm --filter @chat-app/desktop remove react-virtuoso` +Then confirm no references remain: +Run: `grep -rn "react-virtuoso\|Virtuoso\b" apps/desktop/src || echo "clean"` +Expected: `clean`. + +- [ ] **Step 3: Typecheck + tests + commit** + +Run: `pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/desktop test` +Expected: PASS. +```bash +git add -A +git commit -m "chore(desktop): drop react-virtuoso + scroll debug instrumentation" +``` + +- [ ] **Step 4: Release** + +Run (from `chat-app/`, tree clean): `node scripts/release.mjs 0.21.6 "- Nachrichtenliste komplett überarbeitet: Chat-Wechsel öffnet jetzt ruckel- und flackerfrei direkt unten\n- Älteren Verlauf laden springt nicht mehr"` +Then verify `latest.yml` shows 0.21.6 on `update.netralax.de` **and** `update.netralax.cloud`. + +--- + +## Self-Review + +- **Spec coverage:** deferred reveal (Tasks 3,5,6) ✓; TanStack virtualization (Tasks 1,5) ✓; isolation into MessageList + scrollController (Tasks 2,5) ✓; stick-to-bottom (Task 5) ✓; load-older preservation (Task 5) ✓; preserved behaviors incl. jump-to-message/pill/divider/pending (Task 6) ✓; pure-logic unit tests (Task 2) ✓; dev verification (Task 7) ✓; cleanup + release (Task 8) ✓. +- **Placeholders:** the only prose-only steps are the deliberately runtime Task 7 (no code possible) and the "move itemContent verbatim" in Task 6 Step 4 (the body is large and unchanged — copying it verbatim, not rewriting). The illustrative throwaway import in Task 5 Step 1 is explicitly flagged for deletion. +- **Type consistency:** `MessageListHandle.scrollToBottom/scrollToRow`, `VirtuosoRow`, `Anchor`, `ScrollMetrics`, `resolveInitialAnchor` signatures are consistent across Tasks 2/5/6. `ready` flag added in Task 3 is consumed in Task 6.