mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
checkpoint(dashboard): mobile reflow + field richness + fake-data fixes + predict FSRS urgency
Verified-green pre-OS-shell checkpoint. Mobile portrait reflow (portraitAdapt + MobileNav), desktop field-richness on 7 text organs, 4 fake-data fixes (settings version, TRIGGER BIRTH removed, predict real per-memory urgency, palace DIAG), timeline dim-backdrop, stats depth-floor, em-dash cleanup, per-tier launch-gate fill floors. check 0/0, 977 files. Cinema WIP isolated to stash. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0b16709fbe
commit
65595b9ffc
34 changed files with 3312 additions and 291 deletions
|
|
@ -65,12 +65,47 @@ const SETTLE_MS = 3200;
|
|||
// than a static field, so it gets an extended settle before we sample.
|
||||
const SLOW_ORGANS: Record<string, number> = { reasoning: 12_000 };
|
||||
|
||||
// The liveness bar: the mission requires every organ to be a full-bleed MOVING
|
||||
// field (fill >= 35% at full res). The gate samples a 96x96 decode of a real
|
||||
// compositor screenshot; a genuinely-filled field (60-89% at full res) reads
|
||||
// >= 35% here, while a sparse text-on-black organ reads < 18%. 30% cleanly
|
||||
// separates alive from sparse with headroom for settle/downsample variance.
|
||||
const MIN_FILL_PCT = 30;
|
||||
// The liveness bar. TWO tiers, matching the SHIPPED design direction (Sam's
|
||||
// Jul 2026 correction: "the field is COMPLETELY OVERPOWERING THE TEXT" — the
|
||||
// field must be a DIM, legible BACKDROP on text-heavy organs, and the hero
|
||||
// only on the visual organs).
|
||||
//
|
||||
// VISUAL organs (palace, graph, observatory, memories, activation, explore,
|
||||
// timeline, blackbox): the field IS the hero — it fills the screen. High bar.
|
||||
//
|
||||
// TEXT-HEAVY organs (stats, settings, reasoning, feed, schedule, importance,
|
||||
// contradictions, patterns, intentions, dreams, memory-prs, duplicates): the
|
||||
// field is a DIM breathing backdrop behind a reading well so the MSDF text
|
||||
// wins the contrast fight (each carries setIntensity ~0.18-0.26 + a reading
|
||||
// well). fill% legitimately reads LOW here BY DESIGN — a high fill would mean
|
||||
// the blinding-blob bug is back. The bar is still "lit + structured + moving,
|
||||
// not black", just a lower fill floor.
|
||||
//
|
||||
// The gate samples a 96x96 decode of a real compositor screenshot.
|
||||
const MIN_FILL_PCT_VISUAL = 30; // field is the hero → must be full-bleed
|
||||
const MIN_FILL_PCT_TEXT = 6; // dim backdrop by design → lit but not blob
|
||||
|
||||
// Organs where the field is the HERO (full-bleed expected). Everything else is
|
||||
// a text-heavy instrument with a dim backdrop.
|
||||
const VISUAL_ORGANS = new Set([
|
||||
'palace',
|
||||
'graph',
|
||||
'observatory',
|
||||
'memories',
|
||||
'activation',
|
||||
'explore',
|
||||
'timeline',
|
||||
'blackbox'
|
||||
]);
|
||||
|
||||
function minFillFor(label: string): number {
|
||||
// label is like "/stats" or "dived organ .../stats" or "palace hub" — match
|
||||
// the organ name loosely so both smoke and tour callers resolve correctly.
|
||||
for (const organ of VISUAL_ORGANS) {
|
||||
if (label.includes(organ)) return MIN_FILL_PCT_VISUAL;
|
||||
}
|
||||
return MIN_FILL_PCT_TEXT;
|
||||
}
|
||||
|
||||
/** Assert a genuinely-lit, MOVING, error-free field on the current route. */
|
||||
async function assertLiveField(
|
||||
|
|
@ -85,9 +120,11 @@ async function assertLiveField(
|
|||
const s = await sampleCanvas(page);
|
||||
expect(s.avgLum, `${label} avgLum (field must not be black)`).toBeGreaterThan(3);
|
||||
expect(s.variance, `${label} variance (field must have structure)`).toBeGreaterThan(6);
|
||||
expect(s.fillPct, `${label} fill% (field must be full-bleed, not text-on-black)`).toBeGreaterThan(
|
||||
MIN_FILL_PCT
|
||||
);
|
||||
const minFill = minFillFor(label);
|
||||
expect(
|
||||
s.fillPct,
|
||||
`${label} fill% (field must be lit + structured; visual organs full-bleed, text organs a dim backdrop)`
|
||||
).toBeGreaterThan(minFill);
|
||||
return { avgLum: s.avgLum, variance: s.variance, fillPct: s.fillPct };
|
||||
}
|
||||
|
||||
|
|
|
|||
207
apps/dashboard/src/lib/components/MobileNav.svelte
Normal file
207
apps/dashboard/src/lib/components/MobileNav.svelte
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
<script lang="ts">
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// MobileNav — the ONE reliable navigation surface for phones.
|
||||
//
|
||||
// The organs are zero-DOM WebGPU. Their in-canvas nav rail is a desktop
|
||||
// hover-to-expand affordance that CANNOT open on a touchscreen (no hover), and
|
||||
// it only exists on RouteStage organs — ObservatoryStage / direct-canvas organs
|
||||
// (observatory, graph, memories, explore, palace) have no in-canvas nav at all.
|
||||
// A phone user would be stranded. This DOM bar is rendered by the (app) shell so
|
||||
// EVERY organ — every stage type, and even devices with NO WebGPU — gets the
|
||||
// same tap-to-navigate bar. It shows ONLY on narrow/touch viewports, so the
|
||||
// desktop experience stays pure WebGPU with zero DOM chrome.
|
||||
//
|
||||
// Routes mirror the curated COGNITIVE_OS_ROUTES set (single source of truth
|
||||
// shared with the in-canvas rail + palace hero set).
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
import { base } from '$app/paths';
|
||||
import { page } from '$app/stores';
|
||||
import { COGNITIVE_OS_ROUTES } from '$lib/observatory/nav/nav-layer';
|
||||
|
||||
// Show only on coarse-pointer (touch) OR narrow viewports. Reactive to resize
|
||||
// and orientation so a desktop window shrunk narrow also gets the bar. Driven
|
||||
// entirely by matchMedia — nothing hardcoded to a device.
|
||||
let show = $state(false);
|
||||
let open = $state(false);
|
||||
|
||||
function evaluate() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const narrow = window.matchMedia('(max-width: 820px)').matches;
|
||||
const coarse = window.matchMedia('(pointer: coarse)').matches;
|
||||
const portrait = window.innerHeight > window.innerWidth;
|
||||
show = coarse || (narrow && portrait);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
evaluate();
|
||||
const onChange = () => evaluate();
|
||||
window.addEventListener('resize', onChange);
|
||||
window.addEventListener('orientationchange', onChange);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onChange);
|
||||
window.removeEventListener('orientationchange', onChange);
|
||||
};
|
||||
});
|
||||
|
||||
// Active-route detection against the curated set, honouring the base path.
|
||||
const activeHref = $derived.by(() => {
|
||||
const path = $page.url.pathname.replace(base, '') || '/';
|
||||
const match = COGNITIVE_OS_ROUTES.find((r) => path === r.href || path.endsWith(r.href));
|
||||
if (match) return match.href;
|
||||
if (path === '/' || path === '') return '/observatory';
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<!-- Floating pill that expands to the full organ list. Bottom-centre so it sits
|
||||
in the thumb zone and never overlaps the top-of-page instrument text. -->
|
||||
<nav class="mobile-nav" class:open aria-label="Organs">
|
||||
{#if open}
|
||||
<div class="sheet" role="menu">
|
||||
{#each COGNITIVE_OS_ROUTES as route (route.href)}
|
||||
<a
|
||||
class="row"
|
||||
class:active={route.href === activeHref}
|
||||
href={`${base}${route.href}`}
|
||||
role="menuitem"
|
||||
onclick={() => (open = false)}
|
||||
>
|
||||
<span class="key">{route.shortcut ?? route.label.charAt(0)}</span>
|
||||
<span class="label">{route.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class="fab"
|
||||
aria-expanded={open}
|
||||
aria-label={open ? 'Close navigation' : 'Open navigation'}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
{#if open}
|
||||
<span class="fab-x">esc</span>
|
||||
{:else}
|
||||
<span class="fab-key">{COGNITIVE_OS_ROUTES.find((r) => r.href === activeHref)?.shortcut ?? '≡'}</span>
|
||||
<span class="fab-word">{COGNITIVE_OS_ROUTES.find((r) => r.href === activeHref)?.label ?? 'Menu'}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.mobile-nav {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: max(1rem, env(safe-area-inset-bottom));
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mobile-nav > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.7rem 1.15rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0, 245, 212, 0.4);
|
||||
background: rgba(4, 6, 12, 0.82);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
color: #cfeee9;
|
||||
font: 600 0.95rem/1 ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
letter-spacing: 0.06em;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.55), 0 0 22px rgba(0, 245, 212, 0.14);
|
||||
cursor: pointer;
|
||||
}
|
||||
.fab-key {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border-radius: 0.4rem;
|
||||
background: rgba(0, 245, 212, 0.16);
|
||||
color: #00f5d4;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.fab-word {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.fab-x {
|
||||
text-transform: uppercase;
|
||||
color: #00f5d4;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
width: min(88vw, 24rem);
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.4rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid rgba(0, 245, 212, 0.22);
|
||||
background: rgba(4, 6, 12, 0.9);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border-radius: 0.7rem;
|
||||
color: #a9c7c2;
|
||||
text-decoration: none;
|
||||
font: 500 1rem/1 ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.row:active {
|
||||
background: rgba(0, 245, 212, 0.1);
|
||||
}
|
||||
.row.active {
|
||||
color: #00f5d4;
|
||||
background: rgba(0, 245, 212, 0.12);
|
||||
}
|
||||
.row .key {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.55rem;
|
||||
height: 1.55rem;
|
||||
border-radius: 0.45rem;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.row.active .key {
|
||||
background: rgba(0, 245, 212, 0.18);
|
||||
}
|
||||
.row .label {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.sheet {
|
||||
animation: rise 0.16s ease-out;
|
||||
}
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
* (Increment 3 gate, spec §4).
|
||||
*/
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { base } from '$app/paths';
|
||||
import { ObservatoryEngine, type EngineStatus } from '$lib/observatory/engine';
|
||||
import type { DemoMode } from '$lib/observatory/types';
|
||||
|
||||
|
|
@ -65,13 +66,19 @@
|
|||
></canvas>
|
||||
|
||||
{#if status.state === 'unsupported' || status.state === 'error'}
|
||||
<!-- Readable fallback — never a crash (spec §4 Increment 3 gate). -->
|
||||
<!-- Readable fallback — never a crash (spec §4 Increment 3 gate). Not a dead
|
||||
end: the classic Graph view is pure SVG and renders your REAL memory
|
||||
graph without WebGPU, so we route the user straight there. The DOM
|
||||
MobileNav (bottom FAB) also stays reachable, so navigation never breaks. -->
|
||||
<div class="fallback" role="alert">
|
||||
<div class="fallback-title">MEMORY FIELD OFFLINE</div>
|
||||
<div class="fallback-reason">{status.reason}</div>
|
||||
<div class="fallback-title">LIVE FIELD NEEDS WEBGPU</div>
|
||||
<div class="fallback-reason">
|
||||
This device can’t render the animated memory field yet.
|
||||
</div>
|
||||
<a class="fallback-cta" href="{base}/graph">OPEN THE GRAPH VIEW →</a>
|
||||
<div class="fallback-hint">
|
||||
WebGPU is required for the Observatory. Chrome 113+, Edge 113+, or Safari 18+ —
|
||||
the classic <a href="./graph">Graph view</a> works everywhere.
|
||||
The Graph is pure SVG and shows your real memories on any browser. For the
|
||||
full living field, use Chrome 121+, Edge 121+, or Safari 18+.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -116,16 +123,26 @@
|
|||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.fallback-cta {
|
||||
margin-top: 0.35rem;
|
||||
padding: 0.7rem 1.2rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0, 245, 212, 0.45);
|
||||
background: rgba(0, 245, 212, 0.1);
|
||||
color: #7fe6c0;
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 0 18px rgba(0, 245, 212, 0.35);
|
||||
}
|
||||
.fallback-cta:active {
|
||||
background: rgba(0, 245, 212, 0.2);
|
||||
}
|
||||
|
||||
.fallback-hint {
|
||||
color: #7c8a97;
|
||||
font-size: 0.75rem;
|
||||
max-width: 34rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.fallback-hint a {
|
||||
color: #cfe9ff;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -171,48 +171,72 @@
|
|||
.replace(/[^\x20-\x7E]/g, '?');
|
||||
}
|
||||
|
||||
// Portrait/phone check — same live-aspect signal the text + field layers use
|
||||
// (engine.params[6]/[7], window fallback). On a phone the in-canvas HUD chrome
|
||||
// (dev telemetry + the floating PAUSE) is SUPPRESSED: it has fixed landscape
|
||||
// NDC anchors that overprint the reflowed content, the telemetry is debug-only
|
||||
// noise a real user shouldn't see, and the DOM MobileNav already owns the
|
||||
// bottom-thumb zone. Desktop keeps the full chrome unchanged.
|
||||
function isPortrait(): boolean {
|
||||
let vw = engine?.params[6] || 0;
|
||||
let vh = engine?.params[7] || 0;
|
||||
if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') {
|
||||
vw = window.innerWidth;
|
||||
vh = window.innerHeight;
|
||||
}
|
||||
if (vw <= 0 || vh <= 0) return false;
|
||||
return vw / vh < 0.85;
|
||||
}
|
||||
|
||||
function makeChromeItems(frame = frameCount, fps = fpsEstimate): TextLayerItem[] {
|
||||
const items: TextLayerItem[] = [
|
||||
{
|
||||
id: 'route-chrome:pause',
|
||||
kind: 'route-chrome',
|
||||
text: paused ? '> RESUME' : '|| PAUSE',
|
||||
x: 0.66,
|
||||
y: -0.86,
|
||||
size: 0.034,
|
||||
color: paused ? AMBER : CYAN,
|
||||
revealSpan: 1
|
||||
},
|
||||
{
|
||||
id: 'route-chrome:telemetry',
|
||||
kind: 'route-telemetry',
|
||||
text: `${organ.toUpperCase()} - ${frame}F - ${fps}FPS`,
|
||||
x: 0.44,
|
||||
y: 0.88,
|
||||
size: 0.022,
|
||||
color: DIM_GREEN,
|
||||
revealSpan: 1
|
||||
}
|
||||
];
|
||||
const portrait = isPortrait();
|
||||
// Desktop: floating PAUSE + dev telemetry. Phone: neither (see isPortrait).
|
||||
const items: TextLayerItem[] = portrait
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: 'route-chrome:pause',
|
||||
kind: 'route-chrome',
|
||||
text: paused ? '> RESUME' : '|| PAUSE',
|
||||
x: 0.66,
|
||||
y: -0.86,
|
||||
size: 0.034,
|
||||
color: paused ? AMBER : CYAN,
|
||||
revealSpan: 1
|
||||
},
|
||||
{
|
||||
id: 'route-chrome:telemetry',
|
||||
kind: 'route-telemetry',
|
||||
text: `${organ.toUpperCase()} - ${frame}F - ${fps}FPS`,
|
||||
x: 0.44,
|
||||
y: 0.88,
|
||||
size: 0.022,
|
||||
color: DIM_GREEN,
|
||||
revealSpan: 1
|
||||
}
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
items.push({
|
||||
id: 'route-chrome:loading',
|
||||
kind: 'route-status',
|
||||
text: 'REPLAYING COGNITIVE RECEIPT...',
|
||||
x: -0.23,
|
||||
x: portrait ? -0.82 : -0.23,
|
||||
y: 0.02,
|
||||
size: 0.046,
|
||||
size: portrait ? 0.03 : 0.046,
|
||||
color: OXYGEN,
|
||||
startFrame: Math.max(0, frame - 90),
|
||||
revealSpan: 72
|
||||
revealSpan: 72,
|
||||
maxWidthEm: portrait ? 26 : undefined
|
||||
});
|
||||
} else if (error) {
|
||||
items.push(
|
||||
{
|
||||
id: 'route-chrome:error-pulse',
|
||||
kind: 'route-status-pulse',
|
||||
text: '!!!!!!!!!!!!!!!!!!!!!!!!',
|
||||
// The '!!!' pulse bar is a landscape flourish; drop it on a phone
|
||||
// (it overprints the error text once the field reflows narrow).
|
||||
text: portrait ? '' : '!!!!!!!!!!!!!!!!!!!!!!!!',
|
||||
x: -0.36,
|
||||
y: -0.035,
|
||||
size: 0.025,
|
||||
|
|
@ -223,12 +247,12 @@
|
|||
id: 'route-chrome:error',
|
||||
kind: 'route-status',
|
||||
text: asciiSafe(`ERROR - ${error}`).slice(0, 72),
|
||||
x: -0.54,
|
||||
x: portrait ? -0.82 : -0.54,
|
||||
y: 0.025,
|
||||
size: 0.032,
|
||||
size: portrait ? 0.028 : 0.032,
|
||||
color: SCARLET,
|
||||
revealSpan: 14,
|
||||
maxWidthEm: 48
|
||||
maxWidthEm: portrait ? 24 : 48
|
||||
}
|
||||
);
|
||||
} else if (!currentScene.alive) {
|
||||
|
|
@ -236,9 +260,9 @@
|
|||
id: 'route-chrome:empty',
|
||||
kind: 'route-status',
|
||||
text: asciiSafe(emptyLabel),
|
||||
x: -0.36,
|
||||
x: portrait ? -0.82 : -0.36,
|
||||
y: 0.02,
|
||||
size: 0.034,
|
||||
size: portrait ? 0.03 : 0.034,
|
||||
color: DIM_GREEN,
|
||||
revealSpan: 24,
|
||||
maxWidthEm: 48
|
||||
|
|
|
|||
|
|
@ -57,9 +57,18 @@ struct FusionNeck {
|
|||
const SPLAT_WGSL = /* wgsl */ `
|
||||
${COMMON_WGSL}
|
||||
|
||||
// FieldOpts mirrors the membrane's: x=intensity, yz=well center NDC, w=well
|
||||
// half-w; then well half-h, floor, soft, pad. Cells/necks dim by the same amount
|
||||
// so nothing blows out under the centered text overlay.
|
||||
struct FieldOpts {
|
||||
intensity_wx_wy_hw: vec4f,
|
||||
hh_floor_soft_pad: vec4f,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<storage, read> cells: array<FusionCell>;
|
||||
@group(0) @binding(2) var<storage, read> necks: array<FusionNeck>;
|
||||
@group(0) @binding(5) var<uniform> opts: FieldOpts;
|
||||
|
||||
const QUAD = array<vec2f, 6>(
|
||||
vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0),
|
||||
|
|
@ -70,12 +79,30 @@ struct VSOut {
|
|||
@builtin(position) clip: vec4f,
|
||||
@location(0) uv: vec2f,
|
||||
@location(1) @interpolate(flat) misc: vec4f,
|
||||
@location(2) @interpolate(flat) home: vec2f,
|
||||
};
|
||||
|
||||
fn similarity_neck(similarity: f32) -> f32 {
|
||||
return smoothstep(0.78, 0.98, similarity);
|
||||
}
|
||||
|
||||
// Reading-well multiplier at an NDC point (1.0 outside, →floor inside). hw<=0 off.
|
||||
fn field_dim(ndc: vec2f) -> f32 {
|
||||
let intensity = clamp(opts.intensity_wx_wy_hw.x, 0.0, 1.0);
|
||||
let hw = opts.intensity_wx_wy_hw.w;
|
||||
if (hw <= 0.0) { return intensity; }
|
||||
let center = opts.intensity_wx_wy_hw.yz;
|
||||
let hh = opts.hh_floor_soft_pad.x;
|
||||
let floor_v = opts.hh_floor_soft_pad.y;
|
||||
let soft = max(0.02, opts.hh_floor_soft_pad.z);
|
||||
let d = abs(ndc - center) - vec2f(hw, hh);
|
||||
let outside = length(max(d, vec2f(0.0)));
|
||||
let inside = min(max(d.x, d.y), 0.0);
|
||||
let sd = outside + inside;
|
||||
let t = smoothstep(-soft, 0.0, sd);
|
||||
return intensity * mix(floor_v, 1.0, t);
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_splat(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VSOut {
|
||||
var out: VSOut;
|
||||
|
|
@ -88,6 +115,7 @@ fn vs_splat(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) ->
|
|||
out.clip = vec4f(c.pos_retention.xy + corner * radius, 0.0, 1.0);
|
||||
out.uv = corner;
|
||||
out.misc = vec4f(c.pos_retention.z, c.cluster_meta.x, c.visual_meta.x, merge_gate);
|
||||
out.home = c.pos_retention.xy;
|
||||
} else {
|
||||
let n = necks[ii - cell_count];
|
||||
let a = n.a.xy;
|
||||
|
|
@ -102,6 +130,7 @@ fn vs_splat(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) ->
|
|||
out.clip = vec4f(pos, 0.0, 1.0);
|
||||
out.uv = vec2f(corner.x, corner.y / max(0.001, thickness));
|
||||
out.misc = vec4f(n.signals.x, fused, n.signals.z, n.signals.w);
|
||||
out.home = center;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -119,7 +148,9 @@ fn fs_splat(frag: VSOut) -> @location(0) vec4f {
|
|||
let cell_rim = smoothstep(0.24, 0.02, abs(d - (0.58 + retention * 0.16))) * (0.2 + similarity * 0.55);
|
||||
let neck_body = exp(-frag.uv.y * frag.uv.y * 4.0) * smoothstep(1.05, 0.82, abs(frag.uv.x)) * (0.35 + similarity * 0.9);
|
||||
let density = max(cell_body + cell_rim, neck_body * (0.4 + similarity));
|
||||
// r=density, g=retention/luciferin, b=mismatch amber, a reserved. No storage textures.
|
||||
// The splat writes the density FIELD (blurred into the membrane). It must NOT
|
||||
// be dimmed here — the membrane fragment applies intensity + reading well once,
|
||||
// so dimming both would double-darken. r=density, g=retention, b=mismatch amber.
|
||||
return vec4f(density, density * (0.35 + retention * 0.65), mismatch * (0.18 + merge_gate * 0.12), 1.0);
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +164,7 @@ fn vs_cell(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) ->
|
|||
out.clip = vec4f(c.pos_retention.xy + corner * radius, 0.0, 1.0);
|
||||
out.uv = corner;
|
||||
out.misc = vec4f(c.pos_retention.z, c.cluster_meta.x, c.visual_meta.x, winner);
|
||||
out.home = c.pos_retention.xy;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -153,7 +185,10 @@ fn fs_cell(frag: VSOut) -> @location(0) vec4f {
|
|||
let rim = smoothstep(0.98, 0.72, d) * (1.0 - smoothstep(0.72, 0.22, d));
|
||||
let body = exp(-d*d*3.2) * (0.20 + retention * 0.44 + winner * 0.16);
|
||||
let mismatch_ring = smoothstep(0.16, 0.0, abs(d - 0.80)) * mismatch;
|
||||
return vec4f(core * body + ivory * rim * (0.16 + similarity * 0.52) + amber * mismatch_ring * 0.34, 1.0);
|
||||
let color = core * body + ivory * rim * (0.16 + similarity * 0.52) + amber * mismatch_ring * 0.34;
|
||||
// Sharp cells draw on TOP of the membrane, so dim them by the same field
|
||||
// intensity + reading well or they'd punch through the centered text.
|
||||
return vec4f(color * field_dim(frag.home), 1.0);
|
||||
}
|
||||
|
||||
@vertex
|
||||
|
|
@ -175,6 +210,7 @@ fn vs_neck(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) ->
|
|||
out.clip = vec4f(pos + normal * side * thickness, 0.0, 1.0);
|
||||
out.uv = vec2f(t, side);
|
||||
out.misc = vec4f(n.signals.x, n.signals.y, n.signals.z, distance(pos, midpoint));
|
||||
out.home = midpoint;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -189,16 +225,27 @@ fn fs_neck(frag: VSOut) -> @location(0) vec4f {
|
|||
let amber = vec3f(1.0, 0.69, 0.08);
|
||||
let pull = smoothstep(-0.08, 0.20, similarity - threshold);
|
||||
let color = mix(bridge, luciferin, pull) + amber * mismatch * pulse * 0.34;
|
||||
return vec4f(color * (0.14 + similarity * 0.55 + mismatch * 0.18), 1.0);
|
||||
// Necks draw on TOP of the membrane too — dim by field intensity + reading well.
|
||||
return vec4f(color * (0.14 + similarity * 0.55 + mismatch * 0.18) * field_dim(frag.home), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const MEMBRANE_WGSL = /* wgsl */ `
|
||||
${COMMON_WGSL}
|
||||
|
||||
// FieldOpts: x=intensity (0..1 overall dim), yz=well center NDC, w=well half-w,
|
||||
// then well half-h, floor (min emission inside well), soft (edge falloff), pad.
|
||||
// Lets a text-heavy organ dim the whole field AND carve a reading well under the
|
||||
// centered DOM overlay so the labels/values read. hw<=0 disables the well.
|
||||
struct FieldOpts {
|
||||
intensity_wx_wy_hw: vec4f,
|
||||
hh_floor_soft_pad: vec4f,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(3) var field_sampler: sampler;
|
||||
@group(0) @binding(4) var field_tex: texture_2d<f32>;
|
||||
@group(0) @binding(5) var<uniform> opts: FieldOpts;
|
||||
|
||||
const QUAD = array<vec2f, 6>(
|
||||
vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0),
|
||||
|
|
@ -216,6 +263,25 @@ fn vs_fullscreen(@builtin(vertex_index) vi: u32) -> VSOut {
|
|||
return out;
|
||||
}
|
||||
|
||||
// Reading-well multiplier at an NDC point: 1.0 outside the well, falling toward
|
||||
// the floor value inside it (smooth edge of width soft). Disabled when hw<=0.
|
||||
fn reading_well(ndc: vec2f) -> f32 {
|
||||
let hw = opts.intensity_wx_wy_hw.w;
|
||||
if (hw <= 0.0) { return 1.0; }
|
||||
let center = opts.intensity_wx_wy_hw.yz;
|
||||
let hh = opts.hh_floor_soft_pad.x;
|
||||
let floor_v = opts.hh_floor_soft_pad.y;
|
||||
let soft = max(0.02, opts.hh_floor_soft_pad.z);
|
||||
let d = abs(ndc - center) - vec2f(hw, hh);
|
||||
// signed distance to rect edge: <0 inside, >0 outside
|
||||
let outside = length(max(d, vec2f(0.0)));
|
||||
let inside = min(max(d.x, d.y), 0.0);
|
||||
let sd = outside + inside;
|
||||
// sd<=-soft → fully inside (floor); sd>=0 → outside (1.0)
|
||||
let t = smoothstep(-soft, 0.0, sd);
|
||||
return mix(floor_v, 1.0, t);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_membrane(frag: VSOut) -> @location(0) vec4f {
|
||||
let f = textureSample(field_tex, field_sampler, frag.uv);
|
||||
|
|
@ -232,7 +298,9 @@ fn fs_membrane(frag: VSOut) -> @location(0) vec4f {
|
|||
color = color + bridge * density * 0.055 + luciferin * retention * 0.080;
|
||||
color = color + ivory * membrane * 0.22 + amber * mismatch * (0.20 + 0.08 * params.pulse);
|
||||
let vignette = smoothstep(0.96, 0.18, distance(frag.uv, vec2f(0.5)));
|
||||
return vec4f(color * (0.35 + 0.65 * vignette) * params.brightness, 1.0);
|
||||
let ndc = frag.uv * 2.0 - vec2f(1.0);
|
||||
let dim = clamp(opts.intensity_wx_wy_hw.x, 0.0, 1.0) * reading_well(ndc);
|
||||
return vec4f(color * (0.35 + 0.65 * vignette) * params.brightness * dim, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
@ -272,6 +340,7 @@ type GpuResources = {
|
|||
neckBuffer: GPUBuffer;
|
||||
blurHBuffer: GPUBuffer;
|
||||
blurVBuffer: GPUBuffer;
|
||||
optsBuffer: GPUBuffer;
|
||||
splatBindGroup: GPUBindGroup;
|
||||
blurHBindGroup: GPUBindGroup;
|
||||
blurVBindGroup: GPUBindGroup;
|
||||
|
|
@ -328,12 +397,67 @@ export class DuplicatesPass implements FramePass {
|
|||
private neckCount = 0;
|
||||
private cellGeometry: CellGeometry[] = [];
|
||||
private neckGeometry: NeckGeometry[] = [];
|
||||
// 0..1 overall field intensity — text-heavy /duplicates dims to a calm backdrop
|
||||
// so the centered DOM overlay (cluster cards, threshold, counts) stays legible.
|
||||
private intensity = 0.22;
|
||||
// Reading well (NDC rect): the field emits LESS inside it so the centered text
|
||||
// column reads. hw<=0 disables it.
|
||||
private well = { x: 0, y: 0, hw: -1, hh: 0, floor: 0.1, soft: 0.22 };
|
||||
|
||||
constructor(engine: ObservatoryEngine, scene: RouteSceneModel) {
|
||||
this.engine = engine;
|
||||
this.uploadScene(scene);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the overall field intensity (0..1). LOW (~0.22) = dim backdrop for this
|
||||
* text-heavy organ; HIGH = the field is the hero. Picked up immediately by the
|
||||
* membrane + on-top cell/neck shaders via the shared FieldOpts uniform.
|
||||
*/
|
||||
setIntensity(v: number): void {
|
||||
this.intensity = Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0.22));
|
||||
const d = this.engine.gpuDevice;
|
||||
if (d) this.writeOpts(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a "reading well": the field emits LESS inside this NDC rectangle so the
|
||||
* DOM text on top reads. hw<=0 disables it. /duplicates renders its overlay in a
|
||||
* centered `mx-auto max-w-5xl` column, so the well is centered, not a left rail.
|
||||
*/
|
||||
setReadingWell(r: { x: number; y: number; hw: number; hh: number; floor?: number; soft?: number }): void {
|
||||
const finiteN = (v: number, fb = 0) => (Number.isFinite(v) ? v : fb);
|
||||
this.well = {
|
||||
x: finiteN(r.x),
|
||||
y: finiteN(r.y),
|
||||
hw: finiteN(r.hw, -1),
|
||||
hh: finiteN(r.hh),
|
||||
floor: Math.min(1, Math.max(0, finiteN(r.floor ?? 0.1, 0.1))),
|
||||
soft: Math.max(0.02, finiteN(r.soft ?? 0.22, 0.22))
|
||||
};
|
||||
const d = this.engine.gpuDevice;
|
||||
if (d) this.writeOpts(d);
|
||||
}
|
||||
|
||||
/** Write the FieldOpts uniform (intensity + reading-well rect). 8 floats. */
|
||||
private writeOpts(device: GPUDevice): void {
|
||||
if (!this.resources) return;
|
||||
device.queue.writeBuffer(
|
||||
this.resources.optsBuffer,
|
||||
0,
|
||||
new Float32Array([
|
||||
this.intensity,
|
||||
this.well.x,
|
||||
this.well.y,
|
||||
this.well.hw,
|
||||
this.well.hh,
|
||||
this.well.floor,
|
||||
this.well.soft,
|
||||
0
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
uploadScene(scene: RouteSceneModel): void {
|
||||
this.scene = scene as DuplicatesScene;
|
||||
this.buildGeometry();
|
||||
|
|
@ -354,7 +478,9 @@ export class DuplicatesPass implements FramePass {
|
|||
entries: [
|
||||
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
|
||||
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
|
||||
{ binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } }
|
||||
{ binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
|
||||
// FieldOpts (intensity + reading well) — read in fs_cell/fs_neck only.
|
||||
{ binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } }
|
||||
]
|
||||
});
|
||||
this.blurBindLayout = device.createBindGroupLayout({
|
||||
|
|
@ -370,7 +496,9 @@ export class DuplicatesPass implements FramePass {
|
|||
entries: [
|
||||
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
|
||||
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
|
||||
{ binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } }
|
||||
{ binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
|
||||
// FieldOpts (intensity + reading well) — dims the full-bleed membrane.
|
||||
{ binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } }
|
||||
]
|
||||
});
|
||||
const splatLayout = device.createPipelineLayout({ label: 'duplicates-fusion-splat-layout', bindGroupLayouts: [this.splatBindLayout] });
|
||||
|
|
@ -424,6 +552,7 @@ export class DuplicatesPass implements FramePass {
|
|||
let neckBuffer = this.resources?.neckBuffer;
|
||||
let blurHBuffer = this.resources?.blurHBuffer;
|
||||
let blurVBuffer = this.resources?.blurVBuffer;
|
||||
let optsBuffer = this.resources?.optsBuffer;
|
||||
if (!cellBuffer) cellBuffer = device.createBuffer({ label: 'duplicates-cells', size: MAX_CELLS * CELL_FLOATS * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
|
||||
if (!neckBuffer) neckBuffer = device.createBuffer({ label: 'duplicates-necks', size: MAX_NECKS * NECK_FLOATS * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
|
||||
if (!blurHBuffer) {
|
||||
|
|
@ -434,7 +563,16 @@ export class DuplicatesPass implements FramePass {
|
|||
blurVBuffer = device.createBuffer({ label: 'duplicates-blur-v-dir', size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
||||
device.queue.writeBuffer(blurVBuffer, 0, new Float32Array([0, 1, 0, 0]));
|
||||
}
|
||||
if (!needsTextures && this.resources) return;
|
||||
if (!optsBuffer) {
|
||||
// FieldOpts: intensity + reading-well rect. 8 floats = 32 bytes.
|
||||
optsBuffer = device.createBuffer({ label: 'duplicates-field-opts', size: 32, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
||||
}
|
||||
if (!needsTextures && this.resources) {
|
||||
// Buffers already exist; the caller may have just changed intensity/well.
|
||||
this.resources.optsBuffer = optsBuffer;
|
||||
this.writeOpts(device);
|
||||
return;
|
||||
}
|
||||
this.resources?.fieldA.destroy();
|
||||
this.resources?.fieldB.destroy();
|
||||
const usage = GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING;
|
||||
|
|
@ -448,7 +586,8 @@ export class DuplicatesPass implements FramePass {
|
|||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.engine.paramsBuffer } },
|
||||
{ binding: 1, resource: { buffer: cellBuffer } },
|
||||
{ binding: 2, resource: { buffer: neckBuffer } }
|
||||
{ binding: 2, resource: { buffer: neckBuffer } },
|
||||
{ binding: 5, resource: { buffer: optsBuffer } }
|
||||
]
|
||||
});
|
||||
const blurHBindGroup = device.createBindGroup({
|
||||
|
|
@ -475,10 +614,12 @@ export class DuplicatesPass implements FramePass {
|
|||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.engine.paramsBuffer } },
|
||||
{ binding: 3, resource: this.sampler },
|
||||
{ binding: 4, resource: fieldAView }
|
||||
{ binding: 4, resource: fieldAView },
|
||||
{ binding: 5, resource: { buffer: optsBuffer } }
|
||||
]
|
||||
});
|
||||
this.resources = { cellBuffer, neckBuffer, blurHBuffer, blurVBuffer, splatBindGroup, blurHBindGroup, blurVBindGroup, membraneBindGroup, fieldA, fieldB, fieldAView, fieldBView, fieldSize: [w, h] };
|
||||
this.resources = { cellBuffer, neckBuffer, blurHBuffer, blurVBuffer, optsBuffer, splatBindGroup, blurHBindGroup, blurVBindGroup, membraneBindGroup, fieldA, fieldB, fieldAView, fieldBView, fieldSize: [w, h] };
|
||||
this.writeOpts(device);
|
||||
}
|
||||
|
||||
private buildGeometry(): void {
|
||||
|
|
@ -719,6 +860,7 @@ export class DuplicatesPass implements FramePass {
|
|||
this.resources?.neckBuffer.destroy();
|
||||
this.resources?.blurHBuffer.destroy();
|
||||
this.resources?.blurVBuffer.destroy();
|
||||
this.resources?.optsBuffer.destroy();
|
||||
this.resources?.fieldA.destroy();
|
||||
this.resources?.fieldB.destroy();
|
||||
this.resources = null;
|
||||
|
|
@ -757,5 +899,12 @@ export function createDuplicatesPasses(engine: ObservatoryEngine, scene: RouteSc
|
|||
void rgb01(RETENTION.recall);
|
||||
void rgb01(RETENTION.luciferin);
|
||||
void rgb01(IMMUNE.trustMembrane);
|
||||
return [new DuplicatesPass(engine, scene)];
|
||||
const pass = new DuplicatesPass(engine, scene);
|
||||
// /duplicates is a TEXT-HEAVY organ: the DOM overlay (cluster cards, threshold,
|
||||
// counts) is the content, the synaptic-fusion field is a DIM backdrop. Drop the
|
||||
// field to 0.22 and carve a centered reading well under the `mx-auto max-w-5xl`
|
||||
// column so every label/value reads (mirrors observatory's setIntensity+well).
|
||||
pass.setIntensity(0.22);
|
||||
pass.setReadingWell({ x: 0, y: 0, hw: 0.6, hh: 0.85, floor: 0.08, soft: 0.25 });
|
||||
return [pass];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ type GpuResources = {
|
|||
cellBuffer: GPUBuffer;
|
||||
blurHBuffer: GPUBuffer;
|
||||
blurVBuffer: GPUBuffer;
|
||||
optsBuffer: GPUBuffer;
|
||||
splatBindGroup: GPUBindGroup;
|
||||
cellBindGroup: GPUBindGroup;
|
||||
blurHBindGroup: GPUBindGroup;
|
||||
|
|
@ -99,6 +100,15 @@ export class LivingFieldPass implements FramePass {
|
|||
private engine: ObservatoryEngine;
|
||||
private cells: LivingCell[] = [];
|
||||
private scalars: LivingFieldScalars = {};
|
||||
// 0..1 field intensity — how bright the whole field renders. Text-heavy organs
|
||||
// set this LOW (~0.2) so the field is a dim backdrop the labels read over;
|
||||
// pure-visual organs (graph/timeline) keep it high. Default is a calm backdrop,
|
||||
// NOT a full-brightness blast, because most organs carry readable text.
|
||||
private intensity = 0.28;
|
||||
// Reading well (NDC rect) — the field dims inside it so text reads. hw<=0 = off.
|
||||
private well = { x: 0, y: 0, hw: -1, hh: 0, floor: 0.1, soft: 0.22 };
|
||||
// Portrait well-reflow: last aspect bucket, so compute() only re-writes opts on change.
|
||||
private lastAspectBucket = -999;
|
||||
private resources: GpuResources | null = null;
|
||||
private sampler: GPUSampler | null = null;
|
||||
private splatBindLayout: GPUBindGroupLayout | null = null;
|
||||
|
|
@ -114,6 +124,19 @@ export class LivingFieldPass implements FramePass {
|
|||
this.engine = engine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the field intensity (0..1). LOW (~0.15-0.28) = dim backdrop for text
|
||||
* organs; HIGH (~0.6-1.0) = the field is the hero (graph/timeline). Call
|
||||
* before setCells (it's baked into the uploaded cells).
|
||||
*/
|
||||
setIntensity(v: number): void {
|
||||
this.intensity = Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0.28));
|
||||
const d = this.engine.gpuDevice;
|
||||
if (d) this.writeOpts(d); // membrane picks it up immediately
|
||||
// cells read intensity from extra.z, so re-bake the cell buffer too
|
||||
if (this.cells.length) this.setCells(this.cells, this.scalars);
|
||||
}
|
||||
|
||||
/** Upload a fresh set of cells (a data change). Rebuilds the GPU buffer. */
|
||||
setCells(cells: LivingCell[], scalars: LivingFieldScalars = {}): void {
|
||||
this.cells = cells.slice(0, MAX_CELLS);
|
||||
|
|
@ -132,12 +155,14 @@ export class LivingFieldPass implements FramePass {
|
|||
const membraneModule = diagShader(device, 'living-field-membrane', FIELD_MEMBRANE_WGSL);
|
||||
const cellModule = diagShader(device, 'living-field-cell', FIELD_CELL_WGSL);
|
||||
|
||||
// splat + cell share: uniform params (0) + storage cells (1)
|
||||
// splat + cell share: uniform params (0) + storage cells (1) + FieldOpts (2).
|
||||
// (fs_splat ignores binding 2; the cell shader reads it for intensity+well.)
|
||||
this.splatBindLayout = device.createBindGroupLayout({
|
||||
label: 'living-field-splat-layout',
|
||||
entries: [
|
||||
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
|
||||
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } }
|
||||
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
|
||||
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } }
|
||||
]
|
||||
});
|
||||
this.blurBindLayout = device.createBindGroupLayout({
|
||||
|
|
@ -152,6 +177,7 @@ export class LivingFieldPass implements FramePass {
|
|||
label: 'living-field-membrane-layout',
|
||||
entries: [
|
||||
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
|
||||
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
|
||||
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
|
||||
{ binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } }
|
||||
]
|
||||
|
|
@ -203,6 +229,7 @@ export class LivingFieldPass implements FramePass {
|
|||
let cellBuffer = this.resources?.cellBuffer;
|
||||
let blurHBuffer = this.resources?.blurHBuffer;
|
||||
let blurVBuffer = this.resources?.blurVBuffer;
|
||||
let optsBuffer = this.resources?.optsBuffer;
|
||||
if (!cellBuffer) cellBuffer = device.createBuffer({ label: 'living-field-cells', size: MAX_CELLS * CELL_FLOATS * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
|
||||
if (!blurHBuffer) {
|
||||
blurHBuffer = device.createBuffer({ label: 'living-field-blur-h', size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
||||
|
|
@ -212,6 +239,10 @@ export class LivingFieldPass implements FramePass {
|
|||
blurVBuffer = device.createBuffer({ label: 'living-field-blur-v', size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
||||
device.queue.writeBuffer(blurVBuffer, 0, new Float32Array([0, 1, 0, 0]));
|
||||
}
|
||||
if (!optsBuffer) {
|
||||
// FieldOpts: intensity + reading-well rect. 8 floats = 32 bytes.
|
||||
optsBuffer = device.createBuffer({ label: 'living-field-opts', size: 32, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
||||
}
|
||||
if (!needsTextures && this.resources) return;
|
||||
this.resources?.fieldA.destroy();
|
||||
this.resources?.fieldB.destroy();
|
||||
|
|
@ -225,7 +256,8 @@ export class LivingFieldPass implements FramePass {
|
|||
layout: this.splatBindLayout,
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.engine.paramsBuffer } },
|
||||
{ binding: 1, resource: { buffer: cellBuffer } }
|
||||
{ binding: 1, resource: { buffer: cellBuffer } },
|
||||
{ binding: 2, resource: { buffer: optsBuffer } }
|
||||
]
|
||||
});
|
||||
const cellBindGroup = device.createBindGroup({
|
||||
|
|
@ -233,13 +265,103 @@ export class LivingFieldPass implements FramePass {
|
|||
layout: this.splatBindLayout,
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.engine.paramsBuffer } },
|
||||
{ binding: 1, resource: { buffer: cellBuffer } }
|
||||
{ binding: 1, resource: { buffer: cellBuffer } },
|
||||
{ binding: 2, resource: { buffer: optsBuffer } }
|
||||
]
|
||||
});
|
||||
const blurHBindGroup = device.createBindGroup({ label: 'living-field-blur-h-bind', layout: this.blurBindLayout, entries: [{ binding: 0, resource: this.sampler }, { binding: 1, resource: fieldAView }, { binding: 2, resource: { buffer: blurHBuffer } }] });
|
||||
const blurVBindGroup = device.createBindGroup({ label: 'living-field-blur-v-bind', layout: this.blurBindLayout, entries: [{ binding: 0, resource: this.sampler }, { binding: 1, resource: fieldBView }, { binding: 2, resource: { buffer: blurVBuffer } }] });
|
||||
const membraneBindGroup = device.createBindGroup({ label: 'living-field-membrane-bind', layout: this.membraneBindLayout, entries: [{ binding: 0, resource: { buffer: this.engine.paramsBuffer } }, { binding: 3, resource: this.sampler }, { binding: 4, resource: fieldAView }] });
|
||||
this.resources = { cellBuffer, blurHBuffer, blurVBuffer, splatBindGroup, cellBindGroup, blurHBindGroup, blurVBindGroup, membraneBindGroup, fieldA, fieldB, fieldAView, fieldBView, fieldSize: [w, h] };
|
||||
const membraneBindGroup = device.createBindGroup({ label: 'living-field-membrane-bind', layout: this.membraneBindLayout, entries: [{ binding: 0, resource: { buffer: this.engine.paramsBuffer } }, { binding: 2, resource: { buffer: optsBuffer } }, { binding: 3, resource: this.sampler }, { binding: 4, resource: fieldAView }] });
|
||||
this.resources = { cellBuffer, blurHBuffer, blurVBuffer, optsBuffer, splatBindGroup, cellBindGroup, blurHBindGroup, blurVBindGroup, membraneBindGroup, fieldA, fieldB, fieldAView, fieldBView, fieldSize: [w, h] };
|
||||
this.writeOpts(device);
|
||||
}
|
||||
|
||||
/** Write the FieldOpts uniform (intensity + reading-well rect). */
|
||||
private writeOpts(device: GPUDevice): void {
|
||||
if (!this.resources) return;
|
||||
// Portrait: the text layer recentres x and reclaims vertical spread on a
|
||||
// phone (TextLayerPass.portraitAdapt). Apply the SAME transform to the
|
||||
// reading well so the dimmed region still sits under the reflowed text,
|
||||
// driven entirely by the live viewport aspect — nothing hardcoded per page.
|
||||
const well = this.portraitWell();
|
||||
device.queue.writeBuffer(
|
||||
this.resources.optsBuffer,
|
||||
0,
|
||||
new Float32Array([
|
||||
this.intensity,
|
||||
well.x,
|
||||
well.y,
|
||||
well.hw,
|
||||
well.hh,
|
||||
well.floor,
|
||||
well.soft,
|
||||
0
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/** Coarse aspect bucket (matches TextLayerPass) so we only re-write on change. */
|
||||
private aspectBucket(): number {
|
||||
let vw = this.engine.params[6] || 0;
|
||||
let vh = this.engine.params[7] || 0;
|
||||
if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') {
|
||||
vw = window.innerWidth;
|
||||
vh = window.innerHeight;
|
||||
}
|
||||
if (vw <= 0 || vh <= 0) return -999;
|
||||
return Math.round((vw / vh) * 8);
|
||||
}
|
||||
|
||||
/** Mirror TextLayerPass.portraitAdapt's x-recentre + y-reclaim on the well. */
|
||||
private portraitWell(): {
|
||||
x: number;
|
||||
y: number;
|
||||
hw: number;
|
||||
hh: number;
|
||||
floor: number;
|
||||
soft: number;
|
||||
} {
|
||||
if (this.well.hw <= 0) return this.well;
|
||||
let vw = this.engine.params[6] || 0;
|
||||
let vh = this.engine.params[7] || 0;
|
||||
if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') {
|
||||
vw = window.innerWidth;
|
||||
vh = window.innerHeight;
|
||||
}
|
||||
if (vw <= 0 || vh <= 0) return this.well;
|
||||
const aspect = vw / vh;
|
||||
if (aspect >= 0.85) return this.well;
|
||||
const portraitness = clamp01((0.85 - aspect) / (0.85 - 0.46));
|
||||
const xPull = 0.42 * portraitness;
|
||||
const yReclaim = 1 + (1 / Math.max(aspect, 0.2) - 1) * (0.72 * portraitness);
|
||||
// Widen the well to cover the whole narrow column + a little slack.
|
||||
const widen = 1 + 0.25 * portraitness;
|
||||
return {
|
||||
x: this.well.x * (1 - xPull),
|
||||
y: clamp(this.well.y * yReclaim, -0.98, 0.98),
|
||||
hw: Math.min(1.1, this.well.hw * widen),
|
||||
hh: Math.min(1.1, this.well.hh * yReclaim),
|
||||
floor: this.well.floor,
|
||||
soft: this.well.soft
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a "reading well": the field emits LESS inside this NDC rectangle so text
|
||||
* on top reads clearly. hw<=0 (default) disables it → field renders full. Call
|
||||
* with the region your MSDF text occupies (e.g. the left instrument column).
|
||||
*/
|
||||
setReadingWell(r: { x: number; y: number; hw: number; hh: number; floor?: number; soft?: number }): void {
|
||||
this.well = {
|
||||
x: finite(r.x),
|
||||
y: finite(r.y),
|
||||
hw: finite(r.hw, -1),
|
||||
hh: finite(r.hh),
|
||||
floor: clamp01(r.floor ?? 0.1),
|
||||
soft: Math.max(0.02, finite(r.soft ?? 0.22, 0.22))
|
||||
};
|
||||
const d = this.engine.gpuDevice;
|
||||
if (d) this.writeOpts(d);
|
||||
}
|
||||
|
||||
private uploadBuffers(device: GPUDevice): void {
|
||||
|
|
@ -276,7 +398,7 @@ export class LivingFieldPass implements FramePass {
|
|||
data[o + 11] = finite(c.spin ?? 1, 1);
|
||||
data[o + 12] = i;
|
||||
data[o + 13] = finite(c.seed ?? phase * 97.13);
|
||||
data[o + 14] = 0;
|
||||
data[o + 14] = this.intensity; // extra.z — per-field intensity the shader dims by
|
||||
data[o + 15] = 0;
|
||||
}
|
||||
// NOTE: do NOT write engine.params[2] (node_count) here. That lane is SHARED
|
||||
|
|
@ -291,6 +413,14 @@ export class LivingFieldPass implements FramePass {
|
|||
const device = this.engine.gpuDevice;
|
||||
if (!device || !this.resources || !this.splatPipeline || !this.blurPipeline) return;
|
||||
this.ensureResources(device);
|
||||
// Re-write the reading-well opts when the viewport aspect crosses a bucket
|
||||
// (phone rotate / window resize) so the portrait-adapted well tracks the
|
||||
// reflowed text. Cheap (one 32-byte write) and only fires on real changes.
|
||||
const bucket = this.aspectBucket();
|
||||
if (bucket !== this.lastAspectBucket) {
|
||||
this.lastAspectBucket = bucket;
|
||||
this.writeOpts(device);
|
||||
}
|
||||
const res = this.resources;
|
||||
const splat = encoder.beginRenderPass({ label: 'living-field-splat-pass', colorAttachments: [{ view: res.fieldAView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: 'clear', storeOp: 'store' }] });
|
||||
splat.setPipeline(this.splatPipeline);
|
||||
|
|
@ -354,6 +484,7 @@ export class LivingFieldPass implements FramePass {
|
|||
this.resources?.cellBuffer.destroy();
|
||||
this.resources?.blurHBuffer.destroy();
|
||||
this.resources?.blurVBuffer.destroy();
|
||||
this.resources?.optsBuffer.destroy();
|
||||
this.resources?.fieldA.destroy();
|
||||
this.resources?.fieldB.destroy();
|
||||
this.resources = null;
|
||||
|
|
@ -364,6 +495,10 @@ function clamp01(v: number): number {
|
|||
return Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0));
|
||||
}
|
||||
|
||||
function clamp(v: number, lo: number, hi: number): number {
|
||||
return Math.min(hi, Math.max(lo, Number.isFinite(v) ? v : lo));
|
||||
}
|
||||
|
||||
/** Coerce a value to a finite number so no NaN/Infinity reaches the GPU buffer. */
|
||||
function finite(v: number, fallback = 0): number {
|
||||
return Number.isFinite(v) ? v : fallback;
|
||||
|
|
|
|||
|
|
@ -79,6 +79,29 @@ fn orbit(base: vec2f, phase01: f32, spin_scale: f32) -> vec2f {
|
|||
let rr = radius * (1.0 + 0.016 * sin(params.time * 1.1 + phase01 * TAU));
|
||||
return vec2f(cos(ang), sin(ang)) * rr;
|
||||
}
|
||||
|
||||
// Per-field options (a small field-local uniform, NOT the shared Params). Carries
|
||||
// the global intensity + a "reading well" rectangle: the field emits LESS inside
|
||||
// the well so text renders on a dim, readable substrate. hw<=0 disables the well.
|
||||
struct FieldOpts {
|
||||
intensity: f32, // 0..1 global field scale (membrane); cells also honor extra.z
|
||||
well_x: f32, // reading-well rect center, NDC x
|
||||
well_y: f32, // reading-well rect center, NDC y
|
||||
well_hw: f32, // half-width NDC (<=0 disables -> factor 1.0)
|
||||
well_hh: f32, // half-height NDC
|
||||
well_floor: f32, // min multiplier inside the well (e.g. 0.10)
|
||||
well_soft: f32, // edge softness NDC (e.g. 0.22)
|
||||
_pad: f32,
|
||||
};
|
||||
|
||||
// 1.0 outside the well, ramping down to well_floor inside it (a soft rectangle).
|
||||
fn reading_well(uv_ndc: vec2f, o: FieldOpts) -> f32 {
|
||||
if (o.well_hw <= 0.0) { return 1.0; }
|
||||
let dx = max(0.0, abs(uv_ndc.x - o.well_x) - o.well_hw);
|
||||
let dy = max(0.0, abs(uv_ndc.y - o.well_y) - o.well_hh);
|
||||
let outside = length(vec2f(dx, dy)) / max(o.well_soft, 0.001);
|
||||
return mix(o.well_floor, 1.0, clamp(outside, 0.0, 1.0));
|
||||
}
|
||||
`;
|
||||
|
||||
// Pass 1 — additive splat of every cell into the low-res density field
|
||||
|
|
@ -172,6 +195,7 @@ export const FIELD_MEMBRANE_WGSL = /* wgsl */ `
|
|||
${FIELD_COMMON_WGSL}
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(2) var<uniform> fopts: FieldOpts;
|
||||
@group(0) @binding(3) var field_sampler: sampler;
|
||||
@group(0) @binding(4) var field_tex: texture_2d<f32>;
|
||||
|
||||
|
|
@ -205,16 +229,22 @@ fn fs_membrane(in: VSOut) -> @location(0) vec4f {
|
|||
let membrane = smoothstep(0.05, 0.62, density) * (1.0 - smoothstep(2.0, 4.0, density));
|
||||
let edge = smoothstep(0.008, 0.11, grad) * membrane;
|
||||
let breath = 0.72 + 0.55 * params.pulse;
|
||||
// Cold blue-black substrate: the fullscreen plasma is desaturated + dimmed hard
|
||||
// so it reads as a deep breathing floor, NOT a neon-green wash over text.
|
||||
let blackwater = vec3f(0.006, 0.012, 0.015);
|
||||
let amber = vec3f(0.86, 0.42, 0.12);
|
||||
let oxygen_col = vec3f(0.66, 1.0, 0.37);
|
||||
let scarlet = vec3f(1.0, 0.23, 0.18);
|
||||
var color = blackwater * (0.35 + density * 0.14);
|
||||
color = color + mix(amber, oxygen_col, clamp(oxygen / max(density, 0.001), 0.0, 1.0)) * density * 0.34 * breath;
|
||||
color = color + vec3f(0.91, 1.0, 0.72) * edge * (0.85 + 0.5 * params.pulse);
|
||||
color = color + scarlet * scar * (0.6 + 0.4 * params.pulse);
|
||||
let amber = vec3f(0.70, 0.38, 0.14);
|
||||
let oxygen_col = vec3f(0.42, 0.70, 0.40); // desaturated (was neon 0.66,1.0,0.37)
|
||||
let scarlet = vec3f(0.85, 0.22, 0.18);
|
||||
var color = blackwater * (0.30 + density * 0.10);
|
||||
color = color + mix(amber, oxygen_col, clamp(oxygen / max(density, 0.001), 0.0, 1.0)) * density * 0.10 * breath;
|
||||
color = color + vec3f(0.55, 0.62, 0.50) * edge * (0.28 + 0.18 * params.pulse);
|
||||
color = color + scarlet * scar * (0.28 + 0.18 * params.pulse);
|
||||
let vignette = smoothstep(1.02, 0.10, distance(in.uv, vec2f(0.5)));
|
||||
return vec4f(color * (0.5 + 0.5 * vignette) * params.brightness, 1.0);
|
||||
// Reading well: the field emits LESS where text lives, so labels read. Plus the
|
||||
// per-page intensity. Deeper vignette floor pushes frame edges toward void.
|
||||
let uv_ndc = vec2f(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0);
|
||||
let well = reading_well(uv_ndc, fopts);
|
||||
return vec4f(color * (0.35 + 0.65 * vignette) * params.brightness * fopts.intensity * well, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
@ -225,6 +255,7 @@ ${FIELD_COMMON_WGSL}
|
|||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<storage, read> cells: array<LivingCellGpu>;
|
||||
@group(0) @binding(2) var<uniform> fopts: FieldOpts;
|
||||
|
||||
const QUAD = array<vec2f, 6>(
|
||||
vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0),
|
||||
|
|
@ -236,6 +267,10 @@ struct VSOut {
|
|||
@location(0) uv: vec2f,
|
||||
@location(1) @interpolate(flat) hue_energy: vec4f,
|
||||
@location(2) @interpolate(flat) info: vec4f,
|
||||
// x = field intensity (0..1, extra.z), y = twinkle seed (extra.y)
|
||||
@location(3) @interpolate(flat) extra: vec4f,
|
||||
// the cell's orbited center in NDC, so the reading well is evaluated per cell
|
||||
@location(4) @interpolate(flat) center_ndc: vec2f,
|
||||
};
|
||||
|
||||
@vertex
|
||||
|
|
@ -253,6 +288,8 @@ fn vs_cell(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) ->
|
|||
out.uv = corner;
|
||||
out.hue_energy = c.hue_energy;
|
||||
out.info = c.phase_flags;
|
||||
out.extra = c.extra;
|
||||
out.center_ndc = center;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -260,21 +297,35 @@ fn vs_cell(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) ->
|
|||
fn fs_cell(in: VSOut) -> @location(0) vec4f {
|
||||
let d = length(in.uv);
|
||||
if (d > 1.0) { discard; }
|
||||
// Field intensity (extra.z) dims the WHOLE cell so the field is a backdrop the
|
||||
// text reads over. Text organs set this low; visual organs keep it high.
|
||||
let intensity = clamp(in.extra.x, 0.05, 1.0);
|
||||
let hue = in.hue_energy.rgb;
|
||||
let energy = clamp(in.hue_energy.w, 0.0, 1.0);
|
||||
let flags = in.info.y;
|
||||
let metric2 = clamp(in.info.z, 0.0, 1.0);
|
||||
let selected = select(0.0, 1.0, flags == 1.0 || flags == 3.0 || flags == 5.0 || flags == 7.0);
|
||||
let scar = select(0.0, 1.0, (flags >= 2.0 && flags < 4.0) || flags >= 6.0);
|
||||
let phase = in.info.x;
|
||||
let twinkle = 0.6 + 0.8 * (0.5 + 0.5 * sin(params.time * 2.1 + phase * 26.0));
|
||||
let body = exp(-d * d * 2.7) * (0.55 + energy * 1.7) * twinkle;
|
||||
// twinkle tamed to 0.85..1.05 (amp 0.10) — cells breathe, never strobe over text.
|
||||
let twinkle = 0.85 + 0.10 * (0.5 + 0.5 * sin(params.time * 2.1 + phase * 26.0));
|
||||
// tighter core (d*d*3.2), low amplitude, then a soft-knee ceiling so even a
|
||||
// max-energy selected cell + bloom cannot spike into the text luminance range.
|
||||
var body = exp(-d * d * 3.2) * (0.10 + energy * 0.42) * twinkle;
|
||||
body = body / (1.0 + body * 0.9);
|
||||
// cool the hue toward a cold substrate so raw green/amber can't run away and
|
||||
// beat cyan/ivory text (green channel near-white after threshold-free bloom).
|
||||
let cool = vec3f(0.16, 0.22, 0.30);
|
||||
let tinted = mix(cool, hue, 0.55);
|
||||
let rim = smoothstep(0.98, 0.72, d) * (1.0 - smoothstep(0.72, 0.40, d));
|
||||
let scarlet = vec3f(1.0, 0.23, 0.18);
|
||||
let ivory = vec3f(0.95, 1.0, 0.86);
|
||||
var color = hue * body;
|
||||
color = color + ivory * rim * (0.9 + selected * 1.6);
|
||||
color = color + scarlet * scar * smoothstep(0.16, 0.0, abs(d - 0.74)) * 1.5;
|
||||
return vec4f(color, 1.0);
|
||||
let scarlet = vec3f(0.85, 0.22, 0.18);
|
||||
let ivory = vec3f(0.90, 0.96, 0.86);
|
||||
var color = tinted * body;
|
||||
// rim is a SELECTED-ONLY affordance now (no global white shimmer over text).
|
||||
color = color + ivory * rim * selected * 0.5;
|
||||
color = color + scarlet * scar * smoothstep(0.16, 0.0, abs(d - 0.74)) * 0.35;
|
||||
// selected/scar stay a touch brighter than the dimmed backdrop so meaning survives.
|
||||
let keep = max(intensity, (selected + scar) * 0.7);
|
||||
let well = reading_well(in.center_ndc, fopts);
|
||||
return vec4f(color * keep * well, 1.0);
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -86,13 +86,33 @@ class TextNavLayer implements NavLayerPass {
|
|||
private hoverHref: string | null = null;
|
||||
private expanded = false;
|
||||
private ready = false;
|
||||
private readonly engine: ObservatoryEngine;
|
||||
|
||||
constructor(engine: ObservatoryEngine, opts: NavLayerOptions) {
|
||||
this.engine = engine;
|
||||
this.text = new TextLayerPass(engine);
|
||||
this.routes = opts.routes ?? COGNITIVE_OS_ROUTES;
|
||||
this.activePath = opts.activePath ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch/portrait phones have NO hover, so the desktop hover-to-expand rail can
|
||||
* never open and the organ becomes un-navigable. On a narrow viewport we switch
|
||||
* to a directly-TAPPABLE dock: always-visible shortcut letters at the true left
|
||||
* edge, each its own pick target so one tap navigates. Derived from the live
|
||||
* viewport aspect — nothing hardcoded, and desktop is untouched.
|
||||
*/
|
||||
private isMobile(): boolean {
|
||||
let vw = this.engine.params[6] || 0;
|
||||
let vh = this.engine.params[7] || 0;
|
||||
if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') {
|
||||
vw = window.innerWidth;
|
||||
vh = window.innerHeight;
|
||||
}
|
||||
if (vw <= 0 || vh <= 0) return false;
|
||||
return vw / vh < 0.85;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await this.text.init();
|
||||
this.ready = true;
|
||||
|
|
@ -106,6 +126,10 @@ class TextNavLayer implements NavLayerPass {
|
|||
}
|
||||
|
||||
setHoverFromNdc(ndcX: number, ndcY: number): NavPick | null {
|
||||
// Mobile dock is always-visible + directly tappable — there is no hover-to-
|
||||
// expand. Just report whether a marker is under the point (for cursor state)
|
||||
// without any rebuild.
|
||||
if (this.isMobile()) return this.pickAt(ndcX, ndcY);
|
||||
// 1. Decide expand/collapse from the cursor ZONE (with hysteresis) BEFORE
|
||||
// picking, so that on the first move into the edge the labels are built
|
||||
// and immediately hoverable in this same call (no dead pointermove).
|
||||
|
|
@ -159,6 +183,15 @@ class TextNavLayer implements NavLayerPass {
|
|||
|
||||
private rebuild(): void {
|
||||
if (!this.ready) return;
|
||||
// On mobile the in-canvas rail is SUPPRESSED entirely: a global DOM MobileNav
|
||||
// (rendered by the (app) shell) is the single, reliable tap-to-navigate
|
||||
// surface for phones — it works on every stage type AND on devices with no
|
||||
// WebGPU, which this in-canvas rail cannot. Rendering nothing here avoids a
|
||||
// confusing double nav. Desktop keeps the full hover-to-expand rail.
|
||||
if (this.isMobile()) {
|
||||
this.text.setText([]);
|
||||
return;
|
||||
}
|
||||
this.text.setText(this.expanded ? this.buildExpanded() : this.buildCollapsed());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -123,11 +123,31 @@ function clamp01(v: number): number {
|
|||
*/
|
||||
export function buildOrganLabels(
|
||||
positions: OrganScreenPos[],
|
||||
opts: { hoveredHref?: string | null; dimUnhovered?: boolean } = {}
|
||||
opts: { hoveredHref?: string | null; dimUnhovered?: boolean; aspect?: number } = {}
|
||||
): TextLayerItem[] {
|
||||
const hovered = opts.hoveredHref ?? null;
|
||||
const dim = opts.dimUnhovered ?? true;
|
||||
|
||||
// ── Portrait title-guard. On a phone the fixed title HUD ("THE MEMORY PALACE"
|
||||
// + subtitle) lives in the top screen band (authored screen-y ≈ 0.78..0.90),
|
||||
// while the orbital projection lands the top-arc organs (TIMELINE / GRAPH /
|
||||
// MEMORIES) in that SAME band — the labels pile onto the title and it becomes
|
||||
// unreadable. portraitAdapt preserves authored screen-y, so a label projected
|
||||
// into that band renders on top of the title. The cure is to pull every
|
||||
// orbital label DOWN out of the reserved title band. Derived entirely from the
|
||||
// live aspect: the narrower/taller the viewport, the more the top band must be
|
||||
// protected. Landscape/desktop (aspect ≥ 0.85) keeps the authored projection
|
||||
// byte-for-byte, so this is purely a portrait reflow.
|
||||
const aspect = opts.aspect ?? 1;
|
||||
const portrait = aspect < 0.85;
|
||||
// Portraitness 0→1 as aspect narrows from 0.85 to ~0.46 (tall phones). The
|
||||
// title band's lower edge in authored screen-y: on a wide-ish portrait the
|
||||
// subtitle sits at ~0.79, on a tall phone the title needs a touch more room.
|
||||
const portraitness = portrait ? clamp01((0.85 - aspect) / (0.85 - 0.46)) : 0;
|
||||
// Any label whose projected screen-y is above this ceiling gets folded down
|
||||
// below it, keeping the top band clear for the title HUD.
|
||||
const titleCeil = 0.7 - 0.06 * portraitness;
|
||||
|
||||
let n = 0;
|
||||
// Per-frame placed-label rects for greedy de-confliction (deterministic order
|
||||
// → capture-stable). Reused scratch to keep the hot path allocation-light.
|
||||
|
|
@ -149,6 +169,15 @@ export function buildOrganLabels(
|
|||
let x = p.ndcX + clear * OFFSET_RIGHT;
|
||||
let y = p.ndcY + clear * OFFSET_UP;
|
||||
|
||||
// Portrait title-guard: fold any label out of the reserved title band so
|
||||
// it never overprints "THE MEMORY PALACE" / the subtitle. Reflect it down
|
||||
// below the ceiling (mirror across titleCeil) so a top-arc orb's caption
|
||||
// still reads, just underneath the title instead of on top of it. The
|
||||
// de-confliction pass below then keeps folded labels from stacking.
|
||||
if (portrait && y > titleCeil) {
|
||||
y = titleCeil - (y - titleCeil);
|
||||
}
|
||||
|
||||
// Size + brightness ride depth; hover overrides to a hot readout.
|
||||
let size = lerp(SIZE_FAR, SIZE_NEAR, depth);
|
||||
let alpha = lerp(ALPHA_FAR, ALPHA_NEAR, depth);
|
||||
|
|
|
|||
|
|
@ -247,7 +247,17 @@ fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
|
|||
color = color + vec3<f32>(1.0, 1.0, 1.0) * core * 0.6;
|
||||
}
|
||||
|
||||
return vec4<f32>(color * params.brightness, 1.0);
|
||||
// Portrait dim: on a phone the packed additive halos bloom into one blinding
|
||||
// white blob that swallows the lower field and steals contrast from the STATS/
|
||||
// SETTINGS labels. Fold the whole field down to a DIM backdrop when the live
|
||||
// viewport is portrait (aspect < 0.85), scaling with how narrow it is. Derived
|
||||
// purely from viewport_w/viewport_h — landscape/desktop (aspect >= 0.85) gets
|
||||
// an exact 1.0 multiplier, so the desktop render is byte-identical.
|
||||
let aspect = params.viewport_w / max(params.viewport_h, 1.0);
|
||||
let portraitness = clamp((0.85 - aspect) / (0.85 - 0.46), 0.0, 1.0);
|
||||
let portrait_dim = 1.0 - 0.62 * portraitness;
|
||||
|
||||
return vec4<f32>(color * params.brightness * portrait_dim, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -65,9 +65,48 @@ export class TextLayerPass implements FramePass {
|
|||
private runs: TextRunRect[] = [];
|
||||
private runDepths = new Map<string, number>();
|
||||
private initPromise: Promise<void> | null = null;
|
||||
// Portrait reflow re-lays text when the viewport aspect changes (rotate/resize).
|
||||
private onResize: (() => void) | null = null;
|
||||
private resizeRaf = 0;
|
||||
private lastAspectBucket = -1;
|
||||
|
||||
constructor(engine: ObservatoryEngine) {
|
||||
this.engine = engine;
|
||||
this.installResizeReflow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-lay the text when the viewport aspect crosses a portrait bucket (e.g. the
|
||||
* user rotates the phone, or the window is resized narrow). Bucketed + rAF-
|
||||
* debounced so a continuous drag doesn't thrash the glyph buffer.
|
||||
*/
|
||||
private installResizeReflow(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
this.onResize = () => {
|
||||
if (this.resizeRaf) return;
|
||||
this.resizeRaf = requestAnimationFrame(() => {
|
||||
this.resizeRaf = 0;
|
||||
const bucket = this.aspectBucket();
|
||||
if (bucket !== this.lastAspectBucket && this.pendingItems.length) {
|
||||
this.lastAspectBucket = bucket;
|
||||
this.uploadItems(this.pendingItems);
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener('resize', this.onResize);
|
||||
window.addEventListener('orientationchange', this.onResize);
|
||||
}
|
||||
|
||||
/** Coarse aspect bucket so tiny resizes don't trigger a re-layout. */
|
||||
private aspectBucket(): number {
|
||||
let vw = this.engine.params[6] || 0;
|
||||
let vh = this.engine.params[7] || 0;
|
||||
if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') {
|
||||
vw = window.innerWidth;
|
||||
vh = window.innerHeight;
|
||||
}
|
||||
if (vw <= 0 || vh <= 0) return -1;
|
||||
return Math.round((vw / vh) * 8);
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
|
|
@ -116,10 +155,138 @@ export class TextLayerPass implements FramePass {
|
|||
});
|
||||
}
|
||||
|
||||
private uploadItems(items: TextLayerItem[]): void {
|
||||
/**
|
||||
* Portrait reflow — the ONE place mobile layout is handled, driven entirely by
|
||||
* the live viewport aspect (params[6]/[7]); NOTHING is hardcoded per page.
|
||||
*
|
||||
* The MSDF shader keeps glyphs square by doing `pos.y *= min(aspect,1)` in
|
||||
* portrait (aspect<1), which crushes a full-height text column into a thin
|
||||
* central band and leaves glyphs phone-tiny. Layouts are authored in landscape
|
||||
* NDC (x≈-0.9 left column, y spanning ±0.86, size 0.02–0.05). On a phone we:
|
||||
* 1. reclaim vertical spread: pre-divide y by aspect so `y/a * a == y` net,
|
||||
* then gently compress so the tallest item still fits the safe band;
|
||||
* 2. pull wide left-anchored columns toward centre so they don't overflow the
|
||||
* narrow width after the size boost;
|
||||
* 3. boost glyph size (capped) so small labels are readable at 375px, and
|
||||
* tighten maxWidthEm so long lines wrap instead of running off-screen.
|
||||
* Landscape (aspect>=~0.85) passes through untouched — desktop is unchanged.
|
||||
*/
|
||||
private portraitAdapt(items: TextLayerItem[]): TextLayerItem[] {
|
||||
// Live viewport from the engine params (canvas px). params[6]/[7] are written
|
||||
// inside the frame loop, so on the very first setText (during handleReady,
|
||||
// before frame 0) they can be 0 — fall back to the window, which for these
|
||||
// fixed-inset fullscreen organs has the same aspect as the canvas.
|
||||
let vw = this.engine.params[6] || 0;
|
||||
let vh = this.engine.params[7] || 0;
|
||||
if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') {
|
||||
vw = window.innerWidth;
|
||||
vh = window.innerHeight;
|
||||
}
|
||||
if (vw <= 0 || vh <= 0) return items;
|
||||
const aspect = vw / vh;
|
||||
// Only reflow genuinely portrait / narrow viewports. Desktop + landscape
|
||||
// tablet are left byte-for-byte identical.
|
||||
if (aspect >= 0.85) return items;
|
||||
|
||||
// The MSDF shader keeps glyphs square by `pos.y *= aspect` (portrait) and
|
||||
// scales on-screen glyph height by the same aspect. So BOTH the vertical
|
||||
// layout AND the visual size get crushed by `aspect` before hitting the
|
||||
// screen. To place a row at a chosen SCREEN y (and render it at a chosen
|
||||
// SCREEN size), the layout value must be pre-divided by aspect — that's the
|
||||
// exact inverse of what the shader does, so the net screen result is the
|
||||
// value we chose. Everything here is derived from the live aspect; nothing
|
||||
// is hardcoded per page.
|
||||
const inv = 1 / Math.max(aspect, 0.2); // shader-inverse (portrait: >1)
|
||||
|
||||
// Vertical: reclaim the FULL authored spread. y_layout = y_screen / aspect,
|
||||
// and we want y_screen == the authored y (it already fits the safe band).
|
||||
const yReclaim = inv;
|
||||
|
||||
// Size: a label authored at size s renders at on-screen height s*aspect in
|
||||
// portrait — phone-tiny. Target ~1.25x the desktop on-screen height for
|
||||
// touch legibility, i.e. screen size = s*1.25, so layout size = s*1.25*inv.
|
||||
const sizeWant = 1.25 * inv;
|
||||
|
||||
// Horizontal: width is now the TIGHT axis (full ±1, no aspect help). Pull
|
||||
// wide left-anchored columns (x≈-0.9) toward centre and give a small left
|
||||
// margin so the bigger glyphs don't run off either edge.
|
||||
const portraitness = clamp01((0.85 - aspect) / (0.85 - 0.46));
|
||||
const xPull = 0.5 * portraitness;
|
||||
// Usable NDC width for a line starting at the (pulled) left anchor. In
|
||||
// portrait the shader does NOT compress x, so this is full-scale NDC.
|
||||
const LEFT_MARGIN = -0.9;
|
||||
const RIGHT_MARGIN = 0.96;
|
||||
// Empirical NDC advance per em of layout size. The MSDF atlas is roughly
|
||||
// monospace; measured against real vitals lines the effective advance is
|
||||
// ~0.62 (a touch wider than the nominal 0.55), so use that to keep long
|
||||
// lines from bleeding off the right edge.
|
||||
const EM_ADVANCE = 0.62;
|
||||
|
||||
// Navigation + fixed HUD chrome own their OWN mobile layout (nav-layer builds
|
||||
// a touch dock at the true edge; RouteStage pins the pause/telemetry corners).
|
||||
// Recentering/reclaiming them like body content would drag the edge dock into
|
||||
// the page or push the corners off-screen — so pass all chrome through with
|
||||
// only a modest size bump for touch legibility.
|
||||
const isChrome = (k?: string) =>
|
||||
!!k &&
|
||||
(k.startsWith('route-nav') || k === 'route-chrome' || k === 'route-telemetry' || k === 'route-status' || k === 'route-status-pulse');
|
||||
|
||||
return items.map((item) => {
|
||||
if (isChrome(item.kind)) {
|
||||
// Only grow slightly (touch legibility) — keep the authored x/y so the
|
||||
// nav dock and HUD corners stay exactly where nav-layer/RouteStage put
|
||||
// them for mobile. Clamp size growth so corners don't overlap.
|
||||
const s = (item.size ?? 0.03) * Math.min(1.5, 1.1 * inv);
|
||||
return { ...item, size: s };
|
||||
}
|
||||
const baseSize = item.size ?? 0.075;
|
||||
// Start from the desired boosted size, then cap it so the WHOLE line fits
|
||||
// the usable width without needing per-character wrapping. A line of N
|
||||
// chars at layout size s spans ~N*s*EM_ADVANCE in NDC-x.
|
||||
const chars = Math.max(1, item.text.length);
|
||||
const usableW = RIGHT_MARGIN - Math.max(LEFT_MARGIN, item.x * (1 - xPull));
|
||||
const fitSize = usableW / (chars * EM_ADVANCE);
|
||||
// Never shrink below the authored size (desktop was already legible); never
|
||||
// grow past what fits. This keeps long vitals on ONE line and readable.
|
||||
const size = Math.max(baseSize, Math.min(baseSize * sizeWant, fitSize));
|
||||
// Keep the left anchor inside the margin after the centre-pull.
|
||||
const x = Math.max(LEFT_MARGIN, item.x * (1 - xPull));
|
||||
// Clamp the reclaimed y so a row near the authored edge (±0.9) still maps
|
||||
// to an on-screen y inside the safe band (±0.92 screen → ±0.92*inv layout).
|
||||
const maxYLayout = 0.92 * inv;
|
||||
let y = item.y * yReclaim;
|
||||
if (y > maxYLayout) y = maxYLayout;
|
||||
else if (y < -maxYLayout) y = -maxYLayout;
|
||||
// Wrap paragraph-style items (those that opted into maxWidthEm) at a width
|
||||
// that fits the screen; leave single-line vitals alone (fitSize keeps them
|
||||
// on one line). Convert the fit width into an em cap for the chosen size.
|
||||
// Floor at 14 em so we NEVER wrap down toward one-character-per-line (the
|
||||
// stray vertical letter column bug) — better to slightly overflow a long
|
||||
// unbreakable token than to stack it vertically.
|
||||
const emThatFit = Math.floor(usableW / (size * EM_ADVANCE));
|
||||
const maxWidthEm =
|
||||
item.maxWidthEm != null ? Math.max(14, Math.min(item.maxWidthEm, emThatFit)) : item.maxWidthEm;
|
||||
const adapted = { ...item, x, y, size, maxWidthEm };
|
||||
if (typeof window !== 'undefined' && window.location?.search.includes('dbg=1')) {
|
||||
(window as unknown as { __adaptDbg?: unknown[] }).__adaptDbg ??= [];
|
||||
(window as unknown as { __adaptDbg: unknown[] }).__adaptDbg.push({
|
||||
id: item.id,
|
||||
text: item.text?.slice(0, 24),
|
||||
x: +x.toFixed(3),
|
||||
y: +y.toFixed(3),
|
||||
size: +size.toFixed(4),
|
||||
maxWidthEm
|
||||
});
|
||||
}
|
||||
return adapted;
|
||||
});
|
||||
}
|
||||
|
||||
private uploadItems(rawItems: TextLayerItem[]): void {
|
||||
const device = this.engine.gpuDevice;
|
||||
if (!device || !this.engine.paramsBuffer || !this.atlas) return;
|
||||
this.ensurePipeline(device);
|
||||
const items = this.portraitAdapt(rawItems);
|
||||
const packed: number[] = [];
|
||||
const runs: TextRunRect[] = [];
|
||||
let glyphIndex = 0;
|
||||
|
|
@ -242,6 +409,13 @@ export class TextLayerPass implements FramePass {
|
|||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.onResize && typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
window.removeEventListener('orientationchange', this.onResize);
|
||||
}
|
||||
this.onResize = null;
|
||||
if (this.resizeRaf) cancelAnimationFrame(this.resizeRaf);
|
||||
this.resizeRaf = 0;
|
||||
this.glyphBuffer?.destroy();
|
||||
this.glyphBuffer = null;
|
||||
this.atlas?.dispose();
|
||||
|
|
|
|||
|
|
@ -51,6 +51,26 @@ struct TimelineRingGpu {
|
|||
// ('meta' is a WGSL reserved keyword — see GOD-TIER §9 / it broke Blackbox too)
|
||||
stats: vec4f,
|
||||
};
|
||||
|
||||
// Portrait legibility: on a phone the growth-ring field is the whole screen and
|
||||
// its HDR bloom becomes a BLINDING blob that drowns the MSDF HUD/receipt text.
|
||||
// Derive a dim factor from the LIVE viewport aspect (viewport_w/viewport_h) —
|
||||
// nothing is hardcoded per device. Landscape/desktop (aspect >= 0.85) is left at
|
||||
// full brightness (1.0); portrait scales down toward ~0.34 as it narrows so the
|
||||
// field becomes a DIM backdrop and the overlay text wins the contrast fight.
|
||||
fn portrait_field_dim() -> f32 {
|
||||
let a = params.viewport_w / max(params.viewport_h, 1.0);
|
||||
// portraitness: 0 at aspect 0.85 (landscape edge) -> 1 at aspect 0.46 (tall phone)
|
||||
let p = clamp((0.85 - a) / (0.85 - 0.46), 0.0, 1.0);
|
||||
// The ring/membrane colors are pushed HARD into HDR (peak accumulated ~5-8x via
|
||||
// additive blend) specifically so the post-chain bloom flares them. A 0.2 dim
|
||||
// still leaves ~1.0-1.6 — above the bloom knee, so it stayed a blinding blob on
|
||||
// a phone. Pull it down to ~0.07 at full portrait so even the accumulated HDR
|
||||
// peak lands well below the bloom threshold and the field reads as a true DIM
|
||||
// backdrop the MSDF HUD/receipt text can win against. Aspect-derived, no per-
|
||||
// device constant; landscape/desktop (aspect>=0.85) stays untouched at 1.0.
|
||||
return mix(1.0, 0.07, p);
|
||||
}
|
||||
`;
|
||||
|
||||
const SPLAT_WGSL = /* wgsl */ `
|
||||
|
|
@ -161,7 +181,7 @@ fn fs_cell(in: VSOut) -> @location(0) vec4f {
|
|||
let rim = smoothstep(0.98, 0.74, d) * (1.0 - smoothstep(0.74, 0.42, d));
|
||||
let seam = smoothstep(0.12, 0.0, abs(d - 0.48)) * rewritten;
|
||||
let scar = smoothstep(0.16, 0.0, abs(d - 0.76)) * suppressed;
|
||||
return vec4f(core * body + vec3f(0.91, 1.0, 0.72) * rim * 1.1 + indigo * seam * 1.3 + scarlet * scar * 1.5, 1.0);
|
||||
return vec4f((core * body + vec3f(0.91, 1.0, 0.72) * rim * 1.1 + indigo * seam * 1.3 + scarlet * scar * 1.5) * portrait_field_dim(), 1.0);
|
||||
}
|
||||
|
||||
@vertex
|
||||
|
|
@ -212,7 +232,7 @@ fn fs_ring(in: VSOut) -> @location(0) vec4f {
|
|||
color = color + scarlet * suppressed * 1.4;
|
||||
// Bright engraved date ticks flare on selection.
|
||||
color = color + vec3f(0.91, 1.0, 0.72) * tick * (0.14 + selected * 0.6);
|
||||
return vec4f(color, 1.0);
|
||||
return vec4f(color * portrait_field_dim(), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
@ -269,7 +289,7 @@ fn fs_membrane(in: VSOut) -> @location(0) vec4f {
|
|||
// Indigo transaction-time seams shimmer with the breath.
|
||||
color = color + indigo * seam * (0.55 + 0.35 * params.pulse);
|
||||
let vignette = smoothstep(0.98, 0.12, distance(in.uv, vec2f(0.5)));
|
||||
return vec4f(color * (0.55 + 0.45 * vignette) * params.brightness, 1.0);
|
||||
return vec4f(color * (0.55 + 0.45 * vignette) * params.brightness * portrait_field_dim(), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import MobileNav from '$lib/components/MobileNav.svelte';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
|
|
@ -12,4 +13,5 @@
|
|||
-->
|
||||
<div class="relative h-[100dvh] w-full overflow-hidden">
|
||||
{@render children()}
|
||||
<MobileNav />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,8 +15,32 @@
|
|||
type ActivationTextItem = TextLayerItem & { memoryId?: string };
|
||||
|
||||
const CYAN = [...rgb01('#22C7DE'), 1] satisfies [number, number, number, number];
|
||||
const TITLE = [...rgb01('#EAF3FF'), 0.96] satisfies [number, number, number, number];
|
||||
const HEADER = [...rgb01('#7FA0B8'), 0.72] satisfies [number, number, number, number];
|
||||
const ROW_LIMIT = 36;
|
||||
// Phone shows fewer result rows so each gets real breathing room.
|
||||
const ROW_LIMIT_PORTRAIT = 16;
|
||||
const SEARCH_LIMIT = 40;
|
||||
// Pre-revealed anchor: the shared reveal ages every glyph by a GLOBAL index, so a
|
||||
// long list ages past the ~720-frame wrapped clock and all but the first rows
|
||||
// never appear. Anchor deeply negative so every row is revealed on frame 0.
|
||||
const REVEAL_ANCHOR = -100000;
|
||||
|
||||
let engineRef: ObservatoryEngine | null = null;
|
||||
|
||||
// Portrait / narrow viewport from the LIVE engine aspect (params[6]/[7]) with a
|
||||
// window fallback — the same signal TextLayerPass.portraitAdapt uses. Nothing is
|
||||
// hardcoded to a phone width; desktop (aspect>=0.85) is untouched.
|
||||
function isPortrait(): boolean {
|
||||
let vw = engineRef?.params[6] || 0;
|
||||
let vh = engineRef?.params[7] || 0;
|
||||
if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') {
|
||||
vw = window.innerWidth;
|
||||
vh = window.innerHeight;
|
||||
}
|
||||
if (vw <= 0 || vh <= 0) return false;
|
||||
return vw / vh < 0.85;
|
||||
}
|
||||
|
||||
let results = $state<Memory[]>([]);
|
||||
let total = $state(0);
|
||||
|
|
@ -84,33 +108,78 @@
|
|||
return clamp01(memory.retentionStrength ?? relevance(memory));
|
||||
}
|
||||
|
||||
function activationLine(memory: Memory): string {
|
||||
function activationLine(memory: Memory, portrait: boolean): string {
|
||||
if (portrait) {
|
||||
// Phone: drop the id column and keep the primary content + the ONE headline
|
||||
// metric (relevance %). 'title 92%' reads at a glance; full detail on tap.
|
||||
const snippet = sanitizeAscii(memory.content).replace(/\s+/g, ' ').trim().slice(0, 34);
|
||||
return sanitizeAscii(`${snippet} ${Math.round(relevance(memory) * 100)}%`);
|
||||
}
|
||||
const snippet = sanitizeAscii(memory.content).replace(/\s+/g, ' ').trim().slice(0, 54);
|
||||
const score = Math.round(relevance(memory) * 100);
|
||||
return sanitizeAscii(`${snippet} | ${memory.id.slice(0, 8)} | ${score}%`);
|
||||
}
|
||||
|
||||
function buildTextItems(): ActivationTextItem[] {
|
||||
const rows = results.slice(0, ROW_LIMIT);
|
||||
const top = 0.72;
|
||||
const rowStep = 1.5 / Math.max(1, ROW_LIMIT - 1);
|
||||
return rows.map((memory, i) => ({
|
||||
id: `activation:${memory.id}`,
|
||||
kind: 'activation-result',
|
||||
memoryId: memory.id,
|
||||
text: activationLine(memory),
|
||||
x: -0.88,
|
||||
y: top - i * rowStep,
|
||||
size: 0.026,
|
||||
color: CYAN,
|
||||
depth: relevance(memory),
|
||||
weight: retentionOrScore(memory),
|
||||
startFrame: i * 2,
|
||||
revealSpan: 20,
|
||||
maxWidthEm: 46,
|
||||
const portrait = isPortrait();
|
||||
const rowLimit = portrait ? ROW_LIMIT_PORTRAIT : ROW_LIMIT;
|
||||
const rows = results.slice(0, rowLimit);
|
||||
// Phone gets a title + column header and a shorter, generously spaced list;
|
||||
// desktop keeps the dense edge-to-edge result log exactly as authored.
|
||||
const top = portrait ? 0.6 : 0.72;
|
||||
const span = portrait ? 1.28 : 1.5;
|
||||
const rowStep = span / Math.max(1, rowLimit - 1);
|
||||
const items: ActivationTextItem[] = [];
|
||||
|
||||
if (portrait) {
|
||||
items.push({
|
||||
id: 'activation:title',
|
||||
kind: 'activation-title',
|
||||
text: 'ACTIVATION',
|
||||
x: -0.62,
|
||||
y: 0.86,
|
||||
size: 0.06,
|
||||
color: TITLE,
|
||||
depth: 1,
|
||||
weight: 0.9,
|
||||
startFrame: REVEAL_ANCHOR,
|
||||
revealSpan: 1
|
||||
});
|
||||
items.push({
|
||||
id: 'activation:header',
|
||||
kind: 'activation-header',
|
||||
text: `MEMORY / RELEVANCE (${Math.min(results.length, rowLimit)})`,
|
||||
x: -0.62,
|
||||
y: 0.74,
|
||||
size: 0.03,
|
||||
color: HEADER,
|
||||
depth: 0.9,
|
||||
weight: 0.5,
|
||||
startFrame: REVEAL_ANCHOR,
|
||||
revealSpan: 1
|
||||
});
|
||||
}
|
||||
|
||||
rows.forEach((memory, i) => {
|
||||
items.push({
|
||||
id: `activation:${memory.id}`,
|
||||
kind: 'activation-result',
|
||||
memoryId: memory.id,
|
||||
text: activationLine(memory, portrait),
|
||||
x: portrait ? -0.62 : -0.88,
|
||||
y: top - i * rowStep,
|
||||
size: portrait ? 0.03 : 0.026,
|
||||
color: CYAN,
|
||||
depth: portrait ? Math.max(0.7, relevance(memory)) : relevance(memory),
|
||||
weight: retentionOrScore(memory),
|
||||
startFrame: portrait ? REVEAL_ANCHOR + i * 2 : i * 2,
|
||||
revealSpan: portrait ? 1 : 20,
|
||||
maxWidthEm: 46,
|
||||
hitPadX: 0.03,
|
||||
hitPadY: 0.015
|
||||
}));
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
let activationScene: RouteSceneModel = $derived({
|
||||
|
|
@ -149,6 +218,16 @@
|
|||
private field: LivingFieldPass;
|
||||
constructor(engine: ObservatoryEngine) {
|
||||
this.field = new LivingFieldPass(engine);
|
||||
// Landscape/desktop: the field is the hero (bright spread). Portrait: the
|
||||
// same 0.75 field becomes a blinding wall of blobs the result rows can't be
|
||||
// read over, so DIM it and open a reading well behind the label column so
|
||||
// the text sits on a calm backdrop. Both derive from the live aspect only.
|
||||
if (isPortrait()) {
|
||||
this.field.setIntensity(0.24);
|
||||
this.field.setReadingWell({ x: -0.3, y: -0.03, hw: 0.7, hh: 0.9, floor: 0.08, soft: 0.25 });
|
||||
} else {
|
||||
this.field.setIntensity(0.75);
|
||||
}
|
||||
}
|
||||
uploadScene(scene: RouteSceneModel): void {
|
||||
const nodes = scene.nodes as RouteNode[];
|
||||
|
|
@ -182,6 +261,7 @@
|
|||
}
|
||||
|
||||
function createActivationPasses(engine: ObservatoryEngine, scene: RouteSceneModel): RouteFramePass[] {
|
||||
engineRef = engine;
|
||||
// Field FIRST (renders behind the MSDF labels), then the readable text rows.
|
||||
const field = new ActivationFieldPass(engine);
|
||||
field.uploadScene(scene);
|
||||
|
|
|
|||
|
|
@ -177,6 +177,10 @@
|
|||
function createRecorderPasses(engine: ObservatoryEngine, scene: RouteSceneModel): RouteFramePass[] {
|
||||
const field = new LivingFieldPass(engine);
|
||||
runsField = field;
|
||||
// Text-heavy organ: the field is a DIM backdrop, and the centered content
|
||||
// column (max-w-6xl) needs a wide reading well so labels/values stay legible.
|
||||
field.setIntensity(0.24);
|
||||
field.setReadingWell({ x: 0, y: 0, hw: 0.66, hh: 0.85, floor: 0.08, soft: 0.25 });
|
||||
field.setCells(buildRunCells());
|
||||
const fieldWrapper: RouteFramePass = {
|
||||
compute: (encoder) => field.compute(encoder),
|
||||
|
|
@ -210,7 +214,7 @@
|
|||
onpick={handleRoutePick}
|
||||
/>
|
||||
|
||||
<div class="relative z-10 mx-auto max-w-6xl px-5 py-6">
|
||||
<div class="blackbox-shell relative z-10 mx-auto max-w-6xl px-5 py-6">
|
||||
<PageHeader
|
||||
icon="blackbox"
|
||||
title="Agent Black Box"
|
||||
|
|
@ -394,7 +398,7 @@
|
|||
<!-- Pulse set: the memories touched so far -->
|
||||
<div class="pulse glass" use:reveal>
|
||||
<h3 class="panel-title">
|
||||
Memory pulse <span class="text-dim">— touched this run</span>
|
||||
Memory pulse <span class="text-dim">· touched this run</span>
|
||||
</h3>
|
||||
{#if pulsedIds.length === 0}
|
||||
<p class="empty">No memories touched yet.</p>
|
||||
|
|
@ -409,7 +413,7 @@
|
|||
|
||||
<!-- Producer status — honest about what's live vs. off-by-default -->
|
||||
<div class="producers glass" use:reveal>
|
||||
<h3 class="panel-title">Event producers <span class="text-dim">— this run</span></h3>
|
||||
<h3 class="panel-title">Event producers <span class="text-dim">· this run</span></h3>
|
||||
<ul class="producer-list">
|
||||
<li class="producer ok">
|
||||
<span class="p-dot"></span> mcp.call · memory.write · memory.retrieve · memory.suppress
|
||||
|
|
@ -440,7 +444,7 @@
|
|||
{#if receipts.length}
|
||||
<div class="receipts-panel glass" use:reveal>
|
||||
<h3 class="panel-title">
|
||||
Receipts <span class="text-dim">— proof behind retrievals</span>
|
||||
Receipts <span class="text-dim">· proof behind retrievals</span>
|
||||
</h3>
|
||||
<div class="receipts-grid">
|
||||
{#each receipts.slice(0, 2) as r (r.receipt_id)}
|
||||
|
|
@ -501,6 +505,63 @@
|
|||
</div>
|
||||
|
||||
<style>
|
||||
/* ░░ PORTRAIT / NARROW REFLOW ░░
|
||||
Gated on viewport aspect (not a hardcoded phone width) to mirror the
|
||||
engine's aspect<0.85 portrait branch, so the 1440px desktop render is
|
||||
byte-identical. On a phone the shared PageHeader lays the title block and
|
||||
the action pills on one non-wrapping row that is wider than the screen —
|
||||
the Export pill clips off the right edge and the subtitle gets crushed into
|
||||
a thin column beside the icon rail. Here we stack the header: full-width
|
||||
title/subtitle on top, the pills wrapping to their own left-aligned row
|
||||
below so every label is fully readable and reachable. */
|
||||
@media (max-aspect-ratio: 85/100) {
|
||||
.blackbox-shell :global(header) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
/* Title block reclaims the full width so the subtitle reads as a sentence,
|
||||
not a 1-2-word vertical ribbon. */
|
||||
.blackbox-shell :global(header > div:first-child) {
|
||||
min-width: 0;
|
||||
}
|
||||
/* Action pills: their own row, left-aligned, allowed to wrap so the
|
||||
Export pill can never overflow the right screen edge. */
|
||||
.blackbox-shell :global(header > div:last-child) {
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
/* Let the wide Export pill shrink its own box and wrap its label instead
|
||||
of running off-screen. */
|
||||
.export-btn {
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
}
|
||||
/* The shared (app) shell is a fixed 100dvh overflow-hidden box (required so
|
||||
the zero-DOM WebGPU organs' absolute-inset canvases get a real height).
|
||||
That means this DOM content column cannot scroll with the page — so make
|
||||
the blackbox shell itself the scroll container on portrait, and pad its
|
||||
bottom so the lowest content clears the fixed MobileNav FAB. */
|
||||
.blackbox-shell {
|
||||
height: 100dvh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: calc(6rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
/* On portrait the two-column grid collapses to one column, so the Runs
|
||||
aside no longer needs its own sticky + max-height:100vh + internal scroll
|
||||
box (which parked its lowest rows behind the FAB). Let it flow inside the
|
||||
shell scroller instead. */
|
||||
.blackbox-shell .runs {
|
||||
position: static;
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.mode-toggle,
|
||||
.export-btn {
|
||||
display: inline-flex;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,27 @@
|
|||
let error = $state<string | null>(null);
|
||||
let selectedSynapsePair = $state<ImmuneSynapsePair | null>(null);
|
||||
|
||||
// Portrait phones: the full descriptive subtitle wraps to 6-7 lines, eating the
|
||||
// top ~40% before the focal card and pushing its last line into the WebGPU
|
||||
// telemetry band (the green "CONTRADICTIONS - NNNf" chrome sits at y~0.88). A
|
||||
// compact one-line subtitle clears that band and restores a single focal point.
|
||||
// Gated to portrait/narrow aspect (< 0.85) from the LIVE viewport via matchMedia
|
||||
// so the desktop render stays byte-identical. Not a hardcoded phone width.
|
||||
let isPortrait = $state(false);
|
||||
onMount(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mq = window.matchMedia('(max-aspect-ratio: 85/100)');
|
||||
isPortrait = mq.matches;
|
||||
const onChange = (e: MediaQueryListEvent) => (isPortrait = e.matches);
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
});
|
||||
const subtitle = $derived(
|
||||
isPortrait
|
||||
? 'Contradictory memories face off across a scarlet seam.'
|
||||
: 'Contradictory memories face each other across a scarlet trust-weighted seam. Click a fracture for the receipt.'
|
||||
);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
|
@ -102,6 +123,25 @@
|
|||
function createArenaPasses(engine: ObservatoryEngine, scene: RouteSceneModel): RouteFramePass[] {
|
||||
const field = new LivingFieldPass(engine);
|
||||
tissueField = field;
|
||||
// Preserve the verified portrait treatment exactly. On desktop the DOM's
|
||||
// glass panels already protect the dense reading surfaces, so let the immune
|
||||
// tissue breathe more strongly around a tighter well instead of leaving the
|
||||
// remaining viewport near-black. Derive the branch from the engine's live
|
||||
// viewport (never a device-width constant).
|
||||
let viewportWidth = engine.params[6];
|
||||
let viewportHeight = engine.params[7];
|
||||
if ((viewportWidth <= 0 || viewportHeight <= 0) && typeof window !== 'undefined') {
|
||||
viewportWidth = window.innerWidth;
|
||||
viewportHeight = window.innerHeight;
|
||||
}
|
||||
const aspect = viewportWidth / Math.max(1, viewportHeight);
|
||||
if (aspect < 0.85) {
|
||||
field.setIntensity(0.24);
|
||||
field.setReadingWell({ x: 0, y: 0, hw: 0.9, hh: 0.85, floor: 0.08, soft: 0.28 });
|
||||
} else {
|
||||
field.setIntensity(1.3);
|
||||
field.setReadingWell({ x: 0, y: 0, hw: 0.55, hh: 0.58, floor: 0.1, soft: 0.18 });
|
||||
}
|
||||
field.setCells(buildTissueCells());
|
||||
const fieldWrapper: RouteFramePass = {
|
||||
compute: (encoder) => field.compute(encoder),
|
||||
|
|
@ -257,7 +297,7 @@
|
|||
<PageHeader
|
||||
icon="contradictions"
|
||||
title="Immune Synapse Arena"
|
||||
subtitle="Contradictory memories face each other across a scarlet trust-weighted seam. Click a fracture for the receipt."
|
||||
{subtitle}
|
||||
accent="warning"
|
||||
>
|
||||
<span class="text-dim text-sm tabular-nums inline-flex items-center gap-1.5">
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@
|
|||
let error: string | null = $state(null);
|
||||
let selectedRecordId: string | null = $state(null);
|
||||
let dormantPool: Memory[] = $state([]);
|
||||
// Real pool size (whole store, not just the sampled 80) — the dormant screen's
|
||||
// secondary stat: "N memories waiting to be replayed". Straight off /memories total.
|
||||
let dormantTotal = $state(0);
|
||||
|
||||
const dreamScene = $derived.by<DreamScene>(() => normalizeDreamScene(dreamResult, selectedRecordId, loading));
|
||||
|
||||
|
|
@ -55,7 +58,17 @@
|
|||
.list({ limit: '80' })
|
||||
.then((res) => {
|
||||
dormantPool = res.memories;
|
||||
fieldPass?.setCells(buildFieldCells());
|
||||
// /memories total is the returned page size, not the whole store — fall
|
||||
// back to it, but prefer the real store count from /stats below.
|
||||
dormantTotal = Number(res.total ?? res.memories.length) || res.memories.length;
|
||||
fieldPass?.setCells(buildFieldCells(fieldEngine));
|
||||
})
|
||||
.catch(() => {});
|
||||
// Real store size for the dormant-pool line ("N memories waiting to be replayed").
|
||||
void api
|
||||
.stats()
|
||||
.then((s) => {
|
||||
if (Number.isFinite(s.totalMemories) && s.totalMemories > 0) dormantTotal = s.totalMemories;
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
|
@ -66,7 +79,7 @@
|
|||
error = null;
|
||||
try {
|
||||
dreamResult = await api.dream();
|
||||
fieldPass?.setCells(buildFieldCells(), { ambient: 0.5 });
|
||||
fieldPass?.setCells(buildFieldCells(fieldEngine), { ambient: 0.5 });
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
|
|
@ -75,13 +88,15 @@
|
|||
}
|
||||
|
||||
let fieldPass: LivingFieldPass | null = null;
|
||||
// Cached so setCells calls after a dream run can re-derive the live viewport aspect.
|
||||
let fieldEngine: ObservatoryEngine | null = null;
|
||||
|
||||
/**
|
||||
* Dormant → storm. At rest: the memory pool as slow dim cells (the mind
|
||||
* asleep). After a dream: the insight nodes light up bright (consolidation
|
||||
* storm). Both map to REAL data (memories list / dream insights).
|
||||
*/
|
||||
function buildFieldCells() {
|
||||
function buildFieldCells(engine: ObservatoryEngine | null = null) {
|
||||
const insights = dreamResult?.insights ?? [];
|
||||
let data: FieldDatum[];
|
||||
if (insights.length > 0) {
|
||||
|
|
@ -112,7 +127,29 @@
|
|||
} satisfies FieldDatum;
|
||||
});
|
||||
}
|
||||
return layoutGalaxy(data, { maxRadius: 0.92, minCellR: 0.014, maxCellR: 0.052 });
|
||||
const cells = layoutGalaxy(data, { maxRadius: 0.92, minCellR: 0.014, maxCellR: 0.052 });
|
||||
|
||||
// Portrait dormant screen only. The galaxy disc renders as a wide-but-short
|
||||
// band in a tall viewport, and the field is authored dim (0.26) for the
|
||||
// desktop two-column read — so the phone shows a black void around one bit of
|
||||
// centred text. Two portrait-gated (aspect < 0.85) tweaks, both derived from
|
||||
// the LIVE viewport aspect so desktop stays byte-identical:
|
||||
// 1. stretch the dormant substrate to fill the whole tall screen, and
|
||||
// 2. drop a soft focal glow-pill behind the RUN control so the bracket-text
|
||||
// reads as a lit, tappable button. Both use only real dormant cells.
|
||||
if (dreamResult || dormantPool.length === 0) return cells;
|
||||
const aspect = viewportAspect(engine);
|
||||
if (aspect >= 0.85) return cells;
|
||||
// The cell shader maps NDC y -> clip y 1:1 (no aspect divide), so a disc of
|
||||
// radius 0.92 already spans the height; nudge the vertical spread wider so the
|
||||
// substrate reaches the top and bottom thirds instead of hugging the middle.
|
||||
for (const c of cells) {
|
||||
c.y = Math.max(-0.98, Math.min(0.98, c.y * 1.35));
|
||||
}
|
||||
// The button chrome (a framed pill) is drawn in the TEXT layer instead of the
|
||||
// field — the text pass shares the label's exact vertical mapping, so a dashed
|
||||
// over/under-rule lines up perfectly, where a cross-layer field glow never did.
|
||||
return cells;
|
||||
}
|
||||
|
||||
function sanitizeAscii(value: string): string {
|
||||
|
|
@ -294,10 +331,129 @@
|
|||
};
|
||||
}
|
||||
|
||||
function buildDreamItems(scene: RouteSceneModel): DreamTextItem[] {
|
||||
/**
|
||||
* Live viewport aspect, derived from the engine params (canvas px) with a
|
||||
* window fallback — the SAME source TextLayerPass.portraitAdapt reads. Nothing
|
||||
* is hardcoded to a phone width; the portrait branch keys off aspect < 0.85
|
||||
* exactly like the shared adapter, so the desktop (aspect >= 0.85) render is
|
||||
* byte-identical.
|
||||
*/
|
||||
function viewportAspect(engine: ObservatoryEngine | null): number {
|
||||
let vw = engine?.params[6] ?? 0;
|
||||
let vh = engine?.params[7] ?? 0;
|
||||
if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') {
|
||||
vw = window.innerWidth;
|
||||
vh = window.innerHeight;
|
||||
}
|
||||
if (vw <= 0 || vh <= 0) return 1;
|
||||
return vw / vh;
|
||||
}
|
||||
|
||||
function buildDreamItems(scene: RouteSceneModel, engine: ObservatoryEngine | null = null): DreamTextItem[] {
|
||||
const dream = scene as DreamScene;
|
||||
const records = dream.records.slice(0, ROW_LIMIT);
|
||||
const items: DreamTextItem[] = [];
|
||||
// Portrait: the reading column jams every row into the top ~30% and leaves
|
||||
// the lower two-thirds an empty void, and the bare "[ RUN DREAM CYCLE ]"
|
||||
// reads as a heading, not a tappable control. On a phone (aspect < 0.85)
|
||||
// pull the dormant hero into ONE centred focal point with an explicit tap
|
||||
// affordance, so a first-timer both SEES the void filled and KNOWS the
|
||||
// primary action. Landscape/desktop keep the authored top-anchored layout.
|
||||
const portrait = viewportAspect(engine) < 0.85;
|
||||
|
||||
if (portrait && !dream.raw) {
|
||||
// Single centred focal group, vertically balanced in the viewport instead of
|
||||
// stacked at the top. portraitAdapt preserves authored SCREEN-y, so these are
|
||||
// on-screen positions. A soft glow-pill (built in buildFieldCells at the SAME
|
||||
// screen y) sits behind the RUN label so the bracket-text reads as a lit,
|
||||
// tappable button. Above it: a title. Below it: real pool stats + what a
|
||||
// cycle does — so the phone shows readable content, not a void.
|
||||
let hy = 0.5;
|
||||
items.push(
|
||||
textItem('dreams:title', 'dream-detail', 'DREAM ENGINE', hy, {
|
||||
x: -0.62,
|
||||
size: 0.03,
|
||||
color: MUTED,
|
||||
weight: 0.7
|
||||
})
|
||||
);
|
||||
// Button chrome — a dashed over/under-rule framing the label into a pill so
|
||||
// the bracket-text unmistakably reads as a tappable control. Drawn in the
|
||||
// TEXT layer at the SAME x/size as the label (monospace ⇒ exact width match),
|
||||
// so alignment is guaranteed on any portrait viewport. Same size as the label
|
||||
// so the dash-count spans the same width; dimmer oxygen so it frames, not
|
||||
// competes. The rules share the run's kind so a tap on the frame also fires.
|
||||
const runLabel = dream.busy ? '[ DREAMING... ]' : '[ RUN DREAM CYCLE ]';
|
||||
const rule = '-'.repeat(runLabel.length);
|
||||
const runY = 0.16;
|
||||
items.push(
|
||||
textItem('dreams:run-top', 'dream-action', rule, runY + 0.075, {
|
||||
x: -0.62,
|
||||
size: 0.05,
|
||||
color: dream.busy ? MUTED : OXYGEN,
|
||||
weight: 0.6
|
||||
})
|
||||
);
|
||||
hy = runY;
|
||||
items.push(
|
||||
textItem('dreams:run', 'dream-action', runLabel, hy, {
|
||||
x: -0.62,
|
||||
size: 0.05,
|
||||
color: dream.busy ? MUTED : OXYGEN,
|
||||
weight: 0.95,
|
||||
depth: 1,
|
||||
hitPadX: 0.34,
|
||||
hitPadY: 0.11
|
||||
})
|
||||
);
|
||||
items.push(
|
||||
textItem('dreams:run-bot', 'dream-action', rule, runY - 0.03, {
|
||||
x: -0.62,
|
||||
size: 0.05,
|
||||
color: dream.busy ? MUTED : OXYGEN,
|
||||
weight: 0.6
|
||||
})
|
||||
);
|
||||
hy -= 0.11;
|
||||
items.push(
|
||||
textItem('dreams:run-tap', 'dream-detail', dream.busy ? 'consolidating memory...' : 'tap the panel above to begin a cycle', hy, {
|
||||
x: -0.62,
|
||||
color: MUTED,
|
||||
size: 0.026
|
||||
})
|
||||
);
|
||||
hy -= 0.09;
|
||||
// Real secondary content: the actual dormant pool size off /memories total.
|
||||
const poolLine =
|
||||
dormantTotal > 0
|
||||
? `${dormantTotal} memories in the pool, waiting to be replayed`
|
||||
: 'loading the dormant memory pool...';
|
||||
items.push(
|
||||
textItem('dreams:pool', 'dream-detail', poolLine, hy, {
|
||||
x: -0.62,
|
||||
color: CYAN,
|
||||
size: 0.024
|
||||
})
|
||||
);
|
||||
hy -= 0.06;
|
||||
items.push(
|
||||
textItem('dreams:run-note', 'dream-detail', 'a cycle replays them, strengthens the strong,', hy, {
|
||||
x: -0.62,
|
||||
color: MUTED,
|
||||
size: 0.022
|
||||
})
|
||||
);
|
||||
hy -= 0.05;
|
||||
items.push(
|
||||
textItem('dreams:run-note2', 'dream-detail', 'and persists the new connections it finds', hy, {
|
||||
x: -0.62,
|
||||
color: MUTED,
|
||||
size: 0.022
|
||||
})
|
||||
);
|
||||
return items;
|
||||
}
|
||||
|
||||
let y = 0.76;
|
||||
items.push(
|
||||
textItem('dreams:run', 'dream-action', dream.busy ? '[ DREAMING... ]' : '[ RUN DREAM CYCLE ]', y, {
|
||||
|
|
@ -408,12 +564,12 @@
|
|||
this.engine = engine;
|
||||
this.scene = scene;
|
||||
this.text = new TextLayerPass(engine);
|
||||
void this.text.init().then(() => this.text.setText(buildDreamItems(this.scene)));
|
||||
void this.text.init().then(() => this.text.setText(buildDreamItems(this.scene, this.engine)));
|
||||
}
|
||||
|
||||
uploadScene(scene: RouteSceneModel): void {
|
||||
this.scene = scene;
|
||||
this.text.setText(buildDreamItems(scene));
|
||||
this.text.setText(buildDreamItems(scene, this.engine));
|
||||
}
|
||||
|
||||
render(pass: GPURenderPassEncoder): void {
|
||||
|
|
@ -433,7 +589,29 @@
|
|||
function createDreamPasses(engine: ObservatoryEngine, scene: RouteSceneModel): RouteFramePass[] {
|
||||
const field = new LivingFieldPass(engine);
|
||||
fieldPass = field;
|
||||
field.setCells(buildFieldCells());
|
||||
fieldEngine = engine;
|
||||
// Portrait dormant screen ran near-black: intensity 0.26 + a wide reading well
|
||||
// left the whole phone void unlit. Gate off the LIVE viewport aspect (< 0.85)
|
||||
// so the phone gets a visibly-lit dream substrate + a tight well around only the
|
||||
// centred focal group, while desktop (aspect >= 0.85) keeps the exact authored
|
||||
// dim two-column backdrop below. Nothing hardcoded to a pixel width.
|
||||
const portrait = viewportAspect(engine) < 0.85;
|
||||
if (portrait && !dreamResult) {
|
||||
// Fill the tall void with a visibly-lit dim substrate. NO reading well here:
|
||||
// the well multiplies every cell (incl. the focal glow-pill) and would kill
|
||||
// the button chrome. The bright MSDF text (ivory/cyan) sits ON TOP and stays
|
||||
// legible over a 0.42 substrate; the pill cells are marked selected so they
|
||||
// punch a clear lit panel behind the RUN label.
|
||||
field.setIntensity(0.42);
|
||||
field.setReadingWell({ x: 0, y: 0, hw: -1, hh: 0 });
|
||||
} else {
|
||||
// Desktop can carry a richer dormant/storm field because the reading well
|
||||
// protects the complete left rows and centre inspector. Keep this branch
|
||||
// aspect-gated away from portrait, whose verified 0.42 substrate is unchanged.
|
||||
field.setIntensity(portrait ? 0.26 : 0.72);
|
||||
field.setReadingWell({ x: -0.35, y: -0.05, hw: 0.72, hh: 0.9, floor: 0.06, soft: 0.22 });
|
||||
}
|
||||
field.setCells(buildFieldCells(engine));
|
||||
const fieldWrapper: RouteFramePass = {
|
||||
compute: (encoder) => field.compute(encoder),
|
||||
render: (pass) => field.render(pass),
|
||||
|
|
|
|||
|
|
@ -41,6 +41,20 @@
|
|||
let selectedCluster: DuplicateFusionCluster | null = $state(null);
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// PORTRAIT GATE — on a phone (aspect<0.85) the fixed slider-track + value row
|
||||
// can't fit small-Android width (the value clips past the right edge), so we
|
||||
// stack the slider label. Desktop (landscape) keeps the original inline row and
|
||||
// stays byte-identical. Threshold matches TextLayerPass.portraitAdapt.
|
||||
let isPortrait = $state(false);
|
||||
onMount(() => {
|
||||
const update = () => {
|
||||
isPortrait = window.innerWidth / Math.max(1, window.innerHeight) < 0.85;
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
});
|
||||
|
||||
async function detect() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
|
@ -144,8 +158,8 @@
|
|||
<!-- Header -->
|
||||
<PageHeader
|
||||
icon="duplicates"
|
||||
title="Memory Hygiene — Duplicate Detection"
|
||||
subtitle="Cosine-similarity clustering over embeddings. Oversized similarity components are quarantined for review — they chain through pairwise similarity and are not safe to merge. Dismissed clusters are hidden for this session only."
|
||||
title="Memory Hygiene: Duplicate Detection"
|
||||
subtitle="Cosine-similarity clustering over embeddings. Oversized similarity components are quarantined for review because they chain through pairwise similarity and are not safe to merge. Dismissed clusters are hidden for this session only."
|
||||
accent="synapse"
|
||||
>
|
||||
<span
|
||||
|
|
@ -159,58 +173,85 @@
|
|||
|
||||
<!-- Controls panel -->
|
||||
<div class="glass-panel pointer-events-auto flex flex-wrap items-center gap-5 rounded-2xl p-4">
|
||||
<!-- Threshold slider -->
|
||||
<label class="flex flex-1 min-w-64 items-center gap-3 text-xs text-dim">
|
||||
<span class="whitespace-nowrap">Similarity threshold</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.70"
|
||||
max="0.95"
|
||||
step="0.01"
|
||||
bind:value={threshold}
|
||||
oninput={onThresholdChange}
|
||||
class="flex-1 accent-synapse"
|
||||
aria-label="Similarity threshold"
|
||||
/>
|
||||
<span class="w-14 text-right font-mono text-sm text-bright">
|
||||
{(threshold * 100).toFixed(0)}%
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- Results pill -->
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-full border border-synapse/20 bg-synapse/10 px-3 py-1.5 text-xs text-text"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if loading}
|
||||
<span class="breathe h-2 w-2 rounded-full bg-synapse-glow text-synapse-glow"></span>
|
||||
<span>Detecting…</span>
|
||||
{:else if error}
|
||||
<span class="h-2 w-2 rounded-full bg-decay"></span>
|
||||
<span class="text-decay">Error</span>
|
||||
{:else}
|
||||
<span class="breathe h-2 w-2 rounded-full bg-synapse-glow text-synapse-glow"></span>
|
||||
<span class="tabular-nums">
|
||||
{#if visibleClusters.length < apiTotal}
|
||||
<AnimatedNumber value={visibleClusters.length} /> visible of {apiTotal} clusters
|
||||
{:else}
|
||||
<AnimatedNumber value={visibleClusters.length} />
|
||||
{visibleClusters.length === 1 ? 'cluster' : 'clusters'}
|
||||
{/if}
|
||||
· <AnimatedNumber value={totalImplicated} /> memories implicated
|
||||
<!-- Threshold slider. PORTRAIT: stack (label + value on top row, full-width
|
||||
track below) so the value never clips a narrow phone. LANDSCAPE: original
|
||||
inline row — desktop byte-identical. -->
|
||||
{#if isPortrait}
|
||||
<label class="flex w-full flex-col gap-2 text-xs text-dim">
|
||||
<span class="flex items-baseline justify-between gap-3">
|
||||
<span class="whitespace-nowrap">Similarity threshold</span>
|
||||
<span class="font-mono text-sm text-bright">{(threshold * 100).toFixed(0)}%</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.70"
|
||||
max="0.95"
|
||||
step="0.01"
|
||||
bind:value={threshold}
|
||||
oninput={onThresholdChange}
|
||||
class="w-full accent-synapse"
|
||||
aria-label="Similarity threshold"
|
||||
/>
|
||||
</label>
|
||||
{:else}
|
||||
<label class="flex flex-1 min-w-64 items-center gap-3 text-xs text-dim">
|
||||
<span class="whitespace-nowrap">Similarity threshold</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.70"
|
||||
max="0.95"
|
||||
step="0.01"
|
||||
bind:value={threshold}
|
||||
oninput={onThresholdChange}
|
||||
class="flex-1 accent-synapse"
|
||||
aria-label="Similarity threshold"
|
||||
/>
|
||||
<span class="w-14 text-right font-mono text-sm text-bright">
|
||||
{(threshold * 100).toFixed(0)}%
|
||||
</span>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={detect}
|
||||
disabled={loading}
|
||||
class="rounded-lg bg-white/[0.04] px-3 py-1.5 text-xs text-dim transition hover:bg-white/[0.08] hover:text-text disabled:opacity-40 focus:outline-none focus-visible:ring-2 focus-visible:ring-synapse/60"
|
||||
>
|
||||
Rerun
|
||||
</button>
|
||||
<!-- Results pill + Rerun. On a phone in the ERROR state these are suppressed so
|
||||
the standalone error card below is the SINGLE error affordance (message +
|
||||
Retry) instead of also showing an "Error" pill + "Rerun" here — redundant
|
||||
messaging wastes scarce vertical space. Desktop (landscape) keeps both and
|
||||
stays byte-identical. -->
|
||||
{#if !(error && isPortrait)}
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-full border border-synapse/20 bg-synapse/10 px-3 py-1.5 text-xs text-text"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if loading}
|
||||
<span class="breathe h-2 w-2 rounded-full bg-synapse-glow text-synapse-glow"></span>
|
||||
<span>Detecting…</span>
|
||||
{:else if error}
|
||||
<span class="h-2 w-2 rounded-full bg-decay"></span>
|
||||
<span class="text-decay">Error</span>
|
||||
{:else}
|
||||
<span class="breathe h-2 w-2 rounded-full bg-synapse-glow text-synapse-glow"></span>
|
||||
<span class="tabular-nums">
|
||||
{#if visibleClusters.length < apiTotal}
|
||||
<AnimatedNumber value={visibleClusters.length} /> visible of {apiTotal} clusters
|
||||
{:else}
|
||||
<AnimatedNumber value={visibleClusters.length} />
|
||||
{visibleClusters.length === 1 ? 'cluster' : 'clusters'}
|
||||
{/if}
|
||||
· <AnimatedNumber value={totalImplicated} /> memories implicated
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={detect}
|
||||
disabled={loading}
|
||||
class="rounded-lg bg-white/[0.04] px-3 py-1.5 text-xs text-dim transition hover:bg-white/[0.08] hover:text-text disabled:opacity-40 focus:outline-none focus-visible:ring-2 focus-visible:ring-synapse/60"
|
||||
>
|
||||
Rerun
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Field pick inspector -->
|
||||
|
|
|
|||
|
|
@ -66,10 +66,44 @@
|
|||
engineRef = null;
|
||||
});
|
||||
|
||||
// Live viewport aspect (canvas px) — same source portraitAdapt reads, never a
|
||||
// hardcoded phone width. Falls back to the window before frame 0, then 1.
|
||||
function viewportAspect(): number {
|
||||
const vw = engineRef?.params[6] || 0;
|
||||
const vh = engineRef?.params[7] || 0;
|
||||
if (vw > 0 && vh > 0) return vw / vh;
|
||||
if (typeof window !== 'undefined' && window.innerHeight > 0) {
|
||||
return window.innerWidth / window.innerHeight;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Field brightness for the CURRENT viewport. On desktop the neighborhood is the
|
||||
// hero (0.75). On a phone the same intensity blooms into a blinding blob behind
|
||||
// the text and destroys contrast — so portrait drops it to the documented dim-
|
||||
// backdrop range (~0.26). Gated on the LIVE aspect; desktop is unchanged.
|
||||
function fieldIntensity(): number {
|
||||
return viewportAspect() < 0.85 ? 0.24 : 0.75;
|
||||
}
|
||||
|
||||
// On portrait the neighborhood column sits dead-center over the field's bright
|
||||
// selected-cell bloom, killing text contrast. Carve a reading well over the
|
||||
// text band so the field dims there (the pass auto-reflows the well for
|
||||
// portrait). Desktop gets NO well (hw<=0 disables it) → byte-identical render.
|
||||
function applyReadingWell(field: LivingFieldPass) {
|
||||
if (viewportAspect() < 0.85) {
|
||||
field.setReadingWell({ x: 0, y: 0, hw: 1.05, hh: 0.95, floor: 0.08, soft: 0.3 });
|
||||
} else {
|
||||
field.setReadingWell({ x: 0, y: 0, hw: -1, hh: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReady(engine: ObservatoryEngine) {
|
||||
engineRef = engine;
|
||||
const field = new LivingFieldPass(engine);
|
||||
fieldPass = field;
|
||||
field.setIntensity(fieldIntensity());
|
||||
applyReadingWell(field);
|
||||
field.setCells(buildFieldCells());
|
||||
engine.addPass(field);
|
||||
const pass = new TextLayerPass(engine);
|
||||
|
|
@ -153,6 +187,10 @@
|
|||
loading = false;
|
||||
walkingFrom = null; // only the owning generation clears the label
|
||||
textPass?.setText(buildTextItems());
|
||||
// Re-assert intensity for the current aspect (a rotate between load and
|
||||
// now would otherwise keep the stale desktop bloom on a phone).
|
||||
fieldPass?.setIntensity(fieldIntensity());
|
||||
if (fieldPass) applyReadingWell(fieldPass);
|
||||
fieldPass?.setCells(buildFieldCells());
|
||||
engineRef?.demoClock.reset();
|
||||
}
|
||||
|
|
@ -203,10 +241,21 @@
|
|||
return clamp01(scalar(memory.retention, memory.retentionStrength));
|
||||
}
|
||||
|
||||
function resultLine(memory: SearchMemory): string {
|
||||
const snippet = sanitizeAscii(memory.content).replace(/\s+/g, ' ').trim().slice(0, 52);
|
||||
// Trim to a cap on a word boundary near the cap so a portrait row never ends
|
||||
// mid-token; hard-slice fallback for a single unbroken token.
|
||||
function trimSnippet(text: string, cap: number): string {
|
||||
const s = sanitizeAscii(text).replace(/\s+/g, ' ').trim();
|
||||
if (s.length <= cap) return s;
|
||||
const hard = s.slice(0, cap);
|
||||
const lastSpace = hard.lastIndexOf(' ');
|
||||
return lastSpace > cap * 0.6 ? hard.slice(0, lastSpace) : hard;
|
||||
}
|
||||
|
||||
function resultLine(memory: SearchMemory, portrait: boolean): string {
|
||||
const sim = `${Math.round(similarity(memory) * 100)}%`;
|
||||
if (portrait) return sanitizeAscii(`${trimSnippet(memory.content, 28)} ${sim}`);
|
||||
return sanitizeAscii(
|
||||
`${snippet} | ${memory.id.slice(0, 8)} | ${Math.round(similarity(memory) * 100)}% | ${Math.round(retention(memory) * 100)}%`
|
||||
`${trimSnippet(memory.content, 52)} | ${memory.id.slice(0, 8)} | ${sim} | ${Math.round(retention(memory) * 100)}%`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -247,6 +296,58 @@
|
|||
return [statusItem(emptyText, MUTED)];
|
||||
}
|
||||
|
||||
const aspect = viewportAspect();
|
||||
const portrait = aspect < 0.85;
|
||||
|
||||
if (portrait) {
|
||||
// Phone plan: ONE short focal header + a well-spaced, short-line column
|
||||
// that fits the screen band with real negative space. Row count/spacing
|
||||
// derive from the live aspect (never a fixed phone number). portraitAdapt
|
||||
// maps authored-y straight to screen-y, so we author in screen NDC.
|
||||
const portraitness = clamp01((0.85 - aspect) / (0.85 - 0.42));
|
||||
const rowCount = Math.max(9, Math.round(12 - 3 * portraitness));
|
||||
const rows = results.slice(0, rowCount);
|
||||
const header: ExploreTextItem = {
|
||||
id: 'explore:origin',
|
||||
kind: 'explore-origin',
|
||||
text: sanitizeAscii(
|
||||
seededFrom ? `NEIGHBORHOOD OF ${seededFrom} TAP TO WALK` : `NEIGHBORHOOD ${rows.length} THOUGHTS TAP TO WALK`
|
||||
),
|
||||
x: -0.82,
|
||||
y: 0.8,
|
||||
size: 0.028,
|
||||
color: MUTED,
|
||||
depth: 0.85,
|
||||
weight: 0.6,
|
||||
startFrame: REVEAL_ANCHOR,
|
||||
revealSpan: 24,
|
||||
maxWidthEm: 30
|
||||
};
|
||||
const top = 0.62;
|
||||
const bottom = -0.72;
|
||||
const rowStep = rows.length > 1 ? (top - bottom) / (rows.length - 1) : 0;
|
||||
return [
|
||||
header,
|
||||
...rows.map((memory, i) => ({
|
||||
id: `explore:${memory.id}`,
|
||||
kind: 'explore-neighbor',
|
||||
memoryId: memory.id,
|
||||
text: resultLine(memory, true),
|
||||
x: -0.82,
|
||||
y: top - i * rowStep,
|
||||
size: 0.03,
|
||||
color: CYAN,
|
||||
depth: similarity(memory),
|
||||
weight: retention(memory),
|
||||
startFrame: REVEAL_ANCHOR + i * 2,
|
||||
revealSpan: 20,
|
||||
maxWidthEm: 34,
|
||||
hitPadX: 0.04,
|
||||
hitPadY: 0.03
|
||||
}) satisfies ExploreTextItem)
|
||||
];
|
||||
}
|
||||
|
||||
const rows = results.slice(0, ROW_LIMIT);
|
||||
const top = 0.72;
|
||||
const rowStep = 1.5 / Math.max(1, ROW_LIMIT - 1);
|
||||
|
|
@ -273,7 +374,7 @@
|
|||
id: `explore:${memory.id}`,
|
||||
kind: 'explore-neighbor',
|
||||
memoryId: memory.id,
|
||||
text: resultLine(memory),
|
||||
text: resultLine(memory, false),
|
||||
x: -0.88,
|
||||
y: top - i * rowStep,
|
||||
size: 0.026,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,21 @@
|
|||
|
||||
let textPass: TextLayerPass | null = null;
|
||||
let focusedRun: string | null = null;
|
||||
// engine handle captured in the pass factory so buildTextItems can read the live
|
||||
// viewport aspect (params[6]/[7]) for portrait-only event-line shortening.
|
||||
let engineHandle: ObservatoryEngine | null = null;
|
||||
|
||||
// Live viewport aspect, same source (and window fallback) TextLayerPass.portraitAdapt
|
||||
// uses. On a phone a full 138-char event line can't be size-boosted (it already
|
||||
// fills the width), so portrait renders a SHORTER line that portraitAdapt can grow
|
||||
// to a readable size. NOTHING is hardcoded per width — it scales with aspect.
|
||||
function isPortrait(): boolean {
|
||||
const vw = engineHandle?.params[6] || 0;
|
||||
const vh = engineHandle?.params[7] || 0;
|
||||
if (vw > 0 && vh > 0) return vw / vh < 0.85;
|
||||
if (typeof window !== 'undefined' && window.innerHeight > 0) return window.innerWidth / window.innerHeight < 0.85;
|
||||
return false;
|
||||
}
|
||||
|
||||
let feedScene: RouteSceneModel = $derived(buildFeedScene($eventFeed));
|
||||
|
||||
|
|
@ -28,6 +43,7 @@
|
|||
});
|
||||
|
||||
function createFeedPasses(engine: ObservatoryEngine, scene: RouteSceneModel): RouteFramePass[] {
|
||||
engineHandle = engine;
|
||||
const field = new FeedFieldPass(engine);
|
||||
field.uploadScene(scene);
|
||||
const pass = new TextLayerPass(engine);
|
||||
|
|
@ -65,19 +81,56 @@
|
|||
private field: LivingFieldPass;
|
||||
constructor(engine: ObservatoryEngine) {
|
||||
this.field = new LivingFieldPass(engine);
|
||||
const vw = engine.params[6] || (typeof window !== 'undefined' ? window.innerWidth : 0);
|
||||
const vh = engine.params[7] || (typeof window !== 'undefined' ? window.innerHeight : 1);
|
||||
const portrait = vw / Math.max(1, vh) < 0.85;
|
||||
// Preserve the verified portrait substrate exactly; desktop has enough room
|
||||
// for a richer field while the reading well protects every feed row.
|
||||
this.field.setIntensity(portrait ? 0.22 : 0.9);
|
||||
// Feed rows are left-anchored (x=-0.9) but stream rightward as wide lines
|
||||
// (maxWidthEm 58 ~= full width), stacked from y=+0.72 down to y=-0.78. A
|
||||
// left-column-only well would leave the right half of every row drowning,
|
||||
// so the reading well spans the whole text band the rows occupy. Desktop's
|
||||
// higher floor lets structure breathe through without competing with glyphs.
|
||||
this.field.setReadingWell({
|
||||
x: portrait ? 0 : -0.08,
|
||||
y: portrait ? -0.02 : 0.62,
|
||||
hw: portrait ? 0.94 : 0.62,
|
||||
hh: portrait ? 0.84 : 0.24,
|
||||
floor: 0.06,
|
||||
soft: 0.22
|
||||
});
|
||||
}
|
||||
uploadScene(scene: RouteSceneModel): void {
|
||||
const events = scene.nodes.map((node, index) => ({ node, event: $eventFeed[index] })).filter((item) => item.event);
|
||||
const data: FieldDatum[] = events.map(({ node, event }) => ({
|
||||
id: node.source.id,
|
||||
score: node.retention,
|
||||
hue: eventImpulse01(event.type),
|
||||
energy: node.activation,
|
||||
metric2: node.trust,
|
||||
scar: event.type.includes('Deleted') || event.type.includes('Demoted') || event.type.includes('Verdict'),
|
||||
kind: 'feed-event',
|
||||
payload: event
|
||||
}));
|
||||
const data: FieldDatum[] = scene.nodes.map((node, index) => {
|
||||
const event = $eventFeed[index];
|
||||
return {
|
||||
id: node.source.id,
|
||||
score: node.retention,
|
||||
hue: event ? eventImpulse01(event.type) : [CYAN[0], CYAN[1], CYAN[2]],
|
||||
energy: node.activation,
|
||||
metric2: node.trust,
|
||||
scar: node.type.includes('Deleted') || node.type.includes('Demoted') || node.type.includes('Verdict'),
|
||||
kind: 'feed-event',
|
||||
payload: event
|
||||
};
|
||||
});
|
||||
const portrait = isPortrait();
|
||||
// A desktop feed can legitimately be quiet at the exact sampling instant.
|
||||
// Anchor that real connection state as the ambient cell so the substrate
|
||||
// stays alive without changing the verified empty portrait treatment.
|
||||
if (data.length === 0 && !portrait) {
|
||||
const connected = scene.scalars.connected > 0;
|
||||
data.push({
|
||||
id: 'feed:stream-state',
|
||||
score: connected ? 0.72 : 0.48,
|
||||
hue: [CYAN[0], CYAN[1], CYAN[2]],
|
||||
energy: scene.scalars.reconnecting > 0 ? 0.82 : 0.56,
|
||||
metric2: connected ? 0.76 : 0.42,
|
||||
kind: 'feed-stream-state',
|
||||
payload: { connected, reconnecting: scene.scalars.reconnecting > 0 }
|
||||
});
|
||||
}
|
||||
// A quiet live stream may contain only a couple real events — give those a
|
||||
// broad shockwave so the field still fills. But a busy stream (up to
|
||||
// ROW_LIMIT events) needs SMALL cells or they overlap into one blob; scale
|
||||
|
|
@ -86,9 +139,10 @@
|
|||
this.field.setCells(
|
||||
layoutGalaxy(data, {
|
||||
maxRadius: sparse ? 0.78 : 0.92,
|
||||
minCellR: sparse ? 0.24 : 0.018,
|
||||
maxCellR: sparse ? 0.32 : 0.06
|
||||
})
|
||||
minCellR: sparse ? (portrait ? 0.24 : 0.62) : 0.018,
|
||||
maxCellR: sparse ? (portrait ? 0.32 : 0.78) : 0.06
|
||||
}),
|
||||
portrait ? undefined : { ambient: 0.5 }
|
||||
);
|
||||
}
|
||||
compute(encoder: GPUCommandEncoder): void { this.field.compute(encoder); }
|
||||
|
|
@ -131,6 +185,11 @@
|
|||
|
||||
function buildTextItems(events: VestigeEvent[], connected: boolean, reconnecting: boolean): FeedTextItem[] {
|
||||
const rows = events.slice(0, ROW_LIMIT);
|
||||
// Every new-in-this-fix item is gated to portrait so the 1440px desktop render
|
||||
// stays byte-identical (the desktop path below is the ORIGINAL feed layout).
|
||||
if (isPortrait()) return buildPortraitItems(rows, connected, reconnecting);
|
||||
|
||||
// ── DESKTOP (unchanged): empty -> connection status; populated -> wide rows ──
|
||||
if (rows.length === 0) {
|
||||
return [statusItem(connectionPayload(connected, reconnecting), reconnecting ? AMBER : connected ? MUTED : SCARLET)];
|
||||
}
|
||||
|
|
@ -163,6 +222,108 @@
|
|||
});
|
||||
}
|
||||
|
||||
// ── PORTRAIT: content-first, readable feed for a phone. A first-time visitor gets
|
||||
// a real title, a plain-English status, and either a legible empty state or short
|
||||
// event rows sized to fit. All of this is portrait-only; desktop keeps its layout.
|
||||
function buildPortraitItems(rows: VestigeEvent[], connected: boolean, reconnecting: boolean): FeedTextItem[] {
|
||||
const items: FeedTextItem[] = [];
|
||||
items.push({
|
||||
id: 'feed:title',
|
||||
kind: 'feed-title',
|
||||
text: 'LIVE FEED',
|
||||
x: -0.94,
|
||||
y: 0.9,
|
||||
size: 0.04,
|
||||
color: CYAN,
|
||||
depth: 1,
|
||||
weight: 0.9,
|
||||
revealSpan: 12
|
||||
});
|
||||
items.push({
|
||||
id: 'feed:status',
|
||||
kind: 'feed-state',
|
||||
text: connectionLine(connected, reconnecting, rows.length),
|
||||
x: -0.94,
|
||||
y: 0.82,
|
||||
size: 0.022,
|
||||
color: reconnecting ? AMBER : connected ? MUTED : SCARLET,
|
||||
depth: 0.85,
|
||||
weight: 0.62,
|
||||
revealSpan: 18,
|
||||
maxWidthEm: 40
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
// Legible empty state — explain what will appear here instead of a black void.
|
||||
items.push({
|
||||
id: 'feed:empty-1',
|
||||
kind: 'feed-empty',
|
||||
text: connected ? 'WAITING FOR LIVE ACTIVITY' : 'STREAM OFFLINE',
|
||||
x: -0.7,
|
||||
y: 0.12,
|
||||
size: 0.03,
|
||||
color: connected ? CYAN : SCARLET,
|
||||
depth: 0.7,
|
||||
weight: 0.7,
|
||||
revealSpan: 20,
|
||||
maxWidthEm: 40
|
||||
});
|
||||
items.push({
|
||||
id: 'feed:empty-2',
|
||||
kind: 'feed-empty',
|
||||
text: connected
|
||||
? 'Recalls, ingests and consolidations stream here as they happen.'
|
||||
: 'Reconnecting to the live event stream...',
|
||||
x: -0.7,
|
||||
y: 0.02,
|
||||
size: 0.02,
|
||||
color: MUTED,
|
||||
depth: 0.55,
|
||||
weight: 0.5,
|
||||
revealSpan: 24,
|
||||
maxWidthEm: 40
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
// A full 138-char line already fills the phone width, so portraitAdapt can't
|
||||
// grow it and it renders phone-tiny. Render a SHORT line (type + summary, no id),
|
||||
// fewer rows, at a larger authored size that fits and reads.
|
||||
const visible = rows.slice(0, 12);
|
||||
const top = 0.68;
|
||||
const rowStep = 0.13;
|
||||
visible.forEach((event, index) => {
|
||||
const key = eventKey(event, index);
|
||||
items.push({
|
||||
id: `feed:${key}`,
|
||||
kind: 'feed-event',
|
||||
event,
|
||||
eventKey: key,
|
||||
text: eventLineShort(event),
|
||||
x: -0.9,
|
||||
y: top - index * rowStep,
|
||||
size: 0.03,
|
||||
color: eventColor(event),
|
||||
depth: 0.6 + 0.4 * recencyDepth(visible, event, index),
|
||||
weight: eventEnergy(event),
|
||||
startFrame: 0,
|
||||
revealSpan: 1,
|
||||
maxWidthEm: 34,
|
||||
hitPadX: 0.03,
|
||||
hitPadY: 0.013
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
// Human-readable status line — never a raw ISO timestamp (it overflowed the phone
|
||||
// edge) and never JSON. Short, self-explanatory, and count-aware.
|
||||
function connectionLine(connected: boolean, reconnecting: boolean, count: number): string {
|
||||
const state = reconnecting ? 'RECONNECTING' : connected ? 'CONNECTED' : 'OFFLINE';
|
||||
const suffix = count > 0 ? `${count} EVENT${count === 1 ? '' : 'S'}` : 'LIVE';
|
||||
return sanitizeAscii(`${state} - ${suffix}`);
|
||||
}
|
||||
|
||||
function statusItem(text: string, color: [number, number, number, number]): FeedTextItem {
|
||||
return {
|
||||
id: 'feed:connection-state',
|
||||
|
|
@ -179,11 +340,22 @@
|
|||
};
|
||||
}
|
||||
|
||||
function connectionPayload(connected: boolean, reconnecting: boolean): string {
|
||||
return JSON.stringify({ connected, reconnecting, events: 0 });
|
||||
}
|
||||
|
||||
function eventLine(event: VestigeEvent, key: string): string {
|
||||
const summary = payloadSummary(event.data);
|
||||
return sanitizeAscii(`${event.type} | ${key.slice(0, 24)} | ${summary}`.slice(0, 138));
|
||||
}
|
||||
|
||||
// Portrait row: short enough that portraitAdapt can size-boost it to a readable
|
||||
// height on a phone. Event type + a compact payload summary, no id column.
|
||||
function eventLineShort(event: VestigeEvent): string {
|
||||
const summary = payloadSummary(event.data);
|
||||
return sanitizeAscii(`${event.type} - ${summary}`.slice(0, 46));
|
||||
}
|
||||
|
||||
function payloadSummary(data: Record<string, unknown>): string {
|
||||
const pairs = Object.entries(data)
|
||||
.filter(([, value]) => value !== null && value !== undefined && typeof value !== 'object')
|
||||
|
|
@ -208,10 +380,6 @@
|
|||
return sanitizeAscii(String(raw));
|
||||
}
|
||||
|
||||
function connectionPayload(connected: boolean, reconnecting: boolean): string {
|
||||
return JSON.stringify({ connected, reconnecting, events: 0 });
|
||||
}
|
||||
|
||||
function eventEnergy(event: VestigeEvent): number {
|
||||
const d = event.data;
|
||||
const candidates = [
|
||||
|
|
@ -291,6 +459,6 @@
|
|||
passes={createFeedPasses}
|
||||
loading={false}
|
||||
error={null}
|
||||
emptyLabel={connectionPayload($isConnected, $isReconnecting)}
|
||||
emptyLabel=""
|
||||
onpick={handleRoutePick}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -57,9 +57,37 @@
|
|||
function clampGraph(v: number): number {
|
||||
return Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0));
|
||||
}
|
||||
// Field intensity derived from the LIVE viewport aspect (engine params, window
|
||||
// fallback) — nothing hardcoded per phone width. Desktop/landscape (aspect>=0.85)
|
||||
// keeps the field as the hero at 0.8. On portrait phones the field is a blinding
|
||||
// blob behind the DOM controls (bottom stats, top chips); dim it toward a
|
||||
// backdrop the tighter the aspect gets, so the foreground UI reads.
|
||||
function fieldIntensityForAspect(engine: ObservatoryEngine): number {
|
||||
let vw = engine.params[6] || 0;
|
||||
let vh = engine.params[7] || 0;
|
||||
if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') {
|
||||
vw = window.innerWidth;
|
||||
vh = window.innerHeight;
|
||||
}
|
||||
if (vw <= 0 || vh <= 0) return 0.8;
|
||||
const aspect = vw / vh;
|
||||
if (aspect >= 0.85) return 0.8;
|
||||
// portraitness 0 at aspect 0.85 -> 1 at aspect 0.46 (narrow phone). Ramp the
|
||||
// hero 0.8 down to a dim ~0.34 backdrop so the bright bloom core stops
|
||||
// competing with the DOM controls and reads as a backdrop, not a blob.
|
||||
const portraitness = Math.min(1, Math.max(0, (0.85 - aspect) / (0.85 - 0.46)));
|
||||
return 0.8 - 0.46 * portraitness;
|
||||
}
|
||||
|
||||
function handleFieldReady(engine: ObservatoryEngine) {
|
||||
const field = new LivingFieldPass(engine);
|
||||
graphFieldPass = field;
|
||||
// Visual organ: the field IS the content (the living memory galaxy), so on
|
||||
// desktop keep it the hero at high intensity. No reading well — nothing to
|
||||
// protect for legibility; the graph cells are the thing to read. On portrait
|
||||
// the intensity is dimmed (aspect-derived) so the blinding core stops fighting
|
||||
// the DOM controls.
|
||||
field.setIntensity(fieldIntensityForAspect(engine));
|
||||
field.setCells(buildGraphFieldCells());
|
||||
engine.addPass(field);
|
||||
}
|
||||
|
|
@ -177,8 +205,11 @@
|
|||
|
||||
onMount(() => {
|
||||
// Renderer choice: honor the saved preference, defaulting to the WebGPU
|
||||
// field wherever the GPU exists. No WebGPU → classic, always.
|
||||
const hasWebGpu = typeof navigator !== 'undefined' && 'gpu' in navigator;
|
||||
// field wherever the GPU exists. No WebGPU → classic, always. NOTE: check
|
||||
// navigator.gpu is TRUTHY, not just `'gpu' in navigator` — the key can exist
|
||||
// while the value is undefined (feature present but disabled), which would
|
||||
// otherwise send us into field mode straight to the offline card.
|
||||
const hasWebGpu = typeof navigator !== 'undefined' && !!navigator.gpu;
|
||||
let saved: string | null = null;
|
||||
try {
|
||||
saved = localStorage.getItem(RENDERER_KEY);
|
||||
|
|
@ -349,7 +380,7 @@ disown</code>
|
|||
<div class="text-center space-y-4 max-w-md px-8 enter">
|
||||
<div class="mx-auto w-fit text-synapse-glow opacity-50 breathe"><Icon name="graph" size={52} strokeWidth={1.2} /></div>
|
||||
<h2 class="text-xl text-bright text-aurora">Your Mind Awaits</h2>
|
||||
<p class="text-dim text-sm">No memories yet — the moment Vestige starts remembering, your constellation will bloom here.</p>
|
||||
<p class="text-dim text-sm">No memories yet. The moment Vestige starts remembering, your constellation will bloom here.</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if error}
|
||||
|
|
@ -425,7 +456,7 @@ disown</code>
|
|||
aria-checked={renderMode === 'field'}
|
||||
onclick={() => setRenderMode('field')}
|
||||
class="min-h-9 px-3 py-1.5 rounded-lg transition {renderMode === 'field' ? 'bg-[#5dcaa5]/25 text-[#7fe6c0]' : 'text-dim hover:text-text'}"
|
||||
title="Raw-WebGPU living memory field — visuals to the max"
|
||||
title="Raw-WebGPU living memory field: visuals to the max"
|
||||
>
|
||||
✦ Field
|
||||
</button>
|
||||
|
|
@ -435,7 +466,7 @@ disown</code>
|
|||
aria-checked={renderMode === 'classic'}
|
||||
onclick={() => setRenderMode('classic')}
|
||||
class="min-h-9 px-3 py-1.5 rounded-lg transition {renderMode === 'classic' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
|
||||
title="Classic 3D inspector — node picking, colour modes, temporal scrubbing"
|
||||
title="Classic 3D inspector: node picking, colour modes, temporal scrubbing"
|
||||
>
|
||||
Classic
|
||||
</button>
|
||||
|
|
@ -706,3 +737,18 @@ disown</code>
|
|||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Phone/narrow layout: the global MobileNav FAB (bottom-centre, fixed) shows at
|
||||
<=820px and owns the bottom-centre thumb band. The graph stats pill defaults to
|
||||
bottom-left, but its text runs wide enough to slide under the centred FAB — the
|
||||
node/edge/depth counts get clipped and the FAB overprints them. Lift the pill
|
||||
clear ABOVE the FAB band so both read. The offset clears the FAB's own
|
||||
rem-based height (not a viewport-derived constant); it tracks the same 820px
|
||||
breakpoint the FAB uses to appear, so desktop stays byte-identical. */
|
||||
@media (max-width: 820px) {
|
||||
:global(.graph-stats-pill) {
|
||||
bottom: calc(max(1rem, env(safe-area-inset-bottom)) + 4.25rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -20,11 +20,30 @@
|
|||
const SCARLET = [...rgb01('#FF3B30'), 0.92] satisfies [number, number, number, number];
|
||||
const MUTED = [...rgb01('#29F2A9'), 0.62] satisfies [number, number, number, number];
|
||||
const AMBER = [...rgb01('#FFB020'), 0.86] satisfies [number, number, number, number];
|
||||
const TITLE = [...rgb01('#EAF3FF'), 0.96] satisfies [number, number, number, number];
|
||||
const HEADER = [...rgb01('#7FA0B8'), 0.72] satisfies [number, number, number, number];
|
||||
const MEMORY_LIMIT = 36;
|
||||
const ROW_LIMIT = 30;
|
||||
// Phone shows fewer rows so each gets real vertical breathing room instead of a
|
||||
// 30-deep crushed wall of tiny text.
|
||||
const ROW_LIMIT_PORTRAIT = 16;
|
||||
const REVEAL_ANCHOR = -100000;
|
||||
const MIN_VISIBLE_DEPTH = 0.62;
|
||||
|
||||
// Portrait / narrow viewport, derived from the LIVE engine aspect (params[6]/[7])
|
||||
// with a window fallback — same signal TextLayerPass.portraitAdapt uses. Nothing
|
||||
// is hardcoded to a phone width; desktop (aspect>=0.85) is untouched.
|
||||
function isPortrait(): boolean {
|
||||
let vw = engineRef?.params[6] || 0;
|
||||
let vh = engineRef?.params[7] || 0;
|
||||
if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') {
|
||||
vw = window.innerWidth;
|
||||
vh = window.innerHeight;
|
||||
}
|
||||
if (vw <= 0 || vh <= 0) return false;
|
||||
return vw / vh < 0.85;
|
||||
}
|
||||
|
||||
let hostEl: HTMLDivElement | null = $state(null);
|
||||
let engineRef: ObservatoryEngine | null = null;
|
||||
let textPass: TextLayerPass | null = null;
|
||||
|
|
@ -67,6 +86,11 @@
|
|||
stopReducedMotion = initReducedMotion(engine);
|
||||
const field = new LivingFieldPass(engine);
|
||||
fieldPass = field;
|
||||
// Text-heavy organ: keep the field a DIM backdrop so 30 rows of MSDF text
|
||||
// read cleanly. Rows anchor at x=-0.9 and run rightward (~54em), y from 0.72
|
||||
// down to ~-0.78, so the reading well covers the whole left+center column.
|
||||
field.setIntensity(0.22);
|
||||
field.setReadingWell({ x: -0.3, y: -0.03, hw: 0.7, hh: 0.88, floor: 0.08, soft: 0.25 });
|
||||
field.setCells(buildFieldCells());
|
||||
engine.addPass(field);
|
||||
const pass = new TextLayerPass(engine);
|
||||
|
|
@ -129,8 +153,19 @@
|
|||
.replace(/[^\x20-\x7E]/g, '?');
|
||||
}
|
||||
|
||||
function importanceLine(record: ImportanceRecord): string {
|
||||
function importanceLine(record: ImportanceRecord, portrait: boolean): string {
|
||||
const { memory, score } = record;
|
||||
if (portrait) {
|
||||
// Phone: drop the id/retention/recommendation/channel columns that turn the
|
||||
// row into an unreadable wall. Keep the primary content (a longer title
|
||||
// budget) and the ONE headline metric — composite score — right-aligned by
|
||||
// the header. `title 92%` reads at a glance; the full detail lives on tap.
|
||||
const snippet = sanitizeAscii(memory.content ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 34);
|
||||
return sanitizeAscii(`${snippet} ${Math.round(score.composite * 100)}%`);
|
||||
}
|
||||
const snippet = sanitizeAscii(memory.content ?? '').replace(/\s+/g, ' ').trim().slice(0, 44);
|
||||
const strongest = strongestChannel(score);
|
||||
return sanitizeAscii(
|
||||
|
|
@ -168,22 +203,58 @@
|
|||
if (error) return [statusItem(`ERROR - ${error}`.slice(0, 72), SCARLET)];
|
||||
if (records.length === 0) return [statusItem('EMPTY IMPORTANCE FIELD', MUTED)];
|
||||
|
||||
const rows = records.slice(0, ROW_LIMIT);
|
||||
const top = 0.72;
|
||||
const rowStep = 1.5 / Math.max(1, ROW_LIMIT - 1);
|
||||
return rows.map((record, i) => {
|
||||
const portrait = isPortrait();
|
||||
const rowLimit = portrait ? ROW_LIMIT_PORTRAIT : ROW_LIMIT;
|
||||
const rows = records.slice(0, rowLimit);
|
||||
// Phone gets a title + column header and a shorter, more generously spaced
|
||||
// list; desktop keeps the dense edge-to-edge log exactly as authored.
|
||||
const top = portrait ? 0.6 : 0.72;
|
||||
const span = portrait ? 1.28 : 1.5;
|
||||
const rowStep = span / Math.max(1, rowLimit - 1);
|
||||
const items: ImportanceTextItem[] = [];
|
||||
|
||||
if (portrait) {
|
||||
items.push({
|
||||
id: 'importance:title',
|
||||
kind: 'importance-title',
|
||||
text: 'IMPORTANCE',
|
||||
x: -0.62,
|
||||
y: 0.86,
|
||||
size: 0.06,
|
||||
color: TITLE,
|
||||
depth: 1,
|
||||
weight: 0.9,
|
||||
startFrame: REVEAL_ANCHOR,
|
||||
revealSpan: 1
|
||||
});
|
||||
items.push({
|
||||
id: 'importance:header',
|
||||
kind: 'importance-header',
|
||||
text: `MEMORY / SCORE (${records.length})`,
|
||||
x: -0.62,
|
||||
y: 0.74,
|
||||
size: 0.03,
|
||||
color: HEADER,
|
||||
depth: 0.9,
|
||||
weight: 0.5,
|
||||
startFrame: REVEAL_ANCHOR,
|
||||
revealSpan: 1
|
||||
});
|
||||
}
|
||||
|
||||
rows.forEach((record, i) => {
|
||||
// depth = crispness/forward channel — floor it or the DOF blur + dim glow
|
||||
// makes low-composite rows invisible (composite is low for most memories).
|
||||
const depth = Math.max(MIN_VISIBLE_DEPTH, clamp01(record.score.composite));
|
||||
const weight = clamp01(record.memory.retentionStrength);
|
||||
return {
|
||||
items.push({
|
||||
id: `importance:${record.memory.id}`,
|
||||
kind: 'importance',
|
||||
memoryId: record.memory.id,
|
||||
text: importanceLine(record),
|
||||
x: -0.9,
|
||||
text: importanceLine(record, portrait),
|
||||
x: portrait ? -0.62 : -0.9,
|
||||
y: top - i * rowStep,
|
||||
size: 0.025,
|
||||
size: portrait ? 0.03 : 0.025,
|
||||
color: record.score.recommendation === 'save' ? CYAN : AMBER,
|
||||
depth,
|
||||
weight,
|
||||
|
|
@ -196,8 +267,9 @@
|
|||
maxWidthEm: 54,
|
||||
hitPadX: 0.03,
|
||||
hitPadY: 0.018
|
||||
};
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
function pointerToNdc(e: PointerEvent | MouseEvent): { x: number; y: number } | null {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,14 @@
|
|||
related_memories?: string[];
|
||||
};
|
||||
type IntentionTextItem = TextLayerItem & { intentionId?: string };
|
||||
type PredictedIntent = { id: string; content: string; nodeType: string; predictedNeed: string; retention: number };
|
||||
type PredictedIntent = {
|
||||
id: string;
|
||||
content: string;
|
||||
nodeType: string;
|
||||
predictedNeed: string;
|
||||
retention: number;
|
||||
urgency?: number;
|
||||
};
|
||||
|
||||
const CYAN = [...rgb01(CAUSAL.forward), 1] satisfies [number, number, number, number];
|
||||
const LUCIFERIN = [...rgb01(RETENTION.luciferin), 0.88] satisfies [number, number, number, number];
|
||||
|
|
@ -38,6 +45,30 @@
|
|||
let error: string | null = $state(null);
|
||||
let selectedIntentionId: string | null = $state(null);
|
||||
let textPass: TextLayerPass | null = null;
|
||||
let engineRef: ObservatoryEngine | null = null;
|
||||
|
||||
// Live viewport aspect (canvas px) — same signal TextLayerPass.portraitAdapt
|
||||
// reads (engine.params[6]/[7]), with a window fallback for the pre-frame-0
|
||||
// pass. NEVER a hardcoded phone width; desktop (aspect>=0.85) is untouched.
|
||||
function viewportAspect(): number {
|
||||
let vw = engineRef?.params[6] || 0;
|
||||
let vh = engineRef?.params[7] || 0;
|
||||
if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') {
|
||||
vw = window.innerWidth;
|
||||
vh = window.innerHeight;
|
||||
}
|
||||
if (vw <= 0 || vh <= 0) return 1;
|
||||
return vw / vh;
|
||||
}
|
||||
|
||||
// Trim to a cap on a word boundary so a portrait row never ends mid-token.
|
||||
function trimSnippet(text: string, cap: number): string {
|
||||
const s = sanitizeAscii(text).replace(/\s+/g, ' ').trim();
|
||||
if (s.length <= cap) return s;
|
||||
const hard = s.slice(0, cap);
|
||||
const lastSpace = hard.lastIndexOf(' ');
|
||||
return lastSpace > cap * 0.6 ? hard.slice(0, lastSpace) : hard;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void loadIntentions(ACTIVE_FILTER);
|
||||
|
|
@ -46,6 +77,7 @@
|
|||
onDestroy(() => {
|
||||
textPass?.dispose();
|
||||
textPass = null;
|
||||
engineRef = null;
|
||||
});
|
||||
|
||||
async function loadIntentions(nextFilter = filter) {
|
||||
|
|
@ -70,6 +102,7 @@
|
|||
}
|
||||
|
||||
function createIntentionsPasses(engine: ObservatoryEngine, scene: RouteSceneModel): RouteFramePass[] {
|
||||
engineRef = engine;
|
||||
const field = new IntentionsFieldPass(engine);
|
||||
field.uploadScene(scene);
|
||||
const pass = new TextLayerPass(engine);
|
||||
|
|
@ -81,13 +114,33 @@
|
|||
|
||||
class IntentionsFieldPass implements RouteFramePass {
|
||||
private field: LivingFieldPass;
|
||||
constructor(engine: ObservatoryEngine) { this.field = new LivingFieldPass(engine); }
|
||||
private desktop: boolean;
|
||||
constructor(engine: ObservatoryEngine) {
|
||||
this.field = new LivingFieldPass(engine);
|
||||
this.desktop = viewportAspect() >= 0.85;
|
||||
// Portrait keeps its verified dim backdrop exactly. Desktop can carry a much
|
||||
// richer field because the reading well protects the intention rows.
|
||||
this.field.setIntensity(this.desktop ? 1.6 : 0.24);
|
||||
// On desktop, leave more living field around the reading column while keeping
|
||||
// the complete row span inside a soft, low-luminance well.
|
||||
this.field.setReadingWell(
|
||||
this.desktop
|
||||
? { x: -0.2, y: 0.05, hw: 0.58, hh: 0.62, floor: 0.08, soft: 0.18 }
|
||||
: { x: -0.2, y: 0.05, hw: 0.85, hh: 0.92, floor: 0.08, soft: 0.25 }
|
||||
);
|
||||
}
|
||||
uploadScene(scene: RouteSceneModel): void {
|
||||
const data: FieldDatum[] = scene.nodes.map((node) => ({ id: node.source.id, score: node.activation ?? node.retention, hue: FIELD_HUE.forward, energy: node.activation, metric2: node.retention, selected: node.source.id === selectedIntentionId, kind: 'intention', payload: node }));
|
||||
// RouteStage now picks text chrome (front) before field cells (behind),
|
||||
// so the galaxy can fill without stealing the filter toggle's click.
|
||||
const sparse = data.length < 4;
|
||||
this.field.setCells(layoutGalaxy(data, { maxRadius: 0.82, minCellR: sparse ? 0.22 : 0.025, maxCellR: sparse ? 0.3 : 0.075 }));
|
||||
this.field.setCells(
|
||||
layoutGalaxy(data, {
|
||||
maxRadius: this.desktop ? 0.9 : 0.82,
|
||||
minCellR: sparse ? (this.desktop ? 0.56 : 0.22) : 0.025,
|
||||
maxCellR: sparse ? (this.desktop ? 0.7 : 0.3) : 0.075
|
||||
})
|
||||
);
|
||||
}
|
||||
compute(encoder: GPUCommandEncoder): void { this.field.compute(encoder); }
|
||||
render(pass: GPURenderPassEncoder): void { this.field.render(pass); }
|
||||
|
|
@ -138,7 +191,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
function intentionLine(intention: RichIntention): string {
|
||||
function intentionLine(intention: RichIntention, portrait = false): string {
|
||||
// Portrait: drop the id + trigger columns and shorten the snippet so the row
|
||||
// fits the narrow width on one readable line — never edge-to-edge, never
|
||||
// truncated mid-word. Desktop keeps the full, byte-identical row.
|
||||
if (portrait) {
|
||||
// Word-boundary trim so the row never ends mid-token. Cap sized so the
|
||||
// snippet + the trailing " pN status" tag still fits the narrow portrait
|
||||
// width on one line (verified live at 360 and 390).
|
||||
const content = trimSnippet(intention.content, 34);
|
||||
const status = sanitizeAscii(intention.status).slice(0, 8);
|
||||
return sanitizeAscii(`${content} p${intention.priority} ${status}`);
|
||||
}
|
||||
const content = sanitizeAscii(intention.content).replace(/\s+/g, ' ').trim().slice(0, 48);
|
||||
const status = sanitizeAscii(intention.status).slice(0, 12);
|
||||
const trigger = summarizeTrigger(intention);
|
||||
|
|
@ -165,7 +229,31 @@
|
|||
|
||||
// A dedicated, explicit filter toggle (active <-> all). Selecting an intention
|
||||
// must NOT silently flip the global filter — that lives here as its own target.
|
||||
function filterToggleItem(): IntentionTextItem {
|
||||
function filterToggleItem(portrait = false): IntentionTextItem {
|
||||
if (portrait) {
|
||||
// Portrait: a readable, centred heading pinned to the top band. It owns its
|
||||
// OWN mobile layout (portraitAdapt treats intention-filter as body text, so
|
||||
// author in the reclaimed-y space; the near-centre x keeps it on-screen after
|
||||
// the size boost). Drop the "SHOWING:" prefix — the shorter label leaves a
|
||||
// real right-edge margin at 360 so the closing bracket never clips.
|
||||
const portraitText =
|
||||
filter === ACTIVE_FILTER ? '[ ACTIVE - TAP FOR ALL ]' : '[ ALL - TAP FOR ACTIVE ]';
|
||||
return {
|
||||
id: 'intentions:filter',
|
||||
kind: 'intention-filter',
|
||||
text: portraitText,
|
||||
x: -0.5,
|
||||
y: 0.86,
|
||||
size: 0.03,
|
||||
color: AMBER,
|
||||
depth: 1,
|
||||
weight: 0.7,
|
||||
revealSpan: 12,
|
||||
maxWidthEm: 34,
|
||||
hitPadX: 0.06,
|
||||
hitPadY: 0.04
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: 'intentions:filter',
|
||||
kind: 'intention-filter',
|
||||
|
|
@ -189,6 +277,99 @@
|
|||
function buildTextItems(): IntentionTextItem[] {
|
||||
if (loading) return [statusItem('LOADING INTENTION FIELD...', CYAN)];
|
||||
if (error) return [statusItem(`ERROR - ${error}`.slice(0, 72), SCARLET)];
|
||||
|
||||
const portrait = viewportAspect() < 0.85;
|
||||
|
||||
if (portrait) {
|
||||
// Phone plan: ONE focal heading + a short, well-spaced column with real
|
||||
// negative space. Row count derives from the LIVE aspect (taller/narrower
|
||||
// -> fewer rows), never a fixed phone number. When the field is empty or
|
||||
// tiny, a content-first focal message tells a first-timer what they're
|
||||
// looking at instead of leaving a black void.
|
||||
// Instant reveal on portrait: the MSDF reveal gate bumps ageFrame by
|
||||
// (globalGlyphIndex*2) and discards glyphs whose ageFrame exceeds the frame
|
||||
// loop, so a per-row startFrame leaves long portrait rows half-drawn ("...is
|
||||
// li"). A large negative startFrame + revealSpan 1 saturates reveal to 1 on
|
||||
// frame 0 so EVERY row renders in full immediately. Same trick as schedule.
|
||||
const REVEAL = -100000;
|
||||
const items: IntentionTextItem[] = [filterToggleItem(true)];
|
||||
if (intentions.length === 0) {
|
||||
items.push({
|
||||
id: 'intentions:empty',
|
||||
kind: 'intention-status',
|
||||
text: 'NO ACTIVE INTENTIONS',
|
||||
x: -0.5,
|
||||
y: 0.18,
|
||||
size: 0.04,
|
||||
color: MUTED,
|
||||
depth: 0.9,
|
||||
weight: 0.6,
|
||||
startFrame: REVEAL,
|
||||
revealSpan: 1,
|
||||
maxWidthEm: 22
|
||||
});
|
||||
items.push({
|
||||
id: 'intentions:empty-sub',
|
||||
kind: 'intention-status',
|
||||
text: 'Vestige is watching for triggers. Tap the heading to see all.',
|
||||
x: -0.72,
|
||||
y: 0.02,
|
||||
size: 0.026,
|
||||
color: MUTED,
|
||||
depth: 0.78,
|
||||
weight: 0.5,
|
||||
startFrame: REVEAL,
|
||||
revealSpan: 1,
|
||||
maxWidthEm: 30
|
||||
});
|
||||
return items;
|
||||
}
|
||||
const aspect = viewportAspect();
|
||||
const portraitness = clamp01((0.85 - aspect) / (0.85 - 0.42));
|
||||
const rowCount = Math.max(6, Math.round(10 - 4 * portraitness));
|
||||
const rows = intentions.slice(0, rowCount);
|
||||
// A count line so a single-row field reads as "1 of 1", not a lone stray.
|
||||
items.push({
|
||||
id: 'intentions:count',
|
||||
kind: 'intention-status',
|
||||
text: `${intentions.length} ACTIVE FOCUS${intentions.length === 1 ? '' : 'ES'}`,
|
||||
x: -0.62,
|
||||
y: 0.66,
|
||||
size: 0.026,
|
||||
color: MUTED,
|
||||
depth: 0.8,
|
||||
weight: 0.5,
|
||||
startFrame: REVEAL,
|
||||
revealSpan: 1,
|
||||
maxWidthEm: 28
|
||||
});
|
||||
const top = 0.5;
|
||||
const bottom = -0.7;
|
||||
const rowStep = rows.length > 1 ? (top - bottom) / (rows.length - 1) : 0;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const intention = rows[i];
|
||||
const active = selectedIntentionId === intention.id;
|
||||
items.push({
|
||||
id: `intent:${intention.id}`,
|
||||
kind: 'intention',
|
||||
intentionId: intention.id,
|
||||
text: intentionLine(intention, true),
|
||||
x: -0.82,
|
||||
y: top - i * rowStep,
|
||||
size: active ? 0.032 : 0.03,
|
||||
color: active ? LUCIFERIN : CYAN,
|
||||
depth: active ? 1 : Math.max(0.7, intentionDepth(intention)),
|
||||
weight: statusWeight(intention),
|
||||
startFrame: REVEAL,
|
||||
revealSpan: 1,
|
||||
maxWidthEm: 34,
|
||||
hitPadX: 0.05,
|
||||
hitPadY: 0.03
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
if (intentions.length === 0) return [filterToggleItem(), statusItem(`EMPTY ${filter.toUpperCase()} INTENTION FIELD`, MUTED)];
|
||||
|
||||
const items: IntentionTextItem[] = [filterToggleItem()];
|
||||
|
|
@ -238,7 +419,16 @@
|
|||
index: intentions.length + offset,
|
||||
label: sanitizeAscii(prediction.content).slice(0, 48),
|
||||
retention: clamp01(prediction.retention),
|
||||
activation: prediction.predictedNeed === 'high' ? 1 : prediction.predictedNeed === 'medium' ? 0.65 : 0.35,
|
||||
// Real continuous urgency (FSRS decay + review schedule) drives brightness.
|
||||
// Fall back to the high/medium/low band only if an older backend omits it.
|
||||
activation:
|
||||
typeof prediction.urgency === 'number'
|
||||
? clamp01(prediction.urgency)
|
||||
: prediction.predictedNeed === 'high'
|
||||
? 1
|
||||
: prediction.predictedNeed === 'medium'
|
||||
? 0.65
|
||||
: 0.35,
|
||||
trust: clamp01(prediction.retention),
|
||||
tags: [prediction.predictedNeed],
|
||||
type: prediction.nodeType
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@
|
|||
// glowing cell, retention = oxygen. The cursor-parallax text field rides on top.
|
||||
const field = new LivingFieldPass(engine);
|
||||
fieldPass = field;
|
||||
field.setIntensity(0.8);
|
||||
field.setCells(buildFieldCells());
|
||||
engine.addPass(field);
|
||||
const pass = new TextLayerPass(engine);
|
||||
|
|
@ -127,11 +128,39 @@
|
|||
.replace(/[^\x20-\x7E]/g, '?');
|
||||
}
|
||||
|
||||
function memoryLine(memory: Memory): string {
|
||||
const snippet = sanitizeAscii(memory.content).replace(/\s+/g, ' ').trim().slice(0, 52);
|
||||
return sanitizeAscii(
|
||||
`${snippet} | ${memory.id.slice(0, 8)} | ${Math.round(memory.retentionStrength * 100)}%`
|
||||
);
|
||||
// Live viewport aspect (canvas px) — same source portraitAdapt reads, never a
|
||||
// hardcoded phone width. Falls back to the window before frame 0, then 1.
|
||||
function viewportAspect(): number {
|
||||
const vw = engineRef?.params[6] || 0;
|
||||
const vh = engineRef?.params[7] || 0;
|
||||
if (vw > 0 && vh > 0) return vw / vh;
|
||||
if (typeof window !== 'undefined' && window.innerHeight > 0) {
|
||||
return window.innerWidth / window.innerHeight;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Portrait rows must READ, not dump. A phone can only fit ~10-12 rows with the
|
||||
// generous spacing the taste bar demands, and long lines would shrink to
|
||||
// illegible to fit width. So on portrait we shorten each line (drop the id
|
||||
// column, tighten the snippet) so it never runs edge-to-edge, and the caller
|
||||
// caps the row count. Everything is gated on the LIVE aspect — desktop keeps
|
||||
// the full id column and long snippet, byte-identical.
|
||||
// Trim a snippet to a cap, breaking on the last word boundary near the cap so a
|
||||
// portrait row never ends mid-token ("Jul 202"); falls back to a hard slice for
|
||||
// a single unbroken token.
|
||||
function trimSnippet(text: string, cap: number): string {
|
||||
const s = sanitizeAscii(text).replace(/\s+/g, ' ').trim();
|
||||
if (s.length <= cap) return s;
|
||||
const hard = s.slice(0, cap);
|
||||
const lastSpace = hard.lastIndexOf(' ');
|
||||
return lastSpace > cap * 0.6 ? hard.slice(0, lastSpace) : hard;
|
||||
}
|
||||
|
||||
function memoryLine(memory: Memory, portrait: boolean): string {
|
||||
const pct = `${Math.round(memory.retentionStrength * 100)}%`;
|
||||
if (portrait) return sanitizeAscii(`${trimSnippet(memory.content, 28)} ${pct}`);
|
||||
return sanitizeAscii(`${trimSnippet(memory.content, 52)} | ${memory.id.slice(0, 8)} | ${pct}`);
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
|
|
@ -159,6 +188,64 @@
|
|||
if (error) return [statusItem(`ERROR - ${error}`.slice(0, 72), SCARLET)];
|
||||
if (memories.length === 0) return [statusItem('EMPTY MEMORY FIELD', MUTED)];
|
||||
|
||||
const aspect = viewportAspect();
|
||||
const portrait = aspect < 0.85;
|
||||
|
||||
if (portrait) {
|
||||
// Phone plan: ONE focal header + a short, well-spaced column that fits the
|
||||
// screen band with real negative space. Row count and spacing derive from
|
||||
// the live aspect (taller/narrower → fewer rows), never a fixed phone
|
||||
// number. portraitAdapt maps authored-y straight to screen-y (its inv
|
||||
// reclaim and the shader's aspect crush cancel), so we author in screen NDC.
|
||||
const portraitness = clamp01((0.85 - aspect) / (0.85 - 0.42));
|
||||
// 12 rows at the wide-portrait edge, down to 9 on the tallest phones —
|
||||
// enough to be a real list, few enough to breathe and never collide.
|
||||
const rowCount = Math.max(9, Math.round(12 - 3 * portraitness));
|
||||
const rows = memories.slice(0, rowCount);
|
||||
const headerY = 0.8;
|
||||
const top = 0.62;
|
||||
const bottom = -0.72;
|
||||
const rowStep = rows.length > 1 ? (top - bottom) / (rows.length - 1) : 0;
|
||||
const header: MemoryTextItem = {
|
||||
id: 'memories:header',
|
||||
kind: 'memory-header',
|
||||
text: `MEMORY FIELD ${total} TRACES`,
|
||||
x: -0.82,
|
||||
y: headerY,
|
||||
size: 0.03,
|
||||
color: MUTED,
|
||||
depth: 0.85,
|
||||
weight: 0.6,
|
||||
startFrame: REVEAL_ANCHOR,
|
||||
revealSpan: 24,
|
||||
maxWidthEm: 30
|
||||
};
|
||||
return [
|
||||
header,
|
||||
...rows.map((memory, i) => {
|
||||
const retrieval = clamp01(memory.retrievalStrength);
|
||||
const retention = clamp01(memory.retentionStrength);
|
||||
return {
|
||||
id: `mem:${memory.id}`,
|
||||
kind: 'memory',
|
||||
memoryId: memory.id,
|
||||
text: memoryLine(memory, true),
|
||||
x: -0.82,
|
||||
y: top - i * rowStep,
|
||||
size: 0.03,
|
||||
color: CYAN,
|
||||
depth: Math.max(MIN_VISIBLE_DEPTH, retrieval),
|
||||
weight: retention,
|
||||
startFrame: REVEAL_ANCHOR + i * 2,
|
||||
revealSpan: 20,
|
||||
maxWidthEm: 34,
|
||||
hitPadX: 0.04,
|
||||
hitPadY: 0.03
|
||||
} satisfies MemoryTextItem;
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
const rows = memories.slice(0, ROW_LIMIT);
|
||||
const top = 0.72;
|
||||
const rowStep = 1.5 / Math.max(1, ROW_LIMIT - 1);
|
||||
|
|
@ -169,7 +256,7 @@
|
|||
id: `mem:${memory.id}`,
|
||||
kind: 'memory',
|
||||
memoryId: memory.id,
|
||||
text: memoryLine(memory),
|
||||
text: memoryLine(memory, false),
|
||||
x: -0.88,
|
||||
y: top - i * rowStep,
|
||||
size: 0.026,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@
|
|||
import { TextLayerPass, type TextLayerItem } from '$lib/observatory/text/text-layer';
|
||||
import { LivingFieldPass } from '$lib/observatory/field/living-field-pass';
|
||||
import { layoutGalaxy, FIELD_HUE, type FieldDatum } from '$lib/observatory/field/cell-layout';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import AnimatedNumber from '$lib/components/AnimatedNumber.svelte';
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import { spotlight } from '$lib/actions/interactions';
|
||||
|
||||
type MemoryPrTextItem = TextLayerItem & { prId?: string };
|
||||
type WhySignal = { code: string; detail: string };
|
||||
|
|
@ -31,6 +36,36 @@
|
|||
let whySignals: WhySignal[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
// The DOM row the user last asked "why" about — so the returned risk signals
|
||||
// render against a concrete PR, not a floating panel with no anchor.
|
||||
let whyForPrId: string | null = $state(null);
|
||||
|
||||
// PORTRAIT GATE — everything below is gated on the LIVE viewport aspect so the
|
||||
// desktop (landscape, aspect>=0.85) render stays byte-identical zero-DOM: no DOM
|
||||
// overlay, full-strength in-canvas PR field. On a phone (portrait, aspect<0.85)
|
||||
// the in-canvas log wall is illegible, so we surface a readable DOM overlay AND
|
||||
// dim the field to a pure backdrop. Threshold matches TextLayerPass.portraitAdapt.
|
||||
let isPortrait = $state(false);
|
||||
onMount(() => {
|
||||
const update = () => {
|
||||
isPortrait = window.innerWidth / Math.max(1, window.innerHeight) < 0.85;
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
});
|
||||
|
||||
const pendingCount = $derived(prs.filter((pr) => pr.status === 'pending').length);
|
||||
// Cap the readable DOM list to the same window the field renders so the two
|
||||
// stay in sync and the scroll stays bounded on a phone.
|
||||
const domRows = $derived(prs.slice(0, ROW_LIMIT));
|
||||
|
||||
function prStatusTone(status: string): string {
|
||||
if (status === 'pending') return 'text-warning border-warning/30 bg-warning/10';
|
||||
if (status === 'approved' || status === 'promoted') return 'text-recall border-recall/25 bg-recall/10';
|
||||
if (status === 'rejected' || status === 'forgotten') return 'text-decay border-decay/25 bg-decay/10';
|
||||
return 'text-dim border-white/10 bg-white/[0.04]';
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void loadPrs();
|
||||
|
|
@ -97,6 +132,15 @@
|
|||
.slice(0, 96);
|
||||
}
|
||||
|
||||
// In PORTRAIT the readable DOM overlay is the hero, so the in-canvas PR rows must
|
||||
// recede to a faint ambient substrate (they'd otherwise be an illegible wall of
|
||||
// log text competing with the DOM copy on top). In LANDSCAPE (desktop) the
|
||||
// in-canvas text IS the content, so it keeps full strength — desktop unchanged.
|
||||
function dim(color: [number, number, number, number]): [number, number, number, number] {
|
||||
if (!isPortrait) return color;
|
||||
return [color[0], color[1], color[2], color[3] * 0.34];
|
||||
}
|
||||
|
||||
function buildTextItems(): MemoryPrTextItem[] {
|
||||
const rows = prs.slice(0, ROW_LIMIT);
|
||||
const top = 0.74;
|
||||
|
|
@ -109,7 +153,7 @@
|
|||
x: -0.9,
|
||||
y: top - i * rowStep,
|
||||
size: 0.025,
|
||||
color: pr.status === 'pending' ? CYAN : MUTED,
|
||||
color: dim(pr.status === 'pending' ? CYAN : MUTED),
|
||||
depth: confidenceDepth(pr),
|
||||
weight: 1,
|
||||
startFrame: REVEAL_ANCHOR + i * 2,
|
||||
|
|
@ -126,7 +170,7 @@
|
|||
x: -0.82,
|
||||
y: -0.76 - i * 0.052,
|
||||
size: 0.02,
|
||||
color: AMBER,
|
||||
color: dim(AMBER),
|
||||
depth: 0.72,
|
||||
weight: 0.8,
|
||||
startFrame: REVEAL_ANCHOR + (rows.length + i) * 2,
|
||||
|
|
@ -159,24 +203,57 @@
|
|||
alive: prs.length > 0
|
||||
});
|
||||
|
||||
// Live handles so the portrait $effect can re-dim the field and re-push the
|
||||
// (portrait-dimmed) in-canvas text when the viewport aspect crosses the gate.
|
||||
let fieldPass: MemoryPrFieldPass | null = null;
|
||||
let textPass: TextLayerPass | null = null;
|
||||
|
||||
$effect(() => {
|
||||
// Re-apply the portrait/landscape backdrop treatment whenever the gate flips.
|
||||
const portrait = isPortrait;
|
||||
fieldPass?.applyBackdrop(portrait);
|
||||
textPass?.setText(buildTextItems());
|
||||
});
|
||||
|
||||
function createMemoryPrPasses(engine: ObservatoryEngine, scene: RouteSceneModel): RouteFramePass[] {
|
||||
const field = new MemoryPrFieldPass(engine);
|
||||
field.applyBackdrop(isPortrait);
|
||||
field.uploadScene(scene);
|
||||
fieldPass = field;
|
||||
const text = new TextLayerPass(engine);
|
||||
textPass = text;
|
||||
void text.init().then(() => text.setText(buildTextItems()));
|
||||
return [field,
|
||||
{
|
||||
render: (pass) => text.render(pass),
|
||||
uploadScene: () => text.setText(buildTextItems()),
|
||||
pickAt: (x, y) => text.pickAt(x, y),
|
||||
dispose: () => text.dispose()
|
||||
dispose: () => {
|
||||
if (textPass === text) textPass = null;
|
||||
text.dispose();
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
class MemoryPrFieldPass implements RouteFramePass {
|
||||
private field: LivingFieldPass;
|
||||
constructor(engine: ObservatoryEngine) { this.field = new LivingFieldPass(engine); }
|
||||
constructor(engine: ObservatoryEngine) {
|
||||
this.field = new LivingFieldPass(engine);
|
||||
}
|
||||
// PORTRAIT: the readable hero is the DOM overlay, so the field recedes to a
|
||||
// faint full-frame ambient substrate behind the cards. LANDSCAPE (desktop):
|
||||
// the in-canvas PR queue IS the content, so keep the original intensity + the
|
||||
// left-column reading well that kept those rows legible — desktop unchanged.
|
||||
applyBackdrop(portrait: boolean): void {
|
||||
if (portrait) {
|
||||
this.field.setIntensity(0.14);
|
||||
this.field.setReadingWell({ x: 0, y: 0, hw: 1.0, hh: 1.0, floor: 0.05, soft: 0.35 });
|
||||
} else {
|
||||
this.field.setIntensity(0.22);
|
||||
this.field.setReadingWell({ x: -0.2, y: -0.1, hw: 0.78, hh: 0.9, floor: 0.06, soft: 0.25 });
|
||||
}
|
||||
}
|
||||
uploadScene(scene: RouteSceneModel): void {
|
||||
const data: FieldDatum[] = scene.nodes.map((node) => ({ id: node.source.id, score: node.activation ?? 0.5, hue: FIELD_HUE.caution, energy: node.activation, metric2: node.trust, scar: (node.tags?.length ?? 0) > 1, kind: 'memory-pr', payload: node }));
|
||||
this.field.setCells(layoutGalaxy(data, { maxRadius: 0.9, minCellR: 0.035, maxCellR: 0.09 }));
|
||||
|
|
@ -184,7 +261,10 @@
|
|||
compute(encoder: GPUCommandEncoder): void { this.field.compute(encoder); }
|
||||
render(pass: GPURenderPassEncoder): void { this.field.render(pass); }
|
||||
pickAt(x: number, y: number): RoutePick | null { return this.field.pickAt(x, y); }
|
||||
dispose(): void { this.field.dispose(); }
|
||||
dispose(): void {
|
||||
if (fieldPass === this) fieldPass = null;
|
||||
this.field.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRoutePick(pick: RoutePick) {
|
||||
|
|
@ -195,6 +275,11 @@
|
|||
const payload = pick.payload as Partial<MemoryPrTextItem> & { source?: { id?: string } };
|
||||
const prId = payload.prId ?? payload.source?.id;
|
||||
if (!prId) return;
|
||||
await askWhy(prId);
|
||||
}
|
||||
|
||||
async function askWhy(prId: string) {
|
||||
whyForPrId = prId;
|
||||
try {
|
||||
const res = (await api.memoryPrs.act(prId, 'ask_agent_why')) as { why?: WhySignal[] };
|
||||
whySignals = res.why ?? [];
|
||||
|
|
@ -218,3 +303,136 @@
|
|||
emptyLabel="NO MEMORY PRS"
|
||||
onpick={handleRoutePick}
|
||||
/>
|
||||
|
||||
<!-- PORTRAIT-ONLY readable DOM overlay (content-first). On desktop (landscape) this
|
||||
organ stays zero-DOM: the in-canvas PR field IS the content. On a phone the field
|
||||
is an illegible log wall, so we surface THIS as the focal content and dim the field
|
||||
to a backdrop. Container is pointer-events-none so empty gaps still reach the field,
|
||||
while each card is pointer-events-auto. pb-28 clears the global MobileNav FAB. -->
|
||||
{#if isPortrait}
|
||||
<div
|
||||
class="relative z-10 mx-auto max-h-dvh max-w-3xl space-y-6 overflow-y-auto overscroll-contain p-6 pb-28 pointer-events-none"
|
||||
>
|
||||
<!-- Opaque backing so the masthead + description read cleanly over the dim field
|
||||
(the -mb-2 pulls the PageHeader's own bottom margin back inside the panel). -->
|
||||
<div class="glass-subtle pointer-events-auto rounded-2xl p-5 [&_header]:mb-0">
|
||||
<PageHeader
|
||||
icon="memorypr"
|
||||
title="Memory PRs: Review Queue"
|
||||
subtitle="Proposed changes to your memory (new facts, supersessions, merges, and forgets) held for review before they touch the graph. Tap a PR to ask the agent why it was proposed."
|
||||
accent="warning"
|
||||
>
|
||||
<span
|
||||
class="ping-host flex h-2 w-2 items-center justify-center text-warning"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="breathe h-2 w-2 rounded-full bg-warning"></span>
|
||||
</span>
|
||||
<span class="text-xs text-dim">Live</span>
|
||||
</PageHeader>
|
||||
</div>
|
||||
|
||||
<!-- Status / count strip -->
|
||||
<div
|
||||
class="glass-panel pointer-events-auto flex flex-wrap items-center gap-3 rounded-2xl p-4 text-xs text-text"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if loading}
|
||||
<span class="breathe h-2 w-2 rounded-full bg-warning"></span>
|
||||
<span class="text-dim">Loading review queue…</span>
|
||||
{:else if error}
|
||||
<span class="h-2 w-2 rounded-full bg-decay"></span>
|
||||
<span class="text-decay">Queue unavailable</span>
|
||||
{:else}
|
||||
<span class="breathe h-2 w-2 rounded-full bg-warning"></span>
|
||||
<span class="tabular-nums">
|
||||
<AnimatedNumber value={prs.length} />
|
||||
{prs.length === 1 ? 'PR' : 'PRs'}
|
||||
· <AnimatedNumber value={pendingCount} /> pending review
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
{#if error}
|
||||
<div
|
||||
class="glass-panel pointer-events-auto flex flex-col items-center gap-3 rounded-2xl p-10 text-center"
|
||||
>
|
||||
<div class="text-sm text-decay">Couldn't load memory PRs</div>
|
||||
<div class="max-w-md text-xs text-muted">{error}</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => void loadPrs()}
|
||||
class="mt-2 rounded-lg bg-warning/20 px-4 py-2 text-xs font-medium text-warning transition hover:bg-warning/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-warning/60"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
{:else if loading}
|
||||
<div class="pointer-events-auto space-y-3">
|
||||
{#each Array(4) as _}
|
||||
<div class="glass-subtle shimmer h-20 rounded-2xl"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if domRows.length === 0}
|
||||
<div
|
||||
class="glass-panel pointer-events-auto enter flex flex-col items-center gap-3 rounded-2xl p-12 text-center"
|
||||
>
|
||||
<div
|
||||
class="flex h-14 w-14 items-center justify-center rounded-2xl border border-recall/25 bg-recall/10 text-recall"
|
||||
>
|
||||
<Icon name="sparkle" size={26} draw />
|
||||
</div>
|
||||
<div class="text-sm font-medium text-bright">No pending memory PRs.</div>
|
||||
<div class="max-w-sm text-xs text-muted">
|
||||
Every proposed change has been reviewed. New facts and supersessions will queue here
|
||||
for your approval before they touch the graph.
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="pointer-events-auto space-y-3">
|
||||
{#each domRows as pr, i (pr.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => void askWhy(pr.id)}
|
||||
use:reveal={{ delay: Math.min(i * 35, 350), y: 12 }}
|
||||
use:spotlight
|
||||
class="spotlight-surface lift glass-panel block w-full rounded-2xl p-4 text-left transition hover:border-warning/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-warning/60"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium text-bright">{pr.title}</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-dim">
|
||||
<span class="font-mono">{pr.id.slice(0, 8)}</span>
|
||||
<span class="text-muted">·</span>
|
||||
<span class="uppercase tracking-wide">{pr.kind}</span>
|
||||
{#if pr.signals.length}
|
||||
<span class="text-muted">·</span>
|
||||
<span>{pr.signals.length} signal{pr.signals.length === 1 ? '' : 's'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide {prStatusTone(pr.status)}"
|
||||
>
|
||||
{pr.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if whyForPrId === pr.id && whySignals.length}
|
||||
<div class="mt-3 space-y-1.5 border-t border-white/[0.06] pt-3">
|
||||
{#each whySignals.slice(0, 5) as signal (signal.code)}
|
||||
<div class="flex gap-2 text-[11px]">
|
||||
<span class="shrink-0 font-mono text-warning">{signal.code}</span>
|
||||
<span class="text-muted">{signal.detail}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,28 @@
|
|||
// of the recall-path scene. Then the MSDF text HUD on top of that.
|
||||
const field = new LivingFieldPass(engine);
|
||||
fieldPass = field;
|
||||
// The "living nervous system" home: the field stays ALIVE (0.60) but a reading
|
||||
// well dims it under the instrument overlay so the nav labels + HUD read. The
|
||||
// well covers the left nav column (RECALL/ENGRAM/... at x=-0.91) and the
|
||||
// right telemetry (NODES/EDGES at x~0.4), the two text regions.
|
||||
//
|
||||
// Portrait phones: the HUD collapses to ONE centred vertical stack
|
||||
// (buildPortraitItems), so the desktop left-side well would miss it and the
|
||||
// bright bloom core would sit right behind the text. On portrait, dim the
|
||||
// whole field harder (backdrop, not a blob) and centre a tall well over the
|
||||
// stack. Everything derives from the live aspect — desktop is untouched.
|
||||
const aspect = portraitAspect();
|
||||
if (aspect !== null) {
|
||||
const portraitness = clamp01((0.85 - aspect) / (0.85 - 0.46));
|
||||
field.setIntensity(0.32 - 0.1 * portraitness);
|
||||
// The centred stack's labels run left-anchored to the right, so bias the
|
||||
// well slightly right and widen it to cover the full label extent — the
|
||||
// recall wavefront sweeping the right side must stay UNDER the text.
|
||||
field.setReadingWell({ x: 0.05, y: 0.05, hw: 0.82, hh: 0.95, floor: 0.04, soft: 0.3 });
|
||||
} else {
|
||||
field.setIntensity(0.6);
|
||||
field.setReadingWell({ x: -0.55, y: 0.1, hw: 0.5, hh: 0.75, floor: 0.08, soft: 0.25 });
|
||||
}
|
||||
field.setCells(buildFieldCells());
|
||||
engine.addPass(field);
|
||||
const pass = new TextLayerPass(engine);
|
||||
|
|
@ -151,7 +173,131 @@
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Live viewport aspect from the engine params (canvas px) with a window
|
||||
* fallback — the SAME source TextLayerPass.portraitAdapt reads. Returns the
|
||||
* aspect only when genuinely portrait/narrow (aspect < 0.85); null otherwise.
|
||||
* Nothing is hardcoded per phone width; the whole portrait layout scales off
|
||||
* this one live number, so desktop (>=0.85) is byte-identical.
|
||||
*/
|
||||
function portraitAspect(): number | null {
|
||||
let vw = engineRef?.params[6] || 0;
|
||||
let vh = engineRef?.params[7] || 0;
|
||||
if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') {
|
||||
vw = window.innerWidth;
|
||||
vh = window.innerHeight;
|
||||
}
|
||||
if (vw <= 0 || vh <= 0) return null;
|
||||
const aspect = vw / vh;
|
||||
return aspect < 0.85 ? aspect : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Portrait HUD: the desktop layout is FOUR competing columns (nav at x=-0.91,
|
||||
* receipts at x=-0.67, telemetry at x=0.39, exit at x=0.75). On a phone the
|
||||
* shared portraitAdapt pulls them all toward centre, so they overprint (the
|
||||
* "DENTER"/FIREWALL collisions). Instead, author ONE readable vertical stack:
|
||||
* a centred demo-mode list, a compact telemetry block below it, and EXIT
|
||||
* pinned top — no side-by-side columns, no dense receipt lines running off the
|
||||
* right edge. Everything is authored centred (x~0) so portraitAdapt's x-pull
|
||||
* barely moves it; the y bands are spaced so nothing shares a row.
|
||||
*/
|
||||
function buildPortraitItems(aspect: number): ObservatoryTextItem[] {
|
||||
const items: ObservatoryTextItem[] = [];
|
||||
const graph = graphData;
|
||||
const nodeDepth = graph ? graphMetric(graph.nodeCount, Math.max(1, graph.nodes.length, 200)) : 0.5;
|
||||
const edgeWeight = graph ? graphMetric(graph.edgeCount, Math.max(1, graph.nodeCount * 8)) : 0.5;
|
||||
|
||||
// MSDF labels are LEFT-anchored at their x, so an x of 0 sits the column
|
||||
// right-of-centre. Left-shift the whole stack by a portraitness-scaled
|
||||
// amount (bigger shift on the narrowest phones) so the longest label
|
||||
// ("CENTER c5a42e31-c5f") reads visually centred. portraitAdapt scales x by
|
||||
// (1-xPull); pre-divide so the on-screen anchor lands where we want.
|
||||
const portraitness = clamp01((0.85 - aspect) / (0.85 - 0.46));
|
||||
const anchorX = -0.34 - 0.06 * portraitness;
|
||||
|
||||
// EXIT — top of the stack.
|
||||
items.push({
|
||||
id: 'observatory:exit',
|
||||
kind: 'observatory-exit',
|
||||
action: 'exit',
|
||||
text: sanitizeAscii('EXIT'),
|
||||
x: anchorX,
|
||||
y: 0.86,
|
||||
size: 0.03,
|
||||
color: AMBER,
|
||||
depth: 1,
|
||||
weight: edgeWeight,
|
||||
revealSpan: 10,
|
||||
maxWidthEm: 12,
|
||||
hitPadX: 0.08,
|
||||
hitPadY: 0.05
|
||||
});
|
||||
|
||||
// Demo-mode list — the primary interactive column, centred, generously
|
||||
// spaced so touch targets don't crowd. This is the ONE focal point.
|
||||
const navTop = 0.66;
|
||||
const navStep = 0.15;
|
||||
DEMO_MODES.forEach((mode, i) => {
|
||||
items.push({
|
||||
id: `observatory:demo:${mode}`,
|
||||
kind: 'observatory-demo',
|
||||
action: 'demo',
|
||||
demo: mode,
|
||||
text: demoLabel(mode),
|
||||
x: anchorX,
|
||||
y: navTop - i * navStep,
|
||||
size: 0.032,
|
||||
color: mode === demo ? OXYGEN : GREEN,
|
||||
depth: mode === demo ? 1 : nodeDepth,
|
||||
weight: mode === demo ? 0.9 : edgeWeight,
|
||||
startFrame: i * 2,
|
||||
revealSpan: 14,
|
||||
maxWidthEm: 18,
|
||||
hitPadX: 0.14,
|
||||
hitPadY: 0.05
|
||||
});
|
||||
});
|
||||
|
||||
if (loading) return [...items, statusItem('LOADING MEMORY FIELD...', CYAN)];
|
||||
if (error) return [...items, statusItem(`ERROR - ${error}`.slice(0, 60), SCARLET)];
|
||||
if (!graph || graph.nodeCount === 0) return [...items, statusItem('NO MEMORIES IN FIELD', GREEN)];
|
||||
|
||||
// Telemetry — a compact block BELOW the nav list (not a right-hand column),
|
||||
// so it never shares a row with anything. Short labels only; the long
|
||||
// receipt lines that overflowed the right edge on desktop are dropped on
|
||||
// portrait (desktop density a phone can't read).
|
||||
const telemetry = [
|
||||
`NODES ${graph.nodeCount}`,
|
||||
`EDGES ${graph.edgeCount}`,
|
||||
`DEPTH ${graph.depth}`,
|
||||
`CENTER ${graph.center_id.slice(0, 12)}`
|
||||
];
|
||||
const telTop = navTop - DEMO_MODES.length * navStep - 0.06;
|
||||
const telStep = 0.075;
|
||||
telemetry.forEach((text, i) => {
|
||||
items.push({
|
||||
id: `observatory:telemetry:${i}`,
|
||||
kind: 'observatory-telemetry',
|
||||
text: sanitizeAscii(text),
|
||||
x: anchorX,
|
||||
y: telTop - i * telStep,
|
||||
size: 0.024,
|
||||
color: CYAN,
|
||||
depth: 0.9,
|
||||
weight: edgeWeight,
|
||||
startFrame: 8 + i * 2,
|
||||
revealSpan: 10,
|
||||
maxWidthEm: 24
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildTextItems(): ObservatoryTextItem[] {
|
||||
const aspect = portraitAspect();
|
||||
if (aspect !== null) return buildPortraitItems(aspect);
|
||||
|
||||
const items: ObservatoryTextItem[] = [];
|
||||
const graph = graphData;
|
||||
const nodeDepth = graph ? graphMetric(graph.nodeCount, Math.max(1, graph.nodes.length, 200)) : 0.5;
|
||||
|
|
|
|||
|
|
@ -118,8 +118,24 @@
|
|||
];
|
||||
}
|
||||
|
||||
// Live viewport aspect (canvas px), same source portraitAdapt reads — never a
|
||||
// hardcoded phone width. Falls back to the window before frame 0, then 1.
|
||||
function viewportAspect(): number {
|
||||
const vw = engineRef?.params[6] || 0;
|
||||
const vh = engineRef?.params[7] || 0;
|
||||
if (vw > 0 && vh > 0) return vw / vh;
|
||||
if (typeof window !== 'undefined' && window.innerHeight > 0) {
|
||||
return window.innerWidth / window.innerHeight;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function buildAllText(): TextLayerItem[] {
|
||||
const labels = buildOrganLabels(toOrganPositions(), { hoveredHref, dimUnhovered: !!hoveredHref });
|
||||
const labels = buildOrganLabels(toOrganPositions(), {
|
||||
hoveredHref,
|
||||
dimUnhovered: !!hoveredHref,
|
||||
aspect: viewportAspect()
|
||||
});
|
||||
return [...hudLines(), ...labels];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import RouteStage, { type RouteFramePass, type RoutePick } from '$lib/observatory/RouteStage.svelte';
|
||||
import PageHeader from '$components/PageHeader.svelte';
|
||||
import Icon from '$components/Icon.svelte';
|
||||
import { api } from '$stores/api';
|
||||
import { rgb01 } from '$lib/observatory/cognitive-palette';
|
||||
import { assertProvenance, type RouteNode, type RouteSceneModel } from '$lib/observatory/route-scene';
|
||||
|
|
@ -42,6 +44,21 @@
|
|||
let poolCells: FieldDatum[] = [];
|
||||
let patternField: PatternFieldPass | null = null;
|
||||
|
||||
// Portrait phones get a legible DOM error/empty state layered over the field —
|
||||
// the zero-DOM WebGPU error text is unreadable on a phone (tiny centered red
|
||||
// glyphs with no title/affordance). Gated to portrait/narrow aspect (< 0.85) so
|
||||
// the desktop-with-data render stays byte-identical to before. Derived from the
|
||||
// LIVE viewport via matchMedia, not a hardcoded width.
|
||||
let isPortrait = $state(false);
|
||||
onMount(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mq = window.matchMedia('(max-aspect-ratio: 85/100)');
|
||||
isPortrait = mq.matches;
|
||||
const onChange = (e: MediaQueryListEvent) => (isPortrait = e.matches);
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
void loadPatterns();
|
||||
void api.memories
|
||||
|
|
@ -170,8 +187,19 @@
|
|||
class PatternFieldPass implements RouteFramePass {
|
||||
private field: LivingFieldPass;
|
||||
private lastNodes: RouteNode[] = [];
|
||||
private portrait: boolean;
|
||||
constructor(engine: ObservatoryEngine) {
|
||||
this.field = new LivingFieldPass(engine);
|
||||
this.portrait = viewportAspect(engine) < 0.85;
|
||||
// Keep the verified portrait treatment byte-for-byte dim, but let landscape
|
||||
// screens carry a materially richer field outside the reading well. Use the
|
||||
// live engine viewport (with only a pre-frame window fallback), never a
|
||||
// device-width constant.
|
||||
this.field.setIntensity(this.portrait ? 0.24 : 0.62);
|
||||
// Rows anchor at x=-0.88 and run wide (maxWidthEm 58) from y~+0.74 down to
|
||||
// y~-0.74. Quiet the field across that left-weighted reading band so glyphs
|
||||
// stay legible while the rings still breathe in the margins.
|
||||
this.field.setReadingWell({ x: -0.15, y: 0, hw: 0.85, hh: 0.85, floor: 0.08, soft: 0.25 });
|
||||
}
|
||||
uploadScene(scene: RouteSceneModel): void {
|
||||
this.lastNodes = (scene as PatternScene).nodes;
|
||||
|
|
@ -190,8 +218,8 @@
|
|||
layoutRings(poolCells, (_d, i) => i % 6, {
|
||||
ringCount: 6,
|
||||
maxRadius: 0.92,
|
||||
minCellR: 0.02,
|
||||
maxCellR: 0.05
|
||||
minCellR: this.portrait ? 0.02 : 0.06,
|
||||
maxCellR: this.portrait ? 0.05 : 0.14
|
||||
})
|
||||
);
|
||||
return;
|
||||
|
|
@ -310,6 +338,16 @@
|
|||
.replace(/[^\x20-\x7E]/g, '?');
|
||||
}
|
||||
|
||||
function viewportAspect(engine: ObservatoryEngine): number {
|
||||
let vw = engine.params[6] || 0;
|
||||
let vh = engine.params[7] || 0;
|
||||
if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') {
|
||||
vw = window.innerWidth;
|
||||
vh = window.innerHeight;
|
||||
}
|
||||
return vw > 0 && vh > 0 ? vw / vh : 1.6;
|
||||
}
|
||||
|
||||
function finite(value: number): number {
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
|
@ -332,3 +370,62 @@
|
|||
error={error}
|
||||
onpick={handleRoutePick}
|
||||
/>
|
||||
|
||||
<!--
|
||||
Portrait-only DOM chrome. On a phone the WebGPU-only error/empty state is a
|
||||
blank near-black screen with unreadable centered red glyphs and no title. This
|
||||
overlay gives portrait users a legible title (so they know this is Patterns) and
|
||||
a proper card for the error / empty / loading states with a Retry affordance —
|
||||
mirroring the contradictions organ. Gated to portrait (aspect < 0.85) so the
|
||||
desktop-with-data render is byte-identical. pointer-events pass through to the
|
||||
field except on the interactive card.
|
||||
-->
|
||||
{#if isPortrait}
|
||||
<div class="pointer-events-none fixed inset-0 z-10 flex flex-col p-6">
|
||||
<div class="pointer-events-auto">
|
||||
<PageHeader
|
||||
icon="patterns"
|
||||
title="Cross-Project Patterns"
|
||||
accent="recall"
|
||||
>
|
||||
<span class="text-dim text-sm tabular-nums">
|
||||
{visiblePatterns.length} {visiblePatterns.length === 1 ? 'pattern' : 'patterns'}
|
||||
</span>
|
||||
</PageHeader>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="pointer-events-auto mt-2 flex flex-1 items-center justify-center">
|
||||
<div class="glass-panel flex w-full max-w-md flex-col items-center gap-3 rounded-2xl p-8 text-center">
|
||||
<div class="text-sm text-decay">Couldn't load patterns</div>
|
||||
<div class="max-w-sm text-xs text-muted">{error}</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={loadPatterns}
|
||||
class="mt-2 rounded-lg bg-recall/20 px-4 py-2 text-xs font-medium text-recall transition hover:bg-recall/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-recall/60"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if loading}
|
||||
<div class="pointer-events-auto mt-2 flex flex-1 items-center justify-center">
|
||||
<div class="glass-subtle shimmer h-32 w-full max-w-md rounded-2xl"></div>
|
||||
</div>
|
||||
{:else if visiblePatterns.length === 0}
|
||||
<div class="pointer-events-auto mt-2 flex flex-1 items-center justify-center">
|
||||
<div class="glass-panel flex w-full max-w-md flex-col items-center gap-3 rounded-2xl p-8 text-center">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-2xl border border-recall/25 bg-recall/10 text-recall">
|
||||
<Icon name="patterns" size={26} draw />
|
||||
</div>
|
||||
<div class="text-sm font-medium text-bright">
|
||||
No cross-project patterns standing today.
|
||||
</div>
|
||||
<div class="max-w-sm text-xs text-muted">
|
||||
Patterns appear here when a solved approach in one project transfers to another.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,25 @@
|
|||
let reasoningScene: ReasoningScene | null = $state(null);
|
||||
let selection: string | null = $state(null); // aria description of last pick
|
||||
let askInputEl: HTMLInputElement | null = $state(null);
|
||||
// Live viewport aspect → portrait entry affordance. Desktop (aspect>=0.85) is
|
||||
// byte-identical: the DOM overlay never mounts and the full canvas empty label
|
||||
// renders exactly as before. Only narrow/portrait phones get the tappable UI.
|
||||
let portrait = $state(false);
|
||||
// A visible portrait DOM input the user can TAP to ask (phones have no Cmd+K).
|
||||
let mobileInputEl: HTMLInputElement | null = $state(null);
|
||||
|
||||
function syncPortrait() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
portrait = vw > 0 && vh > 0 && vw / vh < 0.85;
|
||||
}
|
||||
|
||||
// Desktop keeps the exact original canvas guidance. Portrait blanks the canvas
|
||||
// label (the DOM overlay owns guidance) so the long line can't clip off-screen.
|
||||
const emptyLabel = $derived(
|
||||
portrait ? '' : 'ASK A QUESTION - PRESS CMD+K - WATCH THE DECISION FORM'
|
||||
);
|
||||
|
||||
// Evidence galaxy behind the trace: every real deep_reference evidence memory
|
||||
// becomes a cell (trust = oxygen, contradictions scar) so the Theater is a
|
||||
|
|
@ -78,6 +97,19 @@
|
|||
}
|
||||
function createReasoningPasses(engine: ObservatoryEngine, scene: RouteSceneModel): RouteFramePass[] {
|
||||
const field = new LivingFieldPass(engine);
|
||||
// Keep portrait byte-for-byte at the verified dim 0.2. Desktop has a broad
|
||||
// reading well over the full trace, so the field can be richer around it
|
||||
// without washing out the MSDF query, gates, evidence, or receipt text.
|
||||
const vw = engine.params[6] || (typeof window !== 'undefined' ? window.innerWidth : 0);
|
||||
const vh = engine.params[7] || (typeof window !== 'undefined' ? window.innerHeight : 0);
|
||||
const aspect = vw > 0 && vh > 0 ? vw / vh : 1;
|
||||
const desktop = aspect >= 0.85;
|
||||
field.setIntensity(desktop ? 0.9 : 0.2);
|
||||
field.setReadingWell(
|
||||
desktop
|
||||
? { x: 0, y: 0.15, hw: 0.95, hh: 0.72, floor: 0.22, soft: 0.22 }
|
||||
: { x: 0, y: 0.15, hw: 0.95, hh: 0.72, floor: 0.06, soft: 0.22 }
|
||||
);
|
||||
evidenceField = field;
|
||||
field.setCells(buildEvidenceCells());
|
||||
const fieldWrapper: RouteFramePass = {
|
||||
|
|
@ -152,7 +184,10 @@
|
|||
|
||||
onMount(() => {
|
||||
askInputEl?.focus();
|
||||
syncPortrait();
|
||||
window.addEventListener('keydown', handleGlobalKey);
|
||||
window.addEventListener('resize', syncPortrait);
|
||||
window.addEventListener('orientationchange', syncPortrait);
|
||||
// Keep the Theater HONEST-EMPTY at rest (the DOM "ask a question to trace"
|
||||
// state), but light the field with a passive real memory pool so the stage
|
||||
// isn't black while it waits. A real query replaces the pool with the actual
|
||||
|
|
@ -173,7 +208,11 @@
|
|||
evidenceField?.setCells(buildEvidenceCells());
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => window.removeEventListener('keydown', handleGlobalKey);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleGlobalKey);
|
||||
window.removeEventListener('resize', syncPortrait);
|
||||
window.removeEventListener('orientationchange', syncPortrait);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -188,10 +227,59 @@
|
|||
passes={createReasoningPasses}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyLabel="ASK A QUESTION - PRESS CMD+K - WATCH THE DECISION FORM"
|
||||
{emptyLabel}
|
||||
onpick={handleRoutePick}
|
||||
/>
|
||||
|
||||
<!--
|
||||
PORTRAIT ENTRY AFFORDANCE (phones have no Cmd+K). Desktop never mounts this —
|
||||
it's gated on the live viewport aspect, so the 1440px render is untouched. This
|
||||
is the ONE focal point at rest on a phone: a title, one line of guidance, and a
|
||||
real tappable input that runs the same ask() the canvas trace consumes.
|
||||
-->
|
||||
{#if portrait && !reasoningScene && !loading && !error}
|
||||
<div class="reasoning-mobile" role="search">
|
||||
<h1 class="rm-title">REASONING THEATER</h1>
|
||||
<p class="rm-sub">Ask your memory a question and watch the decision form from evidence.</p>
|
||||
<form
|
||||
class="rm-form"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
query = mobileInputEl?.value ?? query;
|
||||
void ask();
|
||||
mobileInputEl?.blur();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
bind:this={mobileInputEl}
|
||||
class="rm-input"
|
||||
type="search"
|
||||
enterkeyhint="search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="Ask your memory anything..."
|
||||
aria-label="Ask Vestige a question"
|
||||
/>
|
||||
<button class="rm-go" type="submit">ASK</button>
|
||||
</form>
|
||||
<ul class="rm-examples">
|
||||
{#each EXAMPLE_QUERIES.slice(0, 3) as q}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="rm-chip"
|
||||
onclick={() => {
|
||||
query = q;
|
||||
if (mobileInputEl) mobileInputEl.value = q;
|
||||
void ask();
|
||||
}}>{q}</button
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!--
|
||||
The ONLY DOM: a visually-hidden native input (real keyboard + IME, Cmd+K
|
||||
focus, example-query datalist) and an aria-live output. The visible query
|
||||
|
|
@ -235,4 +323,104 @@
|
|||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Portrait entry affordance — the readable focal point on a phone. Sits in the
|
||||
upper-center over the dim field, clear of the bottom nav FAB. Never shown on
|
||||
desktop (the {#if portrait} gate keeps it unmounted there). */
|
||||
.reasoning-mobile {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 16vh;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
padding: 0 7vw;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.reasoning-mobile > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.rm-title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.4rem, 7vw, 2rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
color: #e9ffb7;
|
||||
text-shadow: 0 0 24px rgba(0, 245, 212, 0.28);
|
||||
}
|
||||
.rm-sub {
|
||||
margin: 0;
|
||||
max-width: 30ch;
|
||||
font-size: clamp(0.85rem, 3.6vw, 1rem);
|
||||
line-height: 1.45;
|
||||
color: rgba(200, 230, 235, 0.72);
|
||||
}
|
||||
.rm-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 30rem;
|
||||
margin-top: 0.4rem;
|
||||
border: 1px solid rgba(0, 245, 212, 0.4);
|
||||
border-radius: 0.7rem;
|
||||
background: rgba(6, 14, 16, 0.66);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.rm-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #eafcff;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
}
|
||||
.rm-input::placeholder {
|
||||
color: rgba(160, 190, 195, 0.55);
|
||||
}
|
||||
.rm-go {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 1.2rem;
|
||||
border: 0;
|
||||
border-left: 1px solid rgba(0, 245, 212, 0.3);
|
||||
background: rgba(0, 245, 212, 0.14);
|
||||
color: #7ffff0;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rm-go:active {
|
||||
background: rgba(0, 245, 212, 0.28);
|
||||
}
|
||||
.rm-examples {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 30rem;
|
||||
margin: 0.2rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.rm-chip {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid rgba(120, 150, 155, 0.25);
|
||||
border-radius: 0.55rem;
|
||||
background: rgba(10, 18, 20, 0.5);
|
||||
color: rgba(190, 220, 225, 0.82);
|
||||
font-size: 0.86rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rm-chip:active {
|
||||
border-color: rgba(0, 245, 212, 0.5);
|
||||
color: #d6fff8;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import RouteStage, { type RouteFramePass, type RoutePick } from '$lib/observatory/RouteStage.svelte';
|
||||
import { api } from '$stores/api';
|
||||
import type { Memory } from '$types';
|
||||
|
|
@ -29,11 +29,39 @@
|
|||
let memories: Memory[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
let engineRef: ObservatoryEngine | null = null;
|
||||
|
||||
// Live viewport aspect (canvas px) — same signal TextLayerPass.portraitAdapt
|
||||
// reads (engine.params[6]/[7]), with a window fallback for the pre-frame-0
|
||||
// pass. NEVER a hardcoded phone width; desktop (aspect>=0.85) is untouched.
|
||||
function viewportAspect(): number {
|
||||
let vw = engineRef?.params[6] || 0;
|
||||
let vh = engineRef?.params[7] || 0;
|
||||
if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') {
|
||||
vw = window.innerWidth;
|
||||
vh = window.innerHeight;
|
||||
}
|
||||
if (vw <= 0 || vh <= 0) return 1;
|
||||
return vw / vh;
|
||||
}
|
||||
|
||||
// Trim to a cap on a word boundary so a portrait row never ends mid-token.
|
||||
function trimSnippet(text: string, cap: number): string {
|
||||
const s = sanitizeAscii(text).replace(/\s+/g, ' ').trim();
|
||||
if (s.length <= cap) return s;
|
||||
const hard = s.slice(0, cap);
|
||||
const lastSpace = hard.lastIndexOf(' ');
|
||||
return lastSpace > cap * 0.6 ? hard.slice(0, lastSpace) : hard;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void loadSchedule();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
engineRef = null;
|
||||
});
|
||||
|
||||
async function loadSchedule() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
|
@ -102,14 +130,19 @@
|
|||
return clamp01(1 - days / 30);
|
||||
}
|
||||
|
||||
function scheduleLine(memory: Memory, nowMs: number): string {
|
||||
const snippet = sanitizeAscii(memory.content).replace(/\s+/g, ' ').trim().slice(0, 48);
|
||||
function scheduleLine(memory: Memory, nowMs: number, portrait = false): string {
|
||||
const next = dueAt(memory);
|
||||
const days = Number.isFinite(next) ? Math.ceil((next - nowMs) / 86_400_000) : 9999;
|
||||
const due = days < 0 ? `${Math.abs(days)}D OVER` : days === 0 ? 'DUE 0D' : `DUE ${days}D`;
|
||||
return sanitizeAscii(
|
||||
`${snippet} | ${memory.id.slice(0, 8)} | ${due} | ${Math.round(memory.retentionStrength * 100)}%`
|
||||
);
|
||||
const pct = `${Math.round(memory.retentionStrength * 100)}%`;
|
||||
// Portrait: drop the id column and shorten the snippet so the row fits the
|
||||
// narrow width on one readable line — never edge-to-edge, never truncated
|
||||
// mid-word. Desktop keeps the full, byte-identical row.
|
||||
if (portrait) {
|
||||
return sanitizeAscii(`${trimSnippet(memory.content, 26)} ${due} ${pct}`);
|
||||
}
|
||||
const snippet = sanitizeAscii(memory.content).replace(/\s+/g, ' ').trim().slice(0, 48);
|
||||
return sanitizeAscii(`${snippet} | ${memory.id.slice(0, 8)} | ${due} | ${pct}`);
|
||||
}
|
||||
|
||||
function dueMemories(source: Memory[]): Memory[] {
|
||||
|
|
@ -161,9 +194,101 @@
|
|||
);
|
||||
|
||||
function buildTextItems(routeScene: RouteSceneModel): ScheduleTextItem[] {
|
||||
const nodes = routeScene.nodes;
|
||||
const portrait = viewportAspect() < 0.85;
|
||||
|
||||
if (portrait) {
|
||||
// Phone plan: ONE focal header + a short, well-spaced column with real
|
||||
// negative space instead of a 40-deep edge-to-edge wall. Row count derives
|
||||
// from the LIVE aspect (taller/narrower -> fewer rows), never a fixed phone
|
||||
// number. Rows carry a short, word-boundary-trimmed label so they never run
|
||||
// off the right edge or truncate mid-word. Desktop is untouched.
|
||||
const aspect = viewportAspect();
|
||||
const portraitness = clamp01((0.85 - aspect) / (0.85 - 0.42));
|
||||
const rowCount = Math.max(9, Math.round(12 - 3 * portraitness));
|
||||
const rows = nodes.slice(0, rowCount);
|
||||
if (rows.length === 0) {
|
||||
return [
|
||||
{
|
||||
id: 'schedule:header',
|
||||
kind: 'schedule-header',
|
||||
text: 'REVIEW SCHEDULE',
|
||||
x: -0.6,
|
||||
y: 0.82,
|
||||
size: 0.032,
|
||||
color: CYAN,
|
||||
depth: 0.9,
|
||||
weight: 0.6,
|
||||
startFrame: -100000,
|
||||
revealSpan: 1,
|
||||
maxWidthEm: 26
|
||||
},
|
||||
{
|
||||
id: 'schedule:empty',
|
||||
kind: 'schedule-header',
|
||||
text: 'Nothing due for review',
|
||||
x: -0.62,
|
||||
y: 0.1,
|
||||
size: 0.03,
|
||||
color: AMBER,
|
||||
depth: 0.85,
|
||||
weight: 0.5,
|
||||
startFrame: -100000,
|
||||
revealSpan: 1,
|
||||
maxWidthEm: 28
|
||||
}
|
||||
];
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
const memById = new Map(memories.map((memory) => [memory.id, memory]));
|
||||
const header: ScheduleTextItem = {
|
||||
id: 'schedule:header',
|
||||
kind: 'schedule-header',
|
||||
text: `REVIEW SCHEDULE ${scheduled.length} DUE`,
|
||||
x: -0.72,
|
||||
y: 0.82,
|
||||
size: 0.03,
|
||||
color: CYAN,
|
||||
depth: 0.9,
|
||||
weight: 0.6,
|
||||
startFrame: -100000,
|
||||
revealSpan: 1,
|
||||
maxWidthEm: 30
|
||||
};
|
||||
const top = 0.6;
|
||||
const bottom = -0.72;
|
||||
const rowStep = rows.length > 1 ? (top - bottom) / (rows.length - 1) : 0;
|
||||
return [
|
||||
header,
|
||||
...rows.map((node, i) => {
|
||||
const memory = memById.get(node.source.id);
|
||||
const text = memory
|
||||
? scheduleLine(memory, nowMs, true)
|
||||
: sanitizeAscii(node.label).slice(0, 34);
|
||||
return {
|
||||
id: `schedule:${node.source.id}`,
|
||||
kind: 'schedule-memory',
|
||||
memoryId: node.source.id,
|
||||
text,
|
||||
x: -0.82,
|
||||
y: top - i * rowStep,
|
||||
size: 0.03,
|
||||
color: urgencyByNode(node.activation ?? 0, node.retention),
|
||||
depth: clamp01(0.7 + (node.activation ?? 0) * 0.3),
|
||||
weight: clamp01(node.retention),
|
||||
startFrame: -100000,
|
||||
revealSpan: 1,
|
||||
maxWidthEm: 34,
|
||||
hitPadX: 0.05,
|
||||
hitPadY: 0.03
|
||||
} satisfies ScheduleTextItem;
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
const top = 0.74;
|
||||
const rowStep = 1.52 / Math.max(1, ROW_LIMIT - 1);
|
||||
return routeScene.nodes.slice(0, ROW_LIMIT).map((node, i) => ({
|
||||
return nodes.slice(0, ROW_LIMIT).map((node, i) => ({
|
||||
id: `schedule:${node.source.id}`,
|
||||
kind: 'schedule-memory',
|
||||
memoryId: node.source.id,
|
||||
|
|
@ -234,7 +359,17 @@
|
|||
|
||||
class ScheduleFieldPass implements RouteFramePass {
|
||||
private field: LivingFieldPass;
|
||||
constructor(engine: ObservatoryEngine) { this.field = new LivingFieldPass(engine); }
|
||||
constructor(engine: ObservatoryEngine) {
|
||||
this.field = new LivingFieldPass(engine);
|
||||
// TEXT-HEAVY organ: field is a DIM backdrop, never a blob that drowns
|
||||
// the 40-row due list. Intensity 0.22 keeps it a living whisper.
|
||||
this.field.setIntensity(0.22);
|
||||
// The schedule rows are anchored in a tall LEFT column: x from -0.9
|
||||
// extending right, y from +0.74 down to ~-0.78 (top=0.74, 40 rows @
|
||||
// rowStep). Carve a reading well over that column so the labels/values
|
||||
// stay crisp against the field.
|
||||
this.field.setReadingWell({ x: -0.35, y: -0.02, hw: 0.72, hh: 0.86, floor: 0.06, soft: 0.24 });
|
||||
}
|
||||
uploadScene(scene: RouteSceneModel): void {
|
||||
const data: FieldDatum[] = scene.nodes.map((node) => ({
|
||||
id: node.source.id,
|
||||
|
|
@ -255,6 +390,7 @@
|
|||
}
|
||||
|
||||
function createSchedulePasses(engine: ObservatoryEngine, initialScene: RouteSceneModel): RouteFramePass[] {
|
||||
engineRef = engine;
|
||||
const field = new ScheduleFieldPass(engine);
|
||||
field.uploadScene(initialScene);
|
||||
return [field, new ScheduleTextPass(engine, initialScene)];
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
import type {
|
||||
ConsolidationResult,
|
||||
DreamResult,
|
||||
HealthCheck,
|
||||
RetentionDistribution,
|
||||
SystemStats
|
||||
} from '$types';
|
||||
|
|
@ -50,15 +51,15 @@
|
|||
const REVEAL = 1;
|
||||
const READ_DEPTH = 0.95;
|
||||
|
||||
const DEMO_NODE_TYPES = ['fact', 'concept', 'pattern', 'decision', 'person', 'place'];
|
||||
|
||||
// ── Live console state ──────────────────────────────────────────────────────
|
||||
let stats = $state<SystemStats | null>(null);
|
||||
// Real backend version + health, from GET /health (CARGO_PKG_VERSION). Never
|
||||
// hardcode the version — it must reflect the actual running binary.
|
||||
let health = $state<HealthCheck | null>(null);
|
||||
let retention = $state<RetentionDistribution | null>(null);
|
||||
let consolidation = $state<ConsolidationResult | null>(null);
|
||||
let dream = $state<DreamResult | null>(null);
|
||||
let busy = $state<null | 'consolidate' | 'dream' | 'refresh'>(null);
|
||||
let birthCount = $state(0);
|
||||
let statusLine = $state('READY');
|
||||
let loading = $state(true);
|
||||
|
||||
|
|
@ -120,17 +121,43 @@
|
|||
|
||||
onMount(() => {
|
||||
void loadData();
|
||||
// Rebuild the console when the viewport crosses the portrait threshold (rotate
|
||||
// / resize) so the stacked-vs-columns layout switches. rAF-debounced + bucketed
|
||||
// so a continuous drag doesn't thrash. buildConsoleItems reads the live aspect.
|
||||
let raf = 0;
|
||||
let lastPortrait = isPortrait();
|
||||
const onResize = () => {
|
||||
if (raf) return;
|
||||
raf = requestAnimationFrame(() => {
|
||||
raf = 0;
|
||||
const p = isPortrait();
|
||||
if (p !== lastPortrait) {
|
||||
lastPortrait = p;
|
||||
consolePass?.refresh();
|
||||
consoleField?.setCells(buildConsoleCells());
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('orientationchange', onResize);
|
||||
return () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('orientationchange', onResize);
|
||||
};
|
||||
});
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
loading = true;
|
||||
try {
|
||||
const [s, r] = await Promise.all([
|
||||
const [s, r, h] = await Promise.all([
|
||||
api.stats().catch(() => null),
|
||||
api.retentionDistribution().catch(() => null)
|
||||
api.retentionDistribution().catch(() => null),
|
||||
api.health().catch(() => null)
|
||||
]);
|
||||
stats = s;
|
||||
retention = r;
|
||||
health = h;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
|
@ -145,9 +172,6 @@
|
|||
case 'settings:action:dream':
|
||||
await runDream();
|
||||
break;
|
||||
case 'settings:action:birth':
|
||||
fireBirth();
|
||||
break;
|
||||
case 'settings:action:refresh':
|
||||
await runRefresh();
|
||||
break;
|
||||
|
|
@ -184,21 +208,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
function fireBirth(): void {
|
||||
const type = DEMO_NODE_TYPES[birthCount % DEMO_NODE_TYPES.length];
|
||||
birthCount++;
|
||||
websocket.injectEvent({
|
||||
type: 'MemoryCreated',
|
||||
data: {
|
||||
id: `demo-birth-${Date.now()}`,
|
||||
content: `Demo memory #${birthCount} - ${type}`,
|
||||
node_type: type,
|
||||
tags: ['demo', 'v2.3-birth-ritual'],
|
||||
retention: 0.9
|
||||
}
|
||||
});
|
||||
statusLine = `BIRTH ORB INJECTED - ${type.toUpperCase()} (SEE GRAPH)`;
|
||||
}
|
||||
|
||||
async function runRefresh(): Promise<void> {
|
||||
busy = 'refresh';
|
||||
|
|
@ -214,9 +223,36 @@
|
|||
function createSettingsPasses(engine: ObservatoryEngine, scene: RouteSceneModel): RouteFramePass[] {
|
||||
const field = new LivingFieldPass(engine);
|
||||
consoleField = field;
|
||||
// Preserve the verified dim portrait treatment exactly. On desktop, enrich
|
||||
// the tissue outside a tighter reading well: the well keeps every MSDF row
|
||||
// crisp while the otherwise empty perimeter becomes a living backdrop.
|
||||
// Branch from the engine's live viewport rather than a device-width constant.
|
||||
let portraitBucket: boolean | null = null;
|
||||
const applyFieldTreatment = () => {
|
||||
const width = engine.params[6];
|
||||
const height = engine.params[7];
|
||||
if (width <= 0 || height <= 0) return;
|
||||
const portrait = width / height < 0.85;
|
||||
if (portrait === portraitBucket) return;
|
||||
portraitBucket = portrait;
|
||||
if (portrait) {
|
||||
field.setIntensity(0.18);
|
||||
field.setReadingWell({ x: -0.2, y: 0, hw: 0.85, hh: 0.92, floor: 0.06, soft: 0.22 });
|
||||
} else {
|
||||
field.setIntensity(1.0);
|
||||
field.setReadingWell({ x: -0.2, y: 0, hw: 0.62, hh: 0.72, floor: 0.12, soft: 0.14 });
|
||||
}
|
||||
};
|
||||
// Creation precedes the engine's first viewport uniform write, so start with
|
||||
// the portrait-safe values and resolve the live aspect in compute().
|
||||
field.setIntensity(0.18);
|
||||
field.setReadingWell({ x: -0.2, y: 0, hw: 0.85, hh: 0.92, floor: 0.06, soft: 0.22 });
|
||||
field.setCells(buildConsoleCells());
|
||||
const fieldWrapper: RouteFramePass = {
|
||||
compute: (encoder) => field.compute(encoder),
|
||||
compute: (encoder) => {
|
||||
applyFieldTreatment();
|
||||
field.compute(encoder);
|
||||
},
|
||||
render: (renderPass) => field.render(renderPass),
|
||||
dispose: () => {
|
||||
field.dispose();
|
||||
|
|
@ -276,8 +312,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Live viewport aspect (canvas px, window fallback) — the SAME source of truth
|
||||
// portraitAdapt uses. Portrait = narrow phone; drives a single stacked column so
|
||||
// the side-by-side desktop vitals/actions never overprint at ~390px.
|
||||
function isPortrait(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
if (vw <= 0 || vh <= 0) return false;
|
||||
return vw / vh < 0.85;
|
||||
}
|
||||
|
||||
// ── Layout: build the whole console as MSDF items ───────────────────────────
|
||||
function buildConsoleItems(): TextLayerItem[] {
|
||||
if (isPortrait()) return buildConsoleItemsPortrait();
|
||||
const items: TextLayerItem[] = [];
|
||||
|
||||
// Masthead ----------------------------------------------------------------
|
||||
|
|
@ -311,7 +359,7 @@
|
|||
base('settings:v-ws', 'settings-vital', online ? 'ONLINE' : 'OFFLINE', 0.04, 0.6, 0.05, online ? RECALL : SCARLET)
|
||||
);
|
||||
items.push(base('settings:v-ver-l', 'settings-vital', 'VESTIGE', 0.5, 0.66, 0.024, FLOW));
|
||||
items.push(base('settings:v-ver', 'settings-vital', `v${stats ? '2.2.0' : '?'}`, 0.5, 0.6, 0.05, LUCIFERIN));
|
||||
items.push(base('settings:v-ver', 'settings-vital', `v${health?.version ?? '?'}`, 0.5, 0.6, 0.05, LUCIFERIN));
|
||||
items.push(
|
||||
base('settings:v-cov', 'settings-vital', `EMBEDDING COVERAGE ${cov.toFixed(0)}%`, -0.9, 0.53, 0.02, FLOW)
|
||||
);
|
||||
|
|
@ -354,12 +402,11 @@
|
|||
busy === 'dream' ? AMBER : RECALL
|
||||
)
|
||||
);
|
||||
items.push(actionItem('settings:action:birth', '[ TRIGGER BIRTH ]', 0.06, actionY, LUCIFERIN));
|
||||
items.push(
|
||||
actionItem(
|
||||
'settings:action:refresh',
|
||||
busy === 'refresh' ? '[ REFRESHING... ]' : '[ REFRESH ]',
|
||||
0.56,
|
||||
0.2,
|
||||
actionY,
|
||||
busy === 'refresh' ? AMBER : FLOW
|
||||
)
|
||||
|
|
@ -400,6 +447,110 @@
|
|||
return items;
|
||||
}
|
||||
|
||||
// ── Portrait console: ONE left-anchored vertical stack ─────────────────────
|
||||
// The desktop layout packs vitals into 4 side-by-side x-columns and the actions
|
||||
// into 3 — which overprint into garbage at phone width. In portrait we lay every
|
||||
// line on its OWN y in a single column at x=-0.9 (portraitAdapt reclaims the full
|
||||
// authored y-spread to the screen and caps size to fit width), so each vital,
|
||||
// histogram row, and action reads as its own line. Nothing here is a phone-width
|
||||
// magic number: the ONLY gate is the live aspect (isPortrait), and the y-spread is
|
||||
// the same authored ±0.86 the landscape layout uses.
|
||||
function buildConsoleItemsPortrait(): TextLayerItem[] {
|
||||
const items: TextLayerItem[] = [];
|
||||
const X = -0.9;
|
||||
let y = 0.86;
|
||||
const step = 0.066; // one line per row across the reclaimed portrait height
|
||||
const row = () => {
|
||||
const cur = y;
|
||||
y -= step;
|
||||
return cur;
|
||||
};
|
||||
|
||||
// Masthead
|
||||
items.push(base('settings:title', 'settings-title', 'SETTINGS & SYSTEM', X, row(), 0.05, LUCIFERIN));
|
||||
items.push(
|
||||
base('settings:subtitle', 'settings-sub', 'TUNE THE COGNITIVE ENGINE. RUN THE RITUALS.', X, row(), 0.026, FLOW, 40)
|
||||
);
|
||||
y -= step * 0.4;
|
||||
|
||||
// Vitals — each on its OWN line as "LABEL value" so nothing overprints.
|
||||
const mem = stats?.totalMemories ?? 0;
|
||||
const ret = stats?.averageRetention ?? 0;
|
||||
const cov = stats?.embeddingCoverage ?? 0;
|
||||
const online = $isConnected;
|
||||
items.push(base('settings:v-mem', 'settings-vital', `MEMORIES ${mem.toLocaleString()}`, X, row(), 0.03, CYAN, 40));
|
||||
items.push(
|
||||
base('settings:v-ret', 'settings-vital', `AVG RETENTION ${(ret * 100).toFixed(1)}%`, X, row(), 0.03, retentionColor(ret), 40)
|
||||
);
|
||||
items.push(
|
||||
base('settings:v-ws', 'settings-vital', `WEBSOCKET ${online ? 'ONLINE' : 'OFFLINE'}`, X, row(), 0.03, online ? RECALL : SCARLET, 40)
|
||||
);
|
||||
items.push(base('settings:v-ver', 'settings-vital', `VESTIGE v${health?.version ?? '?'}`, X, row(), 0.03, LUCIFERIN, 40));
|
||||
items.push(base('settings:v-cov', 'settings-vital', `EMBEDDING COVERAGE ${cov.toFixed(0)}%`, X, row(), 0.026, FLOW, 40));
|
||||
y -= step * 0.4;
|
||||
|
||||
// Retention distribution — short bars (portrait is width-tight) with the count
|
||||
// on the SAME line so the bar can never overprint the value.
|
||||
items.push(base('settings:hist-h', 'settings-hist', 'RETENTION DISTRIBUTION', X, row(), 0.028, LUCIFERIN, 40));
|
||||
const dist = retention?.distribution ?? [];
|
||||
const maxCount = Math.max(1, ...dist.map((b) => b.count));
|
||||
dist.forEach((bucket, i) => {
|
||||
const bandLo = i * 10;
|
||||
const barLen = Math.round((bucket.count / maxCount) * 10); // short: fits phone width
|
||||
const bar = '#'.repeat(Math.max(bucket.count > 0 ? 1 : 0, barLen));
|
||||
const label = `${String(bandLo).padStart(3, ' ')}% ${bar.padEnd(10, ' ')} ${bucket.count.toLocaleString()}`;
|
||||
items.push(base(`settings:hist-${i}`, 'settings-hist', label, X, row(), 0.022, histColor(i), 40));
|
||||
});
|
||||
y -= step * 0.4;
|
||||
|
||||
// Action buttons — STACKED so each is its own tappable target, not overlapped.
|
||||
items.push(
|
||||
actionItem(
|
||||
'settings:action:consolidate',
|
||||
busy === 'consolidate' ? '[ CONSOLIDATING... ]' : '[ CONSOLIDATE ]',
|
||||
X,
|
||||
row(),
|
||||
busy === 'consolidate' ? AMBER : CYAN
|
||||
)
|
||||
);
|
||||
items.push(
|
||||
actionItem('settings:action:dream', busy === 'dream' ? '[ DREAMING... ]' : '[ DREAM ]', X, row(), busy === 'dream' ? AMBER : RECALL)
|
||||
);
|
||||
items.push(
|
||||
actionItem('settings:action:refresh', busy === 'refresh' ? '[ REFRESHING... ]' : '[ REFRESH ]', X, row(), busy === 'refresh' ? AMBER : FLOW)
|
||||
);
|
||||
y -= step * 0.3;
|
||||
|
||||
// Status + results
|
||||
items.push(base('settings:status', 'settings-status', `> ${statusLine}`, X, row(), 0.024, LUCIFERIN, 40));
|
||||
if (consolidation) {
|
||||
const c = consolidation;
|
||||
const line = `PROCESSED ${c.nodesProcessed} DECAYED ${c.decayApplied} MERGED ${c.duplicatesMerged} ${c.durationMs}MS`;
|
||||
items.push(base('settings:res-c', 'settings-result', line, X, row(), 0.02, RECALL, 40));
|
||||
}
|
||||
if (dream) {
|
||||
const d = dream;
|
||||
const line = `REPLAYED ${d.memoriesReplayed} CONNECTIONS ${d.connectionsPersisted} INSIGHTS ${d.insights?.length ?? 0}`;
|
||||
items.push(base('settings:res-d', 'settings-result', line, X, row(), 0.02, RECALL, 40));
|
||||
}
|
||||
|
||||
// Footer — readable size (the desktop 0.018 renders illegibly tiny on a phone).
|
||||
items.push(
|
||||
base(
|
||||
'settings:about',
|
||||
'settings-about',
|
||||
'RUST + AXUM + SVELTEKIT + WEBGPU | FSRS-6 | NOMIC v1.5 | LOCAL-FIRST',
|
||||
X,
|
||||
-0.9,
|
||||
0.02,
|
||||
FLOW,
|
||||
40
|
||||
)
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function base(
|
||||
id: string,
|
||||
kind: string,
|
||||
|
|
|
|||
|
|
@ -90,6 +90,11 @@
|
|||
private field: LivingFieldPass;
|
||||
constructor(engine: ObservatoryEngine) {
|
||||
this.field = new LivingFieldPass(engine);
|
||||
// Text-heavy organ: the field is a DIM backdrop, not the star.
|
||||
this.field.setIntensity(0.22);
|
||||
// Vitals labels run down the left column (x=-0.82, y from +0.68 to -0.68).
|
||||
// Suppress the field there so every metric row stays legible.
|
||||
this.field.setReadingWell({ x: -0.5, y: 0, hw: 0.6, hh: 0.85, floor: 0.08, soft: 0.25 });
|
||||
}
|
||||
uploadScene(scene: RouteSceneModel): void {
|
||||
const receipts = scene.receipts as VitalReceipt[];
|
||||
|
|
@ -180,7 +185,10 @@
|
|||
y: top - i * step,
|
||||
size: i < 4 ? 0.036 : 0.027,
|
||||
color: vitalColor(receipt.metric, magnitude),
|
||||
depth: magnitude,
|
||||
// Depth drives brightness, but floor it so a low-magnitude vital
|
||||
// (e.g. an older newestMemory timestamp) never fades to unreadable —
|
||||
// every stat must be legible; magnitude still varies the glow above it.
|
||||
depth: Math.max(0.55, magnitude),
|
||||
weight: Math.max(0.18, Math.sqrt(magnitude)),
|
||||
startFrame: i * 2,
|
||||
revealSpan: 22,
|
||||
|
|
@ -250,7 +258,13 @@
|
|||
if (Number.isInteger(value)) return value.toLocaleString();
|
||||
return value.toFixed(3);
|
||||
}
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'string') {
|
||||
// Compact an ISO timestamp to "YYYY-MM-DD HH:MM" so the full value fits on
|
||||
// one line (the raw ISO string with ms + timezone overruns and truncates).
|
||||
const parsed = Date.parse(value);
|
||||
if (Number.isFinite(parsed)) return value.slice(0, 16).replace('T', ' ');
|
||||
return value;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@
|
|||
const OXYGEN = [...rgb01('#A8FF5E'), 0.92] satisfies [number, number, number, number];
|
||||
const SCARLET = [...rgb01('#FF3B30'), 0.92] satisfies [number, number, number, number];
|
||||
const MUTED = [...rgb01('#29F2A9'), 0.6] satisfies [number, number, number, number];
|
||||
// Portrait-only: the small captions sit over the (dimmed) ring band, so a 0.6-alpha
|
||||
// MUTED green washes out. A full-alpha brighter mint lifts them clear of the field.
|
||||
const LABEL_HI = [...rgb01('#8AF7D0'), 1] satisfies [number, number, number, number];
|
||||
|
||||
const RANGE_CYCLE = [7, 14, 30, 90, 365] as const;
|
||||
|
||||
|
|
@ -37,6 +40,9 @@
|
|||
// text pass handle (owned by the route pass factory below)
|
||||
let textPass: TextLayerPass | null = null;
|
||||
let focusedRun: string | null = null;
|
||||
// engine handle captured in the pass factory so buildTextItems can read the live
|
||||
// viewport aspect (params[6]/[7]) for portrait-only vital-sign stacking.
|
||||
let engineHandle: ObservatoryEngine | null = null;
|
||||
|
||||
onMount(() => loadTimeline());
|
||||
|
||||
|
|
@ -126,6 +132,18 @@
|
|||
return Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0.5));
|
||||
}
|
||||
|
||||
// Live viewport aspect, same source (and window fallback) TextLayerPass.portraitAdapt
|
||||
// uses. Portrait (aspect < 0.85) needs its own vital-sign stacking so the big
|
||||
// numbers don't collide with their captions and the receipt doesn't overrun the
|
||||
// HUD column. NOTHING here is hardcoded per phone width — it scales with aspect.
|
||||
function viewportAspect(): number {
|
||||
const vw = engineHandle?.params[6] || 0;
|
||||
const vh = engineHandle?.params[7] || 0;
|
||||
if (vw > 0 && vh > 0) return vw / vh;
|
||||
if (typeof window !== 'undefined' && window.innerHeight > 0) return window.innerWidth / window.innerHeight;
|
||||
return 1.6;
|
||||
}
|
||||
|
||||
function line(
|
||||
id: string,
|
||||
kind: string,
|
||||
|
|
@ -191,14 +209,58 @@
|
|||
[`${timeline.length}`, 'CALENDAR SLICES', CYAN],
|
||||
[`${Math.round(avgRetention * 100)}%`, 'AVERAGE RETENTION OXYGEN', OXYGEN]
|
||||
];
|
||||
|
||||
// Portrait: the big number (size 0.05, boosted ~2.7x by portraitAdapt) grows
|
||||
// TALLER than the authored 0.05 num->label gap, so it lands on top of its own
|
||||
// caption; and the right-hand receipt column (x=0.3) gets center-pulled into
|
||||
// the HUD column and collides. So in portrait we author a single left column:
|
||||
// smaller numbers with a wider caption gap, then the receipt STACKED below the
|
||||
// vitals instead of beside them. Landscape/desktop keeps the exact old layout.
|
||||
const portrait = viewportAspect() < 0.85;
|
||||
|
||||
if (portrait) {
|
||||
// Portrait bind: each big number and its caption must read as ONE unit, and
|
||||
// every unit must be clearly separated from the next. The old layout had the
|
||||
// caption gap (0.09) nearly equal to the leftover pitch (0.155-0.09=0.065), so
|
||||
// a number floated ambiguously between its own label and the next number — the
|
||||
// last vital ('AVERAGE RETENTION OXYGEN') looked label-only because its number
|
||||
// had drifted up beside the previous caption. Fix: a SMALL number->label gap
|
||||
// (tight caption) and a LARGE row pitch (clear break) so binding is unambiguous.
|
||||
// portraitAdapt multiplies every y by inv (>1 in portrait), so these authored
|
||||
// gaps scale with the live aspect — nothing here is a hardcoded phone width.
|
||||
const vTop = 0.66;
|
||||
const vGap = 0.052; // number -> its own caption (tight)
|
||||
const vPitch = 0.17; // row -> next row (loose, > 2x the caption gap)
|
||||
vitals.forEach(([value, label, color], i) => {
|
||||
const y = vTop - i * vPitch;
|
||||
items.push(line(`tl:vital-num:${i}`, 'tl-vital', value, -0.92, y, 0.03, color, { depth: 0.9, weight: 0.85 }));
|
||||
items.push(line(`tl:vital-lbl:${i}`, 'tl-vital-lbl', label, -0.92, y - vGap, 0.016, LABEL_HI, { depth: 0.7, weight: 0.7, maxWidthEm: 30 }));
|
||||
});
|
||||
// Receipt/hint below the last caption. Bigger header + hint text so the
|
||||
// 'CLICK A GROWTH RING...' subtext is readable on a phone (bumped in
|
||||
// buildReceiptItems via the portrait flag, not a magic size here).
|
||||
buildReceiptItems(items, -0.92, vTop - vitals.length * vPitch - 0.02, 0.028, LABEL_HI, true);
|
||||
return items;
|
||||
}
|
||||
|
||||
vitals.forEach(([value, label, color], i) => {
|
||||
const y = 0.72 - i * 0.11;
|
||||
items.push(line(`tl:vital-num:${i}`, 'tl-vital', value, -0.92, y, 0.05, color, { depth: 0.9, weight: 0.85 }));
|
||||
items.push(line(`tl:vital-lbl:${i}`, 'tl-vital-lbl', label, -0.92, y - 0.05, 0.017, MUTED, { depth: 0.5 }));
|
||||
});
|
||||
|
||||
// ── RECEIPT (right column) — real /memories/:id/audit + bitemporal state ──
|
||||
items.push(line('tl:receipt-hdr', 'tl-receipt-hdr', 'TIME-SLICE RECEIPT', 0.3, 0.9, 0.022, INDIGO, { depth: 0.85 }));
|
||||
buildReceiptItems(items, 0.3, 0.9, 0.022);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// Receipt / audit block. `rx` is the left anchor, `ry0` the header baseline, and
|
||||
// `hdrSize` the header size — desktop pins it to the right column (0.3, 0.9), but
|
||||
// portrait stacks it below the vitals in the single left column. Everything below
|
||||
// the header is authored relative to ry0 so both layouts share one code path.
|
||||
function buildReceiptItems(items: TextLayerItem[], rx: number, ry0: number, hdrSize: number, muted = MUTED, portrait = false): void {
|
||||
items.push(line('tl:receipt-hdr', 'tl-receipt-hdr', 'TIME-SLICE RECEIPT', rx, ry0, hdrSize, INDIGO, { depth: 0.85 }));
|
||||
const rowTop = ry0 - 0.08;
|
||||
|
||||
if (selectedCell && selectedMemory) {
|
||||
const c = selectedCell;
|
||||
|
|
@ -212,18 +274,18 @@
|
|||
[`retain ${Math.round(c.retention * 100)}% state ${state}`, state === 'suppressed' ? SCARLET : OXYGEN]
|
||||
];
|
||||
rows.forEach(([t, color], i) => {
|
||||
items.push(line(`tl:receipt:${i}`, 'tl-receipt', t, 0.3, 0.82 - i * 0.06, 0.018, color, { revealSpan: 10, startFrame: i * 2 }));
|
||||
items.push(line(`tl:receipt:${i}`, 'tl-receipt', t, rx, rowTop - i * 0.06, 0.018, color, { revealSpan: 10, startFrame: i * 2 }));
|
||||
});
|
||||
|
||||
// real audit events (memoryAudit)
|
||||
const auditY0 = 0.82 - rows.length * 0.06 - 0.04;
|
||||
items.push(line('tl:audit-hdr', 'tl-audit-hdr', auditLoading ? 'MEMORY-AUDIT (loading...)' : 'MEMORY-AUDIT', 0.3, auditY0, 0.016, MUTED, { depth: 0.5 }));
|
||||
const auditY0 = rowTop - rows.length * 0.06 - 0.04;
|
||||
items.push(line('tl:audit-hdr', 'tl-audit-hdr', auditLoading ? 'MEMORY-AUDIT (loading...)' : 'MEMORY-AUDIT', rx, auditY0, 0.016, MUTED, { depth: 0.5 }));
|
||||
selectedAudit.slice(0, 10).forEach((ev, i) => {
|
||||
const t = `${ev.action} ${String(ev.timestamp).slice(0, 19)}`;
|
||||
items.push(line(`tl:audit:${i}`, 'tl-audit', t, 0.3, auditY0 - 0.03 - i * 0.045, 0.015, CYAN, { revealSpan: 8, startFrame: i * 2, depth: 0.6 }));
|
||||
items.push(line(`tl:audit:${i}`, 'tl-audit', t, rx, auditY0 - 0.03 - i * 0.045, 0.015, CYAN, { revealSpan: 8, startFrame: i * 2, depth: 0.6 }));
|
||||
});
|
||||
if (!auditLoading && selectedAudit.length === 0) {
|
||||
items.push(line('tl:audit-empty', 'tl-audit', 'no audit events returned', 0.3, auditY0 - 0.03, 0.015, MUTED));
|
||||
items.push(line('tl:audit-empty', 'tl-audit', 'no audit events returned', rx, auditY0 - 0.03, 0.015, MUTED));
|
||||
}
|
||||
} else if (selectedRing) {
|
||||
const r = selectedRing;
|
||||
|
|
@ -236,28 +298,38 @@
|
|||
['click a cell for its audit receipt', MUTED]
|
||||
];
|
||||
rows.forEach(([t, color], i) => {
|
||||
items.push(line(`tl:ring:${i}`, 'tl-receipt', t, 0.3, 0.82 - i * 0.06, 0.017, color, { revealSpan: 10, startFrame: i * 2 }));
|
||||
items.push(line(`tl:ring:${i}`, 'tl-receipt', t, rx, rowTop - i * 0.06, 0.017, color, { revealSpan: 10, startFrame: i * 2 }));
|
||||
});
|
||||
} else {
|
||||
// The idle hint is the only call-to-action when nothing is selected, so on a
|
||||
// phone it must be readable, not a dim whisper. portraitAdapt caps a line's
|
||||
// size to fit its full char count on ONE line (it can't see the wrap), so a
|
||||
// 90-char hint is forced tiny no matter the authored size. In portrait we use
|
||||
// a SHORTER hint (fewer chars -> the size boost isn't capped away) at a larger
|
||||
// authored size and full-alpha LABEL_HI (passed in as `muted`) so it lifts
|
||||
// clear of the dimmed ring field. Desktop keeps the exact old long hint/size.
|
||||
const hintText = portrait
|
||||
? 'TAP A RING FOR ITS DATE SLICE'
|
||||
: 'CLICK A GROWTH RING FOR ITS DATE SLICE, OR A CELL FOR ITS VALID-TIME VS TRANSACTION-TIME RECEIPT';
|
||||
const hintSize = portrait ? 0.026 : 0.017;
|
||||
items.push(
|
||||
line(
|
||||
'tl:receipt-hint',
|
||||
'tl-receipt',
|
||||
'CLICK A GROWTH RING FOR ITS DATE SLICE, OR A CELL FOR ITS VALID-TIME VS TRANSACTION-TIME RECEIPT',
|
||||
0.3,
|
||||
0.8,
|
||||
0.017,
|
||||
MUTED,
|
||||
{ maxWidthEm: 34, revealSpan: 20 }
|
||||
hintText,
|
||||
rx,
|
||||
rowTop - 0.02,
|
||||
hintSize,
|
||||
muted,
|
||||
{ maxWidthEm: portrait ? 22 : 34, revealSpan: 20 }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// ── the route pass factory: growth-rings organ + MSDF HUD/receipt, zero DOM ──
|
||||
function createTimelineOrganPasses(engine: ObservatoryEngine, scene: RouteSceneModel): RouteFramePass[] {
|
||||
engineHandle = engine;
|
||||
const ringPasses = createTimelinePasses(engine, scene);
|
||||
const text = new TextLayerPass(engine);
|
||||
textPass = text;
|
||||
|
|
|
|||
|
|
@ -1621,22 +1621,53 @@ pub async fn predict_memories(State(state): State<AppState>) -> Result<Json<Valu
|
|||
.get_all_nodes(10, 0)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// "Predicted need" is a REAL per-memory signal, not a constant. A memory is
|
||||
// most likely to be needed (and most at risk of slipping away) when its
|
||||
// retrieval strength has decayed OR its FSRS review is due/overdue — that is
|
||||
// precisely when the system should surface it. We combine both:
|
||||
// - urgency from decay: 1 - retrieval_strength (low accessibility = high need)
|
||||
// - urgency from schedule: review overdue -> high, far future -> low
|
||||
// and map the blended urgency onto high/medium/low bands.
|
||||
let now = chrono::Utc::now();
|
||||
let predictions: Vec<Value> = recent
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let decay_urgency = (1.0 - n.retrieval_strength).clamp(0.0, 1.0);
|
||||
let schedule_urgency = match n.next_review {
|
||||
// Overdue -> 1.0; due within a day -> ~0.8; a week out -> ~0.3; far -> ~0.0
|
||||
Some(next) => {
|
||||
let hours_until = (next - now).num_minutes() as f64 / 60.0;
|
||||
if hours_until <= 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
(1.0 - (hours_until / (24.0 * 7.0))).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
// No scheduled review yet — lean on decay alone.
|
||||
None => decay_urgency,
|
||||
};
|
||||
let urgency = 0.55 * decay_urgency + 0.45 * schedule_urgency;
|
||||
let predicted_need = if urgency >= 0.66 {
|
||||
"high"
|
||||
} else if urgency >= 0.33 {
|
||||
"medium"
|
||||
} else {
|
||||
"low"
|
||||
};
|
||||
serde_json::json!({
|
||||
"id": n.id,
|
||||
"content": n.content.chars().take(100).collect::<String>(),
|
||||
"nodeType": n.node_type,
|
||||
"retention": n.retention_strength,
|
||||
"predictedNeed": "high",
|
||||
"urgency": urgency,
|
||||
"predictedNeed": predicted_need,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"predictions": predictions,
|
||||
"basedOn": "recent_activity",
|
||||
"basedOn": "fsrs_retrieval_and_review_schedule",
|
||||
})))
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue