住人のいる音量
@Lattice_Node
Web · ログイン画面
見られた数 767コピーされた数 13
<!-- UI by @Lattice_Node — ludion.ai/p/login-lumen (MIT) -->
<div data-ld-part="login-lumen" lang="ja">
<canvas class="scene" aria-hidden="true"></canvas>
<main class="stage">
<section class="card" aria-labelledby="ll-title" data-card>
<header class="head rise">
<h1 class="title" id="ll-title" tabindex="-1">ログイン</h1>
<p class="lead">ログインして続けてください</p>
</header>
<form class="form" data-form novalidate>
<div class="field rise">
<label class="label" for="ll-email">メールアドレス</label>
<input class="input" id="ll-email" name="email" type="email" autocomplete="email" inputmode="email" spellcheck="false" required data-validate="email" aria-describedby="ll-email-error">
<p class="error" id="ll-email-error" hidden></p>
</div>
<div class="field rise">
<label class="label" for="ll-password">パスワード</label>
<input class="input" id="ll-password" name="password" type="password" autocomplete="current-password" required data-validate="password" aria-describedby="ll-password-error">
<p class="error" id="ll-password-error" hidden></p>
</div>
<button class="submit rise" type="submit" data-busy="ログイン中…"><span class="submit-text">ログイン</span></button>
</form>
</section>
<section class="welcome" data-welcome tabindex="-1" hidden>
<h2 class="welcome-title">ようこそ</h2>
<p class="welcome-text">Ludion</p>
<button class="link" type="button" data-return>ログイン画面に戻る</button>
</section>
</main>
<span class="probe" aria-hidden="true"></span>
</div>
<style>
/* UI by @Lattice_Node — ludion.ai/p/login-lumen (MIT) */
[data-ld-part="login-lumen"] {
/* 固定しないトークン:使い手の値を受け、なければ作者の値(白を基調に、光は青) */
--_bg: var(--ld-bg, #e9eef6);
--_surface: var(--ld-surface, #fbfcfe);
--_fg: var(--ld-fg, #10182b);
--_muted: var(--ld-muted, #4a5a70);
--_accent: var(--ld-accent, #2563eb);
--_radius: var(--ld-radius, 28px);
--_font: var(--ld-font, "Hiragino Sans", "Yu Gothic UI", "Noto Sans JP", system-ui, sans-serif);
--_space: var(--ld-space, 8px);
--_shadow: var(--ld-shadow, 0 24px 60px rgb(16 24 43 / .18));
--_motion: var(--ld-motion, 240ms);
position: relative;
display: grid;
box-sizing: border-box;
min-height: 100vh;
overflow: hidden;
isolation: isolate;
/* WebGL が使えないときの、静かな白い絵 */
background:
radial-gradient(120% 70% at 50% -10%, var(--_surface), transparent 70%),
radial-gradient(70% 55% at 50% 58%, color-mix(in oklab, var(--_accent) 7%, var(--_surface)), transparent 75%),
var(--_bg);
font-family: var(--_font);
color: var(--_fg);
-webkit-font-smoothing: antialiased;
}
[data-ld-part="login-lumen"] *,
[data-ld-part="login-lumen"] *::before,
[data-ld-part="login-lumen"] *::after {
box-sizing: border-box;
}
[data-ld-part="login-lumen"] [hidden] {
display: none !important;
}
[data-ld-part="login-lumen"] p {
margin: 0;
}
/* 色を読むための見えない目印 */
[data-ld-part="login-lumen"] .probe {
display: none;
}
/* WebGL の canvas。WebGL が使えないときは出さない */
[data-ld-part="login-lumen"] .scene {
position: absolute;
inset: 0;
z-index: 0;
display: none;
width: 100%;
height: 100%;
}
[data-ld-part="login-lumen"].has-gl .scene {
display: block;
}
[data-ld-part="login-lumen"] .stage {
position: relative;
z-index: 1;
display: grid;
place-items: center;
padding: calc(var(--_space) * 3);
pointer-events: none;
}
/* フォームの板。WebGL があるときは、板そのものはシェーダーが描き、ここは同じ場所に重なるだけ */
[data-ld-part="login-lumen"] .card {
grid-area: 1 / 1;
width: min(100%, 400px);
padding: calc(var(--_space) * 5) calc(var(--_space) * 4.5);
border: 1px solid color-mix(in oklab, var(--_muted) 28%, transparent);
border-radius: var(--_radius);
background: var(--_surface);
box-shadow: var(--_shadow);
pointer-events: auto;
}
[data-ld-part="login-lumen"].has-gl .card {
border-color: transparent;
background: transparent;
box-shadow: none;
}
/* フォームの現れ方:板ができてから、順に浮かぶ */
[data-ld-part="login-lumen"] .rise {
transition:
opacity calc(var(--_motion) * 2.2) cubic-bezier(.16, 1, .3, 1),
transform calc(var(--_motion) * 2.2) cubic-bezier(.16, 1, .3, 1);
}
[data-ld-part="login-lumen"] .field:nth-child(1) { transition-delay: calc(var(--_motion) * .2); }
[data-ld-part="login-lumen"] .field:nth-child(2) { transition-delay: calc(var(--_motion) * .35); }
[data-ld-part="login-lumen"] .submit { transition-delay: calc(var(--_motion) * .5); }
[data-ld-part="login-lumen"].is-veiled .rise {
opacity: 0;
transform: translateY(calc(var(--_space) * 1.25));
transition-delay: 0ms;
}
/* 入ったあとは、フォームを先に消す */
[data-ld-part="login-lumen"].is-leaving .rise {
opacity: 0;
transform: translateY(calc(var(--_space) * -1));
transition-duration: var(--_motion);
transition-delay: 0ms;
}
[data-ld-part="login-lumen"] .head {
display: grid;
gap: calc(var(--_space) * .75);
}
[data-ld-part="login-lumen"] .title {
margin: 0;
font-size: 34px;
line-height: 1.3;
font-weight: 600;
font-feature-settings: "palt";
}
[data-ld-part="login-lumen"] .title:focus {
outline: none;
}
[data-ld-part="login-lumen"] .lead {
font-size: 15px;
line-height: 1.7;
color: var(--_muted);
}
[data-ld-part="login-lumen"] .form {
display: grid;
gap: calc(var(--_space) * 2.25);
margin: calc(var(--_space) * 3.5) 0 0;
}
[data-ld-part="login-lumen"] .field {
display: grid;
gap: calc(var(--_space) * .75);
}
[data-ld-part="login-lumen"] .label {
font-size: 13px;
line-height: 1.5;
font-weight: 600;
letter-spacing: .04em;
}
/* 入力欄:ガラスの中の、少し濃い液だまり */
[data-ld-part="login-lumen"] .input {
display: block;
width: 100%;
height: 48px;
margin: 0;
padding: 0 calc(var(--_space) * 2);
border: 1px solid color-mix(in oklab, var(--_fg) 26%, transparent);
border-radius: calc(var(--_radius) * .5);
background: color-mix(in oklab, var(--_surface) 70%, transparent);
box-shadow: inset 0 1px 2px color-mix(in oklab, var(--_fg) 10%, transparent);
color: var(--_fg);
font: 400 16px/22px var(--_font);
caret-color: var(--_accent);
transition: border-color var(--_motion) ease, box-shadow var(--_motion) ease, background var(--_motion) ease;
}
[data-ld-part="login-lumen"] .input:focus {
outline: none;
border-color: var(--_accent);
background: var(--_surface);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--_accent) 26%, transparent);
}
[data-ld-part="login-lumen"] .input[aria-invalid="true"] {
border-color: var(--_fg);
}
[data-ld-part="login-lumen"] .error {
display: flex;
align-items: flex-start;
gap: calc(var(--_space) * .75);
font-size: 13px;
line-height: 1.7;
font-weight: 500;
color: var(--_fg);
}
[data-ld-part="login-lumen"] .error::before {
content: "";
flex: none;
width: 8px;
height: 8px;
margin-top: calc(var(--_space) * .875);
border-radius: 50%;
background: var(--_accent);
}
[data-ld-part="login-lumen"] .submit {
display: grid;
place-items: center;
width: 100%;
min-height: 52px;
margin: calc(var(--_space) * 1) 0 0;
padding: calc(var(--_space) * 1) calc(var(--_space) * 2);
border: 1px solid var(--_accent);
border-radius: calc(var(--_radius) * .5);
background: color-mix(in oklab, var(--_accent) 14%, var(--_surface));
color: var(--_fg);
font: 600 16px/22px var(--_font);
letter-spacing: .04em;
cursor: pointer;
}
[data-ld-part="login-lumen"] .submit:hover {
background: color-mix(in oklab, var(--_accent) 22%, var(--_surface));
}
/* WebGL があるときは、ボタンの面はシェーダーがガラスとして描く。ここは文字と当たり判定だけ */
[data-ld-part="login-lumen"].has-gl .submit,
[data-ld-part="login-lumen"].has-gl .submit:hover {
border-color: transparent;
background: transparent;
}
[data-ld-part="login-lumen"] .submit:disabled {
cursor: progress;
}
[data-ld-part="login-lumen"] .submit:focus-visible,
[data-ld-part="login-lumen"] .link:focus-visible {
outline: 2px solid var(--_accent);
outline-offset: 3px;
}
/* Welcome:光の中に浮かぶ */
[data-ld-part="login-lumen"] .welcome {
grid-area: 1 / 1;
display: grid;
justify-items: center;
gap: calc(var(--_space) * 1.5);
text-align: center;
pointer-events: auto;
animation: login-lumen-float calc(var(--_motion) * 6) cubic-bezier(.16, 1, .3, 1) both;
}
[data-ld-part="login-lumen"] .welcome:focus {
outline: none;
}
[data-ld-part="login-lumen"] .welcome-title {
margin: 0;
font-size: clamp(56px, 11vw, 112px);
line-height: 1.2;
font-weight: 300;
font-feature-settings: "palt";
}
[data-ld-part="login-lumen"] .welcome-text {
font-size: 17px;
line-height: 1.7;
}
[data-ld-part="login-lumen"] .link {
margin: calc(var(--_space) * 1.5) 0 0;
padding: 0;
border: 0;
background: none;
color: var(--_fg);
font: 500 14px/1.7 var(--_font);
text-decoration: underline;
text-decoration-color: var(--_accent);
text-underline-offset: 4px;
cursor: pointer;
}
@keyframes login-lumen-float {
from { opacity: 0; transform: translateY(calc(var(--_space) * 3)); }
to { opacity: 1; transform: none; }
}
@media (max-width: 480px) {
[data-ld-part="login-lumen"] .stage {
padding: calc(var(--_space) * 2);
}
[data-ld-part="login-lumen"] .card {
padding: calc(var(--_space) * 4) calc(var(--_space) * 3);
}
}
@media (prefers-reduced-motion: reduce) {
[data-ld-part="login-lumen"] .rise,
[data-ld-part="login-lumen"] .input {
transition: none;
}
[data-ld-part="login-lumen"] .welcome {
animation: none;
}
}
</style>
<script type="module">
/* UI by @Lattice_Node — ludion.ai/p/login-lumen (MIT) */
// part.js — ESモジュール。rootの中だけを触る
// 1枚の canvas の WebGL で、海中の白い都市と液体のガラスを描く。
// 1. 都市・殻・光を、縮小したテクスチャに描く(重い部分。遅い機械では自動で解像度を下げる)
// 2. それを画面に写しながら、ガラスの板の形(SDF)で屈折させる
// 色は JS に書かない。--_bg、--_surface、--_fg、--_muted、--_accent を getComputedStyle で読む。
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const PULSES = 8;
const DROPS = 14;
const BLADES = 11;
const VERT = `#version 300 es
in vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }`;
const SCENE = `#version 300 es
precision highp float;
uniform vec2 uRes;
uniform float uT, uIris, uRot, uRay, uFlood, uHorizon;
uniform vec2 uPar;
uniform vec3 uPulse[${PULSES}];
uniform vec3 uBg, uSurface, uFg, uMuted, uAccent, uLight, uInk, uGlow;
out vec4 outColor;
const float TAU = 6.2831853;
const float N = ${BLADES}.0;
float h1(float x) { return fract(sin(x * 127.1) * 43758.5453); }
vec2 h2(vec2 p) {
p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
return fract(sin(p) * 43758.5453);
}
float n1(float x) {
float i = floor(x), f = fract(x);
f = f * f * (3.0 - 2.0 * f);
return mix(h1(i), h1(i + 1.0), f);
}
// 水底の光の揺らぎ:格子を強くゆがめて、曲がった網にする。線に近いところだけが光る
float caustic(vec2 p, float t) {
vec2 q = p;
float v = 0.0, a = 1.0;
for (int i = 0; i < 2; i++) {
float fi = float(i);
q += 0.95 * vec2(sin(q.y * 1.1 + t * 0.6 + fi), cos(q.x * 1.05 - t * 0.5));
q += 0.45 * vec2(sin(q.y * 2.1 - t * 0.4), cos(q.x * 2.3 + t * 0.35 + fi));
float f = abs(sin(q.x) * cos(q.y));
v += a * 0.016 / (0.03 + f);
a *= 0.6;
q = q * 1.6 + 3.1;
}
return v;
}
// 上から差す光の筋
float rays(vec2 p, float t) {
vec2 d = p - vec2(0.42, 2.8);
float a = atan(d.x, -d.y);
float v = n1(a * 15.0 + t * 0.07) * 0.62 + n1(a * 39.0 - t * 0.11 + 3.0) * 0.38;
v = pow(v, 2.4) * 2.1;
return v * smoothstep(-1.5, 0.8, p.y);
}
// 値は [F1, F2 - F1]
vec2 voronoi(vec2 p) {
vec2 c = floor(p), f = p - c;
float a = 9.0, b = 9.0;
for (int y = -1; y <= 1; y++) for (int x = -1; x <= 1; x++) {
vec2 g = vec2(float(x), float(y));
vec2 r = g + h2(c + g) * 0.86 + 0.07 - f;
float d = dot(r, r);
if (d < a) { b = a; a = d; } else if (d < b) { b = d; }
}
a = sqrt(a); b = sqrt(b);
return vec2(a, b - a);
}
float pulseSum(float r, float b) {
float s = 0.0;
float lr = log(max(r, 0.01));
for (int i = 0; i < ${PULSES}; i++) {
vec3 u = uPulse[i];
if (u.z <= 0.0 || abs(b - u.x) > 0.5) continue;
float head = -0.95 + u.y * 1.7;
float d = lr - head;
float tail = d < 0.0 ? 0.11 : 0.035;
s += exp(-d * d / tail) * u.z;
}
return s;
}
// 遠い都市:奥から3層。白い有機的な塔が、水の霞の中に立つ
vec3 city(vec2 p, float caust) {
float horizon = uHorizon;
float up = smoothstep(horizon - 0.1, 1.15, p.y);
vec3 deep = mix(uBg, uAccent, 0.16);
vec3 col = mix(deep, uSurface, pow(up, 0.8));
// 水底:光の網が揺れる
if (p.y < horizon + 0.12) {
float z = 1.0 / (horizon + 0.16 - p.y);
float c = caustic(vec2(p.x * z * 1.1, z * 1.6), uT * 0.8);
float fade = smoothstep(0.0, 0.28, horizon + 0.1 - p.y);
vec3 floorCol = mix(uBg, uAccent, 0.2);
floorCol = mix(floorCol, uLight, clamp(c * 1.5, 0.0, 0.92));
col = mix(col, floorCol, fade);
}
for (int j = 0; j < 3; j++) {
float fj = float(j);
float s = 9.5 - fj * 2.8;
float par = 0.01 + fj * 0.024;
vec2 q = (p + uPar * par) * s;
q.x += fj * 7.3;
float baseY = (horizon + 0.05 - 0.05 * fj) * s;
float id = floor(q.x);
for (int c = -1; c <= 1; c++) {
float cell = id + float(c);
float seed = cell + fj * 17.0;
float a = h1(seed), b = h1(seed + 3.1), e = h1(seed + 7.7);
float tall = mix(0.7, 4.6, pow(a, 2.2)) * (1.0 - fj * 0.14);
float w = mix(0.2, 0.44, b) * mix(1.0, 0.72, smoothstep(1.5, 4.0, tall));
float cx = cell + 0.5 + (e - 0.5) * 0.46;
float u = q.x - cx;
float y = q.y - baseY;
if (y < -0.5 || y > tall) continue;
// 卵のように丸い頭と、ゆるいくびれ
float k = clamp(y / tall, 0.0, 1.0);
float hw = w * sqrt(max(1.0 - pow(k, 2.6), 0.0)) * (1.0 + 0.16 * sin(y * 2.2 + a * 6.0));
if (hw < 0.001) continue;
float nx = u / hw;
float aa = fwidth(q.x) * 1.4 / hw;
float mask = 1.0 - smoothstep(1.0 - aa, 1.0 + aa, abs(nx));
if (mask <= 0.0) continue;
mask *= smoothstep(-0.5, 0.35, y);
// 丸い胴の陰影。光は上から
float nz = sqrt(max(0.0, 1.0 - nx * nx));
float lit = 0.52 + 0.42 * nz - 0.1 * nx + 0.1 * k;
vec3 tower = mix(mix(uMuted, uBg, 0.66), uSurface, clamp(lit, 0.0, 1.0));
// 肋骨のような細い輪
float ring = abs(fract(y * 2.2 + nx * nx * 0.5 + a) - 0.5);
tower = mix(tower, uSurface, (1.0 - smoothstep(0.01, 0.04, ring)) * 0.6 * nz);
// 穴あき模様。データの光がここを通る
if (j > 0) {
vec2 v = voronoi(vec2(nx * 2.6 + a * 9.0, y * 5.2));
float hole = smoothstep(0.08, 0.13, v.y) * smoothstep(1.0, 0.7, abs(nx));
hole *= smoothstep(0.02, 0.2, y / tall) * smoothstep(0.98, 0.72, k);
float wave = 0.0;
for (int i = 0; i < ${PULSES}; i++) {
vec3 w2 = uPulse[i];
if (w2.z <= 0.0) continue;
float at = w2.y - 0.45;
if (at < 0.0) continue;
float dist = length(p) - at * 1.2;
wave += exp(-dist * dist * 22.0) * exp(-at * 1.15) * w2.z;
}
vec3 inner = mix(mix(uMuted, uSurface, 0.62), uGlow, clamp(0.12 + wave * 1.3, 0.0, 1.0));
tower = mix(tower, inner, hole * 0.72);
}
tower = mix(tower, uLight, clamp(caust * 1.1, 0.0, 0.6));
float haze = (j == 0 ? 0.62 : j == 1 ? 0.34 : 0.12) + 0.3 * (1.0 - smoothstep(0.0, 0.9, y));
tower = mix(tower, mix(uBg, deep, 0.5), clamp(haze, 0.0, 0.9));
col = mix(col, tower, mask);
}
}
return col;
}
void main() {
vec2 frag = gl_FragCoord.xy;
float S = 0.5 * min(uRes.y, uRes.x * 1.6);
vec2 p = (frag - 0.5 * uRes) / S;
float caust = caustic(p * 2.3 + vec2(0.0, uT * 0.05), uT * 0.7);
vec3 col = city(p, caust);
// ---- 殻:絞りの羽根が渦を巻いて重なる
vec2 rel = p - uPar * 0.012;
float r = length(rel);
float th = atan(rel.y, rel.x);
float s = th / TAU * N + 1.3 * log(r + 0.35) + uRot * N / TAU;
float b = floor(s);
float f = s - b;
float blade = mod(b, N);
float aa = max(1.3 / S, 0.0006);
// 羽根の継ぎ目の幅(atan の切れ目で fwidth が跳ねないよう、式で出す)
float seamW = (N / TAU / max(r, 0.02) + 1.3 / (r + 0.35)) * 1.6 / S;
float scallop = uIris * (1.0 + 0.05 * mix(0.5, f - 0.5, smoothstep(0.0, seamW, f)));
float mask = smoothstep(scallop - aa, scallop + aa, r);
// 開口の内側に、板から縁へ渡る細いリブ
if (mask < 1.0) {
float d = abs(f - 0.5);
float ribIn = (1.0 - smoothstep(0.006, 0.016, d)) * smoothstep(0.3, 0.5, r);
float ribSh = (1.0 - smoothstep(0.012, 0.05, abs(f - 0.53))) * smoothstep(0.3, 0.5, r);
col = mix(col, mix(uMuted, col, 0.5), ribSh * 0.28);
col = mix(col, uLight, ribIn * 0.85);
// 開口のふちが都市に落とす影
float shadow = smoothstep(scallop * 0.8, scallop, r);
col = mix(col, mix(uMuted, uBg, 0.3), shadow * shadow * 0.34);
}
if (mask > 0.0) {
float e = r - scallop;
// 羽根の重なり:一枚の中で明るさが流れ、継ぎ目に影が落ちる
float head = mix(0.8, 1.0, smoothstep(0.0, 1.0, f));
head *= 1.0 - (1.0 - smoothstep(0.0, 0.16, f)) * 0.26;
float shade = mix(1.0, head, smoothstep(0.0, seamW, f));
// 下から見上げた丸み:下の方が光を受ける
shade *= mix(1.03, 0.9, 0.5 + 0.5 * rel.y / max(r, 0.001));
// 外へ行くほど陰る
shade *= mix(1.0, 0.82, smoothstep(0.1, 1.5, e));
vec3 shell = mix(mix(uMuted, uBg, 0.5), uSurface, clamp(shade * 1.04 - 0.1, 0.0, 1.0));
// 肋骨:太い一本と、細い二本。一方に細い影
float rib = 1.0 - smoothstep(0.012, 0.026, abs(f - 0.5));
float ribShadow = (1.0 - smoothstep(0.0, 0.05, f - 0.525)) * step(0.5, f);
float fine = 1.0 - smoothstep(0.004, 0.014, abs(abs(f - 0.5) - 0.25));
// 穴あき模様:開口に近い帯だけ。外はなめらかな殻に戻る
float zone = smoothstep(0.08, 0.2, e) * (1.0 - smoothstep(0.42, 0.95, e));
zone *= smoothstep(0.03, 0.12, f) * (1.0 - smoothstep(0.88, 0.97, f));
zone *= smoothstep(0.035, 0.08, abs(f - 0.5));
if (zone > 0.001) {
vec2 v = voronoi(vec2(log(r + 0.02) * 15.0, f * 4.6 + blade * 13.7));
float wall = mix(0.2, 0.11, smoothstep(0.1, 0.8, e)) + (1.0 - zone) * 0.5;
float hole = smoothstep(wall, wall + 0.04, v.y);
float rim = smoothstep(wall - 0.07, wall, v.y);
shell = mix(shell, mix(uMuted, uSurface, 0.6), rim * 0.42);
vec3 through = mix(uLight, uGlow, 0.2 + clamp(caust, 0.0, 0.3));
shell = mix(shell, through, hole);
}
shell = mix(shell, mix(uMuted, uSurface, 0.45), ribShadow * 0.2);
shell = mix(shell, uLight, rib * 0.9);
shell = mix(shell, uLight, fine * 0.4);
// 水の光が殻に当たる
shell = mix(shell, uLight, clamp(caust * 1.25, 0.0, 0.7));
// 開口のふちの厚み
float lip = exp(-max(e, 0.0) * 42.0);
shell = mix(shell, uLight, lip * 0.95);
float under = exp(-max(e - 0.025, 0.0) * 22.0) * (1.0 - exp(-max(e, 0.0) * 52.0));
shell = mix(shell, mix(uMuted, uBg, 0.5), under * 0.2);
col = mix(col, shell, mask);
}
// データの光の脈:リブを伝い、板から外へ流れる
float ribDist = abs(f - 0.5) * TAU * max(r, 0.02) / N; // リブからの実際の距離
if (ribDist < 0.05) {
float pulse = clamp(pulseSum(r, blade), 0.0, 1.0);
if (pulse > 0.001) {
float halo = exp(-ribDist * ribDist * 3200.0);
float core = 1.0 - smoothstep(0.0022, 0.0052, ribDist);
col = mix(col, uGlow, pulse * halo * 0.42);
col = mix(col, mix(uGlow, uLight, pulse * 0.55), pulse * core);
}
}
// 光の筋
float ray = rays(p, uT) * uRay;
col = mix(col, uLight, clamp(ray * 0.5, 0.0, 0.82));
col = mix(col, uGlow, clamp(ray * 0.05, 0.0, 0.2));
// 光が満ちる:中心から広がる
if (uFlood > 0.0) {
float fl = smoothstep(0.0, 1.0, uFlood * 3.0 - r * 0.85);
vec3 pale = mix(uSurface, uGlow, 0.05 + clamp(caust, 0.0, 0.4) * 0.2);
col = mix(col, pale, fl * 0.9);
}
outColor = vec4(col, 1.0);
}`;
const GLASS = `#version 300 es
precision highp float;
uniform sampler2D uScene;
uniform vec2 uRes;
uniform float uT, uDpr, uRadius, uMelt, uTint, uSheenA, uDropK, uBevel;
uniform vec4 uBtn;
uniform float uBtnR, uBtnA, uBtnTint, uBand, uBandCore, uBandPhase, uBtnHot;
uniform vec3 uAccent;
uniform vec4 uPanel;
uniform vec2 uSheen;
uniform vec3 uDrops[${DROPS}];
uniform int uDropN;
uniform vec3 uSurface, uLight, uInk, uGlow;
out vec4 outColor;
float rb(vec2 p, vec2 b, float r) {
vec2 q = abs(p) - b + r;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
float smin(float a, float b, float k) {
float h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * k * 0.25;
}
// ガラスの形:角丸の板と、集まってくる水滴
float shape(vec2 p) {
float d = 1.0e5;
if (uPanel.z > 0.5 && uPanel.w > 0.5) d = rb(p - uPanel.xy, uPanel.zw, min(uRadius, min(uPanel.z, uPanel.w)));
for (int i = 0; i < uDropN; i++) {
float c = length(p - uDrops[i].xy) - uDrops[i].z;
d = smin(d, c, uDropK);
}
if (uMelt > 0.0) d += uMelt * sin(p.x * 0.011 / uDpr + uT * 0.9) * sin(p.y * 0.014 / uDpr - uT * 0.7) * 16.0 * uDpr;
return d;
}
void main() {
vec2 px = gl_FragCoord.xy;
vec2 uv = px / uRes;
vec3 col = texture(uScene, uv).rgb;
float d = shape(px);
// 板が都市に落とす、やわらかい影
float reach = 70.0 * uDpr;
if (d < reach) {
float ds = shape(px + vec2(0.0, 16.0 * uDpr));
float sh = exp(-max(ds, 0.0) / (26.0 * uDpr));
col = mix(col, uInk, 0.15 * sh * (1.0 - uMelt));
}
if (d < 1.5 * uDpr) {
float e = max(-d, 0.0);
float eps = 1.5 * uDpr;
vec2 grad = vec2(shape(px + vec2(eps, 0.0)) - shape(px - vec2(eps, 0.0)), shape(px + vec2(0.0, eps)) - shape(px - vec2(0.0, eps)));
grad = grad / max(length(grad), 0.0001);
float h = clamp(1.0 - e / uBevel, 0.0, 1.0);
float edge = h * h;
float melt = 1.0 - uMelt;
// 屈折:縁で大きく曲がり、中ほどは少しだけ拡大する
vec2 off = -grad * edge * 42.0 * uDpr * melt;
if (uPanel.z > 0.5) off += (uPanel.xy - px) * 0.05 * melt;
vec2 ou = off / uRes;
float lod = mix(2.1, 0.4, edge) * melt;
vec3 ref;
ref.r = textureLod(uScene, uv + ou * 0.94, lod).r;
ref.g = textureLod(uScene, uv + ou, lod).g;
ref.b = textureLod(uScene, uv + ou * 1.06, lod).b;
// 文字が読めるよう、中ほどは面の色を強く重ねる。縁は透かす
vec3 glass = mix(ref, uSurface, uTint * (1.0 - edge * 0.8) * melt);
// カーソルを追う反射の光
vec2 dm = px - uSheen;
float R = 190.0 * uDpr;
float near = exp(-dot(dm, dm) / (R * R));
glass = mix(glass, uLight, near * uSheenA * melt);
// 縁の光:周を走る光と、カーソルに近い側
vec2 pc = px - uPanel.xy;
float a = atan(pc.y, pc.x);
float run = pow(0.5 + 0.5 * sin(a * 2.0 - uT * 0.85), 12.0);
float rim = 1.0 - smoothstep(0.6 * uDpr, 2.2 * uDpr, abs(e - 1.4 * uDpr));
glass = mix(glass, uLight, rim * (0.55 + 0.45 * run + 0.5 * near) * melt);
glass = mix(glass, uGlow, rim * run * 0.35 * melt);
// 厚み:下の内側に細い影
float inner = (1.0 - smoothstep(1.0 * uDpr, 9.0 * uDpr, e)) * clamp(0.5 - grad.y * 0.6, 0.0, 1.0);
glass = mix(glass, uInk, inner * 0.12 * melt);
float top = (1.0 - smoothstep(1.0 * uDpr, 7.0 * uDpr, e)) * clamp(0.5 + grad.y * 0.6, 0.0, 1.0);
glass = mix(glass, uLight, top * 0.35 * melt);
float cover = 1.0 - smoothstep(-0.8 * uDpr, 0.8 * uDpr, d);
col = mix(col, glass, cover * melt);
}
// ボタン:板と同じガラスの、小さく厚い一枚。中を光が通る
float btnA = uBtnA * (1.0 - uMelt);
if (btnA > 0.001) {
vec2 bp = px - uBtn.xy;
float bd = rb(bp, uBtn.zw, uBtnR);
// 外へにじむ光
if (bd > 0.0 && bd < 26.0 * uDpr) {
col = mix(col, uGlow, exp(-bd / (8.0 * uDpr)) * (0.1 + 0.14 * uBtnHot) * btnA);
}
if (bd < 1.5 * uDpr) {
float be = max(-bd, 0.0);
float eb = 1.5 * uDpr;
vec2 bg = vec2(rb(bp + vec2(eb, 0.0), uBtn.zw, uBtnR) - rb(bp - vec2(eb, 0.0), uBtn.zw, uBtnR), rb(bp + vec2(0.0, eb), uBtn.zw, uBtnR) - rb(bp - vec2(0.0, eb), uBtn.zw, uBtnR));
bg = bg / max(length(bg), 0.0001);
float bh = clamp(1.0 - be / (13.0 * uDpr), 0.0, 1.0);
float bedge = bh * bh;
// 屈折:縁で強く曲がり、中はレンズのように拡大する
vec2 bo = (-bg * bedge * 26.0 * uDpr - bp * 0.16) / uRes;
vec3 br;
br.r = textureLod(uScene, uv + bo * 0.92, 1.2).r;
br.g = textureLod(uScene, uv + bo, 1.2).g;
br.b = textureLod(uScene, uv + bo * 1.08, 1.2).b;
vec3 g = mix(br, uSurface, uBtnTint * (1.0 - bedge * 0.7));
// 中を通る光:斜めの帯が左から右へ抜ける。水の揺らぎで少し乱れる
float u = bp.x / uBtn.z;
float v = bp.y / uBtn.w;
float center = -1.5 + 3.0 * uBandPhase;
float wob = sin(u * 5.0 + uT * 1.3 + sin(v * 2.4 - uT * 0.9) * 1.6) * 0.06;
float bandD = (u - center + v * 0.22 + wob) / 0.42;
float band = exp(-bandD * bandD);
float floorLight = smoothstep(1.0, -1.0, v) * 0.34;
float light = clamp(max(band, floorLight) + 0.18, 0.0, 1.0);
g = mix(g, uGlow, light * uBand);
// 帯の芯は白く明るい。ここが光に見える
float coreD = (u - center + v * 0.22 + wob) / 0.17;
g = mix(g, uLight, exp(-coreD * coreD) * uBandCore);
// 厚み:上の内側は明るく、下の内側に細い影
float bTop = (1.0 - smoothstep(0.5 * uDpr, 5.0 * uDpr, be)) * clamp(0.5 + bg.y * 0.7, 0.0, 1.0);
g = mix(g, uLight, bTop * 0.5);
float bLow = (1.0 - smoothstep(0.5 * uDpr, 6.0 * uDpr, be)) * clamp(0.5 - bg.y * 0.7, 0.0, 1.0);
g = mix(g, uInk, bLow * 0.1);
// 縁:強調色の光の線。帯が通るところで明るくなる
float bRim = 1.0 - smoothstep(0.5 * uDpr, 1.9 * uDpr, abs(be - 1.1 * uDpr));
g = mix(g, uAccent, bRim * (0.8 + 0.2 * uBtnHot));
g = mix(g, uGlow, bRim * band * 0.8);
float bCover = 1.0 - smoothstep(-0.8 * uDpr, 0.8 * uDpr, bd);
col = mix(col, g, bCover * btnA);
}
}
// 白の階調が縞にならないよう、わずかに散らす
float n = fract(sin(dot(px, vec2(12.9898, 78.233))) * 43758.5453) - 0.5;
outColor = vec4(col + n / 255.0, 1.0);
}`;
const clamp = (v, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
const mix = (a, b, t) => a + (b - a) * t;
const seg = (t, from, to) => clamp((t - from) / (to - from));
// すべて、なめらかに減速して止まる
const out = (t, power = 4) => 1 - Math.pow(1 - clamp(t), power);
const inOut = (t) => {
const x = clamp(t);
return x < 0.5 ? 8 * x * x * x * x : 1 - Math.pow(-2 * x + 2, 4) / 2;
};
function messageFor(input) {
const value = input.value;
if (input.dataset.validate === 'email') {
if (!value.trim()) return 'メールアドレスを入力してください。';
if (!EMAIL.test(value.trim())) return 'メールアドレスの形を確かめてください(例:name@example.com)。';
return '';
}
return value ? '' : 'パスワードを入力してください。';
}
function mount(root) {
const cleanups = [];
const timers = new Set();
const on = (target, type, fn, options) => {
target.addEventListener(type, fn, options);
cleanups.push(() => target.removeEventListener(type, fn, options));
};
const later = (fn, ms) => {
const id = setTimeout(() => {
timers.delete(id);
fn();
}, ms);
timers.add(id);
};
const byId = (id) => root.querySelector(`[id="${id}"]`);
const canvas = root.querySelector('.scene');
const probe = root.querySelector('.probe');
const card = root.querySelector('[data-card]');
const welcome = root.querySelector('[data-welcome]');
const form = root.querySelector('[data-form]');
const submit = form.querySelector('.submit');
const submitText = submit.querySelector('.submit-text');
const inputs = [...form.querySelectorAll('[data-validate]')];
const submitIdle = submitText.textContent;
const motionQuery = matchMedia('(prefers-reduced-motion: reduce)');
const schemeQuery = matchMedia('(prefers-color-scheme: dark)');
let reduced = motionQuery.matches;
// ---- 色:どの書き方のトークンでも、1画素に塗って読む
const paint = new OffscreenCanvas(1, 1).getContext('2d', { willReadFrequently: true });
const toRgb = (css) => {
paint.clearRect(0, 0, 1, 1);
paint.fillStyle = css;
paint.fillRect(0, 0, 1, 1);
const d = paint.getImageData(0, 0, 1, 1).data;
return [d[0] / 255, d[1] / 255, d[2] / 255];
};
const lum = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
const colors = {};
const glassMix = { tint: 0.6, sheen: 0.4 };
const linear = (c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
const wcagLum = ([r, g, b]) => 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
const ratio = (a, b) => {
const la = wcagLum(a);
const lb = wcagLum(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
};
const blend = (a, b, t) => a.map((c, i) => mix(c, b[i], t));
// ガラスの後ろがいちばん読みにくい色でも、弱い文字(--_muted)が 4.5:1 を保つ濃さを探す。使い手の配色でも効く
const solveGlass = () => {
glassMix.sheen = colors.dark ? 0.04 : 0.45;
const worst = colors.dark ? blend(colors.hi, colors.light, 0.5) : blend(colors.muted, colors.bg, 0.25);
const goal = 4.7;
let tint = 1;
for (let t = 0.5; t <= 1; t += 0.01) {
const seen = blend(blend(worst, colors.surface, t), colors.dark ? colors.light : colors.surface, colors.dark ? glassMix.sheen : 0);
if (ratio(colors.muted, seen) >= goal) {
tint = t;
break;
}
}
glassMix.tint = tint;
// ボタン:文字(--_fg)が 4.5:1 を保てるところまで透かし、その中でいちばん強く光を通す
const readable = (t, band) => {
const over = (c) => blend(blend(c, colors.surface, t), colors.glow, band);
return ratio(colors.fg, over(worst)) >= goal && ratio(colors.fg, over(colors.surface)) >= goal;
};
glassMix.btnTint = 1;
for (let t = 0.34; t <= 1; t += 0.01) {
if (readable(t, 0.3)) {
glassMix.btnTint = t;
break;
}
}
glassMix.band = 0;
for (let band = 0.62; band >= 0; band -= 0.02) {
if (readable(glassMix.btnTint, band)) {
glassMix.band = band;
break;
}
}
glassMix.core = 0;
for (let core = 0.85; core >= 0; core -= 0.02) {
const lit = (c) => blend(blend(blend(c, colors.surface, glassMix.btnTint), colors.glow, glassMix.band), colors.light, core);
if (ratio(colors.fg, lit(worst)) >= goal && ratio(colors.fg, lit(colors.surface)) >= goal) {
glassMix.core = core;
break;
}
}
};
const readColors = () => {
const style = getComputedStyle(root);
for (const name of ['bg', 'surface', 'fg', 'muted', 'accent']) {
probe.style.color = style.getPropertyValue(`--_${name}`).trim();
colors[name] = toRgb(getComputedStyle(probe).color);
}
const all = [colors.bg, colors.surface, colors.fg].sort((a, b) => lum(a) - lum(b));
colors.ink = all[0];
colors.light = all[2];
colors.dark = lum(colors.surface) < 0.4;
// 光の色:強調色を、いちばん明るい色へ少し寄せる
colors.glow = colors.accent.map((c, i) => mix(c, colors.light[i], colors.dark ? 0.1 : 0.22));
// 都市の中の光:暗い配色では、いちばん明るい色をそのまま使うとうるさいので、弱い文字の色へ寄せる
colors.hi = colors.dark ? colors.muted.map((c, i) => mix(c, colors.light[i], 0.28)) : colors.light;
solveGlass();
};
// ---- WebGL
let gl = null;
try {
gl = canvas.getContext('webgl2', { alpha: false, antialias: false, depth: false, stencil: false, powerPreference: 'high-performance' });
} catch {
gl = null;
}
const compile = (type, source) => {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader) || 'shader');
return shader;
};
const program = (fragment) => {
const p = gl.createProgram();
const v = compile(gl.VERTEX_SHADER, VERT);
const f = compile(gl.FRAGMENT_SHADER, fragment);
gl.attachShader(p, v);
gl.attachShader(p, f);
gl.bindAttribLocation(p, 0, 'aPos');
gl.linkProgram(p);
gl.deleteShader(v);
gl.deleteShader(f);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p) || 'link');
const cache = new Map();
return {
id: p,
at(name) {
if (!cache.has(name)) cache.set(name, gl.getUniformLocation(p, name));
return cache.get(name);
},
};
};
let sceneProg = null;
let glassProg = null;
let quad = null;
let vao = null;
let tex = null;
let fbo = null;
let ready = false;
const setupGl = () => {
if (!gl) return false;
try {
sceneProg = program(SCENE);
glassProg = program(GLASS);
} catch (error) {
console.warn('login-lumen: シェーダーを作れないので、止まった絵にする', error);
return false;
}
vao = gl.createVertexArray();
gl.bindVertexArray(vao);
quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
tex = gl.createTexture();
fbo = gl.createFramebuffer();
return true;
};
// ---- 大きさ
let W = 1;
let H = 1;
let dpr = 1;
let quality = Math.min(1, 1.25 / Math.min(2, devicePixelRatio || 1)); // 都市を描く解像度の割合。遅い機械では下げる
let sceneW = 1;
let sceneH = 1;
const allocScene = () => {
sceneW = Math.max(2, Math.round(canvas.width * quality));
sceneH = Math.max(2, Math.round(canvas.height * quality));
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, sceneW, sceneH, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_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);
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
};
// ---- 板の形(DOM のカードと同じ場所)
const panel = { x: 0, y: 0, hw: 0, hh: 0, radius: 0, bevel: 30, ty: 0, thh: 0, seeded: false };
const btn = { x: 0, y: 0, ty: 0, hw: 0, hh: 0, radius: 0, phase: 0.2, hot: 0, hotT: 0 };
const measureCard = () => {
const base = canvas.getBoundingClientRect();
const rect = card.getBoundingClientRect();
panel.x = (rect.left - base.left + rect.width / 2) * dpr;
panel.ty = canvas.height - (rect.top - base.top + rect.height / 2) * dpr;
panel.hw = (rect.width / 2) * dpr;
panel.thh = (rect.height / 2) * dpr;
const cardStyle = getComputedStyle(card);
panel.radius = (parseFloat(cardStyle.borderTopLeftRadius) || 0) * dpr;
panel.bevel = Math.max(6, Math.min(30, (parseFloat(cardStyle.paddingLeft) || 30) - 4)) * dpr;
// ボタンの場所。登場の途中で動いていても、止まった位置を測る
const bRect = submit.getBoundingClientRect();
const moved = new DOMMatrixReadOnly(getComputedStyle(submit).transform).m42;
btn.x = (bRect.left - base.left + bRect.width / 2) * dpr;
btn.ty = canvas.height - (bRect.top - moved - base.top + bRect.height / 2) * dpr;
btn.hw = (bRect.width / 2) * dpr;
btn.hh = (bRect.height / 2) * dpr;
btn.radius = Math.min(parseFloat(getComputedStyle(submit).borderTopLeftRadius) || 0, bRect.height / 2) * dpr;
if (!panel.seeded) {
btn.y = btn.ty;
panel.y = panel.ty;
panel.hh = panel.thh;
panel.seeded = true;
}
};
const layout = () => {
const rect = root.getBoundingClientRect();
W = Math.max(1, rect.width);
H = Math.max(1, rect.height);
dpr = Math.min(2, devicePixelRatio || 1);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
if (ready) allocScene();
measureCard();
sheen.x = sheen.tx = canvas.width * 0.62;
sheen.y = sheen.ty = canvas.height * 0.68;
seedDrops();
needsDraw = true;
};
// ---- 水滴:散らばった場所から、板の中へ集まる
const drops = [];
const seedDrops = () => {
drops.length = 0;
const span = Math.min(canvas.width, canvas.height);
for (let i = 0; i < DROPS; i++) {
const a = (i / DROPS) * Math.PI * 2 + Math.sin(i * 12.9) * 0.5;
const far = span * (0.26 + 0.2 * (0.5 + 0.5 * Math.sin(i * 78.2)));
drops.push({
sx: panel.x + Math.cos(a) * far * 1.25,
sy: panel.ty + Math.sin(a) * far,
ex: panel.x + Math.cos(a * 3.1) * panel.hw * 0.5,
ey: panel.ty + Math.sin(a * 2.3) * panel.thh * 0.5,
r: (9 + 15 * (0.5 + 0.5 * Math.sin(i * 37.1))) * dpr,
delay: (i % 7) * 0.045,
});
}
};
const dropData = new Float32Array(DROPS * 3);
// ---- 動きの状態
const INTRO = { rays: [0, 0.9], iris: [0.1, 2.15], drops: [0.85, 2.2], panel: [1.25, 2.3], form: 1.9, end: 2.5 };
let started = -1; // 登場の始まり(最初に見えた時)
let now = 0;
let intro = reduced ? 1 : 0;
let introDone = reduced;
let exit = 0; // Sign in の進み。0 → 1
let exitFrom = 0;
let exitTo = 0;
let exitStart = -1;
let exitDur = 2.2;
const par = { x: 0, y: 0, tx: 0, ty: 0 };
const sheen = { x: 0, y: 0, tx: 0, ty: 0 };
let pointerInside = false;
const pulses = new Float32Array(PULSES * 3);
const pulseStart = new Float64Array(PULSES).fill(-100);
const pulseRib = new Float32Array(PULSES);
let pulseNext = 0;
let needsDraw = true;
const addPulse = (strength = 1) => {
if (reduced || !ready) return;
for (let i = 0; i < 2; i++) {
pulseStart[pulseNext] = now;
pulseRib[pulseNext] = Math.floor(Math.random() * BLADES);
pulses[pulseNext * 3 + 2] = strength;
pulseNext = (pulseNext + 1) % PULSES;
}
wake();
};
const irisRest = () => (W / H < 0.8 ? 1.02 : 0.92);
// 地平線。横長の画面は中央の少し下、縦長の画面はカードの上の端(都市がカードの裏に隠れないように)
const horizonUnit = () => {
if (W / H >= 0.8) return -0.3;
const S = 0.5 * Math.min(canvas.height, canvas.width * 1.6);
return (panel.y + panel.thh - canvas.height / 2) / S - 0.02;
};
// ---- 描く
const setColors = (prog, names) => {
for (const [uniform, key] of names) gl.uniform3fv(prog.at(uniform), colors[key]);
};
const render = (t) => {
const introT = reduced ? 99 : t - started;
const openE = exit;
const irisE = out(seg(introT, INTRO.iris[0], INTRO.iris[1]), 3);
const rayE = out(seg(introT, INTRO.rays[0], INTRO.rays[1]), 3);
const rest = irisRest();
const iris = mix(0, rest, irisE) + inOut(seg(openE, 0.05, 0.85)) * 2.5;
const rot = mix(-1.9, 0, irisE) + inOut(seg(openE, 0.05, 0.85)) * 1.25;
const flood = inOut(seg(openE, 0.2, 0.8));
const melt = out(seg(openE, 0.22, 0.72), 3);
const panelE = out(seg(introT, INTRO.panel[0], INTRO.panel[1]), 4);
const motionT = reduced ? 12 : t - started + 40;
// 都市と殻
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.viewport(0, 0, sceneW, sceneH);
gl.useProgram(sceneProg.id);
gl.uniform2f(sceneProg.at('uRes'), sceneW, sceneH);
gl.uniform1f(sceneProg.at('uT'), motionT);
gl.uniform1f(sceneProg.at('uIris'), iris);
gl.uniform1f(sceneProg.at('uRot'), rot);
gl.uniform1f(sceneProg.at('uRay'), rayE * (1 + flood * 0.6));
gl.uniform1f(sceneProg.at('uFlood'), flood);
gl.uniform2f(sceneProg.at('uPar'), par.x, par.y);
gl.uniform1f(sceneProg.at('uHorizon'), horizonUnit());
for (let i = 0; i < PULSES; i++) {
const age = t - pulseStart[i];
pulses[i * 3] = pulseRib[i];
pulses[i * 3 + 1] = age;
if (age > 3.2) pulses[i * 3 + 2] = 0;
}
gl.uniform3fv(sceneProg.at('uPulse'), pulses);
setColors(sceneProg, [['uBg', 'bg'], ['uSurface', 'surface'], ['uFg', 'fg'], ['uMuted', 'muted'], ['uAccent', 'accent'], ['uLight', 'hi'], ['uInk', 'ink'], ['uGlow', 'glow']]);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.generateMipmap(gl.TEXTURE_2D);
// ガラス
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.useProgram(glassProg.id);
gl.activeTexture(gl.TEXTURE0);
gl.uniform1i(glassProg.at('uScene'), 0);
gl.uniform2f(glassProg.at('uRes'), canvas.width, canvas.height);
gl.uniform1f(glassProg.at('uT'), motionT);
gl.uniform1f(glassProg.at('uDpr'), dpr);
gl.uniform1f(glassProg.at('uRadius'), panel.radius);
gl.uniform1f(glassProg.at('uMelt'), melt);
gl.uniform1f(glassProg.at('uTint'), glassMix.tint);
gl.uniform1f(glassProg.at('uSheenA'), glassMix.sheen);
gl.uniform1f(glassProg.at('uBevel'), panel.bevel);
gl.uniform1f(glassProg.at('uDropK'), 34 * dpr);
const grow = 1 + melt * 0.12;
gl.uniform4f(glassProg.at('uPanel'), panel.x, panel.y, panel.hw * panelE * grow, panel.hh * panelE * grow);
gl.uniform2f(glassProg.at('uSheen'), sheen.x, sheen.y);
gl.uniform4f(glassProg.at('uBtn'), btn.x, btn.y, btn.hw, btn.hh);
gl.uniform1f(glassProg.at('uBtnR'), btn.radius);
gl.uniform1f(glassProg.at('uBtnA'), out(seg(introT, INTRO.form - 0.2, INTRO.end), 3));
gl.uniform1f(glassProg.at('uBtnTint'), glassMix.btnTint);
gl.uniform1f(glassProg.at('uBand'), glassMix.band);
gl.uniform1f(glassProg.at('uBandCore'), glassMix.core);
gl.uniform1f(glassProg.at('uBandPhase'), reduced ? 0.42 : btn.phase);
gl.uniform1f(glassProg.at('uBtnHot'), btn.hot);
let n = 0;
if (introT < INTRO.panel[1] + 0.1) {
for (let i = 0; i < DROPS; i++) {
const d = drops[i];
const e = out(seg(introT - d.delay, INTRO.drops[0], INTRO.drops[1]), 3);
const appear = out(seg(introT - d.delay, INTRO.drops[0] - 0.25, INTRO.drops[0] + 0.15), 2);
if (appear <= 0) continue;
dropData[n * 3] = mix(d.sx, d.ex, e);
dropData[n * 3 + 1] = mix(d.sy, d.ey, e);
dropData[n * 3 + 2] = d.r * appear * (1 - panelE * 0.6);
n++;
}
gl.uniform3fv(glassProg.at('uDrops'), dropData);
}
gl.uniform1i(glassProg.at('uDropN'), n);
setColors(glassProg, [['uSurface', 'surface'], ['uLight', 'light'], ['uInk', 'ink'], ['uGlow', 'glow'], ['uAccent', 'accent']]);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
// ---- 時間を進める
let raf = 0;
let last = 0;
let visible = true;
let slow = 0;
let good = 0;
let adaptFrames = 0;
const adapt = (dt) => {
// 登場の始めのコンパイルなどを数えないよう、少し待ってから見る
if (++adaptFrames < 30) return;
if (dt > 0.0205) slow++;
else good++;
if (slow + good < 45) return;
if (slow > 22 && quality > 0.3) {
quality = Math.max(0.3, quality * 0.82);
allocScene();
}
slow = 0;
good = 0;
};
const tick = (ms) => {
raf = 0;
if (!ready) return;
const t = ms / 1000;
const dt = last ? t - last : 1 / 60;
last = t;
now = t;
if (started < 0) {
started = t;
needsDraw = true;
}
if (reduced) {
if (needsDraw) {
render(t);
needsDraw = false;
}
return;
}
adapt(dt);
if (!introDone) {
const introT = t - started;
if (introT >= INTRO.form && root.classList.contains('is-veiled')) root.classList.remove('is-veiled');
if (introT >= INTRO.end) introDone = true;
}
// Sign in の進み
if (exitStart >= 0) {
const p = clamp((t - exitStart) / exitDur);
exit = mix(exitFrom, exitTo, p);
if (p >= 1) exitStart = -1;
}
// カーソル:ない間は、光がゆっくり漂う
if (!pointerInside) {
sheen.tx = canvas.width * (0.5 + 0.2 * Math.sin(t * 0.31));
sheen.ty = canvas.height * (0.5 + 0.26 * Math.sin(t * 0.23 + 1.3));
par.tx = Math.sin(t * 0.13) * 0.35;
par.ty = Math.sin(t * 0.17 + 0.8) * 0.25;
}
const fast = 1 - Math.exp(-dt * 9);
const soft = 1 - Math.exp(-dt * 3.2);
sheen.x += (sheen.tx - sheen.x) * fast;
sheen.y += (sheen.ty - sheen.y) * fast;
par.x += (par.tx - par.x) * soft;
par.y += (par.ty - par.y) * soft;
// エラーで背が伸びたカードに、板がなめらかに追いつく
const follow = 1 - Math.exp(-dt * 12);
panel.y += (panel.ty - panel.y) * follow;
panel.hh += (panel.thh - panel.hh) * follow;
btn.y += (btn.ty - btn.y) * follow;
const busy = submit.disabled ? 1 : 0;
btn.hot += (Math.max(btn.hotT, busy) - btn.hot) * (1 - Math.exp(-dt * 8));
btn.phase = (btn.phase + dt * (0.24 + 0.3 * btn.hot + 0.45 * busy)) % 1;
render(t);
needsDraw = false;
if (visible) raf = requestAnimationFrame(tick);
};
function wake() {
if (!raf && visible && ready) {
last = 0;
raf = requestAnimationFrame(tick);
}
}
// ---- フォーム
const setError = (input, message) => {
const error = byId(input.getAttribute('aria-describedby'));
error.textContent = message;
error.hidden = !message;
if (message) input.setAttribute('aria-invalid', 'true');
else input.removeAttribute('aria-invalid');
};
for (const input of inputs) {
on(input, 'input', () => {
input.dataset.dirty = 'true';
if (input.hasAttribute('aria-invalid') && !messageFor(input)) setError(input, '');
addPulse(1);
});
on(input, 'blur', () => {
if (input.dataset.dirty === 'true') setError(input, messageFor(input));
});
}
const showWelcome = () => {
welcome.hidden = false;
welcome.focus();
};
const send = () => {
if (submit.disabled) return;
let first = null;
for (const input of inputs) {
const message = messageFor(input);
setError(input, message);
if (message && !first) first = input;
}
if (first) {
first.focus();
return;
}
submit.disabled = true;
submit.setAttribute('aria-busy', 'true');
submitText.textContent = submit.dataset.busy;
if (reduced) {
exit = 1;
root.classList.add('is-leaving');
card.inert = true;
card.hidden = true;
needsDraw = true;
wake();
showWelcome();
return;
}
for (let i = 0; i < 4; i++) addPulse(1);
later(() => {
root.classList.add('is-leaving');
card.inert = true;
exitFrom = exit;
exitTo = 1;
exitDur = 2.2;
exitStart = now;
wake();
later(showWelcome, 1250);
}, 450);
};
const back = () => {
welcome.hidden = true;
card.hidden = false;
submit.disabled = false;
submit.removeAttribute('aria-busy');
submitText.textContent = submitIdle;
const password = form.querySelector('[name="password"]');
password.value = '';
delete password.dataset.dirty;
if (reduced) {
exit = 0;
needsDraw = true;
card.inert = false;
root.classList.remove('is-leaving');
wake();
root.querySelector('.title').focus();
return;
}
exitFrom = exit;
exitTo = 0;
exitDur = 1.7;
exitStart = now;
wake();
later(() => {
card.inert = false;
root.classList.remove('is-leaving');
root.querySelector('.title').focus();
}, 1300);
};
// Ludion の iframe は sandbox="allow-scripts" だけで、submit イベントが来ない。送信ボタンの click で受ける。
// Enter で送ったときも、ブラウザが送信ボタンを click する。ふつうのページに貼ったときのために submit も止める。
on(submit, 'click', (event) => {
event.preventDefault();
send();
});
on(form, 'submit', (event) => {
event.preventDefault();
send();
});
// 押し下げた瞬間に入力欄から離れると、エラーの文でボタンがずれてクリックが外れる。押し下げではフォーカスを動かさない
on(submit, 'mousedown', (event) => event.preventDefault());
on(submit, 'pointerenter', () => {
btn.hotT = 1;
wake();
});
on(submit, 'pointerleave', () => {
btn.hotT = 0;
});
on(root.querySelector('[data-return]'), 'click', back);
// ---- カーソル
const pointer = (event) => {
if (reduced) return;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
pointerInside = true;
sheen.tx = x * dpr;
sheen.ty = canvas.height - y * dpr;
par.tx = (x / W - 0.5) * 2;
par.ty = -(y / H - 0.5) * 2;
wake();
};
on(root, 'pointermove', pointer);
on(root, 'pointerdown', pointer);
on(root, 'pointerleave', () => {
pointerInside = false;
wake();
});
// ---- 組み立て
readColors();
ready = setupGl();
let lost = false;
const fallback = () => {
ready = false;
root.classList.remove('has-gl', 'is-veiled');
};
if (ready) {
root.classList.add('has-gl');
if (!reduced) root.classList.add('is-veiled');
on(canvas, 'webglcontextlost', (event) => {
event.preventDefault();
lost = true;
fallback();
});
} else {
fallback();
}
const redraw = () => {
needsDraw = true;
wake();
};
const io = new IntersectionObserver((entries) => {
visible = entries.some((e) => e.isIntersecting);
if (visible) redraw();
else if (raf) {
cancelAnimationFrame(raf);
raf = 0;
}
});
io.observe(root);
const ro = new ResizeObserver((entries) => {
let resized = false;
for (const entry of entries) if (entry.target === root) resized = true;
if (resized) layout();
else measureCard();
redraw();
});
ro.observe(root);
ro.observe(card);
on(motionQuery, 'change', () => {
reduced = motionQuery.matches;
if (reduced) root.classList.remove('is-veiled');
if (reduced) introDone = true;
redraw();
});
on(schemeQuery, 'change', () => {
readColors();
redraw();
});
layout();
redraw();
return () => {
if (raf) cancelAnimationFrame(raf);
raf = 0;
visible = false;
ready = false;
io.disconnect();
ro.disconnect();
for (const off of cleanups) off();
for (const id of timers) clearTimeout(id);
timers.clear();
if (gl && !lost) {
if (sceneProg) gl.deleteProgram(sceneProg.id);
if (glassProg) gl.deleteProgram(glassProg.id);
if (quad) gl.deleteBuffer(quad);
if (vao) gl.deleteVertexArray(vao);
if (tex) gl.deleteTexture(tex);
if (fbo) gl.deleteFramebuffer(fbo);
const lose = gl.getExtension('WEBGL_lose_context');
if (lose) lose.loseContext();
}
};
}
mount(document.querySelector('[data-ld-part="login-lumen"]'));
</script>
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node