From ee45fa8be659a2a833f4353cddd3e5542a9f8da0 Mon Sep 17 00:00:00 2001 From: bulbashenko Date: Sun, 11 Oct 2026 04:43:05 +0200 Subject: [PATCH 1/3] Find the form error by its class in the login tests getByRole("alert") now matches two elements on the page, so the strict locator failed and main went red. Co-Authored-By: Claude Opus 5.5 --- e2e/tests/01-login.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/tests/01-login.spec.ts b/e2e/tests/01-login.spec.ts index c83e46d..fb8cecc 100644 --- a/e2e/tests/01-login.spec.ts +++ b/e2e/tests/01-login.spec.ts @@ -20,7 +20,7 @@ test("wrong password shows an error", async ({ page }) => { const person = await createPerson(["mail-users"]) await page.goto("/login") await passwordStep(page, { ...person, password: "wrong-password-123" }) - await expect(page.getByRole("alert")).toContainText("Неверное имя пользователя или пароль") + await expect(page.locator(".msg.error")).toContainText("Неверное имя пользователя или пароль") }) test("password, TOTP enrolment, then every sign-in needs the second factor", async ({ page }) => { @@ -38,7 +38,7 @@ test("password, TOTP enrolment, then every sign-in needs the second factor", asy await expect(page.getByRole("heading", { name: new RegExp(`Привет, ${person.username}|Ещё шаг`) })).toBeVisible() await totpStep(page, "000000") - await expect(page.getByRole("alert")).toContainText(/Код не подошёл|invalid/) + await expect(page.locator(".msg.error")).toContainText(/Код не подошёл|invalid/) await totpStep(page, await freshTotp(secret)) await expect(page.getByRole("heading", { name: "Готово" })).toBeVisible() From c15813c769ffd83f447ac47ef9fddc15e5423759 Mon Sep 17 00:00:00 2001 From: bulbashenko Date: Sun, 11 Oct 2026 04:43:05 +0200 Subject: [PATCH 2/3] Draw the ASCII eye and background on the GPU The main thread was the bottleneck, not the 3D: every eye frame read the WebGL canvas back with getImageData and drew ~8,000 glyphs with fillText, and the background drew ~13,000 more at 15 fps on every device, phones included. - Eye: three.js renders into an MSAA render target; a shader averages it per cell and picks glyph and colour, a second one draws the glyphs from an atlas texture. No readback per frame, no fillText. Gaze, drag, modes and glitches work as before. - Background: one fragment shader in plain WebGL2, rain as a function of time. No three.js, so narrow screens (where the eye is hidden) don't download it. - The eye loads only at >= 1100px, the width at which its panel is shown. - Nothing is drawn for reduced motion, Save-Data, a missing or lost WebGL2 context, or frames slower than 35 ms on average; the last two stick for the tab session. - Fixes a TypeError when the color attribute was set before connection. Co-Authored-By: Claude Opus 5.5 --- ui/src/components/visual/Visuals.tsx | 101 ++++++- ui/src/components/visual/ascii-bg.js | 179 +++++++++---- ui/src/components/visual/eye-ascii.js | 367 ++++++++++++++++++-------- 3 files changed, 484 insertions(+), 163 deletions(-) diff --git a/ui/src/components/visual/Visuals.tsx b/ui/src/components/visual/Visuals.tsx index 00a3956..91b233b 100644 --- a/ui/src/components/visual/Visuals.tsx +++ b/ui/src/components/visual/Visuals.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useState, useSyncExternalStore } from "react" export type EyeMode = "idle" | "closed" | "scan" | "ok" | "error" @@ -11,15 +11,92 @@ export function setEyeMode(mode: EyeMode) { window.dispatchEvent(new CustomEvent(EVENT, { detail: mode })) } -const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches +const REDUCED = "(prefers-reduced-motion: reduce)" +// Must match the breakpoint that hides .right in globals.css: below it the eye's code is not even downloaded. +const WIDE = "(min-width: 1100px)" +// Set when WebGL failed or frames were too slow; kept for the tab session so later pages don't try again. +const DOWNGRADED = "visual:off" -/** The 3D eye drone rendered as ASCII (three.js, bundled). Skipped for people who ask for reduced motion. */ +const listeners = new Set<() => void>() +let downgraded: boolean | undefined + +function isDowngraded() { + if (downgraded === undefined) { + try { + downgraded = sessionStorage.getItem(DOWNGRADED) === "1" + } catch { + downgraded = false + } + } + return downgraded +} + +function downgrade() { + if (isDowngraded()) return + downgraded = true + try { + sessionStorage.setItem(DOWNGRADED, "1") + } catch {} + listeners.forEach((l) => l()) +} + +function subscribe(onChange: () => void) { + const queries = [REDUCED, WIDE].map((q) => window.matchMedia(q)) + queries.forEach((m) => m.addEventListener("change", onChange)) + // The custom elements report a missing or lost WebGL2 context with this event. + if (listeners.size === 0) window.addEventListener("visual:fail", downgrade) + listeners.add(onChange) + return () => { + queries.forEach((m) => m.removeEventListener("change", onChange)) + listeners.delete(onChange) + if (listeners.size === 0) window.removeEventListener("visual:fail", downgrade) + } +} + +/** Animations run unless the person asks for reduced motion or Save-Data, WebGL2 is missing, or frames are too slow. */ +function isLive() { + const saveData = (navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData + return !(isDowngraded() || saveData || window.matchMedia(REDUCED).matches) +} + +const useLive = () => useSyncExternalStore(subscribe, isLive, () => false) +const useWide = () => useSyncExternalStore(subscribe, () => window.matchMedia(WIDE).matches, () => false) + +/** + * Turns the animations off when the page can't hold its frame rate: the mean interval between animation frames + * stays above 35 ms (under ~28 fps) over a 3 s window. The first second after load and hidden tabs don't count. + */ +function useFrameWatchdog(active: boolean) { + useEffect(() => { + if (!active) return + let raf = 0, prev = 0, sum = 0, count = 0 + const start = performance.now() + const tick = (now: number) => { + raf = requestAnimationFrame(tick) + const dt = now - prev + prev = now + // Gaps over 250 ms are a backgrounded tab or a one-off hitch, not a slow device. + if (now - start < 1000 || document.hidden || dt > 250) return + sum += dt + count++ + if (sum < 3000) return + if (sum / count > 35) downgrade() + sum = count = 0 + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, [active]) +} + +/** The 3D eye drone rendered as ASCII (three.js, bundled). Wide screens only, like the panel that holds it. */ export function Eye({ initial = "idle" }: { initial?: EyeMode }) { + const live = useLive() + const wide = useWide() const [ready, setReady] = useState(false) const [mode, setMode] = useState(initial) useEffect(() => { - if (reducedMotion()) return + if (!live || !wide) return let alive = true import("./eye-ascii.js").then(() => alive && setReady(true)) const onMode = (event: Event) => setMode((event as CustomEvent).detail) @@ -32,22 +109,26 @@ export function Eye({ initial = "idle" }: { initial?: EyeMode }) { window.removeEventListener(EVENT, onMode) if (timer) clearTimeout(timer) } - }, [initial]) + }, [initial, live, wide]) - if (!ready) return null + if (!live || !wide || !ready) return null return } -/** Dim drifting ASCII field behind everything. */ +/** Dim drifting ASCII field behind everything. Also runs the frame-rate watchdog for the page. */ export function AsciiBackground() { + const live = useLive() const [ready, setReady] = useState(false) + useFrameWatchdog(live) + useEffect(() => { - if (reducedMotion()) return + if (!live) return let alive = true import("./ascii-bg.js").then(() => alive && setReady(true)) return () => { alive = false } - }, []) - return ready ? : null + }, [live]) + + return live && ready ? : null } diff --git a/ui/src/components/visual/ascii-bg.js b/ui/src/components/visual/ascii-bg.js index f102807..f79f90d 100644 --- a/ui/src/components/visual/ascii-bg.js +++ b/ui/src/components/visual/ascii-bg.js @@ -1,78 +1,159 @@ -// Ported from design/ascii-bg.js (Claude Design "Bulbashenko SSO") unchanged. // — dim drifting ASCII field + sparse glyph rain, tinted to the phosphor palette. +// Drawn by one fragment shader in plain WebGL2 (no three.js, so pages without the eye stay light). The rain is a +// function of time, not per-column state. Dispatches "visual:fail" on window when WebGL2 is unavailable or lost. (() => { if (customElements.get('ascii-bg')) return; -const RAMP = ' .\u00b7:-=+*#%'; +const RAMP = ' .·:-=+*#%'; const RAIN = '01<>/\\|=+*:;$#ABCDEF'; const CW = 10, CH = 16; -const hash = (x, y) => (((x * 73856093) ^ (y * 19349663)) >>> 0); +// Atlas: one tile per distinct glyph; RAMP and RAIN index into it. +const GLYPHS = [...new Set([...RAMP, ...RAIN])]; +const ids = (s) => [...s].map((c) => GLYPHS.indexOf(c)); + +const VERT = `#version 300 es +in vec2 aPos; +void main() { gl_Position = vec4(aPos, 0.0, 1.0); }`; + +const FRAG = `#version 300 es +precision highp float; +precision highp int; +uniform sampler2D uAtlas; +uniform vec2 uTile; +uniform float uDpr; +uniform float uBufH; +uniform vec2 uGrid; +uniform float uT; +uniform float uK; +uniform int uRamp[${RAMP.length}]; +uniform int uRain[${RAIN.length}]; +out vec4 o; +float h(float a, float b) { return fract(sin(a * 127.1 + b * 311.7) * 43758.5453); } +float glyph(int g, vec2 local) { return texture(uAtlas, (vec2(float(g) * uTile.x, 0.0) + local * uDpr) / vec2(float(${GLYPHS.length}) * uTile.x, uTile.y)).a; } +void over(inout vec4 o, vec3 c, float a) { o = vec4(c * a, a) + o * (1.0 - a); } +void main() { + vec2 p = vec2(gl_FragCoord.x, uBufH - gl_FragCoord.y) / uDpr; + vec2 cell = floor(p / vec2(${CW}.0, ${CH}.0)), local = p - cell * vec2(${CW}.0, ${CH}.0); + float x = cell.x, y = cell.y, t = uT; + o = vec4(0.0); + float u = x * 0.11, v = y * 0.19; + float n = sin(u + v * 0.8 - t * 0.55) * 0.5 + + sin(u * 0.53 - v * 1.3 + t * 0.31 + sin(t * 0.07) * 3.0) * 0.32 + + sin((u + v) * 0.27 + t * 0.9) * 0.18; + n = (n + 1.0) / 2.0; n = n * n * n; + if (n >= 0.18) { + int rl = ${RAMP.length - 1}; + int ri = min(rl, int(floor(n * float(rl) + 0.5))); + if (ri > 0) over(o, vec3(120.0, 184.0, 100.0) / 255.0, glyph(uRamp[ri], local) * (0.06 + n * 0.32) * uK); + } + // Rain: each column runs drops in slots of its own length; a slot holds at most one drop, and some slots none. + float slot = 6.0 + 3.0 * h(x, 1.0), tt = t + h(x, 2.0) * slot, k = floor(tt / slot), lt = tt - k * slot; + if (h(x + k * 0.37, 3.0) < 0.34) { + float speed = (4.0 + 10.0 * h(x, k + 4.0)) * 4.2, len = 5.0 + floor(12.0 * h(x, k + 5.0)); + float head = floor(-h(x, k + 6.0) * uGrid.y * 0.5 + lt * speed); + float j = head - y; + if (j >= 0.0 && j < len) { + uint hs = (uint(x) * 73856093u) ^ (uint(y) * 19349663u); + int g = uRain[(hs + (j == 0.0 ? uint(t * 8.0) : 0u)) % ${RAIN.length}u]; + float f = 1.0 - j / len; + if (j == 0.0) over(o, vec3(184.0, 240.0, 164.0) / 255.0, glyph(g, local) * 0.55 * uK); + else over(o, vec3(163.0, 232.0, 137.0) / 255.0, glyph(g, local) * (0.05 + 0.2 * f * f) * uK); + } + } +}`; + class AsciiBg extends HTMLElement { connectedCallback() { Object.assign(this.style, { display: 'block', position: 'absolute', inset: '0', width: '100%', height: '100%', pointerEvents: 'none' }); - if (!this._c) { - this._c = document.createElement('canvas'); - Object.assign(this._c.style, { position: 'absolute', inset: '0', width: '100%', height: '100%', display: 'block' }); - this.appendChild(this._c); - this._ctx = this._c.getContext('2d'); - } + if (!this._gl && !this._init()) { window.dispatchEvent(new Event('visual:fail')); return; } this._ro = new ResizeObserver(() => this._resize()); this._ro.observe(this); this._io = new IntersectionObserver((es) => { this._vis = es[es.length - 1].isIntersecting; }); this._io.observe(this); - this._last = performance.now(); this._t = 0; + this._last = performance.now(); this._t = this._t || 0; const loop = (now) => { this._raf = requestAnimationFrame(loop); this._draw(now); }; this._raf = requestAnimationFrame(loop); } - disconnectedCallback() { cancelAnimationFrame(this._raf); this._ro && this._ro.disconnect(); this._io && this._io.disconnect(); } + disconnectedCallback() { + cancelAnimationFrame(this._raf); + this._ro && this._ro.disconnect(); this._io && this._io.disconnect(); + if (this._gl) { + this._c.removeEventListener('webglcontextlost', this._onLost); + const lose = this._gl.getExtension('WEBGL_lose_context'); + lose && lose.loseContext(); + this._c.remove(); this._c = this._gl = null; + } + } + _init() { + const c = document.createElement('canvas'); + Object.assign(c.style, { position: 'absolute', inset: '0', width: '100%', height: '100%', display: 'block' }); + const gl = c.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: false, depth: false, powerPreference: 'low-power' }); + if (!gl) return false; + const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return s; }; + const prog = gl.createProgram(); + gl.attachShader(prog, sh(gl.VERTEX_SHADER, VERT)); + gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FRAG)); + gl.linkProgram(prog); + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return false; + gl.useProgram(prog); + gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); + const loc = gl.getAttribLocation(prog, 'aPos'); + gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0); + gl.bindTexture(gl.TEXTURE_2D, gl.createTexture()); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + const U = {}; + for (const n of ['uAtlas', 'uTile', 'uDpr', 'uBufH', 'uGrid', 'uT', 'uK', 'uRamp', 'uRain']) U[n] = gl.getUniformLocation(prog, n); + gl.uniform1i(U.uAtlas, 0); + gl.uniform1iv(U.uRamp, ids(RAMP)); + gl.uniform1iv(U.uRain, ids(RAIN)); + this._onLost = (e) => { e.preventDefault(); cancelAnimationFrame(this._raf); window.dispatchEvent(new Event('visual:fail')); }; + c.addEventListener('webglcontextlost', this._onLost); + this.appendChild(c); + this._c = c; this._gl = gl; this._U = U; this._dpr = 0; + document.fonts && document.fonts.load('12px "IBM Plex Mono"').then(() => { this._dpr = 0; this._resize(); }); + return true; + } + _atlas(dpr) { + const tw = Math.ceil(CW * dpr), th = Math.ceil(CH * dpr); + const a = document.createElement('canvas'); + a.width = GLYPHS.length * tw; a.height = th; + const ctx = a.getContext('2d'); + ctx.font = `${12 * dpr}px "IBM Plex Mono", ui-monospace, monospace`; + ctx.textBaseline = 'top'; + ctx.fillStyle = '#fff'; + GLYPHS.forEach((ch, i) => ctx.fillText(ch, i * tw, 0)); + const gl = this._gl; + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, a); + gl.uniform2f(this._U.uTile, tw, th); + this._dpr = dpr; + } _resize() { + if (!this._gl) return; const W = this.clientWidth, H = this.clientHeight; if (!W || !H) return; - const dpr = Math.min(window.devicePixelRatio || 1, 2); + const dpr = Math.min(window.devicePixelRatio || 1, 2), gl = this._gl; this._W = W; this._H = H; this._c.width = Math.round(W * dpr); this._c.height = Math.round(H * dpr); - this._ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - this._cols = Math.ceil(W / CW); this._rows = Math.ceil(H / CH); - this._drops = Array.from({ length: this._cols }, () => ({ on: Math.random() < 0.18, y: -Math.random() * this._rows, v: 4 + Math.random() * 10, len: 5 + (Math.random() * 12 | 0) })); + gl.viewport(0, 0, this._c.width, this._c.height); + if (dpr !== this._dpr) this._atlas(dpr); + gl.uniform1f(this._U.uDpr, this._c.height / H); + gl.uniform1f(this._U.uBufH, this._c.height); + gl.uniform2f(this._U.uGrid, Math.ceil(W / CW), Math.ceil(H / CH)); + this._lastPaint = 0; } _draw(now) { if (this._vis === false) { this._last = now; return; } if (!this._W) { this._resize(); if (!this._W) return; } const dt = Math.min(0.1, (now - this._last) / 1000); this._last = now; const sp = parseFloat(this.getAttribute('speed') || '1'); - const t = (this._t += dt * sp); + this._t += dt * sp; if (now - (this._lastPaint || 0) < 66) return; // ~15fps: quiet, cheap this._lastPaint = now; const dim = Math.max(0, Math.min(1, parseFloat(this.getAttribute('dim') ?? '0.5'))); - const k = 1 - dim; - const ctx = this._ctx, cols = this._cols, rows = this._rows; - ctx.clearRect(0, 0, this._W, this._H); - ctx.font = '12px "IBM Plex Mono", ui-monospace, monospace'; - ctx.textBaseline = 'top'; - const rl = RAMP.length - 1; - for (let y = 0; y < rows; y++) { - for (let x = 0; x < cols; x++) { - const u = x * 0.11, v = y * 0.19; - let n = Math.sin(u + v * 0.8 - t * 0.55) * 0.5 - + Math.sin(u * 0.53 - v * 1.3 + t * 0.31 + Math.sin(t * 0.07) * 3) * 0.32 - + Math.sin((u + v) * 0.27 + t * 0.9) * 0.18; - n = (n + 1) / 2; n = n * n * n; - if (n < 0.18) continue; - const ch = RAMP[Math.min(rl, Math.round(n * rl))]; - if (ch === ' ') continue; - ctx.fillStyle = `rgba(120,184,100,${(0.06 + n * 0.32) * k})`; - ctx.fillText(ch, x * CW, y * CH); - } - } - for (let x = 0; x < cols; x++) { - const d = this._drops[x]; - d.y += d.v * dt * sp * 4.2; - if (d.y - d.len > rows) { d.y = -Math.random() * rows * 0.5; d.on = Math.random() < 0.18; d.v = 4 + Math.random() * 10; } - if (!d.on) continue; - const head = d.y | 0; - for (let j = 0; j < d.len; j++) { - const y = head - j; if (y < 0 || y >= rows) continue; - const f = 1 - j / d.len; - ctx.fillStyle = j === 0 ? `rgba(184,240,164,${0.55 * k})` : `rgba(163,232,137,${(0.05 + 0.2 * f * f) * k})`; - ctx.fillText(RAIN[(hash(x, y) + (j === 0 ? (t * 8 | 0) : 0)) % RAIN.length], x * CW, y * CH); - } - } + const gl = this._gl; + gl.uniform1f(this._U.uT, this._t); + gl.uniform1f(this._U.uK, 1 - dim); + gl.drawArrays(gl.TRIANGLES, 0, 3); } } customElements.define('ascii-bg', AsciiBg); diff --git a/ui/src/components/visual/eye-ascii.js b/ui/src/components/visual/eye-ascii.js index ba032aa..b7fa5da 100644 --- a/ui/src/components/visual/eye-ascii.js +++ b/ui/src/components/visual/eye-ascii.js @@ -1,31 +1,139 @@ -// Ported from design/eye-ascii.js (Claude Design "Bulbashenko SSO"). Only change: three.js is bundled from npm. -// -// 3D eye drone (three.js) rendered offscreen and resampled into an ASCII glyph grid. +// Ported from design/eye-ascii.js (Claude Design "Bulbashenko SSO"). Changes: three.js is bundled from npm, and the ASCII +// pass runs in shaders on the GPU (the design read the frame back with getImageData and drew every cell with fillText). +// +// 3D eye drone (three.js) rendered into a render target, averaged per cell, and drawn as glyphs from an atlas texture. +// Dispatches "visual:fail" on window when WebGL2 is unavailable or the context is lost. (() => { if (customElements.get('eye-ascii')) return; let threeP; // three.js comes from npm and is bundled: no third-party script on the login page. const loadThree = () => threeP || (threeP = import('three')); const RAMPS = { classic: ' .:-=+*#%@', punk: " .'`,:;!i|/\\(){}[]xX%&#@", block: ' ░▒▓█' }; -const mkPal = (stops) => Array.from({ length: 16 }, (_, k) => { +const GLITCH = '01<>/\\|=+*:;$#ABCDEF'; +// Every glyph in the atlas; a cell's glyph id is its index here + 1, and 0 means an empty cell. +const GLYPHS = [...new Set(['.', ...Object.values(RAMPS).join(''), ...GLITCH])].filter((c) => c !== ' '); +const gid = (c) => GLYPHS.indexOf(c) + 1; +const mkPal = (stops) => new Float32Array(Array.from({ length: 16 }, (_, k) => { const l = k / 15; let i = 0; while (i < stops.length - 2 && l > stops[i + 1][0]) i++; const [a, ca] = stops[i], [b, cb] = stops[i + 1], f = Math.min(1, Math.max(0, (l - a) / (b - a))); - return `rgb(${ca.map((v, j) => Math.round(v + (cb[j] - v) * f)).join(',')})`; -}); + return ca.map((v, j) => Math.round(v + (cb[j] - v) * f) / 255); +}).flat()); const PAL = mkPal([[0, [34, 58, 31]], [0.35, [91, 140, 76]], [0.7, [163, 232, 137]], [1, [214, 250, 196]]]); const PAL_ERR = mkPal([[0, [64, 28, 24]], [0.35, [160, 78, 64]], [0.7, [238, 132, 112]], [1, [255, 204, 190]]]); const CW = 8.4, CH = 10; -let SHARED = null; -function sharedRenderer(THREE) { - if (SHARED && !SHARED.getContext().isContextLost()) return SHARED; - const r = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true }); - r.setPixelRatio(1); r.setClearColor(0x000000, 0); r.setSize(2, 2, false); - r.domElement.addEventListener('webglcontextlost', (e) => e.preventDefault()); - SHARED = r; return r; -} +// Glyphs are 14px tall in 10px rows, so each one spills into the rows above and below: atlas tiles are three rows high. +const TILE_ROWS = 3; +// Atlas tiles also carry this many device pixels on each side for the anti-aliased glyph edges. +const PAD = 2; const rnd = (n, s) => { const v = Math.sin(n * 12.9898 + s * 78.233) * 43758.5453; return v - Math.floor(v); }; +const VERT = 'void main() { gl_Position = vec4(position.xy, 0.0, 1.0); }'; +const SRGB = 'vec3 toSrgb(vec3 c) { return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c)); }'; + +// Pass 1, one fragment per cell: average the scene under the cell, pick its glyph and colour. +// Output: rgb = glyph colour (sRGB), a = glyph id / 255. +const CELL_FRAG = ` +uniform sampler2D uScene; +uniform vec2 uGrid; +uniform int uRamp[32]; +uniform int uRampLen; +uniform int uGlitchGlyphs[32]; +uniform int uGlitchLen; +uniform int uDot; +uniform vec3 uPal[16]; +uniform bool uChroma; +uniform bool uErr; +uniform bool uGlitch; +uniform vec3 uBands[6]; +uniform int uBandCount; +uniform float uSeed; +${SRGB} +float hash(uvec3 v) { + v = v * 1664525u + 1013904223u; + v.x += v.y * v.z; v.y += v.z * v.x; v.z += v.x * v.y; + v ^= v >> 16u; + v.x += v.y * v.z; + return float(v.x) / 4294967295.0; +} +void main() { + ivec2 cell = ivec2(gl_FragCoord.xy); + int cols = int(uGrid.x), rows = int(uGrid.y); + int y = rows - 1 - cell.y; + int off = 0; + if (uGlitch) for (int i = 0; i < 6; i++) { + if (i >= uBandCount) break; + if (y >= int(uBands[i].x) && y < int(uBands[i].y)) { off = int(uBands[i].z); break; } + } + int sx = cell.x - off; + sx -= cols * int(floor(float(sx) / float(cols))); + vec2 base = vec2(float(sx), float(cell.y)) / uGrid; + vec4 sum = vec4(0.0); + for (int i = 0; i < 4; i++) for (int j = 0; j < 4; j++) sum += texture2D(uScene, base + (vec2(i, j) + 0.5) / (4.0 * uGrid)); + sum /= 16.0; + float a = sum.a * 255.0; + if (a < 0.5) { gl_FragColor = vec4(0.0); return; } + vec3 c = toSrgb(sum.rgb / sum.a); + float l = min(1.0, pow(dot(c, vec3(0.299, 0.587, 0.114)), 1.35) * 1.05); + int g; + if (l < 0.035) { + if (a <= 30.0) { gl_FragColor = vec4(0.0); return; } + g = uDot; l = 0.1; + } else g = uRamp[min(uRampLen - 1, 1 + int(floor(l * float(uRampLen - 2) + 0.5)))]; + vec3 col; + if (uChroma) { + float q = (90.0 + 165.0 * l) / 255.0 / max(max(c.r, c.g), max(c.b, 1.0 / 255.0)); + col = uErr ? vec3((110.0 + 145.0 * l) / 255.0, c.g * q * 0.45, c.b * q * 0.4) : min(c * q, 1.0); + } else col = uPal[min(15, int(l * 16.0))]; + uint s = uint(uSeed); + if (uGlitch && hash(uvec3(cell, s)) < 0.04) g = uGlitchGlyphs[min(uGlitchLen - 1, int(hash(uvec3(cell, s + 7919u)) * float(uGlitchLen)))]; + gl_FragColor = vec4(col, float(g) / 255.0); +}`; + +// Pass 2, per screen pixel: composite the glyphs of the own row and the rows above and below, top row first, in the +// order fillText drew them. Neighbour columns count within the atlas padding, so anti-aliased edges meet without seams. +const OUT_FRAG = ` +uniform sampler2D uCells; +uniform sampler2D uAtlas; +uniform vec2 uGrid; +uniform vec2 uOrigin; +uniform vec2 uCell; +uniform float uDpr; +uniform float uBufH; +uniform vec2 uTile; +uniform vec2 uAtlasSize; +void main() { + vec2 p = vec2(gl_FragCoord.x, uBufH - gl_FragCoord.y) / uDpr - uOrigin; + int col = int(floor(p.x / uCell.x)), rc = int(floor(p.y / uCell.y)), cols = int(uGrid.x), rows = int(uGrid.y); + vec4 o = vec4(0.0); + for (int k = -1; k <= 1; k++) { + int r = rc + k; + if (r < 0 || r >= rows) continue; + float ly = (p.y - (float(r) + 0.5) * uCell.y) * uDpr + uTile.y * 0.5; + for (int m = -1; m <= 1; m++) { + int c = col + m; + float lx = (p.x - float(c) * uCell.x) * uDpr + ${PAD}.0; + if (c < 0 || c >= cols || lx < 0.0 || lx >= uTile.x) continue; + vec4 cell = texelFetch(uCells, ivec2(c, rows - 1 - r), 0); + int g = int(cell.a * 255.0 + 0.5); + if (g == 0) continue; + float a = texture2D(uAtlas, vec2(float(g - 1) * uTile.x + lx, ly) / uAtlasSize).a; + o = vec4(cell.rgb * a, a) + o * (1.0 - a); + } + } + gl_FragColor = o; +}`; + +// color="mesh": the plain 3D render, no ASCII. +const MESH_FRAG = ` +uniform sampler2D uScene; +uniform vec2 uBuf; +${SRGB} +void main() { + vec4 s = texture2D(uScene, gl_FragCoord.xy / uBuf); + gl_FragColor = s.a > 0.0 ? vec4(toSrgb(s.rgb / s.a) * s.a, s.a) : vec4(0.0); +}`; + function buildEye(THREE) { const M = { bone: new THREE.MeshStandardMaterial({ name: 'bone', color: 0xdcd6c2, roughness: 0.72, metalness: 0 }), @@ -103,7 +211,7 @@ class EyeAscii extends HTMLElement { constructor() { super(); this._mode = 'idle'; this._yaw = 0; this._pitch = 0; this._lid = 0; this._t = 0; this._rotY = 0; - this._errT = -10; this._frame = this._frame.bind(this); + this._errT = -10; this._seed = 0; this._frame = this._frame.bind(this); } set mode(v) { this.setAttribute('mode', v); } get mode() { return this._mode; } set readout(v) { this.setAttribute('readout', v); } @@ -121,14 +229,6 @@ class EyeAscii extends HTMLElement { } connectedCallback() { Object.assign(this.style, { display: 'block', position: 'relative', width: this.style.width || '100%', height: this.style.height || '100%', cursor: 'grab', touchAction: 'none' }); - if (!this._out) { - this._out = document.createElement('canvas'); - Object.assign(this._out.style, { position: 'absolute', inset: '0', width: '100%', height: '100%', display: 'block' }); - this.appendChild(this._out); - this._ctx = this._out.getContext('2d'); - this._samp = document.createElement('canvas'); - this._sctx = this._samp.getContext('2d', { willReadFrequently: true }); - } this._onMove = (e) => { this._ptr = { x: e.clientX, y: e.clientY, time: performance.now() }; if (this._drag) { this._rotY = this._drag.r + (e.clientX - this._drag.x) * 0.01; } @@ -148,32 +248,101 @@ class EyeAscii extends HTMLElement { window.removeEventListener('pointerup', this._onUp); this.removeEventListener('pointerdown', this._onDown); this._ro && this._ro.disconnect(); this._io && this._io.disconnect(); + this._teardown(); + } + _fail() { + cancelAnimationFrame(this._raf); this._raf = 0; + window.dispatchEvent(new Event('visual:fail')); + } + _teardown() { + if (!this._r) return; + const R = this._r; this._r = null; + this._gpu.forEach((o) => o.dispose()); + R.domElement.removeEventListener('webglcontextlost', this._onLost); + R.dispose(); R.forceContextLoss(); R.domElement.remove(); } async _start() { if (!this._r) { const THREE = await loadThree(); if (!this.isConnected || this._r) return; this.THREE = THREE; - this._r = sharedRenderer(THREE); - this._mc = document.createElement('canvas'); - Object.assign(this._mc.style, { position: 'absolute', inset: '0', width: '100%', height: '100%', display: 'none' }); - this.appendChild(this._mc); - this._mctx = this._mc.getContext('2d'); - const scene = new THREE.Scene(); this._scene = scene; - scene.add(new THREE.HemisphereLight(0xffffff, 0x1a1a1a, 1.3)); - const key = new THREE.DirectionalLight(0xffffff, 2.6); key.position.set(2.5, 3, 4); scene.add(key); - const fill = new THREE.DirectionalLight(0xffffff, 0.7); fill.position.set(-3, 1, 2); scene.add(fill); - const rimA = new THREE.DirectionalLight(0xff2bd6, 1.6); rimA.position.set(-4, 2, -3); scene.add(rimA); - const rimB = new THREE.DirectionalLight(0x00e5ff, 1.1); rimB.position.set(5, 1, -2); scene.add(rimB); - this._cam = new THREE.PerspectiveCamera(28, 1.5, 0.01, 50); - this._E = buildEye(THREE); scene.add(this._E.root); - this._frameCam(); - document.fonts && document.fonts.load('14px "IBM Plex Mono"').then(() => this._resize()); + let R; + try { R = new THREE.WebGLRenderer({ alpha: true, powerPreference: 'low-power' }); } catch { this._fail(); return; } + this._r = R; + R.setClearColor(0x000000, 0); + R.autoClear = false; + Object.assign(R.domElement.style, { position: 'absolute', inset: '0', width: '100%', height: '100%', display: 'block' }); + this._onLost = (e) => { e.preventDefault(); this._fail(); }; + R.domElement.addEventListener('webglcontextlost', this._onLost); + this.appendChild(R.domElement); + if (!this._scene) { + const scene = new THREE.Scene(); this._scene = scene; + scene.add(new THREE.HemisphereLight(0xffffff, 0x1a1a1a, 1.3)); + const key = new THREE.DirectionalLight(0xffffff, 2.6); key.position.set(2.5, 3, 4); scene.add(key); + const fill = new THREE.DirectionalLight(0xffffff, 0.7); fill.position.set(-3, 1, 2); scene.add(fill); + const rimA = new THREE.DirectionalLight(0xff2bd6, 1.6); rimA.position.set(-4, 2, -3); scene.add(rimA); + const rimB = new THREE.DirectionalLight(0x00e5ff, 1.1); rimB.position.set(5, 1, -2); scene.add(rimB); + this._cam = new THREE.PerspectiveCamera(28, 1.5, 0.01, 50); + this._E = buildEye(THREE); scene.add(this._E.root); + this._frameCam(); + } + // Scene in sRGB storage: linear blending and MSAA, no banding in the darks the ramp cares about. + this._rt = new THREE.WebGLRenderTarget(2, 2, { samples: 4, colorSpace: THREE.SRGBColorSpace }); + this._cells = new THREE.WebGLRenderTarget(1, 1, { minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, generateMipmaps: false, depthBuffer: false }); + this._atlasCanvas = document.createElement('canvas'); + this._atlas = new THREE.CanvasTexture(this._atlasCanvas); + Object.assign(this._atlas, { flipY: false, generateMipmaps: false, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter }); + const ints = (a) => { const out = new Int32Array(32); out.set(a); return out; }; + this._cellMat = new THREE.ShaderMaterial({ + vertexShader: VERT, fragmentShader: CELL_FRAG, blending: THREE.NoBlending, depthTest: false, depthWrite: false, + uniforms: { + uScene: { value: this._rt.texture }, uGrid: { value: new THREE.Vector2(1, 1) }, + uRamp: { value: new Int32Array(32) }, uRampLen: { value: 2 }, + uGlitchGlyphs: { value: ints([...GLITCH].map(gid)) }, uGlitchLen: { value: GLITCH.length }, uDot: { value: gid('.') }, + uPal: { value: PAL }, uChroma: { value: false }, uErr: { value: false }, + uGlitch: { value: false }, uBands: { value: new Float32Array(18) }, uBandCount: { value: 0 }, uSeed: { value: 0 }, + }, + }); + this._outMat = new THREE.ShaderMaterial({ + vertexShader: VERT, fragmentShader: OUT_FRAG, blending: THREE.NoBlending, depthTest: false, depthWrite: false, + uniforms: { + uCells: { value: this._cells.texture }, uAtlas: { value: this._atlas }, uGrid: { value: new THREE.Vector2(1, 1) }, + uOrigin: { value: new THREE.Vector2() }, uCell: { value: new THREE.Vector2(CW, CH) }, uDpr: { value: 1 }, uBufH: { value: 1 }, + uTile: { value: new THREE.Vector2(1, 1) }, uAtlasSize: { value: new THREE.Vector2(1, 1) }, + }, + }); + this._meshMat = new THREE.ShaderMaterial({ + vertexShader: VERT, fragmentShader: MESH_FRAG, blending: THREE.NoBlending, depthTest: false, depthWrite: false, + uniforms: { uScene: { value: this._rt.texture }, uBuf: { value: new THREE.Vector2(1, 1) } }, + }); + const geo = new THREE.PlaneGeometry(2, 2); + this._quad = new THREE.Mesh(geo, this._cellMat); this._quad.frustumCulled = false; + this._quadScene = new THREE.Scene(); this._quadScene.add(this._quad); + this._quadCam = new THREE.Camera(); + this._gpu = [this._rt, this._cells, this._atlas, this._cellMat, this._outMat, this._meshMat, geo]; + this._atlasDpr = 0; + document.fonts && document.fonts.load('14px "IBM Plex Mono"').then(() => { this._atlasDpr = 0; this._resize(); }); } this._resize(); this._last = performance.now(); if (!this._raf) this._raf = requestAnimationFrame(this._frame); } + _buildAtlas(dpr) { + const tw = Math.ceil(CW * dpr) + 2 * PAD, th = Math.ceil(TILE_ROWS * CH * dpr); + const c = this._atlasCanvas; + c.width = GLYPHS.length * tw; c.height = th; + const ctx = c.getContext('2d'); + ctx.clearRect(0, 0, c.width, c.height); + ctx.font = `${14 * dpr}px "IBM Plex Mono", ui-monospace, monospace`; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#fff'; + GLYPHS.forEach((ch, i) => ctx.fillText(ch, i * tw + PAD, th / 2)); + // A resized canvas needs a fresh GPU texture. + this._atlas.dispose(); this._atlas.needsUpdate = true; + const u = this._outMat.uniforms; + u.uTile.value.set(tw, th); u.uAtlasSize.value.set(c.width, c.height); + this._atlasDpr = dpr; + } _frameCam() { if (!this._cam || !this._E) return; const THREE = this.THREE, cam = this._cam, asp = cam.aspect, tan = Math.tan((cam.fov * Math.PI) / 360); @@ -188,16 +357,17 @@ class EyeAscii extends HTMLElement { const sy = this._shiftY || 0; cam.position.set(c.x, c.y + sy, c.z + d); cam.lookAt(c.x, c.y + sy, c.z); cam.updateProjectionMatrix(); } - _blit(ctx, w, h) { const [sx, sy, sw, sh] = this._src; ctx.drawImage(this._r.domElement, sx, sy, sw, sh, 0, 0, w, h); } + // Re-centres the drone vertically from the rows that hold glyphs (one small readback, not per frame). _autoCenter() { this._centerAt = -1; if (this.getAttribute('framing') === 'eye') return; - const cols = this._cols, rows = this._rows; - this._sctx.clearRect(0, 0, cols, rows); - this._blit(this._sctx, cols, rows); - const d = this._sctx.getImageData(0, 0, cols, rows).data; + const cols = this._cols, rows = this._rows, d = new Uint8Array(cols * rows * 4); + this._r.readRenderTargetPixels(this._cells, 0, 0, cols, rows, d); let y0 = -1, y1 = -1; - for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++) if (d[(y * cols + x) * 4 + 3] > 20) { if (y0 < 0) y0 = y; y1 = y; break; } + for (let y = 0; y < rows; y++) { + const j = (rows - 1 - y) * cols * 4; + for (let x = 0; x < cols; x++) if (d[j + x * 4 + 3]) { if (y0 < 0) y0 = y; y1 = y; break; } + } if (y0 < 0) return; const off = (y0 + y1) / 2 - (rows - 1) / 2; const perRow = (2 * this._camD * Math.tan((this._cam.fov * Math.PI) / 360)) / rows; @@ -205,25 +375,32 @@ class EyeAscii extends HTMLElement { this._frameCam(); } _resize() { + if (!this._r) return; const W = this.clientWidth, H = this.clientHeight; if (!W || !H) return; - const dpr = Math.min(window.devicePixelRatio || 1, 2); + const dpr = Math.min(window.devicePixelRatio || 1, 2), R = this._r; this._W = W; this._H = H; - this._out.width = Math.round(W * dpr); this._out.height = Math.round(H * dpr); - this._ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this._cols = Math.max(1, Math.floor(W / CW)); this._rows = Math.max(1, Math.floor(H / CH)); - this._x0 = (W - this._cols * CW) / 2; this._y0 = (H - this._rows * CH) / 2; - this._samp.width = this._cols; this._samp.height = this._rows; - if (this._r) { - const mesh = this.getAttribute('color') === 'mesh'; - this._mc.style.display = mesh ? 'block' : 'none'; - this._out.style.display = mesh ? 'none' : 'block'; - const pr = Math.min(dpr, 1.5); - this._rw = mesh ? Math.round(W * pr) : Math.max(2, Math.round(W / 2.5)); - this._rh = mesh ? Math.round(H * pr) : Math.max(2, Math.round(H / 2.5)); - if (mesh) { this._mc.width = this._rw; this._mc.height = this._rh; } - this._cam.aspect = W / H; this._frameCam(); - } + const mesh = this.getAttribute('color') === 'mesh', pr = Math.min(dpr, 1.5); + this._rw = mesh ? Math.round(W * pr) : Math.max(2, Math.round(W / 2.5)); + this._rh = mesh ? Math.round(H * pr) : Math.max(2, Math.round(H / 2.5)); + R.setPixelRatio(dpr); R.setSize(W, H, false); + this._rt.setSize(this._rw, this._rh); + this._cells.setSize(this._cols, this._rows); + if (dpr !== this._atlasDpr) this._buildAtlas(dpr); + const buf = R.getDrawingBufferSize(new this.THREE.Vector2()); + this._cellMat.uniforms.uGrid.value.set(this._cols, this._rows); + const u = this._outMat.uniforms; + u.uGrid.value.set(this._cols, this._rows); + u.uOrigin.value.set((W - this._cols * CW) / 2, (H - this._rows * CH) / 2); + u.uDpr.value = buf.y / H; u.uBufH.value = buf.y; + this._meshMat.uniforms.uBuf.value.copy(buf); + this._cam.aspect = W / H; this._frameCam(); + } + _pass(mat, target) { + this._quad.material = mat; + this._r.setRenderTarget(target); + this._r.render(this._quadScene, this._quadCam); } _frame(now) { this._raf = requestAnimationFrame(this._frame); @@ -268,16 +445,34 @@ class EyeAscii extends HTMLElement { E.root.rotation.y = this._rotY; if (this._glitchOn && t > (this._gUntil || 0) && Math.random() < dt / 2.8) this._burst(0.08 + Math.random() * 0.18); this._glitching = this._glitchOn && t < (this._gUntil || 0); - const R = this._r, gl = R.domElement; - if (gl.width < this._rw || gl.height < this._rh) R.setSize(Math.max(gl.width, this._rw), Math.max(gl.height, this._rh), false); - R.setViewport(0, 0, this._rw, this._rh); R.setScissor(0, 0, this._rw, this._rh); R.setScissorTest(true); - R.clear(); R.render(this._scene, this._cam); - this._src = [0, gl.height - this._rh, this._rw, this._rh]; - if (this.getAttribute('color') === 'mesh') { this._mctx.clearRect(0, 0, this._rw, this._rh); this._blit(this._mctx, this._rw, this._rh); } + const R = this._r, mesh = this.getAttribute('color') === 'mesh'; + R.setRenderTarget(this._rt); R.clear(); R.render(this._scene, this._cam); if (this._centerAt == null) this._centerAt = t + 0.6; - if (t > this._centerAt && this._centerAt > 0) this._autoCenter(); - if (this.getAttribute('color') !== 'mesh') this._ascii(); - else this._mc.style.transform = this._glitching ? `translate(${((Math.random() - 0.5) * 24) | 0}px, 0) skewX(${((Math.random() - 0.5) * 6).toFixed(1)}deg)` : ''; + const center = t > this._centerAt && this._centerAt > 0; + if (!mesh || center) { + const u = this._cellMat.uniforms, color = this.getAttribute('color'); + const ramp = RAMPS[this.getAttribute('ramp')] || RAMPS.classic; + u.uRamp.value.fill(0); u.uRamp.value.set([...ramp].map((ch) => (ch === ' ' ? 0 : gid(ch)))); + u.uRampLen.value = ramp.length; + u.uErr.value = mode === 'error'; + u.uPal.value = mode === 'error' ? PAL_ERR : PAL; + u.uChroma.value = color === 'color'; + u.uGlitch.value = !!this._glitching; + const bands = this._glitching ? this._bands.slice(0, 6) : []; + bands.forEach((b, i) => u.uBands.value.set([b.y0, b.y1, b.off], i * 3)); + u.uBandCount.value = bands.length; + u.uSeed.value = this._seed = (this._seed + 1) % 16777216; + this._pass(this._cellMat, this._cells); + } + R.setRenderTarget(null); R.clear(); + if (mesh) { + this._pass(this._meshMat, null); + R.domElement.style.transform = this._glitching ? `translate(${((Math.random() - 0.5) * 24) | 0}px, 0) skewX(${((Math.random() - 0.5) * 6).toFixed(1)}deg)` : ''; + } else { + R.domElement.style.transform = ''; + this._pass(this._outMat, null); + } + if (center) this._autoCenter(); if (now - (this._ro_t || 0) > 100) { this._ro_t = now; const id = this.getAttribute('readout'), el = id && document.getElementById(id); @@ -288,42 +483,6 @@ class EyeAscii extends HTMLElement { } } } - _ascii() { - const cols = this._cols, rows = this._rows, sctx = this._sctx, ctx = this._ctx; - sctx.imageSmoothingEnabled = true; sctx.imageSmoothingQuality = 'high'; - sctx.clearRect(0, 0, cols, rows); - this._blit(sctx, cols, rows); - const d = sctx.getImageData(0, 0, cols, rows).data; - ctx.clearRect(0, 0, this._W, this._H); - ctx.font = '14px "IBM Plex Mono", ui-monospace, monospace'; - ctx.textBaseline = 'middle'; - const ramp = RAMPS[this.getAttribute('ramp')] || RAMPS.classic, rl = ramp.length - 1; - const pal = this._mode === 'error' ? PAL_ERR : PAL; - const chroma = this.getAttribute('color') === 'color'; - let last = ''; - const G = this._glitching, GL = '01<>/\\|=+*:;$#ABCDEF'; - for (let y = 0; y < rows; y++) { - const py = this._y0 + y * CH + CH / 2; - let off = 0; - if (G) for (const b of this._bands) if (y >= b.y0 && y < b.y1) { off = b.off; break; } - for (let x = 0; x < cols; x++) { - const sx = ((x - off) % cols + cols) % cols; - const j = (y * cols + sx) * 4, a = d[j + 3]; - if (!a) continue; - let l = Math.min(1, Math.pow((0.299 * d[j] + 0.587 * d[j + 1] + 0.114 * d[j + 2]) / 255, 1.35) * 1.05), ch; - if (l < 0.035) { if (a > 30) { ch = '.'; l = 0.1; } else continue; } - else ch = ramp[Math.min(rl, 1 + Math.round(l * (rl - 1)))]; - let st; - if (chroma) { - const m = Math.max(d[j], d[j + 1], d[j + 2], 1), q = (90 + 165 * l) / m; - st = this._mode === 'error' ? `rgb(${(110 + 145 * l) | 0},${(d[j + 1] * q * 0.45) | 0},${(d[j + 2] * q * 0.4) | 0})` : `rgb(${(d[j] * q) | 0},${(d[j + 1] * q) | 0},${(d[j + 2] * q) | 0})`; - } else st = pal[Math.min(15, (l * 16) | 0)]; - if (st !== last) { ctx.fillStyle = st; last = st; } - if (G && Math.random() < 0.04) ch = GL[(Math.random() * GL.length) | 0]; - ctx.fillText(ch, this._x0 + x * CW, py); - } - } - } } customElements.define('eye-ascii', EyeAscii); })(); From 4299e31ddd5581c312effbe682fd9c089822f31f Mon Sep 17 00:00:00 2001 From: bulbashenko Date: Sun, 11 Oct 2026 04:43:05 +0200 Subject: [PATCH 3/3] Drop the decorative status labels and add the favicon SYSTEM ONLINE, / ENCRYPTED CONNECTION, [ SECURE BY DESIGN ] and ACCESS PORTAL / 01 said nothing. The favicon keeps the 16, 32 and 48 px frames of the original (9 KB instead of 432 KB). Co-Authored-By: Claude Opus 5.5 --- ui/src/app/favicon.ico | Bin 0 -> 8730 bytes ui/src/app/globals.css | 5 +---- ui/src/app/login/page.tsx | 2 +- ui/src/components/Shell.tsx | 18 +++++++----------- ui/src/lib/i18n.ts | 8 -------- 5 files changed, 9 insertions(+), 24 deletions(-) create mode 100644 ui/src/app/favicon.ico diff --git a/ui/src/app/favicon.ico b/ui/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c057c20af9e904427821541faa1d61dde05242c8 GIT binary patch literal 8730 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 literal 0 HcmV?d00001 diff --git a/ui/src/app/globals.css b/ui/src/app/globals.css index bcaae7b..2b7db7a 100644 --- a/ui/src/app/globals.css +++ b/ui/src/app/globals.css @@ -88,9 +88,6 @@ a:hover { color: var(--accent-hi); } .langs a { color: var(--g3); } .langs a[aria-current="true"] { color: var(--g1); } -.status { display: flex; gap: 8px; align-items: center; } -.status .dot { width: 5px; height: 5px; background: var(--accent); } -.status .on { color: var(--g2); } .main { position: relative; z-index: 1; flex: 1; display: flex; min-height: 0; } @@ -145,7 +142,7 @@ a:hover { color: var(--accent-hi); } .bar { padding: 0 16px; } .bar.top { height: 56px; } .brand .name { font-size: 20px; } - .brand .sep, .brand .section, .status .off { display: none; } + .brand .sep, .brand .section { display: none; } .left { padding: 24px 16px; } .title { font-size: 44px; } } diff --git a/ui/src/app/login/page.tsx b/ui/src/app/login/page.tsx index 158b0ea..aea73be 100644 --- a/ui/src/app/login/page.tsx +++ b/ui/src/app/login/page.tsx @@ -46,7 +46,7 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis diff --git a/ui/src/components/Shell.tsx b/ui/src/components/Shell.tsx index 4df9bae..c8e09e3 100644 --- a/ui/src/components/Shell.tsx +++ b/ui/src/components/Shell.tsx @@ -46,11 +46,6 @@ export async function Shell({ )}
-
-
© {new Date().getFullYear()} BULBASHENKO.COM
-
-
+ {step && ( +
+
+ )}

{title}

{desc &&

{desc}

}
{children}
- {t("footer.secure")}
diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 6a9ce29..19e4c7c 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -9,12 +9,8 @@ export const isLocale = (value: unknown): value is Locale => LOCALES.includes(va const ru = { "brand.section": "IDENTITY / SSO", - "footer.online": "SYSTEM ONLINE", - "footer.encrypted": "/ ENCRYPTED CONNECTION", - "footer.secure": "[ SECURE BY DESIGN ]", "footer.stack": "ORY / OIDC", - "login.label": "ACCESS PORTAL / 01", "login.title": "Вход", "login.desc": "Один аккаунт для всех сервисов bulbashenko.com.\nРегистрации нет — аккаунт выдаёт администратор.", "login.passkey": "[ ВОЙТИ С ПАСКЛЮЧОМ ]", @@ -123,12 +119,8 @@ export type Key = keyof typeof ru const en: Record = { "brand.section": "IDENTITY / SSO", - "footer.online": "SYSTEM ONLINE", - "footer.encrypted": "/ ENCRYPTED CONNECTION", - "footer.secure": "[ SECURE BY DESIGN ]", "footer.stack": "ORY / OIDC", - "login.label": "ACCESS PORTAL / 01", "login.title": "Sign in", "login.desc": "One account for every bulbashenko.com service.\nNo sign-up: accounts are issued by the admin.", "login.passkey": "[ SIGN IN WITH PASSKEY ]",