feat(B07): CAD 화면을 앱 테마에 맞춘다 — 디자인 토큰·화이트/블랙 모드

iframe 안 CAD가 다크 색을 하드코딩하고 있어 앱의 화이트/블랙 토글과 따로 놀던
것을 고친다. 색 정의처는 앱과 같은 ui_template_theme.css 하나로 모은다.

- openwebcad가 ui_template_theme.css를 직접 import — App.css의 하드코딩 색
  88개를 var(--color-*)로 전량 치환, CAD 골격용 별칭 토큰만 새로 붙임
- src/theme.ts 신설: 동일 오리진 localStorage(frd_theme) + storage 이벤트로
  부모 토글을 리로드 없이 추종(부모 코드 수정 없음), 시스템 폴백 유지
- 캔버스 배경·그리드는 토큰 경유, 저장된 도면 색은 라이트 모드에서 그릴 때만
  휘도 보정(paintColor) — 저장 데이터는 불변
- 테마 변경 시 bumpSceneVersion()으로 정적 장면 캐시 비트맵 재생성
- Button/LayerManager의 tailwind 색 유틸 제거, index.html의 bg-* 고정 제거

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 16:38:38 +09:00
co-authored by Claude Fable 5
parent 6e195afb69
commit 3aed06eaca
7 changed files with 250 additions and 112 deletions
+3 -3
View File
@@ -7,9 +7,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
<title>Aislo 2D Drawing</title>
</head>
<body class="m-0 p-0 overflow-hidden w-full h-full min-h-screen flex flex-row bg-white">
<div id="root" data-id="root" class="bg-slate-950"></div>
<canvas data-id="canvas" class="block bg-black cursor-none"></canvas>
<body class="m-0 p-0 overflow-hidden w-full h-full min-h-screen flex flex-row">
<div id="root" data-id="root"></div>
<canvas data-id="canvas" class="block cursor-none"></canvas>
<div class="export-pdf-wrapper">
<!-- Used to append the svg before exporting it to pdf -->
</div>
+121 -91
View File
@@ -1,4 +1,6 @@
@import "tailwindcss";
/* 색 토큰의 정의처는 앱과 같은 파일 하나 — 여기서 색을 새로 정하지 않는다. */
@import "../../../ui_template/ui_template_theme.css";
:root {
--cad-title-height: 42px;
@@ -7,9 +9,21 @@
--cad-command-height: 0px;
--cad-status-height: 28px;
--cad-panel-width: 248px;
font-family: Inter, Pretendard, "Noto Sans KR", system-ui, sans-serif;
color: #dce6f2;
background: #11161d;
font-family: var(--font-body);
color: var(--color-text-body);
background: var(--color-bg);
/* CAD 표면 단계 — 앱 토큰을 CAD 골격(툴바·리본·패널)에 이름만 붙여 쓴다. */
--cad-chrome: var(--color-surface);
--cad-chrome-raised: var(--color-surface-raised);
--cad-chrome-sunken: var(--color-bg);
--cad-line: var(--color-border);
--cad-text: var(--color-text-body);
--cad-text-dim: var(--color-text-secondary);
--cad-text-muted: var(--color-text-muted);
--cad-accent: var(--color-accent);
--cad-hover: var(--color-mist-violet);
--cad-canvas: var(--color-canvas);
}
* {
@@ -56,7 +70,7 @@ body > canvas[data-id="canvas"] {
var(--cad-command-height) -
var(--cad-status-height)
);
background: #111;
background: var(--cad-canvas);
cursor: none;
}
@@ -68,9 +82,9 @@ body > canvas[data-id="canvas"] {
align-items: center;
gap: 24px;
padding: 0 12px;
background: #19222d;
border-bottom: 1px solid #344252;
box-shadow: 0 1px 4px #0008;
background: var(--cad-chrome);
border-bottom: 1px solid var(--cad-line);
box-shadow: var(--shadow-sm);
}
.cad-brand {
display: flex;
@@ -79,12 +93,12 @@ body > canvas[data-id="canvas"] {
min-width: 220px;
}
.cad-brand strong {
color: #f7fbff;
color: var(--color-text);
font-size: 14px;
}
.cad-brand span,
.cad-file-state {
color: #91a2b5;
color: var(--cad-text-dim);
font-size: 11px;
}
.cad-file-state {
@@ -96,8 +110,8 @@ body > canvas[data-id="canvas"] {
width: 7px;
height: 7px;
border-radius: 50%;
background: #49b675;
box-shadow: 0 0 0 2px #49b67522;
background: var(--color-success);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-success) 25%, transparent);
}
.cad-title-actions {
display: flex;
@@ -114,8 +128,8 @@ body > canvas[data-id="canvas"] {
font-size: 12px;
}
.cad-title-actions button:hover {
border-color: #4d6074;
background: #273544;
border-color: var(--cad-line);
background: var(--cad-hover);
}
.cad-ribbon {
@@ -128,15 +142,15 @@ body > canvas[data-id="canvas"] {
align-items: stretch;
padding: 5px 8px 3px;
overflow-x: auto;
background: #202b37;
border-bottom: 1px solid #3c4a59;
background: var(--cad-chrome-raised);
border-bottom: 1px solid var(--cad-line);
}
.cad-ribbon-group {
display: flex;
flex-direction: column;
min-width: max-content;
padding: 0 9px;
border-right: 1px solid #40505f;
border-right: 1px solid var(--cad-line);
}
.cad-ribbon-tools {
display: flex;
@@ -145,7 +159,7 @@ body > canvas[data-id="canvas"] {
}
.cad-ribbon-group__label {
margin-top: auto;
color: #8293a5;
color: var(--cad-text-muted);
font-size: 10px;
text-align: center;
}
@@ -159,7 +173,7 @@ body > canvas[data-id="canvas"] {
gap: 3px;
align-items: stretch;
font-size: 10px;
color: #8293a5;
color: var(--cad-text-muted);
}
.cad-prop > span {
text-align: center;
@@ -169,9 +183,9 @@ body > canvas[data-id="canvas"] {
height: 24px;
min-width: 64px;
padding: 0 4px;
color: #dce6f2;
background: #2a3745;
border: 1px solid #40505f;
color: var(--cad-text);
background: var(--cad-chrome-sunken);
border: 1px solid var(--cad-line);
border-radius: 4px;
font-size: 11px;
}
@@ -183,8 +197,8 @@ body > canvas[data-id="canvas"] {
height: 24px;
width: 40px;
padding: 1px;
background: #2a3745;
border: 1px solid #40505f;
background: var(--cad-chrome-sunken);
border: 1px solid var(--cad-line);
border-radius: 4px;
cursor: pointer;
}
@@ -203,24 +217,24 @@ body > canvas[data-id="canvas"] {
}
.cad-tool:hover:not(:disabled),
.cad-tool[data-active="true"] {
border-color: #4f87b8;
background: #2b455c;
border-color: var(--cad-accent);
background: var(--cad-hover);
}
.cad-tool[data-active="true"] {
box-shadow: inset 0 -2px #4ca6e8;
box-shadow: inset 0 -2px var(--cad-accent);
}
.cad-tool:disabled {
color: #657382;
color: var(--color-smoke);
cursor: not-allowed;
}
.cad-tool__glyph {
height: 27px;
color: #a9d7fb;
color: var(--cad-accent);
font-size: 22px;
line-height: 27px;
}
.cad-tool:disabled .cad-tool__glyph {
color: #657382;
color: var(--color-smoke);
}
.cad-inspector {
@@ -230,8 +244,8 @@ body > canvas[data-id="canvas"] {
bottom: calc(var(--cad-command-height) + var(--cad-status-height));
left: 0;
width: var(--cad-panel-width);
background: #1b2530;
border-right: 1px solid #3d4c5c;
background: var(--cad-chrome);
border-right: 1px solid var(--cad-line);
transition: width 120ms ease;
}
.cad-inspector[data-collapsed="true"] {
@@ -244,57 +258,57 @@ body > canvas[data-id="canvas"] {
left: 100%;
width: 20px;
height: 38px;
border: 1px solid #465769;
border: 1px solid var(--cad-line);
border-left: 0;
border-radius: 0 4px 4px 0;
background: #263442;
color: #a9bbcd;
background: var(--cad-chrome-raised);
color: var(--cad-text-dim);
}
.cad-inspector-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
height: 38px;
border-bottom: 1px solid #394857;
border-bottom: 1px solid var(--cad-line);
}
.cad-inspector-tabs button {
border: 0;
border-bottom: 2px solid transparent;
background: #17202a;
color: #94a7ba;
background: var(--cad-chrome-sunken);
color: var(--cad-text-dim);
font-size: 12px;
}
.cad-inspector-tabs button[data-active="true"] {
border-bottom-color: #4da3df;
background: #223141;
color: white;
border-bottom-color: var(--cad-accent);
background: var(--cad-chrome-raised);
color: var(--color-text);
}
.cad-properties {
padding: 14px 12px;
}
.cad-properties h2 {
margin: 0 0 14px;
color: #f3f7fb;
color: var(--color-text);
font-size: 13px;
font-weight: 600;
}
.cad-properties dl {
margin: 0;
border-top: 1px solid #344352;
border-top: 1px solid var(--cad-line);
}
.cad-properties dl div {
display: grid;
grid-template-columns: 92px 1fr;
padding: 8px 0;
border-bottom: 1px solid #2d3a47;
border-bottom: 1px solid var(--cad-line);
font-size: 11px;
}
.cad-properties dt {
color: #8092a4;
color: var(--cad-text-dim);
}
.cad-properties dd {
margin: 0;
overflow: hidden;
color: #d7e1eb;
color: var(--cad-text);
text-overflow: ellipsis;
}
.cad-layer-manager {
@@ -306,7 +320,23 @@ body > canvas[data-id="canvas"] {
min-height: 36px;
padding-top: 6px;
padding-bottom: 6px;
background: #202c38;
}
/* 도면층 버튼 — 색은 토큰으로만, 상태는 Button이 붙이는 data-* 속성으로 가른다. */
.cad-layer-manager button[data-type="regular"] {
background: var(--cad-chrome-sunken);
color: var(--cad-accent);
}
.cad-layer-manager button[data-type="transparent"] {
background: transparent;
color: var(--cad-accent);
}
.cad-layer-manager button:hover {
background: var(--cad-hover);
color: var(--color-text);
}
.cad-layer-manager button[data-active="true"] {
background: var(--color-primary);
color: var(--color-primary-text);
}
.cad-view-controls {
@@ -317,29 +347,29 @@ body > canvas[data-id="canvas"] {
display: flex;
align-items: center;
overflow: hidden;
border: 1px solid #465565;
border: 1px solid var(--cad-line);
border-radius: 4px;
background: #202b37e8;
box-shadow: 0 3px 12px #0008;
background: var(--cad-chrome-raised);
box-shadow: var(--shadow-lg);
}
.cad-view-controls button {
width: 34px;
height: 32px;
border: 0;
border-right: 1px solid #3d4c5b;
border-right: 1px solid var(--cad-line);
background: transparent;
font-size: 17px;
}
.cad-view-controls button:hover {
background: #31516b;
background: var(--cad-hover);
}
.cad-view-controls__fit {
color: #7fd0ff;
color: var(--cad-accent);
font-size: 15px;
}
.cad-view-controls span {
min-width: 48px;
color: #9fb0c1;
color: var(--cad-text-dim);
font-size: 10px;
text-align: center;
}
@@ -357,11 +387,11 @@ body > canvas[data-id="canvas"] {
white-space: nowrap;
}
.cad-command-prompt span {
color: #6eaee0;
color: var(--cad-accent);
}
.cad-command-prompt strong {
overflow: hidden;
color: #aab8c6;
color: var(--cad-text-dim);
font-weight: 400;
text-overflow: ellipsis;
}
@@ -372,7 +402,7 @@ body > canvas[data-id="canvas"] {
height: 28px;
}
.cad-command-area label {
color: #e6eef6;
color: var(--color-text);
font-size: 11px;
font-weight: 600;
}
@@ -380,16 +410,16 @@ body > canvas[data-id="canvas"] {
flex: 1;
height: 26px;
padding: 0 8px;
border: 1px solid #46586a;
border: 1px solid var(--cad-line);
border-radius: 2px;
outline: none;
background: #0e141a;
color: #eef5fc;
background: var(--cad-chrome-sunken);
color: var(--cad-text);
font: 11px Consolas, monospace;
}
.cad-command-area input:focus {
border-color: #4b9bd3;
box-shadow: 0 0 0 1px #4b9bd344;
border-color: var(--color-focus-ring);
box-shadow: 0 0 0 1px var(--color-focus-ring);
}
.cad-statusbar {
@@ -403,8 +433,8 @@ body > canvas[data-id="canvas"] {
align-items: center;
gap: 2px;
padding: 0 8px;
background: #263544;
border-top: 1px solid #415263;
background: var(--cad-chrome);
border-top: 1px solid var(--cad-line);
}
.cad-statusbar button {
height: 21px;
@@ -412,20 +442,20 @@ body > canvas[data-id="canvas"] {
border: 1px solid transparent;
border-radius: 2px;
background: transparent;
color: #9caebf;
color: var(--cad-text-dim);
font-size: 10px;
}
.cad-statusbar button:hover {
background: #33485b;
background: var(--cad-hover);
}
.cad-statusbar button[data-active="true"] {
border-color: #4f9bd0;
background: #285579;
color: white;
border-color: var(--cad-accent);
background: var(--color-primary);
color: var(--color-primary-text);
}
.cad-statusbar__hint {
margin-left: auto;
color: #7f91a2;
color: var(--cad-text-muted);
font-size: 10px;
}
@@ -441,11 +471,11 @@ body > canvas[data-id="canvas"] {
/* 표(가장 넓은 자식) 기준 폭 고정 → 접었다 펴도 폭이 변하지 않는다 */
width: max-content;
max-width: min(1040px, calc(100vw - var(--cad-panel-width) - 24px));
border: 1px solid #465565;
border: 1px solid var(--cad-line);
border-radius: 6px;
background: #202b37f2;
box-shadow: 0 4px 16px #0009;
color: #dce6f2;
background: var(--cad-chrome-raised);
box-shadow: var(--shadow-lg);
color: var(--cad-text);
}
.cad-qtable__header {
@@ -463,12 +493,12 @@ body > canvas[data-id="canvas"] {
min-width: 0;
}
.cad-qtable__title strong {
color: #f7fbff;
color: var(--color-text);
font-size: 14px;
}
.cad-qtable__title span {
overflow: hidden;
color: #91a2b5;
color: var(--cad-text-dim);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
@@ -480,26 +510,26 @@ body > canvas[data-id="canvas"] {
font-style: normal;
}
.cad-qtable__status[data-confirmed="true"] {
background: #1f4d33;
color: #7fdca4;
background: color-mix(in srgb, var(--color-success) 22%, transparent);
color: var(--color-success);
}
.cad-qtable__status[data-confirmed="false"] {
background: #4a3a1c;
color: #e6c07a;
background: color-mix(in srgb, var(--color-warning) 22%, transparent);
color: var(--color-warning);
}
.cad-qtable__nav,
.cad-qtable__collapse {
width: 26px;
height: 24px;
border: 1px solid #40505f;
border: 1px solid var(--cad-line);
border-radius: 4px;
background: #2a3745;
color: #cdd8e4;
background: var(--cad-chrome-sunken);
color: var(--cad-text);
font-size: 13px;
}
.cad-qtable__nav:hover:not(:disabled),
.cad-qtable__collapse:hover {
background: #31516b;
background: var(--cad-hover);
}
.cad-qtable__nav:disabled {
opacity: 0.35;
@@ -525,17 +555,17 @@ body > canvas[data-id="canvas"] {
.cad-qtable th,
.cad-qtable td {
padding: 0;
border: 1px solid #40505f;
border: 1px solid var(--cad-line);
font-size: 11px;
text-align: center;
vertical-align: middle;
}
.cad-qtable__label {
padding: 2px 9px;
color: #a9bccd;
color: var(--cad-text-dim);
font-weight: 600;
white-space: nowrap;
background: #26333f;
background: var(--cad-chrome);
}
.cad-qtable__label--vertical {
width: 18px;
@@ -548,18 +578,18 @@ body > canvas[data-id="canvas"] {
padding: 3px 4px;
border: 0;
background: transparent;
color: #eaf1f8;
color: var(--cad-text);
font: inherit;
text-align: center;
}
.cad-qtable__value input:focus {
outline: 2px solid #4ca6e8;
outline: 2px solid var(--color-focus-ring);
outline-offset: -2px;
background: #12324a;
background: var(--cad-hover);
}
.cad-qtable__value--derived input {
color: #8fa3b5;
background: #1a242e;
color: var(--cad-text-muted);
background: var(--cad-chrome-sunken);
cursor: not-allowed;
}
@@ -37,11 +37,9 @@ export const Button: FC<ButtonProps> = ({
left = null,
right = null,
}) => {
// 색은 App.css의 테마 토큰이 data-type/data-active로 준다 — 여기서는 배치만.
const classParts = [
'font-semibold py-4 h-10 flex flex-row justify-start w-full items-center hover:bg-blue-500 hover:text-white hover:border-transparent',
type === 'regular' ? 'bg-gray-950 text-blue-500' : '',
type === 'transparent' ? 'bg-transparent text-blue-500' : '',
active ? 'bg-blue-500 text-white border-transparent hover:bg-blue-400' : '',
'font-semibold py-4 h-10 flex flex-row justify-start w-full items-center',
size === 'regular' ? 'pl-2 pr-2 gap-2' : '',
size === 'small' ? 'pl-2 pr-2 gap-0' : '',
className || '',
@@ -68,7 +66,7 @@ export const Button: FC<ButtonProps> = ({
(iconName && (
<Icon
name={iconName}
className={`${iconClassname} text-blue-700 ${active ? 'text-white' : ''} ${size === 'small' ? 'w-4' : 'w-5'}`}
className={`${iconClassname ?? ''} ${size === 'small' ? 'w-4' : 'w-5'}`}
/>
))}
<div className="flex flex-row flex-nowrap">{left}</div>
@@ -93,7 +93,7 @@ export const LayerManager: FC<LayerManagerProps> = ({
title="Set this layer as active"
active={activeLayerId === layer.id}
onClick={(evt) => handleLayerClick(evt, layer.id)}
className="data-[active=true]:text-white flex-grow"
className="flex-grow"
left={
<>
<Button
@@ -101,7 +101,7 @@ export const LayerManager: FC<LayerManagerProps> = ({
title="Show/hide layer content"
onClick={(evt) => handleShowHideLayer(evt, layer.id)}
size="small"
className="w-10 hover:bg-blue-300 -ml-1"
className="w-10 -ml-1"
type="transparent"
active={activeLayerId === layer.id}
/>
@@ -110,7 +110,7 @@ export const LayerManager: FC<LayerManagerProps> = ({
title="Lock/Unlock layer content"
onClick={(evt) => handleLockUnlockLayer(evt, layer.id)}
size="small"
className="w-10 hover:bg-blue-300"
className="w-10"
type="transparent"
active={activeLayerId === layer.id}
/>
@@ -123,7 +123,7 @@ export const LayerManager: FC<LayerManagerProps> = ({
title="Select entities on this layer"
onClick={(evt) => handleSelectEntitiesOnLayer(evt, layer.id)}
size="small"
className="w-10 hover:bg-blue-300"
className="w-10"
type="transparent"
active={activeLayerId === layer.id}
/>
@@ -132,7 +132,7 @@ export const LayerManager: FC<LayerManagerProps> = ({
title="Assign current selection to layer"
onClick={(evt) => handleAssignSelectionToLayer(evt, layer.id)}
size="small"
className="w-10 hover:bg-blue-300"
className="w-10"
type="transparent"
active={activeLayerId === layer.id}
/>
@@ -141,7 +141,7 @@ export const LayerManager: FC<LayerManagerProps> = ({
title="delete layer and content"
onClick={(evt) => handleDeleteLayer(evt, layer.id)}
size="small"
className="w-10 hover:bg-blue-300"
className="w-10"
type="transparent"
active={activeLayerId === layer.id}
/>
@@ -1,12 +1,16 @@
import { Point, type Vector } from '@flatten-js/core';
import { CANVAS_BACKGROUND_COLOR, MOUSE_ZOOM_MULTIPLIER } from '../App.consts';
import { MOUSE_ZOOM_MULTIPLIER } from '../App.consts';
import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts';
import { getBoundingBoxOfMultipleEntities } from '../helpers/get-bounding-box-of-multiple-entities.ts';
import { mapNumberRange } from '../helpers/map-number-range.ts';
import { StateVariable } from '../helpers/undo-stack.ts';
import { getEntities, getGridEnabled, triggerReactUpdate } from '../state.ts';
import { paintColor, themeColor } from '../theme.ts';
import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController';
/** 토큰을 못 읽을 때만 쓰는 캔버스 배경 폴백. */
const CANVAS_BACKGROUND_FALLBACK = '#111';
/**
* Screen coordinate system:
* 0, 0 X
@@ -231,10 +235,12 @@ export class ScreenCanvasDrawController implements DrawController {
public setLineStyles(
isHighlighted: boolean,
isSelected: boolean,
color: string,
rawColor: string,
lineWidth: number,
dash: number[] = []
) {
// 저장된 도면 색은 다크 배경 기준 — 라이트 테마에서는 그릴 때만 대비를 맞춘다.
const color = paintColor(rawColor);
if (this.batching) {
const effectiveWidth = isHighlighted ? lineWidth + 1 : lineWidth;
const effectiveDash = isSelected ? [5, 5] : dash;
@@ -298,7 +304,7 @@ export class ScreenCanvasDrawController implements DrawController {
}
public setFillStyles(fillColor: string) {
this.context.fillStyle = fillColor;
this.context.fillStyle = paintColor(fillColor);
}
/**
@@ -330,10 +336,10 @@ export class ScreenCanvasDrawController implements DrawController {
if (!this.context) return;
if (this.batching) this.flushBatch();
this.context.fillStyle = CANVAS_BACKGROUND_COLOR;
this.context.fillStyle = themeColor('--cad-canvas', CANVAS_BACKGROUND_FALLBACK);
this.context.fillRect(0, 0, this.canvasSize?.x, this.canvasSize?.y);
if (getGridEnabled()) {
this.context.strokeStyle = '#242b35';
this.context.strokeStyle = themeColor('--color-mist', '#242b35');
this.context.lineWidth = 1;
this.context.setLineDash([]);
this.context.beginPath();
@@ -554,7 +560,7 @@ export class ScreenCanvasDrawController implements DrawController {
this.context.rotate(angle);
this.context.font = `${opts.fontSize}px ${opts.fontFamily}`;
this.context.textAlign = opts.textAlign;
this.context.fillStyle = opts.textColor;
this.context.fillStyle = paintColor(opts.textColor);
this.context.textBaseline = 'middle';
this.context.fillText(label, 0, 0);
this.context.restore();
@@ -630,7 +636,7 @@ export class ScreenCanvasDrawController implements DrawController {
public fillRectScreen(xMin: number, yMin: number, width: number, height: number, color: string) {
if (this.batching) this.flushBatch();
// TODO see if we need to replace this with a call to fillPolygon
this.context.fillStyle = color;
this.context.fillStyle = paintColor(color);
this.context.fillRect(xMin, this.canvasSize.y - yMin, width, height);
}
+4
View File
@@ -31,10 +31,14 @@ import {
setLayers,
setScreenCanvasDrawController,
} from './state';
import { syncThemeFromHost } from './theme.ts';
import { Tool } from './tools';
import { TOOL_STATE_MACHINES } from './tools/tool.consts';
import { ActorEvent, type DrawEvent } from './tools/tool.types';
// 호스트 앱의 화이트/블랙 모드를 먼저 붙인 뒤 렌더한다.
syncThemeFromHost();
ReactDOM.createRoot(document.getElementById('root') as HTMLDivElement).render(
<React.StrictMode>
<App />
+100
View File
@@ -0,0 +1,100 @@
/**
* 호스트 앱(Aislo) 테마를 CAD iframe에 그대로 따라 붙인다.
*
* iframe은 호스트와 **같은 오리진**이라 `localStorage["frd_theme"]`를 공유한다.
* 부모가 토글하며 값을 쓰면 이 문서에는 `storage` 이벤트가 오므로, 부모 쪽에
* 별도 전달 코드(postMessage)를 넣지 않는다. 값이 없으면 미지정 상태로 두어
* `ui_template_theme.css`의 `prefers-color-scheme` 폴백이 그대로 먹는다.
*/
import { bumpSceneVersion } from './helpers/scene-version.ts';
const THEME_STORAGE_KEY = 'frd_theme';
/** 테마가 바뀔 때마다 비우는 색 캐시 (CSS 변수 조회·휘도 보정 결과). */
let cssVarCache = new Map<string, string>();
let paintCache = new Map<string, string>();
function applyTheme(value: string | null): void {
if (value === 'dark' || value === 'light') {
document.documentElement.setAttribute('data-theme', value);
} else {
document.documentElement.removeAttribute('data-theme');
}
cssVarCache = new Map();
paintCache = new Map();
// 정적 장면 비트맵은 배경·선 색까지 구워 두므로 테마가 바뀌면 다시 굽는다.
bumpSceneVersion();
}
/** 부팅 시 1회 호출 — 현재 테마 반영 + 이후 변경 구독. */
export function syncThemeFromHost(): void {
applyTheme(localStorage.getItem(THEME_STORAGE_KEY));
window.addEventListener('storage', (event) => {
if (event.key === THEME_STORAGE_KEY) applyTheme(event.newValue);
});
// 시스템 설정 폴백으로 도는 중이면 OS 전환도 따라간다.
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (!document.documentElement.hasAttribute('data-theme')) applyTheme(null);
});
}
/** 토큰 값 조회 (테마 바뀔 때까지 캐시). */
export function themeColor(varName: string, fallback: string): string {
const cached = cssVarCache.get(varName);
if (cached !== undefined) return cached;
const value =
getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || fallback;
cssVarCache.set(varName, value);
return value;
}
export function isLightTheme(): boolean {
const attr = document.documentElement.getAttribute('data-theme');
if (attr === 'dark') return false;
if (attr === 'light') return true;
return !window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function relativeLuminance(r: number, g: number, b: number): number {
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
}
/**
* 도면 색 보정 — 저장된 엔티티 색은 다크 배경 기준(밝은 회색·흰색)이라
* 라이트 모드 흰 종이 위에서는 안 보인다. **그릴 때만** 휘도를 낮춰 색상(색조)은
* 유지한 채 대비를 살린다. 저장 데이터는 건드리지 않는다.
*/
export function paintColor(color: string): string {
if (!isLightTheme()) return color;
const cached = paintCache.get(color);
if (cached !== undefined) return cached;
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color);
let result = color;
if (hex) {
const raw = hex[1];
const full =
raw.length === 3
? raw
.split('')
.map((c) => c + c)
.join('')
: raw;
const r = Number.parseInt(full.slice(0, 2), 16);
const g = Number.parseInt(full.slice(2, 4), 16);
const b = Number.parseInt(full.slice(4, 6), 16);
const luminance = relativeLuminance(r, g, b);
// 흰 배경에서 묻히는 밝기(0.58 초과)만 목표 휘도 0.3으로 눌러 준다.
if (luminance > 0.58) {
const scale = 0.3 / luminance;
const to = (v: number) =>
Math.round(Math.min(255, v * scale))
.toString(16)
.padStart(2, '0');
result = `#${to(r)}${to(g)}${to(b)}`;
}
}
paintCache.set(color, result);
return result;
}