diff --git a/ApiCore-Frontend/src/components/hive/hive-glass.ts b/ApiCore-Frontend/src/components/hive/hive-glass.ts index 93fb90b..b24e0c1 100644 --- a/ApiCore-Frontend/src/components/hive/hive-glass.ts +++ b/ApiCore-Frontend/src/components/hive/hive-glass.ts @@ -1,12 +1,13 @@ // Frosted glass surfaces over the holo stage (mockups g2/h2). Text is // --holo-label-fg on --holo-label-bg at 70 % over the midnight stage, well // above 4.5:1. The focus ring comes from the global rule; .holo-stage on the -// stage root draws it in the strong edge colour (spec 17.8). +// stage root draws it in the strong edge colour (spec 17.8). In sun the stage +// is black: no blur and no glow (T5 spec 17.6), the fill stays. -const GLASS_FILL = "bg-holo-label-bg/70 text-holo-label-fg backdrop-blur-md"; +const GLASS_FILL = "bg-holo-label-bg/70 text-holo-label-fg backdrop-blur-md sun:backdrop-blur-none"; -export const GLASS_SURFACE = `border border-white/15 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.6)] ${GLASS_FILL}`; +export const GLASS_SURFACE = `border border-white/15 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.6)] sun:shadow-none ${GLASS_FILL}`; -export const GLASS_BUTTON = `inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-sm sm:gap-2 sm:px-4 font-medium transition-colors hover:bg-holo-label-bg/90 disabled:cursor-not-allowed disabled:opacity-60 aria-pressed:border-2 aria-pressed:border-holo-edge-strong aria-pressed:bg-holo-edge/25 [&_svg]:size-5 border border-holo-edge/35 shadow-[0_0_18px_-8px_var(--holo-edge)] ${GLASS_FILL}`; +export const GLASS_BUTTON = `inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-sm sm:gap-2 sm:px-4 font-medium transition-colors hover:bg-holo-label-bg/90 disabled:cursor-not-allowed disabled:opacity-60 aria-pressed:border-2 aria-pressed:border-holo-edge-strong aria-pressed:bg-holo-edge/25 [&_svg]:size-5 border border-holo-edge/35 shadow-[0_0_18px_-8px_var(--holo-edge)] sun:shadow-none ${GLASS_FILL}`; export const GLASS_PILL = `inline-flex h-11 shrink-0 items-center gap-2 rounded-full px-4 text-sm font-medium transition-colors hover:bg-holo-label-bg/90 ${GLASS_SURFACE}`; diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/drop-targets.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/drop-targets.tsx index 0e3e199..cd17dd6 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/drop-targets.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/drop-targets.tsx @@ -11,12 +11,12 @@ import type { DropSlot } from "../editor/drop-slots"; import { pickAnchor } from "./drop-anchors"; import type { SlotAnchor } from "./drop-anchors"; import { clampZoom, pinchZoom, wheelZoom } from "./camera-fit"; -import { createEdgeMaterial, createGlassMaterial } from "./materials"; +import { createEdgeMaterial, createGlassMaterial, setEdgeIntensity } from "./materials"; import { buildEdgeGeometry, buildGlassGeometry } from "./part-geometry"; import type { EdgeSegment } from "./part-geometry"; import { noRaycast, useSceneKit } from "./scene-kit"; import { setPinching, setUniform, setUniformColor } from "./scene-mutations"; -import { useGlassEnvironment } from "./use-scene-effects"; +import { useEdgeViewport, useGlassEnvironment } from "./use-scene-effects"; // Editor drop targets (spec 9.5): every legal slot glows, the slot under the // pointer lights up and a glass preview of the dragged part snaps into it. @@ -38,11 +38,14 @@ function outline(size: Vec3, strength: number): EdgeSegment[] { return segments; } +/** Tube width of the slot markers (a little wider than the part edges). */ +const SLOT_EDGE_WIDTH = 0.03; + /** The glowing place of one slot: a flat frame for a stack gap, a box outline for an insert. */ function SlotMarker({ anchor, material }: { anchor: SlotAnchor; material: ShaderMaterial }) { const flat = anchor.slot.kind === "stack"; const [mx, my, mz] = flat ? [anchor.size[0], 0.001, anchor.size[2]] : anchor.size; - const geometry = useMemo(() => buildEdgeGeometry(outline([mx, my, mz], 1), 0.03), [mx, my, mz]); + const geometry = useMemo(() => buildEdgeGeometry(outline([mx, my, mz], 1), SLOT_EDGE_WIDTH), [mx, my, mz]); useEffect(() => () => geometry.dispose(), [geometry]); return ( createGlassMaterial(theme, { frost: 0.75 }), [theme]); useGlassEnvironment(glass, true); const glow = useMemo(() => createEdgeMaterial(theme), [theme]); + useEdgeViewport([glow]); useEffect( () => () => { geometry.dispose(); @@ -81,7 +85,7 @@ function Preview({ anchor }: { anchor: SlotAnchor }) { // Brighter than the parts around it, so the snapped place reads at once. setUniform(glass, "uOpacity", 0.9); setUniform(glass, "uGlow", 2.2); - setUniform(glow, "uIntensity", 3); + setEdgeIntensity(glow, 3); setUniformColor(glow, "uCyan", theme.edgeStrong); }, [glass, glow, theme]); return ( @@ -118,10 +122,18 @@ export function DropTargets({ anchors, hover, pointer, subscribePointer, onHover const hovered = anchors.find((anchor) => sameSlot(anchor.slot, hover)) ?? null; // Two shared materials for every marker: idle and the one under the pointer. - const materials = useMemo(() => ({ idle: createEdgeMaterial(theme), active: createEdgeMaterial(theme) }), [theme]); + // In sun the idle ones are grey and the active one white and wider (no glow to tell them apart). + const materials = useMemo( + () => ({ + idle: createEdgeMaterial(theme, { tubeWidth: SLOT_EDGE_WIDTH, sunGrey: true }), + active: createEdgeMaterial(theme, { tubeWidth: SLOT_EDGE_WIDTH }), + }), + [theme], + ); + useEdgeViewport([materials.idle, materials.active]); useEffect(() => { - setUniform(materials.idle, "uIntensity", 1.4); - setUniform(materials.active, "uIntensity", 3.2); + setEdgeIntensity(materials.idle, 1.4); + setEdgeIntensity(materials.active, 3.2); return () => { materials.idle.dispose(); materials.active.dispose(); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/frame-plate.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/frame-plate.tsx index c291965..0a3c665 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/frame-plate.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/frame-plate.tsx @@ -1,18 +1,18 @@ "use client"; import { useEffect, useMemo, useRef } from "react"; -import { Color, MeshStandardMaterial } from "three"; -import type { Group, ShaderMaterial } from "three"; +import { MeshBasicMaterial, MeshStandardMaterial } from "three"; +import type { Group, Material, ShaderMaterial, Texture } from "three"; import { subVec3 } from "@/lib/hive/layout"; import type { FrameBox, Vec3 } from "@/lib/hive/layout"; import { HoloLabel } from "./hive-labels"; -import { createEdgeMaterial, createGlassMaterial } from "./materials"; +import { createEdgeMaterial, createGlassMaterial, setEdgeIntensity } from "./materials"; import { buildEdgeGeometry, buildGlassGeometry, frameHintSpec, frameShapeSpec } from "./part-geometry"; import type { PartShapeSpec } from "./part-geometry"; import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit"; import { setMaterialOpacity, setUniform, setUniformMix } from "./scene-mutations"; import { useGlassEnvironment, useLivingEdges } from "./use-scene-effects"; -import { frameRimColor } from "./frame-textures"; +import { frameLookFor } from "./frame-textures"; import type { SceneLabel } from "./scene-model"; import type { NodeHandlers } from "./stack-part"; import { useAnimatedNode } from "./use-animated-node"; @@ -21,7 +21,8 @@ import type { FrameTarget } from "./use-hive-motion"; // Frames (spec 9.3/9.4). At rest only their top bars are hinted inside the // box. In an open box each frame is a clear glass plate; hovered or selected -// it rises and takes its type look (colour and comb pattern). +// it rises and takes its type look (colour and comb pattern; in sun a black +// and white hatch per type with a white rim instead). /** Faint top bars of all frames of a box, one merged mesh using the box's materials. */ export function FrameHints({ @@ -69,6 +70,31 @@ export interface FramePlateProps { const COMB_FROST: Partial> = { DIVIDER: 0.75, FOUNDATION: 0.25 }; +/** Above the neutral tone mapping's knee: the white of a sun hatch comes out white, not 88 % grey. */ +const SUN_HATCH_GAIN = 6; + +/** The comb of a lifted frame: lit comb texture, or in sun the hatch unlit (stays pure black and white). */ +export function frameTypeMaterial(texture: Texture, hatch: boolean, type: FrameBox["type"]): Material { + if (hatch) { + const material = new MeshBasicMaterial({ map: texture, transparent: true, opacity: 0 }); + material.color.setScalar(SUN_HATCH_GAIN); + return material; + } + return new MeshStandardMaterial({ + map: texture, + // The comb pattern as relief: cells read as depth under the studio light. + bumpMap: texture, + bumpScale: 4, + emissiveMap: texture, + emissive: "#ffffff", + emissiveIntensity: 0.8, + roughness: type === "HONEY" ? 0.2 : 0.45, + metalness: 0.05, + transparent: true, + opacity: 0, + }); +} + export function FramePlate({ frame, hostCenter, target, handlers, label }: FramePlateProps) { const { theme, geometries, textures, motion } = useSceneKit(); const group = useRef(null); @@ -93,32 +119,18 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame // While mounted the plate holds its shapes, so the cache does not dispose them. useEffect(() => geometries.retain(barsKey, bars), [geometries, barsKey, bars]); useEffect(() => (combKey && comb ? geometries.retain(combKey, comb) : undefined), [geometries, combKey, comb]); - const texture = textures.get(frame.type); - const rimHex = frameRimColor(frame.type); - const rim = useMemo(() => (rimHex ? new Color(rimHex) : theme.edgeStrong), [rimHex, theme]); + const look = useMemo(() => frameLookFor(frame.type, { sun: theme.sun }), [frame.type, theme.sun]); + const texture = look.hatch ? textures.hatch(look.hatch) : textures.get(frame.type); + const rim = look.color ?? theme.edgeStrong; const materials = useMemo( () => ({ bars: createGlassMaterial(theme, { frost: 0.28 }), comb: createGlassMaterial(theme, { frost: COMB_FROST[frame.type] ?? 0.38 }), edges: createEdgeMaterial(theme), - typed: texture - ? new MeshStandardMaterial({ - map: texture, - // The comb pattern as relief: cells read as depth under the studio light. - bumpMap: texture, - bumpScale: 4, - emissiveMap: texture, - emissive: "#ffffff", - emissiveIntensity: 0.8, - roughness: frame.type === "HONEY" ? 0.2 : 0.45, - metalness: 0.05, - transparent: true, - opacity: 0, - }) - : null, + typed: texture ? frameTypeMaterial(texture, look.hatch !== null, frame.type) : null, }), - [theme, texture, frame.type], + [theme, texture, look.hatch, frame.type], ); useGlassEnvironment(materials.bars, true); useGlassEnvironment(materials.comb, false); @@ -140,7 +152,7 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame setUniform(materials.comb, "uOpacity", opacity * (1 - (materials.typed ? look * 0.92 : 0))); setUniform(materials.comb, "uGlow", glow); setUniform(materials.edges, "uOpacity", opacity); - setUniform(materials.edges, "uIntensity", 1.3 * glow); + setEdgeIntensity(materials.edges, 1.3 * glow); // Lifted, the rim takes the colour of the frame type. setUniformMix(materials.edges, "uCyan", theme.edge, rim, look); if (materials.typed) setMaterialOpacity(materials.typed, opacity * look); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/frame-textures.spec.ts b/ApiCore-Frontend/src/components/hive/hive-scene/frame-textures.spec.ts index e196755..1c89031 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/frame-textures.spec.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/frame-textures.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { FRAME_TYPES } from "@/lib/hive/types"; import type { FrameType } from "@/lib/hive/types"; -import { createFrameTextureCache, framePatternSpec, frameRimColor } from "./frame-textures"; +import { createFrameTextureCache, frameLookFor, framePatternSpec, frameRimColor } from "./frame-textures"; /** A canvas whose 2D context records every fill colour and shape drawn. */ function fakeCanvas() { @@ -20,13 +20,13 @@ function fakeCanvas() { globalAlpha: 1, fillRect: vi.fn(() => fills.push(`rect:${String(fillStyle)}`)), beginPath: vi.fn(), - moveTo: vi.fn(), - lineTo: vi.fn(), + moveTo: vi.fn((x: number, y: number) => fills.push(`move:${x.toFixed(1)},${y.toFixed(1)}`)), + lineTo: vi.fn((x: number, y: number) => fills.push(`line:${x.toFixed(1)},${y.toFixed(1)}`)), closePath: vi.fn(), arc: vi.fn(), ellipse: vi.fn(), fill: vi.fn(() => fills.push(typeof fillStyle === "string" ? fillStyle : "gradient")), - stroke: vi.fn(), + stroke: vi.fn(() => fills.push("stroke")), createRadialGradient: vi.fn(() => gradient), createLinearGradient: vi.fn(() => gradient), }; @@ -116,3 +116,63 @@ describe("frameRimColor", () => { expect(frameRimColor("NONE")).toBeNull(); }); }); + +describe("frameLookFor", () => { + const typed = FRAME_TYPES.filter((type) => type !== "NONE"); + + it("tints a lifted frame in its type colour and draws no hatch outside sun", () => { + for (const type of typed) { + const look = frameLookFor(type, { sun: false }); + expect(look.hatch).toBeNull(); + expect(`#${look.color!.getHexString()}`).toBe(frameRimColor(type)); + } + }); + + it("in sun every frame type has its own hatch and no tint", () => { + const hatches = typed.map((type) => frameLookFor(type, { sun: true })); + for (const look of hatches) { + expect(look.color).toBeNull(); + expect(look.hatch).not.toBeNull(); + } + expect(new Set(hatches.map((look) => look.hatch)).size).toBe(typed.length); + }); + + it("an empty slot has neither tint nor hatch", () => { + expect(frameLookFor("NONE", { sun: false })).toEqual({ color: null, hatch: null }); + expect(frameLookFor("NONE", { sun: true })).toEqual({ color: null, hatch: null }); + }); +}); + +describe("createFrameTextureCache hatches", () => { + it("draws one cached black-and-white hatch texture per kind, each with its own strokes", () => { + const drawn: string[][] = []; + const cache = createFrameTextureCache(() => { + const fake = fakeCanvas(); + drawn.push(fake.fills); + return fake.canvas; + }); + const kinds = FRAME_TYPES.map((type) => frameLookFor(type, { sun: true }).hatch).filter( + (kind): kind is NonNullable => kind !== null, + ); + const first = cache.hatch(kinds[0]); + expect(first).not.toBeNull(); + expect(cache.hatch(kinds[0])).toBe(first); + kinds.slice(1).forEach((kind) => cache.hatch(kind)); + expect(drawn).toHaveLength(kinds.length); + // Black ground, white lines: no colour at all. + for (const fills of drawn) { + expect(fills[0]).toBe("rect:#000000"); + const paints = fills.filter((entry) => entry.startsWith("rect:") || entry.startsWith("#")); + expect(paints.every((entry) => ["rect:#000000", "rect:#ffffff", "#ffffff"].includes(entry))).toBe(true); + } + expect(new Set(drawn.map((fills) => fills.join("|"))).size).toBe(kinds.length); + }); + + it("disposes the hatch textures with the comb textures", () => { + const cache = createFrameTextureCache(() => fakeCanvas().canvas); + const hatch = cache.hatch(frameLookFor("BROOD", { sun: true }).hatch!)!; + const dispose = vi.spyOn(hatch, "dispose"); + cache.dispose(); + expect(dispose).toHaveBeenCalled(); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/frame-textures.ts b/ApiCore-Frontend/src/components/hive/hive-scene/frame-textures.ts index 9dbca02..d5d5ae1 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/frame-textures.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/frame-textures.ts @@ -1,8 +1,10 @@ -import { CanvasTexture, ClampToEdgeWrapping, SRGBColorSpace } from "three"; +import { CanvasTexture, ClampToEdgeWrapping, Color, SRGBColorSpace } from "three"; import type { FrameType } from "@/lib/hive/types"; // Procedural comb textures for the frame look (spec 9.3): colour AND pattern // per frame type, drawn once on a canvas and cached per type. No image assets. +// In sun the tints give way to a black-and-white hatch per type (spec 9.3, +// T5 spec 17.8); the type also stands as a word in the label. const WIDTH = 512; const HEIGHT = 288; @@ -145,6 +147,44 @@ export function frameRimColor(type: FrameType): string | null { return SPECS[type]?.rim ?? OPEN_RIMS[type] ?? null; } +/** Black-and-white patterns that tell the frame types apart in sun without colour. */ +export type HatchKind = + | "crosshatch" + | "rings" + | "horizontal" + | "dots" + | "diagonal" + | "grid" + | "vertical" + | "zigzag" + | "checker"; + +const HATCHES: Partial> = { + BROOD: "crosshatch", + DRONE: "rings", + HONEY: "horizontal", + POLLEN: "dots", + FEED: "diagonal", + FOUNDATION: "grid", + EMPTY_COMB: "vertical", + DRONE_FRAME: "zigzag", + DIVIDER: "checker", +}; + +export interface FrameLook { + /** Rim colour of a lifted or selected frame; null in sun and for an empty slot. */ + color: Color | null; + /** Hatch in place of the tint (sun only). */ + hatch: HatchKind | null; +} + +/** How a lifted or selected frame shows its type: a tint, or in sun a hatch. */ +export function frameLookFor(type: FrameType, { sun }: { sun: boolean }): FrameLook { + if (sun) return { color: null, hatch: HATCHES[type] ?? null }; + const rim = frameRimColor(type); + return { color: rim ? new Color(rim) : null, hatch: null }; +} + /** Small deterministic PRNG (mulberry32). */ function random(seed: number): () => number { let state = seed >>> 0; @@ -244,41 +284,141 @@ function drawPattern(context: CanvasRenderingContext2D, spec: FramePatternSpec): } } +/** Hatch spacing and line width in canvas pixels: coarse enough to read on a phone in the sun. */ +const HATCH_STEP = 24; +const HATCH_LINE = 5; +const HATCH_INK = "#ffffff"; + +function strokeLines(context: CanvasRenderingContext2D, lines: [number, number, number, number][]): void { + context.beginPath(); + for (const [x1, y1, x2, y2] of lines) { + context.moveTo(x1, y1); + context.lineTo(x2, y2); + } + context.stroke(); +} + +function steps(from: number, to: number, step: number): number[] { + const values: number[] = []; + for (let value = from; value < to; value += step) values.push(value); + return values; +} + +const HATCH_DRAWERS: Record void> = { + horizontal: (context) => + strokeLines(context, steps(HATCH_STEP / 2, HEIGHT, HATCH_STEP).map((y) => [0, y, WIDTH, y])), + vertical: (context) => + strokeLines(context, steps(HATCH_STEP / 2, WIDTH, HATCH_STEP).map((x) => [x, 0, x, HEIGHT])), + grid: (context) => { + HATCH_DRAWERS.horizontal(context); + HATCH_DRAWERS.vertical(context); + }, + diagonal: (context) => + strokeLines(context, steps(-HEIGHT, WIDTH, HATCH_STEP).map((x) => [x, HEIGHT, x + HEIGHT, 0])), + crosshatch: (context) => { + HATCH_DRAWERS.diagonal(context); + strokeLines(context, steps(-HEIGHT, WIDTH, HATCH_STEP).map((x) => [x, 0, x + HEIGHT, HEIGHT])); + }, + zigzag: (context) => { + const half = HATCH_STEP / 2; + strokeLines( + context, + steps(HATCH_STEP, HEIGHT, HATCH_STEP * 1.5).flatMap((y) => + steps(0, WIDTH, HATCH_STEP).flatMap((x): [number, number, number, number][] => [ + [x, y, x + half, y - half], + [x + half, y - half, x + HATCH_STEP, y], + ]), + ), + ); + }, + dots: (context) => { + context.fillStyle = HATCH_INK; + for (const y of steps(HATCH_STEP / 2, HEIGHT, HATCH_STEP)) { + for (const x of steps(HATCH_STEP / 2, WIDTH, HATCH_STEP)) { + context.beginPath(); + context.arc(x, y, HATCH_LINE * 0.9, 0, Math.PI * 2); + context.fill(); + } + } + }, + rings: (context) => { + const step = HATCH_STEP * 1.5; + for (const y of steps(step / 2, HEIGHT, step)) { + for (const x of steps(step / 2, WIDTH, step)) { + context.beginPath(); + context.arc(x, y, step * 0.32, 0, Math.PI * 2); + context.stroke(); + } + } + }, + checker: (context) => { + const step = HATCH_STEP * 1.5; + context.fillStyle = HATCH_INK; + steps(0, HEIGHT, step).forEach((y, row) => { + for (const x of steps(row % 2 === 0 ? 0 : step, WIDTH, step * 2)) context.fillRect(x, y, step, step); + }); + }, +}; + +function drawHatch(context: CanvasRenderingContext2D, kind: HatchKind): void { + context.fillStyle = "#000000"; + context.fillRect(0, 0, WIDTH, HEIGHT); + context.strokeStyle = HATCH_INK; + context.lineWidth = HATCH_LINE; + HATCH_DRAWERS[kind](context); +} + export interface FrameTextureCache { /** Texture of a frame type, created on first use; null for types without a comb or without 2D canvas. */ get(type: FrameType): CanvasTexture | null; + /** Black-and-white hatch of a frame type in sun, created on first use; null without 2D canvas. */ + hatch(kind: HatchKind): CanvasTexture | null; dispose(): void; } const defaultCanvas = (): HTMLCanvasElement => document.createElement("canvas"); +function canvasTexture( + makeCanvas: () => HTMLCanvasElement, + draw: (context: CanvasRenderingContext2D) => void, +): CanvasTexture | null { + const canvas = makeCanvas(); + canvas.width = WIDTH; + canvas.height = HEIGHT; + const context = canvas.getContext("2d"); + if (!context) return null; + draw(context); + const texture = new CanvasTexture(canvas); + texture.colorSpace = SRGBColorSpace; + texture.wrapS = ClampToEdgeWrapping; + texture.wrapT = ClampToEdgeWrapping; + texture.anisotropy = 4; + return texture; +} + export function createFrameTextureCache(makeCanvas: () => HTMLCanvasElement = defaultCanvas): FrameTextureCache { const textures = new Map(); + const hatches = new Map(); return { get(type) { if (textures.has(type)) return textures.get(type) ?? null; const spec = framePatternSpec(type); if (!spec) return null; - const canvas = makeCanvas(); - canvas.width = WIDTH; - canvas.height = HEIGHT; - const context = canvas.getContext("2d"); - if (!context) { - textures.set(type, null); - return null; - } - drawPattern(context, spec); - const texture = new CanvasTexture(canvas); - texture.colorSpace = SRGBColorSpace; - texture.wrapS = ClampToEdgeWrapping; - texture.wrapT = ClampToEdgeWrapping; - texture.anisotropy = 4; + const texture = canvasTexture(makeCanvas, (context) => drawPattern(context, spec)); textures.set(type, texture); return texture; }, + hatch(kind) { + if (hatches.has(kind)) return hatches.get(kind) ?? null; + const texture = canvasTexture(makeCanvas, (context) => drawHatch(context, kind)); + hatches.set(kind, texture); + return texture; + }, dispose() { textures.forEach((texture) => texture?.dispose()); textures.clear(); + hatches.forEach((texture) => texture?.dispose()); + hatches.clear(); }, }; } diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/hive-labels.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/hive-labels.tsx index c43e918..cf5c745 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/hive-labels.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/hive-labels.tsx @@ -6,9 +6,10 @@ import type { SceneLabel } from "./scene-model"; // DOM labels in the scene (spec 9.3): sharp, translatable text in a glass // pill with a thin leader line. At most three at once (sceneLabels caps it). +// In sun 16 px on a solid plate, without blur and glow (spec 9.3, T5 17.6). -const PILL = - "whitespace-nowrap rounded-xl border border-holo-edge/45 bg-holo-label-bg/75 px-3 py-1.5 text-sm font-medium text-holo-label-fg shadow-[0_0_22px_-6px_var(--holo-edge)] backdrop-blur-md"; +export const HOLO_LABEL_PILL = + "whitespace-nowrap rounded-xl border border-holo-edge/45 bg-holo-label-bg/75 px-3 py-1.5 text-sm font-medium text-holo-label-fg shadow-[0_0_22px_-6px_var(--holo-edge)] backdrop-blur-md sun:border-holo-edge sun:bg-holo-label-bg sun:text-base sun:shadow-none sun:backdrop-blur-none"; export interface HoloLabelProps { position: Vec3; @@ -22,7 +23,7 @@ export function HoloLabel({ position, text, tone }: HoloLabelProps) { {tone === "frame" ? (
- {text} + {text}
@@ -30,7 +31,7 @@ export function HoloLabel({ position, text, tone }: HoloLabelProps) {
- + {tone === "level" ? : null} {text} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.spec.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.spec.tsx index a2f9678..e8122f0 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.spec.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.spec.tsx @@ -10,6 +10,7 @@ import { partDisplayName } from "@/lib/hive/names"; import { frames, inlineCatalog, part } from "@/lib/hive/test-utils"; import type { HiveState } from "@/lib/hive/types"; import type { MotionLevel } from "@/lib/motion/motion-preference"; +import { APPEARANCE_EVENT } from "@/lib/appearance/appearance-core"; import { createHiveViewStore, HiveViewProvider } from "../use-hive-view"; import type { HiveViewStore } from "../use-hive-view"; import { dropSlots } from "../editor/drop-slots"; @@ -105,6 +106,7 @@ async function renderScene(motion: MotionLevel = "full", editor?: SceneEditor): afterEach(async () => { await current?.renderer.unmount(); current = null; + document.documentElement.removeAttribute("data-contrast"); }); const hit = (renderer: Rendered["renderer"], id: string): ReactThreeTest.ReactThreeTestInstance => @@ -277,6 +279,44 @@ describe("HiveSceneContent", () => { }); }); +/** Edge materials of the mounted scene (they carry the travelling pulse). */ +const edgeMaterials = (renderer: Rendered["renderer"]): ShaderMaterial[] => + renderer.scene + .findAll((node) => (node.instance as unknown as Mesh).material instanceof ShaderMaterial) + .map((node) => (node.instance as unknown as Mesh).material as ShaderMaterial) + .filter((material) => "uPulse" in material.uniforms); + +describe("HiveSceneContent and the appearance", () => { + it("the scene re-reads the holo tokens when the appearance changes", async () => { + const { renderer } = await renderScene(); + const before = edgeMaterials(renderer); + expect(before.length).toBeGreaterThan(0); + expect(before.every((material) => material.uniforms.uSun.value === 0)).toBe(true); + + await act(async () => { + document.documentElement.setAttribute("data-contrast", "sun"); + window.dispatchEvent(new Event(APPEARANCE_EVENT)); + }); + const after = edgeMaterials(renderer); + expect(after).not.toContain(before[0]); + expect(after.every((material) => material.uniforms.uSun.value === 1)).toBe(true); + }); + + it("sun draws white edges as a 2 px line", async () => { + document.documentElement.setAttribute("data-contrast", "sun"); + const { renderer } = await renderScene(); + await renderer.advanceFrames(1, 1 / 60); + for (const material of edgeMaterials(renderer)) { + expect(material.uniforms.uSun.value).toBe(1); + expect(material.uniforms.uCyan.value.getHex()).toBe(0xffffff); + expect(material.uniforms.uHot.value.getHex()).toBe(0xffffff); + expect(material.uniforms.uLinePixels.value).toBe(2); + // The line width is in screen pixels: the shader needs the canvas height. + expect(material.uniforms.uViewHeight.value).toBeGreaterThan(0); + } + }); +}); + describe("HiveSceneContent in the editor", () => { const editorOf = (extra: Partial = {}): SceneEditor => ({ drag: null, @@ -337,6 +377,49 @@ describe("HiveSceneContent in the editor", () => { }); }); +describe("HiveSceneContent in sun: state cues without colour", () => { + it("a hovered part draws its edges 3 px, the others 2 px", async () => { + document.documentElement.setAttribute("data-contrast", "sun"); + const { renderer, store } = await renderScene("off"); + await act(async () => store.setHovered("b1")); + await renderer.advanceFrames(2, 1 / 60); + const pixelsOf = (id: string) => + hit(renderer, id) + .parent!.children.map((node) => (node.instance as unknown as Mesh).material) + .filter((material): material is ShaderMaterial => material instanceof ShaderMaterial && "uPulse" in material.uniforms) + .map((material) => material.uniforms.uLinePixels.value); + expect(pixelsOf("b1")).toContain(3); + expect(pixelsOf("fe")).toEqual(pixelsOf("fe").map(() => 2)); + }); + + it("idle drop slots are grey 2 px on the tube they are built from; the hovered slot is white 3 px", async () => { + document.documentElement.setAttribute("data-contrast", "sun"); + const state = hiveState(); + const slots = dropSlots(state, catalog, { partTypeId: "box" }); + const editor: SceneEditor = { + drag: { source: { partTypeId: "box" }, slots, hover: slots[0] }, + pointer: { current: null }, + subscribePointer: () => () => {}, + onHoverSlot: vi.fn(), + onPartPress: vi.fn(), + zoom: 1, + onZoom: vi.fn(), + }; + const { renderer } = await renderScene("off", editor); + await renderer.advanceFrames(1, 1 / 60); + const markers = renderer.scene + .findAll((node) => node.props.name === "drop-slot") + .map((node) => (node.instance as unknown as Mesh).material as ShaderMaterial); + const idle = markers.filter((material) => material.uniforms.uSunGrey.value === 1); + const active = markers.filter((material) => material.uniforms.uSunGrey.value === 0); + expect(idle.length).toBe(slots.length - 1); + expect(active).toHaveLength(1); + expect(active[0].uniforms.uLinePixels.value).toBe(3); + expect(idle[0].uniforms.uLinePixels.value).toBe(2); + for (const material of markers) expect(material.uniforms.uTubeCorner.value).toBeCloseTo(0.015 * Math.SQRT2, 9); + }); +}); + describe("HiveSceneContent drop anchors", () => { it("computes the slot anchors and motion targets once per drag, not again on every hover change", async () => { const state = hiveState(); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.spec.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.spec.tsx index 70b9205..17becee 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.spec.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.spec.tsx @@ -1,8 +1,10 @@ import ReactThreeTestRenderer from "@react-three/test-renderer"; import { act, forwardRef, useEffect, useImperativeHandle } from "react"; import type { ReactNode } from "react"; -import { WebGLRenderTarget } from "three"; +import { Color, WebGLRenderTarget } from "three"; +import type { Light, Scene } from "three"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { APPEARANCE_EVENT } from "@/lib/appearance/appearance-core"; import { createHiveViewStore, HiveViewProvider } from "../use-hive-view"; import type { HiveViewStore } from "../use-hive-view"; import { HiveStage } from "./hive-stage"; @@ -28,9 +30,9 @@ vi.mock("@react-three/postprocessing", () => ({ }); return ; }), - Bloom: () => null, - ToneMapping: () => null, - Vignette: () => null, + Bloom: () => , + ToneMapping: () => , + Vignette: () => , })); // The test renderer's mock context cannot render into targets: the mirror pass is skipped. @@ -49,6 +51,7 @@ let renderer: Awaited> | null = afterEach(async () => { await renderer?.unmount(); renderer = null; + document.documentElement.removeAttribute("data-contrast"); vi.restoreAllMocks(); }); @@ -120,3 +123,60 @@ describe("HiveStage reflective floor", () => { expect(dispose.mock.calls.length - afterMount).toBeGreaterThanOrEqual(4); }); }); + +const named = (name: string) => renderer!.scene.findAll((node) => node.props.name === name).length; +const isBlack = (value: unknown) => value instanceof Color && value.getHex() === 0x000000; + +describe("HiveStage in sun", () => { + it("sun turns bloom off: no bloom, no vignette, no haze or particles, a plain black stage", async () => { + document.documentElement.setAttribute("data-contrast", "sun"); + renderer = await ReactThreeTestRenderer.create(stage(createHiveViewStore(), "high", false)); + expect(named("bloom")).toBe(0); + expect(named("vignette")).toBe(0); + expect(named("tone-mapping")).toBe(1); + expect(named("stage-haze")).toBe(0); + expect(named("stage-particles")).toBe(0); + expect(isBlack((renderer.scene.instance as Scene).background)).toBe(true); + }); + + it("sun draws no mirror floor and no blue hex grid, and lights the hive in neutral white", async () => { + renderer = await ReactThreeTestRenderer.create(stage(createHiveViewStore(), "high", false)); + expect(named("stage-floor-mirror")).toBe(1); + expect(named("stage-floor-grid")).toBe(1); + await renderer.unmount(); + + document.documentElement.setAttribute("data-contrast", "sun"); + for (const tier of ["high", "low"] as const) { + renderer = await ReactThreeTestRenderer.create(stage(createHiveViewStore(), tier, false)); + expect(named("stage-floor-mirror")).toBe(0); + expect(named("stage-floor-grid")).toBe(0); + const lights = renderer.scene.findAll((node) => (node.instance as unknown as Light).isLight === true); + expect(lights.length).toBeGreaterThan(0); + for (const light of lights) { + const { r, g, b } = (light.instance as unknown as Light).color; + expect(Math.max(r, g, b) - Math.min(r, g, b)).toBeLessThan(1e-6); + } + await renderer.unmount(); + } + renderer = null; + }); + + it("switches bloom off and on live when the appearance changes", async () => { + renderer = await ReactThreeTestRenderer.create(stage(createHiveViewStore(), "high", false)); + expect(named("bloom")).toBe(1); + expect(named("stage-haze")).toBe(1); + + await act(async () => { + document.documentElement.setAttribute("data-contrast", "sun"); + window.dispatchEvent(new Event(APPEARANCE_EVENT)); + }); + expect(named("bloom")).toBe(0); + expect(isBlack((renderer.scene.instance as Scene).background)).toBe(true); + + await act(async () => { + document.documentElement.setAttribute("data-contrast", "normal"); + window.dispatchEvent(new Event(APPEARANCE_EVENT)); + }); + expect(named("bloom")).toBe(1); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.tsx index 6a0fb1b..a417815 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.tsx @@ -29,7 +29,8 @@ import type { HoloTheme } from "./theme"; // The holo stage (spec 9.3, mockups g2/h2): deep midnight backdrop with a // cool light from behind, glossy dark floor with a faint hexagon grid, a few // slow bokeh particles, bloom at half resolution, vignette. Cool light only: -// warm colours belong to the content (owner 2026-10-16). +// warm colours belong to the content (owner 2026-10-16). In sun the stage is +// plain black without bloom, vignette, haze and particles (spec 9.3, T5 17.8). /** Objects on this layer are seen by the camera but not by the floor reflection. */ const OVERLAY_LAYER = 1; @@ -94,7 +95,7 @@ function Backdrop() { const { theme } = useSceneKit(); const scene = useThree((state) => state.scene); useEffect(() => { - const texture = backdropTexture(theme); + const texture = theme.sun ? null : backdropTexture(theme); setSceneBackground(scene, texture ?? theme.background); return () => { setSceneBackground(scene, null); @@ -197,7 +198,7 @@ function ReflectiveFloor() { if (surface.current) reflector.render(gl, scene, camera, surface.current); }); return ( - + ); @@ -229,7 +230,7 @@ function Floor({ tier }: { tier: StageTier }) { if (tier === "low") { return ( - + ); @@ -239,6 +240,7 @@ function Floor({ tier }: { tier: StageTier }) { {/* The grid and the distance fade sit on a layer the reflection camera does not see. */} ; + return ; } function Lights() { const { theme } = useSceneKit(); return ( <> - + {/* Sun: neutral white light only (theme.rim is grey there). */} + @@ -382,7 +385,7 @@ function useFocusMode(): boolean { } function Effects({ tier, depthOfField }: { tier: StageTier; depthOfField: boolean }) { - const { signals } = useSceneKit(); + const { signals, theme } = useSceneKit(); const dof = useRef(null); // Depth of field runs its seven passes every frame even with no blur, so it is mounted only in // focus mode. Entering focus rebuilds the composer once (one shader compile as the flight starts); @@ -402,16 +405,21 @@ function Effects({ tier, depthOfField }: { tier: StageTier; depthOfField: boolea ) : ( <> )} - + {/* Sun: no glow around the white edges, no darkened corners on the black stage. */} + {theme.sun ? ( + <> + ) : ( + + )} - + {theme.sun ? <> : } ); } @@ -462,6 +470,7 @@ function Haze() { useEffect(() => () => material.dispose(), [material]); return ( - - - {tier === "high" ? : null} + {/* Sun: the black stage has no floor (no blue grid, no mirror of the white edges). */} + {theme.sun ? null : } + {theme.sun ? null : } + {tier === "high" && !theme.sun ? : null} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/materials.spec.ts b/ApiCore-Frontend/src/components/hive/hive-scene/materials.spec.ts new file mode 100644 index 0000000..8d23107 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/materials.spec.ts @@ -0,0 +1,62 @@ +import { CanvasTexture, MeshBasicMaterial, MeshStandardMaterial } from "three"; +import { afterEach, describe, expect, it } from "vitest"; +import { frameTypeMaterial } from "./frame-plate"; +import { SUN_EDGE_PIXELS, SUN_STRONG_EDGE_PIXELS, createEdgeMaterial, setEdgeIntensity, sunEdgePixels } from "./materials"; +import { EDGE_TUBE_CORNER } from "./part-geometry"; +import { readHoloTheme } from "./theme"; + +afterEach(() => document.documentElement.removeAttribute("data-contrast")); + +const sunTheme = () => readHoloTheme("sun-light"); + +describe("createEdgeMaterial", () => { + it("finds the axis of the default tube and of a wider one (drop slots)", () => { + expect(createEdgeMaterial(sunTheme()).uniforms.uTubeCorner.value).toBeCloseTo(EDGE_TUBE_CORNER, 9); + expect(createEdgeMaterial(sunTheme(), { tubeWidth: 0.03 }).uniforms.uTubeCorner.value).toBeCloseTo( + 0.015 * Math.SQRT2, + 9, + ); + }); + + it("draws quiet lines in detail grey in sun when asked (idle drop slots)", () => { + expect(createEdgeMaterial(sunTheme()).uniforms.uSunGrey.value).toBe(0); + expect(createEdgeMaterial(sunTheme(), { sunGrey: true }).uniforms.uSunGrey.value).toBe(1); + }); +}); + +describe("sun edge state", () => { + it("draws a strong state (hover, lifted frame, active slot) 3 px wide and the rest 2 px", () => { + expect(sunEdgePixels(1.25)).toBe(SUN_EDGE_PIXELS); + expect(sunEdgePixels(1.4)).toBe(SUN_EDGE_PIXELS); + expect(sunEdgePixels(1.875)).toBe(SUN_EDGE_PIXELS); + expect(sunEdgePixels(2.21)).toBe(SUN_STRONG_EDGE_PIXELS); + expect(sunEdgePixels(2.875)).toBe(SUN_STRONG_EDGE_PIXELS); + expect(SUN_STRONG_EDGE_PIXELS).toBe(3); + }); + + it("setEdgeIntensity sets the glow and the sun line width together", () => { + const material = createEdgeMaterial(sunTheme()); + setEdgeIntensity(material, 3.2); + expect(material.uniforms.uIntensity.value).toBe(3.2); + expect(material.uniforms.uLinePixels.value).toBe(3); + setEdgeIntensity(material, 1.3); + expect(material.uniforms.uLinePixels.value).toBe(2); + }); +}); + +describe("frameTypeMaterial", () => { + const texture = new CanvasTexture(document.createElement("canvas")); + + it("draws the sun hatch unlit and brighter than 1, so white survives the tone mapping", () => { + const material = frameTypeMaterial(texture, true, "BROOD"); + expect(material).toBeInstanceOf(MeshBasicMaterial); + const { r, g, b } = (material as MeshBasicMaterial).color; + expect(Math.min(r, g, b)).toBeGreaterThanOrEqual(5); + expect(r).toBe(g); + expect(g).toBe(b); + }); + + it("keeps the lit comb outside sun", () => { + expect(frameTypeMaterial(texture, false, "HONEY")).toBeInstanceOf(MeshStandardMaterial); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/materials.ts b/ApiCore-Frontend/src/components/hive/hive-scene/materials.ts index 18fd464..784002c 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/materials.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/materials.ts @@ -1,5 +1,6 @@ import { AdditiveBlending, Color, DoubleSide, NormalBlending, ShaderMaterial } from "three"; import type { CubeTexture } from "three"; +import { EDGE_TUBE_CORNER, edgeTubeCorner } from "./part-geometry"; import type { HoloTheme } from "./theme"; // Hand-written shaders instead of transmission. The glass samples a small @@ -132,11 +133,42 @@ export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {} }); } +/** Edge width in sun, in CSS pixels (Beute spec 9.3). */ +export const SUN_EDGE_PIXELS = 2; +/** Sun has no glow: a strong state (hover, lifted frame, active drop slot) draws a wider line. */ +export const SUN_STRONG_EDGE_PIXELS = 3; +/** + * Edge intensity from which a state counts as strong. Rest and focus stay below + * (stack part 1.25 / 1.875, frame 1.3, idle slot 1.4); hover 2.875, lifted + * frame 2.21, active slot 3.2 and the drop preview 3 lie above. + */ +const SUN_STRONG_INTENSITY = 2; + +/** Line width in sun for an edge intensity. */ +export function sunEdgePixels(intensity: number): number { + return intensity >= SUN_STRONG_INTENSITY ? SUN_STRONG_EDGE_PIXELS : SUN_EDGE_PIXELS; +} + +/** Sets the glow of an edge material and, for sun, its line width (no allocation, frame-safe). */ +export function setEdgeIntensity(material: ShaderMaterial, intensity: number): void { + const { uIntensity, uLinePixels } = material.uniforms; + if (uIntensity) uIntensity.value = intensity; + if (uLinePixels) uLinePixels.value = sunEdgePixels(intensity); +} +/** Above the neutral tone mapping's knee, so a sun edge comes out pure white. */ +const SUN_EDGE_GAIN = 6; +/** Detail lines (rebates, grips, frame hints) stay a quiet grey in sun, so the outline leads. */ +const SUN_DETAIL_LEVEL = 0.35; + const EDGE_VERTEX = /* glsl */ ` attribute float aHot; attribute float aStrength; attribute float aAlong; attribute float aPhase; +uniform float uSun; +uniform float uTubeCorner; +uniform float uLinePixels; +uniform float uViewHeight; varying float vHot; varying float vStrength; varying float vAlong; @@ -148,6 +180,13 @@ void main() { vAlong = aAlong; vPhase = aPhase; vec4 view = modelViewMatrix * vec4(position, 1.0); + if (uSun > 0.5) { + // Sun: a line of uLinePixels on screen at any distance. Each vertex sits uTubeCorner + // from the edge's axis along its normal; the tube is rebuilt around that axis in pixels. + vec4 axis = modelViewMatrix * vec4(position - normal * uTubeCorner, 1.0); + float pixel = 2.0 * max(-axis.z, 1e-3) / (projectionMatrix[1][1] * max(uViewHeight, 1.0)); + view = vec4(axis.xyz + normalize(view.xyz - axis.xyz) * pixel * uLinePixels * 0.70710678, 1.0); + } // Tube normal in view space: faces toward the camera form the white-hot core. vec3 normalView = normalize(normalMatrix * normal); vFacing = abs(dot(normalView, normalize(-view.xyz))); @@ -162,12 +201,21 @@ uniform float uIntensity; uniform float uOpacity; uniform float uTime; uniform float uPulse; +uniform float uSun; +uniform float uSunGrey; varying float vHot; varying float vStrength; varying float vAlong; varying float vPhase; varying float vFacing; void main() { + if (uSun > 0.5) { + // Sun: flat white outlines (the sun tokens), bright enough to stay white through the tone + // mapping, detail lines grey; ghosts fade quadratically so the shadow of the hive stays faint. + float level = vStrength >= 0.5 && uSunGrey < 0.5 ? ${SUN_EDGE_GAIN.toFixed(2)} : ${SUN_DETAIL_LEVEL.toFixed(2)}; + gl_FragColor = vec4(uCyan * level * uOpacity * uOpacity, 1.0); + return; + } float hot = vHot * vHot; float core = pow(vFacing, 4.0); vec3 color = mix(uCyan, uHot, clamp(max(hot, core * 0.75), 0.0, 1.0)); @@ -179,16 +227,32 @@ void main() { } `; -/** Glowing edges: additive, white-hot core and corners, electric cyan rim, slow pulses at `full`. */ -export function createEdgeMaterial(theme: HoloTheme): ShaderMaterial { +/** + * Glowing edges: additive, white-hot core and corners, electric cyan rim, slow pulses at `full`. + * In sun (Beute spec 9.3): flat white lines of `SUN_EDGE_PIXELS` on screen, no glow, no pulse. + */ +export interface EdgeOptions { + /** Width the edge geometry was built with, when not the default (drop slots). */ + tubeWidth?: number; + /** Sun: draw every line in detail grey (idle drop slots), so the active one stands out. */ + sunGrey?: boolean; +} + +export function createEdgeMaterial(theme: HoloTheme, options: EdgeOptions = {}): ShaderMaterial { return new ShaderMaterial({ uniforms: { uCyan: { value: theme.edge.clone() }, - uHot: { value: theme.hot.clone() }, + uHot: { value: theme.sun ? theme.edge.clone() : theme.hot.clone() }, uIntensity: { value: 1.6 }, uOpacity: { value: 1 }, uTime: { value: 0 }, uPulse: { value: 0 }, + uSun: { value: theme.sun ? 1 : 0 }, + uSunGrey: { value: options.sunGrey ? 1 : 0 }, + uTubeCorner: { value: options.tubeWidth === undefined ? EDGE_TUBE_CORNER : edgeTubeCorner(options.tubeWidth) }, + uLinePixels: { value: SUN_EDGE_PIXELS }, + // Canvas height in CSS pixels, kept current by useEdgeViewport. + uViewHeight: { value: 1 }, }, vertexShader: EDGE_VERTEX, fragmentShader: EDGE_FRAGMENT, diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/part-geometry.ts b/ApiCore-Frontend/src/components/hive/hive-scene/part-geometry.ts index fc026e9..a0ff215 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/part-geometry.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/part-geometry.ts @@ -503,6 +503,12 @@ export function buildGlassGeometry(slabs: Slab[]): BufferGeometry { /** About 1.5 px at rest-view distance, thicker when a part is in focus. */ const EDGE_WIDTH = 0.024; +/** Distance from an edge's axis to each vertex of a tube of `width` (the sun shader finds the axis with it). */ +export function edgeTubeCorner(width: number): number { + return (width / 2) * Math.SQRT2; +} +/** Tube corner of the default edge width. */ +export const EDGE_TUBE_CORNER = edgeTubeCorner(EDGE_WIDTH); /** Length of the white-hot zone at each corner. */ const HOT_ZONE = 0.22; diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/scene-kit.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/scene-kit.tsx index 79f30a7..718f9fc 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/scene-kit.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/scene-kit.tsx @@ -13,13 +13,13 @@ import { createGeometryCache } from "./part-geometry"; import type { GeometryCache } from "./part-geometry"; import type { StageTier } from "./quality"; import { createStudioEnvironment } from "./scene-env"; -import { readHoloTheme } from "./theme"; +import { useHoloTheme } from "./theme"; import type { HoloTheme } from "./theme"; -// Shared per canvas: theme (tokens read once), geometries per part kind and -// size, comb textures per frame type, the studio cube map, motion level and -// quality tier. Everything is disposed when the canvas unmounts (spec 9.9: -// no leak between colonies). +// Shared per canvas: theme (tokens re-read on each appearance change, T5 spec +// 17.8), geometries per part kind and size, comb textures per frame type, the +// studio cube map, motion level and quality tier. Everything is disposed when +// the canvas unmounts (spec 9.9: no leak between colonies). /** Mutable per-frame hand-offs between scene parts (never drive rendering on their own). */ export interface SceneSignals { @@ -55,7 +55,8 @@ export interface SceneKitProviderProps { export function SceneKitProvider({ motion, tier = "high", children }: SceneKitProviderProps) { const gl = useThree((state) => state.gl); const invalidate = useThree((state) => state.invalidate); - const [theme] = useState(readHoloTheme); + // A new theme rebuilds the materials that use it (a rare event, not per frame). + const theme = useHoloTheme(invalidate); const [geometries] = useState(createGeometryCache); const [textures] = useState(() => createFrameTextureCache()); const [signals] = useState(() => ({ diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/stack-part.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/stack-part.tsx index e3595da..cbefb92 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/stack-part.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/stack-part.tsx @@ -10,7 +10,7 @@ import type { FrameBox, Vec3 } from "@/lib/hive/layout"; import { FillVolume } from "./fill-volume"; import { FrameHints } from "./frame-plate"; import { HoloLabel } from "./hive-labels"; -import { createEdgeMaterial, createGlassMaterial, createGridMaterial } from "./materials"; +import { createEdgeMaterial, createGlassMaterial, createGridMaterial, setEdgeIntensity } from "./materials"; import { evaporatorBottle, feederChambers, partShapeSpec, pocketInner, shapeKey } from "./part-geometry"; import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit"; import { setUniform, setUniformColor } from "./scene-mutations"; @@ -81,7 +81,7 @@ export function StackPart({ part, target, focused, handlers, labels, hintFrames, setUniform(glass, "uGlow", glow); // Ghost edges keep a little more presence than the faces, so the outline of the hive reads. setUniform(edges, "uOpacity", Math.min(1, opacity * 1.6)); - setUniform(edges, "uIntensity", EDGE_INTENSITY * glow); + setEdgeIntensity(edges, EDGE_INTENSITY * glow); setUniformColor(edges, "uCyan", glow > 1.2 ? theme.edgeStrong : theme.edge); grids.forEach((material) => setUniform(material, "uOpacity", opacity)); }; diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/theme.spec.ts b/ApiCore-Frontend/src/components/hive/hive-scene/theme.spec.ts new file mode 100644 index 0000000..516a4d1 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/theme.spec.ts @@ -0,0 +1,92 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { APPEARANCE_EVENT } from "@/lib/appearance/appearance-core"; +import { readHoloTheme, useHoloTheme } from "./theme"; + +const root = document.documentElement; + +function setAppearance({ dark, sun }: { dark: boolean; sun: boolean }) { + root.classList.toggle("dark", dark); + root.setAttribute("data-contrast", sun ? "sun" : "normal"); +} + +afterEach(() => { + root.classList.remove("dark"); + root.removeAttribute("data-contrast"); + vi.restoreAllMocks(); +}); + +describe("readHoloTheme", () => { + it("reads the tokens on every call, not once", () => { + const style = vi.spyOn(window, "getComputedStyle"); + readHoloTheme(); + const first = style.mock.calls.length; + expect(first).toBeGreaterThan(0); + readHoloTheme(); + expect(style.mock.calls.length).toBe(first * 2); + }); + + it("knows sun from the document and falls back to a black stage with white edges there", () => { + setAppearance({ dark: false, sun: true }); + const theme = readHoloTheme(); + expect(theme.sun).toBe(true); + // jsdom has no 2D canvas: the sun fallbacks stand in for the sun tokens. + expect(theme.background.getHex()).toBe(0x000000); + expect(theme.edge.getHex()).toBe(0xffffff); + expect(theme.edgeStrong.getHex()).toBe(0xffffff); + + setAppearance({ dark: false, sun: false }); + const normal = readHoloTheme(); + expect(normal.sun).toBe(false); + expect(normal.edge.getHex()).not.toBe(0xffffff); + }); +}); + +describe("readHoloTheme in sun", () => { + it("has no chromatic stage colour: stage, edges, glass, lights and floor are neutral", () => { + const theme = readHoloTheme("sun-dark"); + for (const key of ["background", "edge", "edgeStrong", "hot", "tint", "rim", "bounce", "grid"] as const) { + const { r, g, b } = theme[key]; + expect(Math.max(r, g, b) - Math.min(r, g, b), key).toBeLessThan(1e-6); + } + }); +}); + +describe("useHoloTheme", () => { + it("re-reads the tokens when the appearance event fires after a change", async () => { + const { result } = renderHook(() => useHoloTheme()); + const before = result.current; + expect(before.sun).toBe(false); + + await act(async () => { + setAppearance({ dark: false, sun: true }); + window.dispatchEvent(new Event(APPEARANCE_EVENT)); + }); + expect(result.current).not.toBe(before); + expect(result.current.sun).toBe(true); + }); + + it("follows a change of the document made elsewhere (other tab, device setting)", async () => { + const { result } = renderHook(() => useHoloTheme()); + await act(async () => setAppearance({ dark: true, sun: true })); + expect(result.current.sun).toBe(true); + }); + + it("keeps the same theme between light and dark, whose holo tokens are the same (no material rebuild)", async () => { + const { result } = renderHook(() => useHoloTheme()); + const before = result.current; + await act(async () => { + setAppearance({ dark: true, sun: false }); + window.dispatchEvent(new Event(APPEARANCE_EVENT)); + }); + expect(result.current).toBe(before); + }); + + it("keeps the same theme while nothing changes (no material rebuild)", async () => { + const { result, rerender } = renderHook(() => useHoloTheme()); + const before = result.current; + rerender(); + await act(async () => window.dispatchEvent(new Event(APPEARANCE_EVENT))); + expect(result.current).toBe(before); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/theme.ts b/ApiCore-Frontend/src/components/hive/hive-scene/theme.ts index 930da68..15bbea6 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/theme.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/theme.ts @@ -1,13 +1,18 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; import { Color } from "three"; +import { APPEARANCE_EVENT } from "@/lib/appearance/appearance-core"; -// Scene colours. The holo tokens (spec 9.3) are read once from :root; the -// material colours (honey, wax, white-hot corners) are fixed design values. -// Lighting stays cool blue/cyan and white; warm colours only appear as -// content (feed in focus, lifted frame looks). +// Scene colours. The holo tokens (spec 9.3) are read from :root whenever the +// appearance changes (T5 spec 17.8: sun swaps them live); the material colours +// (honey, wax, white-hot corners) are fixed design values. Lighting stays cool +// blue/cyan and white; warm colours only appear as content (feed in focus, +// lifted frame looks). export interface HoloTheme { + /** Sun is on: black stage, white 2 px edges, no bloom, hatches instead of tints. */ + sun: boolean; background: Color; - /** Electric cyan along the edges. */ + /** Electric cyan along the edges (white in sun). */ edge: Color; /** Brighter edge of a hovered or focused part. */ edgeStrong: Color; @@ -27,46 +32,132 @@ export interface HoloTheme { grid: Color; } -const FALLBACKS = { - "--holo-bg": "#0b1224", - "--holo-edge": "#33c2f0", - "--holo-edge-strong": "#a9dcfd", -} as const; +/** What the document shows; the tokens differ per set (the holo ones only between sun and not). */ +export type HoloAppearance = "light" | "dark" | "sun-light" | "sun-dark"; -type TokenName = keyof typeof FALLBACKS; +type TokenName = "--holo-bg" | "--holo-edge" | "--holo-edge-strong"; -/** - * Resolves a CSS colour token (the holo tokens are oklch) to a hex string - * three.js understands, by letting a 2D canvas convert it. Falls back when - * there is no 2D context (jsdom) or the token is missing. - */ -export function resolveCssColor(property: TokenName): string { - const fallback = FALLBACKS[property]; - if (typeof document === "undefined") return fallback; - const value = getComputedStyle(document.documentElement).getPropertyValue(property).trim(); - if (!value) return fallback; - const context = document.createElement("canvas").getContext("2d"); - if (!context) return fallback; - context.fillStyle = value; - context.fillRect(0, 0, 1, 1); - const [r, g, b] = context.getImageData(0, 0, 1, 1).data; - return `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; +/** Used where a token cannot be resolved (no 2D canvas, token missing); the sun set mirrors globals.css. */ +const FALLBACKS: Record<"normal" | "sun", Record> = { + normal: { "--holo-bg": "#0b1224", "--holo-edge": "#33c2f0", "--holo-edge-strong": "#a9dcfd" }, + sun: { "--holo-bg": "#000000", "--holo-edge": "#ffffff", "--holo-edge-strong": "#ffffff" }, +}; + +const SERVER_APPEARANCE: HoloAppearance = "light"; + +/** The appearance the document shows right now (set by the inline script and useAppearance). */ +export function currentHoloAppearance(): HoloAppearance { + if (typeof document === "undefined") return SERVER_APPEARANCE; + const root = document.documentElement; + const scheme = root.classList.contains("dark") ? "dark" : "light"; + return root.getAttribute("data-contrast") === "sun" ? `sun-${scheme}` : scheme; } -/** Reads the tokens; call once per canvas (useState initialiser). */ -export function readHoloTheme(): HoloTheme { +const toHex = (channel: number) => channel.toString(16).padStart(2, "0"); + +/** + * Resolves CSS colour tokens (the holo tokens are oklch) to hex strings three.js + * understands, by letting one 2D canvas convert them. Falls back per token when + * there is no 2D context (jsdom) or the token is missing. + */ +function resolveCssColors(names: readonly TokenName[], fallbacks: Record): Record { + const resolved = { ...fallbacks }; + if (typeof document === "undefined") return resolved; + const style = getComputedStyle(document.documentElement); + const values = names + .map((name) => [name, style.getPropertyValue(name).trim()] as const) + .filter(([, value]) => value !== ""); + if (values.length === 0) return resolved; + const context = document.createElement("canvas").getContext("2d", { willReadFrequently: true }); + if (!context) return resolved; + for (const [name, value] of values) { + context.clearRect(0, 0, 1, 1); + context.fillStyle = value; + context.fillRect(0, 0, 1, 1); + const [r, g, b] = context.getImageData(0, 0, 1, 1).data; + resolved[name] = `#${toHex(r)}${toHex(g)}${toHex(b)}`; + } + return resolved; +} + +const TOKENS: readonly TokenName[] = ["--holo-bg", "--holo-edge", "--holo-edge-strong"]; + +type StageLight = Pick; + +/** Cool blue light of the holo stage. */ +const HOLO_LIGHT = { hot: "#f2fbff", tint: "#7fb8e8", rim: "#4aa8ff", bounce: "#9fd8ff", grid: "#2a6fd6" } as const; + +/** Sun: white on black, no hue anywhere on the stage (glass, lights, floor). */ +const SUN_LIGHT = { hot: "#ffffff", tint: "#8c8c8c", rim: "#d9d9d9", bounce: "#6e6e6e", grid: "#000000" } as const; + +/** Fresh colours per theme, so no theme shares a mutable Color with another. */ +function stageLight(sun: boolean): StageLight { + const hex = sun ? SUN_LIGHT : HOLO_LIGHT; return { - background: new Color(resolveCssColor("--holo-bg")), - edge: new Color(resolveCssColor("--holo-edge")), - edgeStrong: new Color(resolveCssColor("--holo-edge-strong")), - hot: new Color("#f2fbff"), - tint: new Color("#7fb8e8"), - rim: new Color("#4aa8ff"), - bounce: new Color("#9fd8ff"), + hot: new Color(hex.hot), + tint: new Color(hex.tint), + rim: new Color(hex.rim), + bounce: new Color(hex.bounce), + grid: new Color(hex.grid), + }; +} + +/** Reads the tokens anew on every call; the scene calls it once per appearance (useHoloTheme). */ +export function readHoloTheme(appearance: HoloAppearance = currentHoloAppearance()): HoloTheme { + const sun = appearance.startsWith("sun"); + const tokens = resolveCssColors(TOKENS, FALLBACKS[sun ? "sun" : "normal"]); + return { + sun, + background: new Color(tokens["--holo-bg"]), + edge: new Color(tokens["--holo-edge"]), + edgeStrong: new Color(tokens["--holo-edge-strong"]), + ...stageLight(sun), honey: new Color("#ffb21a"), honeyDeep: new Color("#8a3a00"), fondant: new Color("#f1d79a"), acid: new Color("#c8f4ff"), - grid: new Color("#2a6fd6"), }; } + +/** + * Changes of scheme or contrast: the appearance event (this tab) and the + * document attributes themselves (inline script, another tab, the device's + * setting, which useAppearance applies without the event). + */ +function subscribeAppearance(onChange: () => void): () => void { + window.addEventListener(APPEARANCE_EVENT, onChange); + const observer = typeof MutationObserver === "undefined" ? null : new MutationObserver(onChange); + observer?.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-contrast"] }); + return () => { + window.removeEventListener(APPEARANCE_EVENT, onChange); + observer?.disconnect(); + }; +} + +const serverAppearance = (): HoloAppearance => SERVER_APPEARANCE; + +const COLOR_KEYS = ["background", "edge", "edgeStrong", "hot", "tint", "rim", "bounce", "grid"] as const; + +function sameHoloTheme(a: HoloTheme, b: HoloTheme): boolean { + return a.sun === b.sun && COLOR_KEYS.every((key) => a[key].equals(b[key])); +} + +/** + * The holo theme of the current appearance. A new theme (and with it new + * materials) only when scheme or contrast change; never per frame. + */ +export function useHoloTheme(onChange?: () => void): HoloTheme { + const appearance = useSyncExternalStore(subscribeAppearance, currentHoloAppearance, serverAppearance); + // Light and dark share the holo tokens: a switch between them keeps the theme (no rebuild). + const [current, setCurrent] = useState(() => ({ appearance, theme: readHoloTheme(appearance) })); + if (current.appearance !== appearance) { + const next = readHoloTheme(appearance); + setCurrent({ appearance, theme: sameHoloTheme(next, current.theme) ? current.theme : next }); + } + const { theme } = current; + // Frames are drawn on demand: ask for one with the new colours. + useEffect(() => { + onChange?.(); + }, [theme, onChange]); + return theme; +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/use-scene-effects.ts b/ApiCore-Frontend/src/components/hive/hive-scene/use-scene-effects.ts index 596cf45..7bdc9ad 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/use-scene-effects.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/use-scene-effects.ts @@ -21,7 +21,9 @@ export const SWEEP_SECONDS = 1.1; * material instead of rebuilding and recompiling them. */ export function useGlassEnvironment(material: ShaderMaterial, dispersive: boolean): void { - const { env, tier } = useSceneKit(); + const { env: studio, tier, theme } = useSceneKit(); + // Sun: the blue studio stays out of the glass; it reflects the neutral analytic studio instead. + const env = theme.sun ? null : studio; const invalidate = useThree((state) => state.invalidate); useLayoutEffect(() => { setGlassEnvironment(material, env, dispersive && tier === "high" ? GLASS_DISPERSION : 0); @@ -29,7 +31,7 @@ export function useGlassEnvironment(material: ShaderMaterial, dispersive: boolea }, [material, env, tier, dispersive, invalidate]); } -/** Travelling light pulses along the edges. */ +/** Travelling light pulses along the edges (and the canvas height the sun line width needs). */ export function useLivingEdges(materials: ShaderMaterial[]): void { const { motion } = useSceneKit(); useFrame((state) => { @@ -37,10 +39,18 @@ export function useLivingEdges(materials: ShaderMaterial[]): void { for (const material of materials) { setUniform(material, "uTime", state.clock.elapsedTime); setUniform(material, "uPulse", pulse); + setUniform(material, "uViewHeight", state.size.height); } }); } +/** Edges without pulses (editor drop targets): only the canvas height for the sun line width. */ +export function useEdgeViewport(materials: ShaderMaterial[]): void { + useFrame((state) => { + for (const material of materials) setUniform(material, "uViewHeight", state.size.height); + }); +} + /** A holographic shimmer and a thin scan line run once across a part when it becomes hovered. */ export function useHoverSweep(material: ShaderMaterial, active: boolean, height: number): void { const { motion } = useSceneKit(); diff --git a/ApiCore-Frontend/src/components/hive/holo-stage.spec.ts b/ApiCore-Frontend/src/components/hive/holo-stage.spec.ts index 7f6164d..6890c5d 100644 --- a/ApiCore-Frontend/src/components/hive/holo-stage.spec.ts +++ b/ApiCore-Frontend/src/components/hive/holo-stage.spec.ts @@ -1,6 +1,8 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { GLASS_BUTTON, GLASS_PILL, GLASS_SURFACE } from "./hive-glass"; +import { HOLO_LABEL_PILL } from "./hive-scene/hive-labels"; const SRC = join(__dirname, "..", ".."); const read = (relative: string) => readFileSync(join(SRC, relative), "utf8"); @@ -26,3 +28,26 @@ describe("hive stage roots carry .holo-stage", () => { expect(read("components/hive/editor/editor-stack-list.tsx")).not.toMatch(/outline-holo-edge-strong/); }); }); + +/** Beute spec 9.3 / T5 spec 17.6: in sun the labels grow to 16 px and nothing on the stage blurs. */ +describe("the stage in sun", () => { + const classes = (value: string) => value.split(/\s+/); + + it("labels are 16 px and without backdrop blur in sun", () => { + const label = classes(HOLO_LABEL_PILL); + expect(label).toContain("sun:text-base"); + expect(label).toContain("sun:backdrop-blur-none"); + // A solid plate instead of the see-through glass, without glow. + expect(label).toContain("sun:bg-holo-label-bg"); + expect(label).toContain("sun:shadow-none"); + }); + + it.each([ + ["GLASS_SURFACE", GLASS_SURFACE], + ["GLASS_BUTTON", GLASS_BUTTON], + ["GLASS_PILL", GLASS_PILL], + ])("%s drops blur and glow in sun", (_name, value) => { + expect(classes(value)).toContain("sun:backdrop-blur-none"); + expect(classes(value)).toContain("sun:shadow-none"); + }); +});