diff --git a/REMINDER.md b/REMINDER.md index e427c59..89c1f06 100644 --- a/REMINDER.md +++ b/REMINDER.md @@ -14,3 +14,4 @@ - 商业前端的本地 `file:` 依赖可能缓存旧版 `@lunar/core`;生产构建必须先构建 core,并通过显式别名引用最新 `dist`。 - 神煞规则不能统一套用“年干与日干”两个锚点;必须按每个神煞的标准查法指定锚点,并使用 `pp.test` 中的真实命例做回归测试。 - 大运切换必须同时切换该步大运对应的十年流年,不能让当前大运标题与首步大运流年混用。 +- 排盘数据会进入服务端时,必须明确说明采集字段、保存目的与范围;免费排盘默认不上传,主动同意后才保存,同意版本需留档,公开写入接口必须限流。付费下单的报告快照应在下单前另行说明。 diff --git a/server/README.md b/server/README.md index 3557def..5f72985 100644 --- a/server/README.md +++ b/server/README.md @@ -53,8 +53,11 @@ go test ./... - `GET /api/commerce/reports/:reportId/access` — 查询报告权益。 - `GET /api/commerce/reports` — 本人已购报告。 - `GET /api/commerce/reports/recover/:accessCode` — 使用恢复码读取购买时快照。 +- `POST /api/commerce/charts` — 用户主动同意后保存免费排盘记录;接口限流并记录同意版本与时间。 -除 Webhook 外,商业接口都要求 `Authorization: Bearer `。 +除 Webhook 和需同意的公开排盘记录接口外,商业接口都要求 `Authorization: Bearer `。 + +免费排盘默认不上传。创建付费订单时,报告快照会发送并保存在服务端,用于生成、交付和恢复已购报告。 ## 管理后台 @@ -78,14 +81,12 @@ go test ./... ## 数据库 -新安装由 GORM AutoMigrate 创建表。已有 MySQL 数据库先备份,再单独执行: +新安装由 GORM AutoMigrate 创建表。商业排盘记录新增同意版本和时间字段,服务启动时通过 AutoMigrate 增量添加;已有 MySQL 数据库应先备份再升级,不要执行数据库 fresh 或 refresh。商业表结构初始化脚本仍为: ```bash mysql < migrations/002_commerce.sql ``` -不要执行数据库 fresh 或 refresh。 - ## 环境变量 完整模板见 `.env.example`。生产环境至少需要: diff --git a/server/cmd/main.go b/server/cmd/main.go index ef977d0..06cf11d 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -95,7 +95,7 @@ func main() { commerce := api.Group("/commerce") { - commerce.POST("/charts", handler.SaveChartRecord) + commerce.POST("/charts", middleware.RateLimit(), handler.SaveChartRecord) commerce.POST("/checkout", middleware.Auth(), handler.CreateReportCheckout) commerce.GET("/orders/:orderNo", middleware.Auth(), handler.GetCommerceOrder) commerce.GET("/reports/:reportId/access", middleware.Auth(), handler.GetReportAccess) diff --git a/server/internal/handler/commerce.go b/server/internal/handler/commerce.go index d0cea34..905a5cd 100644 --- a/server/internal/handler/commerce.go +++ b/server/internal/handler/commerce.go @@ -1,6 +1,7 @@ package handler import ( + "bytes" "encoding/json" "io" "net/http" @@ -9,6 +10,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/gin-gonic/gin" "github.com/gouki/lunar-server/internal/config" @@ -21,21 +23,37 @@ var accessCodePattern = regexp.MustCompile(`^ZM-[A-Z2-7]{4}-[A-Z2-7]{4}-[A-Z2-7] func SaveChartRecord(c *gin.Context) { var request struct { - ReportID string `json:"reportId" binding:"required"` - Name string `json:"name"` - BirthDate string `json:"birthDate" binding:"required"` - BirthTime string `json:"birthTime" binding:"required"` - Gender string `json:"gender" binding:"required"` - City string `json:"city" binding:"required"` - Snapshot json.RawMessage `json:"snapshot" binding:"required"` + ReportID string `json:"reportId" binding:"required"` + Name string `json:"name"` + BirthDate string `json:"birthDate" binding:"required"` + BirthTime string `json:"birthTime" binding:"required"` + Gender string `json:"gender" binding:"required"` + City string `json:"city" binding:"required"` + ConsentVersion string `json:"consentVersion" binding:"required"` + Snapshot json.RawMessage `json:"snapshot" binding:"required"` } - if err := c.ShouldBindJSON(&request); err != nil || !reportIDPattern.MatchString(request.ReportID) || len(request.Snapshot) < 2 || len(request.Snapshot) > 512*1024 || request.Snapshot[0] != '{' { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 600*1024) + if err := c.ShouldBindJSON(&request); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "排盘记录参数错误"}) return } + snapshot := bytes.TrimSpace(request.Snapshot) + var snapshotObject map[string]json.RawMessage + birthDate, dateErr := time.Parse("2006-01-02", request.BirthDate) + _, timeErr := time.Parse("15:04", request.BirthTime) + if !reportIDPattern.MatchString(request.ReportID) || len(snapshot) < 2 || len(snapshot) > 512*1024 || + json.Unmarshal(snapshot, &snapshotObject) != nil || snapshotObject == nil || + dateErr != nil || birthDate.Year() < 1901 || birthDate.Year() > 2100 || timeErr != nil || + (request.Gender != "male" && request.Gender != "female") || request.ConsentVersion != service.ChartRecordConsentVersion || + utf8.RuneCountInString(request.Name) > 64 || utf8.RuneCountInString(request.City) > 64 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "排盘记录参数错误或未同意保存"}) + return + } + consentedAt := time.Now().UTC() record := &model.BaziChartRecord{ ReportID: request.ReportID, Name: request.Name, BirthDate: request.BirthDate, - BirthTime: request.BirthTime, Gender: request.Gender, City: request.City, Snapshot: string(request.Snapshot), + BirthTime: request.BirthTime, Gender: request.Gender, City: request.City, + ConsentVersion: request.ConsentVersion, ConsentAt: &consentedAt, Snapshot: string(snapshot), } if userID, exists := c.Get("userID"); exists { if value, ok := userID.(uint); ok { diff --git a/server/internal/handler/commerce_test.go b/server/internal/handler/commerce_test.go index 9a50bab..a4b63e3 100644 --- a/server/internal/handler/commerce_test.go +++ b/server/internal/handler/commerce_test.go @@ -155,3 +155,88 @@ func TestStripeCheckoutWebhookAndEntitlementHTTPFlow(t *testing.T) { t.Fatalf("expected server-generated premium snapshot, got %d: %s", recoverResponse.Code, recoverResponse.Body.String()) } } + +func TestSaveChartRecordRequiresConsent(t *testing.T) { + router := setupChartRecordAPI(t) + response := postChartRecord(router, "198.51.100.41", false) + if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), `"code":400`) { + t.Fatalf("expected consent to be required, got %d: %s", response.Code, response.Body.String()) + } + var recordCount int64 + if err := config.GetDB().Model(&model.BaziChartRecord{}).Count(&recordCount).Error; err != nil { + t.Fatal(err) + } + if recordCount != 0 { + t.Fatalf("expected no chart record without consent, got %d", recordCount) + } +} + +func TestSaveChartRecordPersistsConsentViaHTTP(t *testing.T) { + router := setupChartRecordAPI(t) + response := postChartRecord(router, "198.51.100.42", true) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"code":0`) { + t.Fatalf("expected chart record to be saved after consent, got %d: %s", response.Code, response.Body.String()) + } + var record model.BaziChartRecord + if err := config.GetDB().Where("report_id = ?", "ZM-CHART123").First(&record).Error; err != nil { + t.Fatal(err) + } + if record.ConsentVersion != "chart-record-v1" || record.ConsentAt == nil { + t.Fatalf("expected persisted consent version and timestamp") + } +} + +func TestSaveChartRecordRateLimitedViaHTTP(t *testing.T) { + router := setupChartRecordAPI(t) + for requestNumber := 0; requestNumber < 11; requestNumber++ { + response := postChartRecord(router, "198.51.100.43", true) + if requestNumber < 10 && response.Code != http.StatusOK { + t.Fatalf("request %d expected HTTP 200, got %d: %s", requestNumber+1, response.Code, response.Body.String()) + } + if requestNumber == 10 && response.Code != http.StatusTooManyRequests { + t.Fatalf("expected HTTP 429 after rate limit, got %d: %s", response.Code, response.Body.String()) + } + } +} + +func TestSaveChartRecordRejectsOversizedBodyViaHTTP(t *testing.T) { + router := setupChartRecordAPI(t) + body := fmt.Sprintf(`{"reportId":"ZM-CHART123","birthDate":"1990-08-08","birthTime":"10:30","gender":"female","city":"上海","consentVersion":"chart-record-v1","snapshot":{"data":"%s"}}`, strings.Repeat("x", 600*1024)) + request := httptest.NewRequest(http.MethodPost, "/api/commerce/charts", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.RemoteAddr = "198.51.100.44:1234" + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("expected oversized body to be rejected, got %d", response.Code) + } +} + +func setupChartRecordAPI(t *testing.T) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + t.Setenv("SERVER_ENV", "test") + t.Setenv("DB_TYPE", "sqlite") + t.Setenv("DB_NAME", fmt.Sprintf("file:chart-consent-http-%d?mode=memory&cache=shared", time.Now().UnixNano())) + t.Setenv("JWT_SECRET", "chart-consent-test-secret") + if err := config.InitDB(config.Load()); err != nil { + t.Fatal(err) + } + router := gin.New() + router.POST("/api/commerce/charts", middleware.RateLimit(), SaveChartRecord) + return router +} + +func postChartRecord(router *gin.Engine, clientIP string, consent bool) *httptest.ResponseRecorder { + body := `{"reportId":"ZM-CHART123","birthDate":"1990-08-08","birthTime":"10:30","gender":"female","city":"上海","snapshot":{"id":"ZM-CHART123"}` + if consent { + body += `,"consentVersion":"chart-record-v1"` + } + body += `}` + request := httptest.NewRequest(http.MethodPost, "/api/commerce/charts", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.RemoteAddr = clientIP + ":1234" + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} diff --git a/server/internal/model/commerce.go b/server/internal/model/commerce.go index 6ca95f7..411410d 100644 --- a/server/internal/model/commerce.go +++ b/server/internal/model/commerce.go @@ -3,17 +3,19 @@ package model import "time" type BaziChartRecord struct { - ID uint `gorm:"primaryKey" json:"id"` - ReportID string `gorm:"index;size:64" json:"reportId"` - UserID *uint `gorm:"index" json:"userId,omitempty"` - Name string `gorm:"size:64" json:"name"` - BirthDate string `gorm:"size:16" json:"birthDate"` - BirthTime string `gorm:"size:8" json:"birthTime"` - Gender string `gorm:"size:16" json:"gender"` - City string `gorm:"size:64" json:"city"` - Snapshot string `gorm:"type:longtext" json:"-"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID uint `gorm:"primaryKey" json:"id"` + ReportID string `gorm:"index;size:64" json:"reportId"` + UserID *uint `gorm:"index" json:"userId,omitempty"` + Name string `gorm:"size:64" json:"name"` + BirthDate string `gorm:"size:16" json:"birthDate"` + BirthTime string `gorm:"size:8" json:"birthTime"` + Gender string `gorm:"size:16" json:"gender"` + City string `gorm:"size:64" json:"city"` + ConsentVersion string `gorm:"size:32;not null;default:''" json:"consentVersion"` + ConsentAt *time.Time `json:"consentAt,omitempty"` + Snapshot string `gorm:"type:longtext" json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type ReportProduct struct { diff --git a/server/internal/service/chart_history.go b/server/internal/service/chart_history.go index d1ccebe..73e8595 100644 --- a/server/internal/service/chart_history.go +++ b/server/internal/service/chart_history.go @@ -7,8 +7,10 @@ import ( "github.com/gouki/lunar-server/internal/model" ) +const ChartRecordConsentVersion = "chart-record-v1" + func (s *CommerceService) CreateChartRecord(record *model.BaziChartRecord) error { - if record.ReportID == "" || record.BirthDate == "" || record.BirthTime == "" || record.Snapshot == "" { + if record.ReportID == "" || record.BirthDate == "" || record.BirthTime == "" || record.Snapshot == "" || record.ConsentVersion != ChartRecordConsentVersion || record.ConsentAt == nil { return errors.New("incomplete chart record") } return s.db.Create(record).Error diff --git a/server/web/static/js/pages/Charts.js b/server/web/static/js/pages/Charts.js index a050fe9..eacfb0a 100644 --- a/server/web/static/js/pages/Charts.js +++ b/server/web/static/js/pages/Charts.js @@ -36,17 +36,17 @@ export default {
-

排盘记录

共 {{ total }} 次排盘,保存免费排盘快照,付费报告另行关联

+

排盘记录

共 {{ total }} 条用户主动同意保存的记录;付费报告快照另行关联

{{ error }}
- + - - - + + +
报告编号称呼出生信息城市记录时间
报告编号称呼出生信息城市同意记录记录时间
正在加载...
暂无排盘记录
{{ record.reportId }}{{ record.name || '-' }}{{ record.birthDate }} {{ record.birthTime }} / {{ record.gender }}{{ record.city }}{{ new Date(record.createdAt).toLocaleString('zh-CN', { hour12: false }) }}
正在加载...
暂无排盘记录
{{ record.reportId }}{{ record.name || '-' }}{{ record.birthDate }} {{ record.birthTime }} / {{ record.gender }}{{ record.city }}{{ record.consentVersion || '迁移前记录(无同意凭证)' }}
{{ record.consentAt ? new Date(record.consentAt).toLocaleString('zh-CN', { hour12: false }) : '-' }}
{{ new Date(record.createdAt).toLocaleString('zh-CN', { hour12: false }) }}
diff --git a/web-commercial/CHANGELOG.md b/web-commercial/CHANGELOG.md index d34a99f..f9aac97 100644 --- a/web-commercial/CHANGELOG.md +++ b/web-commercial/CHANGELOG.md @@ -2,6 +2,12 @@ ## [未发布] +### 排盘数据告知与同意 + +- 免费排盘默认只在当前页面展示;用户主动同意后才上传出生资料和排盘快照。 +- 服务端保存同意版本与时间戳,拒绝缺少同意的写入,并对公开保存接口限流。 +- 修正“本地计算保护隐私”的误导文案,并在付费下单前说明报告快照会保存到服务端。 + ### 排盘记录与免费层 - 每次排盘保存服务端历史记录,后台新增排盘记录页面。 diff --git a/web-commercial/Fetures.md b/web-commercial/Fetures.md index de349dc..6d25ebd 100644 --- a/web-commercial/Fetures.md +++ b/web-commercial/Fetures.md @@ -8,25 +8,30 @@ ```mermaid flowchart TD - A[填写出生日期、时间、性别、城市] --> B[本地计算真太阳时与八字] + A[填写出生日期、时间、性别、城市] --> B[在当前设备计算真太阳时与八字] B --> C[免费查看四柱、五行、大运、流年、刑冲合害、神煞] - C --> D{是否解锁深度解读} - D -- 否 --> E[继续查看免费排盘] - D -- 是 --> F[Postmark 邮箱验证码登录] - F --> G{选择支付渠道} - G --> H[Stripe: 4.99 USD] - G --> I[Binance Pay: 5 USDT] - H --> J[服务商签名 Webhook] - I --> J - J --> K[事务更新订单和报告权益] - K --> L[生成随机报告恢复码] - L --> M[保存不可变报告快照] - M --> N[用户凭购买邮箱和恢复码跨设备找回] + C --> D{是否同意保存本次排盘记录} + D -- 是 --> E[服务端保存所列资料与排盘快照] + D -- 否 --> F[只在当前页面查看,不上传免费排盘记录] + C --> G{是否解锁深度解读} + G -- 否 --> H[继续查看免费排盘] + G -- 是 --> I[邮箱验证码登录并确认下单数据保存说明] + I --> J{选择支付渠道} + J --> K[Stripe: 4.99 USD] + J --> L[Binance Pay: 5 USDT] + K --> M[服务商签名 Webhook] + L --> M + M --> N[事务更新订单和报告权益] + N --> O[生成随机报告恢复码] + O --> P[保存不可变报告快照] + P --> Q[用户凭购买邮箱和恢复码跨设备找回] ``` ## 3. 免费排盘内容 -每次排盘都会向服务端保存一份免费快照,记录出生参数、报告编号和计算结果版本,避免用户重复排盘后无法追踪历史。相同输入在同一算法版本下应得到相同结果;算法升级后保留旧快照,不覆盖历史记录。 +八字运算在当前设备完成。用户默认不上传免费排盘资料;只有主动勾选保存选项后,姓名(如填写)、出生日期、出生时间、性别、城市和排盘快照才会发送到服务端,供授权后台人员查看。未勾选仍可免费计算,但服务端不会创建免费排盘记录。服务端验证同意版本并对公开保存接口限流。相同输入在同一算法版本下应得到相同结果;算法升级后保留已保存快照,不覆盖历史记录。 + +购买时会另行告知:创建订单会把报告快照和订单所需信息发送到服务端,用于生成、交付和恢复已购报告;该购买记录不依赖免费排盘保存选项。 免费层展示基础事实,不把基础数据故意藏到付费层: @@ -150,6 +155,7 @@ Stripe 校验 `Stripe-Signature` 与 5 分钟时间窗口;Binance Pay 使用 - `report_snapshots`:购买时不可变报告快照。 - `payment_events`:Webhook 幂等记录。 - `email_login_codes`:一次性验证码。 +- `bazi_chart_records`:排盘记录增加同意版本与同意时间字段;旧记录不回填为已同意。 - `orders` 新增渠道、币种、服务商交易号、报告号、恢复码等字段。 新部署可使用 GORM AutoMigrate;已有 MySQL 数据库应在备份后执行 `server/migrations/002_commerce.sql`,不要 fresh 或 refresh 数据库。 diff --git a/web-commercial/README.md b/web-commercial/README.md index 89d21b2..854d658 100644 --- a/web-commercial/README.md +++ b/web-commercial/README.md @@ -13,7 +13,7 @@ - Postmark 邮箱验证码登录。 - 支付 Webhook 验签与幂等处理。 - 支付前只下发免费内容和通用锁定占位,个性化付费解读由服务端在支付确认后生成。 -- 每次排盘保存服务端历史记录,后台可查询排盘次数与出生参数。 +- 免费排盘默认不上传;用户主动同意后,服务端保存出生资料、排盘快照及同意版本/时间,授权后台可查询记录。 - 已购报告历史列表、随机恢复码与购买时报告快照。 - 后台真实订单列表、排盘记录和收入统计。 diff --git a/web-commercial/src/App.tsx b/web-commercial/src/App.tsx index df9fed3..6b2f006 100644 --- a/web-commercial/src/App.tsx +++ b/web-commercial/src/App.tsx @@ -30,6 +30,7 @@ import type { BaziFullResult, ElementProfile, ShenshaInfo } from '@lunar/core'; import { authEmailKey, authTokenKey, + chartRecordConsentVersion, checkReportAccess, createCheckout, isDemoPayment, @@ -108,6 +109,8 @@ function App() { const [form, setForm] = useState(DEFAULT_FORM); const [report, setReport] = useState(null); const [isCalculating, setIsCalculating] = useState(false); + const [consentToSaveChart, setConsentToSaveChart] = useState(false); + const [chartSaveStatus, setChartSaveStatus] = useState(''); const [isUnlocked, setIsUnlocked] = useState(false); const [showCheckout, setShowCheckout] = useState(false); const [showRecovery, setShowRecovery] = useState(false); @@ -137,6 +140,8 @@ function App() { const calculate = (event: FormEvent) => { event.preventDefault(); + const shouldSaveChart = consentToSaveChart; + setConsentToSaveChart(false); setIsCalculating(true); const [year, month, day] = form.date.split('-').map(Number); const [hour, minute] = form.time.split(':').map(Number); @@ -161,15 +166,20 @@ function App() { const id = createReportId(`${form.date}|${form.time}|${form.gender}|${form.city}`); const chartSnapshot: ReportState = { id, result, elements, shensha, sections: [] }; setReport(chartSnapshot); - void saveChartRecord({ - reportId: id, - name: form.name, - birthDate: form.date, - birthTime: form.time, - gender: form.gender, - city: form.city, - snapshot: chartSnapshot, - }).catch(() => undefined); + setChartSaveStatus(shouldSaveChart ? '正在保存本次排盘记录…' : '本次未上传或保存排盘资料。'); + if (shouldSaveChart) { + void saveChartRecord({ + reportId: id, + name: form.name, + birthDate: form.date, + birthTime: form.time, + gender: form.gender, + city: form.city, + consentVersion: chartRecordConsentVersion, + snapshot: chartSnapshot, + }).then(() => setChartSaveStatus('本次排盘记录已保存至服务端,授权后台人员可查看。')) + .catch(() => setChartSaveStatus('排盘已完成,但服务端保存失败;本次结果仅保留在当前页面。')); + } const localAccess = localStorage.getItem(`lunar-report:${id}`) === 'unlocked'; setIsUnlocked(localAccess); const token = localStorage.getItem(authTokenKey); @@ -203,7 +213,7 @@ function App() {

看懂命盘,
更懂自己。

输入出生信息,免费查看四柱与五行。完整报告将性格、事业、财富、关系与大运,整理成一份可以反复阅读的人生说明书。

- 本地计算保护隐私 + 资料保存由你选择 一次购买永久回看 无自动续费
@@ -245,10 +255,15 @@ function App() {
+ -

出生资料仅在当前设备参与计算,不会公开展示

+

排盘在当前设备计算;勾选保存后才会上传所列资料。

+ {chartSaveStatus &&

{chartSaveStatus}

} 了解报告 @@ -631,7 +646,8 @@ function Checkout({ reportId, snapshot, onClose, onPaid }: { reportId: string; s )} {error &&

{error}

} -

服务商签名回调确认到账 · 无自动续费

+

{isDemoPayment ? '演示支付不会向服务端创建真实订单。' : '下单后,报告快照和订单信息会发送到服务端保存,用于生成、交付和恢复已购报告。'}

+

服务商签名回调确认到账 · 无自动续费

); diff --git a/web-commercial/src/payment.ts b/web-commercial/src/payment.ts index 1532fd3..18c7fc0 100644 --- a/web-commercial/src/payment.ts +++ b/web-commercial/src/payment.ts @@ -21,6 +21,7 @@ interface ApiPayload { const apiBaseURL = import.meta.env.VITE_API_BASE_URL ?? ''; export const isDemoPayment = import.meta.env.VITE_PAYMENT_MODE === 'demo'; +export const chartRecordConsentVersion = 'chart-record-v1'; export const authTokenKey = 'lunar-commerce-token'; export const authEmailKey = 'lunar-commerce-email'; @@ -45,6 +46,7 @@ export async function saveChartRecord(input: { birthTime: string; gender: string; city: string; + consentVersion: string; snapshot: unknown; }): Promise { await apiRequest('/api/commerce/charts', { diff --git a/web-commercial/src/styles.css b/web-commercial/src/styles.css index 925a762..5e7c2ca 100644 --- a/web-commercial/src/styles.css +++ b/web-commercial/src/styles.css @@ -187,6 +187,10 @@ header nav a:hover { color: #fff; } .primary-action:hover, .offer-card button:hover, .unlock-panel button:hover, .pay-button:hover { background: var(--vermilion-dark); transform: translateY(-1px); } .primary-action:disabled, .pay-button:disabled { opacity: .7; cursor: wait; } .form-note { display: flex; align-items: center; justify-content: center; gap: 6px; margin: 14px 0 0; font-size: 10px; color: #929086; } +.privacy-consent { display: flex; align-items: flex-start; gap: 9px; margin: 16px 0 18px; color: #77786f; font-size: 10px; line-height: 1.7; cursor: pointer; } +.privacy-consent input { flex: 0 0 auto; width: 15px; height: 15px; margin: 2px 0 0; accent-color: var(--vermilion); } +.privacy-consent strong { display: block; margin-bottom: 2px; color: var(--ink); font-size: 11px; } +.chart-save-status { margin: 9px 0 0; color: var(--ink-soft); text-align: center; font-size: 10px; line-height: 1.6; } .loading-mark { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.3); border-top-color: white; border-radius: 50%; animation: spin .7s linear infinite; } .scroll-cue { position: absolute; bottom: 27px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 8px; color: rgba(255,255,255,.42); font-size: 9px; letter-spacing: .2em; writing-mode: vertical-rl; } .scroll-cue svg { animation: bob 1.8s ease-in-out infinite; }