diff --git a/.gitignore b/.gitignore index 0db830df..b70df630 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ packages/upstream-tests/core/ *.log .DS_Store website/docs/guide/api/ +# generated zh mirrors from generate-api.ts (hand-written zh index.mdx +# files for vue-lynx/testing-library are already tracked and unaffected) +website/docs/zh/guide/api/ website/api-sidebar.json .vercel .claude/ diff --git a/docs/superpowers/plans/2026-07-14-elk-sheet-gesture-refinement.md b/docs/superpowers/plans/2026-07-14-elk-sheet-gesture-refinement.md new file mode 100644 index 00000000..4e340137 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-elk-sheet-gesture-refinement.md @@ -0,0 +1,280 @@ +# Elk Sheet Gesture Refinement 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:** Make the Elk navigation Sheet feel native on iOS by porting the current `lynx-ui` direction lock, velocity projection, rubber resistance, progress-linked backdrop, and spring settle behavior into Vue Lynx. + +**Architecture:** Keep gesture state and rendering mutations in main-thread worklets inside `Sheet.vue`. Keep angle, physics, progress, and dismissal decisions as pure functions in `gesture.ts` so Node tests exercise the same calculations. Use a hybrid input policy: the dedicated handle always owns vertical drags, while the scrollable panel claims only downward drags that begin at `scrollTop === 0`. + +**Tech Stack:** Vue Lynx SFCs, Main Thread Script worklets, `useMainThreadRef`, requestAnimationFrame, Node test runner, Rspeedy, Lynx DevTool. + +--- + +## File map + +- `examples/elk/src/components/sheet/gesture.ts`: pure gesture ownership, rubber resistance, velocity projection, progress, and spring-step calculations. +- `examples/elk/src/components/sheet/Sheet.vue`: hybrid touch surfaces, main-thread state machine, direct manipulation, spring settle, and visual structure. +- `examples/elk/src/components/NavBottom.vue`: spacing adjustment for the new grabber without changing navigation behavior. +- `examples/elk/scripts/native-compat.test.mjs`: behavioral and source-contract regression coverage. + +### Task 1: Specify gesture physics with failing tests + +**Files:** + +- Modify: `examples/elk/scripts/native-compat.test.mjs` +- Test: `examples/elk/scripts/native-compat.test.mjs` + +- [ ] **Step 1: Replace the legacy fixed-distance test with behavior tests** + +Add assertions that demonstrate the desired pure API: + +```js +test('sheet claims handle drags vertically and content drags only downward at scroll top', () => { + assert.equal(sheetGesture.shouldClaimSheetGesture?.('handle', 2, -12, 40), true); + assert.equal(sheetGesture.shouldClaimSheetGesture?.('handle', 20, 10, 0), false); + assert.equal(sheetGesture.shouldClaimSheetGesture?.('content', 2, 12, 0), true); + assert.equal(sheetGesture.shouldClaimSheetGesture?.('content', 2, -12, 0), false); + assert.equal(sheetGesture.shouldClaimSheetGesture?.('content', 2, 12, 1), false); +}); + +test('sheet uses bounded rubber resistance above the open position', () => { + assert.equal(sheetGesture.resolveSheetDrag?.(30), 30); + assert.equal(sheetGesture.resolveSheetDrag?.(-40, 80, 0.5), -16); + assert.ok(sheetGesture.resolveSheetDrag?.(-10000, 80, 0.5) > -80); +}); + +test('sheet release uses distance or projected fling travel to dismiss', () => { + assert.equal(sheetGesture.shouldDismissSheet?.(120, 0, 120), true); + assert.equal(sheetGesture.shouldDismissSheet?.(119, 0, 120), false); + assert.equal(sheetGesture.shouldDismissSheet?.(24, 900, 120), true); + assert.equal(sheetGesture.shouldDismissSheet?.(10, 1200, 120), false); + assert.equal(sheetGesture.shouldDismissSheet?.(60, -900, 120), false); +}); + +test('sheet backdrop progress follows downward travel', () => { + assert.equal(sheetGesture.sheetOpenProgress?.(-20, 600), 1); + assert.equal(sheetGesture.sheetOpenProgress?.(150, 600), 0.75); + assert.equal(sheetGesture.sheetOpenProgress?.(800, 600), 0); +}); + +test('sheet filters noisy velocity samples and integrates a stable spring step', () => { + assert.equal(sheetGesture.smoothSheetVelocity?.(0, 9, 10, 0.25), 225); + const next = sheetGesture.stepSheetSpring?.(100, 0, 0, 1 / 60); + assert.ok(next.value < 100); + assert.ok(next.velocity < 0); +}); +``` + +- [ ] **Step 2: Add a source-contract test for the hybrid surfaces** + +Require `.sheet-handle`, separate handle/content start handlers, backdrop and surface refs, layout measurement, and requestAnimationFrame settling. Also require that drag mutation remains main-thread-bound and does not use `transition: all`. + +- [ ] **Step 3: Run the native compatibility test and verify RED** + +Run: + +```bash +pnpm --dir examples/elk test:native-compat +``` + +Expected: the new gesture tests fail because the new pure helpers and hybrid Sheet structure do not exist. + +### Task 2: Implement the pure gesture model + +**Files:** + +- Modify: `examples/elk/src/components/sheet/gesture.ts` +- Test: `examples/elk/scripts/native-compat.test.mjs` + +- [ ] **Step 1: Implement angle and ownership classification** + +Keep the 8 px lock threshold and use vertical angle ranges equivalent to `lynx-ui`: + +```ts +export type SheetGestureSource = 'handle' | 'content'; + +export function shouldClaimSheetGesture( + source: SheetGestureSource, + deltaX: number, + deltaY: number, + scrollTop: number, +): boolean { + 'main thread'; + const displacement = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + if (displacement < SHEET_GESTURE_LOCK_DISTANCE) + return false; + const vertical = Math.abs(deltaY) > Math.abs(deltaX); + if (!vertical) + return false; + return source === 'handle' || (deltaY > 0 && scrollTop <= 0); +} +``` + +- [ ] **Step 2: Implement upstream-compatible rubber resistance** + +```ts +export function rubberEffect(original: number, max: number, coeff = 0.5): number { + 'main thread'; + if (original === 0 || max === 0) + return 0; + return (1 - 1 / ((Math.abs(original) * coeff) / max + 1)) * max; +} + +export function resolveSheetDrag(deltaY: number, rubberMax = 80, coeff = 0.5): number { + 'main thread'; + return deltaY >= 0 ? deltaY : -rubberEffect(-deltaY, rubberMax, coeff); +} +``` + +- [ ] **Step 3: Implement progress, velocity smoothing, projection, and release decisions** + +Use `travel = velocity² / (2 × 2000)` from `lynx-ui`, require 24 px of intentional downward travel for a fling dismissal, and retain the existing 120 px distance threshold as the non-fling path. + +- [ ] **Step 4: Implement one deterministic spring integration step** + +Expose `stepSheetSpring(value, velocity, target, deltaSeconds, stiffness = 300, damping = 30)` returning the next `{ value, velocity }`. Clamp `deltaSeconds` to `1 / 30` so a delayed frame cannot explode the simulation. + +- [ ] **Step 5: Run tests and verify the pure behavior is GREEN** + +Run: + +```bash +pnpm --dir examples/elk test:native-compat +``` + +Expected: physics assertions pass; the source-contract test remains red until `Sheet.vue` is updated. + +### Task 3: Implement hybrid main-thread dragging and motion + +**Files:** + +- Modify: `examples/elk/src/components/sheet/Sheet.vue` +- Modify: `examples/elk/src/components/NavBottom.vue` +- Test: `examples/elk/scripts/native-compat.test.mjs` + +- [ ] **Step 1: Replace the single panel ref with coordinated visual refs** + +Create main-thread refs for the surface, backdrop, measured surface height, gesture source, direction state, last touch sample, smoothed velocity, animation frame, and current translation. Keep all writes inside `'main thread'` functions. + +- [ ] **Step 2: Add the hybrid touch surfaces** + +Render this structure: + +```vue + + + + + + + + + + +``` + +The surface height remains capped by `topInset`; the fixed layer remains inset above the bottom bar and Sparkling safe area. + +- [ ] **Step 3: Implement direction locking and direct manipulation** + +On touch start, record coordinates/source without stopping an existing animation. On the first move beyond 8 px, call `shouldClaimSheetGesture`; reject horizontal/content-scroll gestures permanently, or cancel the current spring and capture the valid vertical gesture. During captured moves, use `resolveSheetDrag`, set `translateY`, and update backdrop opacity from `sheetOpenProgress`. + +- [ ] **Step 4: Implement spring settle and dismissal** + +Drive requestAnimationFrame with `stepSheetSpring`. Snap-back targets `0`; dismissal targets the measured surface height plus 32 px. Stop when distance and velocity are both below `0.5`, then call `runOnBackground(requestClose)()` only for dismissal. Cancelled gestures always spring to `0`. + +- [ ] **Step 5: Reset inline motion after leave** + +Add an `after-leave` handler that invokes a main-thread reset, restoring translation `0`, backdrop opacity `1`, and idle gesture state so reopening never inherits an off-screen inline transform. + +- [ ] **Step 6: Style the grabber and connected rubber fill** + +Use a 28 px hit area, a centered 36 × 4 px pill, the existing themed surface color, and a surface-attached 80 px fill below the panel so upward rubber travel never reveals the backdrop between the Sheet and bottom bar. Reduce `nav-sheet-content` top padding to preserve the current first-row rhythm. + +- [ ] **Step 7: Run the focused suite and verify GREEN** + +Run: + +```bash +pnpm --dir examples/elk test:native-compat +``` + +Expected: every native compatibility test passes. + +### Task 4: Verify native behavior and publish the updated bundle + +**Files:** + +- Verify: `examples/elk/dist/main.lynx.bundle` +- Verify: PR #196 checks and Vercel artifacts + +- [ ] **Step 1: Clear the Elk cache and restart the dev server** + +Run: + +```bash +rm -rf examples/elk/node_modules/.cache +pnpm --dir examples/elk dev +``` + +- [ ] **Step 2: Build both targets** + +Run: + +```bash +pnpm --dir examples/elk build +``` + +Expected: `dist/main.lynx.bundle` and `dist/main.web.bundle` both build successfully. + +- [ ] **Step 3: Verify three native gesture paths** + +Using Lynx DevTool on the Sparkling iOS simulator: + +1. Slow downward drag under the threshold springs open and restores the backdrop. +2. Short fast downward fling closes the Sheet. +3. Upward handle pull resists asymptotically and returns without exposing a gap; upward content swipe still scrolls. + +Capture the final open and post-settle states with `take-screenshot`. + +- [ ] **Step 4: Run repository-level validation** + +Run: + +```bash +pnpm lint +pnpm test:dev-smoke +``` + +Expected: both commands exit successfully. + +- [ ] **Step 5: Commit and push PR #196** + +Stage only the Sheet refinement files, commit with `feat(examples/elk): refine native sheet gestures`, and push `HEAD` to `origin/claude/elk-vue-lynx-port-rh9gpd`. + +- [ ] **Step 6: Confirm deployment artifacts** + +Wait for every PR check and Vercel deployment to pass, then confirm HTTP 200 for: + +```text +https://vue-lynx-git-claude-elk-vue-lynx-port-rh9gpd-huxpros-projects.vercel.app/examples/elk/dist/main.lynx.bundle +``` diff --git a/docs/superpowers/plans/2026-07-14-example-benchmark-navigation.md b/docs/superpowers/plans/2026-07-14-example-benchmark-navigation.md new file mode 100644 index 00000000..02412c35 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-example-benchmark-navigation.md @@ -0,0 +1,126 @@ +# Example Benchmark Navigation 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:** Move Elk under the website Benchmark section after AI Chat and relocate both examples' AI design context into their owning example directories. + +**Architecture:** Integrate current `origin/main` into the published PR #196 branch with a merge commit, preserving AI Chat while keeping the PR history stable. A small Node source-contract test owns the bilingual sidebar ordering and example-local `.impeccable.md` invariants; Rspress and Elk production builds provide integration coverage. + +**Tech Stack:** Git, Node.js test runner, TypeScript Rspress config, pnpm, Rspeedy. + +--- + +## File Map + +- `website/scripts/benchmark-navigation.test.mjs`: regression contract for bilingual ordering, Showcase removal, and example-local design files. +- `website/package.json`: exposes the focused navigation test command. +- `website/rspress.config.ts`: bilingual Benchmark sidebar entries. +- `examples/elk/.impeccable.md`: Elk-specific design context moved from the repository root. +- `examples/ai-chat/.impeccable.md`: AI Chat-specific design context moved from the repository root after merging `main`. +- `docs/superpowers/plans/2026-07-14-example-benchmark-navigation.md`: this execution record. + +### Task 1: Lock the ownership and navigation contract + +**Files:** +- Create: `website/scripts/benchmark-navigation.test.mjs` +- Modify: `website/package.json` + +- [ ] **Step 1: Add the focused test command** + +Add `"test:navigation": "node --test scripts/benchmark-navigation.test.mjs"` to `website/package.json`. + +- [ ] **Step 2: Write the failing source-contract test** + +The test reads `website/rspress.config.ts`, asserts the English and Chinese sequences contain `HackerNews`, `AI Chat`, then Elk; asserts no Showcase section remains; and verifies `examples/elk/.impeccable.md` plus `examples/ai-chat/.impeccable.md` exist while the root `.impeccable.md` does not. + +- [ ] **Step 3: Run the test and verify RED** + +Run: `pnpm --dir website test:navigation` + +Expected: FAIL because both example-local context paths and the merged AI Chat + Elk sidebar order do not yet exist. + +### Task 2: Move Elk context before integrating main + +**Files:** +- Delete: `.impeccable.md` +- Create: `examples/elk/.impeccable.md` + +- [ ] **Step 1: Move the Elk design context without changing its contents** + +Use `apply_patch` to add the existing content at `examples/elk/.impeccable.md` and delete the root file. + +- [ ] **Step 2: Commit the conflict-prevention move and test harness** + +```bash +git add website/package.json website/scripts/benchmark-navigation.test.mjs .impeccable.md examples/elk/.impeccable.md docs/superpowers/plans/2026-07-14-example-benchmark-navigation.md +git commit -m "test(website): lock benchmark navigation ownership" +``` + +### Task 3: Integrate main and localize AI Chat context + +**Files:** +- Merge: `origin/main` +- Delete: `.impeccable.md` +- Create: `examples/ai-chat/.impeccable.md` + +- [ ] **Step 1: Merge the current main branch** + +Run: `git merge --no-edit origin/main` + +Expected: merge succeeds because Elk's conflicting root context was moved first; AI Chat and current website dependencies enter the PR branch. + +- [ ] **Step 2: Move AI Chat's design context without changing its contents** + +Use `apply_patch` to add the merged root content at `examples/ai-chat/.impeccable.md` and delete the root file. + +### Task 4: Put Elk after AI Chat in both sidebars + +**Files:** +- Modify: `website/rspress.config.ts` + +- [ ] **Step 1: Update the English Benchmark list** + +Keep `AI Chat` and add `{ text: 'Elk (Mastodon Client)', link: '/guide/elk' }` immediately after it. Remove the Showcase divider/header block. + +- [ ] **Step 2: Update the Chinese Benchmark list** + +Keep `AI Chat` and add `{ text: 'Elk(Mastodon 客户端)', link: '/zh/guide/elk' }` immediately after it. Remove the 案例展示 divider/header block. + +- [ ] **Step 3: Run the focused test and verify GREEN** + +Run: `pnpm --dir website test:navigation` + +Expected: all navigation and ownership assertions pass. + +- [ ] **Step 4: Commit the integrated result** + +```bash +git add . website/rspress.config.ts examples/ai-chat/.impeccable.md +git commit -m "feat(website): classify Elk as a benchmark" +``` + +### Task 5: Verify and publish PR #196 + +**Files:** +- Verify: `website/dist/` +- Verify: `examples/elk/dist/main.lynx.bundle` + +- [ ] **Step 1: Run focused and production verification** + +```bash +pnpm --dir website test:navigation +pnpm --dir website build +pnpm --dir examples/elk test:native-compat +pnpm --dir examples/elk build +git diff --check +``` + +Expected: all commands exit 0; the website and Lynx bundle build successfully. + +- [ ] **Step 2: Push the existing PR head** + +Run: `git push origin HEAD:claude/elk-vue-lynx-port-rh9gpd` + +- [ ] **Step 3: Verify remote deployment** + +Wait for `gh pr checks 196 --repo Huxpro/vue-lynx --watch --interval 10`, then request the Vercel `/guide/elk` page and `/examples/elk/dist/main.lynx.bundle`. Confirm both return HTTP 200 and the PR is mergeable against `main`. diff --git a/docs/superpowers/specs/2026-07-13-elk-navigation-sheet-design.md b/docs/superpowers/specs/2026-07-13-elk-navigation-sheet-design.md new file mode 100644 index 00000000..aaeb9423 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-elk-navigation-sheet-design.md @@ -0,0 +1,102 @@ +# Elk Navigation Sheet Design + +## Goal + +Bring the Vue Lynx Elk mobile navigation to behavioral and visual parity with +the upstream `elk-zone/elk` bottom navigation and More sheet on Lynx Web and +native iOS Lynx Explorer. + +## Reference behavior + +The reference is upstream Elk commit `d444a59`, run locally with the mocked +development data at a 390 × 844 viewport. Guest navigation contains Explore, +Local, Federated, and More. Opening More leaves the bottom bar fixed, changes +the More icon to the primary-colored close icon, dims the timeline, and raises +a rounded sheet above the bar. The sheet height is capped so roughly 200 px of +the viewport remains outside the panel. Its content scrolls independently and +can be dismissed through the backdrop, the close button, navigation, or a +downward drag. + +The gesture refinement also follows the current `lynx-ui` Sheet implementation +from `origin/main` (package version 3.134.0). Its relevant behaviors are an 8 px +direction-lock threshold, angle-based gesture claiming, a nonlinear rubber-band +at the fully-open boundary, velocity-projected dismissal, motion that stays on +the main thread, and backdrop opacity derived from sheet progress. + +## Considered approaches + +1. Copy only the upstream CSS transition into `NavBottom.vue`. This is small, + but couples panel state, navigation, and drag behavior into one component + and makes native gesture work happen on the background thread. +2. Depend directly on `@lynx-js/lynx-ui-sheet`. This preserves the official + primitive, but it is a ReactLynx package whose hooks and context cannot be + consumed by Vue Lynx. +3. Port the official primitive's state and gesture model into a reusable Vue + Lynx component, then style its content for Elk. This keeps the sheet generic, + gives native drag work to main-thread worklets, and lets Elk own its exact + navigation appearance. This is the selected approach. + +## Architecture + +`components/sheet/Sheet.vue` owns controlled visibility, backdrop dismissal, +mount/unmount transitions, and drag-to-dismiss. It exposes a default slot and +keeps visual surface choices configurable through classes and CSS. A dedicated +28 px handle is always draggable. The scrollable content also accepts a +downward drag only while its scroll position is at the top; upward gestures and +content gestures that start while scrolled remain owned by the scroll view. + +Main-thread touch handlers direction-lock after 8 px, sample drag velocity, +update the panel, handle, rubber fill, and backdrop together, and settle using +a short requestAnimationFrame spring. The background thread is contacted only +after a dismiss animation finishes. A small pure helper module owns angle +classification, nonlinear rubber resistance, velocity filtering, progress, +and dismissal decisions so the behavior can be covered by Node tests without +rendering Lynx. + +`NavBottom.vue` owns Elk-specific destinations, active/disabled state, menu +rows, theme and Zen Mode actions, and route navigation. It renders the sheet as +a root-level fixed overlay whose bottom inset includes the persistent bar and +the Sparkling safe area. Guest and authenticated tab sets mirror upstream Elk +where the current port has matching routes. + +## Interaction and visual details + +- Backdrop opacity enters with the sheet and uses Elk's black 50% scrim. +- Panel entry is 250 ms ease-out; direct backdrop/route exit is 188 ms ease-in. +- A visible 36 × 4 px grabber sits inside a 28 px touch target at the top edge. +- The panel has Elk's 8 px top radius, 1 px top border, translucent themed + surface, and independent vertical scrolling. +- Rows use 20 px horizontal padding, 40 px minimum height, 20 px icons, and the + upstream active, disabled, and primary colors. +- Pressed rows and bottom tabs use short transform/opacity feedback. +- The handle claims vertical drags in either direction after 8 px. Content only + claims a downward drag when `scrollTop` is zero, preserving natural scrolling. +- Pulling upward past the fully-open position uses the same asymptotic + rubber-band equation as `lynx-ui` with coefficient 0.5 and an 80 px cap. +- Pulling downward follows the finger. Release dismisses after the distance + threshold or after a deliberate minimum-distance fast fling; otherwise it + springs back to rest without a background-thread round trip. +- Backdrop opacity follows open progress during drag and settle, rather than + remaining fully dark until dismissal completes. +- A cancelled gesture always springs to the open position and clears captured + direction/velocity state. +- Route changes, backdrop taps, and enabled row taps close the sheet. Disabled + items do not navigate or close it. +- The existing Sparkling safe-area spacers remain outside the overlay, so the + fixed bar and panel share the same safe content on iOS. + +## Testing and verification + +Tests first cover direction locking, hybrid content/handle ownership, +rubber-band limits, velocity filtering, distance/fling dismissal, backdrop +progress, menu structure, main-thread bindings, and the persistent More/close +state. Verification then runs the Elk native compatibility suite, the example +build, the repository checks affected by Vue SFC worklets, the Vercel mobile +preview, and real slow/fast/cancelled drags on the native bundle in iOS Lynx +Explorer with cache and app restarts. + +## Scope + +This change ports the Sheet primitive locally for the Elk example. It does not +publish a new public `vue-lynx` UI package or add unsupported Elk pages merely +to make disabled menu entries clickable. diff --git a/docs/superpowers/specs/2026-07-14-example-benchmark-navigation-design.md b/docs/superpowers/specs/2026-07-14-example-benchmark-navigation-design.md new file mode 100644 index 00000000..9a71e516 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-example-benchmark-navigation-design.md @@ -0,0 +1,38 @@ +# Example Benchmark Navigation Design + +## Goal + +Classify Elk as a Vue Lynx benchmark beside the other production-scale ports, +and keep AI-facing design context owned by the example it describes. + +## Website Navigation + +- Keep the existing Benchmark order: TodoMVC, 7GUIs, HackerNews, AI Chat. +- Add Elk immediately after AI Chat in both English and Chinese sidebars. +- Remove the now-empty Showcase sidebar section. +- Keep the Elk card in the home-page “Try it for yourself” area because that + area is an interactive entry point rather than the sidebar classification. + +## Design Context Ownership + +- Move Elk's root `.impeccable.md` to `examples/elk/.impeccable.md`. +- Move AI Chat's root `.impeccable.md` from current `main` to + `examples/ai-chat/.impeccable.md` while integrating `main` into PR #196. +- Leave no example-specific `.impeccable.md` at the repository root. +- Preserve each file's contents; only its ownership and location change. + +## Integration Strategy + +Merge current `origin/main` into the PR #196 branch instead of rebasing its +published history. Resolve the add/add root `.impeccable.md` conflict by placing +both versions in their owning example directories. Preserve AI Chat and its +website entry from `main`, then place Elk after it. + +## Verification + +- Add a source-level regression check for both localized sidebar orders and the + absence of a Showcase section. +- Assert that each example owns its `.impeccable.md` and the root file is gone. +- Build the website and Elk native bundle. +- Push the merge and navigation changes to PR #196, then verify Vercel Preview + and the deployed native bundle. diff --git a/.impeccable.md b/examples/ai-chat/.impeccable.md similarity index 100% rename from .impeccable.md rename to examples/ai-chat/.impeccable.md diff --git a/examples/elk/.impeccable.md b/examples/elk/.impeccable.md new file mode 100644 index 00000000..daa07b02 --- /dev/null +++ b/examples/elk/.impeccable.md @@ -0,0 +1,21 @@ +## Design Context + +### Users + +Vue Lynx contributors and mobile developers use the Elk example to evaluate whether a Vue application can feel credible on both Lynx Web and native Lynx hosts. The interface should support familiar Mastodon navigation without asking users to relearn the upstream Elk interaction model. + +### Brand Personality + +Faithful, calm, and finely crafted. The example should feel like Elk itself rather than a generic demo, while native interactions should feel direct and physically coherent on iOS. + +### Aesthetic Direction + +Treat upstream `elk-zone/elk` as the visual and interaction reference for mobile web, and current Lynx UI primitives as the native-behavior reference. Support both light and dark themes. Prefer restrained surfaces, precise spacing, clear iconography, and subtle state transitions over decorative effects. + +### Design Principles + +- Preserve upstream Elk hierarchy, navigation semantics, spacing, and theme behavior wherever Vue Lynx supports them. +- Keep direct manipulation responsive on the main thread; motion should explain state changes rather than decorate them. +- Adapt deliberately for native safe areas, scrolling, and touch ownership without changing the recognizable Elk experience. +- Favor small, testable primitives and measurable interaction rules over one-off visual approximations. +- Verify fidelity on mobile web and real Lynx native hosts, including intermediate gesture states—not only final screenshots. diff --git a/examples/elk/PORTING.md b/examples/elk/PORTING.md new file mode 100644 index 00000000..04c16c1e --- /dev/null +++ b/examples/elk/PORTING.md @@ -0,0 +1,160 @@ +# PORTING.md — how Elk became Elk on Lynx + +This example ports [Elk](https://github.com/elk-zone/elk) (a Nuxt 3 Mastodon +web client, ~196 components / 55 pages / 50 composables) to **Vue Lynx**. +Elk was **not forked**: Vue Lynx has no Nuxt (no SSR, file routing, Nitro, +auto-imports), no DOM, and different primitives. Instead, Elk's +framework-agnostic layers were extracted and its UI rebuilt on Lynx +elements. The feature-level status lives in [PRD.md](./PRD.md); this file +explains *what* was reused vs rewritten and *why*. + +## Architecture + +``` +Elk Shared (reused) Elk Lynx (rebuilt) +├── masto.js API client ←→ src/composables/masto.ts (thin de-Nuxt wrapper) +├── content parse pipeline ←→ src/composables/content-parse.ts (near-verbatim) +├── content render walk ←→ src/composables/content-render.ts (Lynx retarget) +├── paginator state machine ←→ src/composables/paginator.ts (Lynx trigger) +├── timeline filters/reorder ←→ src/composables/timeline.ts (verbatim) +├── status action optimistic logic ←→ src/composables/status-actions.ts (verbatim) +├── search composable ←→ src/composables/search.ts (near-verbatim) +├── LRU cache ←→ src/composables/cache.ts (Map-based LRU) +├── account helpers ←→ src/composables/account.ts (verbatim) +└── theme palette (vars.css) ←→ src/styles/theme.css (verbatim values) + +Elk Web (Nuxt + DOM components) → not forked; templates rebuilt in src/components|pages +``` + +## Reused from Elk (and how much changed) + +| Elk source | Ported to | Changes | +| --- | --- | --- | +| `masto` npm package (REST client) | dependency | none — same `createRestAPIClient` | +| `app/composables/content-parse.ts` | `src/composables/content-parse.ts` | ~95% verbatim: sanitizer allow-list, custom-emoji/markdown/named-mention/collapse-mention transforms, `treeToText`. Dropped: twemoji unicode-emoji transforms (native glyphs render already), ``/`dir=auto` (no bidi element), `new URL` protocol check → regex (no URL in Lynx runtime) | +| `app/composables/content-render.ts` | `src/composables/content-render.ts` | same AST walk + mention/hashtag/code special-casing; **output retargeted** (see below) | +| `app/composables/paginator.ts` | `src/composables/paginator.ts` | masto `Paginator` iteration, buffering, state machine kept; DOM trigger (`useElementBounding` + `window.innerHeight` polling) → native `` `scrolltolower`; streaming-prepend removed (no WebSocket) | +| `app/composables/timeline.ts` | `src/composables/timeline.ts` | verbatim (filters, `reorderTimeline`) | +| `app/composables/masto/status.ts` | `src/composables/status-actions.ts` | verbatim optimistic-update logic incl. the cancel-count API-quirk workaround; `navigateTo` → vue-router | +| `app/composables/masto/search.ts` | `src/composables/search.ts` | VueUse `debouncedWatch` inlined; `isHydrated` SSR guard dropped | +| `app/composables/cache.ts` | `src/composables/cache.ts` | `lru-cache` dep → 20-line Map LRU; same keys/API | +| `app/composables/masto/account.ts` | `src/composables/account.ts` | verbatim handle/display-name helpers | +| `app/composables/users.ts` | `src/composables/users.ts` | Elk's guest-mode (`publicServer`) design kept; multi-account persistence dropped (browser storage) | +| `app/styles/{vars,default-theme}.css` | `src/styles/theme.css` | same palette values as Lynx CSS vars on `page` | +| RemixIcon set (Elk's UnoCSS `i-ri:*`) | `src/composables/icons.ts` | same glyphs, inlined as SVG data-URIs for `` (no icon-font/currentColor in Lynx) | + +## Rebuilt for Lynx (and why) + +### Templates: every one of them + +DOM elements don't exist in Lynx. `
`→``, `/

`→``, +``→``, `@click`→`@tap`, CSS via Lynx's subset (flexbox, no +`:hover`). ~20 components in `src/components` + 8 pages in `src/pages` +reimplement Elk's surfaces following the originals' layout +(`StatusCard` ≈ Elk `status/StatusCard.vue` + `StatusBody` + `StatusContent`, +`TimelinePaginator` ≈ `timeline/TimelinePaginator.vue` + `common/CommonPaginator.vue`, …). + +### The content renderer (the crown jewel) + +Elk's pipeline is: Mastodon HTML → `ultrahtml` AST → sanitize → transforms → +Vue vnodes. The **parse half is reused intact**. The **render half changes +its output targets**: + +| Elk emits | Lynx port emits | +| --- | --- | +| `h('p', …)` | `h('text', { class: 'content-p' })` block | +| `h('a', …)` / `RouterLink` | nested `` run with `onTap` → router | +| `AccountHoverWrapper` (floating-vue hover card) | tap → profile navigation (no hover on touch) | +| `` custom emoji | inline `` (static variant) | +| `ContentCode` (Shiki highlight) | plain mono `` block (Shiki's WASM/regex engine unsuited to the Lynx runtime) | +| ``, `dir="auto"`, `` | dropped (no equivalents) | + +Inline styling (bold/italic/del/code…) becomes nested `` runs with +classes — Lynx supports inline text nesting natively. + +### Virtualized timeline + +Elk uses `virtua`'s DOM `WindowVirtualizer`. Lynx's native `` is +already a recycling virtualized scroller, so the port's timeline is a +`` with `estimated-main-axis-size-px` hints and +`lower-threshold-item-count` + `scrolltolower` driving `loadNext()` — +*less* code than the DOM version. + +### Navigation + +Nuxt file routing → explicit `vue-router` table on `createMemoryHistory` +(no browser History API). Elk's route shapes are preserved +(`/:server/@:account`, `/:server/status/:id`, `/:server/tags/:tag`, …) so +the content renderer's mention/hashtag rewrites work unchanged. + +### Sessions + +Elk's OAuth needs its Nitro server (`server/api/[server]/login.ts` +registers an OAuth app, browser redirects to `/oauth/authorize`). A Lynx +app has neither a bundled server nor browser redirects, so the port +supports Elk's **anonymous guest mode** (default) plus **manual +access-token sign-in** in Settings. Multi-account storage +(localStorage/IndexedDB) is out — one session per launch. + +## Lynx-specific landmines (worth knowing) + +1. **`fetch` lives in different scopes across targets.** Native Lynx injects + it into RuntimeWrapperWebpackPlugin's outer wrapper, while Lynx for Web + exposes `globalThis.fetch`. `src/polyfills.ts` selects the callable one + and mirrors it to both scopes before masto runs. Other free-identifier web + constructors use targeted `source.define` rewrites (no masto patches). +2. **No DOMParser anywhere** (worker or native). Elk's `tiny-decode` + entity decoder uses DOMParser in its browser build — replaced with a + 30-line table+numeric decoder (`html-entities.ts`), sufficient for + Mastodon's sanitized output. +3. **The native runtime is missing more than `URL`.** Per + `@lynx-js/types`, the native background thread guarantees only `fetch`, + `Request`, `Response` and timers. masto.js additionally calls + `AbortSignal.any` (unconditionally, on every request — this alone + turned every native request into "Failed to load timeline"), + `new Headers(...)`, `new URL(path, base)`, and `DOMException` for its + error path. `src/polyfills.ts` + installs fill-if-missing implementations before anything else runs; + on Lynx for Web the real worker APIs win and the native-only shims are + inert. +4. **PrimJS cannot parse Unicode-property regexes.** `change-case@5`, pulled + by masto.js, contains `\p{L}`/`\p{Lu}`/`\p{Ll}` expressions that abort the + entire bundle before Vue mounts. The build aliases it to an ASCII adapter; + Mastodon action names and response keys are ASCII by definition. +5. **The native image element has no SVG decoder.** Icons as + `data:image/svg+xml` `` sources render blank on device; the + built-in `` element (web: `x-svg`) renders them on both + targets — that's what `AppIcon` uses. +6. **Fullscreen native cards must consume the host safe area.** Sparkling + exposes iOS `topHeight` / `bottomHeight` through `lynx.__globalProps`; + the Sparkling-enabled Lynx Explorer currently exposes the same insets as + `safeAreaTop` / `safeAreaBottom`. `safe-area.ts` normalizes both contracts, + and `App.vue` places all page, navigation and media-preview UI between root + safe-area spacers. Missing, invalid and non-iOS values resolve to zero. +7. **Icons can't use `currentColor`.** SVG XML is tinted by string + replacement per color (`icons.ts`). +8. **`new URL(path, base)` drops base paths** — relevant to the + verification relay (below), not the app itself. + +## Verification setup + +The sandbox this port was built in blocks browser TLS egress (its proxy +re-terminates TLS and resets Chromium's handshake), so verification uses +two small relays in [`harness/`](./harness/): + +- `serve.mjs` — serves the harness page + `dist/`, and relays + `/api/*` → `https://` via Node fetch (which does honor + the sandbox proxy). Media URLs in JSON responses are rewritten through + the relay. The app opts in via the `ELK_API_PROXY` build define — empty + in normal builds, where it talks to `https://` directly. +- `mitm.mjs` + `--host-resolver-rules=MAP * 127.0.0.1` — a transparent + HTTPS relay so the *original* elk.zone can load in the same sandbox for + side-by-side screenshots against the same instance. +- `shot.mjs` / `shot-elk.mjs` — Playwright capture scripts + (coordinate-based taps; the Lynx view is closed shadow DOM). + +Results: [screenshots/README.md](./screenshots/README.md). + +In an unrestricted environment none of this is needed: build with +`pnpm build`, serve `harness/index.html` + `dist/`, and the app fetches +Mastodon directly. diff --git a/examples/elk/PRD.md b/examples/elk/PRD.md new file mode 100644 index 00000000..c2025402 --- /dev/null +++ b/examples/elk/PRD.md @@ -0,0 +1,201 @@ +# Elk on Lynx — PRD & Feature Parity Checklist + +Port of [Elk](https://github.com/elk-zone/elk) (a Mastodon web client built on Nuxt) to +**Vue Lynx** as a native-client example. Elk's framework-agnostic layers (masto.js API +client, content parser, domain logic, caching, theme) are **reused**; everything +DOM/Nuxt-specific (templates, routing glue, storage, virtual scrolling) is **rebuilt** +on Lynx primitives. See [PORTING.md](./PORTING.md) for the reuse-vs-rewrite rationale. + +Status legend: + +- ✅ ported — implemented in this example and verified on Lynx for Web +- 🚧 partial — implemented with reduced scope (noted) +- ⬜ todo — planned, not yet implemented +- ❌ not suitable — impossible or unsuitable for Lynx, with reason + +## Architecture + +``` +Elk Shared (reused, ported to src/shared + src/composables) +├── masto.js API client ✅ (masto ^7, REST; anonymous + token auth) +├── domain models ✅ (mastodon.v1.* types from masto) +├── Pinia stores / module refs ✅ (module-scope refs, mirrors Elk auto-imports) +├── composables ✅ (paginator, cache, account/status helpers, timeline filters) +├── content parser ✅ (ultrahtml AST pipeline, near-verbatim) +├── formatting utilities ✅ (handles, display names, time-ago, numbers) +└── theme palette ✅ (Elk default theme CSS vars) + +Elk Web = Nuxt + DOM components → not forked +Elk Lynx = this example (examples/elk) → fresh Vue Lynx shell +├── Lynx templates (///) +├── vue-router (memory history) + custom nav shell +└── content renderer retargeted to Lynx elements +``` + +## Feature checklist + +### Core infrastructure + +- ✅ masto.js REST client (`createRestAPIClient`) — reused as-is +- ✅ Anonymous/guest browsing of any public instance (Elk's `publicServer` mode; default server `m.webtoo.ls` like Elk — mastodon.social requires auth for public timelines since 4.5) +- 🚧 Sign-in with access token (manual token entry; no OAuth redirect) +- ❌ OAuth web-redirect sign-in — requires Elk's Nitro server broker (`server/api/[server]/login.ts`) + browser `location.href` redirects; a Lynx app has neither a bundled server nor a browser redirect flow. Token-paste covers authenticated use. +- ❌ Multi-account switching with `window.location.reload()` — browser-only mechanism; single session per launch instead +- ✅ Status/account LRU cache (Elk `cache.ts`, ported without lru-cache dep) +- ❌ IndexedDB persistence (`idb-keyval`) — no IndexedDB in the Lynx JS runtime; in-memory session state instead +- ❌ Streaming timelines (WebSocket `createStreamingAPIClient`) — Lynx JS runtime has no WebSocket; guest mode does not stream in Elk either. Pull-to-refresh replaces live updates. +- ❌ PWA: install prompt, service worker, offline cache, web push, badge — browser platform features, N/A for a native client + +### Navigation & shell + +- ✅ vue-router with `createMemoryHistory` (Elk uses Nuxt file routing — rebuilt as explicit route table) +- ✅ Left/bottom navigation (Elk `NavSide`/`NavBottom` → Lynx bottom tab bar; native apps use bottom tabs) +- ✅ Back navigation on subpages (Elk relies on browser back) +- ✅ Page title header (Elk `NavTitle`/`MainContent` header) +- ❌ Nav footer (about/settings shortcuts) — desktop-sidebar surface; Elk mobile doesn't show it either +- ❌ Command palette (Cmd+K `command/`) & magic keys shortcuts overlay — keyboard-centric, no hardware keyboard assumption on touch devices +- ❌ `useHead` document titles/meta/OG tags — no document in Lynx + +### Timelines + +- ✅ Public/federated timeline (`v1.timelines.public`) +- ✅ Local timeline (`public.list({ local: true })`) +- 🚧 Home timeline (requires token; implemented, verified against API shape only) +- ✅ Infinite scroll via masto `Paginator` (Elk `usePaginator` core reused; DOM `useElementBounding` trigger → Lynx `` `scrolltolower` event) +- ✅ Native virtualized scrolling (Elk uses `virtua` DOM virtual scroller → Lynx `` native recycling) +- ✅ Timeline filtering/reordering (Elk `timeline.ts`: hide replies/boosts prefs, thread reorder — reused verbatim) +- 🚧 Refresh (header refresh button recreates the paginator; replaces Elk's streaming prepend. Native pull-to-refresh gesture not wired) +- 🚧 Bookmarks timeline (page + route built on the shared paginator; requires token to exercise) +- 🚧 Favourites timeline (page + route built on the shared paginator; requires token to exercise) +- ❌ Conversations/DM timeline — requires auth + streaming for liveness; low value in demo scope (API supported, could be added) +- ❌ Scheduled posts management — auth-only editor flow built on TipTap; out of scope with the editor + +### Status card (the core UI) + +- ✅ Account line: avatar, display name with custom emoji, handle, relative timestamp +- ✅ Content rendering via Elk's parser (see Content rendering) +- ✅ Boost (reblog) wrapper card with booster attribution +- ✅ Reply-context line ("replying to @…") +- ✅ Media attachments: images (aspect-ratio preserved, grid for multiples) +- 🚧 Video/gifv/audio attachments — static preview image + type badge; no inline ``; native font renders emoji already, twemoji images skipped deliberately) +- ✅ Mentions → tappable styled `` linking to profile (Elk wraps in hover card — see Account) +- ✅ Hashtags → tappable styled `` linking to tag timeline +- ✅ External links → tappable styled `` (opens vie Lynx `openSchema`/no-op on web preview; ellipsis middle-truncation preserved) +- ✅ Inline markdown: `**bold**` / `*italic*` / `~~del~~` / `` `code` `` (Elk transform reused; styled `` spans) +- ✅ Paragraphs, line breaks, blockquotes, lists (ul/ol/li), headings h1–h5 → ``/`` block mapping +- 🚧 Code blocks — monospace styled block (Elk uses Shiki syntax highlighting; Shiki's WASM/regex engines are too heavy for the Lynx JS runtime) +- ✅ Collapsed mention groups (`transformCollapseMentions` reused) +- ✅ HTML entity decoding (tiny-decode) +- ❌ ``/`dir="auto"` bidi isolation — no bidi element in Lynx text; RTL text renders but without isolation +- ❌ `` annotations — no ruby support in Lynx text + +### Media + +- ✅ Avatar images (rounded, `` with placeholder bg) +- ✅ Attachment grid with aspect ratios +- 🚧 Fullscreen media preview modal (single image + alt text; Elk adds carousel/zoom) +- ❌ Blurhash progressive placeholders — Elk decodes blurhash to ``; no canvas in Lynx. Solid placeholder color instead. + +### Settings & preferences + +- ✅ Settings page shell (interface prefs) +- ✅ Font size setting (Elk: CSS var `--font-size`) +- ✅ Dark/light theme toggle (Elk `vars.css` palettes as cascading Lynx CSS vars; verified vs Elk dark) +- ⬜ Theme color picker (Elk's 8 accent themes) +- ✅ Hide boosts / hide replies / hide alt indicator style prefs (drive `timeline.ts` filters) +- ❌ Language switcher UI + 42-locale i18n — deferred: vue-i18n works on Lynx but doubles demo scope; message catalog structure kept compatible (en-US strings inline) +- ❌ Profile/account settings, push notification settings — auth + browser features + +### Accessibility & misc + +- ❌ ARIA live regions / announcer (`aria/`) — DOM ARIA; Lynx exposes different a11y primitives (`accessibility-label`), applied on interactive elements where cheap +- ✅ Relative timestamps ("5m", "2d" — Elk `useTimeAgo` equivalent, no VueUse dep) +- ✅ Compact number formatting (1.2K, 3.4M — `Intl.NumberFormat` compact like Elk) +- ❌ Offline detection banner — browser `navigator.onLine`; N/A +- ❌ Sponsors/team pages, help preview, release notes — marketing surface, out of scope + +## Verification + +Every ✅ UI feature is verified on **Lynx for Web** (`dist/main.web.bundle` in +`@lynx-js/web-core` ``), screenshotted in Chromium, and compared +side-by-side against the original Elk web UI (same underlying data where possible). +The native compatibility layer and shared UI are additionally verified in the +Sparkling-enabled **Lynx Explorer on an iOS simulator**. Comparisons live in +[`screenshots/`](./screenshots/) with notes in PORTING.md. + +## Loop log + +- **Loop 1**: Repo research, Elk inventory (196 components / 55 pages / 50 composables mapped), PRD drafted, app scaffold builds for lynx+web targets. +- **Loop 2**: Shared layer + content renderer + full UI ported. Solved Lynx runtime landmines (hidden web globals → `source.define`, DOMParser-free entity decoding, no-URL sanitizer). Verified live on Lynx for Web against mas.to: timelines, explore, search, thread, account, settings. +- **Loop 3**: Screenshot comparison pipeline against the ORIGINAL elk.zone (transparent HTTPS relay so both apps run in the same sandbox against the same instance). Parity fixes from comparison: heart favourite icon (was star), stacked name/handle rows, vertical preview cards with wide images, Elk-style icon+primary headers, `Xmin` timestamps, full `@user@server` handles. Side-by-sides in screenshots/README.md. +- **Loop 4**: Dark mode (Elk palette, verified vs elk.zone dark), fullscreen media preview, quote-post nested cards, Following/Followers lists, Bookmarks/Favourites pages, hashtag timeline verified. PRD statuses trued up (pull-to-refresh ⬜, media modal 🚧). +- **Loop 5**: Deep links via Lynx globalProps (`initialPath`), explore News tab (trending links), timeline refresh button, follow-hashtag button, bot/locked badges. Verified News tab + deep-linked #caturday hashtag page. +- **Loop 6**: Edit-history viewer (verified on a live edited status — same capture also proves quote-post nested cards). Example README + preview image, app sources now typecheck clean (bundler-resolution tsconfig). +- **Loop 7**: Notification filter tabs (All/Mentions), final PRD true-up: every remaining ⬜/❌ now carries a reason. Confirmed the production build contains no verification-relay references. +- **Loop 8**: Website integration — `/guide/elk` showcase page (en+zh) with the `` embed (live Web preview tab + QR-code tab serving `main.lynx.bundle` to Lynx Go), sidebar "Showcase" section, home-page showcase card. Verified in the site dev server with live data and in the production `rspress build`. +- **Loop 9**: First real-device run (Lynx Go) surfaced two native-only gaps: masto's unconditional `AbortSignal.any` (plus `Headers`/`URL`) missing from the PrimJS runtime → fill-if-missing polyfills, and blank icons (native image has no SVG decoder) → switched AppIcon to the built-in `` element. Web target regression-verified. +- **Loop 10**: iOS Lynx Explorer validation found three earlier-bootstrap gaps: PrimJS rejects `change-case@5` Unicode-property regexes, the global `fetch` define bypassed RuntimeWrapperWebpackPlugin's native injection, and the same define broke Rspeedy's dev WebSocket transport. Added an ASCII case adapter, synchronized native/web fetch scopes at runtime, left WebSocket wrapper-scoped, filled missing `DOMException`, and removed the temporary on-screen fetch probe. Verified live native local/federated timelines and Explore Posts/Hashtags/News. +- **Loop 11**: Sparkling-enabled iOS Lynx Explorer verification added fullscreen safe-area handling. The root layout consumes Sparkling `topHeight` / `bottomHeight` and Lynx Explorer `safeAreaTop` / `safeAreaBottom` global props, validates invalid/missing values to zero, and keeps page content, bottom navigation and media previews between the insets. DevTool confirmed 62px top and 34px bottom spacers on an iPhone 17 Pro simulator while live Local Timeline and Explore remained functional. +- **Loop 12**: Final fidelity pass against upstream Elk. Matched its system typography and compact timeline rhythm, restored the guest Sign in action, enlarged avatars and redistributed status actions, added the Explore trending explanation, and kept tab indicators mounted so state changes animate cleanly. Added short transform/opacity-only feedback for taps, tabs, refresh and media preview. Verified Web Local/Explore captures and a live native Local Timeline in Sparkling Lynx Explorer; DevTool confirmed 62px/34px safe areas, native touch bindings, 48px avatars and a clean error/warning console. diff --git a/examples/elk/README.md b/examples/elk/README.md new file mode 100644 index 00000000..eab9b779 --- /dev/null +++ b/examples/elk/README.md @@ -0,0 +1,66 @@ +# Elk on Lynx + +A native Mastodon client built with **Vue Lynx** by porting +[Elk](https://github.com/elk-zone/elk) — reusing Elk's framework-agnostic +layers (masto.js API client, Mastodon-HTML content pipeline, domain logic, +theme) and rebuilding its UI on Lynx native elements. + +| Local timeline | Explore | Dark mode | +| --- | --- | --- | +| ![local](./screenshots/lynx/01-local.png) | ![explore](./screenshots/lynx/03-explore.png) | ![dark](./screenshots/lynx/08-dark-local.png) | + +## Features + +Anonymous browsing of any Mastodon instance (default `mas.to`): local / +federated timelines with native `` virtualization and infinite +scroll, rich status cards (custom emoji, mentions, hashtags, markdown, +content warnings, sensitive-media blur, media grids, link preview cards, +quote posts, polls), thread view, account profiles (banner, fields, +posts/replies/media tabs, follower lists), trending posts / hashtags / +news, debounced search, fullscreen media preview, dark mode, and +Elk-compatible optimistic actions (reply/boost/favourite/bookmark) plus +compose — the latter enabled by pasting an access token in Settings. + +See [PRD.md](./PRD.md) for the full feature-parity checklist against Elk +(including what's deliberately not ported and why), +[PORTING.md](./PORTING.md) for the reused-vs-rebuilt architecture map, and +[screenshots/](./screenshots/README.md) for side-by-side comparisons with +the original elk.zone. + +## Run + +```bash +pnpm install +pnpm dev # scan the QR code with LynxExplorer, or open the web preview +pnpm build # dist/main.lynx.bundle + dist/main.web.bundle +``` + +The app talks to `https://` directly. Deep-link a route by +passing `globalProps: { initialPath: '/mas.to/tags/caturday' }` to the +LynxView (native) or `` (web). + +### Web preview / screenshot harness + +[`harness/`](./harness/) contains a minimal `@lynx-js/web-core` host page +plus the Playwright capture scripts used for the screenshot comparisons — +including relays for sandboxed environments where browser TLS egress is +blocked (see PORTING.md "Verification setup"). + +## Notable Lynx adaptations + +- The first-import compatibility layer synchronizes wrapper-injected native + `fetch` with the web worker's `globalThis.fetch`; remaining masto.js web + constructors are rewritten to `globalThis.*` and filled only when missing. +- masto.js's Unicode-aware `change-case` dependency is replaced at build time + with an ASCII-equivalent adapter because Mastodon API keys are ASCII and + PrimJS cannot parse Unicode-property regular expressions. +- Fullscreen iOS cards read Sparkling's `topHeight` / `bottomHeight` global + props (plus Lynx Explorer's `safeAreaTop` / `safeAreaBottom` aliases), keeping + headers, bottom navigation and media previews inside the device safe area. +- Elk's content renderer keeps its ultrahtml parse/sanitize/transform + pipeline verbatim; only the vnode emission changed + (`

//` → `/` with tap navigation). +- Elk's DOM virtual scroller (virtua) is replaced by Lynx's native + recycling `` — less code, native performance. +- RemixIcon glyphs (Elk's `i-ri:*`) render as tinted XML through Lynx's + built-in `` element. diff --git a/examples/elk/harness/debug.mjs b/examples/elk/harness/debug.mjs new file mode 100644 index 00000000..cfb8e289 --- /dev/null +++ b/examples/elk/harness/debug.mjs @@ -0,0 +1,33 @@ +// Verbose debug run: log all console output + network activity. +import { chromium } from 'playwright'; + +const query = process.argv[2] || ''; +const waitMs = Number(process.argv[3] || 12000); +const proxy = process.env.HTTPS_PROXY || process.env.https_proxy; + +const browser = await chromium.launch({ + executablePath: '/opt/pw-browsers/chromium', + headless: true, + proxy: proxy ? { server: proxy, bypass: 'localhost,127.0.0.1' } : undefined, + args: ['--no-sandbox', '--disable-dev-shm-usage'], +}); +const ctx = await browser.newContext({ + viewport: { width: 390, height: 844 }, + ignoreHTTPSErrors: true, +}); +const page = await ctx.newPage(); + +page.on('console', msg => console.log(`[${msg.type()}]`, msg.text().slice(0, 300))); +page.on('pageerror', err => console.log('[pageerror]', String(err).slice(0, 300))); +page.on('requestfailed', (req) => { + if (!req.url().includes('localhost')) + console.log('[reqfail]', req.method(), req.url().slice(0, 120), req.failure()?.errorText); +}); +page.on('response', (res) => { + if (!res.url().includes('localhost')) + console.log('[response]', res.status(), res.url().slice(0, 120)); +}); + +await page.goto(`http://localhost:${process.env.PORT || 8975}/${query}`, { waitUntil: 'load' }); +await page.waitForTimeout(waitMs); +await browser.close(); diff --git a/examples/elk/harness/index.html b/examples/elk/harness/index.html new file mode 100644 index 00000000..80c1bbc7 --- /dev/null +++ b/examples/elk/harness/index.html @@ -0,0 +1,73 @@ + + + + + + Elk on Lynx — Web Preview + + + + +

+ + + + diff --git a/examples/elk/harness/mitm.mjs b/examples/elk/harness/mitm.mjs new file mode 100644 index 00000000..a10bd590 --- /dev/null +++ b/examples/elk/harness/mitm.mjs @@ -0,0 +1,62 @@ +// Transparent HTTPS relay: Chromium resolves every host to 127.0.0.1 +// (--host-resolver-rules), this server accepts the TLS connection with a +// self-signed cert (ignoreHTTPSErrors) and forwards the request to the +// real host via Node fetch (which does honor the sandbox egress proxy). +// Lets the browser load real sites (elk.zone) for screenshot comparison. +import https from 'node:https'; +import fs from 'node:fs'; + +const cert = fs.readFileSync(new URL('./mitm-cert.pem', import.meta.url)); +const key = fs.readFileSync(new URL('./mitm-key.pem', import.meta.url)); + +const HOP_BY_HOP = new Set([ + 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', + 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', + 'content-length', 'accept-encoding', +]); + +https.createServer({ cert, key }, async (req, res) => { + const host = req.headers.host; + if (!host) { + res.writeHead(400); + res.end(); + return; + } + const target = `https://${host}${req.url}`; + try { + const headers = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (!HOP_BY_HOP.has(k.toLowerCase()) && typeof v === 'string') + headers[k] = v; + } + + let body; + if (req.method !== 'GET' && req.method !== 'HEAD') { + const chunks = []; + for await (const c of req) chunks.push(c); + body = Buffer.concat(chunks); + } + + const upstream = await fetch(target, { + method: req.method, + headers, + body, + redirect: 'manual', + }); + + const outHeaders = {}; + upstream.headers.forEach((v, k) => { + if (!HOP_BY_HOP.has(k) && k !== 'content-encoding') + outHeaders[k] = v; + }); + const buf = Buffer.from(await upstream.arrayBuffer()); + outHeaders['content-length'] = String(buf.length); + res.writeHead(upstream.status, outHeaders); + res.end(buf); + } + catch (err) { + console.error('[mitm]', req.method, target, err.message); + res.writeHead(502); + res.end(); + } +}).listen(443, () => console.log('mitm relay on :443')); diff --git a/examples/elk/harness/serve.mjs b/examples/elk/harness/serve.mjs new file mode 100644 index 00000000..e8847dfa --- /dev/null +++ b/examples/elk/harness/serve.mjs @@ -0,0 +1,152 @@ +// Static file server for the Lynx-for-Web harness: +// / → harness/index.html +// /static/* → web-core client_prod assets +// /dist/* → examples/elk/dist (the built bundles) +import http from 'node:http'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HARNESS = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const WEB_CORE_STATIC = path.resolve( + path.dirname(require.resolve('@lynx-js/web-core/client.prod.js')), + '..', +); +const DIST = process.env.ELK_DIST || path.resolve(HARNESS, '../dist'); +const PORT = Number(process.env.PORT || 8975); + +const MIME = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.wasm': 'application/wasm', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.bundle': 'application/octet-stream', +}; + +// Media/asset URL keys in Mastodon JSON that the app loads as images. +// Rewritten to route through this relay (browser TLS is blocked by the +// sandbox egress proxy; Node's env-proxy fetch is not). +const MEDIA_KEY_RE = /"(avatar|avatar_static|header|header_static|preview_url|static_url|image)":"(https:\\?\/\\?\/)([^"]+)"/g; +// "url" keys are rewritten only when they point at media file paths — +// status/account "url" values must stay intact (mention matching etc). +const MEDIA_URL_RE = /"url":"(https:\\?\/\\?\/)([^"]*(?:media_attachments|\/media\/|\/cache\/)[^"]*)"/g; + +async function relay(req, res, url) { + // /api-proxy// + const rest = url.pathname.slice('/api-proxy/'.length); + const slash = rest.indexOf('/'); + const host = slash === -1 ? rest : rest.slice(0, slash); + const path = slash === -1 ? '' : rest.slice(slash); + const target = `https://${host}${path}${url.search}`; + + const cors = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + 'Access-Control-Allow-Headers': '*', + }; + if (req.method === 'OPTIONS') { + res.writeHead(204, cors); + res.end(); + return; + } + + try { + const headers = {}; + if (req.headers.authorization) headers.authorization = req.headers.authorization; + if (req.headers['content-type']) headers['content-type'] = req.headers['content-type']; + + let body; + if (req.method !== 'GET' && req.method !== 'HEAD') { + const chunks = []; + for await (const c of req) chunks.push(c); + body = Buffer.concat(chunks); + } + + const upstream = await fetch(target, { method: req.method, headers, body }); + const contentType = upstream.headers.get('content-type') || 'application/octet-stream'; + const isJson = contentType.includes('json'); + const buf = Buffer.from(await upstream.arrayBuffer()); + + let out = buf; + if (isJson) { + const text = buf.toString('utf8') + .replace( + MEDIA_KEY_RE, + (_m, key, _proto, tail) => `"${key}":"http://localhost:${PORT}/api-proxy/${tail}"`, + ) + .replace( + MEDIA_URL_RE, + (_m, _proto, tail) => `"url":"http://localhost:${PORT}/api-proxy/${tail}"`, + ); + out = Buffer.from(text, 'utf8'); + } + + const passthrough = {}; + const link = upstream.headers.get('link'); + if (link) { + // masto.js paginates via the Link header; rewrite next/prev URLs + // through the relay too. + passthrough.link = link.replaceAll(/https:\/\//g, `http://localhost:${PORT}/api-proxy/`); + } + res.writeHead(upstream.status, { + ...cors, + ...passthrough, + 'Content-Type': contentType, + 'Cache-Control': 'no-store', + }); + res.end(out); + } + catch (err) { + console.error('[relay]', target, err.message); + res.writeHead(502, cors); + res.end(JSON.stringify({ error: String(err) })); + } +} + +// masto.js resolves API paths against the client base URL with +// `new URL(path, base)`, which drops any base path — so bare /api/* and +// friends arrive here without the /api-proxy/ prefix. Forward them +// to the default target instance. +const RELAY_TARGET = process.env.ELK_RELAY_TARGET || 'm.webtoo.ls'; +const BARE_RELAY_PREFIXES = ['/api/', '/nodeinfo/', '/oauth/', '/.well-known/']; + +http.createServer((req, res) => { + const url = new URL(req.url, 'http://localhost'); + if (url.pathname.startsWith('/api-proxy/')) { + relay(req, res, url); + return; + } + if (BARE_RELAY_PREFIXES.some(p => url.pathname.startsWith(p))) { + url.pathname = `/api-proxy/${RELAY_TARGET}${url.pathname}`; + relay(req, res, url); + return; + } + let filePath; + if (url.pathname === '/' || url.pathname === '/index.html') { + filePath = path.join(HARNESS, 'index.html'); + } else if (url.pathname.startsWith('/static/')) { + filePath = path.join(WEB_CORE_STATIC, url.pathname.slice('/static/'.length)); + } else if (url.pathname.startsWith('/dist/')) { + filePath = path.join(DIST, url.pathname.slice('/dist/'.length)); + } else { + filePath = path.join(HARNESS, url.pathname); + } + + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end('not found'); + return; + } + res.writeHead(200, { + 'Content-Type': MIME[path.extname(filePath)] || 'application/octet-stream', + 'Cache-Control': 'no-store', + }); + res.end(data); + }); +}).listen(PORT, () => console.log(`harness on http://localhost:${PORT}`)); diff --git a/examples/elk/harness/shot-elk.mjs b/examples/elk/harness/shot-elk.mjs new file mode 100644 index 00000000..9af2083b --- /dev/null +++ b/examples/elk/harness/shot-elk.mjs @@ -0,0 +1,58 @@ +// Screenshot the real Elk (elk.zone) through the MITM relay for +// side-by-side comparison with the Lynx port. +// Usage: node shot-elk.mjs [waitMs] [actionsJson] +import { chromium } from 'playwright'; + +const out = process.argv[2] || 'elk.png'; +const path = process.argv[3] || '/mas.to/public/local'; +const waitMs = Number(process.argv[4] || 12000); +const actions = process.argv[5] ? JSON.parse(process.argv[5]) : []; + +const browser = await chromium.launch({ + executablePath: '/opt/pw-browsers/chromium', + headless: true, + args: [ + // do NOT inherit the env HTTPS_PROXY — all traffic must hit the local + // MITM relay via host-resolver-rules instead + '--no-proxy-server', + '--no-sandbox', + '--disable-dev-shm-usage', + // resolve EVERYTHING to the local MITM relay + '--host-resolver-rules=MAP * 127.0.0.1, EXCLUDE localhost', + '--ignore-certificate-errors', + ], +}); + +const ctx = await browser.newContext({ + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 2, + ignoreHTTPSErrors: true, + isMobile: true, + hasTouch: true, + colorScheme: process.env.DARK ? 'dark' : 'light', + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', +}); +const page = await ctx.newPage(); + +page.on('pageerror', err => console.log('[pageerror]', String(err).slice(0, 200))); + +await page.goto(`https://elk.zone${path}`, { waitUntil: 'load', timeout: 60000 }); +await page.waitForTimeout(waitMs); + +for (const action of actions) { + if (action.tap) { + await page.mouse.click(action.tap.x, action.tap.y); + await page.waitForTimeout(action.tap.wait ?? 2500); + } + else if (action.type) { + await page.keyboard.type(action.type, { delay: 40 }); + await page.waitForTimeout(2500); + } + else if (action.wait) { + await page.waitForTimeout(action.wait); + } +} + +await page.screenshot({ path: out, clip: { x: 0, y: 0, width: 390, height: 844 } }); +await browser.close(); +console.log('saved', out); diff --git a/examples/elk/harness/shot.mjs b/examples/elk/harness/shot.mjs new file mode 100644 index 00000000..9287bb66 --- /dev/null +++ b/examples/elk/harness/shot.mjs @@ -0,0 +1,56 @@ +// Screenshot the harness with headless Chromium. +// Usage: node shot.mjs [waitMs] [actionsJson] +// Env: APP_PATH=/mas.to/tags/foo — deep-link the app to a route. +// actions: [{"tap":{"x":195,"y":818}}, {"type":"hello"}, {"wait":2000}, {"scroll":300}] +// The app renders inside shadow DOM (closed to selectors), so +// interactions are coordinate-based. +import { chromium } from 'playwright'; + +const out = process.argv[2] || 'shot.png'; +const waitMs = Number(process.argv[3] || 8000); +const actions = process.argv[4] ? JSON.parse(process.argv[4]) : []; + +const proxy = process.env.HTTPS_PROXY || process.env.https_proxy; + +const browser = await chromium.launch({ + executablePath: '/opt/pw-browsers/chromium', + headless: true, + proxy: proxy ? { server: proxy, bypass: 'localhost,127.0.0.1' } : undefined, + args: ['--no-sandbox', '--disable-dev-shm-usage'], +}); + +const ctx = await browser.newContext({ + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 2, + ignoreHTTPSErrors: true, +}); +const page = await ctx.newPage(); + +page.on('pageerror', err => console.log('[pageerror]', String(err).slice(0, 300))); + +const appPath = process.env.APP_PATH ? `?path=${encodeURIComponent(process.env.APP_PATH)}` : ''; +await page.goto(`http://localhost:${process.env.PORT || 8975}/${appPath}`, { waitUntil: 'load' }); +await page.waitForTimeout(waitMs); + +for (const action of actions) { + if (action.tap) { + await page.mouse.click(action.tap.x, action.tap.y); + await page.waitForTimeout(action.tap.wait ?? 2500); + } + else if (action.type) { + await page.keyboard.type(action.type, { delay: 40 }); + await page.waitForTimeout(2500); + } + else if (action.scroll) { + await page.mouse.move(195, 420); + await page.mouse.wheel(0, action.scroll); + await page.waitForTimeout(1200); + } + else if (action.wait) { + await page.waitForTimeout(action.wait); + } +} + +await page.screenshot({ path: out, clip: { x: 0, y: 0, width: 390, height: 844 } }); +await browser.close(); +console.log('saved', out); diff --git a/examples/elk/lynx.config.ts b/examples/elk/lynx.config.ts new file mode 100644 index 00000000..ec81534a --- /dev/null +++ b/examples/elk/lynx.config.ts @@ -0,0 +1,64 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from '@lynx-js/rspeedy'; +import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin'; +import { pluginTailwindCSS } from 'rsbuild-plugin-tailwindcss'; +import { pluginVueLynx } from 'vue-lynx/plugin'; + +const exampleDir = path.dirname(fileURLToPath(import.meta.url)); +const exampleName = path.basename(exampleDir); + +export default defineConfig({ + environments: { + lynx: {}, + web: {}, + }, + output: { + assetPrefix: `https://vue.lynxjs.org/examples/${exampleName}/dist/`, + }, + resolve: { + alias: { + // change-case@5 uses Unicode property escapes, which PrimJS cannot + // parse. Mastodon's action and JSON field names are ASCII, so route + // its camelCase/snakeCase imports through the equivalent adapter. + 'change-case': path.resolve(exampleDir, 'src/change-case.ts'), + }, + }, + source: { + entry: { + main: './src/index.ts', + }, + // RuntimeWrapperWebpackPlugin injects native fetch separately; the + // first-import polyfills synchronize it with globalThis. Rewrite the + // remaining web constructors that masto references as free identifiers. + define: { + Request: 'globalThis.Request', + Response: 'globalThis.Response', + Headers: 'globalThis.Headers', + AbortSignal: 'globalThis.AbortSignal', + AbortController: 'globalThis.AbortController', + URLSearchParams: 'globalThis.URLSearchParams', + FormData: 'globalThis.FormData', + // Optional local API relay for sandboxed verification environments + // where browser TLS egress is blocked (see PORTING.md "Verification"). + // Empty in normal builds — the app talks to https:// directly. + __ELK_API_PROXY__: JSON.stringify(process.env.ELK_API_PROXY ?? ''), + }, + }, + plugins: [ + pluginQRCode({ + schema(url) { + return `${url}?fullscreen=true`; + }, + }), + pluginVueLynx({ + optionsApi: false, + enableCSSInheritance: true, + enableCSSInlineVariables: true, + }), + pluginTailwindCSS({ + config: 'tailwind.config.ts', + exclude: [/[\\/]node_modules[\\/]/], + }), + ], +}); diff --git a/examples/elk/package.json b/examples/elk/package.json new file mode 100644 index 00000000..815a43d0 --- /dev/null +++ b/examples/elk/package.json @@ -0,0 +1,38 @@ +{ + "name": "@vue-lynx-example/elk", + "version": "0.0.1", + "private": true, + "description": "Elk (Mastodon client) ported to Vue-Lynx — native Mastodon client example", + "license": "MIT", + "type": "module", + "files": [ + "dist", + "src", + "lynx.config.ts", + "tsconfig.json" + ], + "scripts": { + "build": "rspeedy build", + "dev": "rspeedy dev", + "test:native-compat": "node --test scripts/native-compat.test.mjs" + }, + "dependencies": { + "masto": "^7.2.0", + "pinia": "^3.0.2", + "ultrahtml": "^1.5.3", + "vue-lynx": "workspace:*", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@lynx-js/qrcode-rsbuild-plugin": "^0.4.6", + "@lynx-js/rspeedy": "^0.13.5", + "@lynx-js/tailwind-preset": "^0.4.0", + "@rsbuild/plugin-vue": "^1.2.6", + "rsbuild-plugin-tailwindcss": "^0.2.3", + "tailwindcss": "^3.4.17", + "typescript": "~5.9.3" + }, + "engines": { + "node": ">=18" + } +} diff --git a/examples/elk/postcss.config.js b/examples/elk/postcss.config.js new file mode 100644 index 00000000..01bf7432 --- /dev/null +++ b/examples/elk/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + tailwindcss: {}, + }, +}; diff --git a/examples/elk/preview-image.png b/examples/elk/preview-image.png new file mode 100644 index 00000000..5451ba4d Binary files /dev/null and b/examples/elk/preview-image.png differ diff --git a/examples/elk/screenshots/README.md b/examples/elk/screenshots/README.md new file mode 100644 index 00000000..a9a7099a --- /dev/null +++ b/examples/elk/screenshots/README.md @@ -0,0 +1,46 @@ +# Screenshot comparisons — Elk on Lynx vs original Elk + +Both columns are captured at 390×844 (mobile viewport) against the **same +live instance (mas.to)** minutes apart: the left column is this example +running on **Lynx for Web** (`dist/main.web.bundle` inside +`@lynx-js/web-core`'s ``), the right column is the original +**elk.zone** in Chromium. Because both talk to a live timeline, the posts +shown differ — compare the anatomy, not the content. + +Captured with the scripts in [`../harness/`](../harness/). See +[PORTING.md](../PORTING.md) for what was reused vs rebuilt. + +| Surface | Elk on Lynx | Original Elk (elk.zone) | +| --- | --- | --- | +| Local timeline | ![lynx local](./lynx/01-local.png) | ![elk local](./elk/01-local.png) | +| Federated timeline | ![lynx federated](./lynx/02-federated.png) | ![elk federated](./elk/02-federated.png) | +| Explore — trending posts | ![lynx explore](./lynx/03-explore.png) | ![elk explore](./elk/03-explore.png) | +| Explore — trending hashtags | ![lynx tags](./lynx/03b-explore-tags.png) | ![elk tags](./elk/03b-explore-tags.png) | +| Search ("vuejs") | ![lynx search](./lynx/04-search.png) | ![elk search](./elk/04-search.png) | +| Settings | ![lynx settings](./lynx/05-settings.png) | ![elk settings](./elk/05-settings.png) | +| Thread / status detail | ![lynx thread](./lynx/06-thread.png) | ![elk thread](./elk/06-thread.png) | +| Account profile | ![lynx account](./lynx/07-account.png) | ![elk account](./elk/07-account.png) | +| Dark mode | ![lynx dark](./lynx/08-dark-local.png) | ![elk dark](./elk/08-dark-local.png) | + +Lynx-only captures (no same-frame Elk counterpart): + +| Surface | Elk on Lynx | +| --- | --- | +| Fullscreen media preview | ![lynx media preview](./lynx/09-media-preview.png) | +| Hashtag timeline | ![lynx tag](./lynx/10-tag.png) | +| Trending news (explore) | ![lynx news](./lynx/03c-explore-links.png) | +| Edit history + quote post | ![lynx edit history](./lynx/12-edit-history.png) | + +## Known visual deltas (intentional or tracked) + +- The guest header now mirrors Elk's **Sign in** action, but routes to the + token-based Settings flow because this example has no OAuth server. +- Elk's action bar includes a **quote** button (Mastodon 4.5); the port + renders quote posts (see the edit-history capture) but composing quotes + is out of scope with the editor. +- Elk renders unicode emoji as twemoji images; the port uses native color + emoji glyphs (deliberate — see PRD "Content rendering"). +- Elk's guest bottom nav has 4 items (…-menu last); the port promotes + Search and Settings into the bar. +- The port follows Elk's system sans stack; glyph rasterization still differs + slightly between Chromium and the native iOS text renderer. diff --git a/examples/elk/screenshots/elk/01-local.png b/examples/elk/screenshots/elk/01-local.png new file mode 100644 index 00000000..e4c6b44a Binary files /dev/null and b/examples/elk/screenshots/elk/01-local.png differ diff --git a/examples/elk/screenshots/elk/02-federated.png b/examples/elk/screenshots/elk/02-federated.png new file mode 100644 index 00000000..787f34cf Binary files /dev/null and b/examples/elk/screenshots/elk/02-federated.png differ diff --git a/examples/elk/screenshots/elk/03-explore.png b/examples/elk/screenshots/elk/03-explore.png new file mode 100644 index 00000000..bc1c361a Binary files /dev/null and b/examples/elk/screenshots/elk/03-explore.png differ diff --git a/examples/elk/screenshots/elk/03b-explore-tags.png b/examples/elk/screenshots/elk/03b-explore-tags.png new file mode 100644 index 00000000..d4e5f4e6 Binary files /dev/null and b/examples/elk/screenshots/elk/03b-explore-tags.png differ diff --git a/examples/elk/screenshots/elk/04-search.png b/examples/elk/screenshots/elk/04-search.png new file mode 100644 index 00000000..07d29063 Binary files /dev/null and b/examples/elk/screenshots/elk/04-search.png differ diff --git a/examples/elk/screenshots/elk/05-settings.png b/examples/elk/screenshots/elk/05-settings.png new file mode 100644 index 00000000..f06ff3be Binary files /dev/null and b/examples/elk/screenshots/elk/05-settings.png differ diff --git a/examples/elk/screenshots/elk/06-thread.png b/examples/elk/screenshots/elk/06-thread.png new file mode 100644 index 00000000..b8b183f9 Binary files /dev/null and b/examples/elk/screenshots/elk/06-thread.png differ diff --git a/examples/elk/screenshots/elk/07-account.png b/examples/elk/screenshots/elk/07-account.png new file mode 100644 index 00000000..0869d88a Binary files /dev/null and b/examples/elk/screenshots/elk/07-account.png differ diff --git a/examples/elk/screenshots/elk/08-dark-local.png b/examples/elk/screenshots/elk/08-dark-local.png new file mode 100644 index 00000000..a9d1c848 Binary files /dev/null and b/examples/elk/screenshots/elk/08-dark-local.png differ diff --git a/examples/elk/screenshots/lynx/01-local.png b/examples/elk/screenshots/lynx/01-local.png new file mode 100644 index 00000000..5451ba4d Binary files /dev/null and b/examples/elk/screenshots/lynx/01-local.png differ diff --git a/examples/elk/screenshots/lynx/02-federated.png b/examples/elk/screenshots/lynx/02-federated.png new file mode 100644 index 00000000..1ae8c3eb Binary files /dev/null and b/examples/elk/screenshots/lynx/02-federated.png differ diff --git a/examples/elk/screenshots/lynx/03-explore.png b/examples/elk/screenshots/lynx/03-explore.png new file mode 100644 index 00000000..6e9a3663 Binary files /dev/null and b/examples/elk/screenshots/lynx/03-explore.png differ diff --git a/examples/elk/screenshots/lynx/03b-explore-tags.png b/examples/elk/screenshots/lynx/03b-explore-tags.png new file mode 100644 index 00000000..c1b7ccf0 Binary files /dev/null and b/examples/elk/screenshots/lynx/03b-explore-tags.png differ diff --git a/examples/elk/screenshots/lynx/03c-explore-links.png b/examples/elk/screenshots/lynx/03c-explore-links.png new file mode 100644 index 00000000..c2d8a7dc Binary files /dev/null and b/examples/elk/screenshots/lynx/03c-explore-links.png differ diff --git a/examples/elk/screenshots/lynx/04-search.png b/examples/elk/screenshots/lynx/04-search.png new file mode 100644 index 00000000..672b6175 Binary files /dev/null and b/examples/elk/screenshots/lynx/04-search.png differ diff --git a/examples/elk/screenshots/lynx/05-settings.png b/examples/elk/screenshots/lynx/05-settings.png new file mode 100644 index 00000000..cd58fb30 Binary files /dev/null and b/examples/elk/screenshots/lynx/05-settings.png differ diff --git a/examples/elk/screenshots/lynx/06-thread.png b/examples/elk/screenshots/lynx/06-thread.png new file mode 100644 index 00000000..089f2375 Binary files /dev/null and b/examples/elk/screenshots/lynx/06-thread.png differ diff --git a/examples/elk/screenshots/lynx/07-account.png b/examples/elk/screenshots/lynx/07-account.png new file mode 100644 index 00000000..5df18949 Binary files /dev/null and b/examples/elk/screenshots/lynx/07-account.png differ diff --git a/examples/elk/screenshots/lynx/08-dark-local.png b/examples/elk/screenshots/lynx/08-dark-local.png new file mode 100644 index 00000000..2f597452 Binary files /dev/null and b/examples/elk/screenshots/lynx/08-dark-local.png differ diff --git a/examples/elk/screenshots/lynx/09-media-preview.png b/examples/elk/screenshots/lynx/09-media-preview.png new file mode 100644 index 00000000..a5cc052a Binary files /dev/null and b/examples/elk/screenshots/lynx/09-media-preview.png differ diff --git a/examples/elk/screenshots/lynx/10-tag.png b/examples/elk/screenshots/lynx/10-tag.png new file mode 100644 index 00000000..b292f232 Binary files /dev/null and b/examples/elk/screenshots/lynx/10-tag.png differ diff --git a/examples/elk/screenshots/lynx/12-edit-history.png b/examples/elk/screenshots/lynx/12-edit-history.png new file mode 100644 index 00000000..719d091f Binary files /dev/null and b/examples/elk/screenshots/lynx/12-edit-history.png differ diff --git a/examples/elk/screenshots/lynx/13-notifications-guest.png b/examples/elk/screenshots/lynx/13-notifications-guest.png new file mode 100644 index 00000000..d5170da4 Binary files /dev/null and b/examples/elk/screenshots/lynx/13-notifications-guest.png differ diff --git a/examples/elk/scripts/native-compat.test.mjs b/examples/elk/scripts/native-compat.test.mjs new file mode 100644 index 00000000..d9dd8530 --- /dev/null +++ b/examples/elk/scripts/native-compat.test.mjs @@ -0,0 +1,495 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const caseAdapter = await import('../src/change-case.ts').catch(() => ({})); +const domExceptionCompat = await import('../src/dom-exception-compat.ts').catch(() => ({})); +const fetchCompat = await import('../src/fetch-compat.ts').catch(() => ({})); +const profileLoadCompat = await import('../src/composables/profile-load.ts').catch(() => ({})); +const safeAreaCompat = await import('../src/safe-area.ts').catch(() => ({})); +const sheetGesture = await import('../src/components/sheet/gesture.ts').catch(() => ({})); +const navItems = await import('../src/components/nav-items.ts').catch(() => ({})); +const lynxConfig = (await import('../lynx.config.ts')).default; + +function isolateWorklet(worklet, captures = {}) { + const names = Object.keys(captures); + return Function(...names, `return (${worklet.toString()})`)( + ...names.map(name => captures[name]), + ); +} + +test('sheet claims handle drags vertically and content drags only downward at scroll top', () => { + assert.equal(sheetGesture.shouldClaimSheetGesture?.('handle', 2, -12, 40), true); + assert.equal(sheetGesture.shouldClaimSheetGesture?.('handle', 20, 10, 0), false); + assert.equal(sheetGesture.shouldClaimSheetGesture?.('content', 2, 12, 0), true); + assert.equal(sheetGesture.shouldClaimSheetGesture?.('content', 2, -12, 0), false); + assert.equal(sheetGesture.shouldClaimSheetGesture?.('content', 2, 12, 1), false); +}); + +test('sheet uses bounded rubber resistance above the open position', () => { + assert.equal(sheetGesture.resolveSheetDrag?.(30), 30); + assert.ok(Math.abs(sheetGesture.resolveSheetDrag?.(-40, 80, 0.5) + 16) < 0.0001); + assert.ok(sheetGesture.resolveSheetDrag?.(-10000, 80, 0.5) > -80); +}); + +test('sheet release uses distance or projected fling travel to dismiss', () => { + assert.equal(sheetGesture.shouldDismissSheet?.(120, 0, 120), true); + assert.equal(sheetGesture.shouldDismissSheet?.(119, 0, 120), false); + assert.equal(sheetGesture.shouldDismissSheet?.(24, 900, 120), true); + assert.equal(sheetGesture.shouldDismissSheet?.(10, 1200, 120), false); + assert.equal(sheetGesture.shouldDismissSheet?.(60, -900, 120), false); +}); + +test('sheet dismiss threshold scales with native layout units', () => { + assert.equal(sheetGesture.sheetDismissThreshold(1600), 240); + assert.equal(sheetGesture.sheetDismissThreshold(1600, 180), 180); + assert.equal(sheetGesture.sheetDismissThreshold(0), 120); +}); + +test('sheet backdrop progress follows downward travel', () => { + assert.equal(sheetGesture.sheetOpenProgress?.(-20, 600), 1); + assert.equal(sheetGesture.sheetOpenProgress?.(150, 600), 0.75); + assert.equal(sheetGesture.sheetOpenProgress?.(800, 600), 0); +}); + +test('sheet filters noisy velocity samples and integrates a stable spring step', () => { + assert.equal(sheetGesture.smoothSheetVelocity?.(0, 9, 10, 0.25), 225); + assert.equal(sheetGesture.sheetReleaseVelocity?.(450, 40), 450); + assert.equal(sheetGesture.sheetReleaseVelocity?.(450, 120), 0); + const next = sheetGesture.stepSheetSpring?.(100, 0, 0, 1 / 60); + assert.ok(next.value < 100); + assert.ok(next.velocity < 0); +}); + +test('sheet worklets keep primitive defaults when detached from module scope', () => { + const claim = isolateWorklet(sheetGesture.shouldClaimSheetGesture); + const resolveDrag = isolateWorklet(sheetGesture.resolveSheetDrag, { + rubberEffect: sheetGesture.rubberEffect, + }); + const project = isolateWorklet(sheetGesture.projectSheetRelease); + const dismiss = isolateWorklet(sheetGesture.shouldDismissSheet, { + projectSheetRelease: project, + }); + + assert.equal(claim('handle', 2, -12, 40), true); + assert.equal(resolveDrag(30), 30); + assert.equal(project(24, 900), 226.5); + assert.equal(dismiss(24, 900, 120), true); +}); + +test('Vue Lynx Sheet keeps hybrid drag and settle work on the main thread', async () => { + const source = await readFile( + new URL('../src/components/sheet/Sheet.vue', import.meta.url), + 'utf8', + ); + const theme = await readFile( + new URL('../src/styles/theme.css', import.meta.url), + 'utf8', + ); + + assert.match(source, /modelValue/); + assert.match(source, /v-show="modelValue"/); + assert.doesNotMatch(source, /v-if="modelValue"/); + assert.match(source, /class="sheet-backdrop"[^>]*:main-thread-ref="backdropRef"[^>]*@tap="requestClose"/s); + assert.match(source, /class="sheet-surface"/); + assert.match( + source, + /\.sheet-layer\s*\{[^}]*overflow:\s*hidden/s, + 'the sheet viewport must clip motion above the bottom nav and safe area', + ); + assert.match(source, /:main-thread-ref="surfaceRef"/); + assert.match(source, /:main-thread-bindlayoutchange="handleSurfaceLayout"/); + assert.match(source, /class="sheet-handle"/); + assert.match(source, / \{[\s\S]*?applySheetMotion\(target\);/, + 'the transition must be committed before its target in a later frame', + ); + assert.match(source, /setTimeout\(\(\) => finishSettle/); + assert.doesNotMatch( + source, + /requestAnimationFrame\(step\)/, + 'a recursive worklet rAF loop can collapse into one native render batch', + ); + assert.match(source, /animationGenerationRef/); + assert.match(source, /watch\(\(\) => props\.modelValue/); + assert.match(source, /prepareSheetForOpen/); + assert.match(source, /runOnBackground\(requestClose\)/); + assert.match(source, /@after-leave="handleAfterLeave"/); + assert.match(source, /transition:\s*opacity/); + assert.match(source, /transition:\s*transform/); + assert.doesNotMatch(source, /transition:\s*all/); +}); + +test('guest bottom navigation and More menu mirror upstream Elk', () => { + const tabs = navItems.buildBottomTabs?.({ authenticated: false, server: 'mas.to' }); + assert.deepEqual(tabs?.map(item => item.label), [ + 'Explore', + 'Local', + 'Federated', + 'More menu', + ]); + + const menu = navItems.buildMoreMenuItems?.({ + authenticated: false, + server: 'mas.to', + activePath: '/mas.to/public', + }); + assert.deepEqual(menu?.map(item => item.label), [ + 'Search', + 'Home', + 'Notifications', + 'Conversations', + 'Favorites', + 'Bookmarks', + 'Compose', + 'Scheduled posts', + 'Explore', + 'Local', + 'Federated', + 'Lists', + 'Hashtags', + 'Settings', + ]); + + const byKey = Object.fromEntries(menu?.map(item => [item.key, item]) ?? []); + for (const key of ['home', 'notifications', 'conversations', 'favorites', 'bookmarks', 'compose', 'scheduled', 'lists', 'hashtags']) + assert.equal(byKey[key]?.disabled, true, key); + for (const key of ['search', 'explore', 'local', 'federated', 'settings']) + assert.equal(byKey[key]?.disabled, false, key); + assert.equal(byKey.federated?.active, true); +}); + +test('authenticated More menu enables implemented private routes', () => { + const menu = navItems.buildMoreMenuItems?.({ + authenticated: true, + server: 'mas.to', + activePath: '/bookmarks', + }); + const byKey = Object.fromEntries(menu?.map(item => [item.key, item]) ?? []); + + for (const key of ['home', 'notifications', 'favorites', 'bookmarks', 'compose']) + assert.equal(byKey[key]?.disabled, false, key); + assert.equal(byKey.bookmarks?.active, true); + assert.equal(byKey.conversations?.disabled, true); +}); + +test('bottom navigation renders the Elk More sheet and persistent close tab', async () => { + const source = await readFile( + new URL('../src/components/NavBottom.vue', import.meta.url), + 'utf8', + ); + + assert.match(source, /import Sheet from '.\/sheet\/Sheet\.vue'/); + assert.match(source, /buildBottomTabs/); + assert.match(source, /buildMoreMenuItems/); + assert.match(source, /]*v-model="sheetVisible"/s); + assert.match(source, /sheetVisible \? 'close-line' : tab\.icon/); + assert.match(source, /nav-sheet-item-disabled/); + assert.match(source, /toggleTheme/); + assert.match(source, /toggleZenMode/); + assert.match(source, /sheetVisible\.value = false/); +}); + +test('sheet and bottom bar share one stable root across Transition removal', async () => { + const [source, sheetSource] = await Promise.all([ + readFile( + new URL('../src/components/NavBottom.vue', import.meta.url), + 'utf8', + ), + readFile( + new URL('../src/components/sheet/Sheet.vue', import.meta.url), + 'utf8', + ), + ]); + + assert.match(source, /