diff --git a/.agents/skills b/.agents/skills new file mode 120000 index 0000000..d883408 --- /dev/null +++ b/.agents/skills @@ -0,0 +1 @@ +/Users/xuser/notes/tkodev/harness-skills \ No newline at end of file diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..d883408 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +/Users/xuser/notes/tkodev/harness-skills \ No newline at end of file diff --git a/docs/prd/01-brief.md b/docs/prd/01-brief.md index 9e221b2..bdd5ac4 100644 --- a/docs/prd/01-brief.md +++ b/docs/prd/01-brief.md @@ -43,13 +43,18 @@ declassified engineering dossier presents evidence: annotated, indexed, precise. Two pillars, both non-negotiable: 1. **On-brand, and honest where it speaks.** The HUD layer has two tiers. A - *readout* (anything displaying a value: a number, date, coordinate, count, - progress) is always true, sourced from the career data, runtime, or the build. - *Atmosphere* (reticles, rings, scanlines, fields) needs no data behind it; - it needs craft and brand coherence: drawn in the system's language, deliberate, - in service of the feeling. Precision instead of theatrics: the failure mode is - not ornament, it's ornament that feels cheap, and a fake readout is the worst - case because it's both. + *readout* (a value presented as describing something real: career data, site + state, runtime, or the build: a stat, a date, a counter, a coordinate tied to + an actual place) is always true. *Atmosphere* (reticles, rings, scanlines, + fields, dial faces) needs no data behind it; it needs craft and brand + coherence: drawn in the system's language, deliberate, in service of the + feeling. This includes instrument furniture that *looks* numeric (dial + tick scales, HUD gauge chrome, reticle telemetry blocks) as long as it never + claims to represent a specific real quantity: it's the printed face of the + instrument, not a measurement. Precision instead of theatrics: the failure + mode is not ornament, it's ornament that feels cheap, and a readout claiming + to be a real, specific quantity while being fake is the worst case because + it's both. 2. **The experience is futuristic.** Boot sequence, darkness with density at the edges, media that plays itself. The COD MW2 (original) loading-screen energy: information-dense, atmospheric, alive. diff --git a/docs/prd/02-observations.md b/docs/prd/02-observations.md index 6c5b39a..73e3e38 100644 --- a/docs/prd/02-observations.md +++ b/docs/prd/02-observations.md @@ -29,11 +29,14 @@ argument against cheap ornament. **Direction.** The two-tier readout/atmosphere rule ([01-brief.md](01-brief.md) pillar 1). Applied concretely: the clock is the real time, the coordinates are Toronto's, the counters derive from the career constants, figure numbers actually -increment, the version stamp is the real build; one obviously-fake readout poisons -trust in all the rest. What gets cut is not ornament but *off-brand* ornament: the -element that could appear on any dark-dashboard Dribbble shot. Precision instead of -theatrics is what separates acustable.com from that pile; feeling instead of -sterility is what separates it from an admin panel. +increment, the version stamp is the real build; one obviously-fake readout claiming +to be a real, specific quantity poisons trust in all the rest. Instrument chrome +that plays a numeric HUD (a dial's tick scale, a reticle's telemetry-block filler) +is atmosphere, not a readout, as long as it never claims a specific real quantity; +it does not need to be true, only on-brand. What gets cut is not ornament but +*off-brand* ornament: the element that could appear on any dark-dashboard Dribbble +shot. Precision instead of theatrics is what separates acustable.com from that +pile; feeling instead of sterility is what separates it from an admin panel. ### Risk: chrome outshining work diff --git a/src/components/atoms/reticle-dial.tsx b/src/components/atoms/reticle-dial.tsx new file mode 100644 index 0000000..e03f01b --- /dev/null +++ b/src/components/atoms/reticle-dial.tsx @@ -0,0 +1,342 @@ +import { forwardRef, type SVGAttributes } from 'react' +import { cn, cva, type VariantProps } from '@/utils/theme' + +const styles = { + root: cva('text-muted-foreground aspect-square overflow-visible'), + ring: cva('svg-origin animate-drift origin-center motion-reduce:animate-none'), + readoutLabel: cva('text-e4 font-expressive fill-muted-foreground uppercase'), + readoutValue: cva('text-e4 font-expressive fill-foreground uppercase'), + panelGroup: cva('opacity-0 transition-opacity duration-1000 xl:opacity-70'), + readoutGroup: cva('opacity-0 transition-opacity duration-1000 xl:opacity-80'), + // boot-in, staggered per top-level child; last delay (500ms) + the 1.5s boot-in-blur + // duration caps the total sequence at 2s + bootGroup: cva([ + '*:svg-origin *:origin-center', + '*:animate-boot-in-blur *:fill-mode-both motion-reduce:*:animate-none', + '[&>*:nth-child(2)]:[animation-delay:50ms]', + '[&>*:nth-child(3)]:[animation-delay:100ms]', + '[&>*:nth-child(4)]:[animation-delay:200ms]', + '[&>*:nth-child(5)]:[animation-delay:400ms]', + '[&>*:nth-child(6)]:[animation-delay:600ms]', + '[&>*:nth-child(7)]:[animation-delay:800ms]', + '[&>*:nth-child(n+8)]:[animation-delay:1200ms]' + ]) +} + +type ReticleDialReadout = { + /** Small caps label above the value, e.g. a designation or unit. */ + label: string + /** The real value the label describes; never a placeholder. */ + value: string +} + +type ReticleDialRef = SVGSVGElement +type ReticleDialProps = SVGAttributes & + VariantProps & { + /** Boxed readouts on the side rails: index 0 left, index 1 right. Real data only. */ + readouts?: ReticleDialReadout[] + } + +const size = 896 +const cx = size / 2 +const cy = size / 2 + +/** Point on a circle of the given radius, angle in degrees (0 = right, -90 = up). */ +const point = (angle: number, radius: number) => { + const rad = (angle * Math.PI) / 180 + return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) } +} + +/** SVG arc path between two angles on a circle of the given radius. */ +const arcPath = (radius: number, angleFrom: number, angleTo: number) => { + const start = point(angleFrom, radius) + const end = point(angleTo, radius) + const largeArc = Math.abs(angleTo - angleFrom) > 180 ? 1 : 0 + return `M${start.x} ${start.y} A${radius} ${radius} 0 ${largeArc} 1 ${end.x} ${end.y}` +} + +// fills the frame the way the original reticle's outer ring (r447.5) did in its 896-tall viewBox +const circleRadius = 378 +const bracketRadius = circleRadius + 40 +const railOffset = bracketRadius + 45 + +// fine spokes just inside the main circle, facing inward like inner dial gauge marks +const fineTickOuter = circleRadius - 8 +const fineTicks = Array.from({ length: 72 }, (_, i) => { + const angle = i * 5 + const long = i % 6 === 0 + const inner = fineTickOuter - (long ? 12 : 6) + const from = point(angle, fineTickOuter) + const to = point(angle, inner) + return { key: angle, long, x1: from.x, y1: from.y, x2: to.x, y2: to.y } +}) + +// texture ticks along the flanking arc brackets +const bracketTicks = [ + ...Array.from({ length: 7 }, (_, i) => -55 + i * (110 / 6)), + ...Array.from({ length: 7 }, (_, i) => 125 + i * (110 / 6)) +].map((angle) => { + const from = point(angle, bracketRadius - 6) + const to = point(angle, bracketRadius + 6) + return { key: angle, x1: from.x, y1: from.y, x2: to.x, y2: to.y } +}) + +const railTicks = Array.from({ length: 9 }, (_, i) => { + const y = cy - 176 + i * 44 + const long = i % 2 === 0 + return { key: i, y, width: long ? 22 : 12 } +}) + +// multi-rung ladder lines flanking the crosshair, mirrored left/right +const ladderRungs = [-92, -46, 46, 92].map((offset) => ({ + key: offset, + y: cy + offset, + width: offset === -46 || offset === 46 ? 44 : 30 +})) + +const cornerMarks = [ + { x: 96, y: 96 }, + { x: size - 96, y: 96 }, + { x: 96, y: size - 96 }, + { x: size - 96, y: size - 96 } +] + +/** + * Fixed decorative HUD panel text: dial furniture, not a readout. Per the + * brief's readout/atmosphere rule ([01-brief.md](../../../docs/02-prd/01-brief.md) + * pillar 1), instrument chrome that never claims a specific real quantity + * is atmosphere, so these values are static and do not need to be true. + */ +const panelReadouts = [ + { + label: 'Drift', + value: '1.0', + x: cx - railOffset - 18, + y: cy - 207, + textAnchor: 'end' as const + }, + { + label: 'Gain', + value: '5.7', + x: cx + railOffset + 18, + y: cy - 207, + textAnchor: 'start' as const + }, + { + label: 'Trim', + value: '0.52', + x: cx - railOffset - 18, + y: cy + 207, + textAnchor: 'end' as const + }, + { + label: 'Scale', + value: '2.485', + x: cx + railOffset + 18, + y: cy + 207, + textAnchor: 'start' as const + } +] + +const ReticleDial = forwardRef((props, ref) => { + // props + const { className, readouts, ...rest } = props + + // render vars + const opacity = 0.3 + const topChevron = point(-90, circleRadius - 18) + const topChevronTip = point(-90, circleRadius - 40) + // mirrored across the center horizon: same shape, flipped vertically + const bottomChevron = point(90, circleRadius - 18) + const bottomChevronTip = point(90, circleRadius - 40) + const railAnchors: { x: number; textAnchor: 'start' | 'end' }[] = [ + { x: cx - railOffset - 18, textAnchor: 'end' }, + { x: cx + railOffset + 18, textAnchor: 'start' } + ] + + // jsx + return ( + + ) +}) +ReticleDial.displayName = 'ReticleDial' + +export { ReticleDial } +export type { ReticleDialProps, ReticleDialReadout, ReticleDialRef } diff --git a/src/components/atoms/reticle.tsx b/src/components/atoms/reticle.tsx index b504c92..ab2f7c9 100644 --- a/src/components/atoms/reticle.tsx +++ b/src/components/atoms/reticle.tsx @@ -61,6 +61,7 @@ const Reticle = forwardRef((props, ref) => { {...rest} > + diff --git a/src/components/organisms/about-hero.tsx b/src/components/organisms/about-hero.tsx index 3b4054f..fb435b6 100644 --- a/src/components/organisms/about-hero.tsx +++ b/src/components/organisms/about-hero.tsx @@ -1,7 +1,7 @@ import { forwardRef, type HTMLAttributes } from 'react' import { Hud, type HudProps } from '@/components/atoms/hud' import { Intro, type IntroProps } from '@/components/atoms/intro' -import { Reticle } from '@/components/atoms/reticle' +import { ReticleDial, type ReticleDialReadout } from '@/components/atoms/reticle-dial' import { cn, cva, type VariantProps } from '@/utils/theme' const styles = { @@ -15,16 +15,18 @@ type AboutHeroProps = HTMLAttributes & VariantProps & { hudProps: HudProps introProps: IntroProps + /** Boxed readouts on the reticle's side rails. Real data only. */ + readouts?: ReticleDialReadout[] } const AboutHero = forwardRef((props, ref) => { // props - const { hudProps, introProps, children, className, ...rest } = props + const { hudProps, introProps, readouts, children, className, ...rest } = props // jsx return (
- + {children} diff --git a/src/components/organisms/design-hero.tsx b/src/components/organisms/design-hero.tsx index 5514010..41c0c3b 100644 --- a/src/components/organisms/design-hero.tsx +++ b/src/components/organisms/design-hero.tsx @@ -1,7 +1,9 @@ import { forwardRef, type HTMLAttributes } from 'react' import { Hud, type HudProps } from '@/components/atoms/hud' import { Intro, type IntroProps } from '@/components/atoms/intro' -import { Reticle } from '@/components/atoms/reticle' +import { ReticleDial } from '@/components/atoms/reticle-dial' +import { appTimeZone } from '@/constants/date' +import { projectEntries } from '@/constants/projects' import { cn, cva, type VariantProps } from '@/utils/theme' const styles = { @@ -24,7 +26,13 @@ const DesignHero = forwardRef((props, ref) => { // jsx return (
- + {children} diff --git a/src/components/organisms/error-hero.tsx b/src/components/organisms/error-hero.tsx index ce5775f..965a963 100644 --- a/src/components/organisms/error-hero.tsx +++ b/src/components/organisms/error-hero.tsx @@ -1,7 +1,7 @@ import { forwardRef, type HTMLAttributes, type ReactNode } from 'react' import { Hud, type HudProps } from '@/components/atoms/hud' import { Intro, type IntroProps } from '@/components/atoms/intro' -import { Reticle } from '@/components/atoms/reticle' +import { ReticleDial, type ReticleDialReadout } from '@/components/atoms/reticle-dial' import { cn, cva, type VariantProps } from '@/utils/theme' const styles = { @@ -15,17 +15,19 @@ type ErrorHeroProps = HTMLAttributes & VariantProps & { hudProps: HudProps introProps: IntroProps + /** Boxed readouts on the reticle's side rails. Real data only. */ + readouts?: ReticleDialReadout[] children?: ReactNode } const ErrorHero = forwardRef((props, ref) => { // props - const { hudProps, introProps, children, className, ...rest } = props + const { hudProps, introProps, readouts, children, className, ...rest } = props // jsx return (
- + {children} diff --git a/src/components/organisms/experience-hero.tsx b/src/components/organisms/experience-hero.tsx index cd6b963..f3f8968 100644 --- a/src/components/organisms/experience-hero.tsx +++ b/src/components/organisms/experience-hero.tsx @@ -1,7 +1,7 @@ import { forwardRef, type HTMLAttributes } from 'react' import { Hud, type HudProps } from '@/components/atoms/hud' import { Intro, type IntroProps } from '@/components/atoms/intro' -import { Reticle } from '@/components/atoms/reticle' +import { ReticleDial, type ReticleDialReadout } from '@/components/atoms/reticle-dial' import { cn, cva, type VariantProps } from '@/utils/theme' const styles = { @@ -15,16 +15,18 @@ type ExperienceHeroProps = HTMLAttributes & VariantProps & { hudProps: HudProps introProps: IntroProps + /** Boxed readouts on the reticle's side rails. Real data only. */ + readouts?: ReticleDialReadout[] } const ExperienceHero = forwardRef((props, ref) => { // props - const { hudProps, introProps, children, className, ...rest } = props + const { hudProps, introProps, readouts, children, className, ...rest } = props // jsx return (
- + {children} diff --git a/src/components/organisms/home-hero.tsx b/src/components/organisms/home-hero.tsx index 537388c..9b30fd2 100644 --- a/src/components/organisms/home-hero.tsx +++ b/src/components/organisms/home-hero.tsx @@ -1,7 +1,7 @@ import { forwardRef, type HTMLAttributes } from 'react' import { Hud, type HudProps } from '@/components/atoms/hud' import { Intro, type IntroProps } from '@/components/atoms/intro' -import { Reticle } from '@/components/atoms/reticle' +import { ReticleDial, type ReticleDialReadout } from '@/components/atoms/reticle-dial' import { cn, cva, type VariantProps } from '@/utils/theme' const styles = { @@ -15,16 +15,18 @@ type HomeHeroProps = HTMLAttributes & VariantProps & { hudProps: HudProps introProps: IntroProps + /** Boxed readouts on the reticle's side rails. Real data only. */ + readouts?: ReticleDialReadout[] } const HomeHero = forwardRef((props, ref) => { // props - const { hudProps, introProps, children, className, ...rest } = props + const { hudProps, introProps, readouts, children, className, ...rest } = props // jsx return (
- + {children} diff --git a/src/themes/theme.css b/src/themes/theme.css index c50a37b..47e27b7 100644 --- a/src/themes/theme.css +++ b/src/themes/theme.css @@ -206,12 +206,14 @@ --animate-boot-down: boot-down 1.5s var(--brand-ease-out); --animate-boot-up: boot-up 1.5s var(--brand-ease-out); --animate-boot-in: boot-in 1.5s var(--brand-ease-out); + --animate-boot-in-blur: boot-in-blur 1.5s var(--brand-ease-out); --animate-boot-out: boot-out 1.5s var(--brand-ease-out); --animate-fade-in: fade-in 1.5s var(--brand-ease-out); --animate-fade-out: fade-out 1.5s var(--brand-ease-out); --animate-cursor-wave: cursor-wave 0.6s var(--brand-ease-out) forwards; --animate-marquee: marquee 80s linear infinite; --animate-ticker: ticker 8s linear infinite alternate; + --animate-drift: drift 240s linear infinite; } /* animations */ @@ -242,6 +244,15 @@ } } +@keyframes drift { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + @keyframes ticker { 0%, 15% { @@ -330,6 +341,19 @@ } } +@keyframes boot-in-blur { + from { + transform: scale(0.85); + filter: blur(8px); + opacity: 0; + } + to { + transform: scale(1); + filter: blur(0px); + opacity: 1; + } +} + @keyframes boot-out { from { transform: scale(1);