Restructure Plan UI for semi-auto tabs and T-quote ladder.

Hide amplitude card when filter off; compact semi form with rules; monitor tab keeps positions and market.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-08 14:01:12 +08:00
parent 457838ef64
commit d98bf46549
4 changed files with 632 additions and 189 deletions
+14 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from ..market import get_gateway
from .auth import require_user
@@ -18,6 +18,19 @@ async def market_snapshot(_user: Annotated[str, Depends(require_user)]) -> dict:
return snap
@router.get("/option-ladder")
async def market_option_ladder(
_user: Annotated[str, Depends(require_user)],
wings: int = Query(default=4, ge=1, le=12),
) -> dict:
"""半自动页 T 型报价:ATM 上下各 wings 档。"""
gw = get_gateway()
ladder = getattr(gw, "option_ladder", None)
if not callable(ladder):
raise HTTPException(status_code=501, detail="当前会话不支持 option-ladder")
return ladder(wings=wings)
@router.post("/realign")
async def market_realign(_user: Annotated[str, Depends(require_user)]) -> dict:
"""手动重对齐次日到期 ATM 合约(运维/调试用)。"""
+93
View File
@@ -1058,6 +1058,99 @@ class StrategySession:
d["ask_compare"] = ac
return d
def option_ladder(self, *, wings: int = 4) -> dict[str, Any]:
"""
T 型报价:当前监控到期附近若干档 Call/Put 盘口。
行:虚值 Call(上行)/ ATM / 虚值 Put(下行);列:Call 卖一·杠杆 | 行权价 | Put 卖一·杠杆。
"""
s = self.settings
wings = max(1, min(12, int(wings)))
idx = self.ex.fetch_index(s.index_inst_id)
mark = self.ex.fetch_mark(s.perp_inst_id) or idx
if mark is None or float(mark) <= 0:
return {"ok": False, "detail": "无标的价", "rows": [], "index_px": None}
underlying = float(mark)
contracts = self.ex.list_option_contracts(s.option_inst_family)
from .selection import _complete_by_expiry, pick_atm_strike
eligible = list_eligible_expiry_ymds(contracts, min_hours=1.0)
ymd = None
if self._pair is not None and self._pair.expiry_ymd:
ymd = str(self._pair.expiry_ymd)
if not ymd and eligible:
ymd = eligible[0]
if not ymd:
return {
"ok": False,
"detail": "无合格到期",
"rows": [],
"index_px": underlying,
}
complete = _complete_by_expiry(contracts)
if ymd not in complete:
return {
"ok": False,
"detail": f"到期 {ymd} 无完整对",
"rows": [],
"index_px": underlying,
"expiry_ymd": ymd,
}
_ems, strikes_map = complete[ymd]
strikes = sorted(float(k) for k in strikes_map.keys())
atm = pick_atm_strike(strikes, underlying)
if atm is None:
return {
"ok": False,
"detail": "无 ATM",
"rows": [],
"index_px": underlying,
"expiry_ymd": ymd,
}
atm_i = min(range(len(strikes)), key=lambda i: abs(strikes[i] - float(atm)))
lo = max(0, atm_i - wings)
hi = min(len(strikes), atm_i + wings + 1)
rows: list[dict[str, Any]] = []
for k in strikes[lo:hi]:
legs = strikes_map[k]
call_id = legs.get("C")
put_id = legs.get("P")
cq = self.ex.quote(str(call_id)) if call_id else None
pq = self.ex.quote(str(put_id)) if put_id else None
c_ask = float(cq.ask) if cq and cq.ask is not None else None
p_ask = float(pq.ask) if pq and pq.ask is not None else None
c_bid = float(cq.bid) if cq and cq.bid is not None else None
p_bid = float(pq.bid) if pq and pq.bid is not None else None
off = float(k) - underlying
if abs(float(k) - float(atm)) < 1e-9:
tag = "atm"
elif float(k) > underlying + 1e-9:
tag = "otm_call" # Call 虚值 / Put 实值
else:
tag = "otm_put" # Put 虚值 / Call 实值
rows.append(
{
"strike": float(k),
"offset": round(off, 2),
"tag": tag,
"call_ask": c_ask,
"call_bid": c_bid,
"call_lev": option_leverage(underlying, c_ask),
"put_ask": p_ask,
"put_bid": p_bid,
"put_lev": option_leverage(underlying, p_ask),
"call_inst_id": call_id,
"put_inst_id": put_id,
}
)
return {
"ok": True,
"detail": "",
"index_px": underlying,
"expiry_ymd": ymd,
"atm_strike": float(atm),
"rows": rows,
}
async def _refresh_loop(self) -> None:
while True:
await asyncio.sleep(30 if self._pair is not None else 10)
+387 -188
View File
@@ -122,6 +122,23 @@ export default function PlanPage() {
const [semiPerpU, setSemiPerpU] = useState(1);
const [semiOptU, setSemiOptU] = useState(4);
const [semiDirty, setSemiDirty] = useState(false);
const [planTab, setPlanTab] = useState<"semi" | "monitor">("semi");
const [ladder, setLadder] = useState<{
ok?: boolean;
detail?: string;
index_px?: number | null;
expiry_ymd?: string;
atm_strike?: number;
rows?: Array<{
strike: number;
offset: number;
tag: string;
call_ask: number | null;
call_lev: number | null;
put_ask: number | null;
put_lev: number | null;
}>;
} | null>(null);
const semiParamsBody = () => ({
semi_view_side: semiView,
@@ -149,12 +166,26 @@ export default function PlanPage() {
setSemiExitU(Number(p.semi_perp_exit_unit ?? 5));
setSemiMinH(Number(p.semi_min_option_hours ?? 30));
setSemiMinLev(Number(p.semi_min_option_leverage ?? 200));
const m = p.semi_moneyness;
setSemiMny(m === "itm" || m === "atm" || m === "otm" ? m : "otm");
const mny = p.semi_moneyness;
setSemiMny(mny === "itm" || mny === "atm" || mny === "otm" ? mny : "otm");
setSemiOtmOff(Number(p.semi_otm_max_offset ?? 25));
setSemiPerpU(Number(p.semi_perp_unit ?? 1));
setSemiOptU(Number(p.semi_option_unit ?? 4));
}
if (
p.semi_auto_enabled &&
String(p.hedge_mode || "") !== "option_option"
) {
try {
setLadder(
await apiFetch<NonNullable<typeof ladder>>(
"/api/market/option-ladder?wings=4",
),
);
} catch {
/* ignore ladder errors */
}
}
setErr("");
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
@@ -276,6 +307,8 @@ export default function PlanPage() {
snap?.hedge_mode === "option_option" ||
pos?.hedge_mode === "option_option" ||
!!pos?.option2_inst_id;
const semiOn = !!plan?.semi_auto_enabled && !isOo;
const showAmpCard = plan?.oo_amplitude_filter_enabled === true;
const exitMode = plan?.exit_mode ?? "fixed_usdt";
const riskBased =
plan?.risk_based === true || plan?.sizing_mode === "risk_based";
@@ -573,209 +606,351 @@ export default function PlanPage() {
{busy ? <span className="meta">{busy}</span> : null}
</div>
{plan?.semi_auto_enabled && !isOo ? (
<section className="card plan-semi-card" aria-label="半自动本单">
<h3 className="plan-panel-title"> · </h3>
<p className="meta" style={{ marginTop: 0 }}>
// 180200±&gt;0×k
</p>
<div className="settings-fields" style={{ marginTop: 8 }}>
<div className="field">
<label htmlFor="semiView"></label>
<select
id="semiView"
className="mono"
disabled={open || !!busy || !!plan.semi_armed}
value={semiView}
onChange={(e) => {
setSemiView(e.target.value === "short" ? "short" : "long");
setSemiDirty(true);
}}
>
<option value="long"> · Call + </option>
<option value="short"> · Put + </option>
</select>
{semiOn ? (
<div className="tabs plan-main-tabs" role="tablist" aria-label="计划页">
<button
type="button"
role="tab"
className={planTab === "semi" ? "tab active" : "tab"}
aria-selected={planTab === "semi"}
onClick={() => setPlanTab("semi")}
>
</button>
<button
type="button"
role="tab"
className={planTab === "monitor" ? "tab active" : "tab"}
aria-selected={planTab === "monitor"}
onClick={() => setPlanTab("monitor")}
>
</button>
</div>
) : null}
{semiOn && planTab === "semi" ? (
<div className="plan-semi-tab">
<section className="card plan-semi-card plan-semi-compact" aria-label="半自动本单">
<div className="plan-semi-head">
<h3 className="plan-panel-title"> · </h3>
<span className="mono meta">
{plan.semi_armed ? "已授权" : "未授权"}
{" · "}
{semiMny === "itm" ? "实值" : semiMny === "atm" ? "平值" : "虚值"}
{" · "}
{fmt(semiPerpU, 0)}:{fmt(semiOptU, 0)}
</span>
</div>
<div className="field">
<label htmlFor="semiMny"></label>
<select
id="semiMny"
className="mono"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMny}
onChange={(e) => {
const v = e.target.value;
setSemiMny(
v === "itm" || v === "atm" || v === "otm" ? v : "otm",
);
setSemiDirty(true);
}}
>
<option value="itm"></option>
<option value="atm"></option>
<option value="otm"></option>
</select>
</div>
{semiMny === "otm" ? (
<details className="plan-semi-rules">
<summary></summary>
<ul>
<li>永续:期权配比与出场 //</li>
<li></li>
<li> 180 200/</li>
<li> ± &gt; 0 </li>
<li> × k </li>
<li> k</li>
</ul>
</details>
<div className="settings-fields plan-semi-fields">
<div className="field">
<label htmlFor="semiOtmOff"></label>
<label htmlFor="semiView"></label>
<select
id="semiView"
className="mono"
disabled={open || !!busy || !!plan.semi_armed}
value={semiView}
onChange={(e) => {
setSemiView(e.target.value === "short" ? "short" : "long");
setSemiDirty(true);
}}
>
<option value="long"> · Call+</option>
<option value="short"> · Put+</option>
</select>
</div>
<div className="field">
<label htmlFor="semiMny"></label>
<select
id="semiMny"
className="mono"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMny}
onChange={(e) => {
const v = e.target.value;
setSemiMny(
v === "itm" || v === "atm" || v === "otm" ? v : "otm",
);
setSemiDirty(true);
}}
>
<option value="itm"></option>
<option value="atm"></option>
<option value="otm"></option>
</select>
</div>
{semiMny === "otm" ? (
<div className="field">
<label htmlFor="semiOtmOff"></label>
<input
id="semiOtmOff"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiOtmOff}
onChange={(e) => {
setSemiOtmOff(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
) : null}
<div className="field">
<label htmlFor="semiPerpU"></label>
<input
id="semiOtmOff"
id="semiPerpU"
className="mono"
type="number"
step="0.1"
min="0.01"
disabled={open || !!busy || !!plan.semi_armed}
value={semiPerpU}
onChange={(e) => {
setSemiPerpU(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiOptU"></label>
<input
id="semiOptU"
className="mono"
type="number"
step="0.1"
min="0.01"
disabled={open || !!busy || !!plan.semi_armed}
value={semiOptU}
onChange={(e) => {
setSemiOptU(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiMove"></label>
<input
id="semiMove"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiOtmOff}
value={semiMove}
onChange={(e) => {
setSemiOtmOff(Number(e.target.value));
setSemiMove(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiExitU"></label>
<input
id="semiExitU"
className="mono"
type="number"
step="0.1"
min="0.1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiExitU}
onChange={(e) => {
setSemiExitU(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiMinH"> h</label>
<input
id="semiMinH"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMinH}
onChange={(e) => {
setSemiMinH(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiMinLev"></label>
<input
id="semiMinLev"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMinLev}
onChange={(e) => {
setSemiMinLev(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
) : null}
<div className="field">
<label htmlFor="semiPerpU"></label>
<input
id="semiPerpU"
className="mono"
type="number"
step="0.1"
min="0.01"
disabled={open || !!busy || !!plan.semi_armed}
value={semiPerpU}
onChange={(e) => {
setSemiPerpU(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiOptU"></label>
<input
id="semiOptU"
className="mono"
type="number"
step="0.1"
min="0.01"
disabled={open || !!busy || !!plan.semi_armed}
value={semiOptU}
onChange={(e) => {
setSemiOptU(Number(e.target.value));
setSemiDirty(true);
}}
/>
<div className="mono meta plan-semi-summary">
{(() => {
const idx = snap?.index_px;
if (idx == null || !Number.isFinite(Number(idx))) {
return `净利目标≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U`;
}
const n = Number(idx);
const tgt =
semiView === "long"
? n + Number(semiMove)
: n - Number(semiMove);
return `${fmtExPx("index", n)}${fmtExPx("index", tgt)} · 净利≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U`;
})()}
</div>
<div className="field">
<label htmlFor="semiMove"> · </label>
<input
id="semiMove"
className="mono"
type="number"
step="1"
min="1"
<div className="plan-semi-btns">
<button
className="btn ghost"
type="button"
disabled={open || !!busy || !semiDirty}
onClick={() => void saveSemiParams()}
>
</button>
<button
className="btn"
type="button"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMove}
onChange={(e) => {
setSemiMove(Number(e.target.value));
setSemiDirty(true);
}}
/>
onClick={() => void armSemi(true)}
>
</button>
<button
className="btn ghost"
type="button"
disabled={open || !!busy || !plan.semi_armed}
onClick={() => void armSemi(false)}
>
</button>
</div>
<div className="field">
<label htmlFor="semiExitU">U×k</label>
<input
id="semiExitU"
className="mono"
type="number"
step="0.1"
min="0.1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiExitU}
onChange={(e) => {
setSemiExitU(Number(e.target.value));
setSemiDirty(true);
}}
/>
</section>
<section className="card plan-t-quote" aria-label="T型报价">
<div className="plan-semi-head">
<h3 className="plan-panel-title">T · / / </h3>
<span className="mono meta">
{ladder?.expiry_ymd
? `到期 ${ladder.expiry_ymd}`
: "—"}
{ladder?.atm_strike != null
? ` · ATM ${Math.round(ladder.atm_strike)}`
: ""}
{ladder?.index_px != null
? ` · 指数 ${fmtExPx("index", ladder.index_px)}`
: ""}
</span>
</div>
<div className="field">
<label htmlFor="semiMinH"> · h</label>
<input
id="semiMinH"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMinH}
onChange={(e) => {
setSemiMinH(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiMinLev"> · </label>
<input
id="semiMinLev"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMinLev}
onChange={(e) => {
setSemiMinLev(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
</div>
<div className="mono meta" style={{ marginTop: 8 }}>
{(() => {
const idx = snap?.index_px;
if (idx == null || !Number.isFinite(Number(idx))) {
return `目标净利≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U · 指数 —`;
}
const n = Number(idx);
const tgt =
semiView === "long" ? n + Number(semiMove) : n - Number(semiMove);
const mnyZh =
semiMny === "itm" ? "实值" : semiMny === "atm" ? "平值" : "虚值";
return `指数 ${fmtExPx("index", n)} → 到点 ${fmtExPx("index", tgt)} · ${mnyZh}${
semiMny === "otm" ? `${fmt(semiOtmOff, 0)}` : ""
} · 配比 ${fmt(semiPerpU, 2)}:${fmt(semiOptU, 2)} · 净利目标≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U · ${plan.semi_armed ? "已授权盯开" : "未授权"}`;
})()}
</div>
<div className="plan-actions" style={{ marginTop: 10, paddingTop: 0 }}>
<button
className="btn ghost"
type="button"
disabled={open || !!busy || !semiDirty}
onClick={() => void saveSemiParams()}
>
</button>
<button
className="btn"
type="button"
disabled={open || !!busy || !!plan.semi_armed}
onClick={() => void armSemi(true)}
>
</button>
<button
className="btn ghost"
type="button"
disabled={open || !!busy || !plan.semi_armed}
onClick={() => void armSemi(false)}
>
</button>
</div>
</section>
{!ladder?.ok ? (
<p className="meta">{ladder?.detail || "暂无报价链"}</p>
) : (
<div className="plan-t-wrap">
<table className="plan-t-table">
<thead>
<tr>
<th>Call卖一</th>
<th>Call杠杆</th>
<th></th>
<th></th>
<th>Put杠杆</th>
<th>Put卖一</th>
</tr>
</thead>
<tbody>
{[...(ladder.rows || [])]
.slice()
.reverse()
.map((r) => {
const tagZh =
r.tag === "atm"
? "平值"
: r.tag === "otm_call"
? "Call虚/Put实"
: "Put虚/Call实";
const hi =
(semiMny === "otm" &&
((semiView === "long" && r.tag === "otm_call") ||
(semiView === "short" && r.tag === "otm_put")) &&
Math.abs(r.offset) <= Number(semiOtmOff) + 1e-9) ||
(semiMny === "atm" && r.tag === "atm") ||
(semiMny === "itm" &&
((semiView === "long" &&
(r.tag === "otm_put" || r.tag === "atm")) ||
(semiView === "short" &&
(r.tag === "otm_call" || r.tag === "atm"))));
return (
<tr
key={r.strike}
className={
r.tag === "atm"
? "plan-t-atm"
: hi
? "plan-t-pick"
: undefined
}
>
<td className="mono">
{r.call_ask != null
? fmtExPx("option", r.call_ask)
: "—"}
</td>
<td className="mono">
{r.call_lev != null
? `${r.call_lev.toFixed(0)}x`
: "—"}
</td>
<td className="mono plan-t-k">
{Math.round(r.strike)}
<span className="meta">
{" "}
({r.offset >= 0 ? "+" : ""}
{fmt(r.offset, 0)})
</span>
</td>
<td>{tagZh}</td>
<td className="mono">
{r.put_lev != null
? `${r.put_lev.toFixed(0)}x`
: "—"}
</td>
<td className="mono">
{r.put_ask != null
? fmtExPx("option", r.put_ask)
: "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</div>
) : null}
{!semiOn || planTab === "monitor" ? (
<div className="plan-shell">
<div className="plan-board">
<div className="card plan-strategy">
@@ -798,7 +973,18 @@ export default function PlanPage() {
<div className="kv">
<span></span>
<span className="mono">
{riskBased ? (
{semiOn ? (
<span className="status-running">
·{" "}
{semiMny === "itm"
? "实值"
: semiMny === "atm"
? "平值"
: "虚值"}{" "}
· {fmt(semiPerpU, 0)}:{fmt(semiOptU, 0)}
{plan?.semi_armed ? " · 已授权" : " · 未授权"}
</span>
) : riskBased ? (
<span className="status-running">{sizingModeLabel}</span>
) : (
sizingModeLabel
@@ -808,7 +994,17 @@ export default function PlanPage() {
<div className="kv">
<span></span>
<span className="mono">
{isOo ? (
{semiOn ? (
<>
{fmt(semiMinH, 0)}h · {fmt(semiMinLev, 0)}x
{semiMny === "otm"
? ` · 虚值≤${fmt(semiOtmOff, 0)}`
: ""}
{" · "}
±{fmt(semiMove, 0)}&gt;0 /
{fmt(semiExitU, 1)}×k
</>
) : isOo ? (
ooSelectLabel
) : (
<>
@@ -1326,7 +1522,8 @@ export default function PlanPage() {
</div>
<div className="plan-market">
<div className="card">
{showAmpCard ? (
<div className="card plan-card-compact">
<h3 className="plan-panel-title"> / </h3>
<div className="kv">
<span></span>
@@ -1375,6 +1572,7 @@ export default function PlanPage() {
</span>
</div>
</div>
) : null}
{!isOo ? (
<div className="card">
<h3 className="plan-panel-title"></h3>
@@ -1617,6 +1815,7 @@ export default function PlanPage() {
</div>
) : null}
</div>
) : null}
</div>
);
}
+138
View File
@@ -468,6 +468,144 @@ input {
color: #5ec8ff;
}
.plan-main-tabs {
margin-top: 4px;
margin-bottom: 10px;
}
.plan-semi-tab {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
}
.plan-semi-compact {
padding: 10px 12px;
}
.plan-semi-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.plan-semi-head .plan-panel-title {
margin: 0;
font-size: 13px;
}
.plan-semi-rules {
margin: 0 0 8px;
font-size: 12px;
color: var(--muted);
}
.plan-semi-rules summary {
cursor: pointer;
color: #5ec8ff;
user-select: none;
}
.plan-semi-rules ul {
margin: 6px 0 0;
padding-left: 1.2em;
line-height: 1.45;
}
.plan-semi-fields {
margin-top: 0 !important;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)) !important;
gap: 8px !important;
}
.plan-semi-fields .field label {
font-size: 11px;
}
.plan-semi-fields .field input,
.plan-semi-fields .field select {
padding: 6px 8px;
font-size: 12px;
min-height: 32px;
}
.plan-semi-summary {
margin-top: 6px !important;
font-size: 12px;
}
.plan-semi-btns {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.plan-semi-btns .btn {
min-height: 34px;
padding: 6px 12px;
font-size: 13px;
}
.plan-t-quote {
padding: 10px 12px;
}
.plan-t-wrap {
overflow-x: auto;
margin-top: 4px;
}
.plan-t-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.plan-t-table th,
.plan-t-table td {
padding: 4px 6px;
border-bottom: 1px solid var(--line);
text-align: right;
white-space: nowrap;
}
.plan-t-table th:nth-child(3),
.plan-t-table td:nth-child(3),
.plan-t-table th:nth-child(4),
.plan-t-table td:nth-child(4) {
text-align: center;
}
.plan-t-table th {
color: var(--muted);
font-weight: 600;
}
.plan-t-atm {
background: rgba(94, 200, 255, 0.08);
}
.plan-t-pick {
background: rgba(14, 203, 129, 0.08);
}
.plan-t-k {
font-weight: 700;
}
.plan-card-compact {
padding: 10px 12px;
}
.plan-card-compact .plan-panel-title {
margin-bottom: 6px;
font-size: 13px;
}
@media (max-width: 900px) {
.app-container {
padding: 0 12px calc(72px + env(safe-area-inset-bottom, 0px));