478 lines
20 KiB
Markdown
478 lines
20 KiB
Markdown
# Portfolio Phase 2: Journey, Scene, and Audio Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Add the persistent chase-view night-drive experience, scroll-reactive route state, section signs, optional audio, and lifecycle-safe controls without changing the semantic reading path.
|
|
|
|
**Architecture:** Pure controllers transform native scroll and section geometry into serializable journey snapshots. Vue coordinates state and accessible controls; ordinary Three.js and Web Audio modules consume narrow interfaces and independently own their resources. Reduced motion, Static mode, or initialization failure leaves the Phase 1 document intact.
|
|
|
|
**Tech Stack:** Vue 3 Composition API, TypeScript, Three.js, Web Audio API, Vitest, Playwright
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-09-01-portfolio-journey-design.md`
|
|
|
|
## Global Constraints
|
|
|
|
- Native scrolling, native anchor `href`s, and browser history remain authoritative.
|
|
- The scene never reads portfolio copy or owns semantic section state.
|
|
- One canvas, one current scene generation, one RAF loop, and one optional audio graph.
|
|
- Three.js loads dynamically only when motion policy permits.
|
|
- The car, road, terrain, sun, signs, particles, and audio are original/procedural.
|
|
- Section signs appear only for eligible forward passive transitions and are `aria-hidden`.
|
|
- AudioContext creation occurs only inside a direct visitor activation path.
|
|
- Every lifecycle edge is a discriminated transition covered by unit tests.
|
|
|
|
---
|
|
|
|
### Task 1: Implement the normative scroll-drive controller
|
|
|
|
**Files:**
|
|
- Create: `src/journey/types.ts`
|
|
- Create: `src/journey/scroll-drive-controller.ts`
|
|
- Create: `src/composables/useScrollDrive.ts`
|
|
- Test: `tests/unit/scroll-drive-controller.test.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `{ scrollY: number; now: number; maxScrollY: number }`.
|
|
- Produces: `ScrollDriveSnapshot`, `createScrollDriveController()`, and `useScrollDrive()`.
|
|
|
|
- [ ] **Step 1: Write failing formula tests**
|
|
|
|
```ts
|
|
it('falls back to cruise for an invalid sample', () => {
|
|
const controller = createScrollDriveController();
|
|
expect(controller.sample({ scrollY: NaN, now: 10, maxScrollY: 1000 }))
|
|
.toMatchObject({ driveSpeed: 0.18, acceleration: 0 });
|
|
});
|
|
|
|
it('resets transient velocity after a gap over 250 ms', () => {
|
|
const controller = createScrollDriveController();
|
|
controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 });
|
|
controller.sample({ scrollY: 500, now: 100, maxScrollY: 1000 });
|
|
expect(controller.sample({ scrollY: 500, now: 351, maxScrollY: 1000 }).acceleration).toBe(0);
|
|
});
|
|
```
|
|
|
|
Add literal cases for `dt=0`, negative `dt`, Infinity, maximum up/down deltas, zero range, and fixed formula values.
|
|
|
|
- [ ] **Step 2: Run the test to verify failure**
|
|
|
|
Run `npm run test:unit -- tests/unit/scroll-drive-controller.test.ts`. Expected: FAIL because the controller is absent.
|
|
|
|
- [ ] **Step 3: Implement the pure controller**
|
|
|
|
```ts
|
|
export interface ScrollDriveSnapshot {
|
|
scrollY: number;
|
|
routeProgress: number;
|
|
direction: -1 | 0 | 1;
|
|
acceleration: number;
|
|
driveSpeed: number;
|
|
}
|
|
|
|
export interface ScrollDriveController {
|
|
sample(input: ScrollDriveSample): ScrollDriveSnapshot;
|
|
reset(input?: ScrollDriveSample): ScrollDriveSnapshot;
|
|
}
|
|
```
|
|
|
|
Use constants `2.5`, `90 ms`, `420 ms`, base `0.18`, and gap `>250 ms` exactly. Direction uses signed document delta; acceleration uses its absolute value.
|
|
|
|
- [ ] **Step 4: Add passive RAF-scheduled Vue integration**
|
|
|
|
`useScrollDrive()` attaches passive scroll/resize/visual-viewport/visibility listeners on mount, queues at most one RAF, resets after hidden gaps, and removes every listener/RAF on teardown.
|
|
|
|
- [ ] **Step 5: Verify and commit**
|
|
|
|
```bash
|
|
npm run typecheck
|
|
npm run test:unit -- tests/unit/scroll-drive-controller.test.ts
|
|
git add src/journey/types.ts src/journey/scroll-drive-controller.ts src/composables/useScrollDrive.ts tests/unit/scroll-drive-controller.test.ts
|
|
git commit -m "feat: add deterministic scroll drive controller"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Implement active sections, sign latching, and navigation epochs
|
|
|
|
**Files:**
|
|
- Create: `src/journey/section-controller.ts`
|
|
- Create: `src/journey/sign-controller.ts`
|
|
- Create: `src/journey/navigation-controller.ts`
|
|
- Create: `src/composables/useJourneySections.ts`
|
|
- Create: `src/composables/useKeyboardNavigation.ts`
|
|
- Modify: `src/components/SiteNavigation.vue`
|
|
- Test: `tests/unit/section-controller.test.ts`
|
|
- Test: `tests/unit/sign-controller.test.ts`
|
|
- Test: `tests/unit/navigation-controller.test.ts`
|
|
- Test: `tests/browser/navigation.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: canonical section geometry, visual viewport height, scroll source/delta, and native link activations.
|
|
- Produces: `JourneySnapshot`, monotonic navigation epoch, one optional `SignSnapshot`, and exact URL/focus effects.
|
|
|
|
- [ ] **Step 1: Write failing section/sign table tests**
|
|
|
|
Test first/last boundaries, equal headings, the 35% activation line, document-end forcing, late growth, forward transitions, reverse suppression, multi-section jumps, and exact sign phase boundaries at `1.2vh`, `0.55vh`, `0.15vh`, and `-0.25vh`.
|
|
|
|
```ts
|
|
expect(selectActiveSection({ headings, activationY: 350, atDocumentEnd: false })).toBe('experience');
|
|
expect(nextSign(signState, { source: 'passive-scroll', deltaY: -2, distanceVh: 0.5 })).toBeNull();
|
|
```
|
|
|
|
- [ ] **Step 2: Implement pure section and sign controllers**
|
|
|
|
```ts
|
|
export interface SignSnapshot {
|
|
targetId: SectionId;
|
|
phase: 'approach' | 'stabilized' | 'pass';
|
|
progress: number;
|
|
}
|
|
|
|
export interface JourneySnapshot extends ScrollDriveSnapshot {
|
|
activeSectionId: SectionId;
|
|
chapter: EnvironmentChapter;
|
|
sign: SignSnapshot | null;
|
|
arrived: boolean;
|
|
}
|
|
|
|
export type EnvironmentChapter =
|
|
| 'deep-night'
|
|
| 'systems'
|
|
| 'infrastructure'
|
|
| 'builds'
|
|
| 'pre-dawn'
|
|
| 'destination';
|
|
```
|
|
|
|
Only one latch may exist. Semantic active section and decorative sign target remain separate.
|
|
|
|
- [ ] **Step 3: Write failing navigation tests**
|
|
|
|
Cover different/current fragments, invalid fragments, exactly one/zero history mutation, wheel cancellation, passive `replaceState`, `popstate`, initial fragments, native `scrollend`, three-frame fallback, 1500 ms alignment, and focus only for a still-current explicit epoch.
|
|
|
|
- [ ] **Step 4: Implement navigation ownership**
|
|
|
|
```ts
|
|
export interface NavigationController {
|
|
navigate(id: SectionId, target: HTMLElement): number;
|
|
noteUserScrollIntent(): number;
|
|
restore(id: SectionId, target: HTMLElement): number;
|
|
dispose(): void;
|
|
}
|
|
```
|
|
|
|
Keep native `href`s. Prevent default only after target validation. Wheel, touchmove, Page Up/Down, Home/End, Space, and arrow-key scroll intent cancel older settle/focus work.
|
|
|
|
- [ ] **Step 5: Add measured Vue integration and browser coverage**
|
|
|
|
Measure headings after fonts, ResizeObserver, media dimensions, orientation, and visual-viewport changes. Browser tests assert URLs, focus, back/forward, direct fragments, and no focus theft from manual scrolling.
|
|
|
|
- [ ] **Step 6: Verify and commit**
|
|
|
|
```bash
|
|
npm run test:unit -- tests/unit/section-controller.test.ts tests/unit/sign-controller.test.ts tests/unit/navigation-controller.test.ts
|
|
npm run test:browser -- tests/browser/navigation.spec.ts
|
|
git add src/journey src/composables/useJourneySections.ts src/composables/useKeyboardNavigation.ts src/components/SiteNavigation.vue tests/unit tests/browser/navigation.spec.ts
|
|
git commit -m "feat: coordinate sections signs and native navigation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Implement motion policy and adaptive quality
|
|
|
|
**Files:**
|
|
- Create: `src/journey/motion-policy.ts`
|
|
- Create: `src/journey/adaptive-quality.ts`
|
|
- Create: `src/composables/useMotionPreferences.ts`
|
|
- Create: `src/composables/useAdaptiveQuality.ts`
|
|
- Test: `tests/unit/motion-policy.test.ts`
|
|
- Test: `tests/unit/adaptive-quality.test.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: system preference, saved pause/quality, viewport/pointer class, visibility, scene state, and eligible frame intervals.
|
|
- Produces: `MotionPolicy`, maximum/actual `QualityTier`, and downgrade decisions.
|
|
|
|
- [ ] **Step 1: Write failing precedence and initial-tier tests**
|
|
|
|
```ts
|
|
expect(resolveMotionPolicy({ systemReduce: true, savedPause: false, savedStatic: false })).toMatchObject({
|
|
allowSceneRequest: false,
|
|
maximumTier: 'static'
|
|
});
|
|
expect(initialMaximum({ width: 390, coarsePointer: true })).toBe('low');
|
|
expect(initialMaximum({ width: 1440, coarsePointer: false })).toBe('high');
|
|
```
|
|
|
|
- [ ] **Step 2: Implement the motion policy**
|
|
|
|
```ts
|
|
export type QualityTier = 'high' | 'medium' | 'low' | 'static';
|
|
export interface MotionPolicy {
|
|
allowSceneRequest: boolean;
|
|
allowAutomaticVideo: boolean;
|
|
maximumTier: QualityTier;
|
|
reason: 'full' | 'system-reduce' | 'visitor-pause' | 'saved-static' | 'data-saver';
|
|
}
|
|
```
|
|
|
|
Persist only pause, maximum quality, and volume.
|
|
|
|
- [ ] **Step 3: Write failing adaptive epoch tests**
|
|
|
|
Cover two-second warm-up, five-second/120-sample windows, nearest-rank p95, `20/25/40 ms` thresholds, two bad Low windows, `>250 ms` same-epoch catastrophic interval, hidden/pause gap exclusion, 30-visible-second cooldown, resize reset, and no automatic upgrade.
|
|
|
|
- [ ] **Step 4: Implement the adaptive controller**
|
|
|
|
```ts
|
|
export interface AdaptiveQualityController {
|
|
enterRunningEpoch(now: number): void;
|
|
leaveRunningEpoch(): void;
|
|
sampleFrame(start: number, end: number): QualityDecision | null;
|
|
setMaximum(tier: QualityTier, reason: 'resize' | 'manual'): QualityDecision | null;
|
|
snapshot(): AdaptiveQualitySnapshot;
|
|
}
|
|
|
|
export interface QualityDecision {
|
|
previous: QualityTier;
|
|
next: QualityTier;
|
|
reason: 'catastrophic-frame' | 'p95-window' | 'two-bad-low-windows' | 'maximum-change';
|
|
}
|
|
|
|
export interface AdaptiveQualitySnapshot {
|
|
maximum: QualityTier;
|
|
actual: QualityTier;
|
|
epoch: number | null;
|
|
coolingDownUntil: number | null;
|
|
eligibleSamples: number;
|
|
consecutiveBadLowWindows: number;
|
|
}
|
|
```
|
|
|
|
Every epoch exit discards its RAF baseline and incomplete window.
|
|
|
|
- [ ] **Step 5: Verify and commit**
|
|
|
|
```bash
|
|
npm run test:unit -- tests/unit/motion-policy.test.ts tests/unit/adaptive-quality.test.ts
|
|
git add src/journey/motion-policy.ts src/journey/adaptive-quality.ts src/composables/useMotionPreferences.ts src/composables/useAdaptiveQuality.ts tests/unit
|
|
git commit -m "feat: add motion policy and adaptive quality"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Build the lifecycle-safe Three.js engine
|
|
|
|
**Files:**
|
|
- Create: `src/scene/types.ts`
|
|
- Create: `src/scene/resources.ts`
|
|
- Create: `src/scene/create-scene-controller.ts`
|
|
- Create: `src/scene/world/road.ts`
|
|
- Create: `src/scene/world/terrain.ts`
|
|
- Create: `src/scene/world/sun.ts`
|
|
- Create: `src/scene/world/car.ts`
|
|
- Create: `src/scene/world/sign.ts`
|
|
- Create: `src/scene/world/particles.ts`
|
|
- Create: `src/scene/world/environment.ts`
|
|
- Create: `src/scene/render-quality.ts`
|
|
- Test: `tests/unit/scene-lifecycle.test.ts`
|
|
- Test: `tests/unit/scene-resources.test.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: canvas, `JourneySnapshot`, `QualityTier`, and abort signal.
|
|
- Produces: `SceneController` and observable `SceneLifecycleState` without exposing Three.js objects.
|
|
|
|
- [ ] **Step 1: Write failing lifecycle tests with fake resources**
|
|
|
|
Cover every legal/illegal edge, generation invalidation, abort during loading, exactly-once disposal, context loss during loading/running/retained pause, hidden restore, five-second cutoff, bfcache freeze/restore, and terminal disposal.
|
|
|
|
```ts
|
|
expect(() => machine.transition('disposed', 'running')).toThrow(/illegal transition/i);
|
|
expect(fakeRenderer.dispose).toHaveBeenCalledTimes(1);
|
|
expect(snapshot.canvasCount).toBe(1);
|
|
expect(snapshot.rafCount).toBeLessThanOrEqual(1);
|
|
```
|
|
|
|
- [ ] **Step 2: Define state and resource ownership**
|
|
|
|
```ts
|
|
export type SceneLifecycleState =
|
|
| { kind: 'idle' }
|
|
| { kind: 'loading'; generation: number }
|
|
| { kind: 'running'; generation: number }
|
|
| { kind: 'paused'; generation: number | null; resumeNeedsLoad: boolean }
|
|
| { kind: 'lost'; lostAt: number; restorationPending: boolean }
|
|
| { kind: 'restoring'; generation: number; lostAt: number }
|
|
| { kind: 'static'; reason: string }
|
|
| { kind: 'disposed' };
|
|
```
|
|
|
|
`SceneResourceBag.dispose()` owns geometries, materials, textures, targets, passes, renderer, listeners, observers, timers, and RAF exactly once.
|
|
|
|
- [ ] **Step 3: Implement the procedural world**
|
|
|
|
Road markers scroll from drive distance/speed; restrained wire terrain frames the road; sun/haze evolve by chapter; the original non-branded car uses custom/primitive geometry with a continuous rear light; signs render canonical route labels to internal textures; particles use fixed pools capped by tier.
|
|
|
|
- [ ] **Step 4: Implement quality and renderer ownership**
|
|
|
|
High/Medium/Low alter particle count, terrain segments, post-processing, and pixel ratio. `createSceneController()` owns one renderer/canvas binding and RAF. `update()` copies a serializable snapshot and never queries content DOM.
|
|
|
|
- [ ] **Step 5: Implement context/page lifecycle behavior**
|
|
|
|
Attach context loss/restore, visibility, pagehide, and pageshow through the resource bag. Check generation and overlay before every async attach. Late/stale completions dispose themselves.
|
|
|
|
- [ ] **Step 6: Verify and commit**
|
|
|
|
```bash
|
|
npm run typecheck
|
|
npm run test:unit -- tests/unit/scene-lifecycle.test.ts tests/unit/scene-resources.test.ts
|
|
git add src/scene tests/unit/scene-lifecycle.test.ts tests/unit/scene-resources.test.ts
|
|
git commit -m "feat: build procedural lifecycle-safe drive scene"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Integrate scene, HUD, poster, and motion controls
|
|
|
|
**Files:**
|
|
- Create: `src/components/SceneCanvas.vue`
|
|
- Create: `src/components/JourneyHud.vue`
|
|
- Create: `src/components/DriveControls.vue`
|
|
- Create: `src/composables/useSceneController.ts`
|
|
- Create: `src/styles/world.css`
|
|
- Modify: `src/components/AppShell.vue`
|
|
- Modify: `src/App.vue`
|
|
- Test: `tests/unit/SceneCanvas.test.ts`
|
|
- Test: `tests/browser/scene-controls.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: journey snapshot, motion policy, quality controller, scene factory.
|
|
- Produces: fixed decorative world, accessible controls, poster fallback, status text.
|
|
|
|
- [ ] **Step 1: Write failing lazy-load/control tests**
|
|
|
|
Assert no Three.js request in reduced motion/Static, exactly one canvas in full motion, visible Pause motion, quality maximum behavior, canvas absent from the accessibility tree, and a usable poster after failure.
|
|
|
|
- [ ] **Step 2: Implement dynamic scene ownership**
|
|
|
|
`useSceneController()` imports the scene only when `allowSceneRequest` is true, binds abort/generation tokens, and disposes once on unmount, non-persisted pagehide, or Static transition.
|
|
|
|
- [ ] **Step 3: Implement scene host and HUD**
|
|
|
|
Expose one visually hidden scene description from content. Canvas, car, signs, and visual HUD are `aria-hidden`. The poster has intrinsic dimensions and empty alt because the adjacent description owns meaning. HUD reads route percentage/current section/quality and `ARRIVED` at Contact.
|
|
|
|
- [ ] **Step 4: Implement controls**
|
|
|
|
Use real buttons and a named native quality control. Exact names are Pause motion, Resume motion, Enable motion, and Quality. Static/unavailable state uses visible text and not color alone.
|
|
|
|
- [ ] **Step 5: Verify and commit**
|
|
|
|
```bash
|
|
npm run test:unit -- tests/unit/SceneCanvas.test.ts
|
|
npm run test:browser -- tests/browser/scene-controls.spec.ts
|
|
git add src/components src/composables/useSceneController.ts src/styles/world.css src/App.vue tests
|
|
git commit -m "feat: integrate accessible journey world and controls"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Build the opt-in generated audio engine
|
|
|
|
**Files:**
|
|
- Create: `src/audio/types.ts`
|
|
- Create: `src/audio/create-audio-controller.ts`
|
|
- Create: `src/audio/synth-engine.ts`
|
|
- Create: `src/composables/useAudioEngine.ts`
|
|
- Modify: `src/components/DriveControls.vue`
|
|
- Test: `tests/unit/audio-lifecycle.test.ts`
|
|
- Test: `tests/browser/audio-controls.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: direct activation, volume/mute input, smoothed drive intensity, visibility/lifecycle events.
|
|
- Produces: `AudioController` and `AudioUiState`; Vue never receives AudioContext or nodes.
|
|
|
|
- [ ] **Step 1: Write failing audio lifecycle/race tests**
|
|
|
|
Cover idle/start/running/suspended/user-required/failed/disposed, double activation, quick hide/show while starting, interruption while starting, mute/volume, one graph, rejection recovery, bfcache, and exactly-once close.
|
|
|
|
```ts
|
|
expect(fakeContext.constructed).toBe(0);
|
|
await controller.startFromGesture();
|
|
expect(fakeContext.constructed).toBe(1);
|
|
await controller.startFromGesture();
|
|
expect(fakeContext.constructed).toBe(1);
|
|
```
|
|
|
|
- [ ] **Step 2: Implement idempotent synthesis and state**
|
|
|
|
```ts
|
|
export type AudioUiState =
|
|
| { kind: 'idle' }
|
|
| { kind: 'starting' }
|
|
| { kind: 'running'; muted: boolean; volume: number }
|
|
| { kind: 'suspended'; resumeRequiresGesture: boolean }
|
|
| { kind: 'failed'; message: string }
|
|
| { kind: 'disposed' };
|
|
```
|
|
|
|
Create a restrained generated engine/road bed from oscillators, filtered noise, gain, and dynamics. Default volume is `0.4`; clamp `[0,1]`; smooth intensity; create/connect nodes once per generation.
|
|
|
|
- [ ] **Step 3: Integrate accessible controls**
|
|
|
|
Before activation show START THE DRIVE and Enables the interactive soundtrack. Afterwards show Mute sound/Unmute sound and a visible native range named Sound volume. Failures show concise recovery text and never obscure content.
|
|
|
|
- [ ] **Step 4: Verify direct-gesture creation and commit**
|
|
|
|
```bash
|
|
npm run test:unit -- tests/unit/audio-lifecycle.test.ts
|
|
npm run test:browser -- tests/browser/audio-controls.spec.ts
|
|
git add src/audio src/composables/useAudioEngine.ts src/components/DriveControls.vue tests
|
|
git commit -m "feat: add opt-in procedural drive audio"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Lock journey integration and static behavior
|
|
|
|
**Files:**
|
|
- Create: `tests/browser/journey-integration.spec.ts`
|
|
- Create: `tests/browser/reduced-motion.spec.ts`
|
|
- Create: `tests/browser/static-fallback.spec.ts`
|
|
- Create: `tests/browser/bfcache-lifecycle.spec.ts`
|
|
- Create: `tests/helpers/scene-instrumentation.ts`
|
|
- Modify: `scripts/verify-artifacts.mjs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: completed Phase 2 app.
|
|
- Produces: release-blocking synchronized-state and resource-count assertions.
|
|
|
|
- [ ] **Step 1: Test one-source journey synchronization**
|
|
|
|
Scroll all sections and assert active navigation, HUD, route progress, environment chapter, and Contact arrival update from one snapshot within one RAF. Visible signs must match the independent latch oracle.
|
|
|
|
- [ ] **Step 2: Test reduced motion before boot**
|
|
|
|
Emulate reduced motion, clear storage, load, and assert no Three.js request, canvas, automatic video source, or sign; require poster, semantic HUD, navigation, and contact.
|
|
|
|
- [ ] **Step 3: Test Static and failure fallback**
|
|
|
|
Block the scene chunk and inject shader/context failures. Assert one poster, zero scene RAF, no unhandled rejection, and full navigation/contact. Enable motion retries only when system reduction is absent.
|
|
|
|
- [ ] **Step 4: Test freeze/restore counts**
|
|
|
|
Exercise persisted pagehide/pageshow across idle/loading/running/paused/lost/restoring/static. Assert at most one current generation, canvas, RAF loop, and audio graph.
|
|
|
|
- [ ] **Step 5: Run the phase gate and commit**
|
|
|
|
```bash
|
|
npm run typecheck
|
|
npm run test:unit
|
|
npm run build:preview
|
|
npm run test:browser -- tests/browser/journey-integration.spec.ts tests/browser/reduced-motion.spec.ts tests/browser/static-fallback.spec.ts tests/browser/bfcache-lifecycle.spec.ts
|
|
git add tests/browser tests/helpers/scene-instrumentation.ts scripts/verify-artifacts.mjs
|
|
git commit -m "test: lock journey lifecycle and static fallback"
|
|
```
|
|
|
|
## Phase 2 completion checkpoint
|
|
|
|
Review desktop, tablet, mobile, reduced-motion, and Static captures. Approve only if the world is memorable while the information layer remains quiet, professional, readable, and dominant enough for hiring decisions.
|