/* ============================================================================
* TDS Asset (Mobile, 2026-03) ─ Task #158
* ----------------------------------------------------------------------------
* - 루트 namespace `Asset` 글로벌 + Frame 베이스 + 5 Wrapped + 5 Content + frameShape 프리셋 11종
* - Stepper.AssetFrame(#149) 와는 의도적으로 분리: Stepper 안 좌측 마커 전용 vs 화면 어디서나 쓰는 범용 Asset.
* - Toast.Icon(#154) 와도 분리: Toast 내부 24×24 고정 아이콘 vs 자유 크기 Asset.Icon.
* - 모든 색·라운드·간격은 var(--tds-…) 토큰 사용. 하드코딩 없음.
* - window.adaptive (#157 에서 IIFE 로 초기화됨) 를 backgroundColor 인자에 그대로 사용 가능.
* ========================================================================= */
(function ensureAdaptive() {
// AgreementV4(#157) 가 먼저 로드되어 있으면 그대로 사용.
// 단독 데모 등으로 Asset 만 먼저 마운트되는 경우를 대비해 가드.
if (typeof window === "undefined") return;
if (window.adaptive && typeof window.adaptive === "object") return;
var hues = ["red", "orange", "yellow", "green", "teal", "blue", "purple", "grey"];
var steps = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
var out = { white: "#ffffff", black: "#000000", transparent: "transparent" };
hues.forEach(function (h) {
steps.forEach(function (s) { out[h + s] = "var(--tds-" + h + "-" + s + ")"; });
});
window.adaptive = out;
})();
/* ----------------------------------------------------------------------------
* 1. Frame 프리셋 (`Asset.frameShape`)
* - radius 는 number(px) 또는 string('50%' / 'var(--tds-radius-…)')
* - 호출자가 직접 {width,height,radius} 를 줘도 동작 (FrameShapeType)
* ------------------------------------------------------------------------- */
const __ASSET_FRAME_SHAPES = Object.freeze({
// Square — 정사각, 작은 라운드
SquareSmall: Object.freeze({ width: 36, height: 36, radius: 8 }),
SquareMedium: Object.freeze({ width: 48, height: 48, radius: 10 }),
SquareLarge: Object.freeze({ width: 64, height: 64, radius: 14 }),
// Rectangle — 가로 긴 직사각
RectangleMedium: Object.freeze({ width: 64, height: 48, radius: 10 }),
RectangleLarge: Object.freeze({ width: 96, height: 64, radius: 14 }),
// Circle — 정원
CircleSmall: Object.freeze({ width: 36, height: 36, radius: "50%" }),
CircleMedium: Object.freeze({ width: 48, height: 48, radius: "50%" }),
CircleLarge: Object.freeze({ width: 64, height: 64, radius: "50%" }),
// Card — 세로 긴 카드
CardSmall: Object.freeze({ width: 80, height: 96, radius: 12 }),
CardMedium: Object.freeze({ width: 104, height: 120, radius: 14 }),
CardLarge: Object.freeze({ width: 128, height: 144, radius: 16 }),
});
const __ASSET_ACC_POSITIONS = ["top-left", "top-right", "bottom-left", "bottom-right"];
const __ASSET_ACC_MASKINGS = ["circle", "none"];
const __ASSET_SCALE_TYPES = ["fit", "crop"];
/* ------- helpers ---------------------------------------------------------- */
function __assetSizeToCss(v) {
// number → "Npx", string → 그대로, 그 외 → undefined
if (typeof v === "number" && Number.isFinite(v)) return v + "px";
if (typeof v === "string" && v.length > 0) return v;
return undefined;
}
function __assetClampScale(v) {
if (typeof v !== "number" || !Number.isFinite(v)) return 1;
if (v <= 0) return 1;
if (v > 1) return 1;
return v;
}
function __assetWarn(msg) {
if (typeof console !== "undefined" && console.warn) console.warn("[Asset] " + msg);
}
function __assetEnum(value, allowed, fallback, propName) {
if (allowed.indexOf(value) === -1) {
__assetWarn(propName + ' 는 ' + JSON.stringify(allowed) + " 중 하나여야 해요. 받은 값: " +
JSON.stringify(value) + " — '" + fallback + "' 로 폴백.");
return fallback;
}
return value;
}
/* ============================================================================
* 2. Asset.Frame ─ 베이스 컨테이너
* props: content*, shape*, backgroundColor, color, acc, accPosition, accMasking, overlap
* ========================================================================= */
function AssetFrame(props) {
const {
content,
shape,
backgroundColor,
color,
acc,
accPosition = "bottom-right",
accMasking = "none",
overlap,
id,
className,
style,
...rest
} = props || {};
if (content == null) __assetWarn("Asset.Frame 에 content 가 없어요. 빈 프레임이 그려져요.");
if (shape == null || typeof shape !== "object") __assetWarn("Asset.Frame 에 shape 가 없어요. width·height 가 자동 부여되지 않아 크기가 0 일 수 있어요.");
const safeShape = (shape && typeof shape === "object") ? shape : {};
const accPos = __assetEnum(accPosition, __ASSET_ACC_POSITIONS, "bottom-right", "accPosition");
const accMask = __assetEnum(accMasking, __ASSET_ACC_MASKINGS, "none", "accMasking");
// 인라인 style 매핑 (CSS 변수로 흘려서 ::before/::after 도 사용)
const cssVars = {};
const w = __assetSizeToCss(safeShape.width);
const h = __assetSizeToCss(safeShape.height);
const r = __assetSizeToCss(safeShape.radius);
if (w) cssVars["--tds-asset-w"] = w;
if (h) cssVars["--tds-asset-h"] = h;
if (r) cssVars["--tds-asset-r"] = r;
if (backgroundColor) cssVars["--tds-asset-bg"] = backgroundColor;
if (color) cssVars["--tds-asset-color"] = color;
// 액세서리 사이즈/위치 (acc.width/height/x/y 모두 옵셔널)
if (acc != null) {
var accCfg = (safeShape && safeShape.acc && typeof safeShape.acc === "object") ? safeShape.acc : {};
var aw = __assetSizeToCss(accCfg.width);
var ah = __assetSizeToCss(accCfg.height);
var ax = __assetSizeToCss(accCfg.x);
var ay = __assetSizeToCss(accCfg.y);
if (aw) cssVars["--tds-asset-acc-w"] = aw;
if (ah) cssVars["--tds-asset-acc-h"] = ah;
if (ax) cssVars["--tds-asset-acc-x"] = ax;
if (ay) cssVars["--tds-asset-acc-y"] = ay;
}
// overlap — 배경에 겹침 효과 (border 색 = overlap.color, ::before pseudo 로 표현)
// FrameOverlapShapeType 의 x/y/blur/spread 는 shape.overlap 에서 옴 (옵셔널).
if (overlap && typeof overlap === "object" && overlap.color) {
cssVars["--tds-asset-overlap-color"] = overlap.color;
var ovlCfg = (safeShape && safeShape.overlap && typeof safeShape.overlap === "object") ? safeShape.overlap : {};
var ox = __assetSizeToCss(ovlCfg.x) || "6px";
var oy = __assetSizeToCss(ovlCfg.y) || "6px";
var ob = __assetSizeToCss(ovlCfg.blur) || "0px";
var os = __assetSizeToCss(ovlCfg.spread) || "0px";
cssVars["--tds-asset-overlap-x"] = ox;
cssVars["--tds-asset-overlap-y"] = oy;
cssVars["--tds-asset-overlap-blur"] = ob;
cssVars["--tds-asset-overlap-spread"] = os;
}
const classes = [
"tds-asset-frame",
overlap && overlap.color ? "tds-asset-frame--has-overlap" : "",
acc != null ? "tds-asset-frame--has-acc" : "",
acc != null ? "tds-asset-frame--acc-" + accPos : "",
acc != null && accMask === "circle" ? "tds-asset-frame--acc-mask-circle" : "",
className || "",
].filter(Boolean).join(" ");
return (
{content}
{acc != null ?
{acc}
: null}
);
}
/* ============================================================================
* 3. Content 컴포넌트 5종
* ========================================================================= */
/* ---- ContentImage -------------------------------------------------------- */
function AssetContentImage(props) {
const { src, alt, scaleType = "fit", scale, style, className, ...rest } = props || {};
if (!src) __assetWarn("Asset.ContentImage 에 src 가 없어요.");
const t = __assetEnum(scaleType, __ASSET_SCALE_TYPES, "fit", "scaleType");
const s = scale != null ? __assetClampScale(scale) : 1;
const inlineStyle = Object.assign(
{
objectFit: t === "crop" ? "cover" : "contain",
width: s === 1 ? "100%" : (s * 100) + "%",
height: s === 1 ? "100%" : (s * 100) + "%",
},
style || {}
);
return (
);
}
/* ---- ContentIcon --------------------------------------------------------- */
function AssetContentIcon(props) {
const { name, color, size, style, className, ...rest } = props || {};
if (!name) __assetWarn("Asset.ContentIcon 에 name 이 없어요.");
// window.IOSIcon 가 있으면 그쪽으로 위임 (우리 앱 표준 아이콘 라이브러리)
const IOSIcon = (typeof window !== "undefined") ? window.IOSIcon : null;
const inlineStyle = Object.assign(
color ? { color: color } : {},
style || {}
);
if (IOSIcon) {
return (
);
}
// 폴백: 단순 동그라미 (IOSIcon 미로드)
return (
);
}
/* ---- ContentLottie ------------------------------------------------------- */
function AssetContentLottie(props) {
const { src, scaleType = "fit", loop = true, autoplay = true, style, className, ...rest } = props || {};
if (!src) __assetWarn("Asset.ContentLottie 에 src 가 없어요.");
const t = __assetEnum(scaleType, __ASSET_SCALE_TYPES, "fit", "scaleType");
const ref = React.useRef(null);
React.useEffect(function () {
if (!ref.current || !src) return undefined;
const lottie = (typeof window !== "undefined") ? window.lottie : null;
if (!lottie || typeof lottie.loadAnimation !== "function") return undefined;
let anim = null;
try {
anim = lottie.loadAnimation({
container: ref.current,
renderer: "svg",
loop: loop,
autoplay: autoplay,
path: src,
});
} catch (e) { __assetWarn("lottie loadAnimation 실패: " + (e && e.message ? e.message : e)); }
return function cleanup() {
try { if (anim) anim.destroy(); } catch (_) {}
};
}, [src, loop, autoplay]);
// Lottie 미설치 폴백: src 가 .json 이면 빈 회색 박스 + 경고, .gif/.png 등이면 img 로 표시
const isJson = typeof src === "string" && /\.json($|\?)/i.test(src);
const fallbackImg = !isJson && src;
const lottieStyle = Object.assign(
{
width: "100%",
height: "100%",
objectFit: t === "crop" ? "cover" : "contain",
},
style || {}
);
return (
{/* lottie 가 마운트되면 SVG 가 ref 안에 채워짐. 미로드 + 이미지 src 면 폴백 이미지. */}
{fallbackImg
?

: null}
);
}
/* ---- ContentText --------------------------------------------------------- */
function AssetContentText(props) {
const { children, style, className, ...rest } = props || {};
return (
{children}
);
}
/* ---- ContentVideo -------------------------------------------------------- */
function AssetContentVideo(props) {
const {
src,
autoPlay = true,
loop = true,
muted = true,
controls = false,
playsInline = true,
scaleType = "fit",
poster,
style,
className,
...rest
} = props || {};
if (!src) __assetWarn("Asset.ContentVideo 에 src 가 없어요.");
const t = __assetEnum(scaleType, __ASSET_SCALE_TYPES, "fit", "scaleType");
const inlineStyle = Object.assign(
{
width: "100%",
height: "100%",
objectFit: t === "crop" ? "cover" : "contain",
background: "transparent",
},
style || {}
);
return (
);
}
/* ============================================================================
* 4. Wrapped 컴포넌트 5종 — Frame + Content 조합
* (스펙: Frame.shape → frameShape 로 prop 명 변경)
* ========================================================================= */
function __assetSplitFrameProps(p) {
// frameShape 는 string(프리셋 키) 또는 객체 둘 다 받음
let shape = p.frameShape;
if (typeof shape === "string") {
if (__ASSET_FRAME_SHAPES[shape]) shape = __ASSET_FRAME_SHAPES[shape];
else { __assetWarn("frameShape 프리셋 키 '" + shape + "' 를 찾을 수 없어요. 객체로 전달하세요."); shape = undefined; }
}
return {
frameProps: {
shape: shape,
backgroundColor: p.backgroundColor,
color: p.color,
acc: p.acc,
accPosition: p.accPosition,
accMasking: p.accMasking,
overlap: p.overlap,
id: p.id,
className: p.className,
style: p.style,
},
rest: p,
};
}
function __assetStripFrameKeys(p) {
const omit = ["frameShape", "backgroundColor", "color", "acc", "accPosition",
"accMasking", "overlap", "id", "className", "style"];
const out = {};
Object.keys(p || {}).forEach(function (k) { if (omit.indexOf(k) === -1) out[k] = p[k]; });
return out;
}
/* ---- Asset.Icon ---------------------------------------------------------- */
function AssetIcon(props) {
const { frameProps } = __assetSplitFrameProps(props);
const contentProps = __assetStripFrameKeys(props);
// 아이콘 색은 Frame 의 color 변수를 따라가도록 currentColor 사용
if (props.color && !frameProps.color) frameProps.color = props.color;
return } />;
}
/* ---- Asset.Image --------------------------------------------------------- */
function AssetImage(props) {
const { frameProps } = __assetSplitFrameProps(props);
const contentProps = __assetStripFrameKeys(props);
return } />;
}
/* ---- Asset.Lottie -------------------------------------------------------- */
function AssetLottie(props) {
const { frameProps } = __assetSplitFrameProps(props);
const contentProps = __assetStripFrameKeys(props);
return } />;
}
/* ---- Asset.Text ---------------------------------------------------------- */
function AssetText(props) {
const { frameProps } = __assetSplitFrameProps(props);
const contentProps = __assetStripFrameKeys(props);
return } />;
}
/* ---- Asset.Video --------------------------------------------------------- */
function AssetVideo(props) {
const { frameProps } = __assetSplitFrameProps(props);
const contentProps = __assetStripFrameKeys(props);
return } />;
}
/* ============================================================================
* 5. Asset namespace export
* ========================================================================= */
const Asset = {
// 베이스
Frame: AssetFrame,
// Content primitives (Frame.content 에 직접 넘길 때 사용)
ContentIcon: AssetContentIcon,
ContentImage: AssetContentImage,
ContentLottie: AssetContentLottie,
ContentText: AssetContentText,
ContentVideo: AssetContentVideo,
// Wrapped (Frame + Content 조합)
Icon: AssetIcon,
Image: AssetImage,
Lottie: AssetLottie,
Text: AssetText,
Video: AssetVideo,
// 프리셋
frameShape: __ASSET_FRAME_SHAPES,
};
if (typeof window !== "undefined") {
window.Asset = Asset;
Object.assign(window, { Asset });
}