release: v0.9.1 (신규 쇼케이스 3종 정식 출시, CSS 변수 및 경로 무결성 게이트 강화)
Some checks failed
ci / build (push) Failing after 37s
release / release (push) Successful in 1m30s

This commit is contained in:
Yun Chan 2026-08-23 23:17:55 +09:00
parent e26b00a825
commit 225c1d9f42
75 changed files with 7406 additions and 224 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

View file

@ -0,0 +1,64 @@
---
interface Props {
class?: string;
}
const { class: className } = Astro.props;
---
<div class:list={["hero-gl-wrap", className]} aria-hidden="true">
<div class="hero-gl-fallback"></div>
<canvas id="showcase-gl" class="hero-gl-canvas" style="opacity: 0;"></canvas>
<div class="hero-gl-scrim"></div>
</div>
<script>
import { boot } from "../gl/boot.ts";
const canvas = document.getElementById("showcase-gl") as HTMLCanvasElement | null;
if (canvas) {
boot(canvas, () => import("../gl/scenes/showcase-hero.ts"));
}
</script>
<style>
.hero-gl-wrap {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
mask-image: linear-gradient(to bottom, black 70%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 70%, transparent 100%);
}
.hero-gl-fallback {
position: absolute;
inset: 0;
background:
radial-gradient(85% 60% at 50% -10%, color-mix(in oklab, var(--accent) 18%, transparent) 0%, transparent 70%),
radial-gradient(60% 50% at 85% 20%, rgba(217, 155, 56, 0.12) 0%, transparent 60%),
linear-gradient(180deg, var(--surface-sunken) 0%, var(--surface) 100%);
}
.hero-gl-canvas {
position: absolute;
inset: 0;
width: 100% !important;
height: 100% !important;
max-width: none !important;
display: block;
}
.hero-gl-scrim {
position: absolute;
inset: 0;
background: radial-gradient(circle at 50% 30%, rgba(12, 16, 23, 0.35) 0%, rgba(12, 16, 23, 0.8) 100%);
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.hero-gl-canvas {
display: none !important;
}
}
</style>

View file

@ -34,6 +34,12 @@ import gaonLms from "../assets/shots/work-gaon-lms.png";
import gaonLms2 from "../assets/shots/work-gaon-lms-2.png";
import dureEnrollment from "../assets/shots/work-dure-enrollment.png";
import dureEnrollment2 from "../assets/shots/work-dure-enrollment-2.png";
import cheongonCare from "../assets/shots/work-cheongon-care.png";
import cheongonCare2 from "../assets/shots/work-cheongon-care-2.png";
import caskArchive from "../assets/shots/work-cask-archive.png";
import caskArchive2 from "../assets/shots/work-cask-archive-2.png";
import materiaInterior from "../assets/shots/work-materia-interior.png";
import materiaInterior2 from "../assets/shots/work-materia-interior-2.png";
export interface WorkItem {
slug: string;
@ -45,6 +51,7 @@ export interface WorkItem {
date: string;
metrics: string[];
href: string;
subLinks?: { label: string; href: string }[];
shot: ImageMetadata;
shotAlt?: ImageMetadata;
alt: string;
@ -258,4 +265,60 @@ export const WORK: WorkItem[] = [
alt: "두레대학 수강신청 — 과목 원장 목록과 주홍 도장 블록이 놓이는 주간 시간표 격자",
size: "md",
},
/* ── 7행: 신규 쇼케이스 3종 (2 + 2 + 2) ── */
{
slug: "cheongon-care",
name: "청온 케어 (淸溫)",
kind: "위생 케어 & 관제 콘솔 · 2모드",
concept: "임상 수준의 6단계 위생 프로토콜 — 정찰제 견적과 실시간 배차 관제 콘솔",
risk: "고객용 견적 신청 폼과 관리자용 관제 대시보드가 단일 웹앱에서 실시간 상태를 공유하는 풀-루프 인터랙션",
preset: "ledger-console",
date: "2026-08",
metrics: ["고객·관리자 2모드", "실시간 정찰제 견적기", "32개 QC 체크리스트", "에메랄드 1색", "하드 게이트 14/14"],
href: "/work/cheongon-care/",
subLinks: [
{ label: "임상 6단계 위생 프로토콜 & ATP 리포트", href: "/work/cheongon-care/protocol/" }
],
shot: cheongonCare,
shotAlt: cheongonCare2,
alt: "청온 케어 — 정밀 입주 케어가 완료된 거실 전경과 독일 카처 고온 스팀 위생 장비",
size: "sm",
},
{
slug: "cask-archive",
name: "캐스크 아카이브 (樽室)",
kind: "독립병입자 위스키바 & 도슨트",
concept: "시간과 배럴이 빚어낸 단 하나의 원액 — 독립병입자 공식 인증 원장과 1:1 도슨트",
risk: "일반 메뉴판 대신 단일 캐스크 넘버와 통관 필증이 기록된 캐스크 원장 및 향미 필터를 첫 화면에 배치",
preset: "editorial-archival",
date: "2026-08",
metrics: ["독립병입자 4종 인증", "실시간 캐스크 원장 검색", "1:1 도슨트 예약", "앰버 골드 1색", "하드 게이트 14/14"],
href: "/work/cask-archive/",
subLinks: [
{ label: "1:1 위스키 도슨트 테이스팅 마스터클래스", href: "/work/cask-archive/docent/" }
],
shot: caskArchive,
shotAlt: caskArchive2,
alt: "캐스크 아카이브 — 오크 배럴 상판 위 싱글 캐스크 위스키 보틀과 스코틀랜드 숙성고 전경",
size: "sm",
},
{
slug: "materia-interior",
name: "마테리아 스튜디오 (空間素材)",
kind: "하이엔드 공간 인테리어 건축",
concept: "빛과 트래버틴이 빚어내는 침묵 — 물성과 자연광의 궤적을 설계하는 아틀리에",
risk: "썸네일 그리드를 버리고 트래버틴·오크·리넨의 실측 물성 스펙과 자연광/야간 조도 앰비언스 실시간 전환을 제공",
preset: "editorial",
date: "2026-08",
metrics: ["자연광·야간 조도 전환", "트래버틴 물성 스펙", "더블 베젤 갤러리", "브론즈 1색", "하드 게이트 14/14"],
href: "/work/materia-interior/",
subLinks: [
{ label: "24개 공간 심층 아카이브 갤러리 (한남 듀플렉스)", href: "/work/materia-interior/projects/hannam-duplex/" }
],
shot: materiaInterior,
shotAlt: materiaInterior2,
alt: "마테리아 스튜디오 — 트래버틴 벽체 펜트하우스 거실 전경과 천연 마감재 플랫레이",
size: "sm",
},
];

71
apps/site/src/gl/boot.ts Normal file
View file

@ -0,0 +1,71 @@
/**
* Three.js 지연 로드 부트스트랩
* designpaca/references/three.md 코드 A 준수
*/
export async function boot(
canvas: HTMLCanvasElement | null,
loader: () => Promise<{ default: (canvas: HTMLCanvasElement, opts: any) => () => void }>
) {
if (!canvas || !gate()) {
canvas?.remove();
return null;
}
await inViewport(canvas);
try {
const { default: mount } = await loader();
const css = getComputedStyle(document.documentElement);
const dispose = mount(canvas, { css });
const dur = css.getPropertyValue("--dur-normal").trim() || "350ms";
const ease = css.getPropertyValue("--ease-out").trim() || "ease";
canvas.style.transition = `opacity ${dur} ${ease}`;
requestAnimationFrame(() => {
canvas.style.opacity = "1";
});
window.addEventListener("pagehide", dispose, { once: true });
return dispose;
} catch (err) {
console.warn("[designpaca:three] WebGL init fallback:", err);
canvas.remove();
return null;
}
}
function gate(): boolean {
if (typeof window === "undefined") return false;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return false;
if ((navigator as any).connection?.saveData) return false;
if (((navigator as any).deviceMemory ?? 8) < 4 || (navigator.hardwareConcurrency ?? 8) < 4) return false;
try {
const probe = document.createElement("canvas");
const gl = probe.getContext("webgl2") || probe.getContext("webgl");
if (!gl) return false;
gl.getExtension("WEBGL_lose_context")?.loseContext();
return true;
} catch {
return false;
}
}
function inViewport(el: HTMLElement): Promise<void> {
return new Promise((resolve) => {
if (typeof IntersectionObserver === "undefined") {
resolve();
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
io.disconnect();
resolve();
}
},
{ rootMargin: "200px" }
);
io.observe(el);
});
}

View file

@ -0,0 +1,166 @@
import * as THREE from "three";
import { createStage } from "../stage.ts";
const VERT = /* glsl */ `
void main() {
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
const FRAG = /* glsl */ `
precision highp float;
uniform float uTime;
uniform float uScroll;
uniform vec2 uResolution;
uniform vec2 uPointer;
uniform vec3 uSurface;
uniform vec3 uRaised;
uniform vec3 uAccent;
uniform vec3 uAccent2;
// Simplex 2D noise
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec3 permute(vec3 x) { return mod289(((x*34.0)+1.0)*x); }
float snoise(vec2 v) {
const vec4 C = vec4(0.211324865405187,
0.366025403784439,
-0.577350269189626,
0.024390243902439);
vec2 i = floor(v + dot(v, C.yy));
vec2 x0 = v - i + dot(i, C.xx);
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod289(i);
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
m = m*m;
m = m*m;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
float fbm(vec2 p) {
float v = 0.0;
float a = 0.5;
mat2 rot = mat2(cos(0.35), sin(0.35), -sin(0.35), cos(0.35));
for (int i = 0; i < 3; ++i) {
v += a * snoise(p);
p = rot * p * 1.75 + vec2(10.0);
a *= 0.5;
}
return v;
}
void main() {
// Normalise coordinates to center, maintaining aspect ratio
vec2 st = (gl_FragCoord.xy * 2.0 - uResolution) / max(uResolution.x, uResolution.y);
// Very gentle, slow breathing tempo (ambient silk light wave, no storm vortex)
float t = uTime * 0.035;
// Gentle pointer glow displacement
vec2 pc = (uPointer - 0.5) * 1.5;
float pDist = length(st - pc);
vec2 pDrift = normalize(st - pc + 1e-4) * exp(-pDist * 2.8) * 0.06;
// Broad, calm rolling ambient wave
vec2 waveCoords = st * 1.1 + pDrift;
float n1 = fbm(waveCoords + vec2(t * 0.1, t * 0.07));
float n2 = fbm(waveCoords * 1.3 - vec2(t * 0.05, n1 * 0.35));
float lightField = fbm(waveCoords + vec2(n2 * 0.5, t * 0.04));
// Base background colors
vec3 col = mix(uSurface, uRaised, clamp(lightField * 0.65 + 0.35, 0.0, 1.0));
// Subtle, elegant emerald & amber mineral glows in soft ambient pockets
float accentGlow = smoothstep(-0.25, 0.65, n1) * 0.28;
float goldGlow = smoothstep(-0.15, 0.7, n2) * 0.14;
col = mix(col, uAccent, accentGlow);
col = mix(col, uAccent2, goldGlow);
// Soft vignette
float vignette = smoothstep(1.35, 0.35, length(st));
col *= (0.75 + 0.25 * vignette);
// Grain dither to eliminate banding
float grain = (fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) * (1.2 / 255.0);
col += grain;
gl_FragColor = vec4(col, 0.92);
}
`;
export default function mount(canvas: HTMLCanvasElement, { css }: { css: CSSStyleDeclaration }) {
const stage = createStage(canvas, { dprMax: Math.min(window.devicePixelRatio || 1, 1.5), alpha: true });
const getColor = (token: string, fallback: string) => {
const val = css.getPropertyValue(token).trim();
return new THREE.Color(val || fallback);
};
// Fullscreen Triangle
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3));
const uniforms = {
uTime: { value: 0 },
uScroll: { value: 0 },
uResolution: { value: new THREE.Vector2(1, 1) },
uPointer: { value: new THREE.Vector2(0.5, 0.5) },
uSurface: { value: getColor("--surface-raised", "#121820") },
uRaised: { value: getColor("--surface-sunken", "#080c11") },
uAccent: { value: getColor("--accent", "#3ee5b3") },
uAccent2: { value: new THREE.Color("#d99b38") }, // Warm gold mineral glow
};
const mesh = new THREE.Mesh(
geometry,
new THREE.ShaderMaterial({
vertexShader: VERT,
fragmentShader: FRAG,
uniforms,
depthTest: false,
depthWrite: false,
transparent: true,
})
);
mesh.frustumCulled = false;
stage.scene.add(mesh);
stage.onResize = (w: number, h: number, dpr: number) => {
uniforms.uResolution.value.set(w * dpr, h * dpr);
};
stage.onResize(stage.size.w, stage.size.h, stage.size.dpr);
const targetPointer = new THREE.Vector2(0.5, 0.5);
const onPointerMove = (e: MouseEvent) => {
targetPointer.set(e.clientX / window.innerWidth, 1 - e.clientY / window.innerHeight);
};
window.addEventListener("pointermove", onPointerMove, { passive: true });
stage.add((t: number, dt: number) => {
uniforms.uTime.value = t;
uniforms.uScroll.value = window.scrollY || 0;
// Smooth pointer lerp
uniforms.uPointer.value.lerp(targetPointer, Math.min(1, dt * 2.5));
});
return () => {
window.removeEventListener("pointermove", onPointerMove);
geometry.dispose();
(mesh.material as THREE.Material).dispose();
};
}

133
apps/site/src/gl/stage.ts Normal file
View file

@ -0,0 +1,133 @@
import * as THREE from "three";
export interface StageOptions {
dprMax?: number;
alpha?: boolean;
}
export function createStage(canvas: HTMLCanvasElement, { dprMax = 1.5, alpha = true }: StageOptions = {}) {
const renderer = new THREE.WebGLRenderer({
canvas,
alpha,
antialias: false,
stencil: false,
powerPreference: "high-performance",
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.NoToneMapping;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.z = 5;
const size = { w: 0, h: 0, dpr: 1 };
const updaters = new Set<(t: number, dt: number) => void>();
let running = false;
let last = 0;
const t0 = performance.now();
const draw = (t: number, dt: number) => {
for (const f of updaters) f(t, dt);
renderer.render(scene, camera);
};
const tick = (now: number) => {
const dt = Math.min((now - last) / 1000, 1 / 20);
last = now;
draw((now - t0) / 1000, dt);
};
const start = () => {
if (!running) {
running = true;
last = performance.now();
renderer.setAnimationLoop(tick);
}
};
const stop = () => {
running = false;
renderer.setAnimationLoop(null);
};
function resize() {
const r = canvas.getBoundingClientRect();
const w = Math.max(1, Math.round(r.width));
const h = Math.max(1, Math.round(r.height));
const dpr = Math.min(window.devicePixelRatio || 1, dprMax);
if (w === size.w && h === size.h && dpr === size.dpr) return;
Object.assign(size, { w, h, dpr });
renderer.setPixelRatio(dpr);
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
stage.onResize?.(w, h, dpr);
if (!running) draw(0, 0);
}
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(resize) : null;
ro?.observe(canvas);
const io =
typeof IntersectionObserver !== "undefined"
? new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop()), { rootMargin: "10%" })
: null;
io?.observe(canvas);
const onVis = () => (document.hidden ? stop() : start());
document.addEventListener("visibilitychange", onVis);
const onLost = (e: Event) => {
e.preventDefault();
stop();
};
canvas.addEventListener("webglcontextlost", onLost);
const stage = {
renderer,
scene,
camera,
size,
start,
stop,
onResize: null as ((w: number, h: number, dpr: number) => void) | null,
renderOnce: () => draw(0, 0),
add(f: (t: number, dt: number) => void) {
updaters.add(f);
return () => updaters.delete(f);
},
setDprMax(v: number) {
dprMax = v;
size.dpr = -1;
resize();
},
dispose() {
stop();
ro?.disconnect();
io?.disconnect();
document.removeEventListener("visibilitychange", onVis);
canvas.removeEventListener("webglcontextlost", onLost);
scene.traverse((o: any) => {
o.geometry?.dispose();
const mats = [o.material].flat().filter(Boolean);
for (const m of mats) {
for (const u of Object.values(m.uniforms ?? {}) as any[]) {
if (u?.value?.isTexture) u.value.dispose();
}
m.dispose();
}
});
scene.clear();
renderer.dispose();
renderer.forceContextLoss();
updaters.clear();
},
};
resize();
return stage;
}

File diff suppressed because it is too large Load diff

View file

@ -7,10 +7,21 @@
*::before,
*::after { box-sizing: border-box; }
img,
picture,
video,
canvas,
svg {
max-width: 100%;
height: auto;
display: block;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
background: var(--surface);
overflow-x: hidden;
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }