diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18ab393..08b565c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,9 @@ jobs: secret-scan: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read steps: - name: Checkout uses: actions/checkout@v4 @@ -38,5 +41,3 @@ jobs: uses: gitleaks/gitleaks-action@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - config-path: .gitleaks.toml diff --git a/.gitignore b/.gitignore index 18744a6..e3c6b85 100644 --- a/.gitignore +++ b/.gitignore @@ -58,9 +58,6 @@ next-env.d.ts # Scrollback config (written by onboarding; may contain credentials) scrollback.config.json -# Legacy config filename from the pre-release project name -feedsilo.config.json - # SQLite generated Prisma client /lib/generated/prisma-sqlite diff --git a/README.md b/README.md index ddb2e1c..6efa741 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,30 @@ It gives you a browser-extension capture flow for X/Twitter, RSS syncing, a poli Built with Next.js 16, React 18, Prisma 7, PostgreSQL/pgvector, SQLite, Tailwind CSS, and optional OpenAI or Google Gemini AI providers. +## Launch Trailer + +The production launch trailer is committed with the repo and published by the app as a static asset: + +- Video: [`/launch/scrollback-launch-trailer.mp4`](public/launch/scrollback-launch-trailer.mp4) +- Poster: [`/launch/scrollback-launch-trailer-poster.jpg`](public/launch/scrollback-launch-trailer-poster.jpg) +- Source kit: [`scrollback-launch-trailer/`](scrollback-launch-trailer/) + +After deployment, the public video URL will be: + +```text +https://scrollback.atomtan.studio/launch/scrollback-launch-trailer.mp4 +``` + +Launch asset map: + +| Path | Purpose | +| --- | --- | +| `public/launch/scrollback-launch-trailer.mp4` | Public launch trailer served by the deployed app | +| `public/launch/scrollback-launch-trailer-poster.jpg` | Poster/share image for the launch trailer | +| `public/trailer-capture/*.svg` | Lightweight feature cards used for trailer and launch collateral | +| `scrollback-launch-trailer/` | Full editable HyperFrames production kit | +| `scrollback-launch-trailer/renders/scrollback-launch-trailer-command-center-glasscore-v4.mp4` | Selected final render kept with the source kit | + ## Features - **One-click X/Twitter capture** - Save posts, threads, linked article metadata, images, GIFs, and videos from the browser extension. @@ -28,6 +52,21 @@ Scrollback is focused on two capture paths: It can enrich linked article cards and some long-form X article content when that data is available in the page or X API responses, but it is not meant to be a universal web clipper yet. +## Browser Extension and X API Safety + +The Scrollback Capture browser extension can save individual X/Twitter posts, threads, media, and linked article cards without an X API token. That keeps setup simple and is useful for normal one-click capture. + +For bulk capture, especially syncing Likes and Bookmarks, Scrollback strongly recommends using an official X API bearer token or OAuth connection. API mode is more reliable, avoids scraping private X web endpoints, and is the lowest-risk way to import large parts of your account activity. + +Without the X API, the extension can still attempt page-based capture from visible X pages as you scroll. That mode is best-effort and depends on X's web app behavior. Because it may rely on internal X web requests or page scraping, use it thoughtfully and understand that it can carry more account risk than official API access. + +Practical guidance: + +- Use the extension without X API for occasional one-click saves. +- Add an X API bearer token before bulk importing Likes or Bookmarks. +- Use OAuth from Settings when you want direct bookmark and like syncing. +- If account safety matters more than convenience, prefer API mode. + ## Quick Start ### Requirements @@ -64,6 +103,40 @@ The onboarding wizard does the same setup work you can do manually: Admin account creation is required. AI provider, X API, RSS feeds, local/R2 media storage, and extension pairing can all be configured later from Settings. +## SQLite Onboarding Smoke Test + +Use this before launch, before publishing a release, or whenever setup code changes. Run it from a clean checkout or temporary copy so existing `.env.local`, `scrollback.config.json`, and database files do not mask first-run behavior. + +```bash +nvm use 22 +npm install +SCROLLBACK_SETUP_TOKEN="$(openssl rand -base64 32)" npm run dev +``` + +Then open [http://localhost:3000/onboarding](http://localhost:3000/onboarding) and follow the wizard: + +1. Paste the setup token from `SCROLLBACK_SETUP_TOKEN`. +2. Choose **Local SQLite**. +3. Keep the default database path, or enter another local path such as `./scrollback-smoke.db`. +4. Continue through migration. +5. Create the first admin account. +6. Skip AI provider and X API setup unless you are testing those integrations. +7. Skip or save the extension pairing token. +8. Confirm the app opens at `/` and you are signed in. + +Expected local files after a successful SQLite onboarding smoke test: + +- `scrollback.config.json` +- `.env.local` +- the SQLite database file you selected, for example `scrollback.db` + +Expected app state: + +- `/api/setup/status` returns `configured: true` and `databaseType: "sqlite"`. +- The SQLite database contains the core tables, including `users`, `content_items`, `categories`, and `rss_feeds`. +- The `users` table has one admin user. +- Reloading `/` shows the authenticated Scrollback app, not onboarding. + ## Configuration For local development, either use onboarding or copy `.env.example` to `.env.local`. @@ -88,14 +161,16 @@ Environment variables override values from `scrollback.config.json`. | `R2_SECRET_ACCESS_KEY` | No | R2 API token secret key | | `R2_BUCKET_NAME` | No | R2 bucket name | | `LOCAL_MEDIA_PATH` | No | Local directory for media storage when not using R2 | -| `XAPI_BEARER_TOKEN` | No | App-only X API bearer token for tweet lookup fallback | -| `XAPI_CLIENT_ID` | No | X OAuth 2.0 client ID for bookmark/like sync | +| `XAPI_BEARER_TOKEN` | Recommended for bulk capture | App-only X API bearer token for safer extension bulk capture and tweet lookup fallback | +| `XAPI_CLIENT_ID` | No | X OAuth 2.0 client ID for direct bookmark/like sync | | `XAPI_CLIENT_SECRET` | No | X OAuth 2.0 client secret | | `XAPI_ENCRYPTION_KEY` | No | 32-byte hex key for encrypted X OAuth token storage | | `DEMO_EMAIL`, `DEMO_PASSWORD` | No | Creates a read-only demo user at startup | ## Production Deploy +Production target: `https://scrollback.atomtan.studio` on a Hetzner VPS behind HTTPS. + ### 1. Use Node 22 Scrollback is pinned to Node 22.x. Set your host, CI, Docker image, or runtime to Node 22 before installing dependencies. @@ -156,6 +231,12 @@ http://localhost:3000 For production, use your deployed HTTPS origin. After code changes in `extension/`, reload the unpacked extension in your browser. +For the safest bulk capture experience, also configure an X API bearer token in the extension popup or Settings before importing Likes or Bookmarks. One-click saves can work without it, but API mode is recommended for account safety and reliability. + +### 6. Static launch assets + +The launch trailer is served by Next.js from `public/launch/`. No separate object storage or CDN step is required for the launch video unless traffic exceeds the VPS bandwidth budget. + ## Scripts | Script | Description | @@ -194,6 +275,10 @@ Expected result for a clean launch branch: See [RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md) for the full release checklist. +## Launch Posting Plan + +Use [docs/LAUNCH_PLAN.md](docs/LAUNCH_PLAN.md) for the launch-day posting checklist, asset URLs, and channel plan. + ## Known Limitations - X/Twitter capture is best-effort. X changes its markup and article surfaces diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 08fef33..afbd6d0 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -7,7 +7,7 @@ Use this before tagging or publishing the open-source release. - [ ] Merge the release branch into `dev`. - [ ] Run `npm run verify`. - [ ] Run `npm audit --audit-level=moderate`. -- [ ] Smoke-test a fresh first-run setup with `SCROLLBACK_SETUP_TOKEN`. +- [ ] Smoke-test a fresh first-run SQLite setup with `SCROLLBACK_SETUP_TOKEN`; follow the README's "SQLite Onboarding Smoke Test" and confirm login works. - [ ] Capture one X/Twitter post with media through the extension. - [ ] Add and sync one RSS feed. - [ ] Export or query a saved item through the remote query API. @@ -33,6 +33,8 @@ Use this before tagging or publishing the open-source release. ## Launch Assets -- [ ] Capture 2-4 clean product screenshots. -- [ ] Create a launch image or short demo clip. +- [ ] Confirm `public/launch/scrollback-launch-trailer.mp4` opens locally after `npm run dev`. +- [ ] Confirm `public/launch/scrollback-launch-trailer-poster.jpg` opens locally after `npm run dev`. +- [ ] Confirm the deployed trailer URL opens at `https://scrollback.atomtan.studio/launch/scrollback-launch-trailer.mp4`. +- [ ] Confirm the source trailer kit in `scrollback-launch-trailer/` is committed. - [ ] Write the launch post with the project URL, scope, and known limitations. diff --git a/app/api/setup/migrate/route.ts b/app/api/setup/migrate/route.ts index 02b9daf..b911cb8 100644 --- a/app/api/setup/migrate/route.ts +++ b/app/api/setup/migrate/route.ts @@ -54,10 +54,12 @@ async function pushSchema(config: ScrollbackConfig, schemaFlag: string): Promise }); } -async function ensureSqliteParentDirectory(databaseUrl: string): Promise { +async function ensureSqliteDatabaseFile(databaseUrl: string): Promise { const sqlitePath = resolveSqliteFilePath(databaseUrl); if (!sqlitePath) return; - await fs.mkdir(path.dirname(sqlitePath), { recursive: true }); + await fs.mkdir(/* turbopackIgnore: true */ path.dirname(sqlitePath), { recursive: true }); + const handle = await fs.open(/* turbopackIgnore: true */ sqlitePath, "a"); + await handle.close(); } async function verifySqliteSchema(databaseUrl: string): Promise { @@ -136,10 +138,6 @@ export async function POST(request: NextRequest) { localMedia: {}, }; - // Write config files - writeConfig(config); - invalidateConfigCache(); - // Run Prisma db push const schemaFlag = config.database.type === "sqlite" @@ -148,7 +146,7 @@ export async function POST(request: NextRequest) { try { if (config.database.type === "sqlite") { - await ensureSqliteParentDirectory(config.database.url); + await ensureSqliteDatabaseFile(config.database.url); } await pushSchema(config, schemaFlag); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -187,6 +185,11 @@ export async function POST(request: NextRequest) { } } + // Write config files only after schema setup succeeds. This avoids locking + // onboarding into a partially configured instance after a failed migration. + writeConfig(config); + invalidateConfigCache(); + // For PostgreSQL, try to create pgvector extension if (config.database.type !== "sqlite") { try { diff --git a/docs/LAUNCH_PLAN.md b/docs/LAUNCH_PLAN.md new file mode 100644 index 0000000..dc1d83c --- /dev/null +++ b/docs/LAUNCH_PLAN.md @@ -0,0 +1,76 @@ +# Scrollback Launch Plan + +## Launch Goal + +Launch Scrollback at **9:00 AM Pacific on Friday, May 8, 2026**. + +Definition of launched: + +- The PR is merged and deployed. +- `https://scrollback.atomtan.studio` loads over HTTPS. +- The trailer URL opens publicly. +- The GitHub repository/release is public and accurate. +- Announcement posts go out within the same launch window. +- The first hour is monitored for broken links, setup issues, replies, and questions. + +## Canonical Links + +- App: `https://scrollback.atomtan.studio` +- Trailer: `https://scrollback.atomtan.studio/launch/scrollback-launch-trailer.mp4` +- Trailer poster: `https://scrollback.atomtan.studio/launch/scrollback-launch-trailer-poster.jpg` +- GitHub repository: `https://github.com/atomtanstudio/scrollback` + +## Asset Checklist + +- `public/launch/scrollback-launch-trailer.mp4` is the deployed trailer file. +- `public/launch/scrollback-launch-trailer-poster.jpg` is the sharing/poster image. +- `scrollback-launch-trailer/` contains the full editable HyperFrames production kit. +- `scrollback-launch-trailer/renders/scrollback-launch-trailer-command-center-glasscore-v4.mp4` is the selected final render. + +## Pre-Post Checklist + +- Run `npm run verify`. +- Run `npm audit --audit-level=moderate`. +- Deploy the latest `main` branch to the Hetzner VPS. +- Confirm DNS points `scrollback.atomtan.studio` at the VPS. +- Confirm HTTPS is active for `scrollback.atomtan.studio`. +- Open the app, trailer, poster, README, privacy policy, and release checklist from the public domain. +- Smoke-test first-run SQLite onboarding from the README. +- Capture one X/Twitter post with the browser extension. +- Confirm README language recommends X API mode for bulk Likes and Bookmarks. + +## Simultaneous Posting Window + +Target all public posts for **9:00-9:15 AM Pacific**. Prepare all drafts before 8:45 AM so launch is copy/paste and link-checking, not writing. + +Launch channels: + +- GitHub release: trailer URL, self-hosting scope, setup notes, known limitations. +- X/Twitter: trailer-first post with app URL and GitHub URL. +- LinkedIn: privacy/self-hosting angle with trailer and repository link. +- Hacker News: concise "Show HN" post after the public URL and repository are verified. +- Reddit: targeted communities only, with a useful self-hosting/open-source framing rather than drive-by promotion. +- Relevant Discord/Slack communities: short post, trailer, GitHub, and what feedback would be useful. + +Recommended posting order inside the 15-minute window: + +1. Publish GitHub release. +2. Post X/Twitter with trailer. +3. Post LinkedIn. +4. Submit Hacker News. +5. Post Reddit/community links. +6. Reply to early questions and pin/favorite the best launch post as the canonical thread. + +## Launch Drafts Needed + +- X/Twitter: short trailer post plus a 4-6 post follow-up thread. +- LinkedIn: slightly longer post emphasizing self-hosting, privacy, and personal archives. +- Hacker News: title and first comment with technical details and limitations. +- Reddit: one version for self-hosted/open-source audiences and one for AI/builder audiences. +- GitHub release notes: what it is, how to run it, known limitations, and safety note for X API bulk capture. + +## Positioning + +Scrollback is a self-hosted archive for X/Twitter saves, RSS, media, prompts, search, AI enrichment, and agent-native retrieval. The launch message should stay concrete: own the archive, search it locally, enrich it when desired, and expose it to tools without giving a hosted service the library. + +Account-safety wording for extension posts: one-click capture works without the X API, but the official X API bearer token or OAuth path is recommended for bulk importing Likes and Bookmarks. diff --git a/lib/config/index.ts b/lib/config/index.ts index cfe098f..ebc041e 100644 --- a/lib/config/index.ts +++ b/lib/config/index.ts @@ -6,14 +6,10 @@ import { configSchema, type ScrollbackConfig, type DatabaseType } from "./schema const PROJECT_ROOT = /* turbopackIgnore: true */ process.cwd(); const DEFAULT_CONFIG_PATH = path.join(PROJECT_ROOT, "scrollback.config.json"); -const LEGACY_CONFIG_PATH = path.join(PROJECT_ROOT, "feedsilo.config.json"); const DEFAULT_ENV_PATH = path.join(PROJECT_ROOT, ".env.local"); function resolveConfigPath(configPath: string): string { - if (configPath !== DEFAULT_CONFIG_PATH) return configPath; - if (fs.existsSync(DEFAULT_CONFIG_PATH)) return DEFAULT_CONFIG_PATH; - if (fs.existsSync(LEGACY_CONFIG_PATH)) return LEGACY_CONFIG_PATH; - return DEFAULT_CONFIG_PATH; + return configPath; } export function readConfig( diff --git a/package-lock.json b/package-lock.json index 8259fce..a69552f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8548,9 +8548,9 @@ } }, "node_modules/hono": { - "version": "4.12.15", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.15.tgz", - "integrity": "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==", + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/package.json b/package.json index c3d69cd..151bc06 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "bugs": { "url": "https://github.com/atomtanstudio/scrollback/issues" }, - "homepage": "https://github.com/atomtanstudio/scrollback#readme", + "homepage": "https://scrollback.atomtan.studio", "engines": { "node": "22.x" }, @@ -89,6 +89,7 @@ }, "overrides": { "@hono/node-server": "1.19.13", + "hono": "4.12.18", "anymatch": { "picomatch": "2.3.2" }, diff --git a/proxy.ts b/proxy.ts index 60a4148..ddae68a 100644 --- a/proxy.ts +++ b/proxy.ts @@ -97,9 +97,7 @@ export async function proxy(request: NextRequest) { !pathname.startsWith("/api/") ) { const hasEnv = Boolean(process.env.DATABASE_URL && process.env.DATABASE_TYPE); - const configured = - request.cookies.get("scrollback-configured") ?? - request.cookies.get("feedsilo-configured"); + const configured = request.cookies.get("scrollback-configured"); if (!hasEnv && configured?.value !== "true") { return NextResponse.redirect(new URL("/onboarding", request.url)); } diff --git a/public/launch/scrollback-launch-trailer-poster.jpg b/public/launch/scrollback-launch-trailer-poster.jpg new file mode 100644 index 0000000..fd84b14 Binary files /dev/null and b/public/launch/scrollback-launch-trailer-poster.jpg differ diff --git a/public/launch/scrollback-launch-trailer.mp4 b/public/launch/scrollback-launch-trailer.mp4 new file mode 100644 index 0000000..18b76f9 Binary files /dev/null and b/public/launch/scrollback-launch-trailer.mp4 differ diff --git a/public/trailer-capture/launch-thread.svg b/public/trailer-capture/launch-thread.svg new file mode 100644 index 0000000..b72bca5 --- /dev/null +++ b/public/trailer-capture/launch-thread.svg @@ -0,0 +1,10 @@ + + + + + + + + X / Twitter capture + Launch thread + \ No newline at end of file diff --git a/public/trailer-capture/media-storage.svg b/public/trailer-capture/media-storage.svg new file mode 100644 index 0000000..71081c6 --- /dev/null +++ b/public/trailer-capture/media-storage.svg @@ -0,0 +1,10 @@ + + + + + + + + Images / video / GIFs + Private media + \ No newline at end of file diff --git a/public/trailer-capture/prompt-library.svg b/public/trailer-capture/prompt-library.svg new file mode 100644 index 0000000..68685fd --- /dev/null +++ b/public/trailer-capture/prompt-library.svg @@ -0,0 +1,10 @@ + + + + + + + + AI art research + Prompt library + \ No newline at end of file diff --git a/public/trailer-capture/rss-entry.svg b/public/trailer-capture/rss-entry.svg new file mode 100644 index 0000000..ad83410 --- /dev/null +++ b/public/trailer-capture/rss-entry.svg @@ -0,0 +1,10 @@ + + + + + + + + Feed sync + RSS entry + \ No newline at end of file diff --git a/public/trailer-capture/source-filters.svg b/public/trailer-capture/source-filters.svg new file mode 100644 index 0000000..bd34ef3 --- /dev/null +++ b/public/trailer-capture/source-filters.svg @@ -0,0 +1,10 @@ + + + + + + + + Articles / threads / RSS + Source filters + \ No newline at end of file diff --git a/public/trailer-capture/tagged-detail.svg b/public/trailer-capture/tagged-detail.svg new file mode 100644 index 0000000..8bd77e9 --- /dev/null +++ b/public/trailer-capture/tagged-detail.svg @@ -0,0 +1,10 @@ + + + + + + + + AI enrichment + Tags + summary + \ No newline at end of file diff --git a/scripts/scrollback-memory-search.mjs b/scripts/scrollback-memory-search.mjs index 3e2ea12..b8abce5 100644 --- a/scripts/scrollback-memory-search.mjs +++ b/scripts/scrollback-memory-search.mjs @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; -process.env.SCROLLBACK_MEMORY_GATEWAY_ENV ||= process.env.FEEDSILO_MEMORY_GATEWAY_ENV || path.join( +process.env.SCROLLBACK_MEMORY_GATEWAY_ENV ||= path.join( os.homedir(), ".codex", "secrets", diff --git a/scrollback-launch-trailer/AUDIO_DIRECTION.md b/scrollback-launch-trailer/AUDIO_DIRECTION.md new file mode 100644 index 0000000..f781a10 --- /dev/null +++ b/scrollback-launch-trailer/AUDIO_DIRECTION.md @@ -0,0 +1,12 @@ +# Music / Audio Direction + +Current preview uses the opening minute of `Glasscore Launch.wav`, trimmed to `assets/audio/glasscore-launch-60s.wav` with a short fade-in/out and light loudness normalization. + +Final direction: upbeat professional synth pulse, warm analog bass, glassy arps, clean electronic percussion, a brighter rise at `0:43`, and a confident logo resolve from `0:52` to `1:00`. Keep space for optional voiceover by ducking the music around the scene titles. + +Suggested voiceover slots: + +- `0:00-0:07` — "The internet remembers less than you think." +- `0:07-0:24` — "Scrollback saves the posts, threads, articles, and media worth keeping." +- `0:24-0:43` — "Search by meaning, organize by theme, and let enrichment add context." +- `0:43-1:00` — "Self-host your archive. Own your memory." diff --git a/scrollback-launch-trailer/DESIGN.md b/scrollback-launch-trailer/DESIGN.md new file mode 100644 index 0000000..173d193 --- /dev/null +++ b/scrollback-launch-trailer/DESIGN.md @@ -0,0 +1,36 @@ +# Scrollback Launch Trailer Design + +## Style Prompt + +Official brand promo for a self-hosted knowledge archive: technical, polished, bright, and cinematic. Blend a futuristic sci-fi command-center language with warm sunrise color, crisp product captures, luminous grid traces, and precise editorial typography. The product UI is dark and information-dense, so the trailer world should feel like warm light moving across professional infrastructure rather than a consumer app ad. + +## Colors + +- Warm canvas: `#fff5e4` +- Ink: `#0c1014` +- Muted ink: `#5f615e` +- Product dark: `#080c10` +- Scrollback amber: `#d8a646` +- Signal teal: `#42d6c5` +- Coral pulse: `#ef7f55` +- Electric blue accent: `#5b8cff` + +## Typography + +- Primary: `Inter`, `ui-sans-serif`, `system-ui`, `-apple-system`, `BlinkMacSystemFont`, `"Segoe UI"`, sans-serif. +- Use large, confident headlines with tight line height and normal letter spacing. +- Keep captions spare and product-specific. + +## Motion + +- Smooth camera pushes, scanline reveals, restrained glints, and data-ribbon motion. +- Use `power3.out`, `expo.out`, and `sine.inOut`. +- Let screen captures drift subtly while lattice and signal elements move on long linear paths. +- Keep all motion deterministic and finite for render stability. + +## What NOT to Do + +- No purple-blue gradient dominance, loud kinetic typography, decorative bokeh/orbs, or generic AI visuals. +- Do not fill the frame with feature grids or mock product screens. +- Do not over-explain the product in visible text. +- Do not let the old screenshot brand become the launch brand; Scrollback lockups own the frame. diff --git a/scrollback-launch-trailer/README.md b/scrollback-launch-trailer/README.md new file mode 100644 index 0000000..1d39a46 --- /dev/null +++ b/scrollback-launch-trailer/README.md @@ -0,0 +1,29 @@ +# Scrollback Launch Trailer + +HyperFrames source for a 60 second, 16:9 product launch trailer. + +## Edit Points + +- `index.html` contains the full composition, timing, copy, screenshot choices, and animation. +- `DESIGN.md` defines the warm sci-fi brand promo system. +- `AUDIO_DIRECTION.md` describes the temp bed and final music/VO plan. +- `assets/site-audit-clean/` contains the live-site captures used in the trailer. +- `assets/site-audit/site-audit.json` records the audited routes and feature notes. +- `renders/scrollback-launch-trailer-command-center-glasscore-v4.mp4` is the selected final render. +- `public/launch/scrollback-launch-trailer.mp4` is the deploy-facing copy served by the app. + +## Commands + +```bash +npm run music +npm run lint +npm run inspect +npm run render +npm run preview +``` + +Published render target: + +```text +https://scrollback.atomtan.studio/launch/scrollback-launch-trailer.mp4 +``` diff --git a/scrollback-launch-trailer/assets/audio/glass-horizon-last-minute.wav b/scrollback-launch-trailer/assets/audio/glass-horizon-last-minute.wav new file mode 100644 index 0000000..71dfa46 Binary files /dev/null and b/scrollback-launch-trailer/assets/audio/glass-horizon-last-minute.wav differ diff --git a/scrollback-launch-trailer/assets/audio/glasscore-launch-60s.wav b/scrollback-launch-trailer/assets/audio/glasscore-launch-60s.wav new file mode 100644 index 0000000..d04d6f2 Binary files /dev/null and b/scrollback-launch-trailer/assets/audio/glasscore-launch-60s.wav differ diff --git a/scrollback-launch-trailer/assets/audio/scrollback-pulse-bed.wav b/scrollback-launch-trailer/assets/audio/scrollback-pulse-bed.wav new file mode 100644 index 0000000..4265261 Binary files /dev/null and b/scrollback-launch-trailer/assets/audio/scrollback-pulse-bed.wav differ diff --git a/scrollback-launch-trailer/assets/brand/scrollback-logo.svg b/scrollback-launch-trailer/assets/brand/scrollback-logo.svg new file mode 100644 index 0000000..48dd9ac --- /dev/null +++ b/scrollback-launch-trailer/assets/brand/scrollback-logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/scrollback-launch-trailer/assets/brand/scrollback-wordmark.svg b/scrollback-launch-trailer/assets/brand/scrollback-wordmark.svg new file mode 100644 index 0000000..dda9d37 --- /dev/null +++ b/scrollback-launch-trailer/assets/brand/scrollback-wordmark.svg @@ -0,0 +1,11 @@ + + + + + + + + scroll + + back + diff --git a/scrollback-launch-trailer/assets/screenshots-live-clean/01-live-capture-library.png b/scrollback-launch-trailer/assets/screenshots-live-clean/01-live-capture-library.png new file mode 100644 index 0000000..c21d744 Binary files /dev/null and b/scrollback-launch-trailer/assets/screenshots-live-clean/01-live-capture-library.png differ diff --git a/scrollback-launch-trailer/assets/screenshots-live-clean/02-live-search-results.png b/scrollback-launch-trailer/assets/screenshots-live-clean/02-live-search-results.png new file mode 100644 index 0000000..e21cd69 Binary files /dev/null and b/scrollback-launch-trailer/assets/screenshots-live-clean/02-live-search-results.png differ diff --git a/scrollback-launch-trailer/assets/screenshots-live-clean/03-live-articles-filter.png b/scrollback-launch-trailer/assets/screenshots-live-clean/03-live-articles-filter.png new file mode 100644 index 0000000..273df01 Binary files /dev/null and b/scrollback-launch-trailer/assets/screenshots-live-clean/03-live-articles-filter.png differ diff --git a/scrollback-launch-trailer/assets/screenshots-live-clean/04-live-detail-summary-tags.png b/scrollback-launch-trailer/assets/screenshots-live-clean/04-live-detail-summary-tags.png new file mode 100644 index 0000000..bfe2d01 Binary files /dev/null and b/scrollback-launch-trailer/assets/screenshots-live-clean/04-live-detail-summary-tags.png differ diff --git a/scrollback-launch-trailer/assets/screenshots-live-clean/05-live-detail-context.png b/scrollback-launch-trailer/assets/screenshots-live-clean/05-live-detail-context.png new file mode 100644 index 0000000..6ac4450 Binary files /dev/null and b/scrollback-launch-trailer/assets/screenshots-live-clean/05-live-detail-context.png differ diff --git a/scrollback-launch-trailer/assets/screenshots-live-clean/06-live-settings-export.png b/scrollback-launch-trailer/assets/screenshots-live-clean/06-live-settings-export.png new file mode 100644 index 0000000..6c6efca Binary files /dev/null and b/scrollback-launch-trailer/assets/screenshots-live-clean/06-live-settings-export.png differ diff --git a/scrollback-launch-trailer/assets/site-audit-clean/01-library.png b/scrollback-launch-trailer/assets/site-audit-clean/01-library.png new file mode 100644 index 0000000..3ab04ba Binary files /dev/null and b/scrollback-launch-trailer/assets/site-audit-clean/01-library.png differ diff --git a/scrollback-launch-trailer/assets/site-audit-clean/02-search-ai.png b/scrollback-launch-trailer/assets/site-audit-clean/02-search-ai.png new file mode 100644 index 0000000..ce6d490 Binary files /dev/null and b/scrollback-launch-trailer/assets/site-audit-clean/02-search-ai.png differ diff --git a/scrollback-launch-trailer/assets/site-audit-clean/03-articles.png b/scrollback-launch-trailer/assets/site-audit-clean/03-articles.png new file mode 100644 index 0000000..3e27698 Binary files /dev/null and b/scrollback-launch-trailer/assets/site-audit-clean/03-articles.png differ diff --git a/scrollback-launch-trailer/assets/site-audit-clean/04-detail-context.png b/scrollback-launch-trailer/assets/site-audit-clean/04-detail-context.png new file mode 100644 index 0000000..5d64626 Binary files /dev/null and b/scrollback-launch-trailer/assets/site-audit-clean/04-detail-context.png differ diff --git a/scrollback-launch-trailer/assets/site-audit-clean/05-settings-data.png b/scrollback-launch-trailer/assets/site-audit-clean/05-settings-data.png new file mode 100644 index 0000000..86e8253 Binary files /dev/null and b/scrollback-launch-trailer/assets/site-audit-clean/05-settings-data.png differ diff --git a/scrollback-launch-trailer/assets/site-audit-clean/06-admin-items.png b/scrollback-launch-trailer/assets/site-audit-clean/06-admin-items.png new file mode 100644 index 0000000..76e617a Binary files /dev/null and b/scrollback-launch-trailer/assets/site-audit-clean/06-admin-items.png differ diff --git a/scrollback-launch-trailer/hyperframes.json b/scrollback-launch-trailer/hyperframes.json new file mode 100644 index 0000000..5fb1d6d --- /dev/null +++ b/scrollback-launch-trailer/hyperframes.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://hyperframes.heygen.com/schema/hyperframes.json", + "registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry", + "paths": { + "blocks": "compositions", + "components": "compositions/components", + "assets": "assets" + } +} diff --git a/scrollback-launch-trailer/index.html b/scrollback-launch-trailer/index.html new file mode 100644 index 0000000..1979f85 --- /dev/null +++ b/scrollback-launch-trailer/index.html @@ -0,0 +1,702 @@ + + + + + + + + + +
+ + +
+
+
+ +
+ + scrollback +
+ +
+
+
+
+
+
Private Archive Online
+
scrollback
+

A self-hosted memory layer for the internet you actually use.

+
+ +
+
+
Capture Flow
+

The feed becomes infrastructure.

+

Tweets, threads, articles, RSS, images, and video prompts land in one searchable command center.

+
+
4,689live saves indexed
+
6recurring topic lanes detected
+
+
+
+
+
librarylive data
+ +
+
Tweets / Threads / RSS
+
4,689 saves
+
+
+ +
+
+
+
searchsemantic recall
+ +
+
+
+
AI
+
50 results
+
+
+
+
Recall
+

Find the signal again.

+

Search the archive by idea, source, author, topic, and the patterns you keep returning to.

+
+
AI50 matching saves
+
CMDinstant keyboard recall
+
+
+
+ +
+
+
Structure
+

Every format stays readable.

+

Articles, prompts, threads, media, categories, and source metadata keep their shape instead of flattening into bookmarks.

+
+
72articles captured
+
RSStrusted feeds in the same archive
+
+
+
+
+
articlesfiltered
+ +
+
+
librarytopic lanes
+ +
+
Articles / Threads / Prompts
+
+
+ +
+
+
+
detailcontext retained
+ +
+
tags
+
source stats
+
related saves
+
+
+
Context
+

Every save keeps its trail.

+

Author cards, source links, tags, related items, media backfill, and admin actions remain attached.

+
+
TAGtopic context beside the item
+
CTXsource and related memory preserved
+
+
+
+ +
+
+
Operations
+

Own the whole archive.

+

Admin review, manual capture, user libraries, media storage, export, and self-hosted control are part of the system.

+
+
ADMreview by user, type, source, and date
+
CSVexport JSON or CSV any time
+
+
+
+
+
admininventory control
+ +
+
+
settingsexport + R2
+ +
+
+
+ +
+
Launch System Ready
+

Scroll back to anything.

+

Self-hosted. Searchable. Built for the long life of your internet memory.

+
+
+
+
+
+
+ +
+ Scrollback +
+ 00:60 +
+ + +
+ + diff --git a/scrollback-launch-trailer/package.json b/scrollback-launch-trailer/package.json new file mode 100644 index 0000000..11aa4ab --- /dev/null +++ b/scrollback-launch-trailer/package.json @@ -0,0 +1,12 @@ +{ + "name": "scrollback-launch-trailer", + "private": true, + "type": "module", + "scripts": { + "music": "node scripts/generate-audio-bed.mjs", + "lint": "npx --yes hyperframes@0.4.45 lint", + "inspect": "npx --yes hyperframes@0.4.45 inspect --samples 18", + "preview": "npx --yes hyperframes@0.4.45 preview --port 3002", + "render": "npx --yes hyperframes@0.4.45 render --quality draft --output renders/scrollback-launch-trailer-preview.mp4" + } +} diff --git a/scrollback-launch-trailer/renders/contact-sheet-command-center-v3.jpg b/scrollback-launch-trailer/renders/contact-sheet-command-center-v3.jpg new file mode 100644 index 0000000..fd84b14 Binary files /dev/null and b/scrollback-launch-trailer/renders/contact-sheet-command-center-v3.jpg differ diff --git a/scrollback-launch-trailer/renders/scrollback-launch-trailer-command-center-glasscore-v4.mp4 b/scrollback-launch-trailer/renders/scrollback-launch-trailer-command-center-glasscore-v4.mp4 new file mode 100644 index 0000000..18b76f9 Binary files /dev/null and b/scrollback-launch-trailer/renders/scrollback-launch-trailer-command-center-glasscore-v4.mp4 differ diff --git a/scrollback-launch-trailer/scripts/capture-live-screens.mjs b/scrollback-launch-trailer/scripts/capture-live-screens.mjs new file mode 100644 index 0000000..ba27d39 --- /dev/null +++ b/scrollback-launch-trailer/scripts/capture-live-screens.mjs @@ -0,0 +1,143 @@ +import { mkdirSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); +const outDir = join(root, "scrollback-launch-trailer", "assets", "screenshots-live"); +const require = createRequire("/Users/richgates/.npm/_npx/4243bb55c76b61c0/node_modules/puppeteer-core/package.json"); +const puppeteer = require("puppeteer-core"); + +const executablePath = "/Users/richgates/Library/Caches/ms-playwright/chromium-1217/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"; +const base = process.env.SCROLLBACK_CAPTURE_BASE_URL || "http://localhost:3000"; +const loginEmail = process.env.SCROLLBACK_CAPTURE_EMAIL || "trailer@scrollback.local"; +const loginPassword = process.env.SCROLLBACK_CAPTURE_PASSWORD || "scrollback-trailer-pass"; +const fillMode = process.env.SCROLLBACK_CAPTURE_FILL_MODE || "direct"; + +mkdirSync(outDir, { recursive: true }); + +const browser = await puppeteer.launch({ + executablePath, + headless: "new", + args: ["--no-sandbox", "--disable-setuid-sandbox", "--font-render-hinting=none"], +}); + +const page = await browser.newPage(); +await page.setViewport({ width: 1920, height: 1200, deviceScaleFactor: 2 }); + +async function waitForApp() { + await page.waitForNetworkIdle({ idleTime: 600, timeout: 15_000 }).catch(() => {}); + await page.evaluate(() => document.fonts?.ready).catch(() => {}); + await page.addStyleTag({ + content: ` + nextjs-portal, + [data-nextjs-toast], + [data-nextjs-dialog-overlay], + [data-nextjs-build-error] { + display: none !important; + opacity: 0 !important; + } + `, + }).catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 500)); +} + +async function login() { + await page.goto(`${base}/login?callbackUrl=/`, { waitUntil: "domcontentloaded" }); + await waitForApp(); + await page.waitForSelector('input[name="email"]', { timeout: 15_000 }); + if (fillMode === "type") { + await page.click('input[name="email"]', { clickCount: 3 }); + await page.type('input[name="email"]', loginEmail); + await page.click('input[name="password"]', { clickCount: 3 }); + await page.type('input[name="password"]', loginPassword); + } else { + await page.evaluate( + ({ email, password }) => { + for (const [name, value] of [ + ["email", email], + ["password", password], + ]) { + const input = document.querySelector(`input[name="${name}"]`); + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + } + }, + { email: loginEmail, password: loginPassword }, + ); + } + await page.click('button[type="submit"]'); + await page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 15_000 }).catch(() => {}); + await waitForApp(); + await page.waitForFunction( + () => !/\/login(?:\?|$)/.test(window.location.pathname + window.location.search), + { timeout: 15_000 }, + ).catch(() => {}); + await waitForApp(); + + const state = await page.evaluate(() => ({ + url: window.location.href, + text: document.body.innerText.replace(/\s+/g, " ").trim().slice(0, 500), + })); + + if (/\/login(?:\?|$)/.test(new URL(state.url).pathname + new URL(state.url).search)) { + throw new Error(`Login did not complete. Current page: ${state.url}. Text: ${state.text}`); + } +} + +async function capture(name, path, afterLoad) { + await page.goto(`${base}${path}`, { waitUntil: "domcontentloaded" }); + await waitForApp(); + if (afterLoad) await afterLoad(); + await waitForApp(); + await page.screenshot({ path: join(outDir, name), fullPage: false, type: "png" }); + console.log(`Captured ${name}`); +} + +await login(); + +const discovery = await page.evaluate(async () => { + const text = document.body.innerText; + const links = Array.from(document.querySelectorAll('a[href^="/item/"]')).map((link) => ({ + href: link.getAttribute("href"), + text: link.textContent?.replace(/\s+/g, " ").trim() || "", + })); + return { text, links }; +}); + +const itemLinks = Array.from(new Set(discovery.links.map((link) => link.href).filter(Boolean))); + +async function findDetail(predicate) { + for (const href of itemLinks.slice(0, 24)) { + await page.goto(`${base}${href}`, { waitUntil: "domcontentloaded" }); + await waitForApp(); + const text = await page.evaluate(() => document.body.innerText); + if (predicate(text)) return href; + } + return itemLinks[0] || "/"; +} + +const taggedDetailPath = await findDetail((text) => text.includes("AI Summary") && text.includes("Tags")); +const mediaDetailPath = await findDetail((text) => /Backfill Media|Read Original|AI Summary|Tags/.test(text)); + +await capture("01-live-capture-library.png", "/"); +await capture("02-live-search-results.png", "/?q=AI", async () => { + await page.waitForFunction(() => /result|results|No results/i.test(document.body.innerText), { timeout: 10_000 }).catch(() => {}); +}); +await capture("03-live-articles-filter.png", "/?type=article", async () => { + await page.waitForFunction(() => document.body.innerText.includes("Articles"), { timeout: 10_000 }).catch(() => {}); +}); +await capture("04-live-detail-summary-tags.png", taggedDetailPath, async () => { + await page.waitForFunction(() => document.body.innerText.includes("AI Summary") || document.body.innerText.includes("Tags"), { timeout: 10_000 }).catch(() => {}); +}); +await capture("05-live-detail-context.png", mediaDetailPath); +await capture("06-live-settings-export.png", "/settings", async () => { + await page.waitForFunction(() => document.body.innerText.includes("Export JSON"), { timeout: 15_000 }).catch(() => {}); + await page.evaluate(() => { + const dataHeading = Array.from(document.querySelectorAll("h3, h4")).find((el) => el.textContent?.trim() === "Data"); + dataHeading?.scrollIntoView({ block: "center" }); + }); +}); + +await browser.close(); diff --git a/scrollback-launch-trailer/scripts/capture-site-audit.mjs b/scrollback-launch-trailer/scripts/capture-site-audit.mjs new file mode 100644 index 0000000..9114de8 --- /dev/null +++ b/scrollback-launch-trailer/scripts/capture-site-audit.mjs @@ -0,0 +1,151 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); +const outDir = join(root, "scrollback-launch-trailer", "assets", "site-audit"); +const require = createRequire("/Users/richgates/.npm/_npx/4243bb55c76b61c0/node_modules/puppeteer-core/package.json"); +const puppeteer = require("puppeteer-core"); + +const executablePath = "/Users/richgates/Library/Caches/ms-playwright/chromium-1217/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"; +const base = process.env.SCROLLBACK_CAPTURE_BASE_URL || "http://localhost:3000"; +const loginEmail = process.env.SCROLLBACK_CAPTURE_EMAIL || "trailer@scrollback.local"; +const loginPassword = process.env.SCROLLBACK_CAPTURE_PASSWORD || "scrollback-trailer-pass"; + +mkdirSync(outDir, { recursive: true }); + +const browser = await puppeteer.launch({ + executablePath, + headless: "new", + args: ["--no-sandbox", "--disable-setuid-sandbox", "--font-render-hinting=none"], +}); + +const page = await browser.newPage(); +await page.setViewport({ width: 1920, height: 1200, deviceScaleFactor: 2 }); + +const audit = []; + +async function waitForApp() { + await page.waitForNetworkIdle({ idleTime: 700, timeout: 18_000 }).catch(() => {}); + await page.evaluate(() => document.fonts?.ready).catch(() => {}); + await page.addStyleTag({ + content: ` + nextjs-portal, + [data-nextjs-toast], + [data-nextjs-dialog-overlay], + [data-nextjs-build-error] { display: none !important; opacity: 0 !important; } + `, + }).catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 700)); +} + +async function login() { + await page.goto(`${base}/login?callbackUrl=/`, { waitUntil: "domcontentloaded" }); + await waitForApp(); + await page.waitForSelector('input[name="email"]', { timeout: 15_000 }); + await page.evaluate( + ({ email, password }) => { + for (const [name, value] of [ + ["email", email], + ["password", password], + ]) { + const input = document.querySelector(`input[name="${name}"]`); + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + } + }, + { email: loginEmail, password: loginPassword }, + ); + await page.click('button[type="submit"]'); + await page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 15_000 }).catch(() => {}); + await waitForApp(); + + const state = await page.evaluate(() => ({ + url: window.location.href, + text: document.body.innerText.replace(/\s+/g, " ").trim().slice(0, 500), + })); + if (/\/login(?:\?|$)/.test(new URL(state.url).pathname + new URL(state.url).search)) { + throw new Error(`Login did not complete. Current page: ${state.url}. Text: ${state.text}`); + } +} + +async function capture(name, path, afterLoad) { + await page.goto(`${base}${path}`, { waitUntil: "domcontentloaded" }); + await waitForApp(); + if (afterLoad) await afterLoad(); + await waitForApp(); + const text = await page.evaluate(() => document.body.innerText.replace(/\s+/g, " ").trim()); + const links = await page.evaluate(() => + Array.from(document.querySelectorAll("a[href]")) + .map((link) => link.getAttribute("href")) + .filter(Boolean) + .slice(0, 30), + ); + await page.screenshot({ path: join(outDir, name), fullPage: false, type: "png" }); + audit.push({ name, path, title: await page.title(), text: text.slice(0, 1600), links }); + console.log(`Captured ${name}`); + return { text, links }; +} + +await login(); + +const home = await capture("01-library.png", "/"); +const itemLinks = Array.from(new Set(home.links.filter((href) => href?.startsWith("/item/")))); + +async function findDetail(predicate) { + for (const href of itemLinks.slice(0, 36)) { + await page.goto(`${base}${href}`, { waitUntil: "domcontentloaded" }); + await waitForApp(); + const text = await page.evaluate(() => document.body.innerText.replace(/\s+/g, " ").trim()); + if (predicate(text)) return href; + } + return itemLinks[0] || "/"; +} + +const detailPath = await findDetail((text) => /Tags|AI Summary|Backfill Media|Read Original/i.test(text)); + +await capture("02-search-ai.png", "/?q=AI", async () => { + await page.waitForFunction(() => /results|No results|items/i.test(document.body.innerText), { timeout: 10_000 }).catch(() => {}); +}); +await capture("03-articles.png", "/?type=article", async () => { + await page.waitForFunction(() => /Articles/i.test(document.body.innerText), { timeout: 10_000 }).catch(() => {}); +}); +await capture("04-detail-context.png", detailPath, async () => { + await page.waitForFunction(() => /Tags|Read Original|Backfill Media|Processing Capture/i.test(document.body.innerText), { timeout: 10_000 }).catch(() => {}); +}); +await capture("05-settings-data.png", "/settings", async () => { + await page.waitForFunction(() => /Export JSON|Pinned Topics|Data/i.test(document.body.innerText), { timeout: 15_000 }).catch(() => {}); + await page.evaluate(() => { + const dataHeading = Array.from(document.querySelectorAll("h2, h3, h4")).find((el) => el.textContent?.trim() === "Data"); + dataHeading?.scrollIntoView({ block: "center" }); + }); +}); +await capture("06-admin-items.png", "/admin", async () => { + await page.waitForFunction(() => /Items|Add Item|total|matching/i.test(document.body.innerText), { timeout: 15_000 }).catch(() => {}); +}); +await capture("07-agent-search.png", "/agent-search", async () => { + await page.waitForFunction(() => /Agent Search|Search captured knowledge|Database/i.test(document.body.innerText), { timeout: 15_000 }).catch(() => {}); + const inputSelector = await page.evaluate(() => { + const inputs = Array.from(document.querySelectorAll("input")); + const target = + inputs.find((input) => /captured knowledge/i.test(input.getAttribute("placeholder") || "")) || + inputs.find((input) => input.type !== "hidden"); + if (!target) return null; + target.setAttribute("data-audit-target", "agent-search-input"); + return '[data-audit-target="agent-search-input"]'; + }); + if (inputSelector) { + await page.type(inputSelector, "AI workflow"); + await page.click('button[type="submit"]').catch(() => {}); + } + await page.waitForFunction(() => /results|Search failed|hybrid|vector/i.test(document.body.innerText), { timeout: 20_000 }).catch(() => {}); +}); + +writeFileSync( + join(outDir, "site-audit.json"), + JSON.stringify({ base, capturedAt: new Date().toISOString(), pages: audit }, null, 2), +); + +await browser.close(); diff --git a/scrollback-launch-trailer/scripts/generate-audio-bed.mjs b/scrollback-launch-trailer/scripts/generate-audio-bed.mjs new file mode 100644 index 0000000..5f5d284 --- /dev/null +++ b/scrollback-launch-trailer/scripts/generate-audio-bed.mjs @@ -0,0 +1,84 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const output = join(root, "assets", "audio", "scrollback-pulse-bed.wav"); + +const sampleRate = 44100; +const seconds = 60; +const bpm = 92; +const beat = 60 / bpm; +const totalSamples = sampleRate * seconds; +const data = new Int16Array(totalSamples); + +const bassNotes = [55, 55, 73.42, 65.41, 82.41, 73.42, 65.41, 49]; +const glassNotes = [220, 246.94, 293.66, 329.63, 392, 440, 493.88, 587.33]; + +const env = (t, attack, decay) => { + if (t < 0) return 0; + if (t < attack) return t / attack; + return Math.exp(-(t - attack) / decay); +}; + +const softClip = (x) => Math.tanh(x * 1.4) / Math.tanh(1.4); + +for (let i = 0; i < totalSamples; i += 1) { + const t = i / sampleRate; + const beatPos = t / beat; + const step = Math.floor(beatPos * 2); + const bar = Math.floor(beatPos / 4); + const halfBeat = (beatPos * 2) % 1; + const pulse = env(halfBeat * beat * 0.5, 0.012, 0.24); + const phase = Math.PI * 2; + + const bass = bassNotes[bar % bassNotes.length]; + let sample = Math.sin(phase * bass * t) * 0.18; + sample += Math.sin(phase * bass * 0.5 * t) * 0.12; + sample *= 0.72 + pulse * 0.28; + + const kickT = (beatPos % 2) * beat; + const kickEnv = env(kickT, 0.006, 0.18); + sample += Math.sin(phase * (44 + 30 * Math.exp(-kickT * 14)) * t) * kickEnv * 0.22; + + if (step % 4 === 1 || step % 8 === 6) { + const note = glassNotes[(step + bar) % glassNotes.length]; + const hitT = halfBeat * beat * 0.5; + const hitEnv = env(hitT, 0.004, 0.9); + sample += Math.sin(phase * note * t) * hitEnv * 0.09; + sample += Math.sin(phase * note * 2.01 * t) * hitEnv * 0.035; + } + + const shimmer = Math.sin(phase * 880 * t + Math.sin(phase * 0.08 * t) * 1.2); + const lateLift = t > 43 ? Math.min(1, (t - 43) / 8) : 0; + sample += shimmer * (0.012 + lateLift * 0.018); + + const resolve = t > 52 ? Math.max(0, 1 - (t - 52) / 8) : 1; + const fadeIn = Math.min(1, t / 2.5); + const fadeOut = Math.min(1, (seconds - t) / 4); + sample *= fadeIn * fadeOut * (0.86 + lateLift * 0.1) * (0.82 + resolve * 0.18); + data[i] = Math.round(softClip(sample) * 32767); +} + +const buffer = Buffer.alloc(44 + data.length * 2); +buffer.write("RIFF", 0); +buffer.writeUInt32LE(36 + data.length * 2, 4); +buffer.write("WAVE", 8); +buffer.write("fmt ", 12); +buffer.writeUInt32LE(16, 16); +buffer.writeUInt16LE(1, 20); +buffer.writeUInt16LE(1, 22); +buffer.writeUInt32LE(sampleRate, 24); +buffer.writeUInt32LE(sampleRate * 2, 28); +buffer.writeUInt16LE(2, 32); +buffer.writeUInt16LE(16, 34); +buffer.write("data", 36); +buffer.writeUInt32LE(data.length * 2, 40); + +for (let i = 0; i < data.length; i += 1) { + buffer.writeInt16LE(data[i], 44 + i * 2); +} + +mkdirSync(dirname(output), { recursive: true }); +writeFileSync(output, buffer); +console.log(`Wrote ${output}`); diff --git a/scrollback-launch-trailer/scripts/scrub-live-brand.mjs b/scrollback-launch-trailer/scripts/scrub-live-brand.mjs new file mode 100644 index 0000000..191939a --- /dev/null +++ b/scrollback-launch-trailer/scripts/scrub-live-brand.mjs @@ -0,0 +1,91 @@ +import { mkdirSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); +const require = createRequire(import.meta.url); +const sharp = require("sharp"); + +const groups = [ + { + inputDir: join(root, "scrollback-launch-trailer", "assets", "screenshots-live"), + outputDir: join(root, "scrollback-launch-trailer", "assets", "screenshots-live-clean"), + files: [ + "01-live-capture-library.png", + "02-live-search-results.png", + "03-live-articles-filter.png", + "04-live-detail-summary-tags.png", + "05-live-detail-context.png", + "06-live-settings-export.png", + ], + }, + { + inputDir: join(root, "scrollback-launch-trailer", "assets", "site-audit"), + outputDir: join(root, "scrollback-launch-trailer", "assets", "site-audit-clean"), + files: [ + "01-library.png", + "02-search-ai.png", + "03-articles.png", + "04-detail-context.png", + "05-settings-data.png", + "06-admin-items.png", + ], + }, +]; + +function brandScrubSvg({ replaceSettingsText = false, replaceSidebarSupport = false } = {}) { + return Buffer.from(` + + + + + + + + + + + + + + + + scroll + + back + ${ + replaceSettingsText + ? ` + Scrollback surfaces recurring capture themes as obvious interests bubble up.` + : "" + } + ${ + replaceSidebarSupport + ? ` + More from the maker of Scrollback` + : "" + } + + `); +} + +for (const group of groups) { + mkdirSync(group.outputDir, { recursive: true }); + for (const file of group.files) { + await sharp(join(group.inputDir, file)) + .composite([ + { + input: brandScrubSvg({ + replaceSettingsText: file === "05-settings-data.png", + replaceSidebarSupport: ["01-library.png", "02-search-ai.png", "03-articles.png"].includes(file), + }), + left: 0, + top: 0, + }, + ]) + .png({ compressionLevel: 9 }) + .toFile(join(group.outputDir, file)); + console.log(`Wrote ${join(group.outputDir, file)}`); + } +} diff --git a/scrollback-launch-trailer/scripts/seed-trailer-library.mjs b/scrollback-launch-trailer/scripts/seed-trailer-library.mjs new file mode 100644 index 0000000..1c4ed0a --- /dev/null +++ b/scrollback-launch-trailer/scripts/seed-trailer-library.mjs @@ -0,0 +1,360 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createHash, randomUUID } from "node:crypto"; +import Database from "better-sqlite3"; +import bcrypt from "bcryptjs"; + +const root = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); +const dbPath = join(root, "scrollback-preview.db"); +const publicAssetDir = join(root, "public", "trailer-capture"); + +const user = { + id: "trailer-user-0000-4000-8000-000000000001", + email: "trailer@scrollback.local", + password: "scrollback-trailer-pass", +}; + +function slugId(prefix, slug) { + return `${prefix}-${slug}`.slice(0, 60); +} + +function hash(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function isoMinutesAgo(minutes) { + return new Date(Date.now() - minutes * 60_000).toISOString(); +} + +function svgCard({ title, subtitle, accent, name }) { + const svg = ` + + + + + + + ${subtitle} + ${title} +`; + writeFileSync(join(publicAssetDir, name), svg); +} + +const categories = [ + ["developer-tools", "Developer Tools"], + ["product-launch", "Product Launch"], + ["research-systems", "Research Systems"], + ["ai-automation", "AI Automation"], + ["self-hosting", "Self Hosting"], + ["design-reference", "Design Reference"], +]; + +const tags = [ + ["launch-systems", "launch systems"], + ["agent-memory", "agent memory"], + ["semantic-search", "semantic search"], + ["rss-archive", "rss archive"], + ["x-twitter-capture", "x/twitter capture"], + ["media-storage", "media storage"], + ["export-api", "export api"], + ["prompt-library", "prompt library"], + ["private-archive", "private archive"], + ["design-reference", "design reference"], +]; + +const items = [ + { + id: "trailer-capture-home", + type: "thread", + platform: "x", + title: "Launch systems worth saving", + author: "maya", + display: "Maya Chen", + body: "Launch thread: capture the idea, the context, the replies, and the links before the timeline moves on. This is exactly the kind of signal Scrollback keeps searchable.", + summary: "A practical thread about launch systems, capture flows, and retaining context after social posts disappear from the timeline.", + tags: ["launch-systems", "x-twitter-capture", "private-archive"], + categories: ["product-launch", "research-systems"], + likes: 842, + views: 42000, + conversation: "launch-thread", + minutesAgo: 8, + media: { file: "launch-thread.svg", title: "Launch thread", subtitle: "X / Twitter capture", accent: "#8c7f9f" }, + }, + { + id: "trailer-detail-enrich", + type: "article", + platform: "web", + title: "Building a private launch-memory system", + author: "scrollbacklab", + display: "Scrollback Lab", + body: "A launch archive is more than bookmarks. It needs summaries, smart tags, durable source metadata, related items, and exportable context so a team can recover what mattered months later.", + html: "

Capture

A launch archive is more than bookmarks.

Enrichment

Summaries, smart tags, categories, and related items keep the archive useful.

Ownership

Self-hosted storage keeps the library private and portable.

", + summary: "Explains how Scrollback turns saved launch research into a private, searchable library with AI summaries, tags, categories, related items, and export-ready context.", + tags: ["launch-systems", "semantic-search", "agent-memory", "export-api", "private-archive"], + categories: ["developer-tools", "product-launch", "ai-automation"], + likes: 512, + views: 18800, + minutesAgo: 14, + media: { file: "tagged-detail.svg", title: "Tags + summary", subtitle: "AI enrichment", accent: "#b89462" }, + }, + { + id: "trailer-search-result", + type: "tweet", + platform: "x", + title: "Search by meaning, not by memory", + author: "nora", + display: "Nora Vale", + body: "The best archive search combines exact words with the things you half-remember: topic, author, source type, tags, and the summary you generated later.", + summary: "Search guidance for archives that combine saved text, tags, summaries, and source filters.", + tags: ["semantic-search", "agent-memory", "launch-systems"], + categories: ["developer-tools", "research-systems"], + likes: 1280, + views: 61000, + minutesAgo: 18, + }, + { + id: "trailer-rss-entry", + type: "article", + platform: "rss", + label: "Signal Review", + domain: "signalreview.example", + title: "RSS feeds as durable research inputs", + author: "Signal Review", + display: "Signal Review", + body: "RSS turns recurring sources into a steady stream of saved context. Scrollback keeps feed entries alongside posts, threads, media, and notes.", + summary: "RSS feed entries can become first-class research saves inside the same private archive.", + tags: ["rss-archive", "private-archive"], + categories: ["research-systems"], + likes: 91, + views: 7000, + minutesAgo: 26, + media: { file: "rss-entry.svg", title: "RSS entry", subtitle: "Feed sync", accent: "#d0a266" }, + }, + { + id: "trailer-media-storage", + type: "tweet", + platform: "x", + title: "Media belongs with the note", + author: "eliot", + display: "Eliot Harper", + body: "Screenshots, images, GIFs, and videos should stay attached to the save. The context is the post plus the media, not one or the other.", + summary: "A note about storing media together with the source post.", + tags: ["media-storage", "x-twitter-capture"], + categories: ["self-hosting"], + likes: 401, + views: 22500, + minutesAgo: 34, + media: { file: "media-storage.svg", title: "Private media", subtitle: "Images / video / GIFs", accent: "#74a6a0" }, + }, + { + id: "trailer-export-api", + type: "article", + platform: "web", + domain: "docs.scrollback.local", + title: "Export APIs for agent workflows", + author: "Scrollback Docs", + display: "Scrollback Docs", + body: "A private library becomes more useful when scripts, Obsidian, and agents can query it without touching the database directly.", + summary: "Remote query APIs make saved context available to local tools and agent workflows.", + tags: ["export-api", "agent-memory", "private-archive"], + categories: ["developer-tools", "self-hosting"], + likes: 303, + views: 15020, + minutesAgo: 42, + }, + { + id: "trailer-prompt-library", + type: "image_prompt", + platform: "x", + title: "A visual prompt worth keeping", + author: "iris", + display: "Iris Kim", + body: "Prompt: editorial product render, graphite UI, warm amber highlights, quiet Apple launch trailer lighting, premium self-hosted archive.", + summary: "An AI art prompt saved as reusable visual research.", + tags: ["prompt-library", "design-reference"], + categories: ["design-reference"], + likes: 219, + views: 11400, + minutesAgo: 55, + media: { file: "prompt-library.svg", title: "Prompt library", subtitle: "AI art research", accent: "#c07b88" }, + }, + { + id: "trailer-source-filters", + type: "article", + platform: "web", + domain: "archivepatterns.example", + title: "Source filters keep archives navigable", + author: "Archive Patterns", + display: "Archive Patterns", + body: "Tweets, threads, RSS, articles, and prompt saves need different visual rhythms. Good source filters let each format keep its shape.", + summary: "Source filters help an archive keep different saved formats easy to scan.", + tags: ["private-archive", "rss-archive"], + categories: ["research-systems"], + likes: 144, + views: 9900, + minutesAgo: 63, + media: { file: "source-filters.svg", title: "Source filters", subtitle: "Articles / threads / RSS", accent: "#9b8cc2" }, + }, +]; + +mkdirSync(publicAssetDir, { recursive: true }); +for (const item of items) { + if (item.media) svgCard({ ...item.media, name: item.media.file }); +} + +const db = new Database(dbPath); +db.pragma("foreign_keys = ON"); + +const passwordHash = await bcrypt.hash(user.password, 10); + +const tx = db.transaction(() => { + db.prepare(` + INSERT INTO users (id, email, password_hash, role, pinned_filters, created_at) + VALUES (@id, @email, @password_hash, 'admin', @pinned_filters, CURRENT_TIMESTAMP) + ON CONFLICT(email) DO UPDATE SET + password_hash=excluded.password_hash, + role='admin', + pinned_filters=excluded.pinned_filters + `).run({ + id: user.id, + email: user.email, + password_hash: passwordHash, + pinned_filters: JSON.stringify([ + { kind: "tag", value: "product-launch", label: "Product Launch" }, + { kind: "tag", value: "agent-memory", label: "Agent Memory" }, + { kind: "type", value: "art", label: "Art" }, + ]), + }); + + const existing = db.prepare("SELECT id FROM content_items WHERE user_id = ?").all(user.id); + for (const row of existing) { + db.prepare("DELETE FROM media WHERE content_item_id = ?").run(row.id); + db.prepare("DELETE FROM content_tags WHERE content_item_id = ?").run(row.id); + db.prepare("DELETE FROM content_categories WHERE content_item_id = ?").run(row.id); + db.prepare("DELETE FROM content_items WHERE id = ?").run(row.id); + } + + for (const [slug, name] of categories) { + db.prepare(` + INSERT INTO categories (id, slug, name, description) + VALUES (?, ?, ?, ?) + ON CONFLICT(slug) DO UPDATE SET name=excluded.name, description=excluded.description + `).run(slugId("cat", slug), slug, name, `${name} saves`); + } + + for (const [slug, name] of tags) { + db.prepare(` + INSERT INTO tags (id, slug, name) + VALUES (?, ?, ?) + ON CONFLICT(slug) DO UPDATE SET name=excluded.name + `).run(slugId("tag", slug), slug, name); + } + + for (const item of items) { + const created = isoMinutesAgo(item.minutesAgo); + const posted = isoMinutesAgo(item.minutesAgo + 60); + const sourceFile = `trailer/${item.id}.md`; + db.prepare(` + INSERT INTO content_items ( + id, user_id, source_type, external_id, author_handle, author_display_name, + title, body_text, body_html, conversation_id, original_url, source_platform, + source_label, source_domain, posted_at, likes, retweets, replies, views, + has_prompt, prompt_text, prompt_type, ai_summary, raw_markdown, source_file_path, + content_hash, processing_status, created_at, updated_at + ) VALUES ( + @id, @user_id, @source_type, @external_id, @author_handle, @author_display_name, + @title, @body_text, @body_html, @conversation_id, @original_url, @source_platform, + @source_label, @source_domain, @posted_at, @likes, @retweets, @replies, @views, + @has_prompt, @prompt_text, @prompt_type, @ai_summary, @raw_markdown, @source_file_path, + @content_hash, 'indexed', @created_at, @updated_at + ) + `).run({ + id: item.id, + user_id: user.id, + source_type: item.type, + external_id: item.id, + author_handle: item.author, + author_display_name: item.display, + title: item.title, + body_text: item.body, + body_html: item.html ?? null, + conversation_id: item.conversation ?? null, + original_url: item.platform === "x" + ? `https://x.com/${item.author}/status/${hash(item.id).slice(0, 12)}` + : `https://${item.domain ?? "scrollback.local"}/${item.id}`, + source_platform: item.platform, + source_label: item.label ?? null, + source_domain: item.domain ?? null, + posted_at: posted, + likes: item.likes, + retweets: Math.floor(item.likes / 8), + replies: Math.floor(item.likes / 18), + views: item.views, + has_prompt: item.type === "image_prompt" ? 1 : 0, + prompt_text: item.type === "image_prompt" ? item.body : null, + prompt_type: item.type === "image_prompt" ? "image" : null, + ai_summary: item.summary, + raw_markdown: `# ${item.title}\n\n${item.body}`, + source_file_path: sourceFile, + content_hash: hash(`${item.id}:${item.body}`), + created_at: created, + updated_at: created, + }); + + for (const tagSlug of item.tags) { + db.prepare("INSERT OR IGNORE INTO content_tags (content_item_id, tag_id) VALUES (?, ?)").run(item.id, slugId("tag", tagSlug)); + } + for (const categorySlug of item.categories) { + db.prepare("INSERT OR IGNORE INTO content_categories (content_item_id, category_id) VALUES (?, ?)").run(item.id, slugId("cat", categorySlug)); + } + + if (item.media) { + db.prepare(` + INSERT INTO media ( + id, content_item_id, media_type, original_url, alt_text, ai_description, + position_in_content, width, height + ) VALUES (?, ?, 'image', ?, ?, ?, 0, 1400, 780) + `).run( + randomUUID(), + item.id, + `http://localhost:3000/trailer-capture/${item.media.file}`, + `${item.title} visual`, + `${item.media.subtitle} visual for ${item.title}`, + ); + } + } + + db.prepare("DROP TRIGGER IF EXISTS content_items_fts_delete").run(); + db.prepare("DROP TRIGGER IF EXISTS content_items_fts_update").run(); + db.prepare("DROP TABLE IF EXISTS content_items_fts").run(); + db.prepare(` + CREATE VIRTUAL TABLE IF NOT EXISTS content_items_fts USING fts5( + title, + body_text, + ai_summary, + author_handle, + content=content_items, + content_rowid=rowid + ) + `).run(); + const ftsRows = db.prepare(` + SELECT rowid, title, body_text, ai_summary, author_handle + FROM content_items + WHERE user_id = ? + `).all(user.id); + + const insertFts = db.prepare(` + INSERT INTO content_items_fts(rowid, title, body_text, ai_summary, author_handle) + VALUES (@rowid, @title, @body_text, @ai_summary, @author_handle) + `); + for (const row of ftsRows) { + insertFts.run(row); + } +}); + +tx(); +db.close(); + +console.log(`Seeded ${items.length} Scrollback trailer items`); +console.log(`Login: ${user.email} / ${user.password}`); diff --git a/services/scrollback-memory-gateway/openclaw/scrollback_memory_search.mjs b/services/scrollback-memory-gateway/openclaw/scrollback_memory_search.mjs index 965ce06..4b7621c 100644 --- a/services/scrollback-memory-gateway/openclaw/scrollback_memory_search.mjs +++ b/services/scrollback-memory-gateway/openclaw/scrollback_memory_search.mjs @@ -11,17 +11,17 @@ const DEFAULT_ENV_PATH = path.resolve( ); const env = { - ...readEnvFile(process.env.SCROLLBACK_MEMORY_GATEWAY_ENV || process.env.FEEDSILO_MEMORY_GATEWAY_ENV || DEFAULT_ENV_PATH), + ...readEnvFile(process.env.SCROLLBACK_MEMORY_GATEWAY_ENV || DEFAULT_ENV_PATH), ...process.env, }; const BASE_URL = - env.SCROLLBACK_MEMORY_GATEWAY_URL || env.FEEDSILO_MEMORY_GATEWAY_URL || env.MEMORY_GATEWAY_URL || "http://172.17.0.1:8788"; + env.SCROLLBACK_MEMORY_GATEWAY_URL || env.MEMORY_GATEWAY_URL || "http://172.17.0.1:8788"; const TOKEN = env.MEMORY_GATEWAY_TOKEN; if (!TOKEN) { fail( - `Missing MEMORY_GATEWAY_TOKEN. Expected it in ${process.env.SCROLLBACK_MEMORY_GATEWAY_ENV || process.env.FEEDSILO_MEMORY_GATEWAY_ENV || DEFAULT_ENV_PATH}` + `Missing MEMORY_GATEWAY_TOKEN. Expected it in ${process.env.SCROLLBACK_MEMORY_GATEWAY_ENV || DEFAULT_ENV_PATH}` ); } diff --git a/services/scrollback-memory-gateway/server.mjs b/services/scrollback-memory-gateway/server.mjs index 6609c48..211aadb 100644 --- a/services/scrollback-memory-gateway/server.mjs +++ b/services/scrollback-memory-gateway/server.mjs @@ -5,7 +5,7 @@ import pg from "pg"; const PORT = Number.parseInt(process.env.PORT || "8787", 10); const DATABASE_URL = process.env.DATABASE_URL; -const DEFAULT_USER_ID = process.env.SCROLLBACK_USER_ID || process.env.FEEDSILO_USER_ID || process.env.DEFAULT_USER_ID; +const DEFAULT_USER_ID = process.env.SCROLLBACK_USER_ID || process.env.DEFAULT_USER_ID; const GATEWAY_TOKEN = process.env.MEMORY_GATEWAY_TOKEN; const ALLOW_ANONYMOUS = process.env.MEMORY_GATEWAY_ALLOW_ANONYMOUS === "true"; const ALLOW_USER_OVERRIDE = process.env.MEMORY_GATEWAY_ALLOW_USER_OVERRIDE === "true";