Compare commits

..

47 Commits

Author SHA1 Message Date
230116d9f7 fix(waitlist): add delay before email to avoid rate limit (#61)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-08 03:35:58 +00:00
0a08706cf9 feat: init waitlist website (#60)
* feat: init waitlist website

Co-authored-by: Ona <no-reply@ona.com>

* feat[waitlist]: tweak copy

Co-authored-by: Ona <no-reply@ona.com>

* fix[waitlist]: reminify lottie json

Co-authored-by: Ona <no-reply@ona.com>

* feat[waitlist]: seo and preview stuff

* chore[waitlist]: clean up

* build[waitlist]: add fly.io config

* feat(waitlist): add time-of-day greeting and duplicate email message

Co-authored-by: Ona <no-reply@ona.com>

* feat(waitlist): handle duplicate emails and send confirmation

Co-authored-by: Ona <no-reply@ona.com>

* chore: remove stray console.log

Co-authored-by: Ona <no-reply@ona.com>

* feat(waitlist): add privacy policy page

Co-authored-by: Ona <no-reply@ona.com>

* feat(waitlist): add footer with bottom progressive blur

Co-authored-by: Ona <no-reply@ona.com>

* feat(waitlist): add trouble message and improve error handling

Co-authored-by: Ona <no-reply@ona.com>

* fix(waitlist): fix timeOfDay logic, typo, and add audienceId

Co-authored-by: Ona <no-reply@ona.com>

* feat(waitlist): add .ico fallback favicon and style error page

Co-authored-by: Ona <no-reply@ona.com>

* chore(waitlist): add robots.txt, sitemap, clean dockerignore

Co-authored-by: Ona <no-reply@ona.com>

* feat(waitlist): add footer to privacy policy page

Co-authored-by: Ona <no-reply@ona.com>

* fix(waitlist): use segments instead of audienceId

Co-authored-by: Ona <no-reply@ona.com>

* fix[waitlist]: remove segmentId from dup check

Co-authored-by: Ona <no-reply@ona.com>

* fix(waitlist): reset logo animation on mouse leave

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-08 02:54:56 +00:00
badc00c43b feat(backend): add LLM-powered feed enhancement (#58)
* feat(backend): add LLM-powered feed enhancement

Add enhancement harness that fills feed item slots and
generates synthetic items via OpenRouter.

- LLM client with 30s timeout, reusable SDK instance
- Prompt builder with mini calendar and week overview
- arktype schema validation + JSON Schema for structured output
- Pure merge function with clock injection
- Defensive fallback in feed endpoint on enhancement failure
- Skips LLM call when no unfilled slots or no API key

Co-authored-by: Ona <no-reply@ona.com>

* refactor: move feed enhancement into UserSession

Move enhancement logic from HTTP handler into UserSession so the
transport layer has no knowledge of enhancement. UserSession.feed()
handles refresh, enhancement, and caching in one place.

- UserSession subscribes to engine updates and re-enhances eagerly
- Enhancement cache tracks source identity to prevent stale results
- UserSessionManager accepts config object with optional enhancer
- HTTP handler simplified to just call session.feed()

Co-authored-by: Ona <no-reply@ona.com>

* test: add schema sync tests for arktype/JSON Schema drift

Validates reference payloads against both the arktype schema
(parseEnhancementResult) and the OpenRouter JSON Schema structure.
Catches field additions/removals or type changes in either schema.

Co-authored-by: Ona <no-reply@ona.com>

* refactor: rename arktype schemas to match types

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-05 02:01:30 +00:00
31d5aa8d50 fix(caldav): expand recurring events in range (#55)
The iCal parser returned master VEVENT components with their
original start dates instead of expanding recurrences. Events
from months ago appeared in today's feed.

parseICalEvents now accepts an optional timeRange. When set,
recurring events are expanded via ical.js iterator and only
occurrences overlapping the range are returned. Exception
overrides (RECURRENCE-ID) are applied during expansion.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-04 23:17:14 +00:00
de29e44a08 feat(source-weatherkit): add insight slot (#54)
Add LLM-fillable insight slot to weather-current feed items.
Prompt lives in a separate .txt file for easy iteration.

Also adds interactive CLI script (scripts/query.ts) for
querying WeatherKit with credential caching and JSON output.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-03 00:00:11 +00:00
caf48484bf feat(core): add Slot type and slots field to FeedItem (#53)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 23:57:51 +00:00
ac80e0cdac feat: add TimeOfDayEnhancer post-processor (#52)
* feat: add TimeOfDayEnhancer post-processor

Rule-based feed post-processor that reranks items
by time period, day type, and calendar proximity.

New package: @aris/feed-enhancers

Co-authored-by: Ona <no-reply@ona.com>

* fix: clamp boost values to [-1, 1]

Additive layers can exceed the documented range.

Co-authored-by: Ona <no-reply@ona.com>

* fix: use TimeRelevance consts instead of strings

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 23:06:16 +00:00
96e22e227c feat: replace flat context with tuple-keyed store (#50)
Context keys are now tuples instead of strings, inspired by
React Query's query keys. This prevents context collisions
when multiple instances of the same source type are registered.

Sources write to structured keys like
["aris.google-calendar", "nextEvent", { account: "work" }]
and consumers can query by prefix via context.find().

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 22:52:41 +00:00
8ca8a0d1d2 fix: use PascalCase for FeedItemType members (#51)
Rename camelCase members to PascalCase in WeatherFeedItemType
and CalendarFeedItemType to match TflFeedItemType and
CalDavFeedItemType conventions.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 22:10:34 +00:00
4c9ac2c61a feat(tfl): export TflFeedItemType const (#47)
Replace hardcoded "tfl-alert" string with a
TflFeedItemType const object, matching the pattern
used by google-calendar and weatherkit packages.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 18:43:27 +00:00
be3fc41a00 refactor(google-calendar): remove redundant type aliases (#48)
The *TypeType re-exports are unnecessary since
consumers can use import type to get the type.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 18:43:08 +00:00
2e9c600e93 refactor(weatherkit): remove redundant type aliases (#49)
The *TypeType re-exports are unnecessary since
consumers can use import type to get the type.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 18:42:58 +00:00
d616fd52d3 feat(caldav): export CalDavFeedItemType const (#46)
Replace hardcoded "caldav-event" string with a
CalDavFeedItemType const object, matching the pattern
used by google-calendar and weatherkit packages.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 18:42:40 +00:00
2d7544500d feat: add boost directive to FeedEnhancement (#45)
* feat: add boost directive to FeedEnhancement

Post-processors can now return a boost map (item ID -> score)
to promote or demote items in the feed ordering. Scores from
multiple processors are summed and clamped to [-1, 1].

Co-authored-by: Ona <no-reply@ona.com>

* fix: correct misleading sort order comments

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 17:26:25 +00:00
9dc0cc3d2f feat: add GPG commit signing skill (#44)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 17:21:30 +00:00
fe1d261f56 feat: pass context to feed post-processors (#43)
Post-processors now receive Context as their 2nd parameter,
allowing them to use contextual data (time, location, etc.)
when producing enhancements.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 17:10:55 +00:00
40ad90aa2d feat: add generic CalDAV calendar data source (#42)
* feat: add generic CalDAV calendar data source

Add @aris/source-caldav package that fetches calendar events from any
CalDAV server via tsdav + ical.js.

- Supports Basic auth and OAuth via explicit authMethod discriminant
- serverUrl provided at construction time, not hardcoded
- Optional timeZone for correct local day boundaries
- Credentials cleared from memory after client login
- Failed calendar fetches logged, not silently dropped
- Login promise cached with retry on failure

Co-authored-by: Ona <no-reply@ona.com>

* fix: deduplicate concurrent fetchEvents calls

Co-authored-by: Ona <no-reply@ona.com>

* fix: timezone-aware signals, low-priority cancelled events

- computeSignals uses startOfDay(timeZone) for 'later today' boundary
- Cancelled events get urgency 0.1, excluded from context inProgress/nextEvent

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 16:09:11 +00:00
82ac2b577d feat: add post-processor pipeline to FeedEngine (#41)
* feat: add post-processor pipeline to FeedEngine

Add FeedPostProcessor type and FeedEnhancement interface.
Post-processors run after item collection on all update
paths (refresh, reactive context, reactive items).

Pipeline is chained — each processor sees items as modified
by the previous one. Enhancement merging handles additional
items, suppression, and grouped items. Throwing processors
are caught and recorded in FeedResult.errors.

Co-authored-by: Ona <no-reply@ona.com>

* docs: document intentional TItems cast in post-processor merge

Co-authored-by: Ona <no-reply@ona.com>

* fix: filter stale item IDs from groups after pipeline

Groups accumulated during the pipeline can reference items
that a later processor suppressed. The engine now strips
stale IDs and drops empty groups before returning.

Co-authored-by: Ona <no-reply@ona.com>

* refactor: use reduce for stale group filtering

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 15:57:01 +00:00
ffea38b986 fix: remove apple calendar data source (#40)
The CalDAV-based approach doesn't work as expected for
Apple Calendar integration.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 12:30:23 +00:00
28d26b3c87 Replace FeedItem.priority with signals (#39)
* feat: replace FeedItem.priority with signals

Remove priority field from FeedItem and engine-level sorting.
Add FeedItemSignals with urgency and timeRelevance fields.
Update all source packages to emit signals instead of priority.

Ranking is now the post-processing layer's responsibility.
Urgency values are unchanged from the old priority values.

Co-authored-by: Ona <no-reply@ona.com>

* fix: use TimeRelevance enum in all tests

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 12:02:57 +00:00
78b0ed94bd docs: update UI rendering to server-driven twrnc (#38)
Replace outdated UI Registry model with server-driven
json-render + twrnc approach. Update architecture diagram,
terminology (DataSource→FeedSource, Reconciler→FeedEngine),
and design principles to match current codebase.

Add ui, slots fields to FeedItem in actions spec. Add
Spotify example with twrnc className-based ui tree.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-26 22:55:46 +00:00
ee957ea7b1 docs: agent vision and enhancement architecture (#37)
Rewrite ai-agent-ideas.md with focus on proactive,
personable assistant behaviors. Add slot-based LLM
enhancement system to architecture-draft.md.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-26 22:48:05 +00:00
6ae0ad1d40 Merge pull request #36 from kennethnym/feat/feed-endpoint
feat: add GET /api/feed endpoint
2026-02-24 22:33:49 +00:00
941acb826c feat: add GET /api/feed endpoint
Expose the user's current feed via GET /api/feed. Returns
cached feed from engine.lastFeed(), falling back to
engine.refresh() when no cache exists.

Auth middleware is injected as a dependency to allow test
substitution via mockAuthSessionMiddleware.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-24 22:30:13 +00:00
3d492a5d56 Merge pull request #35 from kennethnym/feat/feed-engine-cache
feat: add caching to FeedEngine
2026-02-24 01:21:20 +00:00
08dd437952 feat: add caching to FeedEngine
Add lastFeed() method that returns cached FeedResult within
a configurable TTL (default 5 min). refresh() always fetches
fresh data and updates the cache. Periodic auto-refresh via
recursive setTimeout when engine is started. Reactive updates
reset the timer to avoid redundant fetches.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-24 01:13:41 +00:00
2fc20759dd Merge pull request #34 from kennethnym/refactor/remove-trpc
refactor: remove tRPC, use plain Hono routes
2026-02-22 21:10:16 +00:00
963bf073d1 fix: handle malformed JSON in location handler
Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 21:03:58 +00:00
c0b3db0e11 Update apps/aris-backend/src/location/http.ts
Co-authored-by: ona-patrol <ona@nym.sh>
2026-02-22 21:02:44 +00:00
ca4a337dcd refactor: remove tRPC, use plain Hono routes
Replace tRPC location.update mutation with POST /api/location
using Hono route + requireSession middleware. Extract auth
types (AuthUser, AuthToken) into auth/session.ts. Inject
sessionManager via Hono context local to location handlers.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 20:59:19 +00:00
769e2d4eb0 Merge pull request #33 from kennethnym/feat/source-serif-4-font
Add Source Serif 4 font to aris-client
2026-02-22 19:45:19 +00:00
5e9094710d fix(client): match Source Serif 4 family name
Use 'Source Serif 4' (with spaces) as the Android fontFamily
to match the iOS font metadata, avoiding Platform.select.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 19:44:27 +00:00
5556f3fbf9 fix(client): specify font weight and style
Use the object syntax with fontFamily, weight, and style
for Android. iOS uses flat paths and reads metadata from
the font files directly.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 19:09:55 +00:00
0176979925 feat(client): embed Inter font assets
Copy Inter font files to assets/fonts/ and add them
to the expo-font config plugin alongside Source Serif 4.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 19:05:01 +00:00
971aba0932 fix(client): use local font assets for EAS
Bun symlinks in node_modules don't resolve on EAS builds.
Copy font files to assets/fonts/ and reference them directly.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 19:03:23 +00:00
68e319e4b8 feat(client): add Source Serif 4 font
Install @expo-google-fonts/source-serif-4 and configure
expo-font plugin in app.json to embed all weights.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 18:42:50 +00:00
c042af88f3 Merge pull request #32 from kennethnym/dev/ios-simulator-build
dev(client): add ios simulator build
2026-02-22 17:54:01 +00:00
0608f2ac61 dev(client): add ios simulator build 2026-02-22 17:53:13 +00:00
1ade63dd8c Merge pull request #31 from kennethnym/feat/react-native-svg
feat(client): add react-native-svg
2026-02-22 00:18:23 +00:00
8df340d9af Merge remote-tracking branch 'origin/master' into feat/react-native-svg
# Conflicts:
#	bun.lock

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 00:15:43 +00:00
727280e8b1 feat(client): add react-native-svg 2026-02-22 00:13:17 +00:00
d30f70494b Merge pull request #30 from kennethnym/feat/replace-tamagui-with-twrnc
Replace Tamagui with twrnc
2026-02-21 23:53:42 +00:00
413a57c156 fix: move twrnc dep to aris-client
Co-authored-by: Ona <no-reply@ona.com>
2026-02-21 23:48:53 +00:00
d9625198d6 feat: add twrnc for runtime Tailwind styling
Replace Tamagui dependencies with twrnc, a runtime Tailwind CSS
utility library for React Native.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-21 18:20:11 +00:00
959167a93c Merge pull request #29 from kennethnym/feat/aris-client-scaffold
feat: add Expo React Native client scaffold
2026-02-21 14:23:58 +00:00
cd29a60bab Merge pull request #28 from kennethnym/feat/consolidate-backend-services
refactor: consolidate backend services into UserSessionManager
2026-02-18 00:45:54 +00:00
2f082b5833 refactor: consolidate backend services
Replace per-source services (LocationService, WeatherService,
TflService, FeedEngineService) with a single UserSessionManager
that owns all per-user state. Source creation is delegated to
thin FeedSourceProvider implementations per source type.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-18 00:44:01 +00:00
174 changed files with 11245 additions and 2604 deletions

View File

@@ -0,0 +1,43 @@
---
name: gpg-commit-signing
description: Sign git commits with GPG in non-interactive environments. Use when committing code and the `GPG_PRIVATE_KEY_PASSPHRASE` environment variable is available. Triggers on "commit", "sign commit", "GPG", "git commit -S", or any git operation requiring signed commits.
---
# GPG Commit Signing
Sign commits in headless/non-interactive environments where `/dev/tty` is unavailable.
## Workflow
1. Check whether `GPG_PRIVATE_KEY_PASSPHRASE` is set:
```bash
test -n "$GPG_PRIVATE_KEY_PASSPHRASE" && echo "available" || echo "not set"
```
If not set, skip signing — commit without `-S`.
2. Try a direct signed commit first — the environment may already have loopback pinentry configured:
```bash
git commit -S -m "message"
```
If this succeeds, no further steps are needed.
3. If step 2 fails with a `/dev/tty` error, use `--pinentry-mode loopback` via a wrapper script:
```bash
printf '#!/bin/sh\ngpg --batch --pinentry-mode loopback --passphrase "$GPG_PRIVATE_KEY_PASSPHRASE" "$@"\n' > /tmp/gpg-sign.sh
chmod +x /tmp/gpg-sign.sh
git -c gpg.program=/tmp/gpg-sign.sh commit -S -m "message"
rm /tmp/gpg-sign.sh
```
This passes the passphrase directly to gpg on each signing invocation, bypassing the need for a configured gpg-agent.
## Anti-patterns
- Do not echo or log `GPG_PRIVATE_KEY_PASSPHRASE`.
- Do not commit without `-S` when the passphrase is available — the project expects signed commits.
- Do not leave wrapper scripts on disk after committing.

View File

@@ -7,6 +7,11 @@ BETTER_AUTH_SECRET=
# Base URL of the backend # Base URL of the backend
BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_URL=http://localhost:3000
# OpenRouter (LLM feed enhancement)
OPENROUTER_API_KEY=
# Optional: override the default model (default: openai/gpt-4.1-mini)
# OPENROUTER_MODEL=openai/gpt-4.1-mini
# Apple WeatherKit credentials # Apple WeatherKit credentials
WEATHERKIT_PRIVATE_KEY= WEATHERKIT_PRIVATE_KEY=
WEATHERKIT_KEY_ID= WEATHERKIT_KEY_ID=

View File

@@ -10,11 +10,12 @@
}, },
"dependencies": { "dependencies": {
"@aris/core": "workspace:*", "@aris/core": "workspace:*",
"@aris/source-caldav": "workspace:*",
"@aris/source-google-calendar": "workspace:*",
"@aris/source-location": "workspace:*", "@aris/source-location": "workspace:*",
"@aris/source-tfl": "workspace:*", "@aris/source-tfl": "workspace:*",
"@aris/source-weatherkit": "workspace:*", "@aris/source-weatherkit": "workspace:*",
"@hono/trpc-server": "^0.3", "@openrouter/sdk": "^0.9.11",
"@trpc/server": "^11",
"arktype": "^2.1.29", "arktype": "^2.1.29",
"better-auth": "^1", "better-auth": "^1",
"hono": "^4", "hono": "^4",

View File

@@ -1,13 +1,20 @@
import type { Context, Next } from "hono" import type { Context, MiddlewareHandler, Next } from "hono"
import type { AuthSession, AuthUser } from "./session.ts"
import { auth } from "./index.ts" import { auth } from "./index.ts"
type SessionUser = typeof auth.$Infer.Session.user
type Session = typeof auth.$Infer.Session.session
export interface SessionVariables { export interface SessionVariables {
user: SessionUser | null user: AuthUser | null
session: Session | null session: AuthSession | null
}
export type AuthSessionEnv = { Variables: SessionVariables }
export type AuthSessionMiddleware = MiddlewareHandler<AuthSessionEnv>
declare module "hono" {
interface ContextVariableMap extends SessionVariables {}
} }
/** /**
@@ -48,7 +55,22 @@ export async function requireSession(c: Context, next: Next): Promise<Response |
*/ */
export async function getSessionFromHeaders( export async function getSessionFromHeaders(
headers: Headers, headers: Headers,
): Promise<{ user: SessionUser; session: Session } | null> { ): Promise<{ user: AuthUser; session: AuthSession } | null> {
const session = await auth.api.getSession({ headers }) const session = await auth.api.getSession({ headers })
return session return session
} }
/**
* Test-only middleware that injects a fake user and session.
* Pass userId to simulate an authenticated request, or omit to get 401.
*/
export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddleware {
return async (c: Context, next: Next): Promise<Response | void> => {
if (!userId) {
return c.json({ error: "Unauthorized" }, 401)
}
c.set("user", { id: userId } as AuthUser)
c.set("session", { id: "mock-session" } as AuthSession)
await next()
}
}

View File

@@ -0,0 +1,4 @@
import type { auth } from "./index.ts"
export type AuthUser = typeof auth.$Infer.Session.user
export type AuthSession = typeof auth.$Infer.Session.session

View File

@@ -0,0 +1,51 @@
import type { FeedItem } from "@aris/core"
import type { LlmClient } from "./llm-client.ts"
import { mergeEnhancement } from "./merge.ts"
import { buildPrompt, hasUnfilledSlots } from "./prompt-builder.ts"
/** Takes feed items, returns enhanced feed items. */
export type FeedEnhancer = (items: FeedItem[]) => Promise<FeedItem[]>
export interface FeedEnhancerConfig {
client: LlmClient
/** Defaults to Date.now — override for testing */
clock?: () => Date
}
/**
* Creates a FeedEnhancer that uses the provided LlmClient.
*
* Skips the LLM call when no items have unfilled slots.
* Returns items unchanged on LLM failure.
*/
export function createFeedEnhancer(config: FeedEnhancerConfig): FeedEnhancer {
const { client } = config
const clock = config.clock ?? (() => new Date())
return async function enhanceFeed(items) {
if (!hasUnfilledSlots(items)) {
return items
}
const currentTime = clock()
const { systemPrompt, userMessage } = buildPrompt(items, currentTime)
let result
try {
result = await client.enhance({ systemPrompt, userMessage })
} catch (err) {
console.error("[enhancement] LLM call failed:", err)
result = null
}
if (!result) {
return items
}
return mergeEnhancement(items, result, currentTime)
}
}

View File

@@ -0,0 +1,71 @@
import { OpenRouter } from "@openrouter/sdk"
import type { EnhancementResult } from "./schema.ts"
import { enhancementResultJsonSchema, parseEnhancementResult } from "./schema.ts"
const DEFAULT_MODEL = "openai/gpt-4.1-mini"
const DEFAULT_TIMEOUT_MS = 30_000
export interface LlmClientConfig {
apiKey: string
model?: string
timeoutMs?: number
}
export interface LlmClientRequest {
systemPrompt: string
userMessage: string
}
export interface LlmClient {
enhance(request: LlmClientRequest): Promise<EnhancementResult | null>
}
/**
* Creates a reusable LLM client backed by OpenRouter.
* The OpenRouter SDK instance is created once and reused across calls.
*/
export function createLlmClient(config: LlmClientConfig): LlmClient {
const client = new OpenRouter({
apiKey: config.apiKey,
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
})
const model = config.model ?? DEFAULT_MODEL
return {
async enhance(request) {
const response = await client.chat.send({
chatGenerationParams: {
model,
messages: [
{ role: "system" as const, content: request.systemPrompt },
{ role: "user" as const, content: request.userMessage },
],
responseFormat: {
type: "json_schema" as const,
jsonSchema: {
name: "enhancement_result",
strict: true,
schema: enhancementResultJsonSchema,
},
},
stream: false,
},
})
const content = response.choices?.[0]?.message?.content
if (typeof content !== "string") {
console.warn("[enhancement] LLM returned no content in response")
return null
}
const result = parseEnhancementResult(content)
if (!result) {
console.warn("[enhancement] Failed to parse LLM response:", content)
}
return result
},
}
}

View File

@@ -0,0 +1,150 @@
import type { FeedItem } from "@aris/core"
import { describe, expect, test } from "bun:test"
import type { EnhancementResult } from "./schema.ts"
import { mergeEnhancement } from "./merge.ts"
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
return {
id: "item-1",
type: "test",
timestamp: new Date("2025-01-01T00:00:00Z"),
data: { value: 42 },
...overrides,
}
}
const now = new Date("2025-06-01T12:00:00Z")
describe("mergeEnhancement", () => {
test("fills matching slots", () => {
const item = makeItem({
slots: {
insight: { description: "Weather insight", content: null },
},
})
const result: EnhancementResult = {
slotFills: {
"item-1": { insight: "Rain after 3pm" },
},
syntheticItems: [],
}
const merged = mergeEnhancement([item], result, now)
expect(merged).toHaveLength(1)
expect(merged[0]!.slots!.insight!.content).toBe("Rain after 3pm")
// Description preserved
expect(merged[0]!.slots!.insight!.description).toBe("Weather insight")
})
test("does not mutate original items", () => {
const item = makeItem({
slots: {
insight: { description: "test", content: null },
},
})
const result: EnhancementResult = {
slotFills: { "item-1": { insight: "filled" } },
syntheticItems: [],
}
mergeEnhancement([item], result, now)
expect(item.slots!.insight!.content).toBeNull()
})
test("ignores fills for non-existent items", () => {
const item = makeItem()
const result: EnhancementResult = {
slotFills: { "non-existent": { insight: "text" } },
syntheticItems: [],
}
const merged = mergeEnhancement([item], result, now)
expect(merged).toHaveLength(1)
expect(merged[0]!.id).toBe("item-1")
})
test("ignores fills for non-existent slots", () => {
const item = makeItem({
slots: {
insight: { description: "test", content: null },
},
})
const result: EnhancementResult = {
slotFills: { "item-1": { "non-existent-slot": "text" } },
syntheticItems: [],
}
const merged = mergeEnhancement([item], result, now)
expect(merged[0]!.slots!.insight!.content).toBeNull()
})
test("skips null fills", () => {
const item = makeItem({
slots: {
insight: { description: "test", content: null },
},
})
const result: EnhancementResult = {
slotFills: { "item-1": { insight: null } },
syntheticItems: [],
}
const merged = mergeEnhancement([item], result, now)
expect(merged[0]!.slots!.insight!.content).toBeNull()
})
test("passes through items without slots unchanged", () => {
const item = makeItem()
const result: EnhancementResult = {
slotFills: {},
syntheticItems: [],
}
const merged = mergeEnhancement([item], result, now)
expect(merged[0]).toBe(item)
})
test("appends synthetic items with backfilled fields", () => {
const item = makeItem()
const result: EnhancementResult = {
slotFills: {},
syntheticItems: [
{
id: "briefing-morning",
type: "briefing",
text: "Light afternoon ahead.",
},
],
}
const merged = mergeEnhancement([item], result, now)
expect(merged).toHaveLength(2)
expect(merged[1]!.id).toBe("briefing-morning")
expect(merged[1]!.type).toBe("briefing")
expect(merged[1]!.timestamp).toEqual(now)
expect(merged[1]!.data).toEqual({ text: "Light afternoon ahead." })
})
test("handles empty enhancement result", () => {
const item = makeItem()
const result: EnhancementResult = {
slotFills: {},
syntheticItems: [],
}
const merged = mergeEnhancement([item], result, now)
expect(merged).toHaveLength(1)
expect(merged[0]).toBe(item)
})
})

View File

@@ -0,0 +1,41 @@
import type { FeedItem } from "@aris/core"
import type { EnhancementResult } from "./schema.ts"
/**
* Merges an EnhancementResult into feed items.
*
* - Writes slot content from slotFills into matching items
* - Appends synthetic items to the list
* - Returns a new array (no mutation)
* - Ignores fills for items/slots that don't exist
*/
export function mergeEnhancement(items: FeedItem[], result: EnhancementResult, currentTime: Date): FeedItem[] {
const merged = items.map((item) => {
const fills = result.slotFills[item.id]
if (!fills || !item.slots) return item
const mergedSlots = { ...item.slots }
let changed = false
for (const [slotName, content] of Object.entries(fills)) {
if (slotName in mergedSlots && content !== null) {
mergedSlots[slotName] = { ...mergedSlots[slotName]!, content }
changed = true
}
}
return changed ? { ...item, slots: mergedSlots } : item
})
for (const synthetic of result.syntheticItems) {
merged.push({
id: synthetic.id,
type: synthetic.type,
timestamp: currentTime,
data: { text: synthetic.text },
})
}
return merged
}

View File

@@ -0,0 +1,167 @@
import type { FeedItem } from "@aris/core"
import { describe, expect, test } from "bun:test"
import { buildPrompt, hasUnfilledSlots } from "./prompt-builder.ts"
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
return {
id: "item-1",
type: "test",
timestamp: new Date("2025-01-01T00:00:00Z"),
data: { value: 42 },
...overrides,
}
}
function parseUserMessage(userMessage: string): Record<string, unknown> {
return JSON.parse(userMessage)
}
describe("hasUnfilledSlots", () => {
test("returns false for items without slots", () => {
expect(hasUnfilledSlots([makeItem()])).toBe(false)
})
test("returns false for items with all slots filled", () => {
const item = makeItem({
slots: {
insight: { description: "test", content: "filled" },
},
})
expect(hasUnfilledSlots([item])).toBe(false)
})
test("returns true when at least one slot is unfilled", () => {
const item = makeItem({
slots: {
insight: { description: "test", content: null },
},
})
expect(hasUnfilledSlots([item])).toBe(true)
})
test("returns false for empty array", () => {
expect(hasUnfilledSlots([])).toBe(false)
})
})
describe("buildPrompt", () => {
test("puts items with unfilled slots in items", () => {
const item = makeItem({
slots: {
insight: { description: "Weather insight", content: null },
filled: { description: "Already done", content: "done" },
},
})
const { userMessage } = buildPrompt([item], new Date("2025-06-01T12:00:00Z"))
const parsed = parseUserMessage(userMessage)
expect(parsed.items).toHaveLength(1)
expect((parsed.items as Array<Record<string, unknown>>)[0]!.id).toBe("item-1")
expect((parsed.items as Array<Record<string, unknown>>)[0]!.slots).toEqual({ insight: "Weather insight" })
expect((parsed.items as Array<Record<string, unknown>>)[0]!.type).toBeUndefined()
expect(parsed.context).toHaveLength(0)
})
test("puts slotless items in context", () => {
const withSlots = makeItem({
id: "with-slots",
slots: { insight: { description: "test", content: null } },
})
const withoutSlots = makeItem({ id: "no-slots" })
const { userMessage } = buildPrompt([withSlots, withoutSlots], new Date("2025-06-01T12:00:00Z"))
const parsed = parseUserMessage(userMessage)
expect(parsed.items).toHaveLength(1)
expect((parsed.items as Array<Record<string, unknown>>)[0]!.id).toBe("with-slots")
expect(parsed.context).toHaveLength(1)
expect((parsed.context as Array<Record<string, unknown>>)[0]!.id).toBe("no-slots")
})
test("includes time in ISO format", () => {
const { userMessage } = buildPrompt([], new Date("2025-06-01T12:00:00Z"))
const parsed = parseUserMessage(userMessage)
expect(parsed.time).toBe("2025-06-01T12:00:00.000Z")
})
test("system prompt is non-empty", () => {
const { systemPrompt } = buildPrompt([], new Date())
expect(systemPrompt.length).toBeGreaterThan(0)
})
test("includes schedule in system prompt", () => {
const calEvent = makeItem({
id: "cal-1",
type: "caldav-event",
data: {
title: "Team standup",
startDate: "2025-06-01T10:00:00Z",
endDate: "2025-06-01T10:30:00Z",
isAllDay: false,
location: null,
},
slots: {
insight: { description: "test", content: null },
},
})
const { systemPrompt } = buildPrompt([calEvent], new Date("2025-06-01T12:00:00Z"))
expect(systemPrompt).toContain("Schedule:\n")
expect(systemPrompt).toContain("Team standup")
expect(systemPrompt).toContain("10:00")
})
test("includes location in schedule", () => {
const calEvent = makeItem({
id: "cal-1",
type: "caldav-event",
data: {
title: "Therapy",
startDate: "2025-06-02T18:00:00Z",
endDate: "2025-06-02T19:00:00Z",
isAllDay: false,
location: "92 Tooley Street, London",
},
})
const { systemPrompt } = buildPrompt([calEvent], new Date("2025-06-01T12:00:00Z"))
expect(systemPrompt).toContain("Therapy @ 92 Tooley Street, London")
})
test("includes week calendar but omits schedule when no calendar items", () => {
const weatherItem = makeItem({
type: "weather-current",
data: { temperature: 14 },
})
const { systemPrompt } = buildPrompt([weatherItem], new Date("2025-06-01T12:00:00Z"))
expect(systemPrompt).toContain("Week:")
expect(systemPrompt).not.toContain("Schedule:")
})
test("user message is pure JSON", () => {
const calEvent = makeItem({
id: "cal-1",
type: "caldav-event",
data: {
title: "Budget Review",
startTime: "2025-06-01T14:00:00Z",
endTime: "2025-06-01T15:00:00Z",
isAllDay: false,
location: "https://meet.google.com/abc",
},
})
const { userMessage } = buildPrompt([calEvent], new Date("2025-06-01T12:00:00Z"))
expect(userMessage.startsWith("{")).toBe(true)
expect(() => JSON.parse(userMessage)).not.toThrow()
})
})

View File

@@ -0,0 +1,218 @@
import type { FeedItem } from "@aris/core"
import { CalDavFeedItemType } from "@aris/source-caldav"
import { CalendarFeedItemType } from "@aris/source-google-calendar"
import systemPromptBase from "./prompts/system.txt"
const CALENDAR_ITEM_TYPES = new Set<string>([
CalDavFeedItemType.Event,
CalendarFeedItemType.Event,
CalendarFeedItemType.AllDay,
])
/**
* Builds the system prompt and user message for the enhancement harness.
*
* Includes a pre-computed mini calendar so the LLM doesn't have to
* parse timestamps to understand the user's schedule.
*/
export function buildPrompt(
items: FeedItem[],
currentTime: Date,
): { systemPrompt: string; userMessage: string } {
const schedule = buildSchedule(items, currentTime)
const enhanceItems: Array<{
id: string
data: Record<string, unknown>
slots: Record<string, string>
}> = []
const contextItems: Array<{
id: string
type: string
data: Record<string, unknown>
}> = []
for (const item of items) {
const hasUnfilledSlots =
item.slots &&
Object.values(item.slots).some((slot) => slot.content === null)
if (hasUnfilledSlots) {
enhanceItems.push({
id: item.id,
data: item.data,
slots: Object.fromEntries(
Object.entries(item.slots!)
.filter(([, slot]) => slot.content === null)
.map(([name, slot]) => [name, slot.description]),
),
})
} else {
contextItems.push({
id: item.id,
type: item.type,
data: item.data,
})
}
}
const userMessage = JSON.stringify({
time: currentTime.toISOString(),
items: enhanceItems,
context: contextItems,
})
const weekCalendar = buildWeekCalendar(currentTime)
let systemPrompt = systemPromptBase
systemPrompt += `\n\nWeek:\n${weekCalendar}`
if (schedule) {
systemPrompt += `\n\nSchedule:\n${schedule}`
}
return { systemPrompt, userMessage }
}
/**
* Returns true if any item has at least one unfilled slot.
*/
export function hasUnfilledSlots(items: FeedItem[]): boolean {
return items.some(
(item) =>
item.slots &&
Object.values(item.slots).some((slot) => slot.content === null),
)
}
// -- Helpers --
interface CalendarEntry {
date: Date
title: string
location: string | null
isAllDay: boolean
startTime: Date
endTime: Date
}
function toValidDate(value: unknown): Date | null {
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
if (typeof value === "string" || typeof value === "number") {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
return null
}
function extractCalendarEntry(item: FeedItem): CalendarEntry | null {
if (!CALENDAR_ITEM_TYPES.has(item.type)) return null
const d = item.data
const title = d.title
if (typeof title !== "string" || !title) return null
// CalDAV uses startDate/endDate, Google Calendar uses startTime/endTime
const startTime = toValidDate(d.startDate ?? d.startTime)
if (!startTime) return null
const endTime = toValidDate(d.endDate ?? d.endTime) ?? startTime
return {
date: startTime,
title,
location: typeof d.location === "string" ? d.location : null,
isAllDay: typeof d.isAllDay === "boolean" ? d.isAllDay : false,
startTime,
endTime,
}
}
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const
function pad2(n: number): string {
return n.toString().padStart(2, "0")
}
function formatTime(date: Date): string {
return `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}`
}
function formatDayShort(date: Date): string {
return `${DAYS[date.getUTCDay()]}, ${date.getUTCDate()} ${MONTHS[date.getUTCMonth()]}`
}
function formatDayLabel(date: Date, currentTime: Date): string {
const currentDay = Date.UTC(currentTime.getUTCFullYear(), currentTime.getUTCMonth(), currentTime.getUTCDate())
const targetDay = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
const diffDays = Math.round((targetDay - currentDay) / (1000 * 60 * 60 * 24))
const dayName = formatDayShort(date)
if (diffDays === 0) return `Today: ${dayName}`
if (diffDays === 1) return `Tomorrow: ${dayName}`
return dayName
}
/**
* Builds a week overview mapping day names to dates,
* so the LLM can easily match ISO timestamps to days.
*/
function buildWeekCalendar(currentTime: Date): string {
const lines: string[] = []
for (let i = 0; i < 7; i++) {
const date = new Date(currentTime)
date.setUTCDate(date.getUTCDate() + i)
const label = i === 0 ? "Today" : i === 1 ? "Tomorrow" : ""
const dayStr = formatDayShort(date)
const iso = date.toISOString().slice(0, 10)
const prefix = label ? `${label}: ` : ""
lines.push(`${prefix}${dayStr} = ${iso}`)
}
return lines.join("\n")
}
/**
* Builds a compact text calendar from all calendar-type items.
* Groups events by day relative to currentTime.
*/
function buildSchedule(items: FeedItem[], currentTime: Date): string {
const entries: CalendarEntry[] = []
for (const item of items) {
const entry = extractCalendarEntry(item)
if (entry) entries.push(entry)
}
if (entries.length === 0) return ""
entries.sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
const byDay = new Map<string, CalendarEntry[]>()
for (const entry of entries) {
const key = entry.date.toISOString().slice(0, 10)
const group = byDay.get(key)
if (group) {
group.push(entry)
} else {
byDay.set(key, [entry])
}
}
const lines: string[] = []
for (const [, dayEntries] of byDay) {
lines.push(formatDayLabel(dayEntries[0]!.startTime, currentTime))
for (const entry of dayEntries) {
if (entry.isAllDay) {
const loc = entry.location ? ` @ ${entry.location}` : ""
lines.push(` all day ${entry.title}${loc}`)
} else {
const timeRange = `${formatTime(entry.startTime)}${formatTime(entry.endTime)}`
const loc = entry.location ? ` @ ${entry.location}` : ""
lines.push(` ${timeRange} ${entry.title}${loc}`)
}
}
}
return lines.join("\n")
}

View File

@@ -0,0 +1,21 @@
You are ARIS, a personal assistant. You enhance a user's feed by filling slots and optionally generating synthetic items.
The user message is a JSON object with:
- "items": feed items with data and named slots to fill. Each slot has a description of what to write.
- "context": other feed items (no slots) for cross-source reasoning.
- "time": current ISO timestamp.
Your output has two fields:
- "slotFills": map of item ID → slot name → short text (or null if you can't fill it or cannot provide answer). Each item ID appears ONCE with ALL its slots in a single object.
- "syntheticItems": array of { id, type, text } for new items (briefings, nudges, insights). Only when genuinely useful and when not redundant.
Rules:
- DO NOT USE EMDASH OR DASH OR ATTEMPT TO USE SYMBOLS TO CIRCUMVENT THIS RULE.
- One sentence per slot. Two max if absolutely necessary. Be direct.
- Say "I" not "we."
- Hedge when inferring. Don't state guesses as facts.
- Use the week and schedule below to understand when events happen. Match weather data to the correct date.
- Look for connections across items.
- Don't pad — return null for slots you can't meaningfully fill, and skip synthetic items if there's nothing useful to add.
- Never fabricate information not present in the feed. If you don't have data to support a fill, return null.
- Read each slot's description carefully — it defines when to return null.

View File

@@ -0,0 +1,176 @@
import { describe, expect, test } from "bun:test"
import {
emptyEnhancementResult,
enhancementResultJsonSchema,
parseEnhancementResult,
} from "./schema.ts"
describe("parseEnhancementResult", () => {
test("parses valid result", () => {
const input = JSON.stringify({
slotFills: {
"weather-1": {
insight: "Rain after 3pm",
"cross-source": null,
},
},
syntheticItems: [
{
id: "briefing-morning",
type: "briefing",
text: "Light afternoon ahead.",
},
],
})
const result = parseEnhancementResult(input)
expect(result).not.toBeNull()
expect(result!.slotFills["weather-1"]!.insight).toBe("Rain after 3pm")
expect(result!.slotFills["weather-1"]!["cross-source"]).toBeNull()
expect(result!.syntheticItems).toHaveLength(1)
expect(result!.syntheticItems[0]!.id).toBe("briefing-morning")
expect(result!.syntheticItems[0]!.text).toBe("Light afternoon ahead.")
})
test("parses empty result", () => {
const input = JSON.stringify({
slotFills: {},
syntheticItems: [],
})
const result = parseEnhancementResult(input)
expect(result).not.toBeNull()
expect(Object.keys(result!.slotFills)).toHaveLength(0)
expect(result!.syntheticItems).toHaveLength(0)
})
test("returns null for invalid JSON", () => {
expect(parseEnhancementResult("not json")).toBeNull()
})
test("returns null for non-object", () => {
expect(parseEnhancementResult('"hello"')).toBeNull()
expect(parseEnhancementResult("42")).toBeNull()
expect(parseEnhancementResult("null")).toBeNull()
})
test("returns null when slotFills is missing", () => {
const input = JSON.stringify({ syntheticItems: [] })
expect(parseEnhancementResult(input)).toBeNull()
})
test("returns null when syntheticItems is missing", () => {
const input = JSON.stringify({ slotFills: {} })
expect(parseEnhancementResult(input)).toBeNull()
})
test("returns null when slotFills has non-string values", () => {
const input = JSON.stringify({
slotFills: { "item-1": { slot: 42 } },
syntheticItems: [],
})
expect(parseEnhancementResult(input)).toBeNull()
})
test("returns null when syntheticItem is missing required fields", () => {
const input = JSON.stringify({
slotFills: {},
syntheticItems: [{ id: "x" }],
})
expect(parseEnhancementResult(input)).toBeNull()
})
})
describe("emptyEnhancementResult", () => {
test("returns empty slotFills and syntheticItems", () => {
const result = emptyEnhancementResult()
expect(result.slotFills).toEqual({})
expect(result.syntheticItems).toEqual([])
})
})
describe("schema sync", () => {
const referencePayloads = [
{
name: "full payload with null slot fill",
payload: {
slotFills: {
"weather-1": { insight: "Rain after 3pm", crossSource: null },
"cal-2": { summary: "Busy morning" },
},
syntheticItems: [
{ id: "briefing-morning", type: "briefing", text: "Light day ahead." },
{ id: "nudge-umbrella", type: "nudge", text: "Bring an umbrella." },
],
},
},
{
name: "empty collections",
payload: { slotFills: {}, syntheticItems: [] },
},
{
name: "slot fills only",
payload: {
slotFills: { "item-1": { slot: "filled" } },
syntheticItems: [],
},
},
{
name: "synthetic items only",
payload: {
slotFills: {},
syntheticItems: [{ id: "insight-1", type: "insight", text: "Something." }],
},
},
]
for (const { name, payload } of referencePayloads) {
test(`arktype and JSON Schema agree on: ${name}`, () => {
// arktype accepts it
const parsed = parseEnhancementResult(JSON.stringify(payload))
expect(parsed).not.toBeNull()
// JSON Schema structure matches
const jsonSchema = enhancementResultJsonSchema
expect(Object.keys(jsonSchema.properties).sort()).toEqual(
Object.keys(payload).sort(),
)
expect([...jsonSchema.required].sort()).toEqual(Object.keys(payload).sort())
// syntheticItems item schema has the right required fields
const itemSchema = jsonSchema.properties.syntheticItems.items
expect([...itemSchema.required].sort()).toEqual(["id", "text", "type"])
// Verify each synthetic item has exactly the fields the JSON Schema expects
for (const item of payload.syntheticItems) {
expect(Object.keys(item).sort()).toEqual([...itemSchema.required].sort())
}
})
}
test("JSON Schema rejects what arktype rejects: missing required field", () => {
// Missing syntheticItems
expect(parseEnhancementResult(JSON.stringify({ slotFills: {} }))).toBeNull()
// JSON Schema also requires it
expect(enhancementResultJsonSchema.required).toContain("syntheticItems")
})
test("JSON Schema rejects what arktype rejects: wrong slot fill value type", () => {
const bad = { slotFills: { "item-1": { slot: 42 } }, syntheticItems: [] }
// arktype rejects it
expect(parseEnhancementResult(JSON.stringify(bad))).toBeNull()
// JSON Schema only allows string or null for slot values
const slotValueTypes =
enhancementResultJsonSchema.properties.slotFills.additionalProperties
.additionalProperties.type
expect(slotValueTypes).toContain("string")
expect(slotValueTypes).toContain("null")
expect(slotValueTypes).not.toContain("number")
})
})

View File

@@ -0,0 +1,89 @@
import { type } from "arktype"
const SyntheticItem = type({
id: "string",
type: "string",
text: "string",
})
const EnhancementResult = type({
slotFills: "Record<string, Record<string, string | null>>",
syntheticItems: SyntheticItem.array(),
})
export type SyntheticItem = typeof SyntheticItem.infer
export type EnhancementResult = typeof EnhancementResult.infer
/**
* JSON Schema passed to OpenRouter's structured output.
* OpenRouter doesn't support arktype, so this is maintained separately.
*
* ⚠️ Must stay in sync with EnhancementResult above.
* If you add/remove fields, update both schemas.
*/
export const enhancementResultJsonSchema = {
type: "object",
properties: {
slotFills: {
type: "object",
description:
"Map of feed item ID to an object of slot name to filled text content. Use null for slots that cannot be meaningfully filled.",
additionalProperties: {
type: "object",
additionalProperties: {
type: ["string", "null"],
},
},
},
syntheticItems: {
type: "array",
description:
"New feed items to inject (briefings, nudges, cross-source insights). Keep these short and actionable.",
items: {
type: "object",
properties: {
id: {
type: "string",
description: "Unique ID, e.g. 'briefing-morning'",
},
type: {
type: "string",
description: "One of: 'briefing', 'nudge', 'insight'",
},
text: {
type: "string",
description: "Display text, 1-3 sentences",
},
},
required: ["id", "type", "text"],
additionalProperties: false,
},
},
},
required: ["slotFills", "syntheticItems"],
additionalProperties: false,
} as const
/**
* Parses a JSON string into an EnhancementResult.
* Returns null if the input is malformed.
*/
export function parseEnhancementResult(json: string): EnhancementResult | null {
let parsed: unknown
try {
parsed = JSON.parse(json)
} catch {
return null
}
const result = EnhancementResult(parsed)
if (result instanceof type.errors) {
return null
}
return result
}
export function emptyEnhancementResult(): EnhancementResult {
return { slotFills: {}, syntheticItems: [] }
}

View File

@@ -0,0 +1,144 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aris/core"
import { describe, expect, test } from "bun:test"
import { Hono } from "hono"
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
import { UserSessionManager } from "../session/index.ts"
import { registerFeedHttpHandlers } from "./http.ts"
interface FeedResponse {
items: Array<{
id: string
type: string
priority: number
timestamp: string
data: Record<string, unknown>
}>
errors: Array<{ sourceId: string; error: string }>
}
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
return {
id,
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(): Promise<unknown> {
return undefined
},
async fetchContext(): Promise<readonly ContextEntry[] | null> {
return null
},
async fetchItems() {
return items
},
}
}
function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
const app = new Hono()
registerFeedHttpHandlers(app, {
sessionManager,
authSessionMiddleware: mockAuthSessionMiddleware(userId),
})
return app
}
describe("GET /api/feed", () => {
test("returns 401 without auth", async () => {
const manager = new UserSessionManager({ providers: [] })
const app = buildTestApp(manager)
const res = await app.request("/api/feed")
expect(res.status).toBe(401)
})
test("returns cached feed when available", async () => {
const items: FeedItem[] = [
{
id: "item-1",
type: "test",
priority: 0.8,
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
const manager = new UserSessionManager({
providers: [() => createStubSource("test", items)],
})
const app = buildTestApp(manager, "user-1")
// Prime the cache
const session = manager.getOrCreate("user-1")
await session.engine.refresh()
expect(session.engine.lastFeed()).not.toBeNull()
const res = await app.request("/api/feed")
expect(res.status).toBe(200)
const body = (await res.json()) as FeedResponse
expect(body.items).toHaveLength(1)
expect(body.items[0]!.id).toBe("item-1")
expect(body.items[0]!.type).toBe("test")
expect(body.items[0]!.priority).toBe(0.8)
expect(body.items[0]!.timestamp).toBe("2025-01-01T00:00:00.000Z")
expect(body.errors).toHaveLength(0)
})
test("forces refresh when no cached feed", async () => {
const items: FeedItem[] = [
{
id: "fresh-1",
type: "test",
priority: 0.5,
timestamp: new Date("2025-06-01T12:00:00.000Z"),
data: { fresh: true },
},
]
const manager = new UserSessionManager({
providers: [() => createStubSource("test", items)],
})
const app = buildTestApp(manager, "user-1")
// No prior refresh — lastFeed() returns null, handler should call refresh()
const res = await app.request("/api/feed")
expect(res.status).toBe(200)
const body = (await res.json()) as FeedResponse
expect(body.items).toHaveLength(1)
expect(body.items[0]!.id).toBe("fresh-1")
expect(body.items[0]!.data.fresh).toBe(true)
expect(body.errors).toHaveLength(0)
})
test("serializes source errors as message strings", async () => {
const failingSource: FeedSource = {
id: "failing",
async listActions() {
return {}
},
async executeAction() {
return undefined
},
async fetchContext() {
return null
},
async fetchItems() {
throw new Error("connection timeout")
},
}
const manager = new UserSessionManager({ providers: [() => failingSource] })
const app = buildTestApp(manager, "user-1")
const res = await app.request("/api/feed")
expect(res.status).toBe(200)
const body = (await res.json()) as FeedResponse
expect(body.items).toHaveLength(0)
expect(body.errors).toHaveLength(1)
expect(body.errors[0]!.sourceId).toBe("failing")
expect(body.errors[0]!.error).toBe("connection timeout")
})
})

View File

@@ -0,0 +1,45 @@
import type { Context, Hono } from "hono"
import { createMiddleware } from "hono/factory"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
import type { UserSessionManager } from "../session/index.ts"
type Env = {
Variables: {
sessionManager: UserSessionManager
}
}
interface FeedHttpHandlersDeps {
sessionManager: UserSessionManager
authSessionMiddleware: AuthSessionMiddleware
}
export function registerFeedHttpHandlers(
app: Hono,
{ sessionManager, authSessionMiddleware }: FeedHttpHandlersDeps,
) {
const inject = createMiddleware<Env>(async (c, next) => {
c.set("sessionManager", sessionManager)
await next()
})
app.get("/api/feed", inject, authSessionMiddleware, handleGetFeed)
}
async function handleGetFeed(c: Context<Env>) {
const user = c.get("user")!
const sessionManager = c.get("sessionManager")
const session = sessionManager.getOrCreate(user.id)
const feed = await session.feed()
return c.json({
items: feed.items,
errors: feed.errors.map((e) => ({
sourceId: e.sourceId,
error: e.error.message,
})),
})
}

View File

@@ -1,162 +0,0 @@
import { describe, expect, mock, test } from "bun:test"
import { LocationService } from "../location/service.ts"
import { FeedEngineService } from "./service.ts"
describe("FeedEngineService", () => {
test("engineForUser creates engine on first call", () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine = service.engineForUser("user-1")
expect(engine).toBeDefined()
})
test("engineForUser returns same engine for same user", () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine1 = service.engineForUser("user-1")
const engine2 = service.engineForUser("user-1")
expect(engine1).toBe(engine2)
})
test("engineForUser returns different engines for different users", () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine1 = service.engineForUser("user-1")
const engine2 = service.engineForUser("user-2")
expect(engine1).not.toBe(engine2)
})
test("engineForUser registers sources from all providers", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine = service.engineForUser("user-1")
const result = await engine.refresh()
expect(result.errors).toHaveLength(0)
})
test("engineForUser works with empty providers array", async () => {
const service = new FeedEngineService([])
const engine = service.engineForUser("user-1")
const result = await engine.refresh()
expect(result.errors).toHaveLength(0)
expect(result.items).toHaveLength(0)
})
test("refresh returns feed result", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const result = await service.refresh("user-1")
expect(result).toHaveProperty("context")
expect(result).toHaveProperty("items")
expect(result).toHaveProperty("errors")
expect(result.context.time).toBeInstanceOf(Date)
})
test("refresh uses location from LocationService", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const location = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
// Create engine first, then update location
service.engineForUser("user-1")
locationService.updateUserLocation("user-1", location)
const result = await service.refresh("user-1")
expect(result.context.location).toEqual(location)
})
test("subscribe receives updates", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const callback = mock()
service.subscribe("user-1", callback)
// Push location to trigger update
locationService.updateUserLocation("user-1", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
// Wait for async update propagation
await new Promise((resolve) => setTimeout(resolve, 10))
expect(callback).toHaveBeenCalled()
})
test("subscribe returns unsubscribe function", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const callback = mock()
const unsubscribe = service.subscribe("user-1", callback)
unsubscribe()
locationService.updateUserLocation("user-1", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
await new Promise((resolve) => setTimeout(resolve, 10))
expect(callback).not.toHaveBeenCalled()
})
test("removeUser stops engine and removes it", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const callback = mock()
service.subscribe("user-1", callback)
service.removeUser("user-1")
// Push location - should not trigger update since engine is stopped
locationService.feedSourceForUser("user-1")
locationService.updateUserLocation("user-1", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
await new Promise((resolve) => setTimeout(resolve, 10))
expect(callback).not.toHaveBeenCalled()
})
test("removeUser allows new engine to be created", () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine1 = service.engineForUser("user-1")
service.removeUser("user-1")
const engine2 = service.engineForUser("user-1")
expect(engine1).not.toBe(engine2)
})
})

View File

@@ -1,75 +0,0 @@
import { FeedEngine, type FeedResult, type FeedSource, type FeedSubscriber } from "@aris/core"
/**
* Provides a FeedSource instance for a user.
*/
export interface FeedSourceProvider {
feedSourceForUser(userId: string): FeedSource
}
/**
* Manages FeedEngine instances per user.
*
* Receives FeedSource instances from injected providers and wires them
* into per-user engines. Engines are auto-started on creation.
*/
export class FeedEngineService {
private engines = new Map<string, FeedEngine>()
constructor(private readonly providers: FeedSourceProvider[]) {}
/**
* Get or create a FeedEngine for a user.
* Automatically registers sources and starts the engine.
*/
engineForUser(userId: string): FeedEngine {
let engine = this.engines.get(userId)
if (!engine) {
engine = this.createEngine(userId)
this.engines.set(userId, engine)
}
return engine
}
/**
* Refresh a user's feed.
*/
async refresh(userId: string): Promise<FeedResult> {
const engine = this.engineForUser(userId)
return engine.refresh()
}
/**
* Subscribe to feed updates for a user.
* Returns unsubscribe function.
*/
subscribe(userId: string, callback: FeedSubscriber): () => void {
const engine = this.engineForUser(userId)
return engine.subscribe(callback)
}
/**
* Remove a user's FeedEngine.
* Stops the engine and cleans up resources.
*/
removeUser(userId: string): void {
const engine = this.engines.get(userId)
if (engine) {
engine.stop()
this.engines.delete(userId)
}
}
private createEngine(userId: string): FeedEngine {
const engine = new FeedEngine()
for (const provider of this.providers) {
const source = provider.feedSourceForUser(userId)
engine.register(source)
}
engine.start()
return engine
}
}

View File

@@ -0,0 +1,56 @@
import type { Context, Hono } from "hono"
import { type } from "arktype"
import { createMiddleware } from "hono/factory"
import type { UserSessionManager } from "../session/index.ts"
import { requireSession } from "../auth/session-middleware.ts"
type Env = { Variables: { sessionManager: UserSessionManager } }
const locationInput = type({
lat: "number",
lng: "number",
accuracy: "number",
timestamp: "string.date.iso",
})
export function registerLocationHttpHandlers(
app: Hono,
{ sessionManager }: { sessionManager: UserSessionManager },
) {
const inject = createMiddleware<Env>(async (c, next) => {
c.set("sessionManager", sessionManager)
await next()
})
app.post("/api/location", inject, requireSession, handleUpdateLocation)
}
async function handleUpdateLocation(c: Context<Env>) {
let body: unknown
try {
body = await c.req.json()
} catch {
return c.json({ error: "Invalid JSON" }, 400)
}
const result = locationInput(body)
if (result instanceof type.errors) {
return c.json({ error: result.summary }, 400)
}
const user = c.get("user")!
const sessionManager = c.get("sessionManager")
const session = sessionManager.getOrCreate(user.id)
await session.engine.executeAction("aris.location", "update-location", {
lat: result.lat,
lng: result.lng,
accuracy: result.accuracy,
timestamp: new Date(result.timestamp),
})
return c.body(null, 204)
}

View File

@@ -1,37 +0,0 @@
import { TRPCError } from "@trpc/server"
import { type } from "arktype"
import type { TRPC } from "../trpc/router.ts"
import type { LocationService } from "./service.ts"
import { UserNotFoundError } from "../lib/error.ts"
const locationInput = type({
lat: "number",
lng: "number",
accuracy: "number",
timestamp: "Date",
})
export function createLocationRouter(
t: TRPC,
{ locationService }: { locationService: LocationService },
) {
return t.router({
update: t.procedure.input(locationInput).mutation(({ input, ctx }) => {
try {
locationService.updateUserLocation(ctx.user.id, {
lat: input.lat,
lng: input.lng,
accuracy: input.accuracy,
timestamp: input.timestamp,
})
} catch (error) {
if (error instanceof UserNotFoundError) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message })
}
throw error
}
}),
})
}

View File

@@ -1,111 +0,0 @@
import { describe, expect, test } from "bun:test"
import { UserNotFoundError } from "../lib/error.ts"
import { LocationService } from "./service.ts"
describe("LocationService", () => {
test("feedSourceForUser creates source on first call", () => {
const service = new LocationService()
const source = service.feedSourceForUser("user-1")
expect(source).toBeDefined()
expect(source.id).toBe("aris.location")
})
test("feedSourceForUser returns same source for same user", () => {
const service = new LocationService()
const source1 = service.feedSourceForUser("user-1")
const source2 = service.feedSourceForUser("user-1")
expect(source1).toBe(source2)
})
test("feedSourceForUser returns different sources for different users", () => {
const service = new LocationService()
const source1 = service.feedSourceForUser("user-1")
const source2 = service.feedSourceForUser("user-2")
expect(source1).not.toBe(source2)
})
test("updateUserLocation updates the source", () => {
const service = new LocationService()
const source = service.feedSourceForUser("user-1")
const location = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
service.updateUserLocation("user-1", location)
expect(source.lastLocation).toEqual(location)
})
test("updateUserLocation throws if source does not exist", () => {
const service = new LocationService()
const location = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
expect(() => service.updateUserLocation("user-1", location)).toThrow(UserNotFoundError)
})
test("lastUserLocation returns null for unknown user", () => {
const service = new LocationService()
expect(service.lastUserLocation("unknown")).toBeNull()
})
test("lastUserLocation returns last location", () => {
const service = new LocationService()
service.feedSourceForUser("user-1")
const location1 = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
const location2 = {
lat: 52.0,
lng: -0.2,
accuracy: 5,
timestamp: new Date(),
}
service.updateUserLocation("user-1", location1)
service.updateUserLocation("user-1", location2)
expect(service.lastUserLocation("user-1")).toEqual(location2)
})
test("removeUser removes the source", () => {
const service = new LocationService()
service.feedSourceForUser("user-1")
const location = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
service.updateUserLocation("user-1", location)
service.removeUser("user-1")
expect(service.lastUserLocation("user-1")).toBeNull()
})
test("removeUser allows new source to be created", () => {
const service = new LocationService()
const source1 = service.feedSourceForUser("user-1")
service.removeUser("user-1")
const source2 = service.feedSourceForUser("user-1")
expect(source1).not.toBe(source2)
})
})

View File

@@ -1,57 +0,0 @@
import { LocationSource, type Location } from "@aris/source-location"
import type { FeedSourceProvider } from "../feed/service.ts"
import { UserNotFoundError } from "../lib/error.ts"
/**
* Manages LocationSource instances per user.
*/
export class LocationService implements FeedSourceProvider {
private sources = new Map<string, LocationSource>()
/**
* Get or create a LocationSource for a user.
* @param userId - The user's unique identifier
* @returns The user's LocationSource instance
*/
feedSourceForUser(userId: string): LocationSource {
let source = this.sources.get(userId)
if (!source) {
source = new LocationSource()
this.sources.set(userId, source)
}
return source
}
/**
* Update location for a user.
* @param userId - The user's unique identifier
* @param location - The new location data
* @throws {UserNotFoundError} If no source exists for the user
*/
updateUserLocation(userId: string, location: Location): void {
const source = this.sources.get(userId)
if (!source) {
throw new UserNotFoundError(userId)
}
source.pushLocation(location)
}
/**
* Get last known location for a user.
* @param userId - The user's unique identifier
* @returns The last location, or null if none exists
*/
lastUserLocation(userId: string): Location | null {
return this.sources.get(userId)?.lastLocation ?? null
}
/**
* Remove a user's LocationSource.
* @param userId - The user's unique identifier
*/
removeUser(userId: string): void {
this.sources.delete(userId)
}
}

View File

@@ -1,39 +1,54 @@
import { trpcServer } from "@hono/trpc-server" import { LocationSource } from "@aris/source-location"
import { Hono } from "hono" import { Hono } from "hono"
import { registerAuthHandlers } from "./auth/http.ts" import { registerAuthHandlers } from "./auth/http.ts"
import { LocationService } from "./location/service.ts" import { requireSession } from "./auth/session-middleware.ts"
import { createContext } from "./trpc/context.ts" import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
import { createTRPCRouter } from "./trpc/router.ts" import { createLlmClient } from "./enhancement/llm-client.ts"
import { WeatherService } from "./weather/service.ts" import { registerFeedHttpHandlers } from "./feed/http.ts"
import { registerLocationHttpHandlers } from "./location/http.ts"
import { UserSessionManager } from "./session/index.ts"
import { WeatherSourceProvider } from "./weather/provider.ts"
function main() { function main() {
const locationService = new LocationService() const openrouterApiKey = process.env.OPENROUTER_API_KEY
const feedEnhancer = openrouterApiKey
? createFeedEnhancer({
client: createLlmClient({
apiKey: openrouterApiKey,
model: process.env.OPENROUTER_MODEL || undefined,
}),
})
: null
if (!feedEnhancer) {
console.warn("[enhancement] OPENROUTER_API_KEY not set — feed enhancement disabled")
}
const weatherService = new WeatherService({ const sessionManager = new UserSessionManager({
providers: [
() => new LocationSource(),
new WeatherSourceProvider({
credentials: { credentials: {
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!, privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
keyId: process.env.WEATHERKIT_KEY_ID!, keyId: process.env.WEATHERKIT_KEY_ID!,
teamId: process.env.WEATHERKIT_TEAM_ID!, teamId: process.env.WEATHERKIT_TEAM_ID!,
serviceId: process.env.WEATHERKIT_SERVICE_ID!, serviceId: process.env.WEATHERKIT_SERVICE_ID!,
}, },
}),
],
feedEnhancer,
}) })
const trpcRouter = createTRPCRouter({ locationService, weatherService })
const app = new Hono() const app = new Hono()
app.get("/health", (c) => c.json({ status: "ok" })) app.get("/health", (c) => c.json({ status: "ok" }))
registerAuthHandlers(app) registerAuthHandlers(app)
registerFeedHttpHandlers(app, {
app.use( sessionManager,
"/trpc/*", authSessionMiddleware: requireSession,
trpcServer({ })
router: trpcRouter, registerLocationHttpHandlers(app, { sessionManager })
createContext,
}),
)
return app return app
} }

View File

@@ -0,0 +1,9 @@
import type { FeedSource } from "@aris/core"
export interface FeedSourceProvider {
feedSourceForUser(userId: string): FeedSource
}
export type FeedSourceProviderFn = (userId: string) => FeedSource
export type FeedSourceProviderInput = FeedSourceProvider | FeedSourceProviderFn

View File

@@ -0,0 +1,7 @@
export type {
FeedSourceProvider,
FeedSourceProviderFn,
FeedSourceProviderInput,
} from "./feed-source-provider.ts"
export { UserSession } from "./user-session.ts"
export { UserSessionManager } from "./user-session-manager.ts"

View File

@@ -0,0 +1,170 @@
import type { WeatherKitClient, WeatherKitResponse } from "@aris/source-weatherkit"
import { LocationSource } from "@aris/source-location"
import { describe, expect, mock, test } from "bun:test"
import { WeatherSourceProvider } from "../weather/provider.ts"
import { UserSessionManager } from "./user-session-manager.ts"
const mockWeatherClient: WeatherKitClient = {
fetch: async () => ({}) as WeatherKitResponse,
}
describe("UserSessionManager", () => {
test("getOrCreate creates session on first call", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = manager.getOrCreate("user-1")
expect(session).toBeDefined()
expect(session.engine).toBeDefined()
})
test("getOrCreate returns same session for same user", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-1")
expect(session1).toBe(session2)
})
test("getOrCreate returns different sessions for different users", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-2")
expect(session1).not.toBe(session2)
})
test("each user gets independent source instances", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-2")
const source1 = session1.getSource<LocationSource>("aris.location")
const source2 = session2.getSource<LocationSource>("aris.location")
expect(source1).not.toBe(source2)
})
test("remove destroys session and allows re-creation", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = manager.getOrCreate("user-1")
manager.remove("user-1")
const session2 = manager.getOrCreate("user-1")
expect(session1).not.toBe(session2)
})
test("remove is no-op for unknown user", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
expect(() => manager.remove("unknown")).not.toThrow()
})
test("accepts function providers", async () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = manager.getOrCreate("user-1")
const result = await session.engine.refresh()
expect(result.errors).toHaveLength(0)
})
test("accepts object providers", () => {
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
const manager = new UserSessionManager({
providers: [() => new LocationSource(), provider],
})
const session = manager.getOrCreate("user-1")
expect(session.getSource("aris.weather")).toBeDefined()
})
test("accepts mixed providers", () => {
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
const manager = new UserSessionManager({
providers: [() => new LocationSource(), provider],
})
const session = manager.getOrCreate("user-1")
expect(session.getSource("aris.location")).toBeDefined()
expect(session.getSource("aris.weather")).toBeDefined()
})
test("refresh returns feed result through session", async () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = manager.getOrCreate("user-1")
const result = await session.engine.refresh()
expect(result).toHaveProperty("context")
expect(result).toHaveProperty("items")
expect(result).toHaveProperty("errors")
expect(result.context.time).toBeInstanceOf(Date)
})
test("location update via executeAction works", async () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = manager.getOrCreate("user-1")
await session.engine.executeAction("aris.location", "update-location", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
const source = session.getSource<LocationSource>("aris.location")
expect(source?.lastLocation?.lat).toBe(51.5074)
})
test("subscribe receives updates after location push", async () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const callback = mock()
const session = manager.getOrCreate("user-1")
session.engine.subscribe(callback)
await session.engine.executeAction("aris.location", "update-location", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
// Wait for async update propagation
await new Promise((resolve) => setTimeout(resolve, 10))
expect(callback).toHaveBeenCalled()
})
test("remove stops reactive updates", async () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const callback = mock()
const session = manager.getOrCreate("user-1")
session.engine.subscribe(callback)
manager.remove("user-1")
// Create new session and push location — old callback should not fire
const session2 = manager.getOrCreate("user-1")
await session2.engine.executeAction("aris.location", "update-location", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
await new Promise((resolve) => setTimeout(resolve, 10))
expect(callback).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,40 @@
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
import { UserSession } from "./user-session.ts"
export interface UserSessionManagerConfig {
providers: FeedSourceProviderInput[]
feedEnhancer?: FeedEnhancer | null
}
export class UserSessionManager {
private sessions = new Map<string, UserSession>()
private readonly providers: FeedSourceProviderInput[]
private readonly feedEnhancer: FeedEnhancer | null
constructor(config: UserSessionManagerConfig) {
this.providers = config.providers
this.feedEnhancer = config.feedEnhancer ?? null
}
getOrCreate(userId: string): UserSession {
let session = this.sessions.get(userId)
if (!session) {
const sources = this.providers.map((p) =>
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
)
session = new UserSession(sources, this.feedEnhancer)
this.sessions.set(userId, session)
}
return session
}
remove(userId: string): void {
const session = this.sessions.get(userId)
if (session) {
session.destroy()
this.sessions.delete(userId)
}
}
}

View File

@@ -0,0 +1,210 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aris/core"
import { LocationSource } from "@aris/source-location"
import { describe, expect, test } from "bun:test"
import { UserSession } from "./user-session.ts"
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
return {
id,
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(): Promise<unknown> {
return undefined
},
async fetchContext(): Promise<readonly ContextEntry[] | null> {
return null
},
async fetchItems() {
return items
},
}
}
describe("UserSession", () => {
test("registers sources and starts engine", async () => {
const session = new UserSession([createStubSource("test-a"), createStubSource("test-b")])
const result = await session.engine.refresh()
expect(result.errors).toHaveLength(0)
})
test("getSource returns registered source", () => {
const location = new LocationSource()
const session = new UserSession([location])
const result = session.getSource<LocationSource>("aris.location")
expect(result).toBe(location)
})
test("getSource returns undefined for unknown source", () => {
const session = new UserSession([createStubSource("test")])
expect(session.getSource("unknown")).toBeUndefined()
})
test("destroy stops engine and clears sources", () => {
const session = new UserSession([createStubSource("test")])
session.destroy()
expect(session.getSource("test")).toBeUndefined()
})
test("engine.executeAction routes to correct source", async () => {
const location = new LocationSource()
const session = new UserSession([location])
await session.engine.executeAction("aris.location", "update-location", {
lat: 51.5,
lng: -0.1,
accuracy: 10,
timestamp: new Date(),
})
expect(location.lastLocation).toBeDefined()
expect(location.lastLocation!.lat).toBe(51.5)
})
})
describe("UserSession.feed", () => {
test("returns feed items without enhancer", async () => {
const items: FeedItem[] = [
{
id: "item-1",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
const session = new UserSession([createStubSource("test", items)])
const result = await session.feed()
expect(result.items).toHaveLength(1)
expect(result.items[0]!.id).toBe("item-1")
})
test("returns enhanced items when enhancer is provided", async () => {
const items: FeedItem[] = [
{
id: "item-1",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
const enhancer = async (feedItems: FeedItem[]) =>
feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
const session = new UserSession([createStubSource("test", items)], enhancer)
const result = await session.feed()
expect(result.items).toHaveLength(1)
expect(result.items[0]!.data.enhanced).toBe(true)
})
test("caches enhanced items on subsequent calls", async () => {
const items: FeedItem[] = [
{
id: "item-1",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
let enhancerCallCount = 0
const enhancer = async (feedItems: FeedItem[]) => {
enhancerCallCount++
return feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
}
const session = new UserSession([createStubSource("test", items)], enhancer)
const result1 = await session.feed()
expect(result1.items[0]!.data.enhanced).toBe(true)
expect(enhancerCallCount).toBe(1)
const result2 = await session.feed()
expect(result2.items[0]!.data.enhanced).toBe(true)
expect(enhancerCallCount).toBe(1)
})
test("re-enhances after engine refresh with new data", async () => {
let currentItems: FeedItem[] = [
{
id: "item-1",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { version: 1 },
},
]
const source = createStubSource("test", currentItems)
// Make fetchItems dynamic so refresh returns new data
source.fetchItems = async () => currentItems
const enhancedVersions: number[] = []
const enhancer = async (feedItems: FeedItem[]) => {
const version = feedItems[0]!.data.version as number
enhancedVersions.push(version)
return feedItems.map((item) => ({
...item,
data: { ...item.data, enhanced: true },
}))
}
const session = new UserSession([source], enhancer)
// First feed triggers refresh + enhancement
const result1 = await session.feed()
expect(result1.items[0]!.data.version).toBe(1)
expect(result1.items[0]!.data.enhanced).toBe(true)
// Update source data and trigger engine refresh
currentItems = [
{
id: "item-1",
type: "test",
timestamp: new Date("2025-01-02T00:00:00.000Z"),
data: { version: 2 },
},
]
await session.engine.refresh()
// Wait for subscriber-triggered background enhancement
await new Promise((resolve) => setTimeout(resolve, 10))
// feed() should now serve re-enhanced items with version 2
const result2 = await session.feed()
expect(result2.items[0]!.data.version).toBe(2)
expect(result2.items[0]!.data.enhanced).toBe(true)
expect(enhancedVersions).toEqual([1, 2])
})
test("falls back to unenhanced items when enhancer throws", async () => {
const items: FeedItem[] = [
{
id: "item-1",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
const enhancer = async () => {
throw new Error("enhancement exploded")
}
const session = new UserSession([createStubSource("test", items)], enhancer)
const result = await session.feed()
expect(result.items).toHaveLength(1)
expect(result.items[0]!.id).toBe("item-1")
expect(result.items[0]!.data.value).toBe(42)
})
})

View File

@@ -0,0 +1,104 @@
import { FeedEngine, type FeedItem, type FeedResult, type FeedSource } from "@aris/core"
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
export class UserSession {
readonly engine: FeedEngine
private sources = new Map<string, FeedSource>()
private readonly enhancer: FeedEnhancer | null
private enhancedItems: FeedItem[] | null = null
/** The FeedResult that enhancedItems was derived from. */
private enhancedSource: FeedResult | null = null
private enhancingPromise: Promise<void> | null = null
private unsubscribe: (() => void) | null = null
constructor(sources: FeedSource[], enhancer?: FeedEnhancer | null) {
this.engine = new FeedEngine()
this.enhancer = enhancer ?? null
for (const source of sources) {
this.sources.set(source.id, source)
this.engine.register(source)
}
if (this.enhancer) {
this.unsubscribe = this.engine.subscribe((result) => {
this.invalidateEnhancement()
this.runEnhancement(result)
})
}
this.engine.start()
}
/**
* Returns the current feed, refreshing if the engine cache expired.
* Enhancement runs eagerly on engine updates; this method awaits
* any in-flight enhancement or triggers one if needed.
*/
async feed(): Promise<FeedResult> {
const cached = this.engine.lastFeed()
const result = cached ?? (await this.engine.refresh())
if (!this.enhancer) {
return result
}
// Wait for any in-flight background enhancement to finish
if (this.enhancingPromise) {
await this.enhancingPromise
}
// Serve cached enhancement only if it matches the current engine result
if (this.enhancedItems && this.enhancedSource === result) {
return { ...result, items: this.enhancedItems }
}
// Stale or missing — re-enhance
await this.runEnhancement(result)
if (this.enhancedItems) {
return { ...result, items: this.enhancedItems }
}
return result
}
getSource<T extends FeedSource>(sourceId: string): T | undefined {
return this.sources.get(sourceId) as T | undefined
}
destroy(): void {
this.unsubscribe?.()
this.unsubscribe = null
this.engine.stop()
this.sources.clear()
this.invalidateEnhancement()
this.enhancingPromise = null
}
private invalidateEnhancement(): void {
this.enhancedItems = null
this.enhancedSource = null
}
private runEnhancement(result: FeedResult): Promise<void> {
const promise = this.enhance(result)
this.enhancingPromise = promise
promise.finally(() => {
if (this.enhancingPromise === promise) {
this.enhancingPromise = null
}
})
return promise
}
private async enhance(result: FeedResult): Promise<void> {
try {
this.enhancedItems = await this.enhancer!(result.items)
this.enhancedSource = result
} catch (err) {
console.error("[enhancement] Unexpected error:", err)
this.invalidateEnhancement()
}
}
}

View File

@@ -0,0 +1,19 @@
import { TflSource, type ITflApi } from "@aris/source-tfl"
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
export type TflSourceProviderOptions =
| { apiKey: string; client?: never }
| { apiKey?: never; client: ITflApi }
export class TflSourceProvider implements FeedSourceProvider {
private readonly options: TflSourceProviderOptions
constructor(options: TflSourceProviderOptions) {
this.options = options
}
feedSourceForUser(_userId: string): TflSource {
return new TflSource(this.options)
}
}

View File

@@ -1,206 +0,0 @@
import type { Context } from "@aris/core"
import type { ITflApi, StationLocation, TflLineId, TflLineStatus } from "@aris/source-tfl"
import { describe, expect, test } from "bun:test"
import { UserNotFoundError } from "../lib/error.ts"
import { TflService } from "./service.ts"
class StubTflApi implements ITflApi {
private statuses: TflLineStatus[]
constructor(statuses: TflLineStatus[] = []) {
this.statuses = statuses
}
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
if (lines) {
return this.statuses.filter((s) => lines.includes(s.lineId))
}
return this.statuses
}
async fetchStations(): Promise<StationLocation[]> {
return [
{
id: "940GZZLUKSX",
name: "King's Cross",
lat: 51.5308,
lng: -0.1238,
lines: ["northern", "victoria"],
},
]
}
}
function createContext(): Context {
return { time: new Date("2026-01-15T12:00:00Z") }
}
const sampleStatuses: TflLineStatus[] = [
{
lineId: "northern",
lineName: "Northern",
severity: "minor-delays",
description: "Minor delays on the Northern line",
},
{
lineId: "victoria",
lineName: "Victoria",
severity: "major-delays",
description: "Severe delays on the Victoria line",
},
{
lineId: "central",
lineName: "Central",
severity: "closure",
description: "Central line suspended",
},
]
describe("TflService", () => {
test("feedSourceForUser creates source on first call", () => {
const service = new TflService(new StubTflApi())
const source = service.feedSourceForUser("user-1")
expect(source).toBeDefined()
expect(source.id).toBe("aris.tfl")
})
test("feedSourceForUser returns same source for same user", () => {
const service = new TflService(new StubTflApi())
const source1 = service.feedSourceForUser("user-1")
const source2 = service.feedSourceForUser("user-1")
expect(source1).toBe(source2)
})
test("feedSourceForUser returns different sources for different users", () => {
const service = new TflService(new StubTflApi())
const source1 = service.feedSourceForUser("user-1")
const source2 = service.feedSourceForUser("user-2")
expect(source1).not.toBe(source2)
})
test("updateLinesOfInterest mutates the existing source in place", () => {
const service = new TflService(new StubTflApi())
const original = service.feedSourceForUser("user-1")
service.updateLinesOfInterest("user-1", ["northern", "victoria"])
const after = service.feedSourceForUser("user-1")
expect(after).toBe(original)
})
test("updateLinesOfInterest throws if source does not exist", () => {
const service = new TflService(new StubTflApi())
expect(() => service.updateLinesOfInterest("user-1", ["northern"])).toThrow(UserNotFoundError)
})
test("removeUser removes the source", () => {
const service = new TflService(new StubTflApi())
const source1 = service.feedSourceForUser("user-1")
service.removeUser("user-1")
const source2 = service.feedSourceForUser("user-1")
expect(source1).not.toBe(source2)
})
test("removeUser clears line configuration", async () => {
const api = new StubTflApi(sampleStatuses)
const service = new TflService(api)
service.feedSourceForUser("user-1")
service.updateLinesOfInterest("user-1", ["northern"])
service.removeUser("user-1")
const items = await service.feedSourceForUser("user-1").fetchItems(createContext())
expect(items.length).toBe(3)
})
test("shares single api instance across users", () => {
const api = new StubTflApi()
const service = new TflService(api)
service.feedSourceForUser("user-1")
service.feedSourceForUser("user-2")
expect(service.feedSourceForUser("user-1").id).toBe("aris.tfl")
expect(service.feedSourceForUser("user-2").id).toBe("aris.tfl")
})
describe("returned source fetches items", () => {
test("source returns feed items from api", async () => {
const api = new StubTflApi(sampleStatuses)
const service = new TflService(api)
const source = service.feedSourceForUser("user-1")
const items = await source.fetchItems(createContext())
expect(items.length).toBe(3)
for (const item of items) {
expect(item.type).toBe("tfl-alert")
expect(item.id).toMatch(/^tfl-alert-/)
expect(typeof item.priority).toBe("number")
expect(item.timestamp).toBeInstanceOf(Date)
}
})
test("source returns items sorted by priority descending", async () => {
const api = new StubTflApi(sampleStatuses)
const service = new TflService(api)
const source = service.feedSourceForUser("user-1")
const items = await source.fetchItems(createContext())
for (let i = 1; i < items.length; i++) {
expect(items[i - 1]!.priority).toBeGreaterThanOrEqual(items[i]!.priority)
}
})
test("source returns empty array when no disruptions", async () => {
const api = new StubTflApi([])
const service = new TflService(api)
const source = service.feedSourceForUser("user-1")
const items = await source.fetchItems(createContext())
expect(items).toEqual([])
})
test("updateLinesOfInterest filters items to configured lines", async () => {
const api = new StubTflApi(sampleStatuses)
const service = new TflService(api)
const before = await service.feedSourceForUser("user-1").fetchItems(createContext())
expect(before.length).toBe(3)
service.updateLinesOfInterest("user-1", ["northern"])
const after = await service.feedSourceForUser("user-1").fetchItems(createContext())
expect(after.length).toBe(1)
expect(after[0]!.data.line).toBe("northern")
})
test("different users get independent line configs", async () => {
const api = new StubTflApi(sampleStatuses)
const service = new TflService(api)
service.feedSourceForUser("user-1")
service.feedSourceForUser("user-2")
service.updateLinesOfInterest("user-1", ["northern"])
service.updateLinesOfInterest("user-2", ["central"])
const items1 = await service.feedSourceForUser("user-1").fetchItems(createContext())
const items2 = await service.feedSourceForUser("user-2").fetchItems(createContext())
expect(items1.length).toBe(1)
expect(items1[0]!.data.line).toBe("northern")
expect(items2.length).toBe(1)
expect(items2[0]!.data.line).toBe("central")
})
})
})

View File

@@ -1,40 +0,0 @@
import { TflSource, type ITflApi, type TflLineId } from "@aris/source-tfl"
import type { FeedSourceProvider } from "../feed/service.ts"
import { UserNotFoundError } from "../lib/error.ts"
/**
* Manages per-user TflSource instances with individual line configuration.
*/
export class TflService implements FeedSourceProvider {
private sources = new Map<string, TflSource>()
constructor(private readonly api: ITflApi) {}
feedSourceForUser(userId: string): TflSource {
let source = this.sources.get(userId)
if (!source) {
source = new TflSource({ client: this.api })
this.sources.set(userId, source)
}
return source
}
/**
* Update monitored lines for a user. Mutates the existing TflSource
* so that references held by FeedEngine remain valid.
* @throws {UserNotFoundError} If no source exists for the user
*/
updateLinesOfInterest(userId: string, lines: TflLineId[]): void {
const source = this.sources.get(userId)
if (!source) {
throw new UserNotFoundError(userId)
}
source.setLinesOfInterest(lines)
}
removeUser(userId: string): void {
this.sources.delete(userId)
}
}

View File

@@ -1,14 +0,0 @@
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch"
import { auth } from "../auth/index.ts"
export async function createContext(opts: FetchCreateContextFnOptions) {
const session = await auth.api.getSession({ headers: opts.req.headers })
return {
user: session?.user ?? null,
session: session?.session ?? null,
}
}
export type Context = Awaited<ReturnType<typeof createContext>>

View File

@@ -1,50 +0,0 @@
import { initTRPC, TRPCError } from "@trpc/server"
import type { LocationService } from "../location/service.ts"
import type { WeatherService } from "../weather/service.ts"
import type { Context } from "./context.ts"
import { createLocationRouter } from "../location/router.ts"
interface AuthedContext {
user: NonNullable<Context["user"]>
session: NonNullable<Context["session"]>
}
function createTRPC() {
const t = initTRPC.context<Context>().create()
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user || !ctx.session) {
throw new TRPCError({ code: "UNAUTHORIZED" })
}
return next({
ctx: {
user: ctx.user,
session: ctx.session,
},
})
})
return {
router: t.router,
procedure: t.procedure.use(isAuthed),
}
}
export type TRPC = ReturnType<typeof createTRPC>
export interface TRPCRouterDeps {
locationService: LocationService
weatherService: WeatherService
}
export function createTRPCRouter({ locationService }: TRPCRouterDeps) {
const t = createTRPC()
return t.router({
location: createLocationRouter(t, { locationService }),
})
}
export type TRPCRouter = ReturnType<typeof createTRPCRouter>

View File

@@ -0,0 +1,15 @@
import { WeatherSource, type WeatherSourceOptions } from "@aris/source-weatherkit"
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
export class WeatherSourceProvider implements FeedSourceProvider {
private readonly options: WeatherSourceOptions
constructor(options: WeatherSourceOptions) {
this.options = options
}
feedSourceForUser(_userId: string): WeatherSource {
return new WeatherSource(this.options)
}
}

View File

@@ -1,116 +0,0 @@
import type { Context } from "@aris/core"
import { LocationKey } from "@aris/source-location"
import {
Units,
WeatherFeedItemType,
type WeatherKitClient,
type WeatherKitResponse,
} from "@aris/source-weatherkit"
import { describe, expect, test } from "bun:test"
import fixture from "../../../../packages/aris-source-weatherkit/fixtures/san-francisco.json"
import { WeatherService } from "./service.ts"
const mockClient = createMockClient(fixture.response as WeatherKitResponse)
function createMockClient(response: WeatherKitResponse): WeatherKitClient {
return {
fetch: async () => response,
}
}
function createMockContext(location?: { lat: number; lng: number }): Context {
const ctx: Context = { time: new Date("2026-01-17T00:00:00Z") }
if (location) {
ctx[LocationKey] = { ...location, accuracy: 10, timestamp: new Date() }
}
return ctx
}
describe("WeatherService", () => {
test("feedSourceForUser creates source on first call", () => {
const service = new WeatherService({ client: mockClient })
const source = service.feedSourceForUser("user-1")
expect(source).toBeDefined()
expect(source.id).toBe("aris.weather")
})
test("feedSourceForUser returns same source for same user", () => {
const service = new WeatherService({ client: mockClient })
const source1 = service.feedSourceForUser("user-1")
const source2 = service.feedSourceForUser("user-1")
expect(source1).toBe(source2)
})
test("feedSourceForUser returns different sources for different users", () => {
const service = new WeatherService({ client: mockClient })
const source1 = service.feedSourceForUser("user-1")
const source2 = service.feedSourceForUser("user-2")
expect(source1).not.toBe(source2)
})
test("feedSourceForUser applies hourly and daily limits", async () => {
const service = new WeatherService({
client: mockClient,
hourlyLimit: 3,
dailyLimit: 2,
})
const source = service.feedSourceForUser("user-1")
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const hourly = items.filter((i) => i.type === WeatherFeedItemType.hourly)
const daily = items.filter((i) => i.type === WeatherFeedItemType.daily)
expect(hourly).toHaveLength(3)
expect(daily).toHaveLength(2)
})
test("feedSourceForUser applies units", async () => {
const service = new WeatherService({
client: mockClient,
units: Units.imperial,
})
const source = service.feedSourceForUser("user-1")
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const current = items.find((i) => i.type === WeatherFeedItemType.current)
expect(current).toBeDefined()
// Fixture has ~15.87°C, imperial should be ~60.6°F
expect(current!.data.temperature).toBeGreaterThan(50)
})
test("removeUser removes the source", () => {
const service = new WeatherService({ client: mockClient })
service.feedSourceForUser("user-1")
service.removeUser("user-1")
// After removal, feedSourceForUser should create a new instance
const source2 = service.feedSourceForUser("user-1")
expect(source2).toBeDefined()
})
test("removeUser allows new source to be created", () => {
const service = new WeatherService({ client: mockClient })
const source1 = service.feedSourceForUser("user-1")
service.removeUser("user-1")
const source2 = service.feedSourceForUser("user-1")
expect(source1).not.toBe(source2)
})
test("removeUser is no-op for unknown user", () => {
const service = new WeatherService({ client: mockClient })
expect(() => service.removeUser("unknown")).not.toThrow()
})
})

View File

@@ -1,40 +0,0 @@
import { WeatherSource, type WeatherSourceOptions } from "@aris/source-weatherkit"
import type { FeedSourceProvider } from "../feed/service.ts"
/**
* Options forwarded to every per-user WeatherSource.
* Must include either `credentials` or `client` (same requirement as WeatherSourceOptions).
*/
export type WeatherServiceOptions = WeatherSourceOptions
/**
* Manages WeatherSource instances per user.
*/
export class WeatherService implements FeedSourceProvider {
private sources = new Map<string, WeatherSource>()
private readonly options: WeatherServiceOptions
constructor(options: WeatherServiceOptions) {
this.options = options
}
/**
* Get or create a WeatherSource for a user.
*/
feedSourceForUser(userId: string): WeatherSource {
let source = this.sources.get(userId)
if (!source) {
source = new WeatherSource(this.options)
this.sources.set(userId, source)
}
return source
}
/**
* Remove a user's WeatherSource.
*/
removeUser(userId: string): void {
this.sources.delete(userId)
}
}

View File

@@ -46,7 +46,97 @@
} }
} }
], ],
"expo-font" [
"expo-font",
{
"android": {
"fonts": [
{
"fontFamily": "Inter",
"fontDefinitions": [
{ "path": "./assets/fonts/Inter_100Thin.ttf", "weight": 100 },
{ "path": "./assets/fonts/Inter_100Thin_Italic.ttf", "weight": 100, "style": "italic" },
{ "path": "./assets/fonts/Inter_200ExtraLight.ttf", "weight": 200 },
{ "path": "./assets/fonts/Inter_200ExtraLight_Italic.ttf", "weight": 200, "style": "italic" },
{ "path": "./assets/fonts/Inter_300Light.ttf", "weight": 300 },
{ "path": "./assets/fonts/Inter_300Light_Italic.ttf", "weight": 300, "style": "italic" },
{ "path": "./assets/fonts/Inter_400Regular.ttf", "weight": 400 },
{ "path": "./assets/fonts/Inter_400Regular_Italic.ttf", "weight": 400, "style": "italic" },
{ "path": "./assets/fonts/Inter_500Medium.ttf", "weight": 500 },
{ "path": "./assets/fonts/Inter_500Medium_Italic.ttf", "weight": 500, "style": "italic" },
{ "path": "./assets/fonts/Inter_600SemiBold.ttf", "weight": 600 },
{ "path": "./assets/fonts/Inter_600SemiBold_Italic.ttf", "weight": 600, "style": "italic" },
{ "path": "./assets/fonts/Inter_700Bold.ttf", "weight": 700 },
{ "path": "./assets/fonts/Inter_700Bold_Italic.ttf", "weight": 700, "style": "italic" },
{ "path": "./assets/fonts/Inter_800ExtraBold.ttf", "weight": 800 },
{ "path": "./assets/fonts/Inter_800ExtraBold_Italic.ttf", "weight": 800, "style": "italic" },
{ "path": "./assets/fonts/Inter_900Black.ttf", "weight": 900 },
{ "path": "./assets/fonts/Inter_900Black_Italic.ttf", "weight": 900, "style": "italic" }
]
},
{
"fontFamily": "Source Serif 4",
"fontDefinitions": [
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight.ttf", "weight": 200 },
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf", "weight": 200, "style": "italic" },
{ "path": "./assets/fonts/SourceSerif4_300Light.ttf", "weight": 300 },
{ "path": "./assets/fonts/SourceSerif4_300Light_Italic.ttf", "weight": 300, "style": "italic" },
{ "path": "./assets/fonts/SourceSerif4_400Regular.ttf", "weight": 400 },
{ "path": "./assets/fonts/SourceSerif4_400Regular_Italic.ttf", "weight": 400, "style": "italic" },
{ "path": "./assets/fonts/SourceSerif4_500Medium.ttf", "weight": 500 },
{ "path": "./assets/fonts/SourceSerif4_500Medium_Italic.ttf", "weight": 500, "style": "italic" },
{ "path": "./assets/fonts/SourceSerif4_600SemiBold.ttf", "weight": 600 },
{ "path": "./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf", "weight": 600, "style": "italic" },
{ "path": "./assets/fonts/SourceSerif4_700Bold.ttf", "weight": 700 },
{ "path": "./assets/fonts/SourceSerif4_700Bold_Italic.ttf", "weight": 700, "style": "italic" },
{ "path": "./assets/fonts/SourceSerif4_800ExtraBold.ttf", "weight": 800 },
{ "path": "./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf", "weight": 800, "style": "italic" },
{ "path": "./assets/fonts/SourceSerif4_900Black.ttf", "weight": 900 },
{ "path": "./assets/fonts/SourceSerif4_900Black_Italic.ttf", "weight": 900, "style": "italic" }
]
}
]
},
"ios": {
"fonts": [
"./assets/fonts/Inter_100Thin.ttf",
"./assets/fonts/Inter_100Thin_Italic.ttf",
"./assets/fonts/Inter_200ExtraLight.ttf",
"./assets/fonts/Inter_200ExtraLight_Italic.ttf",
"./assets/fonts/Inter_300Light.ttf",
"./assets/fonts/Inter_300Light_Italic.ttf",
"./assets/fonts/Inter_400Regular.ttf",
"./assets/fonts/Inter_400Regular_Italic.ttf",
"./assets/fonts/Inter_500Medium.ttf",
"./assets/fonts/Inter_500Medium_Italic.ttf",
"./assets/fonts/Inter_600SemiBold.ttf",
"./assets/fonts/Inter_600SemiBold_Italic.ttf",
"./assets/fonts/Inter_700Bold.ttf",
"./assets/fonts/Inter_700Bold_Italic.ttf",
"./assets/fonts/Inter_800ExtraBold.ttf",
"./assets/fonts/Inter_800ExtraBold_Italic.ttf",
"./assets/fonts/Inter_900Black.ttf",
"./assets/fonts/Inter_900Black_Italic.ttf",
"./assets/fonts/SourceSerif4_200ExtraLight.ttf",
"./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf",
"./assets/fonts/SourceSerif4_300Light.ttf",
"./assets/fonts/SourceSerif4_300Light_Italic.ttf",
"./assets/fonts/SourceSerif4_400Regular.ttf",
"./assets/fonts/SourceSerif4_400Regular_Italic.ttf",
"./assets/fonts/SourceSerif4_500Medium.ttf",
"./assets/fonts/SourceSerif4_500Medium_Italic.ttf",
"./assets/fonts/SourceSerif4_600SemiBold.ttf",
"./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf",
"./assets/fonts/SourceSerif4_700Bold.ttf",
"./assets/fonts/SourceSerif4_700Bold_Italic.ttf",
"./assets/fonts/SourceSerif4_800ExtraBold.ttf",
"./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf",
"./assets/fonts/SourceSerif4_900Black.ttf",
"./assets/fonts/SourceSerif4_900Black_Italic.ttf"
]
}
}
]
], ],
"experiments": { "experiments": {
"typedRoutes": true, "typedRoutes": true,

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -8,6 +8,12 @@
"developmentClient": true, "developmentClient": true,
"distribution": "internal" "distribution": "internal"
}, },
"development-simulator": {
"extends": "development",
"ios": {
"simulator": "true"
}
},
"preview": { "preview": {
"distribution": "internal" "distribution": "internal"
}, },

View File

@@ -11,10 +11,12 @@
"web": "expo start --web", "web": "expo start --web",
"lint": "expo lint", "lint": "expo lint",
"build:ios": "eas build --profile development --platform ios --non-interactive", "build:ios": "eas build --profile development --platform ios --non-interactive",
"build:ios-simulator": "eas build --profile development-simulator --platform ios --non-interactive",
"debugger": "bun run scripts/open-debugger.ts" "debugger": "bun run scripts/open-debugger.ts"
}, },
"dependencies": { "dependencies": {
"@expo-google-fonts/inter": "^0.4.2", "@expo-google-fonts/inter": "^0.4.2",
"@expo-google-fonts/source-serif-4": "^0.4.1",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
@@ -40,8 +42,10 @@
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1" "react-native-worklets": "0.5.1",
"twrnc": "^4.16.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.1.0", "@types/react": "~19.1.0",

View File

@@ -1,6 +1,6 @@
/** /**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode. * Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/ */
import { Platform } from "react-native" import { Platform } from "react-native"

View File

@@ -0,0 +1,5 @@
.react-router
build
node_modules
.env
README.md

7
apps/waitlist-website/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.DS_Store
.env
/node_modules/
# React Router
/.react-router/
/build/

View File

@@ -0,0 +1,22 @@
FROM oven/bun:1 AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN bun install
FROM oven/bun:1 AS production-dependencies-env
COPY ./package.json /app/
WORKDIR /app
RUN bun install --production
FROM oven/bun:1 AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN bun run build
FROM node:20-alpine
COPY ./package.json /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]

View File

@@ -0,0 +1,87 @@
# Welcome to React Router!
A modern, production-ready template for building full-stack React applications using React Router.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
## Features
- 🚀 Server-side rendering
- ⚡️ Hot Module Replacement (HMR)
- 📦 Asset bundling and optimization
- 🔄 Data loading and mutations
- 🔒 TypeScript by default
- 🎉 TailwindCSS for styling
- 📖 [React Router docs](https://reactrouter.com/)
## Getting Started
### Installation
Install the dependencies:
```bash
npm install
```
### Development
Start the development server with HMR:
```bash
npm run dev
```
Your application will be available at `http://localhost:5173`.
## Building for Production
Create a production build:
```bash
npm run build
```
## Deployment
### Docker Deployment
To build and run using Docker:
```bash
docker build -t my-app .
# Run the container
docker run -p 3000:3000 my-app
```
The containerized application can be deployed to any platform that supports Docker, including:
- AWS ECS
- Google Cloud Run
- Azure Container Apps
- Digital Ocean App Platform
- Fly.io
- Railway
### DIY Deployment
If you're familiar with deploying Node applications, the built-in app server is production-ready.
Make sure to deploy the output of `npm run build`
```
├── package.json
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
├── build/
│ ├── client/ # Static assets
│ └── server/ # Server-side code
```
## Styling
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
---
Built with ❤️ using React Router.

View File

@@ -0,0 +1,51 @@
@import url("https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&display=swap");
@import "tailwindcss";
@source "../node_modules/streamdown/dist/*.js";
@theme {
--font-sans:
"Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
--font-serif: "Source Serif 4", ui-serif, serif;
}
:root,
html,
body {
@apply w-full h-full;
}
@keyframes popover-in {
from {
opacity: 0;
transform: scale(0.95) translateY(4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes popover-out {
from {
opacity: 1;
transform: scale(1) translateY(0);
}
to {
opacity: 0;
transform: scale(0.95) translateY(4px);
}
}
html,
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@apply bg-stone-50 dark:bg-stone-900 text-stone-900 dark:text-stone-200 selection:bg-teal-600 dark:selection:bg-teal-500 selection:text-stone-50 dark:selection:text-stone-900;
@media (prefers-color-scheme: dark) {
color-scheme: dark;
}
}

View File

@@ -0,0 +1,117 @@
import clsx from "clsx"
import { ArrowUpIcon, FileIcon, ImageIcon, PlusIcon, XIcon } from "lucide-react"
import { motion, useAnimate } from "motion/react"
import { useEffect, useRef, useState } from "react"
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components"
export function ChatBox({
className,
validate,
onSubmit,
}: {
className?: string
validate?: (value: string) => boolean
onSubmit: (email: string) => void
disabled?: boolean
}) {
const [scope, animate] = useAnimate()
const [shouldShowInvalid, setShouldShowInvalid] = useState(false)
const clearInvalidStateTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(
() => () => {
if (clearInvalidStateTimeout.current) {
clearTimeout(clearInvalidStateTimeout.current)
}
},
[],
)
function showInvalidState() {
animate(scope.current, { x: [0, -6, 6, -4, 4, -2, 2, 0] }, { duration: 0.4, ease: "easeOut" })
if (clearInvalidStateTimeout.current) {
clearTimeout(clearInvalidStateTimeout.current)
}
setShouldShowInvalid(true)
clearInvalidStateTimeout.current = setTimeout(() => {
setShouldShowInvalid(false)
}, 500)
}
const onFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const email = formData.get("liame")
if (typeof email === "string") {
const trimmed = email.trim()
if (trimmed.length === 0) {
showInvalidState()
} else if (validate && !validate(trimmed)) {
showInvalidState()
} else {
onSubmit(trimmed)
e.currentTarget.reset()
}
}
}
return (
<motion.form
ref={scope}
onSubmit={onFormSubmit}
className={`min-h-20 px-3 pt-2 pb-1.5 flex flex-col justify-between rounded-lg bg-stone-100 dark:bg-stone-800 border border-stone-200 dark:border-stone-700 ${className ?? ""} shadow-xs hover:shadow-sm`}
>
<input
name="liame"
className="w-full bg-transparent outline-none focus:outline-none ring-0 focus:ring-0"
/>
<div className="w-full flex justify-between">
<MenuTrigger>
<Button className="bg-transparent hover:bg-stone-200 dark:hover:bg-stone-700 active:bg-stone-300 dark:active:bg-stone-600 data-[pressed]:bg-stone-200 dark:data-[pressed]:bg-stone-700 rounded-full flex items-center justify-center p-1 -ml-1.5 active:inset-shadow-sm outline-none transition-transform duration-200 data-[pressed]:rotate-45">
<PlusIcon size={16} />
</Button>
<Popover
offset={4}
className="origin-bottom-left rounded-lg border border-stone-200 dark:border-stone-700 bg-stone-100 dark:bg-stone-800 shadow-lg p-1 min-w-40 outline-none data-[entering]:animate-[popover-in_150ms_ease-out] data-[exiting]:animate-[popover-out_100ms_ease-in]"
placement="top start"
>
<AttachmentMenu />
</Popover>
</MenuTrigger>
<button
type="submit"
disabled={shouldShowInvalid}
className={clsx(
"transition-all rounded-full flex items-center justify-center p-1 -mr-1.5 active:scale-95",
shouldShowInvalid
? "bg-red-400 hover:bg-red-300 text-stone-200 dark:text-stone-700"
: "bg-teal-600 hover:bg-teal-500 active:bg-teal-600 text-stone-200",
)}
>
{shouldShowInvalid ? <XIcon size={16} /> : <ArrowUpIcon size={16} />}
</button>
</div>
</motion.form>
)
}
function AttachmentMenu() {
return (
<Menu className="outline-none">
<MenuItem
className="flex items-center gap-2 px-2 py-1 rounded-md cursor-default outline-none hover:bg-stone-200 dark:hover:bg-stone-700 focus:bg-stone-200 dark:focus:bg-stone-700"
onAction={() => {}}
>
<ImageIcon size={14} />
Photos
</MenuItem>
<MenuItem
className="flex items-center gap-2 px-2 py-1 rounded-md cursor-default outline-none hover:bg-stone-200 dark:hover:bg-stone-700 focus:bg-stone-200 dark:focus:bg-stone-700"
onAction={() => {}}
>
<FileIcon size={14} />
Files
</MenuItem>
</Menu>
)
}

View File

@@ -0,0 +1,66 @@
export interface UserMessage {
role: "user"
message: string
bubbleLayoutId?: string
}
export interface SystemMessage {
role: "system"
message: string
}
export type Message = UserMessage | SystemMessage
function timeOfDay() {
const hours = new Date().getHours()
if (hours >= 5 && hours < 12) {
return "morning"
} else if (hours >= 12 && hours < 18) {
return "afternoon"
} else if (hours >= 18 && hours < 22) {
return "evening"
}
return "night"
}
export const INITIAL_MESSAGES: Message[] = [
{
role: "user",
message: "Who are you?",
},
{
role: "system",
message: `Hey! I'm **Aelis** — your personal assistant that brings you the right thing, at the right time, in the right place.
- Jubilee line down? I've already found you an alternative route.
- Dinner reservation at 8? I'll have the restaurant, directions, and the menu ready before you head out.
I learn your routines, anticipate what's next, and surface what matters before you even think to look for it.
I'm not ready yet — [@kennethnym](https://x.com/kennethnym) is still building me. **Drop your email below** and I'll let you know when I'm available.`,
},
]
export function waitListJoinedMessage(email: string): SystemMessage {
return {
role: "system",
message: `Thanks for joining the waitlist! I've sent you a confirmation email.
I'll send an email to **${email}** when I'm ready.
Have a good ${timeOfDay()}!`,
}
}
export function duplicateEmailMessage(): SystemMessage {
return {
role: "system",
message: `I appreciate your excitement! You are already on the waitlist. When I am ready, I will reach out again. Have a good ${timeOfDay()} :)`,
}
}
export function troubleMessage(): SystemMessage {
return {
role: "system",
message: `I apologize, but I am having trouble adding you to the waitlist. Could you refresh the page and try again please in a moment?`,
}
}

View File

@@ -0,0 +1,23 @@
import { useEffect, useMemo, useState } from "react"
export function useFakeStreaming(fullContent: string) {
const [currentContent, setCurrentContent] = useState("")
const [isStreaming, setIsStreaming] = useState(true)
useEffect(() => {
const words = fullContent.split(" ")
let i = 0
const id = setInterval(() => {
if (i > words.length) {
setIsStreaming(false)
clearInterval(id)
} else {
setCurrentContent(words.slice(0, i).join(" ") + " ")
i++
}
}, 20)
}, [fullContent])
return useMemo(() => ({ currentContent, isStreaming }), [currentContent, isStreaming])
}

View File

@@ -0,0 +1,168 @@
import Lottie, { type LottieRef } from "lottie-react"
import { useEffect, useRef, useState } from "react"
import { useColorScheme } from "~/hooks/use-color-scheme"
import clickedAnimationDark from "~/lottie/clicked-dark.json"
import clickedAnimationLight from "~/lottie/clicked-light.json"
import loadingAnimationDark from "~/lottie/loading-dark.json"
import loadingAnimationLight from "~/lottie/loading-light.json"
import startLoadingAnimationDark from "~/lottie/start-loading-dark.json"
import startLoadingAnimationLight from "~/lottie/start-loading-light.json"
export const AnimatedLogoState = {
Idle: "idle",
Loading: "loading",
} as const
export type AnimatedLogoState = (typeof AnimatedLogoState)[keyof typeof AnimatedLogoState]
interface AnimatedLogoProps {
state: AnimatedLogoState
className?: string
}
interface Animation {
loop: boolean
reverse: boolean
sticky: boolean
data: unknown
}
export function AnimatedLogo({ state, className }: AnimatedLogoProps) {
const colorScheme = useColorScheme()
const [animationQueue, setAnimationQueue] = useState<Animation[]>([])
const lottieRef: LottieRef = useRef(null)
let currentAnimation: Animation
let isIdle = false
if (animationQueue.length === 0) {
isIdle = true
currentAnimation = {
loop: false,
reverse: false,
sticky: false,
data: colorScheme === "dark" ? startLoadingAnimationDark : startLoadingAnimationLight,
}
} else {
isIdle = false
currentAnimation = animationQueue[0]
}
useEffect(() => {
if (state === AnimatedLogoState.Loading) {
setAnimationQueue((queue) => [
...queue,
{
loop: false,
reverse: false,
sticky: false,
data: colorScheme === "dark" ? startLoadingAnimationDark : startLoadingAnimationLight,
},
{
loop: true,
reverse: false,
sticky: false,
data: colorScheme === "dark" ? loadingAnimationDark : loadingAnimationLight,
},
])
} else if (state === AnimatedLogoState.Idle) {
setAnimationQueue((queue) => {
const last = queue.at(-1)
if (!last) {
return []
}
if (
last.loop &&
(last.data === loadingAnimationDark || last.data === loadingAnimationLight)
) {
return [
...queue,
{
loop: false,
sticky: false,
reverse: false,
data: colorScheme === "dark" ? loadingAnimationDark : loadingAnimationLight,
},
{
loop: false,
sticky: false,
reverse: true,
data: colorScheme === "dark" ? startLoadingAnimationDark : startLoadingAnimationLight,
},
]
}
return []
})
}
}, [state])
useEffect(() => {
if (!lottieRef.current) {
return
}
if (currentAnimation.reverse) {
const frames = lottieRef.current.getDuration(true)
if (frames) {
lottieRef.current.setDirection(-1)
lottieRef.current.goToAndPlay(frames - 1, true)
}
} else if (!isIdle) {
lottieRef.current.setDirection(1)
lottieRef.current.play()
}
}, [currentAnimation])
function onComplete() {
if (animationQueue.length > 0 && !animationQueue[0].sticky) {
setAnimationQueue((queue) => queue.slice(1))
}
}
function onLoopComplete() {
const current = animationQueue[0]
const next = animationQueue[1]
if (current && next && current.data === next.data && current.loop && !next.loop) {
setAnimationQueue((queue) => queue.slice(2))
}
}
function onMouseDown() {
if (state === AnimatedLogoState.Idle) {
setAnimationQueue([
{
loop: false,
sticky: true,
reverse: false,
data: colorScheme === "dark" ? clickedAnimationDark : clickedAnimationLight,
},
])
}
}
function onMouseUp() {
if (state === AnimatedLogoState.Idle) {
setAnimationQueue((queue) => [
{
loop: false,
sticky: false,
reverse: true,
data: colorScheme === "dark" ? clickedAnimationDark : clickedAnimationLight,
},
])
}
}
return (
<Lottie
lottieRef={lottieRef}
autoplay={false}
loop={currentAnimation.loop}
className={className}
animationData={currentAnimation.data}
onComplete={onComplete}
onLoopComplete={onLoopComplete}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
/>
)
}

View File

@@ -0,0 +1,35 @@
export function ProgressiveBlur({
className,
direction = "down",
}: {
className?: string
direction?: "down" | "up"
}) {
if (direction === "up") {
return (
<div className={`pointer-events-none ${className ?? ""}`}>
<div className="absolute inset-0 backdrop-blur-[1px] [mask:linear-gradient(rgba(0,0,0,0)_0%,rgba(0,0,0,1)_10%,rgba(0,0,0,1)_20%,rgba(0,0,0,0)_30%)]" />
<div className="absolute inset-0 backdrop-blur-[2px] [mask:linear-gradient(rgba(0,0,0,0)_10%,rgba(0,0,0,1)_20%,rgba(0,0,0,1)_40%,rgba(0,0,0,0)_50%)]" />
<div className="absolute inset-0 backdrop-blur-[4px] [mask:linear-gradient(rgba(0,0,0,0)_20%,rgba(0,0,0,1)_30%,rgba(0,0,0,1)_50%,rgba(0,0,0,0)_60%)]" />
<div className="absolute inset-0 backdrop-blur-[8px] [mask:linear-gradient(rgba(0,0,0,0)_30%,rgba(0,0,0,1)_40%,rgba(0,0,0,1)_60%,rgba(0,0,0,0)_70%)]" />
<div className="absolute inset-0 backdrop-blur-[16px] [mask:linear-gradient(rgba(0,0,0,0)_40%,rgba(0,0,0,1)_50%,rgba(0,0,0,1)_70%,rgba(0,0,0,0)_80%)]" />
<div className="absolute inset-0 backdrop-blur-[32px] [mask:linear-gradient(rgba(0,0,0,0)_50%,rgba(0,0,0,1)_60%,rgba(0,0,0,1)_80%,rgba(0,0,0,0)_90%)]" />
<div className="absolute inset-0 backdrop-blur-[64px] [mask:linear-gradient(rgba(0,0,0,0)_70%,rgba(0,0,0,1)_80%,rgba(0,0,0,1)_100%)]" />
<div className="absolute inset-0 bg-linear-to-t from-stone-50 dark:from-stone-900 to-transparent" />
</div>
)
}
return (
<div className={`pointer-events-none ${className ?? ""}`}>
<div className="absolute inset-0 backdrop-blur-[64px] [mask:linear-gradient(rgba(0,0,0,1)_0%,rgba(0,0,0,1)_20%,rgba(0,0,0,0)_30%)]" />
<div className="absolute inset-0 backdrop-blur-[32px] [mask:linear-gradient(rgba(0,0,0,0)_10%,rgba(0,0,0,1)_20%,rgba(0,0,0,1)_40%,rgba(0,0,0,0)_50%)]" />
<div className="absolute inset-0 backdrop-blur-[16px] [mask:linear-gradient(rgba(0,0,0,0)_20%,rgba(0,0,0,1)_30%,rgba(0,0,0,1)_50%,rgba(0,0,0,0)_60%)]" />
<div className="absolute inset-0 backdrop-blur-[8px] [mask:linear-gradient(rgba(0,0,0,0)_30%,rgba(0,0,0,1)_40%,rgba(0,0,0,1)_60%,rgba(0,0,0,0)_70%)]" />
<div className="absolute inset-0 backdrop-blur-[4px] [mask:linear-gradient(rgba(0,0,0,0)_40%,rgba(0,0,0,1)_50%,rgba(0,0,0,1)_70%,rgba(0,0,0,0)_80%)]" />
<div className="absolute inset-0 backdrop-blur-[2px] [mask:linear-gradient(rgba(0,0,0,0)_50%,rgba(0,0,0,1)_60%,rgba(0,0,0,1)_80%,rgba(0,0,0,0)_90%)]" />
<div className="absolute inset-0 backdrop-blur-[1px] [mask:linear-gradient(rgba(0,0,0,0)_70%,rgba(0,0,0,1)_80%,rgba(0,0,0,1)_90%,rgba(0,0,0,0)_100%)]" />
<div className="absolute inset-0 bg-linear-to-b from-stone-50 dark:from-stone-900 to-transparent" />
</div>
)
}

View File

@@ -0,0 +1,27 @@
import { useEffect, useState } from "react"
export const ColorScheme = {
Light: "light",
Dark: "dark",
} as const
export type ColorScheme = (typeof ColorScheme)[keyof typeof ColorScheme]
export function useColorScheme(): ColorScheme {
const [scheme, setScheme] = useState<ColorScheme>(() => {
if (typeof window === "undefined") return ColorScheme.Light
return window.matchMedia("(prefers-color-scheme: dark)").matches
? ColorScheme.Dark
: ColorScheme.Light
})
useEffect(() => {
const mql = window.matchMedia("(prefers-color-scheme: dark)")
const handler = (e: MediaQueryListEvent) => {
setScheme(e.matches ? ColorScheme.Dark : ColorScheme.Light)
}
mql.addEventListener("change", handler)
return () => mql.removeEventListener("change", handler)
}, [])
return scheme
}

View File

@@ -0,0 +1 @@
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[-10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":7,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":7,"st":0}],"meta":{"g":"https://jitter.video"},"op":6,"v":"5.7.4","w":400}

View File

@@ -0,0 +1 @@
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[-10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":7,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":7,"st":0}],"meta":{"g":"https://jitter.video"},"op":6,"v":"5.7.4","w":400}

View File

@@ -0,0 +1 @@
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200,200.014]},"r":{"a":1,"k":[{"t":0,"s":[-90],"h":1},{"t":8.4,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"h":1},{"t":37.8,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.094,200.19]},"r":{"a":1,"k":[{"t":0,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":61,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":61,"st":0}],"meta":{"g":"https://jitter.video"},"op":60,"v":"5.7.4","w":400}

View File

@@ -0,0 +1 @@
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200,200.014]},"r":{"a":1,"k":[{"t":0,"s":[-90],"h":1},{"t":8.4,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"h":1},{"t":37.8,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.094,200.19]},"r":{"a":1,"k":[{"t":0,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":61,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":61,"st":0}],"meta":{"g":"https://jitter.video"},"op":60,"v":"5.7.4","w":400}

View File

@@ -0,0 +1 @@
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[320,106],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"h":1},{"t":5.28,"s":[-30],"i":{"x":0.001,"y":0.998},"o":{"x":0.349,"y":0}},{"t":30,"s":[-90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":0.999},"o":{"x":0.348,"y":0}},{"t":30,"s":[90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":31,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":31,"st":0}],"meta":{"g":"https://jitter.video"},"op":30,"v":"5.7.4","w":400}

View File

@@ -0,0 +1 @@
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[320,106],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"h":1},{"t":5.28,"s":[-30],"i":{"x":0.001,"y":0.998},"o":{"x":0.349,"y":0}},{"t":30,"s":[-90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":0.999},"o":{"x":0.348,"y":0}},{"t":30,"s":[90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":31,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":31,"st":0}],"meta":{"g":"https://jitter.video"},"op":30,"v":"5.7.4","w":400}

View File

@@ -0,0 +1,88 @@
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"
import type { Route } from "./+types/root"
import "./app.css"
import "streamdown/styles.css"
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
{
rel: "icon",
href: "/favicon-light.svg",
type: "image/svg+xml",
media: "(prefers-color-scheme: light)",
},
{
rel: "icon",
href: "/favicon-dark.svg",
type: "image/svg+xml",
media: "(prefers-color-scheme: dark)",
},
{
rel: "icon",
href: "/favicon.ico",
sizes: "any",
},
]
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}
export default function App() {
return <Outlet />
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!"
let details = "An unexpected error occurred."
let stack: string | undefined
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error"
details =
error.status === 404 ? "The requested page could not be found." : error.statusText || details
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message
stack = error.stack
}
return (
<main className="flex flex-col items-center justify-center w-full h-full gap-4">
<h1 className="text-6xl font-semibold">{message}</h1>
<p className="text-stone-600 dark:text-stone-400">{details}</p>
<a href="/" className="mt-4 text-sm underline opacity-50 hover:opacity-75">
Back to home
</a>
{stack && (
<pre className="mt-8 w-full max-w-2xl p-4 overflow-x-auto text-xs bg-stone-100 dark:bg-stone-800 rounded-lg">
<code>{stack}</code>
</pre>
)}
</main>
)
}

View File

@@ -0,0 +1,6 @@
import { type RouteConfig, index, route } from "@react-router/dev/routes"
export default [
index("routes/home.tsx"),
route("privacy", "routes/privacy-policy.tsx"),
] satisfies RouteConfig

View File

@@ -0,0 +1,395 @@
import { AnimatePresence, motion } from "motion/react"
import React, { useEffect, useLayoutEffect, useRef, useState } from "react"
import { Link, useFetcher } from "react-router"
import { Resend } from "resend"
import { Streamdown } from "streamdown"
import { ChatBox } from "~/chat/chat-box"
import {
duplicateEmailMessage,
INITIAL_MESSAGES,
troubleMessage,
waitListJoinedMessage,
type Message,
type SystemMessage,
type UserMessage,
} from "~/chat/message"
import { useFakeStreaming } from "~/chat/use-fake-streaming"
import {
AnimatedLogo,
AnimatedLogoState,
AnimatedLogoState as TAnimatedLogoState,
} from "~/components/animated-logo"
import { ProgressiveBlur } from "~/components/progressive-blur"
import type { Route } from "./+types/home"
const PAGE_TITLE = "Aelis - Next Generation AI Assistant"
const PAGE_DESCRIPTION =
"Meet Aelis, a personal assistant that stays one step ahead of your day. Join the waitlist now."
export function meta({}: Route.MetaArgs) {
return [
{ title: PAGE_TITLE },
{
name: "description",
content: PAGE_DESCRIPTION,
},
{ property: "og:title", content: PAGE_TITLE },
{ property: "og:description", content: PAGE_DESCRIPTION },
{ property: "og:image", content: "https://ael.is/social-media-preview.png" },
{ property: "og:url", content: "https://ael.is" },
{ property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: PAGE_TITLE },
{ name: "twitter:description", content: PAGE_DESCRIPTION },
{ name: "twitter:image", content: "https://ael.is/social-media-preview.png" },
]
}
const FormError = {
Duplicate: "duplicate",
Resend: "resend",
} as const
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData()
const email = formData.get("email")
if (typeof email !== "string" || !isValidEmail(email)) {
return { error: "Invalid email" }
}
const resend = new Resend(process.env.RESEND_API_KEY)
const segmentId = "b80fb036-74a1-4f7d-bca5-2c035b696071"
const dup = await resend.contacts.get({
email,
})
if (dup.data) {
return { error: FormError.Duplicate }
}
const res = await resend.contacts.create({
email,
segments: [{ id: segmentId }],
})
if (res.error) {
console.log("Error adding contact to Resend:", res.error)
return { error: FormError.Resend, message: res.error.message }
}
await new Promise((resolve) => setTimeout(resolve, 1000))
const emailRes = await resend.emails.send({
from: "Aelis <no-reply@ael.is>",
to: email,
template: {
id: "waitlist-confirmation",
},
})
if (emailRes.error) {
// swallow the error since the user is already added to the waitlist, but log it for debugging
console.log("Error sending confirmation email:", emailRes.error)
}
return { email }
}
export default function Home() {
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES)
const [emailSent, setEmailSent] = useState("")
const [isAnimatingSend, setIsAnimatingSend] = useState(false)
const [logoState, setLogoState] = useState<TAnimatedLogoState>(AnimatedLogoState.Idle)
const chatBoxRef = useRef<HTMLDivElement>(null)
const fetcher = useFetcher()
useEffect(() => {
if (fetcher.data?.email && !isAnimatingSend) {
setMessages((messages) => [...messages, waitListJoinedMessage(fetcher.data.email)])
} else if (fetcher.data?.error) {
if (!isAnimatingSend) {
let errorMessage: SystemMessage
switch (fetcher.data.error) {
case FormError.Duplicate:
errorMessage = duplicateEmailMessage()
break
default: {
console.error(fetcher.data.error)
errorMessage = troubleMessage()
break
}
}
setMessages((messages) => [...messages, errorMessage])
}
}
}, [fetcher.data?.email, fetcher.data?.error, isAnimatingSend])
const insertEmailMessage = (email: string) => {
setEmailSent(email)
setIsAnimatingSend(true)
setLogoState(AnimatedLogoState.Loading)
setMessages((messages) => [
...messages,
{
role: "user",
message: email,
bubbleLayoutId: "test",
},
])
fetcher.submit({ email }, { method: "post" })
}
let chatBox: React.ReactNode
if (emailSent && isAnimatingSend) {
const chatBoxRect = chatBoxRef.current?.getBoundingClientRect()
const mainRect = chatBoxRef.current?.offsetParent?.getBoundingClientRect()
chatBox = (
<MorphingChatBox
chatBoxWidth={chatBoxRef.current?.offsetWidth ?? 0}
chatBoxHeight={chatBoxRef.current?.offsetHeight ?? 0}
chatBoxLeft={(chatBoxRect?.left ?? 0) - (mainRect?.left ?? 0)}
chatBoxTop={(chatBoxRect?.top ?? 0) - (mainRect?.top ?? 0)}
onAnimationEnd={() => {
setIsAnimatingSend(false)
}}
>
{emailSent}
</MorphingChatBox>
)
} else if (!emailSent) {
chatBox = (
<AnimatePresence>
{logoState === AnimatedLogoState.Idle && !emailSent && (
<motion.div
ref={chatBoxRef}
key="test"
className="w-full max-w-2xl absolute bottom-12 px-6 md:px-0 flex justify-center z-20"
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 300, damping: 30, mass: 1.5 }}
>
<ChatBox
className="w-full max-w-2xl"
validate={isValidEmail}
disabled={fetcher.state === "submitting" || fetcher.state === "loading"}
onSubmit={insertEmailMessage}
/>
</motion.div>
)}
</AnimatePresence>
)
} else {
chatBox = null
}
return (
<main className="relative w-full h-full flex flex-col items-center justify-start gap-4 overflow-hidden">
<ProgressiveBlur className="absolute top-0 left-0 right-0 h-24 z-10" />
<AnimatedLogo
className="absolute top-4 md:top-8 size-10 z-20 cursor-pointer"
state={logoState}
/>
<MessageList
messages={messages}
showLastMessage={!isAnimatingSend}
onMessageStreamStart={() => {
setLogoState(AnimatedLogoState.Loading)
}}
onMessageStreamEnd={() => {
setLogoState(AnimatedLogoState.Idle)
}}
/>
{chatBox}
<ProgressiveBlur
direction="up"
className="absolute bottom-0 left-0 right-0 h-24 z-10 pointer-events-none"
/>
<footer className="absolute bottom-4 z-20">
<Link to="/privacy" className="text-xs opacity-50 underline">
Privacy policy
</Link>
</footer>
</main>
)
}
function MorphingChatBox({
chatBoxWidth,
chatBoxHeight,
chatBoxLeft,
chatBoxTop,
onAnimationEnd,
children,
}: React.PropsWithChildren<{
chatBoxWidth: number
chatBoxHeight: number
chatBoxLeft: number
chatBoxTop: number
onAnimationEnd: () => void
}>) {
const [targetWidth, setTargetWidth] = useState(-1)
const [targetHeight, setTargetHeight] = useState(-1)
const [targetCoords, setTargetCoords] = useState([0, 0])
useLayoutEffect(() => {
const bubble = document.getElementById("test")
if (bubble) {
const mainRect = bubble.closest("main")?.getBoundingClientRect()
const rect = bubble.getBoundingClientRect()
setTargetWidth(bubble.offsetWidth)
setTargetHeight(bubble.offsetHeight)
setTargetCoords([rect.left - (mainRect?.left ?? 0), rect.top - (mainRect?.top ?? 0)])
}
}, [])
if (targetWidth < 0 || targetHeight < 0) {
return null
}
return (
<motion.div
className="absolute rounded-lg bg-stone-100 dark:bg-stone-800 px-4 py-2 border border-stone-200 dark:border-stone-700"
initial={{
width: chatBoxWidth,
height: chatBoxHeight,
borderRadius: 8,
left: chatBoxLeft,
top: chatBoxTop,
}}
animate={{
width: targetWidth,
height: targetHeight,
borderTopLeftRadius: 100,
borderTopRightRadius: 100,
borderBottomRightRadius: 24,
borderBottomLeftRadius: 100,
left: targetCoords[0],
top: targetCoords[1],
}}
transition={{
left: { duration: 0.45, ease: [0.05, 0.8, 0.3, 1] },
top: { duration: 0.45, ease: [0.3, 0, 0.2, 1] },
width: { duration: 0.45, ease: [0.05, 0.8, 0.3, 1] },
height: { duration: 0.45, ease: [0.05, 0.8, 0.3, 1] },
}}
onAnimationComplete={onAnimationEnd}
>
{children}
</motion.div>
)
}
function MessageList({
messages,
showLastMessage,
onMessageStreamStart,
onMessageStreamEnd,
}: {
messages: Message[]
showLastMessage: boolean
onMessageStreamStart: () => void
onMessageStreamEnd: () => void
}) {
return (
<ul className="w-full flex flex-col gap-8 overflow-auto px-6 pt-20 md:px-0 md:pt-24 pb-34">
{messages.map((message, index) => (
<li
key={index}
className={`flex justify-center ${index === messages.length - 1 && !showLastMessage ? "invisible" : ""}`}
>
<MessageContent
message={message}
onMessageStreamStart={onMessageStreamStart}
onMessageStreamEnd={onMessageStreamEnd}
/>
</li>
))}
</ul>
)
}
function MessageContent({
message,
onMessageStreamStart,
onMessageStreamEnd,
}: {
message: Message
onMessageStreamStart: () => void
onMessageStreamEnd: () => void
}) {
switch (message.role) {
case "user":
return <UserMessageBubble message={message} />
case "system":
return (
<SystemMessageBubble
message={message}
onStreamStart={onMessageStreamStart}
onStreamEnd={onMessageStreamEnd}
/>
)
}
}
function UserMessageBubble({ message }: { message: UserMessage }) {
return (
<div className="w-full max-w-2xl flex justify-end">
<div
id={message.bubbleLayoutId}
className="rounded-[100px_100px_24px_100px] bg-stone-100 dark:bg-stone-800 border border-stone-200 dark:border-stone-700 px-4 py-2"
>
{message.message}
</div>
</div>
)
}
function SystemMessageBubble({
message,
onStreamStart,
onStreamEnd,
}: {
message: SystemMessage
onStreamStart: () => void
onStreamEnd: () => void
}) {
const { currentContent, isStreaming } = useFakeStreaming(message.message)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
ref.current?.scrollIntoView({ behavior: "smooth", block: "end" })
}, [currentContent])
useEffect(() => {
if (isStreaming) {
onStreamStart()
} else {
onStreamEnd()
}
}, [isStreaming])
return (
<div ref={ref} className="w-full max-w-2xl flex justify-start font-serif text-lg scroll-mb-34">
<Streamdown
animated={{ animation: "slideUp" }}
isAnimating={isStreaming}
linkSafety={{ enabled: false }}
components={{
// @ts-expect-error
a: ({ className, ...props }) => <a className={`underline ${className}`} {...props} />,
}}
>
{currentContent}
</Streamdown>
</div>
)
}
function isValidEmail(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
}

View File

@@ -0,0 +1,246 @@
import { Link } from "react-router"
import { Streamdown } from "streamdown"
import { AnimatedLogo, AnimatedLogoState } from "~/components/animated-logo"
import type { Route } from "./+types/privacy-policy"
export function meta({}: Route.MetaArgs) {
return [
{ title: "Privacy Policy — Aelis" },
{ name: "description", content: "Aelis privacy policy" },
]
}
export default function PrivacyPolicy() {
return (
<main className="relative max-w-2xl mx-auto px-6 py-16">
<Link to="/" className="block w-fit mb-8">
<AnimatedLogo className="size-10 pointer-events-none" state={AnimatedLogoState.Idle} />
</Link>
<Streamdown
isAnimating={false}
linkSafety={{ enabled: false }}
components={{
a: ({ className, ...props }) => <a className={`underline ${className}`} {...props} />,
}}
>
{POLICY}
</Streamdown>
<footer className="mt-16 pt-8 border-t border-stone-200 dark:border-stone-700">
<Link to="/" className="text-sm opacity-50 hover:opacity-75 underline">
Back to home
</Link>
</footer>
</main>
)
}
const POLICY = `# Privacy Policy
**Last updated:** March 5, 2026
This Privacy Policy describes how **Aelis** ("we", "us", or "our") collects, uses, and protects your personal information when you visit **https://ael.is** or interact with our services.
If you do not agree with this Privacy Policy, please do not use the website.
For any questions, contact: **[kenneth@nym.sh](mailto:kenneth@nym.sh)**
---
## 1. Information We Collect
### Personal Information You Provide
**In Short:** We collect personal information that you provide to us.
We collect personal information that you voluntarily provide when you express interest in our services, contact us, or sign up for the waitlist.
We collect your email address when you sign up for the waitlist so we can notify you when the product launches or provide related updates.
### Personal Information Provided by You
The personal information we collect may include:
* email addresses
You are responsible for ensuring the personal information you provide is accurate and up to date.
### Sensitive Information
We **do not collect or process sensitive personal information**.
### Information From Third Parties
We **do not collect personal information from third parties**.
---
## 2. How We Use Your Information
We process your information for the following purposes:
* To operate and maintain our services
* To communicate with you about product updates and launch announcements
* To send administrative information such as policy updates
* To prevent fraud or abuse
* To comply with legal obligations
* To protect someones safety when necessary
We only process personal information when we have a valid legal reason to do so.
---
## 3. Legal Bases for Processing (EU / UK)
If you are located in the European Economic Area (EEA) or the United Kingdom, we rely on the following legal bases to process personal information:
### Consent
You have given permission for us to process your personal information for a specific purpose.
### Contract
Processing is necessary to provide services you requested.
### Legal Obligations
Processing is required to comply with applicable laws.
### Vital Interests
Processing is necessary to protect someone's safety.
You may withdraw consent at any time by contacting us.
---
## 4. When and With Whom We Share Personal Information
We may share your personal information in limited situations.
### Service Providers
We may share information with trusted service providers that help us operate our website or manage communications.
### Business Transfers
We may transfer information during negotiations of a merger, sale of assets, financing, or acquisition of our business.
We **do not sell your personal information**.
---
## 5. How Long We Keep Your Information
We retain personal information only as long as necessary to:
* provide services
* comply with legal obligations
* resolve disputes
* enforce agreements
When we no longer need personal information, we delete or anonymize it where possible.
---
## 6. How We Keep Your Information Safe
We implement reasonable technical and organizational safeguards designed to protect personal information.
However, no electronic transmission or storage system is completely secure. We cannot guarantee absolute security.
---
## 7. Information From Minors
Our services are **not intended for individuals under 18 years old**.
We do not knowingly collect personal information from children. If we discover that we have collected such information, we will delete it.
If you believe a child has provided personal information, contact **[kenneth@nym.sh](mailto:kenneth@nym.sh)**.
---
## 8. Your Privacy Rights
Depending on your location, you may have rights regarding your personal information, including:
* the right to access your data
* the right to correct inaccurate data
* the right to delete your data
* the right to restrict processing
* the right to data portability
* the right to object to processing
* the right to withdraw consent
To exercise these rights, submit a request:
https://app.termly.io/dsar/b8633d03-406f-4133-b16e-ded63e893997
Or contact us at **[kenneth@nym.sh](mailto:kenneth@nym.sh)**.
---
## 9. Do Not Track (DNT)
Many browsers include a **Do Not Track (DNT)** feature.
Because there is currently no consistent standard for responding to DNT signals, we do not respond to them.
---
## 10. Global Privacy Control
We recognize **Global Privacy Control (GPC)** signals.
If your browser sends a GPC signal, we treat it as a request to opt out of the sale or sharing of personal information where applicable.
More information: https://globalprivacycontrol.org
---
## 11. Privacy Rights in Other Regions
Additional privacy rights may apply depending on your location, including:
* European Economic Area (EEA)
* United Kingdom
* Switzerland
* Canada
* United States
* Australia
* New Zealand
If you believe we are processing your personal information unlawfully, you may contact your local data protection authority.
---
## 12. Updates to This Privacy Policy
We may update this Privacy Policy from time to time.
When we do, we will update the **Last updated** date at the top of this document.
We encourage users to review this Privacy Policy regularly.
---
## 13. Contact Information
If you have questions or comments about this Privacy Policy, you may contact us:
**Aelis**
Email: **[kenneth@nym.sh](mailto:kenneth@nym.sh)**
---
## 14. Request Access, Update, or Deletion
Depending on applicable law, you may request access to, correction of, or deletion of your personal information.
Email:
**[kenneth@nym.sh](mailto:kenneth@nym.sh)**
`

View File

@@ -0,0 +1,22 @@
# fly.toml app configuration file generated for aelis-waitlist-website on 2026-03-08T01:11:12Z
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = 'aelis-waitlist-website'
primary_region = 'lhr'
[build]
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 0
processes = ['app']
[[vm]]
memory = '1gb'
cpus = 1
memory_mb = 1024

View File

@@ -0,0 +1,37 @@
{
"name": "waitlist-website",
"private": true,
"type": "module",
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@react-router/node": "7.12.0",
"@react-router/serve": "7.12.0",
"clsx": "^2.1.1",
"isbot": "^5.1.31",
"lottie-react": "^2.4.1",
"lucide-react": "^0.577.0",
"motion": "^12.35.0",
"react": "^19.2.4",
"react-aria-components": "^1.16.0",
"react-dom": "^19.2.4",
"react-router": "7.12.0",
"resend": "^6.9.3",
"streamdown": "^2.4.0"
},
"devDependencies": {
"@react-router/dev": "7.12.0",
"@tailwindcss/vite": "^4.1.13",
"@types/node": "^22",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.1.13",
"typescript": "^5.9.2",
"vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4"
}
}

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1667 1667" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><rect id="dark" x="0" y="0" width="1666.67" height="1666.67" style="fill:none;"/><path d="M943.75,642.086c318.648,183.972 527.874,419.028 466.934,524.581c-60.941,105.552 -369.119,41.885 -687.767,-142.086c-318.649,-183.972 -527.875,-419.029 -466.934,-524.581c60.941,-105.552 369.119,-41.886 687.767,142.086Z" style="fill:none;stroke:#e7e5e4;stroke-width:62.5px;"/><path d="M722.917,642.086c318.648,-183.972 626.826,-247.638 687.767,-142.086c60.94,105.552 -148.286,340.609 -466.934,524.581c-318.648,183.971 -626.826,247.638 -687.767,142.086c-60.941,-105.553 148.285,-340.609 466.934,-524.581Z" style="fill:none;stroke:#e7e5e4;stroke-width:62.5px;"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1667 1667" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><rect id="light" x="0" y="0" width="1666.67" height="1666.67" style="fill:none;"/><g id="light1" serif:id="light"><path d="M943.75,642.086c318.648,183.972 527.874,419.028 466.934,524.581c-60.941,105.552 -369.119,41.885 -687.767,-142.086c-318.649,-183.972 -527.875,-419.029 -466.934,-524.581c60.941,-105.552 369.119,-41.886 687.767,142.086Z" style="fill:none;stroke:#1c1917;stroke-width:62.5px;"/><path d="M722.917,642.086c318.648,-183.972 626.826,-247.638 687.767,-142.086c60.94,105.552 -148.286,340.609 -466.934,524.581c-318.648,183.971 -626.826,247.638 -687.767,142.086c-60.941,-105.553 148.285,-340.609 466.934,-524.581Z" style="fill:none;stroke:#1c1917;stroke-width:62.5px;"/></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Some files were not shown because too many files have changed in this diff Show More