From e4a744443d225aad34e6db2283da43980fe79189 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 23 Jul 2026 20:09:58 +0900 Subject: [PATCH] 260722_4 --- .../B05_wf2_Route_UI_Profile_Edit.ts | 38 ++++- .../B05_wf2_Route_UI_Profile_Panel.ts | 14 +- .../B05_wf2_Route_UI_Profile_Table.ts | 147 +++++++++++------- B05_wf2_Route/B05_wf2_Route_UI_Style.css | 43 +++-- 4 files changed, 166 insertions(+), 76 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts index 640e480e..dc84dbd7 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts @@ -9,6 +9,10 @@ * - 측점 ▲ / ▼ : 그 측점을 변화점으로 승격시켜 계획고를 ±step 만큼 꺾는다. * - 구간 ⇧ / ⇩ : 직선 구간 전체를 평행이동한다(구배 유지, 양 끝 변화점 동시 이동). * - 원복 ↺ : 그 측점의 편집 델타만 지워 자동 선형으로 되돌린다. + * + * 측점 버튼과 구간 버튼은 **그래프 위·아래 같은 줄**에 놓는다. 안쪽 줄에 두면 X축 제목과 + * 측점 라벨에 가려 보이지 않기 때문이다. 자리가 겹칠 땐 측점 버튼을 수직선 위에 고정하고 + * 구간 버튼만 옆으로 비킨다. * ========================================================================== */ import type { @@ -19,6 +23,9 @@ import type { import { chainageKey, emptyEdits, hasEdits } from "./B05_wf2_Route_UI_Profile_Alignment"; const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft"; +/** 버튼 폭(18px)에 여유를 더한 값 — 이보다 가까우면 겹친 것으로 본다. */ +const BUTTON_CLEARANCE_PX = 20; +const BUTTON_HALF_PX = 9; /** 길게 누르기: 이만큼 유지하면 반복이 시작되고, 그 뒤 초당 10회(0.1m씩)로 이어진다. */ const HOLD_DELAY_MS = 500; const HOLD_INTERVAL_MS = 100; @@ -193,6 +200,25 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { const repeater = createHoldRepeater(); const edited = new Set(Object.keys(alignment.edits.station_offsets)); + const stationXs = alignment.stations.map((station) => x(station.chainage_m)); + + /** + * 구간 버튼을 측점 버튼과 같은 행에 두되, 겹치는 자리면 옆으로 비킨다. + * 측점 버튼은 측점 수직선 위에 있어야 의미가 통하므로 **측점 쪽을 고정**하고 + * 구간 버튼만 오른쪽으로 밀어낸다(구간이 짝수 개 측점을 걸치면 중점이 측점과 겹친다). + */ + function avoidStations(center: number): number { + let nearest = center; + let best = Infinity; + for (const stationX of stationXs) { + const distance = Math.abs(stationX - center); + if (distance < best) { + best = distance; + nearest = stationX; + } + } + return best < BUTTON_CLEARANCE_PX ? nearest + BUTTON_CLEARANCE_PX : center; + } alignment.stations.forEach((station) => { const left = x(station.chainage_m); const isEdited = edited.has(chainageKey(station.chainage_m)); @@ -201,7 +227,7 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { const up = overlayButton(repeater, "is-station is-up", "▲", `${label} — ${step}m 올림`, () => onStation(station.chainage_m, step), ); - up.style.left = `${left - 9}px`; + up.style.left = `${left - BUTTON_HALF_PX}px`; const down = overlayButton( repeater, "is-station is-down", @@ -209,7 +235,7 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { `${label} — ${step}m 내림`, () => onStation(station.chainage_m, -step), ); - down.style.left = `${left - 9}px`; + down.style.left = `${left - BUTTON_HALF_PX}px`; layer.append(up, down); if (!isEdited) return; @@ -221,7 +247,7 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { `${label} — 자동 선형으로 원복 (현재 ${offset >= 0 ? "+" : ""}${offset.toFixed(2)}m)`, () => onResetStation(station.chainage_m), ); - reset.style.left = `${left - 9}px`; + reset.style.left = `${left - BUTTON_HALF_PX}px`; layer.append(reset); }); @@ -229,14 +255,14 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { const left = x(segment.from_m); const right = x(segment.to_m); if (right - left < 36) return; - const center = (left + right) / 2; + const center = avoidStations((left + right) / 2); const label = `구간 ${segment.from_m.toFixed(0)}~${segment.to_m.toFixed(0)}m ` + `(구배 ${segment.grade_percent.toFixed(2)}%) 전체 평행이동`; const up = overlayButton(repeater, "is-segment is-up", "⇧", `${label} — ${step}m 올림`, () => onSegment(segment, step), ); - up.style.left = `${center - 9}px`; + up.style.left = `${center - BUTTON_HALF_PX}px`; const down = overlayButton( repeater, "is-segment is-down", @@ -244,7 +270,7 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { `${label} — ${step}m 내림`, () => onSegment(segment, -step), ); - down.style.left = `${center - 9}px`; + down.style.left = `${center - BUTTON_HALF_PX}px`; layer.append(up, down); }); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index a9bba231..e7aeb20a 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -146,15 +146,19 @@ function stationCellWidth(data: LongitudinalSection, x: (chainage: number) => nu } /** - * 테이블 값이 목표 글자 크기로 잘리지 않으려면 캔버스가 최소 이만큼 넓어야 한다. + * 측점 한 칸의 고정 폭. 목표 글자 크기(12px)로 7자리 값이 잘리지 않는 크기다. + * 화면 폭에 맞춰 늘였다 줄였다 하지 않고 이 값을 유지하고, 남는 가로는 스크롤로 훑는다. + */ +const STATION_COLUMN_PX = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL_GAP_PX; + +/** + * 측점 칸 폭을 고정했을 때 필요한 캔버스 폭. * * 종단면도 렌더러의 최소 폭(`longitudinalMinimumWidth`)은 그래프 아래 **측점 라벨**만 * 안 겹치면 되는 기준이라 열이 48px밖에 안 돼, 7자리 값(`3000.00`)을 담기엔 좁다. - * 가로 스크롤로 훑는 화면이므로 폭을 늘려 글자 크기를 확보하는 편이 낫다. */ function tableMinimumWidth(stationCount: number): number { - const column = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL_GAP_PX; - return LONG_PAD.left + LONG_PAD.right + Math.max(1, stationCount) * column; + return LONG_PAD.left + LONG_PAD.right + Math.max(1, stationCount) * STATION_COLUMN_PX; } export function createRouteProfilePanel( @@ -312,6 +316,8 @@ export function createRouteProfilePanel( width, height: tableHeight, cellWidth: stationCellWidth(longitudinal, x), + // 이름표 열을 그래프 좌측 여백과 같은 폭으로 맞춰야 그래프 시작점이 가려지지 않는다. + labelWidth: LONG_PAD.left, rowCount: TABLE_ROW_COUNT, x, onCurveRadiusChange: (curve, radius) => diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts index 86f24164..1e9d7092 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts @@ -27,6 +27,8 @@ export interface ProfileTableOptions { height: number; /** 이웃 측점과 겹치지 않는 셀 폭(px). */ cellWidth: number; + /** 행 이름표 열의 폭(px). 그래프의 좌측 여백과 같아야 X축이 맞물린다. */ + labelWidth: number; rowCount: number; /** 종단면도와 공유하는 chainage → x(px) 매핑. */ x: (chainageM: number) => number; @@ -35,6 +37,7 @@ export interface ProfileTableOptions { interface StationRowSpec { label: string; + unit: string; cell: (index: number) => string; modifier?: string; } @@ -92,17 +95,23 @@ function placeCell(row: HTMLElement, centerX: number, node: HTMLElement): void { row.append(node); } -function createRow(className: string, label: string): HTMLElement { +/** + * 행 하나. 이름표는 sticky로 좌측에 고정되며 **그래프의 좌측 여백과 같은 폭**을 쓴다. + * 단위는 이름표를 좁게 유지하려고 툴팁으로 뺐다(넓히면 그래프 시작점을 가린다). + */ +function createRow(className: string, label: string, unit: string): HTMLElement { const row = element("div", `b05-profile-table__row ${className}`); - row.append(element("span", "b05-profile-table__label", label)); + const caption = element("span", "b05-profile-table__label", label); + caption.title = `${label} (${unit})`; + row.append(caption); return row; } /** 도면의 구배 블록 3행: 구간 연장(m) / 고저차(m) / 기울기(%). 단위는 행 이름표가 대신한다. */ const SEGMENT_ROWS: SegmentRowSpec[] = [ - { label: "구배 L", unit: "m", cell: (segment) => segment.length_m.toFixed(2) }, - { label: "구배 H", unit: "m", cell: (segment) => segment.height_m.toFixed(2) }, - { label: "구배 S", unit: "%", cell: (segment) => segment.grade_percent.toFixed(2) }, + { label: "구배 L", unit: "m 구간 연장", cell: (segment) => segment.length_m.toFixed(2) }, + { label: "구배 H", unit: "m 구간 고저차", cell: (segment) => segment.height_m.toFixed(2) }, + { label: "구배 S", unit: "% 구간 기울기", cell: (segment) => segment.grade_percent.toFixed(2) }, ]; function buildStationRows(alignment: ProfileAlignment, interval: number): StationRowSpec[] { @@ -110,51 +119,60 @@ function buildStationRows(alignment: ProfileAlignment, interval: number): Statio return [ { label: "절토고", + unit: "m", modifier: "cut", cell: (index) => (stations[index].cut_m > 0.005 ? stations[index].cut_m.toFixed(2) : ""), }, { label: "성토고", + unit: "m", modifier: "fill", cell: (index) => (stations[index].fill_m > 0.005 ? stations[index].fill_m.toFixed(2) : ""), }, { label: "계획고", + unit: "m", modifier: "plan", cell: (index) => stations[index].plan_elevation_m.toFixed(2), }, - { label: "지반고", cell: (index) => stations[index].ground_elevation_m.toFixed(2) }, - { label: "누가거리", cell: (index) => stations[index].chainage_m.toFixed(2) }, - { label: "거리", cell: (index) => (index ? stations[index].distance_m.toFixed(2) : "") }, - { label: "측점", cell: (index) => stationLabel(stations[index].chainage_m, interval) }, + { label: "지반고", unit: "m", cell: (index) => stations[index].ground_elevation_m.toFixed(2) }, + { label: "누가거리", unit: "m", cell: (index) => stations[index].chainage_m.toFixed(2) }, + { + label: "거리", + unit: "m 전 측점 대비", + cell: (index) => (index ? stations[index].distance_m.toFixed(2) : ""), + }, + { + label: "측점", + unit: "측점번호+잔여거리", + cell: (index) => stationLabel(stations[index].chainage_m, interval), + }, ]; } /** - * 구배 3행. 각 블록은 **곡선 구간(BVC~EVC)을 뺀 실제 직선 부분**만 덮는다. - * 변화점 자리는 곡선이 차지하므로 그 구간에 구배를 적어 두면 존재하지 않는 직선의 - * 값을 읽는 셈이 된다. 값(L/H/S) 자체는 도면 관례대로 변화점 사이 기준이다. + * 구배 3행. 각 블록은 **변화점에서 변화점까지**를 덮는다. + * + * 구분선을 곡선의 접선점(BVC/EVC)에 두면 곡선 길이만큼 블록 사이에 틈이 생기고, + * 양옆 블록의 테두리가 그 틈을 감싸 "빈 셀"처럼 보인다. 변화점은 곧 **생성된 R의 + * 중심**이자 측점 수직선이므로 그 자리를 구분선으로 삼으면 블록이 빈틈없이 이어지고, + * 표기 값(L/H/S)이 변화점 사이 기준이라는 점과도 일치한다. */ function buildSegmentRows( alignment: ProfileAlignment, x: (chainage: number) => number, fontPx: number, ): HTMLElement[] { - const curveByPvi = new Map( - alignment.curves.filter((curve) => !curve.omitted).map((curve) => [curve.pvi_index, curve]), - ); return SEGMENT_ROWS.map((spec) => { - const row = createRow("b05-profile-table__row--grade", `${spec.label} (${spec.unit})`); + const row = createRow("b05-profile-table__row--grade", spec.label, spec.unit); alignment.segments.forEach((segment) => { - const startCurve = curveByPvi.get(segment.index); - const endCurve = curveByPvi.get(segment.index + 1); - const left = x(startCurve ? startCurve.evc_m : segment.from_m); - const span = x(endCurve ? endCurve.bvc_m : segment.to_m) - left; + const left = x(segment.from_m); + const span = x(segment.to_m) - left; const text = spec.cell(segment); - // 측점을 편집해 구간이 잘게 쪼개지면 글자가 안 들어가 테두리만 남은 빈 칸이 된다. - // 그럴 땐 블록 자체를 만들지 않는다(값은 계획고·거리 행과 그래프에서 읽을 수 있다). - if (!segmentTextFits(text, span, fontPx)) return; - const node = element("span", "b05-profile-table__segment", text); + const node = element("span", "b05-profile-table__segment", ""); + // 구간이 짧아 글자가 안 들어가면 숫자만 비운다. 블록을 지우면 그 자리가 다시 + // 틈으로 남아 빈 셀처럼 보이므로, 칸은 유지하고 값은 툴팁으로 읽게 한다. + if (segmentTextFits(text, span, fontPx)) node.textContent = text; node.style.left = `${left}px`; node.style.width = `${span}px`; node.title = @@ -182,61 +200,80 @@ function curveTitle(curve: AlignmentCurve): string { ); } -/** 곡선 2행: 반경 R(입력·1차 값) / 곡선길이 L(= R × |대수차| 파생). */ +/** + * 곡선 2행: 반경 R(입력·1차 값) / 곡선길이 L(= R × |대수차| 파생). + * + * 곡선이 없는 측점에도 **빈 칸을 만든다**. 변화점에만 셀을 두면 세로 구분선이 띄엄띄엄 + * 끊겨 위쪽 측점값 행들과 격자가 맞지 않는다. + */ function buildCurveRows(options: ProfileTableOptions): HTMLElement[] { const { alignment, x, onCurveRadiusChange } = options; - const lengthRow = createRow("b05-profile-table__row--curve is-group-start", "곡선 L (m)"); - const radiusRow = createRow("b05-profile-table__row--curve", "곡선 R (m)"); - - alignment.curves.forEach((curve) => { - const lengthCell = element("span", "b05-profile-table__curve", curve.l_m.toFixed(2)); - lengthCell.title = curveTitle(curve); + const lengthRow = createRow( + "b05-profile-table__row--curve is-group-start", + "곡선 L", + "m 종단곡선 길이 (R × |대수차|)", + ); + const radiusRow = createRow("b05-profile-table__row--curve", "곡선 R", "m 종단곡선 반경 (입력)"); + const curveByChainage = new Map( + alignment.curves.map((curve) => [curve.chainage_m.toFixed(3), curve]), + ); + alignment.stations.forEach((station) => { + const lengthCell = element("span", "b05-profile-table__curve"); const radiusCell = element("span", "b05-profile-table__curve"); - const input = document.createElement("input"); - input.type = "number"; - input.step = "1"; - input.min = "1"; - input.className = "b05-profile-table__radius"; - input.value = curve.r_m.toFixed(1); - input.title = `종단곡선 반경 R (m) — 이 값이 기준이고 곡선길이 L이 따라 계산됩니다.\n${curveTitle(curve)}`; - input.addEventListener("change", () => { - const parsed = Number.parseFloat(input.value); - onCurveRadiusChange(curve, Number.isFinite(parsed) && parsed > 0 ? parsed : null); - }); - radiusCell.append(input); + const curve = curveByChainage.get(station.chainage_m.toFixed(3)); + if (curve) { + lengthCell.textContent = curve.l_m.toFixed(2); + lengthCell.title = curveTitle(curve); - [lengthCell, radiusCell].forEach((cell) => { - if (curve.skip_allowed) cell.classList.add("is-optional"); - if (curve.omitted) cell.classList.add("is-omitted"); - }); - placeCell(lengthRow, x(curve.chainage_m), lengthCell); - placeCell(radiusRow, x(curve.chainage_m), radiusCell); + const input = document.createElement("input"); + input.type = "number"; + input.step = "1"; + input.min = "1"; + input.className = "b05-profile-table__radius"; + input.value = curve.r_m.toFixed(1); + input.title = `종단곡선 반경 R (m) — 이 값이 기준이고 곡선길이 L이 따라 계산됩니다.\n${curveTitle(curve)}`; + input.addEventListener("change", () => { + const parsed = Number.parseFloat(input.value); + onCurveRadiusChange(curve, Number.isFinite(parsed) && parsed > 0 ? parsed : null); + }); + radiusCell.append(input); + + [lengthCell, radiusCell].forEach((cell) => { + if (curve.skip_allowed) cell.classList.add("is-optional"); + if (curve.omitted) cell.classList.add("is-omitted"); + }); + } + placeCell(lengthRow, x(station.chainage_m), lengthCell); + placeCell(radiusRow, x(station.chainage_m), radiusCell); }); return [lengthRow, radiusRow]; } export function createProfileTable(options: ProfileTableOptions): HTMLElement { - const { alignment, stationInterval, width, height, cellWidth, rowCount, x } = options; + const { alignment, stationInterval, width, height, cellWidth, labelWidth, rowCount, x } = options; const table = element("div", "b05-profile-table"); const fontSize = fitFontSize(height / Math.max(rowCount, 1), cellWidth); + // 이름표는 "누가거리"(한글 4자)가 들어가야 하므로 값 글자보다 크지 않게 제한한다. + const labelFontSize = Math.min(fontSize, Math.floor((labelWidth - 10) / 4)); table.style.width = `${width}px`; table.style.height = `${height}px`; table.style.setProperty("--b05-table-font", `${fontSize}px`); table.style.setProperty("--b05-table-cell-width", `${cellWidth}px`); - // 행 이름표는 가장 긴 "구배 L (m)"이 잘리지 않을 만큼만 차지한다. - table.style.setProperty("--b05-table-label-width", `${Math.round(fontSize * 5.2 + 14)}px`); + table.style.setProperty("--b05-table-label-width", `${labelWidth}px`); + table.style.setProperty("--b05-table-label-font", `${Math.max(8, labelFontSize)}px`); table.append(...buildSegmentRows(alignment, x, fontSize)); buildStationRows(alignment, stationInterval).forEach((spec, index) => { const row = createRow( `${spec.modifier ? `is-${spec.modifier}` : ""}${index === 0 ? " is-group-start" : ""}`, spec.label, + spec.unit, ); + // 값이 없는 측점(절토고/성토고 중 한쪽)도 빈 칸을 만들어야 세로 구분선이 끊기지 않는다. alignment.stations.forEach((station, stationIndex) => { - const value = spec.cell(stationIndex); - if (!value) return; - placeCell(row, x(station.chainage_m), element("span", "b05-profile-table__cell", value)); + const cell = element("span", "b05-profile-table__cell", spec.cell(stationIndex)); + placeCell(row, x(station.chainage_m), cell); }); table.append(row); }); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index b2a5c5c0..b9ef068a 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -119,6 +119,13 @@ height: 100%; } +/* 축 제목과 측점 라벨은 아래 도면 테이블(측점 행)이 그대로 담고 있어 중복이다. + 지우면 그래프 상·하단이 비어 편집 버튼이 가려지지 않고, 좌측 여백도 이름표 열로 쓸 수 있다. */ +.b05-route-profile .b06-chart__axis-label, +.b05-route-profile .b06-chart__station-label { + display: none; +} + .b05-route__viewport canvas { display: block; width: 100%; @@ -398,9 +405,10 @@ border-bottom: 0; } +/* 가로 스크롤 시 값들이 이름표 열을 뚫고 보이지 않도록 가장 위 층에 불투명하게 둔다. */ .b05-profile-table__label { position: sticky; - z-index: 3; + z-index: 5; left: 0; display: inline-flex; align-items: center; @@ -411,7 +419,9 @@ border-right: 2px solid var(--color-text-muted, var(--color-plum-velvet)); background: var(--color-surface-raised); color: var(--color-text); + font-size: var(--b05-table-label-font, inherit); font-weight: var(--font-weight-medium); + white-space: nowrap; } .b05-profile-table__cell, @@ -426,13 +436,21 @@ overflow: hidden; } -/* 측점 값 셀은 측점 수직선을 중심으로 좌우 대칭 배치된다. */ +/* 측점 값 셀은 측점 수직선을 중심으로 좌우 대칭 배치된다. + 셀 경계(= 이웃 측점과의 중간)에 세로 구분선을 둬 구배 행과 같은 격자를 만든다. */ .b05-profile-table__cell, .b05-profile-table__curve { + box-sizing: border-box; width: var(--b05-table-cell-width, 56px); + border-left: 1px solid var(--color-border); transform: translateX(-50%); } +.b05-profile-table__cell:last-child, +.b05-profile-table__curve:last-child { + border-right: 1px solid var(--color-border); +} + .b05-profile-table__row.is-cut .b05-profile-table__cell { color: rgb(220 38 38); } @@ -446,10 +464,15 @@ font-weight: var(--font-weight-medium); } -/* 구간 블록: 변화점 사이 직선 전체를 덮어 어느 구간의 값인지 한눈에 보이게 한다. */ +/* 구간 블록: 변화점 사이를 빈틈없이 채운다. + 구분선은 변화점(= 생성된 R의 중심, 측점 수직선) 위에 놓이므로 왼쪽 테두리 하나면 + 충분하다. 양쪽에 다 주면 맞닿는 자리가 2px로 두꺼워진다. */ .b05-profile-table__segment { box-sizing: border-box; border-left: 1px solid var(--color-border); +} + +.b05-profile-table__segment:last-child { border-right: 1px solid var(--color-border); } @@ -460,7 +483,6 @@ .b05-profile-table__curve { z-index: 2; - background: var(--color-surface-raised); } .b05-profile-table__curve.is-optional { @@ -532,17 +554,16 @@ bottom: 2px; } -.b05-profile-edit__btn.is-segment.is-up { - top: 19px; -} - -.b05-profile-edit__btn.is-segment.is-down { - bottom: 19px; +/* 구간 시프트 버튼은 측점 버튼과 같은 줄(is-up/is-down)에 놓이고, 겹치는 자리에서만 + 렌더러가 좌우로 비켜 배치한다. 구분을 위해 색만 달리한다. */ +.b05-route-profile:hover .b05-profile-edit__btn.is-segment { + border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 40%, transparent); + color: var(--color-royal-amethyst, rgb(109 40 217)); } .b05-profile-edit__btn.is-reset, .b05-route-profile:hover .b05-profile-edit__btn.is-reset { - top: 36px; + top: 20px; border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 45%, transparent); background: var(--color-surface-raised); color: var(--color-royal-amethyst, rgb(109 40 217));