diff --git a/.claude/launch.json b/.claude/launch.json index 02932d4..9ae2a8d 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -1,10 +1,10 @@ -{ +{ "version": "0.0.1", "configurations": [ { "name": "web", "runtimeExecutable": "pnpm", - "runtimeArgs": ["--filter", "@atrium/web", "dev"], + "runtimeArgs": ["--filter", "@ourspace/web", "dev"], "port": 5173 } ] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f0711c1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,134 @@ +Working title: Atrium (placeholder — see Naming at the bottom) + +What this is + +A portfolio-first social platform for artists and creators — the intersection +of LinkedIn and Instagram with forum features later, built to remove +liminality rather than farm engagement. Every person is represented by a +low-poly avatar they build themselves; every profile is a warm, curated page +of modules showing their work; and their image-based modules can be entered +as small 3D spaces, walked through in third person like a private gallery. +Reference feeling: https://thevertmenthe.dault-lafon.fr/ — quiet, personal, +the art is the point. + +Product principles (non-negotiable) + +1. Profile-first, never feed-first. The unit of the platform is a person's + space, not a stream of content. Discovery happens by visiting people. +2. Warm, not liminal. Every surface should feel inhabited and intentional — + a studio apartment, not an airport. +3. Documents are parameters, not meshes. An avatar is a compact JSON document + (AvatarDoc); a profile is a compact JSON document (ProfileDoc). Rendering + is deterministic on the client. Cheap to store, fast to sync, portable. +4. Archetypes are starting points, never cages. The onboarding quiz assembles + a first profile; everything it builds can be rebuilt, mixed, or removed. + A musician-painter stacks a music module on top of a painting module. +5. The work is the centerpiece. Modules present it; galleries stage it. + Chrome stays out of the way. +6. PS2-era craft. Low-poly, flat-shaded, visible polygons — Mii charm with + Final Fantasy sincerity. Never photoreal, never sterile. + +Core concepts and vocabulary + +- Avatar: the user's low-poly character, built during onboarding, defined by + an AvatarDoc (skin, hair style/color, outfit colors, accessory). +- Profile: a person's page — avatar top-left, name/username, About Me, then + their modules. Defined by a ProfileDoc. +- Module: one block of a profile — photos, music, moodboard, text. Typed, + orderable, addable/removable at will. +- Archetype: the module set the onboarding quiz assembles from a person's + crafts (musician, visual artist, photographer, writer, ...). +- Room: the walkable 3D rendering of a module — framed works on walls, the + visitor's avatar in third person. +- Lobby (future): a shared room where multiple avatars hang out — groups, + forums, co-browsing. + +Architecture + +Client (now) + +- TypeScript, React, Vite; three.js via react-three-fiber, drei, zustand +- Local-first v0: ProfileDoc persists to localStorage; no accounts yet +- Avatars and rooms are built from primitives at runtime — no modeled + assets, no GLTF pipeline yet; parameters in, geometry out +- Performance budget: 60 frames per second on a mid-range laptop, room + scenes under 150 draw calls, profile page interactive in under 2 seconds + +Backend (next, deliberately deferred) + +- Likely Supabase (auth, Postgres, storage for images/audio) once the + local-first experience is proven; profiles publish at /@username +- Forums and presence come after profiles work: apps/presence already + sketches the ephemeral co-browsing contract (never persist location) + +Repo layout (pnpm workspaces) + +``` +apps/ + web/ # Vite + React + react-three-fiber client + presence/ # WebSocket presence server (future lobbies; stub) +packages/ + core/ # pure TypeScript: ProfileDoc/AvatarDoc types, archetype + # logic, seeded PRNG, color utils. No three.js, no DOM. + avatar/ # react-three-fiber low-poly avatar built from AvatarDoc + ui/ # shared 2D UI components +docs/ # design docs and architecture decision records (ADRs) +``` + +Conventions + +- TypeScript strict mode everywhere. No `any` without an explanatory comment. +- Prettier and ESLint, enforced in continuous integration (CI). +- Conventional Commits: `feat:`, `fix:`, `chore:`, `refactor:`, `docs:`. +- Small pull requests (PRs), one reviewer minimum, no direct pushes to `main`. +- `packages/core` stays pure and deterministic: no `Math.random`, no clocks, + no three.js, no DOM (enforced by ESLint). Seeded PRNG only, so a username + can deterministically seed a default avatar. +- Every new dependency gets a one-line justification in the PR description. + +Team workflow (GitHub CLI) + +```bash +gh repo create / --private --clone +gh issue create -t "P1: avatar builder" -b "acceptance criteria..." +git switch -c feat/12-avatar-hair # branch names reference issue numbers +gh pr create --fill +gh pr merge --squash +``` + +- Trunk-based development: short-lived branches off `main`, squash merges. +- Every task starts as an issue; PRs close issues via `Closes #N`. +- Tag releases `v0.x`; generate changelogs from commit history. + +Roadmap + +- P0 Pivot: this restructure — core/avatar packages, docs honored +- P1 A person: onboarding quiz + avatar-builder tutorial, profile page with + editable modules (photos, music, moodboard, text), local persistence +- P2 A room: image modules walkable in third person — frames, captions, + soft light, the vert-menthe feeling +- P3 Real accounts: backend, publish profiles at /@username, uploads in + object storage, shareable links +- P4 Visiting: browse other people's profiles and rooms, follows, guestbook +- P5 Together: presence server lights up — avatars co-browse rooms, join + groups and lobbies +- P6 Forums: communities with threads, avatar-native, moderation tools + +Working agreement for Codex + +- Read this file at the start of every session. +- Prefer small, reviewable diffs. Never commit directly to `main`. +- Run `pnpm typecheck && pnpm lint && pnpm test` before proposing any commit. +- Ask before adding dependencies or changing document schemas. +- Keep `packages/core` free of side effects and nondeterminism. +- When product direction is ambiguous, re-read Product principles and pick + the warmer, more personal option. +- Codex docs, if needed: https://docs.Codex.com/en/docs/Codex/overview + +Naming + +Working title Atrium: the room of a house you walk into first, open to the +sky. Not user-confirmed — candidates welcome: Foyer, Vitrine, Parlor, +Alcove, Studiolo, Maquette. Verify trademark and domain availability before +any public launch. (Repo folder still says OurSpace; renaming the directory +is disruptive mid-session and cosmetic — do it whenever convenient.) diff --git a/README.md b/README.md index f564e69..9781493 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,140 @@ -# Atrium (working title) +# ourspace -_Make yourself at home._ +A portfolio-first social platform for artists and creators. People build a +low-poly avatar, assemble a personal page from creative modules, and step into +walkable galleries and music rooms. Discovery happens by visiting people and +their worlds—not by scrolling an engagement feed. -A portfolio-first social platform for artists and creators. Build a low-poly -avatar, answer a few questions about what you make, and get a warm personal -page of modules — your photography, your music, your moodboard. Image modules -open into small 3D rooms you can walk through in third person, like a private -gallery of your own work. +The current build is a local-first prototype. Profiles are stored in the +browser, so no database, account service, API key, or `.env` file is required. -LinkedIn × Instagram energy, forum features later, liminality removed on -purpose. PS2-era aesthetics: visible polygons, flat shading, Mii charm. +## What works now -## Status +- Six-step account questionnaire and PS1/PS2-inspired avatar builder +- Editable profile modules for artwork, music, moodboards, and text +- Favorite song and playlist support +- Third-person gallery rooms and creator-DJ rave rooms +- A continuous flight into a planet-style creator discovery hub +- Friends, similar, opposite, random, and new-person discovery filters +- Responsive desktop and mobile layouts -**P1 — A person.** Onboarding quiz + avatar builder, editable profile -modules, and the first walkable gallery room. Local-first: everything -persists in your browser. See the [roadmap](CLAUDE.md#roadmap). +## Run it locally -## Quickstart +### 1. Install the prerequisites + +- [Node.js](https://nodejs.org/) 20 or newer +- pnpm 9.15.9 + +If pnpm is not installed, run: ```bash +npm install --global pnpm@9.15.9 +``` + +### 2. Clone and start the project + +```bash +git clone https://github.com/madirewolf/ourspace.git +cd ourspace pnpm install -pnpm dev # opens the web client at http://localhost:5173 +pnpm dev +``` + +Open the local URL printed in the terminal. It will normally be: + +```text +http://localhost:5173/ ``` -## Repository layout +If port 5173 is already occupied, Vite prints a different port. Use the URL it +prints instead. + +### 3. Create a profile + +Choose **Create your space** and complete the questionnaire. The generated +profile is saved to that browser's local storage. It is safe to refresh, but it +will not automatically appear in another browser or on another computer. + +For a pre-filled development profile, open: + +```text +http://localhost:5173/?demo=1 +``` + +Demo mode is temporary and does not overwrite the profile saved in the browser. + +## Useful commands + +Run these from the repository root: -| Path | What it is | -| ----------------- | -------------------------------------------------------------------- | -| `apps/web` | Vite + React + react-three-fiber client | -| `apps/presence` | WebSocket presence server (future avatar lobbies; stub) | -| `packages/core` | Pure TS: ProfileDoc/AvatarDoc types, archetypes, seeded PRNG, no DOM | -| `packages/avatar` | Low-poly react-three-fiber avatar built from AvatarDoc parameters | -| `packages/ui` | Shared 2D UI components | -| `docs/` | Design docs and architecture decision records | +```bash +pnpm dev # start the Vite development server +pnpm typecheck # run strict TypeScript checks in every workspace +pnpm lint # run ESLint +pnpm format:check # check Prettier formatting +pnpm test # run the Vitest suite +pnpm build # create a production build +``` -## Commands +Before sharing a branch or pull request, run: ```bash -pnpm dev # run the web client -pnpm typecheck # strict TypeScript, all workspaces -pnpm lint # ESLint (core has extra purity rules) -pnpm test # vitest -pnpm build # production build +pnpm typecheck +pnpm lint +pnpm test +pnpm build ``` -## Contributing +## Project structure + +| Path | Purpose | +| ----------------- | ------------------------------------------------------------ | +| `apps/web` | React, Vite, and react-three-fiber client | +| `apps/presence` | Stub for future multiplayer presence and lobbies | +| `packages/core` | Pure TypeScript profile/avatar documents and archetype logic | +| `packages/avatar` | Runtime-generated low-poly avatar renderer | +| `packages/ui` | Shared interface components | +| `docs` | Architecture decisions and product notes | + +## How the prototype stores data + +- Profiles currently live in browser `localStorage`. +- Uploaded audio uses session-only browser URLs and may need to be re-added + after reopening the browser. +- The discoverable creator catalog is seeded prototype data. +- There is no production authentication or remote database yet. + +The planned backend phase will replace these local-only pieces with real +accounts, storage, and published profile URLs. + +## Working on the project + +Read [AGENTS.md](AGENTS.md) for the product principles and engineering +conventions before making larger changes. In particular: + +- Keep the product profile-first, never feed-first. +- Keep `packages/core` deterministic and free of browser or Three.js code. +- Use short-lived branches and Conventional Commit messages. +- Do not add dependencies without documenting why they are needed. + +## Troubleshooting + +**`pnpm` is not recognized** + +Install the pinned version with `npm install --global pnpm@9.15.9`, close and +reopen the terminal, then run `pnpm install` again. + +**The page is blank or the 3D scene is very slow** + +Use a current version of Chrome, Edge, Firefox, or Safari and make sure hardware +acceleration/WebGL is enabled. + +**The wrong project opens on localhost** + +Check the URL printed by `pnpm dev`. Another project may already be using port +5173, in which case ourspace will start on the next available port. + +**A clean profile is needed for testing** -Read [CLAUDE.md](CLAUDE.md) first — product principles are non-negotiable. -Trunk-based development, Conventional Commits, every dependency justified. +Use a private/incognito window or clear this site's local storage in the +browser's developer tools. diff --git a/apps/presence/package.json b/apps/presence/package.json index a1071f4..8f51d86 100644 --- a/apps/presence/package.json +++ b/apps/presence/package.json @@ -1,5 +1,5 @@ { - "name": "@atrium/presence", + "name": "@ourspace/presence", "version": "0.1.0", "private": true, "type": "module", diff --git a/apps/web/index.html b/apps/web/index.html index 85dfb8e..22ee53b 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,8 +3,13 @@ - - Atrium + + + + ourspace — make your corner of the internet
diff --git a/apps/web/package.json b/apps/web/package.json index fcb1db7..9938da9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,5 +1,5 @@ { - "name": "@atrium/web", + "name": "@ourspace/web", "version": "0.1.0", "private": true, "type": "module", @@ -10,8 +10,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@atrium/avatar": "workspace:*", - "@atrium/core": "workspace:*", + "@ourspace/avatar": "workspace:*", + "@ourspace/core": "workspace:*", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.6.1", "react": "^19.2.7", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4d9bc67..3445d3c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,13 +1,55 @@ +import { useState } from "react"; +import { AccountGateway } from "./account/AccountGateway"; import { Onboarding } from "./onboarding/Onboarding"; import { ProfilePage } from "./profile/ProfilePage"; import { GalleryPage } from "./gallery/GalleryPage"; +import { RavePage } from "./rave/RavePage"; +import { DiscoveryPage } from "./discovery/DiscoveryPage"; +import { VisitorProfilePage } from "./discovery/VisitorProfilePage"; +import { getDiscoveryCreator } from "./discovery/catalog"; import { useApp } from "./store"; export function App() { const profile = useApp((s) => s.profile); + const signedOut = useApp((s) => s.signedOut); const screen = useApp((s) => s.screen); + const [creating, setCreating] = useState(false); - if (!profile) return ; + // Creation must win over the existing-profile route. This also lets a + // signed-out user deliberately replace the one local account supported in v0. + if (creating) { + return setCreating(false)} onComplete={() => setCreating(false)} />; + } + if (!profile || signedOut) { + return setCreating(true)} />; + } if (screen.name === "gallery") return ; + if (screen.name === "rave") return ; + if (screen.name === "discovery") return ; + if (screen.name === "visitor") return ; + if (screen.name === "visitorGallery") { + const creator = getDiscoveryCreator(screen.profileId); + if (creator) { + return ( + + ); + } + } + if (screen.name === "visitorRave") { + const creator = getDiscoveryCreator(screen.profileId); + if (creator) { + return ( + + ); + } + } return ; } diff --git a/apps/web/src/account/AccountGateway.tsx b/apps/web/src/account/AccountGateway.tsx new file mode 100644 index 0000000..c957aad --- /dev/null +++ b/apps/web/src/account/AccountGateway.tsx @@ -0,0 +1,196 @@ +import { useState, type FormEvent } from "react"; +import type { ProfileDoc } from "@ourspace/core"; +import { useApp } from "../store"; + +interface AccountGatewayProps { + profile: ProfileDoc | null; + onCreate: () => void; +} + +export function AccountGateway({ profile, onCreate }: AccountGatewayProps) { + const signIn = useApp((s) => s.signIn); + const [showLogin, setShowLogin] = useState(profile !== null); + const [username, setUsername] = useState(profile?.username ?? ""); + const [error, setError] = useState(""); + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (!profile) { + setError("No local account lives on this device yet. Create one first."); + return; + } + if (username.trim().toLowerCase() !== profile.username.toLowerCase()) { + setError(`This device only knows @${profile.username}.`); + return; + } + signIn(); + }; + + return ( +
+ + +
+
+
PORTFOLIOS FOR INTERNET PEOPLE // 2000—FOREVER
+

+ Your work. +
+ Your world. +
+ Your space. +

+

+ Build a tiny digital home for everything you make. No feed to please—just your avatar, + your rooms, and the people who came to see your work. +

+
+ + +
+
+ ✦ profile-first + ✦ no algorithm + ✦ built for artists +
+
+ +
+
+
+
+
+ ourspace://you +
+ + + +
+
+
+ +
+ NOW VISITING + @yourname +
+ 01 / WORKS ENTER → +
+
+ 02 / SOUNDS PLAY ▶ +
+
+
+
+ + MAKE +
+ IT +
+ YOURS +
+ WWW. +
+
+ +
+
+ AVATAR BUILDER ✦ WALKABLE GALLERIES ✦ PROFILE SOUNDTRACKS ✦ CREATOR MOODBOARDS ✦ YOUR WORK + IS THE POINT ✦ +
+
+ +
+
+ 01 +

A page, not a feed.

+

Curate a place people choose to visit. Nothing gets buried by an algorithm.

+
+
+ 02 +

Become low-poly.

+

Build a PS1-era you, then walk through image galleries and late-night sound rooms.

+
+
+ 03 +

Press play.

+

Pin a favorite song to your profile or take a whole playlist into the rave.

+
+
+ + {showLogin && ( +
setShowLogin(false)}> +
e.stopPropagation()} + > +
+ secure_login.exe + +
+
+
+

RETURN TO YOURSPACE

+

Log in locally

+

+ Real cloud accounts arrive with the backend. For now, your profile is saved only in + this browser. +

+ + {error && ( +

+ {error} +

+ )} + + + {profile && ( +

+ Completing setup replaces @{profile.username} on this browser. You can exit setup + before the final step without changing anything. +

+ )} +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/AvatarStage.tsx b/apps/web/src/components/AvatarStage.tsx index 05ed715..aa83f2c 100644 --- a/apps/web/src/components/AvatarStage.tsx +++ b/apps/web/src/components/AvatarStage.tsx @@ -1,8 +1,8 @@ -import { useEffect, useRef, type ReactNode } from "react"; +import { useEffect, useRef, type ReactNode } from "react"; import { Canvas, useFrame, useThree } from "@react-three/fiber"; import type { Group } from "three"; -import { Avatar } from "@atrium/avatar"; -import type { AvatarDoc } from "@atrium/core"; +import { Avatar } from "@ourspace/avatar"; +import type { AvatarDoc } from "@ourspace/core"; /** Aim the default camera at the avatar's middle, whatever the box aspect. */ function AimCamera({ y }: { y: number }) { @@ -35,20 +35,26 @@ export interface AvatarStageProps { export function AvatarStage({ doc, turntable = false, zoom = "full" }: AvatarStageProps) { const camera = zoom === "bust" - ? { fov: 28, position: [0, 1.3, 2.3] as [number, number, number] } - : { fov: 30, position: [0, 1.2, 4.0] as [number, number, number] }; + ? { fov: 26, position: [0, 1.58, 2.2] as [number, number, number] } + : { fov: 29, position: [0, 1.25, 4.35] as [number, number, number] }; return ( - - - - - + + + + + - + diff --git a/apps/web/src/discovery/DiscoveryPage.tsx b/apps/web/src/discovery/DiscoveryPage.tsx new file mode 100644 index 0000000..0939fb7 --- /dev/null +++ b/apps/web/src/discovery/DiscoveryPage.tsx @@ -0,0 +1,611 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Canvas, useFrame } from "@react-three/fiber"; +import * as THREE from "three"; +import { Avatar } from "@ourspace/avatar"; +import { CRAFTS, lerp, smoothstep, type ProfileDoc } from "@ourspace/core"; +import { AvatarStage } from "../components/AvatarStage"; +import { useApp, type DiscoveryMode } from "../store"; +import { DISCOVERY_CREATORS, sharedCraftCount, type DiscoveryCreator } from "./catalog"; + +type FlightPhase = "run" | "drop" | "dock" | "arrived"; + +const MODES: Array<{ id: DiscoveryMode; label: string; mark: string }> = [ + { id: "all", label: "All worlds", mark: "∞" }, + { id: "friends", label: "Friends", mark: "♥" }, + { id: "similar", label: "Same wave", mark: "≈" }, + { id: "opposite", label: "Opposites", mark: "↯" }, + { id: "random", label: "Surprise me", mark: "?" }, + { id: "new", label: "New signals", mark: "+" }, +]; + +const PLANET_POSITIONS: Array<[number, number, number]> = [ + [-4.2, 2.1, -8.5], + [-1.35, 0.75, -10.8], + [3.1, 2.15, -9.7], + [-3.2, -1.25, -12.5], + [0.75, -1.1, -11.8], + [4.25, -0.35, -13.3], +]; + +function seededOrder(value: string): number { + let hash = 2166136261; + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +function StarField() { + const points = useMemo(() => { + const values = new Float32Array(220 * 3); + for (let index = 0; index < 220; index++) { + const a = Math.sin(index * 12.9898) * 43758.5453; + const b = Math.sin(index * 78.233 + 4.1) * 24634.6345; + const c = Math.sin(index * 39.425 + 9.7) * 13579.2468; + values[index * 3] = (a - Math.floor(a) - 0.5) * 34; + values[index * 3 + 1] = (b - Math.floor(b) - 0.5) * 20; + values[index * 3 + 2] = -4 - (c - Math.floor(c)) * 28; + } + return values; + }, []); + + return ( + + + + + + + ); +} + +function MoodboardLedge() { + return ( + + + + + + {[-2.2, 0, 2.2].map((x, index) => ( + + + + + ))} + + + + + + ); +} + +function Spaceship({ + shipRef, + exteriorRef, +}: { + shipRef: React.RefObject; + exteriorRef: React.RefObject; +}) { + return ( + + + + + + + + + + + + + + + + + + + + + {/* Cockpit elements remain visible when the camera moves inside. */} + + + + + + + + + + + + + + + + + {[-1.4, -0.85, -0.3, 0.3, 0.85, 1.4].map((x, index) => ( + + + + + ))} + + ); +} + +function Planet({ + creator, + position, + selected, + onSelect, +}: { + creator: DiscoveryCreator; + position: [number, number, number]; + selected: boolean; + onSelect: () => void; +}) { + const group = useRef(null); + const planet = useRef(null); + useFrame((state, delta) => { + if (planet.current) { + planet.current.rotation.y += delta * 0.18; + planet.current.rotation.z += delta * 0.035; + } + if (group.current) { + const targetScale = selected ? 1.28 : 1; + const scale = THREE.MathUtils.lerp( + group.current.scale.x, + targetScale, + 1 - Math.exp(-8 * delta), + ); + group.current.scale.setScalar(scale); + group.current.position.y = + position[1] + Math.sin(state.clock.elapsedTime * 0.8 + position[0]) * 0.13; + } + }); + + return ( + { + event.stopPropagation(); + onSelect(); + }} + > + + + + + + + + + + + + + {selected && } + + ); +} + +function FlightScene({ + profile, + skipIntro, + creators, + selectedId, + onSelect, + onPhase, +}: { + profile: ProfileDoc; + skipIntro: boolean; + creators: DiscoveryCreator[]; + selectedId: string; + onSelect: (id: string) => void; + onPhase: (phase: FlightPhase) => void; +}) { + const avatar = useRef(null); + const deck = useRef(null); + const ship = useRef(null); + const shipExterior = useRef(null); + const start = useRef(null); + const lastPhase = useRef(null); + const lookAt = useMemo(() => new THREE.Vector3(), []); + + useFrame((state) => { + if (start.current === null) start.current = state.clock.elapsedTime; + const elapsed = skipIntro ? 99 : state.clock.elapsedTime - start.current; + const avatarGroup = avatar.current; + const shipGroup = ship.current; + const exteriorGroup = shipExterior.current; + const deckGroup = deck.current; + const camera = state.camera; + + let phase: FlightPhase; + if (elapsed < 2.35) phase = "run"; + else if (elapsed < 4.7) phase = "drop"; + else if (elapsed < 7.4) phase = "dock"; + else phase = "arrived"; + if (phase !== lastPhase.current) { + lastPhase.current = phase; + onPhase(phase); + } + + if (!avatarGroup || !shipGroup || !deckGroup || !exteriorGroup) return; + + if (phase === "run") { + const progress = smoothstep(0, 2.35, elapsed); + avatarGroup.visible = true; + avatarGroup.position.set( + lerp(-2.4, 2.55, progress), + 0.2 + Math.sin(progress * Math.PI) * 0.12, + 0.4, + ); + avatarGroup.rotation.set(0, Math.PI / 2, 0); + deckGroup.position.y = 0; + shipGroup.position.y = -5.2; + exteriorGroup.visible = true; + camera.position.set(lerp(-0.7, 0.55, progress), 2.5, lerp(7.2, 5.9, progress)); + lookAt.set(avatarGroup.position.x * 0.24, 0.9, 0.1); + camera.lookAt(lookAt); + } else if (phase === "drop") { + const progress = smoothstep(2.35, 4.7, elapsed); + avatarGroup.visible = true; + avatarGroup.position.set( + 2.55 - progress * 1.6, + lerp(0.2, -4.1, progress * progress), + lerp(0.4, 0, progress), + ); + avatarGroup.rotation.set(progress * 1.6, Math.PI / 2 + progress * 2.2, progress * 0.45); + deckGroup.position.y = lerp(0, 4.5, progress); + shipGroup.position.y = -5.2; + exteriorGroup.visible = true; + camera.position.set( + lerp(0.55, 0.15, progress), + lerp(2.5, -1.2, progress), + lerp(5.9, 6.7, progress), + ); + lookAt.copy(avatarGroup.position); + camera.lookAt(lookAt); + } else if (phase === "dock") { + const progress = smoothstep(4.7, 7.4, elapsed); + avatarGroup.visible = progress < 0.22; + avatarGroup.position.y = -4.1; + deckGroup.position.y = 4.5; + shipGroup.position.y = lerp(-5.2, 0, progress); + exteriorGroup.visible = progress < 0.78; + camera.position.set(0, lerp(-1.2, 1.48, progress), lerp(6.7, 4.75, progress)); + lookAt.set(0, lerp(-3.8, 0.8, progress), lerp(0, -8.5, progress)); + camera.lookAt(lookAt); + } else { + avatarGroup.visible = false; + deckGroup.position.y = 4.5; + shipGroup.position.y = 0; + exteriorGroup.visible = false; + const time = state.clock.elapsedTime; + camera.position.set( + Math.sin(time * 0.35) * 0.045, + 1.48 + Math.sin(time * 0.48) * 0.025, + 4.75, + ); + lookAt.set(0, 0.55, -10.2); + camera.lookAt(lookAt); + } + }); + + return ( + <> + + + + + + + + + + + + + + + + + + {creators.map((creator, index) => ( + onSelect(creator.id)} + /> + ))} + + + ); +} + +function reasonFor(creator: DiscoveryCreator, ownCrafts: string[], mode: DiscoveryMode): string { + const shared = sharedCraftCount(ownCrafts, creator.profile.crafts); + if (mode === "friends") return "already in your orbit"; + if (mode === "similar") return `${shared} shared practice${shared === 1 ? "" : "s"}`; + if (mode === "opposite") return "outside your usual atmosphere"; + if (mode === "new") return "a fresh signal on ourspace"; + if (mode === "random") return "the universe picked this one"; + return creator.vibe; +} + +export function DiscoveryPage({ resume = false }: { resume?: boolean }) { + const profile = useApp((state) => state.profile); + const setScreen = useApp((state) => state.setScreen); + const mode = useApp((state) => state.discovery.mode); + const query = useApp((state) => state.discovery.query); + const randomSeed = useApp((state) => state.discovery.randomSeed); + const selectedId = useApp((state) => state.discovery.selectedId); + const updateDiscovery = useApp((state) => state.updateDiscovery); + const [phase, setPhase] = useState(resume ? "arrived" : "run"); + const setSelectedId = useCallback( + (id: string) => updateDiscovery({ selectedId: id }), + [updateDiscovery], + ); + + const creators = useMemo(() => { + if (!profile) return []; + let result = DISCOVERY_CREATORS.filter((creator) => { + const shared = sharedCraftCount(profile.crafts, creator.profile.crafts); + if (mode === "friends") return creator.relation === "friend"; + if (mode === "similar") return shared > 0; + if (mode === "opposite") return shared === 0; + if (mode === "new") return creator.relation === "new"; + return true; + }); + const needle = query.trim().toLowerCase(); + if (needle) { + result = result.filter((creator) => + [creator.profile.name, creator.profile.username, creator.vibe, ...creator.profile.crafts] + .join(" ") + .toLowerCase() + .includes(needle), + ); + } + if (mode === "random") { + result = [...result].sort( + (a, b) => + seededOrder(`${a.id}:${profile.username}:${randomSeed}`) - + seededOrder(`${b.id}:${profile.username}:${randomSeed}`), + ); + } + return result; + }, [mode, profile, query, randomSeed]); + + useEffect(() => { + if (creators.length && !creators.some((creator) => creator.id === selectedId)) { + setSelectedId(creators[0]?.id ?? ""); + } + }, [creators, selectedId, setSelectedId]); + + const selected = creators.find((creator) => creator.id === selectedId) ?? creators[0]; + const arrived = phase === "arrived"; + const selectRelative = useCallback( + (direction: -1 | 1) => { + if (!creators.length) return; + const current = Math.max( + 0, + creators.findIndex((creator) => creator.id === selectedId), + ); + const next = (current + direction + creators.length) % creators.length; + setSelectedId(creators[next]?.id ?? ""); + }, + [creators, selectedId, setSelectedId], + ); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if ( + !arrived || + (event.target instanceof HTMLElement && /input|textarea/i.test(event.target.tagName)) + ) + return; + if (event.key === "Escape") setScreen({ name: "profile" }); + if (event.key === "ArrowRight" || event.key.toLowerCase() === "d") selectRelative(1); + if (event.key === "ArrowLeft" || event.key.toLowerCase() === "a") selectRelative(-1); + if ((event.key === "Enter" || event.key === " ") && selected) { + setScreen({ name: "visitor", profileId: selected.id }); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [arrived, selectRelative, selected, setScreen]); + + if (!profile) return null; + + return ( +
+ + + + +
+
+ +
+ + + {phase === "run" + ? "LEAVING PROFILE" + : phase === "drop" + ? "GRAVITY ASSIST" + : phase === "dock" + ? "ENTERING COCKPIT" + : "ORBIT STABLE"} + +
+ + {arrived + ? "A / D TO TUNE · ENTER TO VISIT · ESC HOME" + : "ONE CONTINUOUS SIGNAL — HOLD TIGHT"} + +
+ + + + {selected ? ( + + ) : ( + + )} + + +
+
+ ); +} diff --git a/apps/web/src/discovery/VisitorProfilePage.tsx b/apps/web/src/discovery/VisitorProfilePage.tsx new file mode 100644 index 0000000..35e8362 --- /dev/null +++ b/apps/web/src/discovery/VisitorProfilePage.tsx @@ -0,0 +1,231 @@ +import { useEffect, useState } from "react"; +import { + CRAFTS, + neonAccent, + type ModuleDoc, + type MusicModule, + type TrackItem, +} from "@ourspace/core"; +import { AvatarStage } from "../components/AvatarStage"; +import { useApp } from "../store"; +import { getDiscoveryCreator } from "./catalog"; + +function favoriteTrack(modules: ModuleDoc[]): { track: TrackItem; module: MusicModule } | null { + for (const module of modules) { + if (module.kind !== "music" || !module.favoriteTrackId) continue; + const track = module.tracks.find((item) => item.id === module.favoriteTrackId); + if (track) return { track, module }; + } + return null; +} + +export function VisitorProfilePage({ profileId }: { profileId: string }) { + const setScreen = useApp((state) => state.setScreen); + const creator = getDiscoveryCreator(profileId); + const [following, setFollowing] = useState(creator?.relation === "friend"); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setScreen({ name: "discovery", resume: true }); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [setScreen]); + + useEffect(() => { + if (!creator) setScreen({ name: "discovery", resume: true }); + }, [creator, setScreen]); + if (!creator) return null; + + const profile = creator.profile; + const favorite = favoriteTrack(profile.modules); + const accent = neonAccent(creator.planetAccent); + + return ( +
+ + +
+
+
+ + +
+
+ +
+
+
CREATOR WORLD // {creator.vibe.toUpperCase()}
+

{profile.name}

+
@{profile.username}
+

{profile.about}

+
+ {profile.crafts.map((craft) => ( + {CRAFTS.find((item) => item.id === craft)?.label ?? craft} + ))} +
+
◎ {creator.signal}
+
+
+ + Read-only visit · their space stays theirs +
+
+ + {favorite && ( +
+
+ + +
+
+ THIS WORLD IS PLAYING + {favorite.track.title} +
+ signal preview · audio not attached + +
+ )} + +
+ INSIDE @{profile.username} + {String(profile.modules.length).padStart(2, "0")} ROOMS +
+ {profile.modules.map((module, index) => ( +
+
+
+ {String(index + 1).padStart(2, "0")} +

{module.title}

+ + {module.kind === "music" ? "SOUND ROOM" : module.kind.toUpperCase()} + +
+
+ {module.kind === "photos" && ( + <> +
+ {module.items.map((item) => ( +
+ {item.caption +
+ ))} +
+
+ +
+ + )} + {module.kind === "music" && ( + <> +
    + {module.tracks.map((track, trackIndex) => ( +
  • + {String(trackIndex + 1).padStart(2, "0")} + + {track.title} + {track.id === module.favoriteTrackId && PROFILE SONG} + + remote signal +
  • + ))} +
+
+ +
+ + )} + {module.kind === "moodboard" && ( +
+ {module.items.map((item) => ( +
+ {item.kind === "image" && ( + {`${profile.name}'s + )} + {item.kind === "note" &&

{item.text}

} + {item.kind === "color" && ( +
+ {item.value} +
+ )} +
+ ))} +
+ )} + {module.kind === "text" && ( +
+ {module.body.split("\n\n").map((paragraph, paragraphIndex) => ( +

{paragraph}

+ ))} +
+ )} +
+ ))} + +
+ + you visited @{profile.username} without changing a thing +
+
+
+ ); +} diff --git a/apps/web/src/discovery/catalog.ts b/apps/web/src/discovery/catalog.ts new file mode 100644 index 0000000..520760b --- /dev/null +++ b/apps/web/src/discovery/catalog.ts @@ -0,0 +1,264 @@ +import type { AvatarDoc, ProfileDoc } from "@ourspace/core"; +import { sampleArt } from "../sampleArt"; + +export type DiscoveryRelation = "friend" | "new" | "stranger"; + +export interface DiscoveryCreator { + id: string; + profile: ProfileDoc; + relation: DiscoveryRelation; + vibe: string; + signal: string; + planetColor: string; + planetAccent: string; +} + +interface CreatorSeed { + id: string; + name: string; + username: string; + about: string; + crafts: string[]; + relation: DiscoveryRelation; + vibe: string; + signal: string; + planetColor: string; + planetAccent: string; + avatar: AvatarDoc; + note: string; + track: string; +} + +const SEEDS: CreatorSeed[] = [ + { + id: "mara-vale", + name: "Mara Vale", + username: "maravale", + about: "Film photos, hand-painted flyers, and small evidence that a night actually happened.", + crafts: ["photography", "visual"], + relation: "friend", + vibe: "after-hours romantic", + signal: "Toronto · usually awake", + planetColor: "#6f35d8", + planetAccent: "#ff55ca", + avatar: avatar( + "#d9a066", + "#241f1c", + "#3d6b4f", + "#8a6fc0", + "#26221c", + "#26221c", + "buns", + "shades", + ), + note: "Flash glare, violet ink, the last train home.", + track: "Blue Exit — 3:17 AM", + }, + { + id: "ari-flux", + name: "Ari Flux", + username: "ariflux", + about: + "I build loud little worlds out of field recordings, broken synths, and practical effects.", + crafts: ["music", "film"], + relation: "friend", + vibe: "kinetic and electric", + signal: "Montreal · open to collabs", + planetColor: "#0c8f88", + planetAccent: "#54ffdc", + avatar: avatar( + "#8c5a33", + "#57a773", + "#2b2420", + "#26221c", + "#3a4a6b", + "#f2ede4", + "spiky", + "headphones", + ), + note: "Distortion is a texture, not a mistake.", + track: "Soft Crash — Ari Flux", + }, + { + id: "kaito-moss", + name: "Kaito Moss", + username: "kaitomoss", + about: "Tiny ceramic creatures and stories about where they go when nobody is looking.", + crafts: ["maker", "writing"], + relation: "stranger", + vibe: "quietly strange", + signal: "Kyoto · collecting pen pals", + planetColor: "#688b34", + planetAccent: "#c8ff32", + avatar: avatar( + "#eec1a3", + "#4a342a", + "#3a5f8a", + "#57a773", + "#8c6a4f", + "#26221c", + "bowl", + "glasses", + ), + note: "Soft moss, kiln heat, five-line ghost stories.", + track: "Garden Receiver — Field Note 08", + }, + { + id: "june-hex", + name: "June Hex", + username: "junehex", + about: "Poetry, screenshots, browser ghosts. I keep archives of things the web forgot.", + crafts: ["writing", "photography"], + relation: "new", + vibe: "tender internet archaeology", + signal: "Online 12m ago · new arrival", + planetColor: "#324a91", + planetAccent: "#6da7ff", + avatar: avatar( + "#f6d7b0", + "#5a7fd6", + "#7a4a8a", + "#3a4a6b", + "#26221c", + "#d96fa8", + "long", + "none", + ), + note: "Cached pages, blue hour, unfinished sentences.", + track: "404 Lullaby — Soft Static", + }, + { + id: "odo-bloom", + name: "Odo Bloom", + username: "odobloom", + about: "Maximalist paintings and gentle dance music for rooms with too many lamps.", + crafts: ["visual", "music"], + relation: "stranger", + vibe: "bright beautiful overload", + signal: "Lagos · sends voice notes", + planetColor: "#ca4d18", + planetAccent: "#ffbd39", + avatar: avatar( + "#5d3a1f", + "#d96fa8", + "#2b2420", + "#d98e32", + "#8a6fc0", + "#26221c", + "afro", + "headphones", + ), + note: "More color. Then one more color after that.", + track: "Lamp Party — Odo Bloom", + }, + { + id: "soft-static", + name: "Soleil Static", + username: "softstatic", + about: "Costumes, miniature sets, and slow films about people becoming something else.", + crafts: ["maker", "film"], + relation: "new", + vibe: "dreamlike transformation", + signal: "Marseille · looking for composers", + planetColor: "#9c3a74", + planetAccent: "#ff7fc4", + avatar: avatar( + "#b07b4f", + "#e8d8a0", + "#3d6b4f", + "#b5443a", + "#26221c", + "#8c6a4f", + "ponytail", + "beret", + ), + note: "Velvet, painted moons, a door that is slightly too small.", + track: "Papier Moon — Soleil Static", + }, +]; + +function avatar( + skin: string, + hairColor: string, + eyeColor: string, + top: string, + bottom: string, + shoes: string, + hairStyle: string, + accessory: string, +): AvatarDoc { + return { version: 1, skin, hairColor, eyeColor, top, bottom, shoes, hairStyle, accessory }; +} + +function makeProfile(seed: CreatorSeed): ProfileDoc { + const trackId = `${seed.id}-track`; + return { + version: 1, + name: seed.name, + username: seed.username, + about: seed.about, + crafts: seed.crafts, + avatar: seed.avatar, + modules: [ + { + id: `${seed.id}-works`, + kind: "photos", + title: "Selected works", + items: [1, 2, 3, 4].map((index) => ({ + id: `${seed.id}-work-${index}`, + src: sampleArt(`discovery:${seed.id}:${index}`), + caption: `${seed.name} — piece ${String(index).padStart(2, "0")}`, + })), + }, + { + id: `${seed.id}-mood`, + kind: "moodboard", + title: "Current atmosphere", + items: [ + { id: `${seed.id}-note`, kind: "note", text: seed.note }, + { id: `${seed.id}-color-a`, kind: "color", value: seed.planetColor }, + { id: `${seed.id}-color-b`, kind: "color", value: seed.planetAccent }, + { + id: `${seed.id}-mood-image`, + kind: "image", + src: sampleArt(`discovery:${seed.id}:mood`), + }, + ], + }, + { + id: `${seed.id}-sounds`, + kind: "music", + title: "Sound moodboard", + tracks: [ + { id: trackId, title: seed.track }, + { id: `${seed.id}-track-b`, title: "Voice memo from another room" }, + ], + favoriteTrackId: trackId, + }, + { + id: `${seed.id}-note-module`, + kind: "text", + title: "Pinned note", + body: `${seed.note}\n\nIf you found this place by accident, leave with one idea you did not arrive with.`, + }, + ], + }; +} + +export const DISCOVERY_CREATORS: DiscoveryCreator[] = SEEDS.map((seed) => ({ + id: seed.id, + relation: seed.relation, + vibe: seed.vibe, + signal: seed.signal, + planetColor: seed.planetColor, + planetAccent: seed.planetAccent, + profile: makeProfile(seed), +})); + +export function getDiscoveryCreator(id: string): DiscoveryCreator | undefined { + return DISCOVERY_CREATORS.find((creator) => creator.id === id); +} + +export function sharedCraftCount(a: readonly string[], b: readonly string[]): number { + return a.filter((craft) => b.includes(craft)).length; +} diff --git a/apps/web/src/gallery/GalleryPage.tsx b/apps/web/src/gallery/GalleryPage.tsx index 34f0c4a..8394cfb 100644 --- a/apps/web/src/gallery/GalleryPage.tsx +++ b/apps/web/src/gallery/GalleryPage.tsx @@ -1,9 +1,10 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Canvas, useFrame } from "@react-three/fiber"; +import { useCallback } from "react"; import * as THREE from "three"; -import { Avatar } from "@atrium/avatar"; -import type { PhotoItem, PhotosModule } from "@atrium/core"; -import { useApp } from "../store"; +import { Avatar } from "@ourspace/avatar"; +import type { PhotoItem, PhotosModule, ProfileDoc } from "@ourspace/core"; +import { useApp, type Screen } from "../store"; /** * The walkable room: the owner's images framed on facing walls, visited in @@ -282,9 +283,22 @@ function Scene({ items, textures, title, accent, onNear }: SceneProps) { ); } -export function GalleryPage({ moduleId }: { moduleId: string }) { - const profile = useApp((s) => s.profile); +export function GalleryPage({ + moduleId, + profileOverride, + returnScreen, +}: { + moduleId: string; + profileOverride?: ProfileDoc; + returnScreen?: Screen; +}) { + const localProfile = useApp((s) => s.profile); const setScreen = useApp((s) => s.setScreen); + const profile = profileOverride ?? localProfile; + const goBack = useCallback( + () => setScreen(returnScreen ?? { name: "profile" }), + [returnScreen, setScreen], + ); const module = profile?.modules.find( (m): m is PhotosModule => m.id === moduleId && m.kind === "photos", ); @@ -309,16 +323,16 @@ export function GalleryPage({ moduleId }: { moduleId: string }) { useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setScreen({ name: "profile" }); + if (e.key === "Escape") goBack(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [setScreen]); + }, [goBack]); const missing = !profile || !module; useEffect(() => { - if (missing) setScreen({ name: "profile" }); - }, [missing, setScreen]); + if (missing) goBack(); + }, [goBack, missing]); if (missing) return null; const caption = near !== null ? (items[near]?.caption ?? `Untitled ${near + 1}`) : null; @@ -336,11 +350,7 @@ export function GalleryPage({ moduleId }: { moduleId: string }) { />
-
diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 0a3ea2d..ce31ad1 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -5,678 +5,2756 @@ } :root { - --paper: #f3eee1; - --ink: #2a251d; - --card: #fffdf6; - --muted: rgba(42, 37, 29, 0.55); - --accent: #57a773; - --shadow: 4px 4px 0 rgba(42, 37, 29, 0.85); + color-scheme: dark; + --void: #050509; + --paper: #090a10; + --panel: #11121a; + --panel-2: #171824; + --ink: #f2f0fa; + --muted: #9292a8; + --line: #343545; + --acid: #c8ff32; + --cyan: #54ffdc; + --pink: #ff4dd8; + --accent: #9b72ff; + --shadow: 8px 8px 0 #020204; + font-synthesis: none; +} + +html { + scroll-behavior: smooth; } - html, body, #root { - height: 100%; + min-height: 100%; } - body { - background: var(--paper); - background-image: radial-gradient(rgba(42, 37, 29, 0.05) 1px, transparent 1px); - background-size: 22px 22px; + min-width: 320px; + background: + radial-gradient(circle at 18% -5%, rgba(112, 48, 213, 0.24), transparent 34rem), + radial-gradient(circle at 88% 12%, rgba(20, 208, 168, 0.1), transparent 25rem), + linear-gradient(rgba(255, 255, 255, 0.018) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.018) 1px, transparent 1px), var(--void); + background-size: + auto, + auto, + 22px 22px, + 22px 22px, + auto; color: var(--ink); - font-family: - "Segoe UI", - system-ui, - -apple-system, - sans-serif; + font-family: Arial, Helvetica, sans-serif; + overflow-x: hidden; +} +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + opacity: 0.28; + background-image: radial-gradient(#fff 0.65px, transparent 0.65px); + background-size: 47px 47px; + mask-image: linear-gradient(to bottom, #000, transparent 76%); +} +button, +input, +textarea { + font: inherit; +} +button, +a { + -webkit-tap-highlight-color: transparent; +} +button:focus-visible, +a:focus-visible, +input:focus-visible, +textarea:focus-visible { + outline: 2px solid var(--cyan); + outline-offset: 3px; } - .display { - font-family: Georgia, "Times New Roman", serif; - font-weight: 600; - letter-spacing: -0.015em; + font-family: Impact, "Arial Black", sans-serif; + font-weight: 900; + letter-spacing: -0.035em; + line-height: 0.95; + text-transform: uppercase; +} +.eyebrow, +.profile-kicker, +.module-code { + color: var(--cyan); + font: + 700 0.68rem/1.2 "Courier New", + monospace; + letter-spacing: 0.14em; +} + +/* Shared chrome */ +.site-nav, +.setup-nav { + width: min(1180px, calc(100% - 40px)); + height: 74px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + border-bottom: 1px solid var(--line); + position: relative; + z-index: 5; +} +.wordmark { + color: var(--ink); + text-decoration: none; + font: + italic 900 1.6rem/1 Arial, + sans-serif; + letter-spacing: -0.08em; +} +.wordmark span { + color: var(--pink); +} +.wordmark i { + color: var(--acid); + font-size: 0.7rem; + margin-left: 6px; + vertical-align: top; + text-shadow: 0 0 12px var(--acid); +} +.wordmark-button { + border: 0; + background: none; + cursor: pointer; +} +.nav-status { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font: + 700 0.64rem "Courier New", + monospace; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.nav-status > span { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--cyan); + box-shadow: 0 0 12px var(--cyan); } - -/* ---------- shared chunky bits ---------- */ - .card { - background: var(--card); - border: 2px solid var(--ink); - border-radius: 18px; + position: relative; + background: linear-gradient(145deg, rgba(27, 28, 40, 0.97), rgba(12, 13, 20, 0.98)); + border: 1px solid #424456; + border-radius: 5px; box-shadow: var(--shadow); - padding: 22px 24px; + padding: 24px; +} +.card::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + border-top: 1px solid rgba(255, 255, 255, 0.16); + border-left: 1px solid rgba(255, 255, 255, 0.08); + border-radius: inherit; } - .btn { display: inline-flex; align-items: center; - gap: 6px; - padding: 10px 18px; - border: 2px solid var(--ink); - border-radius: 12px; - background: var(--card); + justify-content: center; + gap: 8px; + min-height: 42px; + padding: 10px 17px; + border: 1px solid #6b6e80; + border-radius: 3px; + background: linear-gradient(#282a37, #14151e); color: var(--ink); - font: inherit; - font-weight: 600; + font: + 800 0.72rem/1 "Courier New", + monospace; + letter-spacing: 0.035em; + text-transform: uppercase; cursor: pointer; - box-shadow: 3px 3px 0 rgba(42, 37, 29, 0.85); + box-shadow: 3px 3px 0 #030305; transition: - transform 80ms ease, - box-shadow 80ms ease; + transform 100ms ease, + border-color 100ms ease, + color 100ms ease, + box-shadow 100ms ease; } - .btn:hover { transform: translate(-1px, -1px); - box-shadow: 4px 4px 0 rgba(42, 37, 29, 0.85); + border-color: var(--cyan); + color: #fff; + box-shadow: + 4px 4px 0 #030305, + 0 0 16px rgba(84, 255, 220, 0.12); } - .btn:active { transform: translate(2px, 2px); - box-shadow: 1px 1px 0 rgba(42, 37, 29, 0.85); + box-shadow: 1px 1px 0 #030305; } - .btn:disabled { - opacity: 0.45; - cursor: default; + opacity: 0.38; + cursor: not-allowed; transform: none; } - .btn.primary { background: var(--accent); - color: #fffdf6; + border-color: color-mix(in srgb, var(--accent), white 42%); + color: #08080d; + box-shadow: + 3px 3px 0 #030305, + 0 0 22px color-mix(in srgb, var(--accent), transparent 72%); +} +.btn.ghost { + background: rgba(9, 10, 16, 0.65); } - .btn.small { - padding: 7px 13px; - font-size: 0.85rem; + min-height: 34px; + padding: 7px 12px; + font-size: 0.65rem; } - .btn.tiny { - padding: 3px 9px; - font-size: 0.8rem; - box-shadow: 2px 2px 0 rgba(42, 37, 29, 0.85); + min-height: 27px; + padding: 4px 8px; + font-size: 0.62rem; + box-shadow: 2px 2px 0 #030305; } - .btn.danger { - background: #f3d9d5; + border-color: #70354f; + color: #ff8abf; +} +.btn.danger.confirming { + background: #e43973; + color: white; } - input, textarea { - font: inherit; - color: inherit; - background: #fff; - border: 2px solid var(--ink); - border-radius: 10px; - padding: 10px 12px; - outline: none; + width: 100%; + color: var(--ink); + background: #07080d; + border: 1px solid #47495b; + border-radius: 2px; + padding: 11px 12px; + outline: 0; + font-family: "Courier New", monospace; + transition: + border-color 120ms ease, + box-shadow 120ms ease; } - input:focus, textarea:focus { - border-color: var(--accent); -} - -/* ---------- onboarding ---------- */ - -.onboarding { - min-height: 100%; - display: grid; - place-items: center; - padding: 32px 16px; + border-color: var(--cyan); + box-shadow: + inset 0 0 0 1px var(--cyan), + 0 0 14px rgba(84, 255, 220, 0.1); } - -.onboarding-card { - width: min(880px, 100%); - background: var(--card); - border: 2px solid var(--ink); - border-radius: 22px; - box-shadow: 6px 6px 0 rgba(42, 37, 29, 0.85); - padding: 34px 38px; -} - -.progress { - display: flex; - gap: 8px; - margin-bottom: 26px; -} - -.dot { - width: 10px; - height: 10px; - border-radius: 50%; - border: 2px solid var(--ink); - background: transparent; +input::placeholder, +textarea::placeholder { + color: #5f6072; } - -.dot.filled { - background: var(--accent); +audio { + height: 32px; + max-width: 100%; + filter: invert(0.88) hue-rotate(165deg) saturate(1.35); } -.step h1 { - font-size: 2.3rem; - margin-bottom: 12px; +/* Landing / account gateway */ +.gateway-shell { + min-height: 100vh; + overflow: hidden; } - -.step h2 { - font-size: 1.7rem; - margin-bottom: 10px; +.gateway-hero { + width: min(1180px, calc(100% - 40px)); + min-height: 650px; + margin: 0 auto; + padding: 76px 0 66px; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(380px, 0.86fr); + align-items: center; + gap: 70px; } - -.lede { - color: var(--muted); - max-width: 56ch; - margin-bottom: 22px; - line-height: 1.55; +.hero-copy { + position: relative; + z-index: 2; } - -.field { - display: block; - margin-bottom: 16px; +.hero-copy .eyebrow { + margin-bottom: 23px; } - -.field span { - display: block; - font-size: 0.8rem; - font-weight: 700; +.hero-copy h1 { + font: + 900 clamp(4.3rem, 8vw, 7.8rem)/0.76 Impact, + "Arial Black", + sans-serif; + letter-spacing: -0.065em; text-transform: uppercase; - letter-spacing: 0.08em; - margin-bottom: 6px; - color: var(--muted); -} - -.field input { - width: min(380px, 100%); -} - -.field span.field-hint { - font-size: 0.72rem; - font-weight: 400; - color: var(--muted); - margin-top: 5px; - text-transform: none; - letter-spacing: 0.02em; } - -.about-input { - width: 100%; - resize: vertical; +.hero-copy h1 em { + position: relative; + color: var(--acid); + font-style: italic; + text-shadow: + 5px 5px 0 #3f1b8b, + 0 0 28px rgba(200, 255, 50, 0.15); } - -.step-actions { +.hero-copy h1 em::after { + content: ""; + position: absolute; + height: 3px; + left: 0; + right: 0; + bottom: -6px; + background: linear-gradient(90deg, var(--pink), var(--cyan)); + transform: skewX(-20deg); +} +.hero-copy > p { + max-width: 600px; + margin: 32px 0 28px; + color: #b8b8c9; + font-size: 1.02rem; + line-height: 1.65; +} +.hero-actions { display: flex; + flex-wrap: wrap; gap: 12px; - justify-content: flex-end; - margin-top: 26px; } - -.crafts { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 12px; +.gateway-cta { + min-height: 50px; + padding-inline: 22px; } - -.craft { - display: grid; - gap: 3px; - text-align: left; - padding: 14px 16px; - border: 2px solid var(--ink); - border-radius: 14px; - background: #fff; - cursor: pointer; - font: inherit; - color: inherit; - box-shadow: 3px 3px 0 rgba(42, 37, 29, 0.5); +.gateway-cta span { + font-size: 1.1rem; } - -.craft.selected { - background: var(--accent); - color: #fffdf6; - box-shadow: 3px 3px 0 rgba(42, 37, 29, 0.85); +.hero-proof { + display: flex; + flex-wrap: wrap; + gap: 18px; + margin-top: 28px; + color: #77798e; + font: + 700 0.62rem "Courier New", + monospace; + text-transform: uppercase; + letter-spacing: 0.08em; } - -.craft-emoji { - font-size: 1.4rem; +.portal-stack { + position: relative; + height: 500px; + display: grid; + place-items: center; + perspective: 850px; } - -.craft-label { - font-weight: 700; +.portal-window { + position: relative; + z-index: 2; + width: min(420px, 100%); + border: 1px solid #9a9db2; + background: #10111b; + box-shadow: + 16px 18px 0 rgba(0, 0, 0, 0.55), + 0 0 70px rgba(117, 62, 255, 0.2); + transform: rotateY(-5deg) rotateZ(2deg); +} +.window-bar { + height: 31px; + padding: 0 9px; + display: flex; + align-items: center; + justify-content: space-between; + color: #d8d9e4; + background: linear-gradient(#606276, #242532 55%, #12131b 55%); + border-bottom: 1px solid #898b9e; + font: + 700 0.61rem "Courier New", + monospace; + letter-spacing: 0.05em; +} +.window-bar div { + display: flex; + gap: 5px; } - -.craft-blurb { - font-size: 0.8rem; - opacity: 0.75; +.window-bar i { + display: block; + width: 9px; + height: 9px; + border: 1px solid #b8bacb; + background: #272936; +} +.window-bar button { + color: #fff; + background: none; + border: 0; + font-size: 1.1rem; + cursor: pointer; } - -/* ---------- avatar builder ---------- */ - -.builder { +.portal-screen { + min-height: 390px; display: grid; - grid-template-columns: 300px 1fr; - gap: 22px; - align-items: start; + grid-template-columns: 0.8fr 1fr; + gap: 24px; + align-items: center; + padding: 32px 27px; + background: + radial-gradient(circle at 25% 45%, rgba(255, 47, 207, 0.22), transparent 34%), + linear-gradient(130deg, #19132a, #080a12 62%); } - -@media (max-width: 720px) { - .builder { - grid-template-columns: 1fr; - } +.portal-screen::after { + content: ""; + position: absolute; + inset: 31px 0 0; + pointer-events: none; + background: repeating-linear-gradient( + to bottom, + transparent 0 3px, + rgba(255, 255, 255, 0.025) 4px + ); } - -.builder-preview { - height: 360px; - border: 2px solid var(--ink); - border-radius: 16px; - background: linear-gradient(180deg, #e7f0e4, #d8e4d0); +.pixel-person { position: relative; - overflow: hidden; + height: 260px; + filter: drop-shadow(0 0 18px rgba(84, 255, 220, 0.28)); } - -.builder-preview .btn { +.pixel-person span { position: absolute; + display: block; left: 50%; transform: translateX(-50%); - bottom: 10px; + clip-path: polygon(12% 0, 88% 0, 100% 24%, 83% 100%, 17% 100%, 0 24%); +} +.pixel-hair { + width: 112px; + height: 88px; + top: 10px; + background: #caff33; + z-index: 3; + transform: translateX(-50%) rotate(-7deg) !important; +} +.pixel-head { + width: 82px; + height: 82px; + top: 50px; + background: #b97856; z-index: 2; } - -.builder-preview .btn:active { - transform: translateX(-50%) translate(2px, 2px); +.pixel-body { + width: 112px; + height: 110px; + top: 121px; + background: linear-gradient(110deg, #7739ed, #ff44ce); } - -.tabs { - display: flex; - gap: 8px; - margin-bottom: 16px; - flex-wrap: wrap; +.pixel-leg { + width: 34px; + height: 76px; + top: 205px; + background: #1b233c; } - -.tab { - padding: 7px 16px; - border: 2px solid var(--ink); - border-radius: 999px; - background: #fff; - font: inherit; - font-weight: 700; - cursor: pointer; +.pixel-leg.left { + margin-left: -27px; + transform: translateX(-50%) rotate(5deg); } - -.tab.selected { - background: var(--ink); - color: var(--card); +.pixel-leg.right { + margin-left: 27px; + transform: translateX(-50%) rotate(-5deg); } - -.builder-row { - margin-bottom: 16px; +.portal-copy { + position: relative; + z-index: 2; + display: grid; + gap: 10px; } - -.builder-label { - display: block; - font-size: 0.78rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--muted); - margin-bottom: 8px; +.portal-copy small { + color: var(--cyan); + font: + 700 0.58rem "Courier New", + monospace; + letter-spacing: 0.12em; } - -.swatches { - display: flex; - gap: 8px; - flex-wrap: wrap; +.portal-copy > strong { + font-size: 1.2rem; } - -.swatch { - width: 34px; - height: 34px; - border-radius: 50%; - border: 2px solid var(--ink); - cursor: pointer; +.mini-module { + display: grid; + gap: 9px; + padding: 13px; + background: #11121b; + border: 1px solid #3a3c4c; + color: #b8b9c7; + font: + 700 0.63rem "Courier New", + monospace; } - -.swatch.selected { - outline: 3px solid var(--accent); - outline-offset: 2px; +.mini-module b { + color: var(--cyan); } - -.options { - display: flex; - gap: 8px; - flex-wrap: wrap; +.mini-module.hot { + border-color: #7a3a84; } - -.option { - padding: 8px 14px; - border: 2px solid var(--ink); - border-radius: 10px; - background: #fff; - font: inherit; - cursor: pointer; +.mini-module.hot b { + color: var(--pink); } - -.option.selected { - background: var(--accent); - color: #fffdf6; - font-weight: 700; +.portal-orbit { + position: absolute; + border: 1px solid rgba(148, 114, 255, 0.24); + border-radius: 50%; } - -/* ---------- profile ---------- */ - -.profile { - max-width: 860px; - margin: 0 auto; - padding: 34px 18px 60px; - display: grid; - gap: 22px; +.orbit-one { + width: 490px; + height: 230px; + transform: rotate(-23deg); } - -.profile-header { - display: grid; - grid-template-columns: 200px 1fr auto; - gap: 20px; - align-items: start; +.orbit-two { + width: 330px; + height: 530px; + transform: rotate(58deg); + border-color: rgba(84, 255, 220, 0.17); } - -@media (max-width: 680px) { - .profile-header { - grid-template-columns: 1fr; - } -} - -.profile-avatar { - height: 220px; - border: 2px solid var(--ink); - border-radius: 16px; - background: linear-gradient(180deg, #e7f0e4, #d8e4d0); +.sticker { + position: absolute; + z-index: 3; + display: grid; + place-items: center; + text-align: center; + color: #07080b; + font: + 900 0.67rem/1.05 Arial, + sans-serif; + box-shadow: 5px 6px 0 #020204; +} +.sticker-a { + top: 34px; + right: -8px; + width: 84px; + height: 84px; + background: var(--pink); + clip-path: polygon( + 50% 0, + 61% 23%, + 81% 7%, + 78% 32%, + 100% 35%, + 81% 51%, + 96% 70%, + 70% 69%, + 68% 96%, + 51% 77%, + 31% 97%, + 31% 71%, + 5% 75%, + 23% 54%, + 0 42%, + 26% 35%, + 20% 11%, + 42% 25% + ); + transform: rotate(10deg); +} +.sticker-b { + bottom: 44px; + left: 0; + padding: 12px 16px; + background: var(--cyan); + transform: rotate(-7deg); +} +.gateway-ticker { + border-block: 1px solid #4a4c5d; + background: var(--acid); + color: #07080c; overflow: hidden; + transform: rotate(-1deg) scale(1.02); +} +.gateway-ticker div { + width: max-content; + padding: 12px 0; + font: + 900 0.75rem "Courier New", + monospace; + word-spacing: 14px; +} +.gateway-values { + width: min(1180px, calc(100% - 40px)); + margin: 70px auto 100px; + display: grid; + grid-template-columns: repeat(3, 1fr); + border: 1px solid var(--line); +} +.gateway-values article { + min-height: 220px; + padding: 28px; + border-right: 1px solid var(--line); + background: rgba(12, 13, 20, 0.72); +} +.gateway-values article:last-child { + border-right: 0; +} +.gateway-values article > span { + color: var(--pink); + font: + 700 0.65rem "Courier New", + monospace; +} +.gateway-values h2 { + margin: 44px 0 12px; + font: + 900 1.45rem Arial, + sans-serif; + text-transform: uppercase; } - -.profile-id h1 { - font-size: 2rem; -} - -.username { - color: var(--accent); - font-weight: 700; - margin: 2px 0 12px; -} - -.about { +.gateway-values p { + color: var(--muted); + font-size: 0.86rem; line-height: 1.55; - max-width: 52ch; - white-space: pre-wrap; } - -.profile-tools { +.auth-scrim { + position: fixed; + inset: 0; + z-index: 30; display: grid; - gap: 8px; + place-items: center; + padding: 18px; + background: rgba(2, 2, 5, 0.82); + backdrop-filter: blur(8px); +} +.auth-dialog { + width: min(460px, 100%); + border: 1px solid #8b8d9f; + background: #11121a; + box-shadow: + 14px 16px 0 #000, + 0 0 80px rgba(138, 70, 255, 0.25); +} +.auth-dialog form { + padding: 32px; +} +.auth-icon { + width: 48px; + height: 48px; + display: grid; + place-items: center; + margin-bottom: 22px; + color: #06070a; + background: var(--acid); + font-size: 1.5rem; + box-shadow: 4px 4px 0 #42228d; +} +.auth-dialog h2 { + margin: 6px 0 12px; + font: + 900 2.1rem Impact, + sans-serif; + text-transform: uppercase; } - -.builder-card { - padding: 24px; +.auth-copy { + margin-bottom: 22px; + color: var(--muted); + font-size: 0.82rem; + line-height: 1.5; } - -/* modules */ - -.module-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 16px; +.auth-dialog .btn.primary { + width: 100%; + margin-top: 6px; } - -.module-title { - font-family: Georgia, serif; - font-size: 1.3rem; +.auth-text-btn { + width: 100%; + margin-top: 15px; + border: 0; + background: none; + color: var(--cyan); + font: + 0.68rem "Courier New", + monospace; + cursor: pointer; } - -.module-title-input { - font-family: Georgia, serif; - font-size: 1.15rem; - font-weight: 600; - width: min(320px, 100%); +.auth-error { + margin: -4px 0 12px; + color: #ff72ae; + font-size: 0.75rem; } -.module-tools { - display: flex; - gap: 6px; +/* Onboarding */ +.onboarding { + min-height: 100vh; + padding-bottom: 50px; } - -.module-actions { +.setup-nav > span { + color: var(--muted); + font: + 700 0.65rem "Courier New", + monospace; + letter-spacing: 0.12em; +} +.onboarding-card { + width: min(980px, calc(100% - 32px)); + margin: 42px auto 0; + padding: 0; + background: linear-gradient(145deg, #171822, #0b0c12); + border: 1px solid #484a5d; + box-shadow: 12px 14px 0 #020204; +} +.progress { + height: 42px; display: flex; - gap: 10px; - margin-top: 14px; - flex-wrap: wrap; align-items: center; + gap: 7px; + padding: 0 18px; + border-bottom: 1px solid #383a4b; + background: #0a0b11; } - -.empty-hint { - color: var(--muted); - font-style: italic; +.dot { + display: block; + width: 34px; + height: 4px; + background: #252633; } - -.module-notice { - margin-top: 10px; - font-size: 0.8rem; - color: #8a4a3a; +.dot.filled { + background: var(--accent); + box-shadow: 0 0 10px color-mix(in srgb, var(--accent), transparent 50%); } - -.module-notice.quiet { - margin-top: 0; +.progress b { + margin-left: auto; color: var(--muted); - font-style: italic; + font: + 0.65rem "Courier New", + monospace; } - -.storage-banner { - background: #f8e3d4; - border: 2px solid #8a4a3a; - border-radius: 14px; - box-shadow: var(--shadow); - padding: 14px 18px; - font-weight: 600; - color: #6b3020; +.step { + padding: 44px 48px 40px; } - -.btn.danger.confirming { - background: #b5443a; - color: #fffdf6; +.step h1 { + max-width: 760px; + margin: 10px 0 22px; + font-size: clamp(3rem, 6vw, 5.4rem); } - -.photo-grid { +.step h2 { + margin: 10px 0 12px; + font-size: clamp(2.35rem, 5vw, 4.2rem); +} +.lede { + max-width: 630px; + margin-bottom: 28px; + color: #aaaabd; + font-size: 0.95rem; + line-height: 1.6; +} +.setup-preview-row { display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - gap: 12px; + grid-template-columns: repeat(4, 1fr); + margin-top: 32px; + border: 1px solid var(--line); } - -.photo { - position: relative; - border: 2px solid var(--ink); - border-radius: 12px; - overflow: hidden; - background: #fff; - aspect-ratio: 4 / 3; +.setup-preview-row span { + padding: 16px 12px; + border-right: 1px solid var(--line); + color: var(--muted); + font: + 700 0.63rem "Courier New", + monospace; + text-transform: uppercase; } - -.photo img { - width: 100%; - height: 100%; - object-fit: cover; +.setup-preview-row span:last-child { + border-right: 0; +} +.setup-preview-row b { display: block; + margin-bottom: 8px; + color: var(--pink); } - -.photo-remove { - position: absolute; - top: 6px; - right: 6px; - width: 26px; - height: 26px; - border-radius: 50%; - border: 2px solid var(--ink); - background: #fffdf6; - cursor: pointer; - font-weight: 700; +.step-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 30px; } - -.tracks { - list-style: none; +.form-grid { display: grid; - gap: 10px; + grid-template-columns: 1fr 1fr; + gap: 18px; + max-width: 720px; } - -.track { +.field { + display: block; + margin-bottom: 17px; +} +.field > span { + display: block; + margin-bottom: 7px; + color: #b5b6c5; + font: + 700 0.63rem "Courier New", + monospace; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.field small, +.field-note { + display: block; + margin-top: 7px; + color: var(--muted); + font-size: 0.69rem; + line-height: 1.45; +} +.prefixed-input { display: flex; align-items: center; - gap: 12px; - border: 2px solid var(--ink); - border-radius: 12px; - padding: 8px 14px; - background: #fff; - flex-wrap: wrap; + border: 1px solid #47495b; + background: #07080d; } - -.track-n { - font-family: Consolas, monospace; - color: var(--muted); +.prefixed-input:focus-within { + border-color: var(--cyan); } - -.track-title { - font-weight: 700; - flex: 1; - min-width: 120px; +.prefixed-input b { + padding-left: 11px; + color: var(--pink); } - -.track audio { - height: 32px; - max-width: 260px; +.prefixed-input input { + border: 0; } - -.track-missing { - font-size: 0.8rem; +.about-input { + resize: vertical; + min-height: 150px; +} +.prompt-chips { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 12px; +} +.prompt-chips span { + padding: 6px 9px; + border: 1px solid var(--line); color: var(--muted); - font-style: italic; + font: + 0.61rem "Courier New", + monospace; } - -.mood-grid { +.crafts { display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 12px; - align-items: start; + grid-template-columns: repeat(3, 1fr); + gap: 10px; } - -.mood { +.craft { position: relative; - border: 2px solid var(--ink); - border-radius: 12px; - overflow: hidden; - background: #fff; -} - -.mood img { - width: 100%; - display: block; -} - -.mood-color { - aspect-ratio: 1; + min-height: 142px; display: grid; - place-items: end start; - padding: 8px; -} - -.mood-color code { - background: rgba(255, 253, 246, 0.85); - border-radius: 6px; - padding: 1px 6px; - font-size: 0.75rem; -} - -.mood-note p, -.mood p { - padding: 12px; - font-size: 0.9rem; - line-height: 1.45; + align-content: end; + gap: 4px; + padding: 18px; + border: 1px solid #414353; + background: #0b0c12; + color: var(--ink); + text-align: left; + cursor: pointer; } - -.note-input { - flex: 1; - min-width: 160px; - padding: 7px 12px; +.craft:hover { + border-color: var(--cyan); } - -.color-btn { - position: relative; +.craft.selected { + background: linear-gradient(145deg, color-mix(in srgb, var(--accent), #161722 36%), #11121a); + border-color: var(--accent); + box-shadow: inset 0 0 24px color-mix(in srgb, var(--accent), transparent 80%); } - -.text-module p { - line-height: 1.6; - margin-bottom: 12px; - max-width: 64ch; +.craft-index { + position: absolute; + top: 12px; + left: 13px; + color: #626477; + font: + 700 0.6rem "Courier New", + monospace; } - -.text-module-input { - width: 100%; - resize: vertical; +.craft-emoji { + position: absolute; + top: 15px; + right: 15px; + font-size: 1.35rem; + filter: saturate(0.8); } - -.add-module { - border-style: dashed; +.craft-label { + font-weight: 900; + text-transform: uppercase; } - -.profile-footer { - text-align: center; +.craft-blurb { color: var(--muted); - font-size: 0.78rem; - letter-spacing: 0.06em; + font-size: 0.72rem; } - -/* ---------- gallery ---------- */ - -.gallery { - position: fixed; - inset: 0; +.sound-setup-panel { + display: grid; + grid-template-columns: 220px 1fr; + align-items: center; + gap: 35px; + padding: 30px; + border: 1px solid #424455; + background: + radial-gradient(circle at 13% 50%, rgba(255, 55, 201, 0.16), transparent 30%), #0a0b11; +} +.disc-art, +.soundtrack-disc { + position: relative; + aspect-ratio: 1; + border-radius: 50%; + background: repeating-radial-gradient(circle, #0a0b10 0 8px, #252636 9px 10px); + border: 1px solid #484a5c; + box-shadow: 0 0 35px rgba(255, 63, 209, 0.2); + display: grid; + place-items: center; } - -.gallery canvas { - display: block; +.disc-art { + width: 170px; } - -.gallery-ui { +.disc-art i, +.soundtrack-disc i { position: absolute; - inset: 0; - pointer-events: none; + width: 32%; + aspect-ratio: 1; + border-radius: 50%; + background: var(--pink); + box-shadow: 0 0 18px var(--pink); } - -.gallery-back { - position: absolute; - top: 18px; - left: 18px; - pointer-events: auto; +.disc-art b, +.soundtrack-disc span { + position: relative; + z-index: 2; + color: #08090e; } -.gallery-hint { - position: absolute; - top: 22px; - right: 22px; - font-size: 0.8rem; - color: var(--muted); - background: rgba(255, 253, 246, 0.8); - border: 2px solid var(--ink); - border-radius: 999px; - padding: 6px 14px; +/* Avatar lab */ +.builder { + display: grid; + grid-template-columns: 330px 1fr; + gap: 28px; + align-items: start; } - -.gallery-caption { +.builder-preview { + height: 420px; + position: relative; + overflow: hidden; + border: 1px solid #545668; + background: + radial-gradient(circle at 50% 34%, rgba(183, 73, 255, 0.22), transparent 35%), + linear-gradient(rgba(84, 255, 220, 0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(84, 255, 220, 0.06) 1px, transparent 1px), #080911; + background-size: + auto, + 26px 26px, + 26px 26px, + auto; +} +.builder-preview::after { + content: "POLYGON PREVIEW"; + position: absolute; + top: 10px; + left: 12px; + color: #727487; + font: + 700 0.56rem "Courier New", + monospace; + letter-spacing: 0.12em; +} +.builder-preview .surprise-btn { + position: absolute; + z-index: 2; + left: 50%; + bottom: 12px; + transform: translateX(-50%); +} +.builder-preview .surprise-btn:hover { + transform: translateX(-50%) translate(-1px, -1px); +} +.tabs { + display: flex; + gap: 5px; + flex-wrap: wrap; + margin-bottom: 10px; +} +.tab { + padding: 9px 13px; + border: 1px solid #444657; + background: #0b0c12; + color: var(--muted); + font: + 700 0.65rem "Courier New", + monospace; + text-transform: uppercase; + cursor: pointer; +} +.tab.selected { + color: #07080c; + background: var(--cyan); + border-color: var(--cyan); +} +.builder-status { + margin-bottom: 24px; + color: #5e6072; + font: + 0.58rem "Courier New", + monospace; +} +.builder-row { + margin-bottom: 20px; +} +.builder-label { + display: block; + margin-bottom: 9px; + color: #a6a7b8; + font: + 700 0.63rem "Courier New", + monospace; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.swatches, +.options { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.swatch { + width: 34px; + height: 34px; + border: 1px solid #77798b; + border-radius: 2px; + cursor: pointer; +} +.swatch.selected { + outline: 2px solid var(--cyan); + outline-offset: 3px; +} +.option { + padding: 8px 12px; + border: 1px solid #464859; + background: #0a0b11; + color: #c8c8d5; + font: + 0.7rem "Courier New", + monospace; + cursor: pointer; +} +.option.selected { + border-color: var(--accent); + color: white; + background: color-mix(in srgb, var(--accent), #0a0b11 65%); +} + +/* Profile */ +.profile-shell { + min-height: 100vh; +} +.profile-nav-actions { + display: flex; + align-items: center; + gap: 22px; +} +.nav-link { + border: 0; + background: none; + color: var(--muted); + font: + 700 0.62rem "Courier New", + monospace; + text-transform: uppercase; + cursor: pointer; +} +.nav-link:hover { + color: var(--pink); +} +.profile { + width: min(1040px, calc(100% - 34px)); + margin: 0 auto; + padding: 42px 0 70px; + display: grid; + gap: 22px; +} +.profile-header { + min-height: 320px; + display: grid; + grid-template-columns: 260px minmax(0, 1fr) auto; + gap: 28px; + align-items: start; + overflow: hidden; +} +.profile-header::after { + content: ""; + position: absolute; + width: 220px; + height: 220px; + right: -120px; + bottom: -120px; + border: 30px solid color-mix(in srgb, var(--accent), transparent 75%); + border-radius: 50%; +} +.profile-avatar { + height: 270px; + overflow: hidden; + border: 1px solid #4c4e61; + background: + radial-gradient( + circle at 50% 35%, + color-mix(in srgb, var(--accent), transparent 70%), + transparent 38% + ), + linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px), #090a11; + background-size: + auto, + 22px 22px, + 22px 22px, + auto; +} +.profile-avatar::after { + content: "AVATAR // ONLINE"; + position: absolute; + left: 34px; + bottom: 34px; + color: var(--cyan); + font: + 700 0.53rem "Courier New", + monospace; +} +.profile-kicker { + margin-top: 4px; +} +.profile-id h1 { + margin: 12px 0 5px; + font-size: clamp(3rem, 6vw, 5.7rem); + color: var(--ink); + text-shadow: 4px 4px 0 color-mix(in srgb, var(--accent), #000 58%); +} +.username { + color: var(--accent); + font: + 700 0.72rem "Courier New", + monospace; +} +.craft-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 18px 0; +} +.craft-tags span { + padding: 5px 8px; + border: 1px solid color-mix(in srgb, var(--accent), transparent 45%); + color: #c6c7d7; + font: + 700 0.58rem "Courier New", + monospace; + text-transform: uppercase; +} +.about { + max-width: 56ch; + color: #b8b9c8; + font-size: 0.88rem; + line-height: 1.65; + white-space: pre-wrap; +} +.profile-tools { + position: relative; + z-index: 2; + display: grid; + gap: 7px; +} +.profile-sticker { + position: absolute; + right: 20px; + bottom: 13px; + z-index: 2; + color: var(--acid); + font: + italic 900 0.62rem/1.05 Arial, + sans-serif; + transform: rotate(-8deg); + text-align: center; +} +.profile-soundtrack { + display: grid; + grid-template-columns: 78px 1fr minmax(230px, 0.75fr) auto; + gap: 18px; + align-items: center; + border-color: color-mix(in srgb, var(--accent), #333 50%); + overflow: hidden; +} +.profile-soundtrack::after { + content: "NOW PLAYING"; + position: absolute; + right: -18px; + top: 6px; + padding: 4px 28px; + color: #06070a; + background: var(--acid); + transform: rotate(32deg); + font: + 900 0.52rem "Courier New", + monospace; +} +.soundtrack-disc { + width: 72px; +} +.soundtrack-copy { + display: grid; + gap: 3px; + min-width: 0; +} +.soundtrack-copy span { + color: var(--pink); + font: + 700 0.57rem "Courier New", + monospace; +} +.soundtrack-copy strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.soundtrack-copy small, +.signal-missing small { + color: var(--muted); +} +.signal-missing { + color: #7c7e91; + font: + 700 0.6rem/1.45 "Courier New", + monospace; +} +.builder-card { + padding: 25px; +} +.module-stack-label { + display: flex; + justify-content: space-between; + color: var(--muted); + font: + 700 0.6rem "Courier New", + monospace; + letter-spacing: 0.1em; + border-bottom: 1px solid var(--line); + padding: 10px 2px; +} +.module-stack-label b { + color: var(--cyan); +} +.module { + padding: 25px 27px 27px; +} +.module::after { + content: ""; + position: absolute; + right: 0; + top: 0; + width: 5px; + height: 100%; + background: linear-gradient(var(--accent), transparent); + opacity: 0.65; +} +.module-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 14px; + margin-bottom: 20px; + padding-bottom: 14px; + border-bottom: 1px solid #313342; +} +.module-title-wrap { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; +} +.module-index { + color: var(--pink); + font: + 700 0.65rem "Courier New", + monospace; +} +.module-title { + font: + 900 1.45rem Arial, + sans-serif; + text-transform: uppercase; +} +.module-title-input { + max-width: 380px; + padding: 6px 8px; + font-weight: 800; +} +.module-kind { + margin-left: 4px; + padding: 4px 7px; + color: #737587; + border: 1px solid #353746; + font: + 700 0.52rem "Courier New", + monospace; +} +.module-tools, +.track-tools { + display: flex; + gap: 5px; +} +.module-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 9px; + margin-top: 17px; +} +.empty-hint { + color: #717386; + font: + italic 0.8rem "Courier New", + monospace; +} +.module-notice { + margin-top: 10px; + color: #ff83b9; + font-size: 0.73rem; +} +.module-notice.quiet { + color: var(--muted); + font-style: normal; + line-height: 1.4; +} +.storage-banner { + padding: 14px 17px; + color: #ffd1e3; + background: #351527; + border: 1px solid #a63c6c; + font-size: 0.78rem; +} +.photo-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 10px; +} +.photo { + position: relative; + aspect-ratio: 4/3; + overflow: hidden; + border: 1px solid #484a5b; + background: #08090e; +} +.photo img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; + filter: saturate(0.9) contrast(1.03); +} +.photo:hover img { + filter: saturate(1.1) contrast(1.06); +} +.photo-remove { + position: absolute; + top: 7px; + right: 7px; + width: 25px; + height: 25px; + border: 1px solid #77798b; + background: #0b0c12; + color: #fff; + cursor: pointer; +} +.tracks { + list-style: none; + display: grid; + gap: 7px; +} +.track { + min-height: 55px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + padding: 9px 11px; + border: 1px solid #383a49; + background: #090a10; +} +.track.favorite { + border-color: color-mix(in srgb, var(--accent), #333 55%); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--accent), transparent 89%), + #090a10 45% + ); +} +.track-n { + color: #66687a; + font: + 700 0.62rem "Courier New", + monospace; +} +.track-title { + display: grid; + gap: 2px; + flex: 1; + min-width: 150px; + font-size: 0.8rem; +} +.track-title small { + color: var(--pink); + font: + 700 0.5rem "Courier New", + monospace; + letter-spacing: 0.08em; +} +.track-missing { + color: #66687a; + font: + italic 0.66rem "Courier New", + monospace; +} +.favorite-button { + width: 29px; + height: 29px; + border: 1px solid #4b4d5d; + background: #13141c; + color: #56586a; + cursor: pointer; +} +.favorite-button.selected { + color: var(--acid); + border-color: var(--acid); + text-shadow: 0 0 9px var(--acid); +} +.track-linker { + display: grid; + grid-template-columns: 1fr 1.4fr auto; + gap: 8px; + margin-top: 15px; + padding: 15px; + border: 1px dashed #454757; + background: #090a10; +} +.track-linker-head { + grid-column: 1/-1; + display: flex; + justify-content: space-between; + margin-bottom: 2px; + color: var(--cyan); + font: + 700 0.57rem "Courier New", + monospace; +} +.track-linker-head small { + color: var(--muted); +} +.track-linker .module-notice { + grid-column: 1/-1; +} +.mood-grid { + columns: 4 150px; + column-gap: 10px; +} +.mood { + position: relative; + break-inside: avoid; + margin-bottom: 10px; + overflow: hidden; + border: 1px solid #444656; + background: #0a0b11; +} +.mood img { + width: 100%; + display: block; +} +.mood-color { + aspect-ratio: 1; + display: grid; + align-items: end; + padding: 8px; +} +.mood-color code { + width: max-content; + padding: 3px 5px; + background: rgba(7, 8, 13, 0.8); + color: white; + font-size: 0.6rem; +} +.mood p { + padding: 13px; + color: #b8b9c6; + font-size: 0.78rem; + line-height: 1.5; +} +.note-input { + flex: 1; + min-width: 190px; + padding: 8px 10px; +} +.text-module p { + max-width: 68ch; + margin-bottom: 12px; + color: #c2c2cf; + font-family: "Courier New", monospace; + font-size: 0.85rem; + line-height: 1.7; +} +.text-module-input { + resize: vertical; +} +.add-module { + display: grid; + grid-template-columns: 210px 1fr; + gap: 30px; + align-items: center; + border-style: dashed; +} +.add-module p { + color: var(--muted); + font-size: 0.74rem; +} +.add-module .module-actions { + margin: 0; +} +.add-module-button { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid #3f4150; + background: #0a0b11; + color: #b9bac8; + cursor: pointer; +} +.add-module-button:hover { + border-color: var(--cyan); +} +.add-module-button b { + padding: 4px; + color: #08090c; + background: var(--cyan); + font: + 0.55rem "Courier New", + monospace; +} +.add-module-button span { + font-size: 0.68rem; +} +.profile-footer { + display: flex; + justify-content: space-between; + padding: 22px 0; + color: #5f6172; + font: + 700 0.56rem "Courier New", + monospace; + letter-spacing: 0.08em; +} + +/* Gallery room */ +.gallery, +.rave { + position: fixed; + inset: 0; + background: #050509; +} +.gallery canvas, +.rave canvas { + display: block; +} +.gallery-ui, +.rave-ui { + position: absolute; + inset: 0; + pointer-events: none; +} +.gallery-ui .btn, +.rave-ui .btn, +.rave-playlist audio { + pointer-events: auto; +} +.gallery-back, +.rave-back { + position: absolute; + top: 18px; + left: 18px; +} +.gallery-hint { + position: absolute; + top: 20px; + right: 20px; + max-width: calc(100% - 180px); + padding: 8px 12px; + color: #d0d0da; + background: rgba(7, 8, 13, 0.82); + border: 1px solid #5c5e70; + font: + 700 0.6rem "Courier New", + monospace; +} +.gallery-caption { + position: absolute; + left: 50%; + bottom: 24px; + transform: translateX(-50%); + padding: 11px 18px; + color: #eee; + background: rgba(8, 9, 14, 0.9); + border: 1px solid #686a7b; + font-size: 0.85rem; +} + +/* Rave room */ +.rave-now { + position: absolute; + top: 18px; + left: 50%; + transform: translateX(-50%); + display: grid; + min-width: 280px; + gap: 2px; + padding: 10px 18px; + text-align: center; + background: rgba(6, 5, 13, 0.78); + border: 1px solid #593466; + backdrop-filter: blur(8px); +} +.rave-now span { + color: var(--cyan); + font: + 700 0.54rem "Courier New", + monospace; + letter-spacing: 0.12em; +} +.rave-now strong { + text-transform: uppercase; +} +.rave-now small { + color: #747589; + font: + 0.51rem "Courier New", + monospace; +} +.rave-playlist { + position: absolute; + right: 18px; + bottom: 18px; + width: min(390px, calc(100% - 36px)); + pointer-events: auto; + border: 1px solid #585a6d; + background: rgba(8, 8, 15, 0.88); + box-shadow: 8px 9px 0 rgba(0, 0, 0, 0.6); + backdrop-filter: blur(10px); +} +.rave-playlist-head { + display: flex; + justify-content: space-between; + padding: 9px 11px; + color: var(--pink); + border-bottom: 1px solid #414354; + font: + 700 0.56rem "Courier New", + monospace; +} +.rave-playlist-head b { + color: var(--cyan); +} +.rave-track { + display: grid; + grid-template-columns: 24px minmax(80px, 1fr) minmax(130px, 0.8fr); + align-items: center; + gap: 8px; + min-height: 43px; + padding: 6px 10px; + border-bottom: 1px solid #282a38; +} +.rave-track:last-child { + border-bottom: 0; +} +.rave-track > span { + color: #646679; + font: + 0.56rem "Courier New", + monospace; +} +.rave-track strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.7rem; +} +.rave-track.favorite strong { + color: var(--acid); +} +.rave-track audio { + width: 100%; + height: 28px; +} +.rave-track small, +.rave-playlist > p { + color: #66687b; + font: + 0.55rem "Courier New", + monospace; +} +.rave-playlist > p { + padding: 20px; + text-align: center; +} + +@media (max-width: 900px) { + .gateway-hero { + grid-template-columns: 1fr; + padding-top: 55px; + gap: 30px; + } + .portal-stack { + height: 450px; + } + .portal-window { + transform: none; + } + .sticker-a { + right: 2%; + } + .gateway-values { + grid-template-columns: 1fr; + } + .gateway-values article { + min-height: 170px; + border-right: 0; + border-bottom: 1px solid var(--line); + } + .gateway-values article:last-child { + border-bottom: 0; + } + .gateway-values h2 { + margin-top: 25px; + } + .profile-header { + grid-template-columns: 220px 1fr; + } + .profile-tools { + grid-column: 1/-1; + display: flex; + } + .profile-sticker { + right: 24px; + top: 25px; + bottom: auto; + } + .profile-soundtrack { + grid-template-columns: 70px 1fr; + } + .profile-soundtrack audio, + .profile-soundtrack > .btn { + grid-column: span 1; + } + .builder { + grid-template-columns: 280px 1fr; + } + .track-linker { + grid-template-columns: 1fr 1fr; + } + .track-linker .btn { + grid-column: 1/-1; + } + .add-module { + grid-template-columns: 1fr; + } +} + +@media (max-width: 680px) { + .site-nav, + .setup-nav { + width: calc(100% - 26px); + height: 62px; + } + .nav-status { + display: none; + } + .gateway-hero { + width: calc(100% - 28px); + min-height: auto; + padding: 50px 0; + } + .hero-copy h1 { + font-size: clamp(3.9rem, 20vw, 6rem); + } + .portal-stack { + height: 390px; + } + .portal-window { + width: 94%; + } + .portal-screen { + min-height: 330px; + padding: 22px 17px; + gap: 12px; + } + .pixel-person { + height: 240px; + transform: scale(0.84); + } + .orbit-one { + width: 360px; + } + .orbit-two { + height: 390px; + } + .sticker { + display: none; + } + .gateway-values { + width: calc(100% - 28px); + margin-block: 55px; + } + .onboarding-card { + width: calc(100% - 20px); + margin-top: 18px; + } + .step { + padding: 32px 20px 28px; + } + .step h1 { + font-size: 2.8rem; + } + .step h2 { + font-size: 2.5rem; + } + .setup-preview-row, + .form-grid, + .crafts { + grid-template-columns: 1fr; + } + .setup-preview-row span { + border-right: 0; + border-bottom: 1px solid var(--line); + } + .sound-setup-panel { + grid-template-columns: 1fr; + padding: 21px; + } + .disc-art { + width: 130px; + margin: auto; + } + .builder { + grid-template-columns: 1fr; + } + .builder-preview { + height: 390px; + } + .profile { + width: calc(100% - 22px); + padding-top: 22px; + } + .profile-nav-actions { + gap: 10px; + } + .profile-header { + grid-template-columns: 1fr; + padding: 17px; + } + .profile-avatar { + height: 310px; + } + .profile-tools { + display: grid; + } + .profile-id h1 { + font-size: 3.5rem; + } + .profile-sticker { + display: none; + } + .profile-soundtrack { + padding: 16px; + grid-template-columns: 60px 1fr; + } + .profile-soundtrack audio, + .profile-soundtrack > .btn { + grid-column: 1/-1; + width: 100%; + } + .module { + padding: 19px 16px; + } + .module-title-wrap { + flex-wrap: wrap; + } + .module-kind { + display: none; + } + .module-head { + align-items: flex-start; + } + .photo-grid { + grid-template-columns: repeat(2, 1fr); + } + .mood-grid { + columns: 2 120px; + } + .track audio { + width: 100%; + order: 5; + } + .track-linker { + grid-template-columns: 1fr; + } + .track-linker-head { + display: grid; + gap: 4px; + } + .track-linker .btn, + .track-linker .module-notice { + grid-column: auto; + } + .profile-footer { + display: grid; + gap: 7px; + } + .rave-now { + top: 65px; + } + .rave-playlist { + bottom: 12px; + right: 12px; + width: calc(100% - 24px); + } + .rave-track { + grid-template-columns: 22px 1fr; + } + .rave-track audio, + .rave-track small { + grid-column: 2; + } + .gallery-hint { + top: 65px; + right: 12px; + max-width: calc(100% - 24px); + } + .gallery-caption { + width: calc(100% - 24px); + text-align: center; + } +} + +/* Discovery launch */ +.orbit-launch-card { + display: grid; + grid-template-columns: 92px minmax(0, 1fr) auto; + align-items: center; + gap: 22px; + min-height: 142px; + overflow: hidden; + border-color: color-mix(in srgb, var(--cyan), #343545 55%); + background: + radial-gradient(circle at 8% 50%, rgba(84, 255, 220, 0.15), transparent 21%), + radial-gradient(circle at 82% 10%, rgba(255, 77, 216, 0.12), transparent 30%), + linear-gradient(145deg, #161525, #090a11); +} +.orbit-launch-card::after { + content: ""; + position: absolute; + width: 290px; + height: 95px; + right: 8%; + border: 1px solid rgba(84, 255, 220, 0.18); + border-radius: 50%; + transform: rotate(-12deg); +} +.launch-orbit-visual { + position: relative; + width: 74px; + height: 74px; + display: grid; + place-items: center; + border-radius: 50%; + background: linear-gradient(145deg, #7345dc, #261343); + box-shadow: 0 0 26px rgba(142, 78, 255, 0.36); +} +.launch-orbit-visual::before { + content: ""; + position: absolute; + width: 104px; + height: 30px; + border: 2px solid var(--cyan); + border-radius: 50%; + transform: rotate(-19deg); +} +.launch-orbit-visual i { + position: absolute; + width: 13px; + height: 13px; + right: 0; + top: 12px; + border-radius: 50%; + background: var(--pink); + box-shadow: 0 0 12px var(--pink); +} +.launch-orbit-visual span { + color: var(--acid); + font-size: 1.2rem; +} +.launch-orbit-visual b { + position: absolute; + bottom: -5px; + color: var(--cyan); +} +.launch-orbit-copy { + position: relative; + z-index: 2; +} +.launch-orbit-copy h2 { + margin: 7px 0 5px; + font-size: 1.35rem; + text-transform: uppercase; +} +.launch-orbit-copy p { + max-width: 58ch; + color: var(--muted); + font-size: 0.77rem; + line-height: 1.5; +} +.launch-orbit-button { + position: relative; + z-index: 2; + min-height: 50px; + white-space: nowrap; +} +.launch-orbit-hint { + position: absolute; + right: 27px; + bottom: 8px; + color: #5d6073; + font: + 700 0.5rem "Courier New", + monospace; + text-transform: uppercase; +} + +/* Continuous cockpit discovery */ +.discovery { + position: fixed; + inset: 0; + overflow: hidden; + background: #030308; +} +.discovery canvas { + display: block; +} +.discovery-ui { + position: absolute; + inset: 0; + pointer-events: none; +} +.flight-status { + position: absolute; + top: 16px; + left: 18px; + right: 18px; + min-height: 44px; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 18px; + padding: 8px 13px; + color: #c7c8d8; + border: 1px solid rgba(103, 106, 128, 0.62); + background: rgba(7, 7, 14, 0.72); + backdrop-filter: blur(8px); + font-family: "Courier New", monospace; + pointer-events: auto; +} +.flight-home { + justify-self: start; + border: 0; + background: none; + color: white; + font: + italic 900 1.05rem Arial, + sans-serif; + letter-spacing: -0.08em; + cursor: pointer; +} +.flight-home span { + color: var(--pink); +} +.flight-status > div { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.62rem; + letter-spacing: 0.1em; +} +.flight-status > small { + justify-self: end; + color: #696b7e; + font-size: 0.52rem; +} +.flight-pulse { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--cyan); + box-shadow: 0 0 11px var(--cyan); + animation: flight-pulse 1.1s steps(2) infinite; +} +@keyframes flight-pulse { + 50% { + opacity: 0.35; + } +} +.discovery-filters, +.discovery-preview { + position: absolute; + z-index: 3; + width: min(330px, calc(100% - 36px)); + border: 1px solid rgba(102, 104, 126, 0.76); + background: rgba(7, 8, 15, 0.86); + box-shadow: 8px 9px 0 rgba(0, 0, 0, 0.46); + backdrop-filter: blur(12px); + pointer-events: auto; + transition: + transform 520ms cubic-bezier(0.2, 0.8, 0.2, 1), + opacity 300ms ease; +} +.discovery-filters { + left: 18px; + bottom: 92px; +} +.discovery-preview { + right: 18px; + top: 84px; + padding-bottom: 14px; +} +.in-flight .discovery-filters { + transform: translateX(-120%); + opacity: 0; +} +.in-flight .discovery-preview { + transform: translateX(120%); + opacity: 0; +} +.cockpit-panel-head { + min-height: 31px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 10px; + color: var(--pink); + border-bottom: 1px solid #373947; + font: + 700 0.55rem "Courier New", + monospace; + letter-spacing: 0.09em; +} +.cockpit-panel-head b { + color: var(--cyan); +} +.discovery-search { + display: flex; + align-items: center; + gap: 7px; + margin: 10px; + padding-left: 10px; + border: 1px solid #3c3e4e; + background: #06070c; +} +.discovery-search span { + color: var(--cyan); +} +.discovery-search input { + border: 0; + padding: 8px 7px; + font-size: 0.65rem; + box-shadow: none; +} +.filter-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + padding: 0 10px 10px; +} +.filter-grid button { + min-height: 39px; + display: grid; + grid-template-columns: 24px 1fr; + align-items: center; + gap: 5px; + padding: 5px 7px; + border: 1px solid #30323f; + background: #0a0b12; + color: #a7a8ba; + text-align: left; + cursor: pointer; +} +.filter-grid button:hover { + border-color: var(--cyan); +} +.filter-grid button.selected { + color: white; + border-color: var(--accent); + background: color-mix(in srgb, var(--accent), #090a11 82%); +} +.filter-grid button b { + display: grid; + place-items: center; + width: 22px; + height: 22px; + color: #07080c; + background: #6d7084; + font-size: 0.7rem; +} +.filter-grid button.selected b { + background: var(--accent); +} +.filter-grid button span { + font: + 700 0.57rem "Courier New", + monospace; + text-transform: uppercase; +} +.preview-person { + display: grid; + grid-template-columns: 98px 1fr; + align-items: center; + gap: 12px; + margin: 10px; + border: 1px solid #343644; + background: radial-gradient(circle at 16% 50%, rgba(142, 79, 255, 0.2), transparent 40%), #090a11; +} +.preview-avatar { + height: 112px; + border-right: 1px solid #343644; +} +.preview-person small { + color: var(--cyan); + font: + 700 0.49rem "Courier New", + monospace; +} +.preview-person h1 { + margin: 4px 0 1px; + font: + 900 1.45rem/0.95 Impact, + "Arial Black", + sans-serif; + text-transform: uppercase; +} +.preview-person p { + color: var(--accent); + font: + 700 0.6rem "Courier New", + monospace; +} +.preview-about { + margin: 12px 14px; + color: #b3b4c5; + font-size: 0.72rem; + line-height: 1.48; +} +.preview-vibe { + display: grid; + gap: 3px; + margin: 0 14px 10px; + padding: 9px 10px; + border-left: 2px solid var(--pink); + background: rgba(255, 77, 216, 0.055); +} +.preview-vibe span { + color: #676a7d; + font: + 700 0.48rem "Courier New", + monospace; +} +.preview-vibe strong { + color: #e4e3ee; + font-size: 0.7rem; + text-transform: uppercase; +} +.preview-crafts { + display: flex; + flex-wrap: wrap; + gap: 5px; +} +.discovery-preview .preview-crafts { + margin: 0 14px 10px; +} +.preview-crafts span { + padding: 4px 6px; + color: #a9aabb; + border: 1px solid #3b3d4c; + font: + 700 0.49rem "Courier New", + monospace; + text-transform: uppercase; +} +.preview-signal, +.visitor-signal { + color: #77798d; + font: + 700 0.53rem "Courier New", + monospace; +} +.preview-signal { + margin: 0 14px 12px; +} +.preview-actions { + display: grid; + grid-template-columns: 35px 1fr 35px; + gap: 6px; + margin: 0 12px; +} +.planet-step { + border: 1px solid #4b4d5e; + background: #0a0b12; + color: var(--cyan); + cursor: pointer; +} +.visit-world { + width: 100%; +} +.empty-signal { + padding: 45px 24px; + text-align: center; +} +.empty-signal strong { + color: var(--pink); +} +.empty-signal p { + margin-top: 8px; + color: var(--muted); + font-size: 0.72rem; +} +.planet-dock { position: absolute; - bottom: 26px; left: 50%; + bottom: 15px; + z-index: 4; + width: min(700px, calc(100% - 380px)); + display: flex; + gap: 3px; + padding: 6px; + overflow-x: auto; + border: 1px solid rgba(82, 84, 104, 0.75); + background: rgba(6, 7, 13, 0.83); transform: translateX(-50%); - background: rgba(255, 253, 246, 0.92); - border: 2px solid var(--ink); - border-radius: 12px; - box-shadow: var(--shadow); - padding: 10px 22px; - font-family: Georgia, serif; - font-size: 1.05rem; + pointer-events: auto; + transition: transform 550ms ease; +} +.in-flight .planet-dock { + transform: translate(-50%, 130%); +} +.planet-dock button { + min-width: 96px; + display: grid; + grid-template-columns: 7px 18px 1fr; + align-items: center; + gap: 5px; + padding: 7px; + border: 1px solid transparent; + background: #0b0c13; + color: #75778a; + cursor: pointer; +} +.planet-dock button.selected { + color: white; + border-color: var(--cyan); + background: #141621; +} +.planet-dock i { + width: 6px; + height: 6px; + border-radius: 50%; + box-shadow: 0 0 8px currentColor; +} +.planet-dock span { + font: + 700 0.5rem "Courier New", + monospace; +} +.planet-dock b { + overflow: hidden; + text-overflow: ellipsis; + font: + 700 0.52rem "Courier New", + monospace; +} + +/* Read-only creator worlds */ +.visitor-shell { + min-height: 100vh; + background: + radial-gradient( + circle at 80% 4%, + color-mix(in srgb, var(--accent), transparent 82%), + transparent 30rem + ), + var(--void); +} +.visitor-nav { + width: min(1120px, calc(100% - 34px)); + height: 66px; + margin: 0 auto; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + border-bottom: 1px solid var(--line); +} +.visitor-nav button, +.visitor-footer button { + border: 0; + background: none; + color: #aaaabd; + font: + 700 0.62rem "Courier New", + monospace; + text-transform: uppercase; + cursor: pointer; +} +.visitor-nav button:hover, +.visitor-footer button:hover { + color: var(--cyan); +} +.visitor-location { + display: flex; + align-items: center; + gap: 7px; + color: var(--cyan); + font: + 700 0.6rem "Courier New", + monospace; + letter-spacing: 0.08em; +} +.visitor-location span { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--cyan); + box-shadow: 0 0 10px var(--cyan); +} +.visitor-home { + justify-self: end; +} +.visitor-profile { + width: min(980px, calc(100% - 34px)); + margin: 0 auto; + padding: 34px 0 70px; + display: grid; + gap: 20px; +} +.visitor-hero { + min-height: 330px; + display: grid; + grid-template-columns: 235px 1fr auto; + gap: 27px; + align-items: center; + overflow: hidden; +} +.visitor-planet-mark { + position: absolute; + right: -95px; + top: -95px; + width: 240px; + height: 240px; + border-radius: 50%; + background: radial-gradient( + circle at 34% 27%, + var(--planet-accent), + var(--planet) 48%, + #100b1d 100% + ); + box-shadow: 0 0 60px color-mix(in srgb, var(--planet-accent), transparent 70%); +} +.visitor-planet-mark::after { + content: ""; + position: absolute; + width: 310px; + height: 82px; + left: -38px; + top: 83px; + border: 9px solid color-mix(in srgb, var(--planet-accent), white 10%); + border-radius: 50%; + transform: rotate(-17deg); +} +.visitor-avatar { + position: relative; + z-index: 2; + height: 275px; + border: 1px solid #494b5d; + background: + radial-gradient(circle, color-mix(in srgb, var(--accent), transparent 72%), transparent 45%), + #090a11; +} +.visitor-identity { + position: relative; + z-index: 2; +} +.visitor-identity h1 { + margin: 10px 0 3px; + font-size: clamp(3.3rem, 6vw, 5.4rem); +} +.visitor-identity > p { + max-width: 50ch; + margin: 20px 0 15px; + color: #babac8; + font-size: 0.87rem; + line-height: 1.62; +} +.visitor-identity .preview-crafts { + margin-bottom: 12px; +} +.visitor-actions { + position: relative; + z-index: 3; + align-self: end; + display: grid; + gap: 8px; + text-align: center; +} +.visitor-actions small { + color: #626477; + font: + 0.5rem "Courier New", + monospace; +} +.visitor-soundbar { + display: grid; + grid-template-columns: 62px 1fr auto auto; + align-items: center; + gap: 15px; + border-color: color-mix(in srgb, var(--accent), #343545 55%); +} +.visitor-soundbar .soundtrack-disc { + width: 58px; +} +.visitor-soundbar > div:nth-child(2) { + display: grid; + gap: 3px; +} +.visitor-soundbar small { + color: var(--pink); + font: + 700 0.52rem "Courier New", + monospace; +} +.visitor-module-count { + display: flex; + justify-content: space-between; + padding: 11px 2px; + color: #77798b; + border-bottom: 1px solid var(--line); + font: + 700 0.58rem "Courier New", + monospace; + letter-spacing: 0.1em; +} +.visitor-module-count b { + color: var(--cyan); +} +.visitor-module { + padding: 25px 27px 27px; +} +.visitor-footer { + display: flex; + justify-content: space-between; + padding-top: 20px; + color: #5c5e70; + font: + 0.55rem "Courier New", + monospace; +} + +@media (max-width: 900px) { + .orbit-launch-card { + grid-template-columns: 80px 1fr; + } + .launch-orbit-button { + grid-column: 1 / -1; + width: 100%; + } + .launch-orbit-hint { + display: none; + } + .discovery-filters { + width: 280px; + } + .discovery-preview { + width: 295px; + } + .planet-dock { + width: calc(100% - 340px); + } + .visitor-hero { + grid-template-columns: 210px 1fr; + } + .visitor-actions { + grid-column: 1 / -1; + grid-template-columns: auto 1fr; + align-items: center; + text-align: left; + } +} + +@media (max-width: 680px) { + .orbit-launch-card { + grid-template-columns: 60px 1fr; + gap: 15px; + padding: 18px 16px; + } + .launch-orbit-visual { + width: 54px; + height: 54px; + } + .launch-orbit-visual::before { + width: 72px; + height: 23px; + } + .launch-orbit-copy h2 { + font-size: 1rem; + } + .launch-orbit-copy p { + font-size: 0.67rem; + } + .flight-status { + top: 7px; + left: 7px; + right: 7px; + grid-template-columns: auto 1fr; + min-height: 41px; + } + .flight-status > div { + justify-self: end; + font-size: 0.52rem; + } + .flight-status > small { + display: none; + } + .discovery-filters { + top: 55px; + left: 7px; + bottom: auto; + width: calc(100% - 14px); + } + .cockpit-panel-head { + min-height: 26px; + } + .discovery-search { + width: calc(45% - 14px); + float: left; + margin: 7px; + } + .filter-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 3px; + padding: 7px; + } + .filter-grid button { + min-height: 31px; + grid-template-columns: 18px 1fr; + padding: 3px; + } + .filter-grid button b { + width: 17px; + height: 17px; + font-size: 0.55rem; + } + .filter-grid button span { + font-size: 0.45rem; + } + .discovery-preview { + top: auto; + left: 7px; + right: 7px; + bottom: 66px; + width: auto; + padding-bottom: 8px; + } + .preview-person { + grid-template-columns: 1fr; + min-height: 65px; + padding: 10px; + } + .preview-avatar, + .preview-person small { + display: none; + } + .preview-person h1 { + font-size: 1.25rem; + } + .preview-about { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + margin-block: 8px; + } + .preview-vibe { + margin-bottom: 6px; + padding-block: 6px; + } + .discovery-preview .preview-crafts, + .preview-signal { + display: none; + } + .preview-actions { + margin-inline: 8px; + } + .planet-dock { + left: 7px; + right: 7px; + bottom: 7px; + width: auto; + transform: none; + } + .in-flight .planet-dock { + transform: translateY(140%); + } + .planet-dock button { + min-width: 86px; + } + .visitor-nav { + width: calc(100% - 22px); + grid-template-columns: 1fr auto; + } + .visitor-location { + display: none; + } + .visitor-profile { + width: calc(100% - 22px); + padding-top: 20px; + } + .visitor-hero { + grid-template-columns: 1fr; + padding: 16px; + } + .visitor-avatar { + height: 300px; + } + .visitor-identity h1 { + font-size: 3.4rem; + } + .visitor-actions { + grid-template-columns: 1fr; + } + .visitor-soundbar { + grid-template-columns: 55px 1fr; + padding: 14px; + } + .visitor-soundbar > .track-missing, + .visitor-soundbar > .btn { + grid-column: 1 / -1; + width: 100%; + } + .visitor-module { + padding: 19px 16px; + } + .visitor-footer { + display: grid; + gap: 9px; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + scroll-behavior: auto !important; + transition: none !important; + } } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index cd4e314..8852748 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,7 +1,7 @@ -import { StrictMode } from "react"; +import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { createRng } from "@atrium/core"; -import { randomAvatar } from "@atrium/avatar"; +import { createRng } from "@ourspace/core"; +import { randomAvatar } from "@ourspace/avatar"; import { App } from "./App"; import { assembleProfile } from "./onboarding/Onboarding"; import { disableStorageWrites, useApp } from "./store"; @@ -21,8 +21,14 @@ if (params.has("demo")) { "Sam Ellery", "samellery", "I paint small things very large, and sometimes I record the sounds the paintings make.", - randomAvatar(createRng("atrium:avatar:v1:samellery")), + { + ...randomAvatar(createRng("ourspace:avatar:v1:samellery")), + hairStyle: "spiky", + accessory: "headphones", + top: "#8a6fc0", + }, ["visual", "music"], + { title: "Neon Receiver — demo signal" }, ), ); } diff --git a/apps/web/src/onboarding/AvatarBuilder.tsx b/apps/web/src/onboarding/AvatarBuilder.tsx index 840a0c8..95af55d 100644 --- a/apps/web/src/onboarding/AvatarBuilder.tsx +++ b/apps/web/src/onboarding/AvatarBuilder.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { createRng, type AvatarDoc } from "@atrium/core"; +import { useState } from "react"; +import { createRng, type AvatarDoc } from "@ourspace/core"; import { ACCESSORIES, EYE_COLORS, @@ -8,7 +8,7 @@ import { OUTFIT_COLORS, randomAvatar, SKIN_TONES, -} from "@atrium/avatar"; +} from "@ourspace/avatar"; import { AvatarStage } from "../components/AvatarStage"; type Tab = "skin" | "hair" | "outfit" | "extras"; @@ -95,10 +95,10 @@ export function AvatarBuilder({ value, onChange }: AvatarBuilderProps) {
@@ -115,6 +115,8 @@ export function AvatarBuilder({ value, onChange }: AvatarBuilderProps) { ))}
+

AVATAR_DOC // LIVE PREVIEW // DRAG-FREE TURNTABLE

+ {tab === "skin" && ( <> module.kind === "music" && module.title === "Playlist", + ) ?? modules.find((module): module is MusicModule => module.kind === "music"); + if (playlist) { + const id = makeId(); + playlist.tracks.push({ + id, + title: favoriteSong.title.trim(), + ...(favoriteSong.src?.trim() ? { src: favoriteSong.src.trim() } : {}), + }); + playlist.favoriteTrackId = id; + } + } + return { version: 1, name, username, about, avatar, crafts, modules }; } -export function Onboarding() { +export function Onboarding({ + onCancel, + onComplete, +}: { + onCancel?: () => void; + onComplete?: () => void; +}) { const createProfile = useApp((s) => s.createProfile); const [step, setStep] = useState("welcome"); const [name, setName] = useState(""); const [username, setUsername] = useState(""); const [avatar, setAvatar] = useState(null); const [crafts, setCrafts] = useState([]); + const [favoriteSongTitle, setFavoriteSongTitle] = useState(""); + const [favoriteSongUrl, setFavoriteSongUrl] = useState(""); const [about, setAbout] = useState(""); const stepIndex = STEPS.indexOf(step); const next = () => { if (step === "identity" && avatar === null) { - // First visit to the builder: the username deterministically seeds a - // starting look — same name, same little person, anywhere. - setAvatar(randomAvatar(createRng(`atrium:avatar:v1:${username || name}`))); + // Same name, same starting person: the seed stays deterministic. + setAvatar(randomAvatar(createRng(`ourspace:avatar:v1:${username || name}`))); } const upcoming = STEPS[stepIndex + 1]; if (upcoming) setStep(upcoming); @@ -68,31 +105,71 @@ export function Onboarding() { const finish = () => { if (!avatar) return; - createProfile(assembleProfile(name.trim(), username.trim(), about.trim(), avatar, crafts)); + createProfile( + assembleProfile(name.trim(), username.trim(), about.trim(), avatar, crafts, { + title: favoriteSongTitle, + src: favoriteSongUrl, + }), + ); + onComplete?.(); }; const toggleCraft = (id: string) => - setCrafts((c) => (c.includes(id) ? c.filter((x) => x !== id) : [...c, id])); + setCrafts((current) => + current.includes(id) ? current.filter((craft) => craft !== id) : [...current, id], + ); return ( -
+
+
-
- {STEPS.map((s, i) => ( - +
+ {STEPS.map((item, index) => ( + ))} + {Math.round(((stepIndex + 1) / STEPS.length) * 100)}%
{step === "welcome" && ( -
-

Make yourself at home.

+
+
NEW_USER_PROTOCOL
+

+ Build your corner +
+ of the internet. +

- This is your atrium — a page that is actually yours. Build a little you, tell us what - you make, and we'll set up the rooms. You can rearrange all of it later. + Make a low-poly version of yourself, tell us what you create, and we’ll assemble your + first profile. Every part can be remixed later.

+
+ + 01 identity + + + 02 avatar + + + 03 your work + + + 04 soundtrack + +
+ {onCancel && ( + + )}
@@ -100,113 +177,168 @@ export function Onboarding() { {step === "identity" && (
-

Who's moving in?

- - -
- - +
01 // NAME YOUR SPACE
+

Who’s moving in?

+

This is how visitors will find and remember you.

+
+ +
+
)} {step === "avatar" && avatar && (
-

Build your little you.

-

This is who walks the galleries.

+
02 // CHOOSE YOUR FORM
+

Build your low-poly you.

+

Sharper silhouettes, visible polygons, main-character energy.

-
- - -
+
)} {step === "crafts" && (
+
03 // SELECT YOUR PRACTICE

What do you make?

- Pick everything that applies — your page starts with the rooms to match. + Pick everything that applies. Archetypes are starting points, never cages.

- {CRAFTS.map((c) => ( + {CRAFTS.map((craft, index) => ( ))}
-
- - + +
+ )} + + {step === "sound" && ( +
+
04 // SET YOUR PROFILE SIGNAL
+

What’s playing in your space?

+

+ Optional, but encouraged. Pin a favorite song now or upload tracks from your page + later. +

+
+ +
+ + +

+ This becomes your profile song and the first track in your rave room. Browser + autoplay rules may ask visitors to press play. +

+
+
)} {step === "about" && (
-

Say hi.

-

A few lines about you, for the top of your page.

+
05 // TRANSMIT SOMETHING HUMAN
+

Leave a note at the door.

+

+ Who are you, what are you making, and what do you hope people notice? +