테마
프론트엔드 가이드 — csr-widget
업무패키지(Business Package, 이하 BP) 화면을 Portal Shell(포탈 셸) 안에 iframe 없이 직접 마운트하는 방식이다. Portal Shell과 React 인스턴스를 공유하지 않으므로 Portal Shell이 어떤 프레임워크·버전을 쓰든 BP의 선택과 무관하다. 다만 SDK의 훅이 React 19를 peer로 요구하므로 React 19를 사용한다.
정본 예제: examples/task-bp/frontend/src/.
제3자 BP가 실제로 사용할 수 있는 화면 방식은 csr-widget 하나다. frontendDeliveryMode의 나머지 세 값이 어떻게 동작하는지는 bp-manifest.md §frontendDeliveryMode를 참고한다.
1. mount 계약
번들은 window.__BP_WIDGETS__[businessPackageId]에 mount 함수를 등록하는 IIFE여야 한다. Portal Shell은 컨테이너 div와 호스트 컨텍스트를 전달하고, 위젯은 거기에 자기 React root를 만든다.
정본: examples/task-bp/frontend/src/index.tsx.
tsx
import type { BpHostContext, BpMountFn } from "@starj-portal/bp-frontend-sdk";
import { createRoot } from "react-dom/client";
import css from "./widget.css"; // esbuild 의 loader: { ".css": "text" } 전제. ?inline 을 붙이지 않는다
const mount: BpMountFn = (container, host) => {
// 같은 container 에 두 번 마운트될 수 있다(Portal Shell 이 재렌더하거나 정리 함수를 사용하지 못한 경우).
// attachShadow 를 무조건 호출하면 두 번째 호출에서 NotSupportedError 가 발생하고 화면이 비어 있는 상태로 남는다.
const shadow = container.shadowRoot ?? container.attachShadow({ mode: "open" });
shadow.replaceChildren(); // 재마운트면 이전 내용을 직접 제거한다
const style = document.createElement("style");
style.textContent = css;
shadow.append(style);
const el = document.createElement("div");
shadow.append(el);
const root = createRoot(el);
root.render(<App host={host} />);
// 정리 함수 반환은 **필수다**(아래 설명)
return () => { root.unmount(); shadow.replaceChildren(); };
};
window.__BP_WIDGETS__ = { ...window.__BP_WIDGETS__, "task-bp": { mount } };전역 키는 bp-manifest.yaml의 id와 정확히 같아야 한다. 다르면 Portal Shell이 위젯을 찾지 못한다.
정리 함수 반환은 선택이 아니다
SDK 타입상 반환값은 선택이지만, Shadow DOM 경계를 만드는 위젯은 반드시 반환해야 한다.
Portal Shell의 대체 정리는 container.innerHTML = ""이고, 그것은 shadow root 안을 제거하지 못한다. 정리 함수를 반환하지 않으면 화면을 벗어나도 React 트리가 계속 살아 있고 이전 화면의 내용도 남는다. 예외가 발생하지 않으므로 오류 메시지 없는 오작동으로 나타난다.
재마운트에 안전해야 한다
container.attachShadow()를 조건 없이 호출하면 같은 컨테이너에 두 번째 마운트가 일어날 때 NotSupportedError: Shadow root cannot be created on a host which already hosts a shadow tree가 발생한다. container.shadowRoot ?? container.attachShadow(...) 형태로 방어하고, 이전 내용을 replaceChildren()으로 제거한다.
같은 페이지에서 번들이 다시 실행될 수 있다 (계약 2.1.0)
BP가 업데이트되면 Portal Shell은 페이지를 새로 읽지 않고 새 번들을 다시 적재해 위젯을 마운트한다(§8-1). 이때 셸은 이전 <script> 요소와 window.__BP_WIDGETS__[id]를 지우지만, 이전 번들이 페이지에 남긴 다른 전역 등록은 지우지 못한다. 새 번들의 최상위 코드는 같은 페이지에서 한 번 더 실행된다.
그래서 번들의 최상위에서 하는 전역 등록은 두 번 실행되어도 안전해야 한다.
ts
// customElements.define 은 같은 이름으로 두 번 부르면 NotSupportedError 를 던지고, 그 뒤의 코드(위젯 등록)가 실행되지 않는다.
if (!customElements.get("task-bp-date-picker")) {
customElements.define("task-bp-date-picker", DatePicker);
}
// 전역 변수는 덮어쓰지 말고 있으면 그대로 쓴다. 이전 번들이 만든 값을 새 번들이 다르게 해석할 수 있으면 이름에 버전을 넣는다.
window.__TASK_BP_CACHE__ ??= new Map();
// 위젯 등록은 지금처럼 덮어쓴다. 셸은 새 번들이 등록한 mount 를 쓴다.
window.__BP_WIDGETS__ = { ...window.__BP_WIDGETS__, "task-bp": { mount } };customElements.define은 반드시customElements.get으로 먼저 확인한다. 이미 등록된 이름은 새 클래스로 바꿀 수 없으므로, 새 번들에서 요소의 동작이 바뀌었다면 요소 이름을 바꾼다.document.head에<style>·<link>를 추가하는 번들은 같은 요소가 두 번 생긴다. 스타일은 Shadow DOM 안에 둔다(§4).window·document에 등록한 이벤트 리스너는mount가 돌려주는 정리 함수에서 제거한다. 최상위에서 등록하면 이전 번들의 리스너가 남는다.
숨겨진 채로 살아 있을 수 있다 — 표시 상태 신호
Portal Shell(포탈 셸)은 사용자가 탭으로 연 화면 중 최근에 본 몇 개를 언마운트하지 않고 숨긴 채 살려 둔다(포탈 FR-UI-23.4). 위젯도 같다. 다른 탭으로 옮겨 가면 위젯 컨테이너가 CSS로만 가려지고, mount가 돌려준 정리 함수는 불리지 않는다. 돌아오면 입력·조회 결과·스크롤이 그대로 남아 있다.
숨은 동안에도 위젯의 타이머와 폴링은 계속 돈다. 보일 때만 필요한 일은 표시 상태 신호를 받아 위젯이 스스로 쉰다.
| 신호 | 형태 |
|---|---|
컨테이너 속성 data-starj-visible | "true" 또는 "false". 속성이 없으면 표시 중으로 본다(이 신호를 보내지 않는 포탈) |
컨테이너 이벤트 starj:visibilitychange | CustomEvent, detail: { visible: boolean }. 바뀔 때마다 온다 |
ts
import { onHostVisibilityChange, isHostVisible } from "@starj-portal/bp-frontend-sdk";
const mount: BpMountFn = (container, host) => {
const poller = startPolling({ paused: !isHostVisible(container) });
const stop = onHostVisibilityChange(container, (visible) => (visible ? poller.resume() : poller.pause()));
// ...
return () => { stop(); poller.stop(); root.unmount(); };
};주의 사항
- 숨은 동안에도 브라우저 주소는 다른 화면으로 바뀐다.
useUrlParam(§9)은 렌더할 때 지금 주소를 읽으므로, 숨은 위젯이 다시 렌더되면 다른 화면의 주소를 읽어 선택 상태가 풀릴 수 있다. 숨은 동안에는 상태를 바꾸는 렌더를 피하고, 보일 때 갱신한다. 포탈의 내장 화면은 이 문제를 셸이 막지만, 위젯 번들 안의 SDK에는 그 장치가 닿지 않는다. - 다시 보일 때 데이터를 새로 읽을지는 위젯이 정한다. 셸은 자동으로 새로고침하지 않는다.
2. 호스트 컨텍스트
ts
interface BpHostContext {
domainId: string; // 필수 — 모든 API 호출에 포함해야 한다
apiBasePath: string; // 이 BP로 향하는 API의 base path (same-origin)
appUri?: string; // 지금 렌더링해야 할 App의 uri
locale?: string; // BCP-47 (예: "ko")
theme?: "light" | "dark";
actions?: BpHostActions; // Portal Shell 오퍼레이션 호출 통로
// ── 계약 1.1.0에서 추가 ──
viewer?: BpViewer; // 지금 이 화면을 조회 중인 사용자 (userNo/username/displayName/email)
canWrite?: boolean; // 이 사용자가 지금 이 App에 쓰기 권한을 갖는가
// ── 계약 1.5.0에서 추가 ──
features?: BpHostFeatures; // 이 배포에 설치된 기능의 확인 — has(feature) 하나. hasHostFeature로 묻는다
}viewer와 canWrite, features는 구버전 Portal Shell을 위해 optional이다. 값이 전달되지 않는 경우를 위한 대체 경로를 함께 준비한다(§3).
API 호출 규칙
ts
import { bpFetch } from "@starj-portal/bp-frontend-sdk";
const res = await bpFetch(host, `${host.apiBasePath}/tasks?domainId=${encodeURIComponent(host.domainId)}`);bpFetch는 fetch와 같지만 BP 업데이트 중 응답을 처리한다(§8-1). 계약 2.1.0부터 쓴다. 아래 두 가지가 계약이다.
경로는
apiBasePath뒤에 연결해 만든다. 값은 결합 방식에 따라 다르고 그 차이는 Portal Shell이 흡수한다 — 직접/bp/...를 하드코딩하면 다른 결합 방식에서 정상 동작하지 않는다. in-process BP에서는 이 값이/bp/{businessPackageId}이며, 백엔드 컨트롤러가 그 접두어를 직접 선언해야 한다(backend-guide.md §3).domainId를 쿼리 파라미터로 반드시 포함한다. 실제로 자주 발생하는 누락이며, 누락은 오류로 나타나지 않는다.transport: "external"BP에서는 Destination 프록시가 이 값을 권한 판정과 신원 전파의 도메인으로 사용하는데, 누락하면 거부되지 않고 호출한 사용자의 소속 도메인으로 대체된다. 그 도메인에서 BP가 활성이면 조회는 그대로 통과하므로 다른 도메인의 데이터를 표시하고 있다는 사실이 화면에 드러나지 않고, 쓰기 요청에서만 뒤늦게 403이 된다. 소속 도메인에서 BP가 비활성이면 요청은 404가 된다. 외부 BP의 iframe 화면도 같은 규칙을 따르며, 셸이domainId를 추가하는 곳은 첫 문서 URL뿐이다. 그 화면이 내는 XHR·fetch·자산 요청에는 BP가 직접domainId를 추가한다(bp-manifest.md §iframe 화면의 하위 요청에는domainId를 추가한다).- csr-widget(in-process) BP에서는 포탈이 이 값을 읽지 않는 대신 BP 자신이 도메인 격리 판정과 권한 질의(
checkAppAccess)에 이 값을 필요로 한다(backend-guide.md §6-4).
어느 쪽이든 포함한다.
인증 토큰은 전달되지 않는다. 호출이 same-origin이라 브라우저 세션 쿠키가 자연히 실려간다. 세션이 만료되면 인증 실패 응답이 반환되므로, 위젯은 그 상태를 화면에 표시하고 사용자에게 재로그인을 안내한다.
App이 여러 개인 BP
번들은 businessPackageId 하나로 등록되므로, App이 둘 이상이면 host.appUri로 화면을 고른다. App이 하나뿐이면 무시해도 된다.
tsx
function App({ host }: { host: BpHostContext }) {
if (host.appUri === "task-bp/detail") return <TaskDetail host={host} />;
return <TaskList host={host} />;
}host.theme과 host.locale은 마운트 시점에 한 번 전달되는 값이다. 마운트 이후 사용자가 테마·로케일을 변경했을 때의 통지 경로가 계약에 없다. CSS 커스텀 프로퍼티로 처리되는 부분은 자동으로 따라가지만(§4), 스크립트에서 테마 값을 사용하는 코드(차트 색 등)는 직접 감시해야 한다 — 판정 방법은 §5에 있다. theme은 사용자가 "시스템 설정 따름"을 선택한 경우 전달되지 않는다.
3. 사용자와 권한 — viewer / canWrite
계약 1.1.0에서 두 값이 추가되었다. 둘 다 표시 판단을 위한 값이며 보안 경계가 아니다.
| 값 | 무엇인가 | 무엇이 아닌가 |
|---|---|---|
viewer | 지금 이 화면을 조회 중인 사용자 — userNo / username / displayName / email | 권한 판정의 근거가 아니다. 무엇을 할 수 있는지는 canWrite와, 최종적으로는 서버의 checkAppAccess가 정한다 |
canWrite | 이 사용자가 지금 이 App에 쓰기 권한을 갖는가 | 서버 판정의 대체물이 아니다. 서버는 여전히 모든 쓰기 요청에서 스스로 판정한다 |
1.0.0에는 이 두 값이 없어서, 사용자를 아는 화면("내 신청 목록" 등)은 첫 렌더 전에 백엔드를 한 번 왕복해야 했고, 읽기 전용 사용자에게도 저장 버튼을 일단 표시한 뒤 서버가 403을 반환하면 오류를 표시하는 방식밖에 없었다. 조작할 수 없는 버튼을 표시하는 것은 화면의 결함이다.
displayName을 위젯이 조합하지 않는다. Portal Shell이 로케일에 맞춰 조합한 값을 그대로 표시한다.- 두 값 모두 optional이므로 없는 경우를 처리한다.
viewer가 없으면 BP 자신의 API가 사용자 정보를 반환하게 하고,canWrite가 없으면 서버 응답에 쓰기 가능 여부를 함께 담아 사용한다(checkAppAccess(req, domainId, appId, "update")결과를 응답에 포함하는 방식).
설치된 기능에 따라 버튼을 감춘다 — features (계약 1.5.0)
스프레드시트 내보내기처럼 포탈에 설치된 기능이 있어야 되는 조작은, 그 기능이 없는 배포에서 버튼을 그리지 않는다. 버튼을 그려 놓고 눌렀을 때 서버가 501로 거부하는 화면은 결함이다. 묻는 방법은 hasHostFeature 하나다.
tsx
import { BP_FEATURE_KEYS, hasHostFeature } from "@starj-portal/bp-frontend-sdk";
function ExportButton({ host }: { host: BpHostContext }) {
// 렌더링할 때 묻는다.
if (!hasHostFeature(host, BP_FEATURE_KEYS.EXPORT_WORKBOOK)) return null;
const onClick = async () => {
// 조작할 때 다시 묻는다.
if (!hasHostFeature(host, BP_FEATURE_KEYS.EXPORT_WORKBOOK)) return;
// ... GET /tasks/export 를 부르고, 501·503을 처리한다
};
return <button type="button" onClick={onClick}>내보내기</button>;
}| 규정 | 내용 |
|---|---|
| 묻는 방법 | hasHostFeature(host, feature). host.features가 없으면(구버전 셸) false다. 식별자는 BP_FEATURE_KEYS(백엔드 SDK와 같은 값)를 쓴다 |
| 답이 바뀌는 시점 | 셸은 기동 직후 설치 목록을 모를 수 있다. 그동안 답은 false이고, 셸이 목록을 알게 되면 다음 호출부터 true가 된다. 위젯을 다시 마운트하지 않는다. 그래서 답을 useState나 변수에 저장해 두지 않고, 렌더링할 때와 조작할 때마다 부른다. 목록 변화를 알리는 이벤트는 없으므로, 목록이 늦게 알려지면 버튼은 위젯이 다음에 다시 렌더링될 때(목록을 불러온 뒤 등) 나타난다 |
| 알려 주지 않는 것 | 설치된 기능의 목록 전체와 기능 패키지의 이름·버전. 하나씩 묻는 것만 된다 |
| 권한과의 관계 | 권한 판정의 근거가 아니다. 버튼을 감추는 데만 쓴다. 서버는 기능이 없으면 스스로 거부하므로, 버튼이 보였더라도 501(errorCode: "CAPABILITY_NOT_INSTALLED")과 503(다시 시도) 응답을 처리한다 |
canWrite와의 관계 | 서로 다른 판단이다. 내보내기는 읽기이므로 canWrite가 false여도 표시된다. 쓰기 조작이면 둘 다 확인한다 |
| iframe 화면 | transport: "external" BP의 iframe 화면에는 호스트 컨텍스트가 없으므로 이 확인을 쓸 수 없다 |
정본 예제 examples/task-bp/frontend/src/TaskList.tsx의 내보내기 버튼이 이 형태이고, scripts/check-widget.mjs가 기능이 있는 셸·없는 셸·구버전 셸에서 버튼의 유무와 눌렀을 때의 내려받기·501·503 처리를 확인한다. 서버 쪽은 backend-guide.md §22다.
여전히 호스트 컨텍스트에 없는 것
- App 단위보다 세밀한 권한.
canWrite는 그 App 전체에 대한 판정 하나이며, 화면 안의 항목별 권한은 BP가 자기 API로 판정해 반환한다. - 다른 사용자의 정보.
viewer는 조회 중인 본인뿐이다. 백엔드의host.users도 단건 조회만 제공한다(backend-guide.md §15). - 테마·로케일 변경 통지. 두 값은 마운트 시점에 한 번만 전달된다(§2).
4. 스타일 격리 — Shadow DOM 경계는 규정이다
iframe을 사용하지 않아 얻은 것(하나의 문서, 하나의 스크롤, 테마 자동 전파)의 대가는 Portal Shell과 위젯이 하나의 CSS 캐스케이드를 공유한다는 것이다. 위젯이 Portal Shell과 같은 유틸리티 프레임워크(Tailwind 등)를 쓰면 동명 클래스가 같은 레이어에 두 번 선언되고 나중에 삽입된 쪽이 우선한다 — 실제로 위젯의 .hidden이 Portal Shell 상단 내비게이션의 md:block보다 우선 적용되어 BP 화면을 열 때마다 Portal Shell의 상단 내비게이션 전체가 표시되지 않았다.
경계를 만드는 책임은 위젯에 있다. Portal Shell은 평범한 div를 전달하며 attachShadow를 호출하지 않는다.
| 규칙 | 이유 |
|---|---|
container.shadowRoot ?? container.attachShadow()로 경계를 만들고 CSS를 그 안에만 삽입한다 | 규칙이 경계에서 차단된다 |
| Portal Shell의 테마 토큰은 재정의하지 않는다 | CSS 커스텀 프로퍼티는 shadow 경계를 넘어 상속된다 — 라이트/다크 전환이 위젯 안에서도 그대로 동작한다(§5) |
| 모달·드롭다운도 shadow 안에서 렌더링한다 | document.body에 포탈을 만들면 그 노드는 경계 밖이라 스타일이 적용되지 않는다 |
| 리셋(preflight)을 포함한다면 아래 규칙을 레이어 밖에 둔다 | preflight의 html, :host 규칙이 :host에 서체와 행간을 직접 대입해 Portal Shell 서체 상속을 차단한다 |
css
:host { display: block; font-family: inherit; font-size: inherit; line-height: inherit; color: inherit; }접두사 없는 평문 CSS만 쓰는 위젯은 지금 당장은 충돌이 없다. 그러나 경계 생성은 규정으로 취급한다 — UI 라이브러리를 도입하는 시점에 위 사고가 재발하며, 그때는 원인이 위젯 밖에서 관측된다.
mode: "closed" shadow root를 만들면 토큰 상속이 차단된다. mode: "open"을 사용한다.
5. 테마 토큰
Portal Shell의 테마 토큰은 평범한 CSS 커스텀 프로퍼티이며, shadow 경계를 넘어 상속되기 때문에 위젯이 var(--color-text) 같은 형태로 그대로 사용할 수 있다. Portal Shell은 이 값을 컨테이너 엘리먼트에 복사하거나 인라인 스타일로 전달하지 않는다 — 상속이 유일한 전달 경로다.
아래 목록에 없는 이름은 보장되지 않는다. 이름이 틀리면 오류 없이 항상 대체값이 적용되므로, 다크 모드에서 어두운 배경 위에 어두운 글자가 표시되는 형태로 나타난다.
색 (--color-*)
--color-bg --color-surface --color-surface-muted --color-border
--color-text --color-text-muted
--color-brand --color-brand-foreground
--color-success --color-warning --color-danger
--color-nav-active-bg --color-nav-active-fg --color-nav-bg --color-nav-border
--color-nav-group --color-nav-hover-bg --color-nav-section-line
--color-shell-active --color-shell-bg --color-shell-border --color-shell-fg
--color-shell-field-bg --color-shell-field-border --color-shell-hover--color-nav-*와 --color-shell-*는 Portal Shell 자신의 내비게이션·상단 영역을 위한 값이다. 위젯 본문에는 앞의 여섯 개(bg / surface / surface-muted / border / text / text-muted)와 brand 계열, 상태 색 세 개를 사용한다.
그 밖에 위젯이 사용할 만한 것
| 토큰 | 용도 |
|---|---|
--radius | 기본 모서리 반경 |
--shadow-card | 카드 그림자 |
--font-sans | 기본 서체 |
--density-control-h · --density-control-px · --density-control-py · --density-control-text | 입력·버튼의 높이·여백·글자 크기(밀도 설정을 따라간다) |
--chart-cat-1~4 · --chart-ord-1~4 · --chart-grid | 차트 색과 격자선 |
--button-ghost-border | 테두리 없는 버튼(고스트·아이콘 버튼)의 평소 테두리색. 정밀 포인터·640px 이상에서만 transparent다 |
--density-icon-button | 아이콘 버튼 정사각형의 한 변(1.75rem, Mobile 폭 2.5rem) |
테두리 없는 버튼은 토큰으로 처리한다
포탈 화면의 고스트·아이콘 버튼은 정밀 포인터 환경에서 평소 테두리를 숨기고, 터치 환경과 Mobile 폭에서는 항상 테두리를 표시한다. 위젯은 이 조건을 @media (hover: hover) and (pointer: fine) 같은 미디어 쿼리로 복사하지 않는다. --button-ghost-border 값이 이미 그 조건에 따라 바뀌므로 아래처럼 참조만 한다.
css
.xx-ghost, .xx-icon-button { border: 1px solid var(--button-ghost-border, var(--xx-border)); }
.xx-ghost:hover, .xx-ghost:focus-visible,
.xx-icon-button:hover, .xx-icon-button:focus-visible { border-color: var(--xx-border); }
.xx-icon-button { width: var(--density-icon-button, 1.75rem); height: var(--density-icon-button, 1.75rem); }대체값은 필수다. 이 두 토큰은 공개 선언된 호스트 표면이며, 대체값·iframe BP·탭 목록 예외 규칙은 host-contract.md §7에 있다.
다크에서 실제로 바뀌는 것은 15개뿐이다
--color-bg --color-surface --color-surface-muted --color-border
--color-text --color-text-muted
--chart-cat-1~4 --chart-ord-1~4 --chart-grid--color-brand·--color-success·--color-warning·--color-danger·--radius·--shadow-card·--font-sans와 모든 --nav-*·--density-*에는 다크 오버라이드가 없다. 라이트 값이 그대로 사용된다. 다크에서 대비가 필요한 색이 있다면 위 6개 토큰 위에 얹어 설계한다.
라이트/다크를 스크립트로 판정하는 방법
CSS만으로 처리되는 부분은 토큰 상속으로 자동 해결된다. 스크립트에서 현재 모드를 알아야 한다면 두 선택자를 확인한다.
js
const root = document.documentElement;
const isDark =
root.classList.contains("dark") ||
(!root.classList.contains("light") && window.matchMedia("(prefers-color-scheme: dark)").matches);:root.dark— 사용자가 수동으로 다크를 선택한 상태@media (prefers-color-scheme: dark)+:root:not(.light)— 운영체제 설정을 따르는 상태
[data-theme] 속성을 라이트/다크 판정에 사용하면 틀린다. 그것은 디자인 세트를 구분하는 축(default / fiori / console)이며 명암 모드와 무관하다.
토큰이 전달되지 않는 경우
frontendDeliveryMode: "iframe"으로 렌더링되는 외부 BP 화면은 CSS 커스텀 프로퍼티가 문서 경계를 넘지 못하므로 Portal Shell 토큰을 전혀 받지 못한다. 그런 BP는 자기 색 체계를 직접 정의한다. --button-ghost-border도 받지 못하므로 대체값(항상 표시되는 일반 테두리색)이 실제 값이 된다.
6. Portal Shell 오퍼레이션 호출 — host.actions
actions를 제외한 컨텍스트의 모든 값은 읽기 전용이다. Portal Shell의 상태를 변경하는 유일한 통로가 actions다.
ts
if (host.actions) {
const on = await host.actions.isCardOnHome("task-bp:open-count");
await host.actions.addCardToHome("task-bp:open-count");
await host.actions.removeCardFromHome("task-bp:open-count");
}카드 배치 3종의 규칙
- 작용 범위는 항상 현재 로그인 사용자·현재 도메인이다 — 다른 사용자를 지정하는 파라미터 자체가 없다.
- 대상은 그 BP가 소유한 카드로 제한된다.
cardId가{businessPackageId}:로 시작하지 않으면 세 함수 모두 예외를 발생시킨다. 카드 id 명명 규약은 backend-guide.md §5-5에 있다. cardId는cards[].id와 완전히 같은 값이다.- 위젯이 기억해 둔 상태를 사용하지 말고 매번
isCardOnHome으로 질의한다. 다른 기기·다른 탭에서 이미 변경되었을 수 있다. 다만 이 호출은 매번 서버에 질의하므로 연속 호출을 피한다. - 한 사용자의 홈에 배치할 수 있는 카드는 최대 24개다. 초과하면 추가가 거부된다.
- 구버전 Portal Shell을 위해 optional이므로 존재 확인이 필요하다.
계약 1.1.0에서 추가된 다섯 가지
Portal Shell이 이미 보유한 오퍼레이션을 위젯이 다시 구현하지 않게 한다. 위젯이 자체 토스트·확인 대화상자를 만들면 Shadow DOM 경계 안에 갇혀 Portal Shell의 레이어 위로 올라가지 못하고, 스크롤 컨테이너 안에서 잘리거나 포탈의 다른 대화상자와 겹친다. 화면 이동도 마찬가지다 — 위젯이 location.href를 직접 변경하면 Portal Shell 전체가 다시 적재되어 사용자 조작 문맥이 사라진다.
| 오퍼레이션 | 용도와 유의사항 |
|---|---|
toast({ tone, text }) | 조작 결과를 알린다. tone은 success / info / warning / error. Portal Shell의 메시지 영역에 표시되며 위젯의 Shadow DOM 밖에 렌더링된다 |
confirm({ title, text, confirmLabel? }) | 되돌릴 수 없는 조작 전의 확인. 사용자가 승인하면 true를 반환한다. window.confirm을 사용하지 않는다 — 포탈 디자인과 이질적이고 일부 환경에서 차단된다 |
navigateToApp(appUri, params?) | 같은 포탈의 다른 App으로 이동한다(§7) |
reportError({ message, stack?, context? }) | 위젯 오류를 포탈 로그 체계로 전송한다(§8). 사용자에게 표시되는 것은 없다 — 표시는 toast가 담당한다 |
uploadFile(file, options?) | 파일을 포탈 저장소에 업로드하고 식별자를 반환한다. 아래 "파일 업로드" 참고 |
다섯 가지 모두 optional이다. 구버전 Portal Shell에서는 전달되지 않으므로 존재를 확인하고 호출하며, 없을 때의 대체 동작(인라인 메시지 등)을 준비한다.
계약 2.1.0에서 추가된 reportUpdating
reportUpdating({ retryAfterSeconds? })는 BP 서버가 업데이트 중이라 요청이 거부되었음을 Portal Shell에 알린다. 위젯 코드에서 직접 부르지 않는다. bpFetch나 reportBpUpdating이 응답을 판별해 대신 부른다(§8-1). optional이며 2.0.0 셸에는 없다.
파일 업로드 — uploadFile
첨부 파일은 위젯이 포탈에 직접 올리고, BP 백엔드는 그 식별자만 전달받는다. 큰 바이너리가 BP 컨트롤러를 경유하지 않게 하기 위한 구조다.
ts
const uploaded = await host.actions!.uploadFile(file, {
onProgress: (loadedBytes, totalBytes) => setPercent(Math.round((loadedBytes / totalBytes) * 100)),
});
// uploaded: { fileUuid, fileSeq, originName, mime, size }
await fetch(`${host.apiBasePath}/receipts?domainId=${encodeURIComponent(host.domainId)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fileUuid: uploaded.fileUuid }), // 파일 내용이 아니라 식별자만 전송한다
});위젯이 업로드 경로를 알지 않는 것이 이 오퍼레이션의 요점이다. 포탈 업로드 엔드포인트의 주소·요청 형식·용량 상한은 포탈의 담당 범위이며 변경될 수 있다. 위젯이 그 경로를 직접 호출하면 포탈이 그것을 변경할 수 없게 된다.
흐름은 네 단계로 고정되어 있다.
- 위젯이
uploadFile()로 파일을 업로드하고fileUuid를 받는다. - 위젯은
fileUuid만 자기 BP API로 전송한다. - BP 백엔드가
host.files.stat()으로 메타데이터를 확인하고host.files.claim()으로 소유권을 확보한다(backend-guide.md §19). - 이후 열람은 BP가 권한을 판정한 뒤
host.files.read()로 내용을 반환하는 BP 자신의 엔드포인트를 통한다.
반드시 확인할 유의사항 다섯 가지.
mime과size는 브라우저가 선언한 값이다. Portal Shell이 업로드에 사용한File객체에서 채운 표시용 값이며 서버가 검증한 값이 아니다. 용량 판정은 서버에서host.files.stat()의fileSize로 수행한다 — 형식은 서버 쪽 값도 브라우저 선언값이므로 그것으로도 판정할 수 없다(backend-guide.md §19).fileUuid를 화면·URL·로그에 노출하지 않는다. claim이 끝나기 전까지 그 값을 아는 사용자는 파일을 내려받을 수 있다.- 한 번에 한 파일이다. 반환값은 파일 하나를 가리키므로 여러 개를 첨부하려면 파일마다 호출한다.
- 거부되면 예외가 발생한다. 용량 초과와 허용되지 않는 형식이 이에 해당하며, 예외 메시지는 사용자에게 그대로 표시해도 되는 문장이다. 다만 용량 상한과 허용 형식은 계약에 노출되지 않는다 — 포탈 저장소 대상의 설정값이라 설치마다 다르다. 위젯이 상한을 가정하지 말고 예외 메시지를 표시한다.
- claim 이전의 파일은 그 포탈에 로그인한 사용자면 누구나 내려받을 수 있다. 포탈의 범용 파일 경로는 claim된 파일만 거부하기 때문이다. 업로드 직후 BP API 호출까지 이어 claim을 마치고, 사용자가 중간에 화면을 벗어난 경우의 정리 방법을 BP가 준비한다.
- optional이므로 없는 경우를 처리한다. 구버전 Portal Shell에서는 전달되지 않으므로, 첨부 기능을 사용할 수 없다는 안내를 표시하는 대체 경로를 준비한다.
파일 내려받기 — 계약에 오퍼레이션이 없다
uploadFile과 짝이 되는 내려받기 오퍼레이션은 계약에 없다. 위젯이 직접 처리한다.
csr-widget은 iframe이 아니라 Portal Shell과 같은 문서에 마운트되고(Shadow DOM은 스타일 격리에만 사용된다 — §4), Portal Shell은 Content-Security-Policy 헤더를 설정하지 않는다. 따라서 위젯이 생성한 Blob을 URL.createObjectURL과 <a download>로 저장하는 방식이 동작한다.
ts
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
const a = document.createElement("a");
a.href = url;
a.download = "expenses.csv";
a.click();
URL.revokeObjectURL(url);- BP 서버가 파일을 반환하는 경우에도 같은 방식이다. 위젯이 BP 엔드포인트의 응답을 Blob으로 받아 저장한다. 스프레드시트 파일은 BP 서버가
host.workbook.write로 만들어 내려보낸다(계약 1.5.0, backend-guide.md §22-1). 위젯에 Excel 라이브러리를 넣지 않는다. 포탈의 범용 파일 경로(/files/…)는 claim된 파일을 거부하므로 그 경로로 연결하지 않는다. - 운영 환경의 리버스 프록시가 Content-Security-Policy를 추가하면 차단될 수 있다. 포탈 자신은 그 헤더를 설정하지 않지만 설치 환경은 다를 수 있으므로, 내보내기 기능은 설치 후 확인 항목에 포함하고 화면 안 표 표시 같은 대체 경로를 함께 준비한다.
7. App 경계 URL과 화면 이동
App 경계 URL의 형식은 이렇다.
/apps/{domainId}/{appUri}- App은
appId가 아니라uri로 주소지정된다.appId도businessPackageId도 URL에 등장하지 않는다. appUri는 슬래시를 포함할 수 있으며{businessPackageId}/접두어로 시작해야 한다. 관례가 아니라 요구사항이다 — Portal Shell이 App 경계 URL에서 이 접두어를 제거해 하위 경로를 재조립하므로, 접두어가 다르면 그 재조립이 어긋난다.
다른 App으로 이동한다 — navigateToApp
계약 1.1.0에서 이동 수단이 열렸다.
ts
host.actions?.navigateToApp("hr/employees", { id: "1024" });appUri는 매니페스트apps[].uri값이다 — 도메인도 앞 슬래시도 붙이지 않는다. 위젯이 App 경계 URL을 조립하지 않는 것이 요점이다(그 형식은 Portal Shell의 담당 범위다).- 다른 BP의 App으로도 이동할 수 있다. 대상 App을 조회할 권한이 없으면 Portal Shell이 그에 맞게 처리한다.
params는 대상 App의 쿼리 문자열로 전달된다.- optional이다. 구버전 Portal Shell에서는 전달되지 않으므로 존재를 확인한다.
window.location.assign()으로 App 경계 URL을 직접 여는 것도 여전히 동작하지만, 그 대가는 전체 문서 재적재다 — Portal Shell의 상단·좌측 내비게이션이 재마운트되고 사용자가 그 지연을 인지한다. navigateToApp이 있으면 그것을 사용한다.
useUrlParam 계열은 자기 App 안의 쿼리 파라미터 상태를 보존하는 용도이며 화면 이동 수단이 아니다.
설계 지침은 변하지 않는다 — App은 권한을 다르게 부여해야 하는 단위로만 나누고, 한 App 안의 하위 화면 전환은 URL 파라미터로 표현한다(§9). App을 화면 수만큼 만들면 이동할 때마다 권한 판정과 화면 재적재가 발생한다.
8. 위젯의 오류 처리
위젯은 자체 에러 경계(React error boundary)를 둔다. Portal Shell은 위젯 내부의 렌더링 예외를 대신 처리하지 않는다.
- 렌더링 중 예외가 발생해도 위젯 영역 안에서 오류 상태를 표시하고, 사용자에게 다시 시도할 수단을 제공한다.
- 그 에러 경계에서
host.actions?.reportError(...)를 호출한다. 계약 1.1.0에서 열린 통로이며, 위젯 오류가 포탈 로그 체계로 전송된다. 1.0.0에는 이 통로가 없어 운영 중 위젯 오류를 수집할 방법이 사용자의 브라우저 콘솔뿐이었다. reportError는 사용자에게 아무것도 표시하지 않는다. 사용자에게 알리는 것은toast이거나 위젯 자신의 오류 화면이다.- optional이므로 없는 경우를 처리한다. 구버전 Portal Shell에서는
console.error가 유일한 수집 경로다. - 제출에는 억제와 절단이 적용된다. 사용자와 BP 조합마다 10초당 20건까지만 수집되며, 렌더 루프가 오류를 반복 발생시키는 상황에서 그 이후의 제출은 유실된다.
message는 2,000자,stack은 8,000자,context는 직렬화 4,000자에서 잘린다 — 원인 판정에 필요한 값을context깊은 곳에 두지 말고 앞쪽에 짧게 넣는다. 적재된 BP의 id가 아닌 값으로 제출하면 400으로 거부된다.
BpUpdatingError는 이 규칙의 예외다. 오류 상태나 재시도 수단을 표시하지 않고 reportError로 보내지도 않는다(§8-1).
번들 적재 자체가 실패하는 경우 Portal Shell은 오류 화면을 표시하지 않는다. 화면 영역이 비어 있는 상태로 남고 브라우저 콘솔에 처리되지 않은 promise 거부만 기록된다. 이 상태의 원인 대부분은 위젯 코드가 아니라 산출물 배치다 — troubleshooting.md 참고.
8-1. 업데이트 중 응답 — bpFetch (계약 2.1.0)
운영자가 이미 사용 중인 BP를 새 버전으로 바꾸면, 포탈은 마이그레이션을 적용하고 새 서버 코드가 모든 포탈 인스턴스에 적재될 때까지 그 BP의 요청만 거부한다. 거부 응답은 503이고 x-portal-bp-updating 헤더와 Retry-After가 있다(형식은 host-contract.md §4). 이 동안 Portal Shell은 위젯 위치에 "업데이트 중입니다. 잠시 후 다시 접속하세요."를 표시하고, 업데이트가 끝나면 새 번들로 위젯을 다시 마운트한다.
이 응답은 오류가 아니다. 위젯이 할 일은 셸에 알리고 멈추는 것뿐이다. 정본: examples/task-bp/frontend/src/api.ts(bpFetch와 isUpdating), 각 화면의 catch(TaskList.tsx·TaskDetail.tsx), notify.ts의 report.
ts
import { BpUpdatingError, bpFetch } from "@starj-portal/bp-frontend-sdk";
export async function listTasks(host: BpHostContext): Promise<Task[]> {
const res = await bpFetch(host, `${host.apiBasePath}/tasks?domainId=${encodeURIComponent(host.domainId)}`);
if (!res.ok) throw new Error(`목록 조회 실패: ${res.status}`);
return res.json();
}
// 호출하는 쪽
try {
setTasks(await listTasks(host));
} catch (e) {
if (e instanceof BpUpdatingError) return; // 셸이 안내로 바꾼다. 오류를 표시하지 않는다
setError(e);
}bpFetch(host, input, init?)가 하는 일은 다음과 같다.
credentials의 기본값을"same-origin"으로 두고fetch를 호출한다.init에 값이 있으면 그 값을 쓴다.- 응답이 업데이트 차단(503이고
x-portal-bp-updating헤더가 있음)이면host.actions.reportUpdating으로 셸에 알리고BpUpdatingError를 던진다.error.retryAfterSeconds에Retry-After값이 있다. - 그 밖의 응답은 상태와 무관하게 그대로 반환한다. 4xx·5xx 처리는 지금처럼 호출하는 쪽이 한다. 네트워크 오류도 감싸지 않고 그대로 던진다.
지켜야 할 것은 다음과 같다.
BpUpdatingError에는 오류 문구, 토스트, 재시도 버튼을 표시하지 않는다. 셸이 위젯을 정리하고 안내로 바꾼다. 위젯이 오류를 표시하면 셸이 정리하기 전까지 오류 화면과 안내가 섞인다.- 알린 뒤에는 요청을 더 보내지 않는다. 위젯 안에서 재시도하지 않는다. 셸이 업데이트가 끝났는지 확인하고, 끝나면 새 번들로 다시 마운트한다. 이전 번들의 화면은 다시 쓰이지 않는다.
- 에러 경계에서
reportError로 보내지 않는다. 데이터 조회 라이브러리(React Query 등)를 쓰면 재시도 조건에서BpUpdatingError를 제외한다. fetch를 직접 쓰거나 다른 HTTP 라이브러리를 쓰면 응답마다reportBpUpdating(host, response)를 부른다.true를 반환하면 업데이트 차단 응답이므로 그 결과를 처리하지 않고 멈춘다. 응답 본문을 읽지 않고 상태와 헤더로 판별하므로 판별한 뒤에도 본문을 읽을 수 있다.- 헤더가 없는 503은 업데이트 차단이 아니다. 대여 기능의
busy·timeout이나 프록시의 서비스 불가가 그 예다. 판별은isBpUpdatingResponse(response)로 하고, 상태 코드만으로 판단하지 않는다.
구버전 셸에서도 그대로 동작한다. 2.0.0 셸에는 reportUpdating이 없으므로 bpFetch는 알리지 않고 BpUpdatingError만 던진다. 그 포탈에는 업데이트 차단도 없으므로 이 응답이 오지 않는다. 2.1.0 셸은 위젯이 알리지 않아도 업데이트 상태를 주기적으로(10초 간격) 확인해 같은 안내로 바꾼다. bpFetch를 쓰면 이 전환이 즉시 일어난다.
BP 업데이트 중에도 위젯을 이미 열어 둔 사용자가 있다. 그 위젯의 다음 요청이 이 응답을 받는다. 업데이트 뒤에는 새 번들이 같은 페이지에서 다시 실행되므로 §1의 "같은 페이지에서 번들이 다시 실행될 수 있다"를 함께 지킨다.
9. URL 상태 동기화
목록에서 무엇을 선택했는지를 컴포넌트 state가 아니라 URL에 둔다.
tsx
import { useUrlParam } from "@starj-portal/bp-frontend-sdk";
const [taskId, setTaskId] = useUrlParam("task_id");
setTaskId("1024");
setTaskId(null); // 파라미터 제거React 밖(이벤트 핸들러 등)에서는 getUrlParam / setUrlParam을 사용한다.
유지되는 것과 유지되지 않는 것을 정확히 구분한다.
| 동작 | 유지되는가 |
|---|---|
| 새로고침 | ✅ URL 자체가 저장소다 |
| 링크 공유·북마크 | ✅ |
| Portal Shell이 만든 히스토리 항목에 대한 뒤로가기 | ✅ popstate를 반영한다 |
| 위젯이 만든 선택 상태에 대한 뒤로가기 | ✖ |
setUrlParam은 history.replaceState만 사용하므로 브라우저 뒤로가기 스택에 항목을 추가하지 않는다. 따라서 사용자가 상세 화면에서 뒤로가기를 누르면 이전 선택으로 돌아가지 않고 그 화면을 벗어난다.
이것은 의도된 설계다 — 목록에서 선택을 변경할 때마다 히스토리가 쌓이면 뒤로가기가 화면 이동이 아니라 이전 선택으로만 되돌아가는 조작이 된다. 뒤로가기로 되돌아갈 수 있어야 하는 이동이라면 URL 파라미터가 아니라 별도 App으로 분리하는 것을 검토한다(다만 §7의 제약을 함께 고려한다).
예약 파라미터
domainId는 Portal Shell이 예약해 사용하는 이름이다. 위젯이 이 이름으로 값을 쓰면 콘솔 경고가 출력되고 도메인 전환이 정상 동작하지 않을 수 있다. 파라미터 이름에는 BP를 구분하는 접두어를 붙이는 것을 권장한다(예: task_bp_status).
10. loadAndMountCsrWidget은 누가 사용하나
Portal Shell(호스트)이 사용한다. 위젯 스크립트를 중복 주입 없이 적재해 mount를 호출하고 정리 함수를 반환하는 헬퍼다. BP가 호출할 일은 없다 — SDK에 함께 포함된 이유는 Portal Shell과 위젯이 같은 계약 정의를 참조하게 하기 위해서다.
계약 2.1.0에서 다섯째 인자 options?: { reload?: boolean }이 생겼다. reload: true이면 그 BP의 script[data-bp-widget="<id>"] 요소를 모두 제거하고 window.__BP_WIDGETS__[id]를 지운 뒤 새로 적재한다. 셸은 BP 업데이트가 끝난 뒤 새 번들을 적재할 때 이 옵션을 쓴다. 생략하면 이미 적재한 번들을 다시 쓴다. 이 옵션 때문에 위젯 번들은 같은 페이지에서 두 번 실행될 수 있다(§1).
11. 테스트 더블 — Portal Shell 없이 위젯을 적재한다
위젯을 단독으로 적재하려면 Portal Shell(포탈 셸)이 전달하는 BpHostContext가 필요하다. SDK가 서브패스로 제공한다.
ts
import { createFakeBpHostContext } from "@starj-portal/bp-frontend-sdk/testing";
const shell = createFakeBpHostContext({ businessPackageId: "task-bp" });
window.__BP_WIDGETS__["task-bp"].mount(container, shell.context);BpHostContext 자체는 값의 묶음이지만 actions는 Portal Shell의 오퍼레이션이며 규칙을 갖는다. 특히 카드 소유권 가드({businessPackageId}: 접두어가 없는 카드 id는 거부된다)를 BP마다 손으로 다시 작성하면 조금씩 달라지고, 위젯이 접두어를 누락한 결함이 Portal Shell에서만 드러난다.
| 옵션 | 지정하는 것 |
|---|---|
businessPackageId | 필수. apiBasePath의 기본값과 카드 소유권 가드의 근거다 |
domainId · appUri · locale · theme · apiBasePath | 셸이 전달하는 값. 생략하면 기본값이 채워진다 |
viewer · canWrite | 계약 1.1.0에서 추가된 값. null을 지정하면 전달하지 않는다(구버전 셸) |
features | 계약 1.5.0. 설치된 기능 식별자 배열이다. 생략하면 BP_FEATURE_KEYS의 식별자가 전부 설치된 셸이고, []이면 기능 패키지가 없는 배포, null이면 features를 전달하지 않는 구버전 셸이다. 셸처럼 has()만 넘기고 목록은 넘기지 않는다 |
actions: false | actions 자체를 전달하지 않는다 — 위젯이 그 부재를 견디는지 확인한다 |
actions: { optional: false } | 카드 조작 3종만 있는 계약 1.0.0 셸이 된다 |
actions: { homeCards, confirmAnswer, upload } | 홈에 배치된 카드, confirm()의 응답, uploadFile()의 결과. upload에 Error를 지정하면 셸이 업로드를 거부한 상황이 된다 |
actions: { reportUpdating: false } | 계약 2.0.0 셸이 된다. 1.1.0 액션은 있고 reportUpdating만 없다 |
confirm()의 기본 응답은 취소(false)다 — 되돌릴 수 없는 조작을 확인 없이 실행하는 위젯을 기본 설정에서 발견하기 위함이다. 승인한 경우는 shell.setConfirmAnswer(true)로 이어서 확인한다.
호출 기록은 shell.calls에 모인다 — cards / toasts / confirms / navigations / uploads / errors / reportUpdating. 화면마다 값을 바꿔 마운트할 때는 shell.contextWith({ appUri, canWrite })를 사용한다(같은 셸이므로 호출 기록이 이어진다). 기능이 없는 셸은 shell.contextWith({ features: { has: () => false } })로 만든다.
셸이 설치 목록을 늦게 알게 되는 경우는 shell.setFeatures(list)로 흉내낸다. 이미 마운트한 위젯이 받은 features.has도 다음 호출부터 새 목록으로 답한다. 위젯을 다시 마운트하지 않고, 위젯을 다시 렌더링하게 하는 조작(목록 다시 불러오기 등)을 한 뒤 버튼이 나타나는지 단언한다. 셸에 구독 통로가 없으므로 더블도 위젯에 변화를 알리지 않으며, 다시 렌더링하는 시점은 위젯이 정한다.
| 호출 | 뜻 |
|---|---|
shell.setFeatures([...]) | 셸이 목록을 알게 됐다. 그 목록으로 답한다 |
shell.setFeatures(null) | 셸이 목록을 모른다. features 필드는 그대로 있고 has가 모두 false다 |
생성 옵션 features: null | 구버전 셸이다. features 필드 자체가 없고 setFeatures로도 생기지 않는다 |
업데이트 중 응답은 makeBpUpdatingResponse(retryAfterSeconds?)로 만든다. 포탈의 거부와 같은 상태·헤더·본문을 가진 Response이며, 기본 Retry-After는 5초다. null을 넘기면 Retry-After를 싣지 않는다.
ts
import { createFakeBpHostContext, makeBpUpdatingResponse } from "@starj-portal/bp-frontend-sdk/testing";
const shell = createFakeBpHostContext({ businessPackageId: "task-bp" });
globalThis.fetch = async () => makeBpUpdatingResponse();
window.__BP_WIDGETS__["task-bp"].mount(container, shell.context);
// ...목록 조회가 끝날 때까지 기다린다
expect(shell.calls.reportUpdating).toEqual([{ retryAfterSeconds: 5 }]);
expect(container.shadowRoot?.textContent).not.toMatch(/오류|다시 시도/); // 오류 문구와 재시도 버튼이 없다두 가지를 함께 단언한다. 셸에 알렸는지와 위젯이 오류를 표시하지 않았는지다. 앞의 것만 확인하면 알리면서 오류도 표시하는 위젯이 통과한다.
정본 예제 examples/task-bp/scripts/check-widget.mjs가 이 더블로 구버전 셸·카드 조작 셸·계약 1.1.0 셸 세 가지를 만들어 확인하고, 1.1.0 셸에서 features를 바꿔 내보내기 버튼을 확인한다. 모든 요청이 makeBpUpdatingResponse()를 받는 셸에서는 목록과 상세 화면이 셸에 알리는지, 오류 문구·reportError·오류 토스트를 내지 않는지 함께 확인한다.
12. UI 키트 (선택)
§5의 토큰은 색과 밀도만 제공한다. 버튼·입력·표·페이지 헤더를 어떤 형태로 렌더링할지는 위젯이 정한다. 그래서 토큰만 쓰고 컴포넌트 스타일을 두지 않은 위젯은 브라우저 기본 버튼과 기본 입력칸이 그대로 보이고, BP마다 따로 만든 스타일은 포탈 화면과 조금씩 어긋난다.
@starj-portal/bp-ui-kit은 portal-admin(포탈 관리 화면)의 형태를 위젯이 그대로 쓸 수 있게 옮긴 스타일시트다. 선택 사항이며 호스트 계약이 아니다. 쓰지 않아도 포탈은 위젯을 똑같이 적재하고, 버전도 계약 버전과 따로 움직인다.
넣는 방법
ts
import { adoptUiKit } from "@starj-portal/bp-ui-kit";
const shadow = container.shadowRoot ?? container.attachShadow({ mode: "open" });
adoptUiKit(shadow); // 키트를 shadow root 맨 앞에 넣는다. 재마운트해도 하나만 남는다
const own = document.createElement("style");
own.textContent = WIDGET_CSS; // BP 고유 CSS는 키트 뒤에
shadow.append(own);
const el = document.createElement("div");
el.className = "sj-root"; // 렌더링 루트. 글꼴·글자색·box-sizing이 여기서 정해진다
shadow.append(el);키트도 §4의 규정을 전제로 한다. 키트를 document.head에 넣지 않는다. 대화상자·팝오버를 document.body에 렌더링하면 shadow 밖이라 키트 스타일이 닿지 않으므로, 렌더링 대상도 shadow 안의 요소로 둔다.
무엇이 들어 있나
| 클래스 | portal-admin에서 대응하는 것 |
|---|---|
sj-page-header (__icon __eyebrow __title __description __actions) | 화면 맨 위 헤더. 아이콘 상자, 상위 이름, 제목, 설명, 오른쪽 주 조작, 아래 구분선 |
sj-btn (--secondary --ghost --danger --sm), sj-icon-btn | 버튼. 기본이 주 조작(브랜드색)이다. 고스트·아이콘 버튼의 테두리는 §5의 --button-ghost-border를 따른다 |
sj-input sj-select sj-textarea sj-label sj-required sj-field sj-form-grid sj-checkbox sj-switch | 입력과 폼 배치. 높이는 밀도 토큰을 따른다 |
sj-list-report (__row __list __detail, --70-30, --stacked), sj-back | 목록·상세 좌우 분할(포탈 FR-UI-1). 자기 폭이 48rem 미만이면 선택 여부(data-has-selection)에 따라 한쪽만 표시된다. 넓은 표처럼 나눌 수 없는 목록은 --stacked로 폭과 상관없이 전환한다 |
sj-toolbar sj-count sj-list sj-list-item | 목록 위 검색·필터 판, 건수, 카드형 목록 행(선택 행은 aria-current="true") |
sj-table-wrap (--cards) sj-table sj-num sj-sort | 표. --cards를 붙이고 각 td에 data-label을 달면 좁은 폭에서 행이 카드로 바뀐다. 정렬은 th 안에 button.sj-sort를 둬 키보드로 닿게 한다. 키·값 표의 행 머리글은 th scope="row"다 |
sj-tabs sj-tab | 탭(role="tab", aria-selected). 키보드 이동은 위젯이 구현한다 |
sj-card sj-badge sj-message sj-empty sj-dialog | 카드, 상태 배지, 안내 스트립, 점선 빈 상태, 입력용 대화상자 |
sj-metrics sj-metric sj-link | 요약 지표 타일, 글자만 있는 이동·내려받기 조작 |
마크업 예시는 패키지의 ui-kit.css 주석에 있다. 확인 대화상자와 토스트는 키트가 아니라 §6의 host.actions.confirm·toast를 쓴다.
바꿔 쓰는 방법
키트는 기준을 제공할 뿐 강제하지 않는다. 바꾸는 범위에 따라 셋 중 하나를 고른다.
| 범위 | 방법 |
|---|---|
| 색·반경만 | :host에서 키트 변수(--sj-brand, --sj-radius, --sj-surface 등)를 덮어쓴다. 셸 토큰(--color-*)은 §4대로 재정의하지 않는다 |
| 특정 컴포넌트 | BP CSS로 키트 클래스를 덮어쓴다. 키트 규칙은 모두 @layer starj-ui-kit 안에 있고 shadow root에서 가장 먼저 선언되므로, BP가 쓰는 규칙은 선택자 우선순위와 상관없이 키트를 이긴다 |
| 디자인 전체 | @starj-portal/bp-ui-kit/ui-kit.css를 BP 저장소에 복사해 고치고 adoptUiKit 대신 직접 넣는다. 복사한 뒤로는 키트 갱신을 받지 않는다 |
BP CSS에서 맨 요소 선택자(button {}, input {})를 쓰지 않는다. 위의 우선순위 때문에 키트의 버튼·입력 형태 전체가 덮인다. 꼭 필요하면 키트 클래스가 없는 요소만 고른다(button:not([class*="sj-"]) {}). 같은 이유로 Tailwind처럼 자기 레이어(base, utilities)를 선언하는 프레임워크와 함께 쓰면 그 프레임워크의 preflight가 키트를 덮는다. 둘 중 하나만 쓴다.
함께 볼 것
- backend-guide.md §5 — 홈 인사이트 카드 선언과 데이터 응답 형태
- build-and-package.md — 위젯 번들을 만드는 방법
- troubleshooting.md — 화면이 표시되지 않거나 스타일이 틀어질 때
- backend-guide.md §20 — 백엔드의
BpHostRuntime테스트 더블