This commit is contained in:
王性驊 2026-07-30 01:25:34 +00:00
parent f46a5cf0a9
commit 475b117c95
49 changed files with 937 additions and 685 deletions

View File

@ -0,0 +1,57 @@
# Call Home 會議重點與 Action Items
**日期:** 2026/07/28
## 會議重點
### 1. 整合方向
Call Home 可拆成三個部分:
1. **TriggerState**:事件收集、分類、去重與 Recovery。
2. **Delivery**Email、SMS、Phone、ServiceNow、Salesforce。
3. **Service Process**自動開單、Ticket 狀態與結案責任。
### 2. 可以直接重用的能力
- **SSM 團隊提供:** Event Mapping、事件白名單、資料收集、去重與 Recovery 邏輯。
- **SCC-FLEX 提供:** Policy Engine、統一 Webhook、多通路通知與 Salesforce API。
- **Local Administrator** 沿用 SCC-FLEX 現有 RecipientReceiver 設定,不另外開發獨立模組。
### 3. 目前主要缺口
- 尚未定義統一 Event Payload 與 Correlation Key。
- SCC-FLEX 尚缺完整 Deduplication、SoftHard State 與 Event Lifecycle。
- Service Team 可接受的 Event 白名單尚未確認。
- Device Recovered 與 Ticket Closed 的關係尚未定義。
- Salesforce Ticket 的 Owner、Close 權限與 Ask-to-Close 流程尚未確認。
- Call Home License 模式尚未確認。
### 4. 初步共識
- Supermicro Service 情境傾向**自動建立 Ticket**。
- 自動開單前必須先完成 Event 白名單、去重及 Idempotency。
- 初期先支援 ComputeSupermicro Hardware。
- CDU、PDU、Liquid Cooling 等產品後續再評估。
## Action Items
| Owner | Action Item | Status |
|---|---|---|
| Daniel | 依 Trigger、Delivery、Service Process 三部分整理整合需求與責任邊界。 | 待整理 |
| Daniel | 建立跨團隊討論群組並發布會議記錄。 | 待處理 |
| DanielPM | 確認 DCM Single Key、Service Key、每台 Host 計費及 Support Advisor 的關係。 | 待確認 |
| PMService Team | 提供可自動開單的 Event 白名單、必要 Payload附件及維護 Owner。 | 待確認 |
| SSM 團隊 | 提供 Event Mapping、Trigger List、Deduplication、Recovery 與資料收集規則。 | 待指派 |
| SCC-FLEX 團隊 | 定義 Event Payload、Correlation Key、SoftHard State、Deduplication 與 Idempotency。 | 待排程 |
| DanielITService Team | 確認 Salesforce API Owner、Ticket Update、Close 與 Ask-to-Close 規則。 | 待確認 |
| PM產品團隊 | 決定 Event 選擇方式及 Ticket ListAsk-to-Close 是否列入產品範圍。 | 待討論 |
## 下一步
下一次會議只需要確認四件事:
1. Service Team Event 白名單。
2. SSM 可提供的資料與介面。
3. Salesforce Ticket Lifecycle 與責任人。
4. License 與產品範圍。

View File

@ -98,9 +98,11 @@ type (
} }
UtmLinkPublic { UtmLinkPublic {
Id string `json:"id"` Id string `json:"id"`
Code string `json:"code"` Code string `json:"code"`
// TrackPath 保留給同源前端TrackUrl 是後端算好的絕對網址,貼到 Threads 用這個。
TrackPath string `json:"track_path"` TrackPath string `json:"track_path"`
TrackUrl string `json:"track_url"`
DestinationUrl string `json:"destination_url"` DestinationUrl string `json:"destination_url"`
OutcomeId string `json:"outcome_id,optional"` OutcomeId string `json:"outcome_id,optional"`
Label string `json:"label,optional"` Label string `json:"label,optional"`

View File

@ -27,12 +27,11 @@ func (l *GetBenchmarkLogic) GetBenchmark(req *types.BenchmarkReq) (*types.Benchm
if !ok || uid <= 0 { if !ok || uid <= 0 {
return nil, fmt.Errorf("unauthorized") return nil, fmt.Errorf("unauthorized")
} }
niche := req.Niche niche := usecase.NormalizeNiche(req.Niche)
if niche == "" {
niche = "general"
}
var yourEng, yourViews float64 var yourEng, yourViews float64
// contribute + read from own posts if studio available // 只讀自己的貼文算出對照值。這裡刻意不 ContributeBenchmark查詢是讀路徑
// 若順手寫入,任何人只要輸入某個利基名稱就能把自己的數字灌進那個桶。
// 貢獻的唯一入口是 insights summary那裡的數字來自使用者自己的已同步貼文
if l.svcCtx.Studio != nil { if l.svcCtx.Studio != nil {
posts, err := l.svcCtx.Studio.ListOwnPosts(l.ctx, uid, req.AccountId) posts, err := l.svcCtx.Studio.ListOwnPosts(l.ctx, uid, req.AccountId)
if err == nil && len(posts) > 0 { if err == nil && len(posts) > 0 {
@ -52,15 +51,14 @@ func (l *GetBenchmarkLogic) GetBenchmark(req *types.BenchmarkReq) (*types.Benchm
if views > 0 { if views > 0 {
yourEng = float64(likes+replies) / float64(views) yourEng = float64(likes+replies) / float64(views)
} }
_ = l.svcCtx.Growth.ContributeBenchmark(l.ctx, uid, niche, yourEng, yourViews)
} }
} }
} }
sample, medEng, medViews, avail, hint := l.svcCtx.Growth.GetBenchmark(l.ctx, niche, yourEng, yourViews) sample, medEng, medViews, avail, hint := l.svcCtx.Growth.GetBenchmark(l.ctx, niche, yourEng, yourViews)
return &types.BenchmarkData{ return &types.BenchmarkData{
Available: avail, SampleSize: sample, Niche: niche, Available: avail, SampleSize: sample, Niche: niche,
MedianEngRate: usecase.Round2(medEng), MedianViews: usecase.Round2(medViews), MedianEngRate: usecase.RoundRate(medEng), MedianViews: usecase.Round2(medViews),
YourEngRate: usecase.Round2(yourEng), YourViews: usecase.Round2(yourViews), YourEngRate: usecase.RoundRate(yourEng), YourViews: usecase.Round2(yourViews),
PercentileHint: hint, Message: hint, PercentileHint: hint, Message: hint,
}, nil }, nil
} }

View File

@ -80,7 +80,7 @@ func (l *GetInsightsSummaryLogic) GetInsightsSummary(req *types.InsightsSummaryR
} }
top = append(top, types.InsightsPostPublic{ top = append(top, types.InsightsPostPublic{
Id: p.ID, TextPreview: prev, PublishedAt: ts, Id: p.ID, TextPreview: prev, PublishedAt: ts,
ViewCount: p.ViewCount, LikeCount: p.LikeCount, ReplyCount: p.ReplyCount, EngRate: usecase.Round2(eng), ViewCount: p.ViewCount, LikeCount: p.LikeCount, ReplyCount: p.ReplyCount, EngRate: usecase.RoundRate(eng),
}) })
} }
sort.Slice(top, func(i, j int) bool { return top[i].EngRate > top[j].EngRate }) sort.Slice(top, func(i, j int) bool { return top[i].EngRate > top[j].EngRate })
@ -98,7 +98,7 @@ func (l *GetInsightsSummaryLogic) GetInsightsSummary(req *types.InsightsSummaryR
e = float64(a.likes+a.replies) / float64(a.views) e = float64(a.likes+a.replies) / float64(a.views)
} }
buckets = append(buckets, types.InsightsMonthBucket{ buckets = append(buckets, types.InsightsMonthBucket{
MonthKey: mk, Posts: a.posts, Views: a.views, Likes: a.likes, Replies: a.replies, AvgEngRate: usecase.Round2(e), MonthKey: mk, Posts: a.posts, Views: a.views, Likes: a.likes, Replies: a.replies, AvgEngRate: usecase.RoundRate(e),
}) })
} }
sort.Slice(buckets, func(i, j int) bool { return buckets[i].MonthKey < buckets[j].MonthKey }) sort.Slice(buckets, func(i, j int) bool { return buckets[i].MonthKey < buckets[j].MonthKey })
@ -116,7 +116,7 @@ func (l *GetInsightsSummaryLogic) GetInsightsSummary(req *types.InsightsSummaryR
} }
return &types.InsightsSummaryData{ return &types.InsightsSummaryData{
AccountId: req.AccountId, PostCount: n, TotalViews: totalV, TotalLikes: totalL, TotalReplies: totalR, AccountId: req.AccountId, PostCount: n, TotalViews: totalV, TotalLikes: totalL, TotalReplies: totalR,
AvgEngRate: usecase.Round2(avgEng), TopPosts: top, MonthBuckets: buckets, AvgEngRate: usecase.RoundRate(avgEng), TopPosts: top, MonthBuckets: buckets,
Conclusion: conclusion, Suggestions: sugs, Conclusion: conclusion, Suggestions: sugs,
}, nil }, nil
} }

View File

@ -30,9 +30,6 @@ func (l *CreateUtmLinkLogic) CreateUtmLink(req *types.CreateUtmLinkReq) (*types.
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &types.UtmLinkPublic{ out := publicView(l.svcCtx.Config, u)
Id: u.ID, Code: u.Code, TrackPath: "/api/v1/public/u/" + u.Code, return &out, nil
DestinationUrl: u.DestinationURL, OutcomeId: u.OutcomeID, Label: u.Label,
Clicks: u.Clicks, CreatedAt: u.CreatedAt,
}, nil
} }

View File

@ -32,11 +32,7 @@ func (l *ListUtmLinksLogic) ListUtmLinks() (*types.UtmLinkListData, error) {
} }
out := make([]types.UtmLinkPublic, 0, len(list)) out := make([]types.UtmLinkPublic, 0, len(list))
for _, u := range list { for _, u := range list {
out = append(out, types.UtmLinkPublic{ out = append(out, publicView(l.svcCtx.Config, u))
Id: u.ID, Code: u.Code, TrackPath: "/api/v1/public/u/" + u.Code,
DestinationUrl: u.DestinationURL, OutcomeId: u.OutcomeID, Label: u.Label,
Clicks: u.Clicks, CreatedAt: u.CreatedAt,
})
} }
return &types.UtmLinkListData{List: out}, nil return &types.UtmLinkListData{List: out}, nil
} }

View File

@ -0,0 +1,38 @@
package utm
import (
"strings"
"apps/backend/internal/config"
"apps/backend/internal/module/growth/domain"
"apps/backend/internal/types"
)
const trackPathPrefix = "/api/v1/public/u/"
// publicView 把追蹤連結轉成對外型別。TrackUrl 必須由後端算:連結會被貼進 Threads
// 貼文,前端自己拼 window.location.origin 在 API 與前端不同 host 時會產出 404 連結。
func publicView(c config.Config, u *domain.UtmLink) types.UtmLinkPublic {
path := trackPathPrefix + u.Code
return types.UtmLinkPublic{
Id: u.ID,
Code: u.Code,
TrackPath: path,
TrackUrl: publicAPIBase(c) + path,
DestinationUrl: u.DestinationURL,
OutcomeId: u.OutcomeID,
Label: u.Label,
Clicks: u.Clicks,
CreatedAt: u.CreatedAt,
}
}
// publicAPIBase 與 threads oauth callback 用同一套推導PublicAPIBase 優先,否則
// PublicWebBase正式站 nginx、dev 的 vite 都會把 /api 轉到 gateway。兩者皆空
// 時回空字串TrackUrl 退回相對路徑讓瀏覽器自己解。
func publicAPIBase(c config.Config) string {
if b := strings.TrimRight(strings.TrimSpace(c.PublicAPIBase), "/"); b != "" {
return b
}
return strings.TrimRight(strings.TrimSpace(c.PublicWebBase), "/")
}

View File

@ -0,0 +1,36 @@
package utm
import (
"testing"
"apps/backend/internal/config"
"apps/backend/internal/module/growth/domain"
"github.com/stretchr/testify/require"
)
func TestTrackUrlIsAbsoluteSoItCanBePastedIntoAPost(t *testing.T) {
var c config.Config
c.PublicAPIBase = "https://api.example.com/"
c.PublicWebBase = "https://app.example.com"
out := publicView(c, &domain.UtmLink{ID: "l1", Code: "abc123", DestinationURL: "https://shop.example.com/p/1"})
require.Equal(t, "https://api.example.com/api/v1/public/u/abc123", out.TrackUrl)
require.Equal(t, "/api/v1/public/u/abc123", out.TrackPath)
}
func TestTrackUrlFallsBackToWebBaseWhichProxiesApi(t *testing.T) {
var c config.Config
c.PublicWebBase = "http://127.0.0.1:5173"
out := publicView(c, &domain.UtmLink{Code: "xyz"})
require.Equal(t, "http://127.0.0.1:5173/api/v1/public/u/xyz", out.TrackUrl)
}
func TestTrackUrlStaysRelativeWhenNoBaseIsConfigured(t *testing.T) {
out := publicView(config.Config{}, &domain.UtmLink{Code: "xyz"})
require.Equal(t, "/api/v1/public/u/xyz", out.TrackUrl)
}

View File

@ -292,11 +292,17 @@ func (s *Service) TrackUtmClick(ctx context.Context, code string) (dest string,
// --- Benchmark --- // --- Benchmark ---
func (s *Service) ContributeBenchmark(ctx context.Context, ownerUID int64, niche string, engRate, avgViews float64) error { // NormalizeNiche 讓利基桶有機會對得上:大小寫與前後空白不該切出不同的桶。
niche = strings.TrimSpace(niche) func NormalizeNiche(niche string) string {
niche = strings.ToLower(strings.TrimSpace(niche))
if niche == "" { if niche == "" {
niche = "general" return "general"
} }
return niche
}
func (s *Service) ContributeBenchmark(ctx context.Context, ownerUID int64, niche string, engRate, avgViews float64) error {
niche = NormalizeNiche(niche)
h := sha256.Sum256([]byte(fmt.Sprintf("%d|%s", ownerUID, niche))) h := sha256.Sum256([]byte(fmt.Sprintf("%d|%s", ownerUID, niche)))
return s.Repo.UpsertBenchmarkSample(ctx, &domain.BenchmarkSample{ return s.Repo.UpsertBenchmarkSample(ctx, &domain.BenchmarkSample{
Niche: niche, EngRate: engRate, AvgViews: avgViews, Niche: niche, EngRate: engRate, AvgViews: avgViews,
@ -305,10 +311,7 @@ func (s *Service) ContributeBenchmark(ctx context.Context, ownerUID int64, niche
} }
func (s *Service) GetBenchmark(ctx context.Context, niche string, yourEng, yourViews float64) (sampleSize int, medianEng, medianViews float64, available bool, hint string) { func (s *Service) GetBenchmark(ctx context.Context, niche string, yourEng, yourViews float64) (sampleSize int, medianEng, medianViews float64, available bool, hint string) {
niche = strings.TrimSpace(niche) niche = NormalizeNiche(niche)
if niche == "" {
niche = "general"
}
list, err := s.Repo.ListBenchmarkSamples(ctx, niche, 500) list, err := s.Repo.ListBenchmarkSamples(ctx, niche, 500)
if err != nil || len(list) < 5 { if err != nil || len(list) < 5 {
return len(list), 0, 0, false, "樣本不足 5暫不顯示中位數多貢獻幾次成效後再看" return len(list), 0, 0, false, "樣本不足 5暫不顯示中位數多貢獻幾次成效後再看"
@ -534,3 +537,9 @@ func toPDFSafe(s string) string {
func Round2(v float64) float64 { func Round2(v float64) float64 {
return math.Round(v*100) / 100 return math.Round(v*100) / 100
} }
// RoundRate 用在互動率這種比率0.0234)。比率不能走 Round2 —— 那會輾成 0.02
// 前端 *100 後所有人都變成整數百分比。保留 4 位小數 = 百分比的 2 位。
func RoundRate(v float64) float64 {
return math.Round(v*10000) / 10000
}

View File

@ -220,3 +220,26 @@ func TestUTM_RefusesToRedirectToAStoredBadDestination(t *testing.T) {
_, err = svc.TrackUtmClick(ctx, link.Code) _, err = svc.TrackUtmClick(ctx, link.Code)
require.ErrorIs(t, err, domain.ErrValidation) require.ErrorIs(t, err, domain.ErrValidation)
} }
// 互動率是比率,走 Round2 會被輾成整數百分比0.0234 → 0.02 → 前端 2.00%)。
func TestRoundRateKeepsTwoDecimalsOfAPercentage(t *testing.T) {
require.Equal(t, 0.0234, RoundRate(0.023449))
require.Equal(t, 0.1235, RoundRate(0.12345))
require.Equal(t, 0.0, RoundRate(0))
require.Equal(t, 0.02, Round2(0.023449), "Round2 仍供瀏覽數這類整數量使用")
}
func TestBenchmarkBucketsIgnoreCaseAndPadding(t *testing.T) {
svc := New(repository.NewMemory())
ctx := context.Background()
for uid := int64(1); uid <= 5; uid++ {
require.NoError(t, svc.ContributeBenchmark(ctx, uid, " SkinCare ", 0.02, 100))
}
size, medEng, medViews, available, _ := svc.GetBenchmark(ctx, "skincare", 0.02, 100)
require.True(t, available)
require.Equal(t, 5, size)
require.Equal(t, 0.02, medEng)
require.Equal(t, 100.0, medViews)
}

View File

@ -1796,6 +1796,7 @@ type UtmLinkPublic struct {
Id string `json:"id"` Id string `json:"id"`
Code string `json:"code"` Code string `json:"code"`
TrackPath string `json:"track_path"` TrackPath string `json:"track_path"`
TrackUrl string `json:"track_url"`
DestinationUrl string `json:"destination_url"` DestinationUrl string `json:"destination_url"`
OutcomeId string `json:"outcome_id,optional"` OutcomeId string `json:"outcome_id,optional"`
Label string `json:"label,optional"` Label string `json:"label,optional"`

View File

@ -7,6 +7,7 @@
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "oxlint", "lint": "oxlint",
"check:tokens": "node scripts/check-tokens.mjs",
"test": "vitest run", "test": "vitest run",
"test:unit": "vitest run", "test:unit": "vitest run",
"preview": "vite preview --host 0.0.0.0" "preview": "vite preview --host 0.0.0.0"

View File

@ -1,19 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<defs> <defs>
<linearGradient id="night" x1="8" y1="5" x2="58" y2="61" gradientUnits="userSpaceOnUse"> <linearGradient id="night" x1="8" y1="5" x2="58" y2="61" gradientUnits="userSpaceOnUse">
<stop stop-color="#141A19"/> <stop stop-color="#141A1A"/>
<stop offset=".58" stop-color="#1B2725"/> <stop offset=".58" stop-color="#1B2627"/>
<stop offset="1" stop-color="#20B49C"/> <stop offset="1" stop-color="#5F9AA3"/>
</linearGradient> </linearGradient>
<linearGradient id="orbit" x1="14" y1="14" x2="50" y2="51" gradientUnits="userSpaceOnUse"> <linearGradient id="orbit" x1="14" y1="14" x2="50" y2="51" gradientUnits="userSpaceOnUse">
<stop stop-color="#C8F5EE"/> <stop stop-color="#DCEEF1"/>
<stop offset=".52" stop-color="#22C3A8"/> <stop offset=".52" stop-color="#6FADB6"/>
<stop offset="1" stop-color="#3CDDC2"/> <stop offset="1" stop-color="#8BC5CD"/>
</linearGradient> </linearGradient>
<radialGradient id="core" cx="0" cy="0" r="1" gradientTransform="translate(32 30) rotate(90) scale(12)"> <radialGradient id="core" cx="0" cy="0" r="1" gradientTransform="translate(32 30) rotate(90) scale(12)">
<stop stop-color="#FFFFFF"/> <stop stop-color="#FFFFFF"/>
<stop offset=".42" stop-color="#DDFBF7"/> <stop offset=".42" stop-color="#E8F4F6"/>
<stop offset="1" stop-color="#3CDDC2" stop-opacity=".12"/> <stop offset="1" stop-color="#8BC5CD" stop-opacity=".12"/>
</radialGradient> </radialGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%"> <filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2.4"/> <feGaussianBlur stdDeviation="2.4"/>
@ -21,19 +21,19 @@
</defs> </defs>
<rect width="64" height="64" rx="18" fill="url(#night)"/> <rect width="64" height="64" rx="18" fill="url(#night)"/>
<circle cx="32" cy="31" r="19" stroke="#69D4C5" stroke-opacity=".2"/> <circle cx="32" cy="31" r="19" stroke="#8BC5CD" stroke-opacity=".2"/>
<path d="M15.5 35.5c1.8 9 10.2 15.8 19.8 14.4 9.5-1.4 16.1-10.6 13.7-20" stroke="url(#orbit)" stroke-width="2.4" stroke-linecap="round"/> <path d="M15.5 35.5c1.8 9 10.2 15.8 19.8 14.4 9.5-1.4 16.1-10.6 13.7-20" stroke="url(#orbit)" stroke-width="2.4" stroke-linecap="round"/>
<path d="M17.2 24.8c4.9-9 16.4-12.5 25.5-7.5" stroke="#C8F5EE" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="1 4"/> <path d="M17.2 24.8c4.9-9 16.4-12.5 25.5-7.5" stroke="#DCEEF1" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="1 4"/>
<ellipse cx="32" cy="31" rx="24" ry="10.5" transform="rotate(-24 32 31)" stroke="#3CDDC2" stroke-opacity=".5" stroke-width="1.4"/> <ellipse cx="32" cy="31" rx="24" ry="10.5" transform="rotate(-24 32 31)" stroke="#8BC5CD" stroke-opacity=".5" stroke-width="1.4"/>
<circle cx="32" cy="30" r="11" fill="#3CDDC2" fill-opacity=".3" filter="url(#glow)"/> <circle cx="32" cy="30" r="11" fill="#8BC5CD" fill-opacity=".3" filter="url(#glow)"/>
<circle cx="32" cy="30" r="9.2" fill="url(#core)"/> <circle cx="32" cy="30" r="9.2" fill="url(#core)"/>
<path d="M34.8 21.3a9.2 9.2 0 1 0 6 13.8 10.7 10.7 0 0 1-6-13.8Z" fill="#F4F6F5"/> <path d="M34.8 21.3a9.2 9.2 0 1 0 6 13.8 10.7 10.7 0 0 1-6-13.8Z" fill="#F4F6F6"/>
<path d="M17 43.5c4-3 8-3 12 0s8 3 12 0 8-3 12 0" stroke="#3CDDC2" stroke-width="2.5" stroke-linecap="round"/> <path d="M17 43.5c4-3 8-3 12 0s8 3 12 0 8-3 12 0" stroke="#8BC5CD" stroke-width="2.5" stroke-linecap="round"/>
<path d="M46 13.5c.5 2.2 1.8 3.5 4 4-2.2.5-3.5 1.8-4 4-.5-2.2-1.8-3.5-4-4 2.2-.5 3.5-1.8 4-4Z" fill="#FF7B73"/> <path d="M46 13.5c.5 2.2 1.8 3.5 4 4-2.2.5-3.5 1.8-4 4-.5-2.2-1.8-3.5-4-4 2.2-.5 3.5-1.8 4-4Z" fill="#FEE5A1"/>
<path d="M18.5 15c.3 1.5 1.2 2.4 2.7 2.7-1.5.3-2.4 1.2-2.7 2.7-.3-1.5-1.2-2.4-2.7-2.7 1.5-.3 2.4-1.2 2.7-2.7Z" fill="#C8F5EE"/> <path d="M18.5 15c.3 1.5 1.2 2.4 2.7 2.7-1.5.3-2.4 1.2-2.7 2.7-.3-1.5-1.2-2.4-2.7-2.7 1.5-.3 2.4-1.2 2.7-2.7Z" fill="#DCEEF1"/>
<circle cx="50.5" cy="33.5" r="1.5" fill="#FFF"/> <circle cx="50.5" cy="33.5" r="1.5" fill="#FFF"/>
<circle cx="14" cy="32" r="1" fill="#69D4C5"/> <circle cx="14" cy="32" r="1" fill="#8BC5CD"/>
<rect x="1" y="1" width="62" height="62" rx="17" stroke="#E0EBE9" stroke-opacity=".15" stroke-width="2"/> <rect x="1" y="1" width="62" height="62" rx="17" stroke="#E0E9EB" stroke-opacity=".15" stroke-width="2"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -1,15 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<defs> <defs>
<linearGradient id="bg" x1="3" y1="2" x2="29" y2="31" gradientUnits="userSpaceOnUse"> <linearGradient id="bg" x1="3" y1="2" x2="29" y2="31" gradientUnits="userSpaceOnUse">
<stop stop-color="#141A19"/> <stop stop-color="#141A1A"/>
<stop offset="1" stop-color="#20B49C"/> <stop offset="1" stop-color="#5F9AA3"/>
</linearGradient> </linearGradient>
</defs> </defs>
<rect width="32" height="32" rx="9" fill="url(#bg)"/> <rect width="32" height="32" rx="9" fill="url(#bg)"/>
<circle cx="16" cy="15.5" r="9.5" stroke="#69D4C5" stroke-opacity=".5"/> <circle cx="16" cy="15.5" r="9.5" stroke="#8BC5CD" stroke-opacity=".5"/>
<ellipse cx="16" cy="15.5" rx="12" ry="5.2" transform="rotate(-24 16 15.5)" stroke="#3CDDC2" stroke-opacity=".72"/> <ellipse cx="16" cy="15.5" rx="12" ry="5.2" transform="rotate(-24 16 15.5)" stroke="#8BC5CD" stroke-opacity=".72"/>
<circle cx="16" cy="15" r="5" fill="#DDFBF7"/> <circle cx="16" cy="15" r="5" fill="#E8F4F6"/>
<path d="M17.5 10.2a5 5 0 1 0 3.2 7.5 5.8 5.8 0 0 1-3.2-7.5Z" fill="#F4F6F5"/> <path d="M17.5 10.2a5 5 0 1 0 3.2 7.5 5.8 5.8 0 0 1-3.2-7.5Z" fill="#F4F6F6"/>
<path d="M7.5 22c2-1.5 4-1.5 6 0s4 1.5 6 0 4-1.5 6 0" stroke="#3CDDC2" stroke-width="1.5" stroke-linecap="round"/> <path d="M7.5 22c2-1.5 4-1.5 6 0s4 1.5 6 0 4-1.5 6 0" stroke="#8BC5CD" stroke-width="1.5" stroke-linecap="round"/>
<path d="M24 6.2c.3 1.2 1 2 2.2 2.2-1.2.3-2 1-2.2 2.2-.3-1.2-1-2-2.2-2.2 1.2-.3 2-1 2.2-2.2Z" fill="#FF7B73"/> <path d="M24 6.2c.3 1.2 1 2 2.2 2.2-1.2.3-2 1-2.2 2.2-.3-1.2-1-2-2.2-2.2 1.2-.3 2-1 2.2-2.2Z" fill="#FEE5A1"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 901 B

After

Width:  |  Height:  |  Size: 901 B

View File

@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#152825" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#152825" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#20b49c" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#20b49c" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#20b49c" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#152825" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#20b49c" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#20b49c" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#152825" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -0,0 +1,105 @@
/**
* 樣式把關顏色與字級只能來自 tokens.css
*
* 會擋下的事
* 1. css 寫死 font-size 數值em 例外那是刻意的相對縮放
* 2. tsx inline style 寫死 fontSize 或色碼
* 3. css 出現任何 css 檔都沒定義的 --hb-*--hb-warn 這種拼錯永遠吃 fallback很難發現
* 4. css 出現寫死的顏色
* 5. 把兩個有彩度的 token 互相 color-mix藍配橘是補色混出來一定是濁褐
*
* 4 條放過兩種情形純黑純白只用於陰影與高光的 color-mix本身不是主題色
* 以及 global.css 主題預覽色塊那 12 個值 那裡必須同時顯示淺色與深色
* 不能吃會隨主題切換的 var()
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
const STYLE_DIR = "src/styles";
const NEUTRAL = /^#(fff|000|ffffff|000000)$/i;
/**
* 有彩度的 token這些彼此不能混主色在 H187暖色在 H32是補色關係
* 任何比例混出來都是濁褐要調淡就跟 surfacelinetransparent 要同一顏色
* 的深淺兩階就用 X X-deep
*/
const CHROMATIC =
/^--hb-(brand|brand-hover|brand-deep|brand-dim|magic|accent-warm|warning|warning-deep|danger|danger-deep|success|gold|gold-bright)$/;
const MIX = /color-mix\(in srgb,\s*var\((--hb-[a-z0-9-]+)\)[^,]*,\s*var\((--hb-[a-z0-9-]+)\)\s*\)/g;
/** 主題預覽色塊:見 global.css 的 .hb-theme-picker__swatch--* */
const HEX_BUDGET = { "global.css": 12, "layout.css": 0, "ui.css": 0 };
const errors = [];
function walk(dir) {
const out = [];
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) out.push(...walk(path));
else out.push(path);
}
return out;
}
const cssFiles = readdirSync(STYLE_DIR).filter((f) => f.endsWith(".css"));
// 除了 tokens.css 的全域 token元件內宣告的區域變數如 --hb-top-ctrl也算已定義。
const defined = new Set(
cssFiles.flatMap((f) => [
...readFileSync(join(STYLE_DIR, f), "utf8").matchAll(/^\s*(--hb-[a-z0-9-]+)\s*:/gm),
].map((m) => m[1])),
);
for (const file of readdirSync(STYLE_DIR)) {
if (!file.endsWith(".css") || file === "tokens.css") continue;
const path = join(STYLE_DIR, file);
const text = readFileSync(path, "utf8");
text.split("\n").forEach((line, i) => {
const at = `${path}:${i + 1}`;
if (/font-size:\s*[0-9.]+(rem|px|pt)/.test(line)) {
errors.push(`${at} 寫死 font-size請改用 var(--hb-text-*)${line.trim()}`);
}
for (const [, name] of line.matchAll(/var\((--hb-[a-z0-9-]+)/g)) {
if (!defined.has(name)) {
errors.push(`${at} 用了 tokens.css 沒有的 ${name}(拼錯?)`);
}
}
for (const [, a, b] of line.matchAll(MIX)) {
if (CHROMATIC.test(a) && CHROMATIC.test(b)) {
errors.push(
`${at} 混了兩個有彩度的顏色(${a} + ${b}),會變濁。` +
`要調淡請跟 surfacelinetransparent 混,要深一階請用 -deep。`,
);
}
}
});
const hex = (text.match(/#[0-9a-fA-F]{3,8}\b/g) ?? []).filter((h) => !NEUTRAL.test(h));
const budget = HEX_BUDGET[file] ?? 0;
if (hex.length > budget) {
errors.push(
`${path} 寫死顏色 ${hex.length} 處(上限 ${budget}${[...new Set(hex)].join(" ")}` +
`顏色請走 tokens.css否則深淺模式會有一半畫面不跟著切。`,
);
}
}
for (const path of walk("src")) {
if (!path.endsWith(".tsx")) continue;
const text = readFileSync(path, "utf8");
text.split("\n").forEach((line, i) => {
const at = `${path}:${i + 1}`;
if (/fontSize:\s*"[0-9.]/.test(line)) {
errors.push(`${at} inline fontSize 寫死,請改用 var(--hb-text-*)`);
}
if (/(color|background|borderColor):\s*"#[0-9a-fA-F]{3,8}"/.test(line)) {
errors.push(`${at} inline 色碼寫死,請改用 var(--hb-*)`);
}
});
}
if (errors.length) {
console.error(`樣式把關失敗(${errors.length}`);
for (const e of errors) console.error(" " + e);
process.exit(1);
}
console.log("樣式把關通過:顏色與字級都來自 tokens.css");

View File

@ -32,7 +32,7 @@ export function AccountMenu() {
const avatarAccount = { const avatarAccount = {
display_name: name, display_name: name,
username: member?.email || "user", username: member?.email || "user",
avatar_color: "#20b49c", avatar_color: "var(--hb-brand)",
avatar_url: member?.avatar_url, avatar_url: member?.avatar_url,
}; };

View File

@ -377,7 +377,10 @@ export function PersonaWorkbench() {
</span> </span>
<span className="text-muted hb-persona-item__tone">{toneOf(p) || t("common.dash")}</span> <span className="text-muted hb-persona-item__tone">{toneOf(p) || t("common.dash")}</span>
{p.learning_summary ? ( {p.learning_summary ? (
<span className="text-muted" style={{ display: "block", fontSize: "0.8em" }}> <span
className="text-muted"
style={{ display: "block", fontSize: "var(--hb-text-2xs)" }}
>
{p.learning_summary} {p.learning_summary}
</span> </span>
) : null} ) : null}
@ -511,7 +514,7 @@ export function PersonaWorkbench() {
}) })
} }
/> />
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: 0 }}>
{t("persona.guardChars", { n: selected.guard.maxChars })} {t("persona.guardChars", { n: selected.guard.maxChars })}
{selected.guard.banAiTone ? t("persona.banAi") : ""} {selected.guard.banAiTone ? t("persona.banAi") : ""}
{!isPersonaReady(selected) ? t("persona.notReadySuffix") : ""} {!isPersonaReady(selected) ? t("persona.notReadySuffix") : ""}
@ -613,7 +616,7 @@ export function PersonaWorkbench() {
{hasAnalysis ? ( {hasAnalysis ? (
<div className="hb-stack"> <div className="hb-stack">
<p className="text-muted" style={{ fontSize: "0.875rem", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)", margin: 0 }}>
{t("persona.sampleMeta", { n: selected.style.sampleCount })} {t("persona.sampleMeta", { n: selected.style.sampleCount })}
{" · "} {" · "}
{selected.style.source === "benchmark" {selected.style.source === "benchmark"
@ -643,7 +646,7 @@ export function PersonaWorkbench() {
) : null} ) : null}
</div> </div>
) : ( ) : (
<p className="text-muted" style={{ fontSize: "0.875rem" }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}>
{t("persona.analyzeHint")} {t("persona.analyzeHint")}
</p> </p>
)} )}
@ -652,7 +655,7 @@ export function PersonaWorkbench() {
{detailTab === "fingerprint" ? ( {detailTab === "fingerprint" ? (
<div className="hb-stack"> <div className="hb-stack">
<p className="text-muted" style={{ fontSize: "0.875rem", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)", margin: 0 }}>
{t("persona.fingerprintHint")} {t("persona.fingerprintHint")}
</p> </p>
<Textarea <Textarea
@ -670,7 +673,7 @@ export function PersonaWorkbench() {
{detailTab === "preview" ? ( {detailTab === "preview" ? (
<div className="hb-stack"> <div className="hb-stack">
<p className="text-muted" style={{ fontSize: "0.875rem", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)", margin: 0 }}>
{t("persona.previewHint")} {t("persona.previewHint")}
</p> </p>
<Button <Button
@ -681,12 +684,12 @@ export function PersonaWorkbench() {
{busy === "preview" ? t("persona.previewRunning") : t("persona.tryGen")} {busy === "preview" ? t("persona.previewRunning") : t("persona.tryGen")}
</Button> </Button>
{!isPersonaReady(selected) ? ( {!isPersonaReady(selected) ? (
<p className="text-muted" style={{ fontSize: "0.875rem" }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}>
{t("persona.notReadyMsg")} {t("persona.notReadyMsg")}
</p> </p>
) : null} ) : null}
{previewTopic ? ( {previewTopic ? (
<p className="text-muted" style={{ fontSize: "0.85rem", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)", margin: 0 }}>
{t("persona.previewTopicLabel", { {t("persona.previewTopicLabel", {
topic: previewTopic, topic: previewTopic,
source: source:
@ -699,7 +702,7 @@ export function PersonaWorkbench() {
</p> </p>
) : null} ) : null}
{previewNotes ? ( {previewNotes ? (
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: 0 }}>
{previewNotes} {previewNotes}
</p> </p>
) : null} ) : null}

View File

@ -179,12 +179,12 @@ export function ImageAttach({
</Button> </Button>
</div> </div>
{uploadingN > 0 ? ( {uploadingN > 0 ? (
<p className="text-muted" style={{ margin: 0, fontSize: "0.8rem" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-xs)" }}>
{t("image.uploadingN", { n: uploadingN })} {t("image.uploadingN", { n: uploadingN })}
</p> </p>
) : null} ) : null}
{err ? ( {err ? (
<p className="hb-form-error" role="alert" style={{ margin: 0, fontSize: "0.8rem" }}> <p className="hb-form-error" role="alert" style={{ margin: 0, fontSize: "var(--hb-text-xs)" }}>
{err} {err}
</p> </p>
) : null} ) : null}

View File

@ -85,7 +85,7 @@ export function ReplyComposer({
</Select> </Select>
</div> </div>
{!ready ? ( {!ready ? (
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: 0 }}>
{t("reply.notReady")} {t("reply.notReady")}
</p> </p>
) : null} ) : null}

View File

@ -48,7 +48,8 @@ function Spark({
s?: number; s?: number;
tone?: "magic" | "accent"; tone?: "magic" | "accent";
}) { }) {
const color = tone === "accent" ? "var(--hb-accent-warm)" : "var(--hb-magic)"; // magic 不用 --hb-magic那是次色奶油黃當線條放在淺底上幾乎看不見。
const color = tone === "accent" ? "var(--hb-accent-warm)" : "var(--hb-brand-deep)";
return ( return (
<g opacity={0.9} transform={`translate(${x} ${y}) scale(${s})`}> <g opacity={0.9} transform={`translate(${x} ${y}) scale(${s})`}>
<path <path

View File

@ -502,7 +502,8 @@ function mapAccount(raw: Record<string, unknown>): ThreadsAccount {
display_name: String(raw.display_name ?? ""), display_name: String(raw.display_name ?? ""),
connection: (raw.connection as ThreadsAccount["connection"]) ?? "disconnected", connection: (raw.connection as ThreadsAccount["connection"]) ?? "disconnected",
is_usable: Boolean(raw.is_usable), is_usable: Boolean(raw.is_usable),
avatar_color: String(raw.avatar_color ?? "#20b49c"), // 留空字串讓 AccountAvatar 退回 var(--hb-brand),寫死色碼會不跟著深淺模式切。
avatar_color: String(raw.avatar_color ?? ""),
avatar_url: raw.avatar_url != null ? String(raw.avatar_url) : null, avatar_url: raw.avatar_url != null ? String(raw.avatar_url) : null,
error_message: raw.error_message != null ? String(raw.error_message) : undefined, error_message: raw.error_message != null ? String(raw.error_message) : undefined,
session_expires_at: mapUnixNano(raw.session_expires_at), session_expires_at: mapUnixNano(raw.session_expires_at),

View File

@ -376,7 +376,7 @@ export function AdminUsersPage() {
{revealed.reason === "create" ? t("admin.users.tempPwNew") : t("admin.users.tempPw")} {revealed.reason === "create" ? t("admin.users.tempPwNew") : t("admin.users.tempPw")}
{t("admin.users.tempPwPersist")} {t("admin.users.tempPwPersist")}
</p> </p>
<span className="text-muted" style={{ fontSize: "0.78rem" }}> <span className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
{revealed.display_name} · {revealed.email} {revealed.display_name} · {revealed.email}
{" · "} {" · "}
{formatLocalDateTime(revealed.created_at)} {formatLocalDateTime(revealed.created_at)}
@ -396,7 +396,7 @@ export function AdminUsersPage() {
</div> </div>
</div> </div>
{copyHint ? ( {copyHint ? (
<p className="text-muted" style={{ margin: 0, fontSize: "0.8rem" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-xs)" }}>
<strong style={{ color: "var(--hb-brand)" }}>{copyHint}</strong> <strong style={{ color: "var(--hb-brand)" }}>{copyHint}</strong>
</p> </p>
) : null} ) : null}
@ -507,7 +507,7 @@ export function AdminUsersPage() {
) : null} ) : null}
</div> </div>
{query ? ( {query ? (
<p className="text-muted" style={{ margin: 0, fontSize: "0.8rem" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-xs)" }}>
{t("admin.users.searchActive", { query })} {t("admin.users.searchActive", { query })}
</p> </p>
) : null} ) : null}
@ -582,7 +582,7 @@ export function AdminUsersPage() {
{selected.email} {selected.email}
</p> </p>
<p style={{ margin: "0.35rem 0 0" }}> <p style={{ margin: "0.35rem 0 0" }}>
<Link to="/app/invite" className="text-muted" style={{ fontSize: "0.82rem" }}> <Link to="/app/invite" className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
{t("invite.openFromAdmin")} {t("invite.openFromAdmin")}
</Link> </Link>
</p> </p>

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { PageHeader } from "../components/layout/PageHeader"; import { PageHeader } from "../components/layout/PageHeader";
import { Button, Card, Input } from "../components/ui"; import { Button, Card } from "../components/ui";
import { apiRequest } from "../data/live/http"; import { apiRequest } from "../data/live/http";
type Bench = { type Bench = {
@ -16,19 +16,19 @@ type Bench = {
}; };
export function BenchmarkPage() { export function BenchmarkPage() {
const [niche, setNiche] = useState("general");
const [data, setData] = useState<Bench | null>(null); const [data, setData] = useState<Bench | null>(null);
const [err, setErr] = useState(""); const [err, setErr] = useState("");
// 利基維度還沒有真來源(帳號/品牌都沒存利基),先只比全站桶;
// 給輸入框只會讓使用者打出一個永遠「樣本不足」的空桶。
const load = useCallback(async () => { const load = useCallback(async () => {
setErr(""); setErr("");
try { try {
const q = new URLSearchParams({ niche }); setData(await apiRequest<Bench>("/api/v1/benchmark"));
setData(await apiRequest<Bench>(`/api/v1/benchmark?${q}`));
} catch (e) { } catch (e) {
setErr(e instanceof Error ? e.message : "fail"); setErr(e instanceof Error ? e.message : "fail");
} }
}, [niche]); }, []);
useEffect(() => { useEffect(() => {
void load(); void load();
@ -36,17 +36,16 @@ export function BenchmarkPage() {
return ( return (
<> <>
<PageHeader title="同利基基準" description="匿名聚合中位數樣本≥5 才顯示)" /> <PageHeader title="成效基準" description="全站匿名聚合中位數樣本≥5 才顯示)" />
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}> <div style={{ marginBottom: "1rem" }}>
<Input value={niche} onChange={(e) => setNiche(e.target.value)} placeholder="niche" />
<Button type="button" onClick={() => void load()}> <Button type="button" onClick={() => void load()}>
</Button> </Button>
</div> </div>
{err ? <p className="hb-form-error">{err}</p> : null} {err ? <p className="hb-form-error">{err}</p> : null}
{data ? ( {data ? (
<Card> <Card>
<p>{data.niche} · {data.sample_size}</p> <p> {data.sample_size}</p>
{data.available ? ( {data.available ? (
<> <>
<p> <p>

View File

@ -214,7 +214,7 @@ export function CrewPage() {
<Button type="button" onClick={() => void connectAccount()} disabled={Boolean(busy)}> <Button type="button" onClick={() => void connectAccount()} disabled={Boolean(busy)}>
{busy === "create" ? t("crew.connecting") : t("crew.connect")} {busy === "create" ? t("crew.connecting") : t("crew.connect")}
</Button> </Button>
<p className="text-muted" style={{ margin: 0, fontSize: "0.8rem", alignSelf: "center" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-xs)", alignSelf: "center" }}>
{t("crew.tokenRenewHint")} {t("crew.tokenRenewHint")}
</p> </p>
</div> </div>

View File

@ -46,10 +46,10 @@ export function ForgotPasswordPage() {
<p className="hb-banner-ok" role="status" style={{ margin: 0 }}> <p className="hb-banner-ok" role="status" style={{ margin: 0 }}>
{done.message} {done.message}
</p> </p>
<p className="text-muted" style={{ margin: 0, fontSize: "0.875rem" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("forgot.checkInbox")} {t("forgot.checkInbox")}
</p> </p>
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("forgot.nextStep")} {t("forgot.nextStep")}
</p> </p>
<Link to={resetPath} className="hb-btn hb-btn--primary">{t("forgot.openReset")}</Link> <Link to={resetPath} className="hb-btn hb-btn--primary">{t("forgot.openReset")}</Link>
@ -69,7 +69,7 @@ export function ForgotPasswordPage() {
</div> </div>
) : ( ) : (
<form className="hb-stack" onSubmit={(e) => void onSubmit(e)}> <form className="hb-stack" onSubmit={(e) => void onSubmit(e)}>
<p className="text-muted" style={{ margin: 0, fontSize: "0.875rem" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("forgot.hint")} {t("forgot.hint")}
</p> </p>
<Input <Input

View File

@ -222,7 +222,7 @@ export function JobDetailPage() {
) : null} ) : null}
{job.error ? <p className="hb-form-error">{job.error}</p> : null} {job.error ? <p className="hb-form-error">{job.error}</p> : null}
{job.template_type === "compose_mimic" && job.status === "succeeded" ? ( {job.template_type === "compose_mimic" && job.status === "succeeded" ? (
<p className="text-muted" style={{ fontSize: "0.875rem", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)", margin: 0 }}>
{t("jobs.mimicApplyHint")} {t("jobs.mimicApplyHint")}
</p> </p>
) : null} ) : null}

View File

@ -139,7 +139,7 @@ export function OutboxDetailPage() {
</p> </p>
) : null} ) : null}
{inFlight ? ( {inFlight ? (
<p className="text-muted" style={{ fontSize: "0.875rem" }} role="status"> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }} role="status">
{t("outbox.detail.sendingHint")} {t("outbox.detail.sendingHint")}
</p> </p>
) : null} ) : null}

View File

@ -143,7 +143,7 @@ export function PlaybooksPage() {
<Badge tone="neutral">{p.author_label}</Badge> <Badge tone="neutral">{p.author_label}</Badge>
<Badge tone="neutral"> {p.import_count}</Badge> <Badge tone="neutral"> {p.import_count}</Badge>
</div> </div>
<pre style={{ whiteSpace: "pre-wrap", fontSize: "0.85rem" }}>{p.body.slice(0, 400)}</pre> <pre style={{ whiteSpace: "pre-wrap", fontSize: "var(--hb-text-sm)" }}>{p.body.slice(0, 400)}</pre>
{!p.mine ? ( {!p.mine ? (
<Button type="button" disabled={busy === p.id} onClick={() => void importPb(p.id)}> <Button type="button" disabled={busy === p.id} onClick={() => void importPb(p.id)}>

View File

@ -72,7 +72,7 @@ export function ResetPasswordPage() {
<p className="hb-banner-ok" role="status" style={{ margin: 0 }}> <p className="hb-banner-ok" role="status" style={{ margin: 0 }}>
{okMsg} {okMsg}
</p> </p>
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("reset.redirecting")} {t("reset.redirecting")}
</p> </p>
<Link to="/login"> <Link to="/login">
@ -81,7 +81,7 @@ export function ResetPasswordPage() {
</div> </div>
) : ( ) : (
<form className="hb-stack" onSubmit={(e) => void onSubmit(e)}> <form className="hb-stack" onSubmit={(e) => void onSubmit(e)}>
<p className="text-muted" style={{ margin: 0, fontSize: "0.875rem" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("reset.hint")} {t("reset.hint")}
</p> </p>
<Input <Input
@ -113,7 +113,7 @@ export function ResetPasswordPage() {
required required
placeholder={t("reset.passwordPh")} placeholder={t("reset.passwordPh")}
/> />
<p className="text-muted" style={{ margin: 0, fontSize: "0.8rem" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-xs)" }}>
{t("password.policy.hint")} {t("password.policy.hint")}
</p> </p>
<Input <Input

View File

@ -677,7 +677,7 @@ export function ScoutPage() {
))} ))}
</Select> </Select>
{selectedProduct ? ( {selectedProduct ? (
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: 0 }}>
{t("scout.placement", { label: selectedProduct.label })} {t("scout.placement", { label: selectedProduct.label })}
{selectedProduct.pain_points?.[0] {selectedProduct.pain_points?.[0]
? t("scout.painPart", { pain: selectedProduct.pain_points[0] }) ? t("scout.painPart", { pain: selectedProduct.pain_points[0] })
@ -685,7 +685,7 @@ export function ScoutPage() {
</p> </p>
) : null} ) : null}
{allProducts.length === 0 ? ( {allProducts.length === 0 ? (
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: 0 }}>
{t("scout.noProductsBefore")}{" "} {t("scout.noProductsBefore")}{" "}
<Link to="/app/brands">{t("nav.brands")}</Link>{" "} <Link to="/app/brands">{t("nav.brands")}</Link>{" "}
{t("scout.noProductsAfter")} {t("scout.noProductsAfter")}
@ -724,7 +724,7 @@ export function ScoutPage() {
{runGroups.length > 0 ? ( {runGroups.length > 0 ? (
<Card title={t("scout.runs")}> <Card title={t("scout.runs")}>
<div className="hb-stack"> <div className="hb-stack">
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("scout.runCount", { n: runGroups.length })} {t("scout.runCount", { n: runGroups.length })}
</p> </p>
<div className="hb-scout-run-list"> <div className="hb-scout-run-list">
@ -785,7 +785,7 @@ export function ScoutPage() {
</div> </div>
<p className="hb-scout-now__text">{current.text}</p> <p className="hb-scout-now__text">{current.text}</p>
{current.created_at || allowHttpUrl(current.permalink) ? ( {current.created_at || allowHttpUrl(current.permalink) ? (
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: 0 }}>
{current.created_at ? t("scout.createdAt", { time: formatLocalDateTime(current.created_at) }) : ""} {current.created_at ? t("scout.createdAt", { time: formatLocalDateTime(current.created_at) }) : ""}
{current.created_at && allowHttpUrl(current.permalink) ? " · " : ""} {current.created_at && allowHttpUrl(current.permalink) ? " · " : ""}
{allowHttpUrl(current.permalink) ? ( {allowHttpUrl(current.permalink) ? (
@ -796,12 +796,12 @@ export function ScoutPage() {
</p> </p>
) : null} ) : null}
{current.match_reason ? ( {current.match_reason ? (
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: 0 }}>
{current.match_reason} {current.match_reason}
</p> </p>
) : null} ) : null}
{current.opportunity ? ( {current.opportunity ? (
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: 0 }}>
{current.opportunity} {current.opportunity}
</p> </p>
) : null} ) : null}
@ -814,7 +814,7 @@ export function ScoutPage() {
placeholder={current.scout_mode === "activity" ? t("scout.draftPhActivity") : t("scout.draftPhValue")} placeholder={current.scout_mode === "activity" ? t("scout.draftPhActivity") : t("scout.draftPhValue")}
/> />
<p className="text-muted" style={{ fontSize: "0.85rem", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)", margin: 0 }}>
{t("scout.manualReplyHint")} {t("scout.manualReplyHint")}
</p> </p>

View File

@ -304,7 +304,7 @@ export function SettingsPage() {
<Card title={t("settings.ai")}> <Card title={t("settings.ai")}>
{ai ? ( {ai ? (
<div className="hb-stack"> <div className="hb-stack">
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("settings.aiUnifiedHint")} {t("settings.aiUnifiedHint")}
</p> </p>
<Select <Select
@ -353,11 +353,11 @@ export function SettingsPage() {
} }
/> />
{ai.models_error ? ( {ai.models_error ? (
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("settings.modelsHint")}: {ai.models_error} {t("settings.modelsHint")}: {ai.models_error}
</p> </p>
) : ai.models_from_cache ? ( ) : ai.models_from_cache ? (
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("settings.modelsCached")} {t("settings.modelsCached")}
</p> </p>
) : null} ) : null}

View File

@ -210,7 +210,7 @@ export function TodayPage() {
display_name: accId, display_name: accId,
connection: "connected", connection: "connected",
is_usable: true, is_usable: true,
avatar_color: "#888", avatar_color: "var(--hb-muted)",
} as ThreadsAccount); } as ThreadsAccount);
rows.push({ rows.push({
account: acc, account: acc,
@ -338,7 +338,7 @@ export function TodayPage() {
</div> </div>
</div> </div>
{!hasOutcome ? ( {!hasOutcome ? (
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("today.outcome.emptyHint")}{" "} {t("today.outcome.emptyHint")}{" "}
<Link to="/app/scout">{t("today.goScout")}</Link> <Link to="/app/scout">{t("today.goScout")}</Link>
</p> </p>
@ -375,7 +375,7 @@ export function TodayPage() {
</ul> </ul>
</div> </div>
) : ( ) : (
<p className="text-muted" style={{ margin: "0.75rem 0 0", fontSize: "0.85rem" }}> <p className="text-muted" style={{ margin: "0.75rem 0 0", fontSize: "var(--hb-text-sm)" }}>
{t("today.checkup.empty")} {t("today.checkup.empty")}
</p> </p>
)} )}
@ -482,7 +482,7 @@ export function TodayPage() {
))} ))}
</ul> </ul>
{pendingScout.length > pendingPreview.length ? ( {pendingScout.length > pendingPreview.length ? (
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("today.pending.more", { n: pendingScout.length - pendingPreview.length })}{" "} {t("today.pending.more", { n: pendingScout.length - pendingPreview.length })}{" "}
<Link to="/app/scout">{t("today.pending.handle")}</Link> <Link to="/app/scout">{t("today.pending.handle")}</Link>
</p> </p>
@ -589,7 +589,7 @@ export function TodayPage() {
</p> </p>
) : ( ) : (
<div className="hb-stack" style={{ gap: "0.5rem" }}> <div className="hb-stack" style={{ gap: "0.5rem" }}>
<p style={{ margin: 0, fontSize: "0.9rem" }}> <p style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("today.outbox.summary", { {t("today.outbox.summary", {
sent: sentToday, sent: sentToday,
running: runningOutbox.length, running: runningOutbox.length,
@ -597,13 +597,13 @@ export function TodayPage() {
})} })}
</p> </p>
{failedOutbox.slice(0, 2).map((o) => ( {failedOutbox.slice(0, 2).map((o) => (
<p key={o.id} style={{ margin: 0, fontSize: "0.85rem" }}> <p key={o.id} style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
<Badge tone="danger">{t("today.badge.failed")}</Badge>{" "} <Badge tone="danger">{t("today.badge.failed")}</Badge>{" "}
<Link to={`/app/outbox/${o.id}`}>{o.title || o.id.slice(0, 8)}</Link> <Link to={`/app/outbox/${o.id}`}>{o.title || o.id.slice(0, 8)}</Link>
</p> </p>
))} ))}
{runningOutbox.slice(0, 2).map((o) => ( {runningOutbox.slice(0, 2).map((o) => (
<p key={o.id} style={{ margin: 0, fontSize: "0.85rem" }}> <p key={o.id} style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
<Badge tone="brand"> <Badge tone="brand">
{o.status === "scheduling" {o.status === "scheduling"
? t("today.badge.scheduling") ? t("today.badge.scheduling")
@ -653,7 +653,7 @@ export function TodayPage() {
<li key={row.account.id} className="hb-today-account"> <li key={row.account.id} className="hb-today-account">
<div className="hb-today-account__head"> <div className="hb-today-account__head">
<strong>@{row.account.username}</strong> <strong>@{row.account.username}</strong>
<span className="text-muted" style={{ fontSize: "0.78rem" }}> <span className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
{t("today.postsCount", { n: row.posts })} {t("today.postsCount", { n: row.posts })}
</span> </span>
</div> </div>

View File

@ -327,7 +327,7 @@ export function UsagePage() {
: ""} : ""}
</p> </p>
{!outcomeSummary?.reach && !outcomeSummary?.conversations && !outcomeSummary?.conversions ? ( {!outcomeSummary?.reach && !outcomeSummary?.conversations && !outcomeSummary?.conversions ? (
<p className="text-muted" style={{ margin: "0.35rem 0 0", fontSize: "0.82rem" }}> <p className="text-muted" style={{ margin: "0.35rem 0 0", fontSize: "var(--hb-text-xs)" }}>
{t("usage.outcome.emptyHint")} {t("usage.outcome.emptyHint")}
</p> </p>
) : null} ) : null}

View File

@ -7,6 +7,8 @@ type Link = {
id: string; id: string;
code: string; code: string;
track_path: string; track_path: string;
/** 後端算好的絕對網址;舊版後端沒有這欄,退回 track_path。 */
track_url?: string;
destination_url: string; destination_url: string;
outcome_id?: string; outcome_id?: string;
label?: string; label?: string;
@ -14,11 +16,14 @@ type Link = {
created_at: number; created_at: number;
}; };
function trackUrlOf(l: Link): string {
return l.track_url || `${window.location.origin}${l.track_path}`;
}
export function UtmLinksPage() { export function UtmLinksPage() {
const [list, setList] = useState<Link[]>([]); const [list, setList] = useState<Link[]>([]);
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [label, setLabel] = useState(""); const [label, setLabel] = useState("");
const [outcomeId, setOutcomeId] = useState("");
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
const load = useCallback(async () => { const load = useCallback(async () => {
@ -34,11 +39,10 @@ export function UtmLinksPage() {
try { try {
await apiRequest("/api/v1/utm/links", { await apiRequest("/api/v1/utm/links", {
method: "POST", method: "POST",
body: { destination_url: url, label, outcome_id: outcomeId || undefined }, body: { destination_url: url, label },
}); });
setUrl(""); setUrl("");
setLabel(""); setLabel("");
setOutcomeId("");
await load(); await load();
setMsg("已建立追蹤連結"); setMsg("已建立追蹤連結");
} catch (e) { } catch (e) {
@ -46,19 +50,23 @@ export function UtmLinksPage() {
} }
} }
async function copy(l: Link) {
try {
await navigator.clipboard.writeText(trackUrlOf(l));
setMsg("已複製追蹤連結");
} catch {
setMsg("複製失敗,請手動選取網址");
}
}
return ( return (
<> <>
<PageHeader title="UTM 追蹤連結" description="點擊自動計入成果歸因" /> <PageHeader title="UTM 追蹤連結" description="貼進貼文的可追蹤連結,累計點擊次數" />
{msg ? <p className="text-muted">{msg}</p> : null} {msg ? <p className="text-muted">{msg}</p> : null}
<Card title="新建"> <Card title="新建">
<div className="hb-stack"> <div className="hb-stack">
<Input label="目標 URL" value={url} onChange={(e) => setUrl(e.target.value)} /> <Input label="目標 URL" value={url} onChange={(e) => setUrl(e.target.value)} />
<Input label="標籤" value={label} onChange={(e) => setLabel(e.target.value)} /> <Input label="標籤" value={label} onChange={(e) => setLabel(e.target.value)} />
<Input
label="綁定 Outcome ID選填"
value={outcomeId}
onChange={(e) => setOutcomeId(e.target.value)}
/>
<Button type="button" disabled={!url.startsWith("http")} onClick={() => void create()}> <Button type="button" disabled={!url.startsWith("http")} onClick={() => void create()}>
</Button> </Button>
@ -71,11 +79,13 @@ export function UtmLinksPage() {
{list.map((l) => ( {list.map((l) => (
<Card key={l.id} title={l.label || l.code}> <Card key={l.id} title={l.label || l.code}>
<p className="text-muted" style={{ wordBreak: "break-all" }}> <p className="text-muted" style={{ wordBreak: "break-all" }}>
{window.location.origin} {trackUrlOf(l)}
{l.track_path}
</p> </p>
<p>{l.destination_url}</p> <p>{l.destination_url}</p>
<p>{l.clicks}</p> <p>{l.clicks}</p>
<Button type="button" onClick={() => void copy(l)}>
</Button>
</Card> </Card>
))} ))}
</ul> </ul>

View File

@ -81,8 +81,8 @@ export function VerifyEmailPage() {
<div className="hb-stack"> <div className="hb-stack">
<div className="hb-verify-status"> <div className="hb-verify-status">
<span className="hb-verify-status__badge is-pending">{t("verify.pending")}</span> <span className="hb-verify-status__badge is-pending">{t("verify.pending")}</span>
<p style={{ margin: 0, fontSize: "0.9rem" }}>{t("verify.body")}</p> <p style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>{t("verify.body")}</p>
<p className="text-muted" style={{ margin: 0, fontSize: "0.82rem" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-xs)" }}>
{t("verify.currentAccount", { email: member.email })} {t("verify.currentAccount", { email: member.email })}
{" · "} {" · "}
{role.primaryLabel} {role.primaryLabel}
@ -136,7 +136,7 @@ export function VerifyEmailPage() {
</Button> </Button>
</div> </div>
<p className="text-muted" style={{ margin: 0, fontSize: "0.78rem", textAlign: "center" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-xs)", textAlign: "center" }}>
{t("verify.after")} {t("verify.after")}
</p> </p>
</div> </div>

View File

@ -271,12 +271,12 @@ export function ComposerPanel({ accountId, personaId, personaReady }: Props) {
return ( return (
<div className="hb-stack"> <div className="hb-stack">
<p className="text-muted" style={{ fontSize: "0.875rem", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)", margin: 0 }}>
{t("compose.hint")} <Link to="/app/studio?tab=plays">{t("compose.playsLink")}</Link> {t("compose.hint")} <Link to="/app/studio?tab=plays">{t("compose.playsLink")}</Link>
{t("compose.hintEnd")} {t("compose.hintEnd")}
</p> </p>
{!personaReady ? ( {!personaReady ? (
<p className="text-muted" style={{ fontSize: "0.875rem" }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}>
{t("compose.personaOff")} {t("compose.personaOff")}
</p> </p>
) : null} ) : null}

View File

@ -425,7 +425,7 @@ export function InsightsPanel({
</Select> </Select>
) : ( ) : (
<div className="hb-insights-toolbar__hint"> <div className="hb-insights-toolbar__hint">
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("insights.panelHint")} {t("insights.panelHint")}
{syncedAt ? ( {syncedAt ? (
<> <>
@ -576,7 +576,7 @@ export function InsightsPanel({
<div className="hb-insights-snap-panel"> <div className="hb-insights-snap-panel">
<div className="hb-insights-snap-meta"> <div className="hb-insights-snap-meta">
<strong>{t("insights.analysisOf", { label: selectedSnap.month_label })}</strong> <strong>{t("insights.analysisOf", { label: selectedSnap.month_label })}</strong>
<span className="text-muted" style={{ fontSize: "0.8rem" }}> <span className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
{t("insights.producedAt", { time: formatLocalDateTime(selectedSnap.analyzed_at) })} {t("insights.producedAt", { time: formatLocalDateTime(selectedSnap.analyzed_at) })}
{selectedSnap.delta.views != null {selectedSnap.delta.views != null
? t("insights.viewsVsPrev", { delta: fmtDelta(selectedSnap.delta.views) }) ? t("insights.viewsVsPrev", { delta: fmtDelta(selectedSnap.delta.views) })
@ -702,12 +702,12 @@ export function InsightsPanel({
{p.text.length > 120 ? "…" : ""} {p.text.length > 120 ? "…" : ""}
</span> </span>
{p.insight || p.formula_summary ? ( {p.insight || p.formula_summary ? (
<span className="text-muted" style={{ fontSize: "0.8rem" }}> <span className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
{p.insight || p.formula_summary} {p.insight || p.formula_summary}
</span> </span>
) : null} ) : null}
{p.permalink ? ( {p.permalink ? (
<a href={p.permalink} target="_blank" rel="noreferrer" style={{ fontSize: "0.8rem" }}> <a href={p.permalink} target="_blank" rel="noreferrer" style={{ fontSize: "var(--hb-text-xs)" }}>
{t("insights.openThreads")} {t("insights.openThreads")}
</a> </a>
) : null} ) : null}

View File

@ -1249,7 +1249,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
); );
})} })}
{brands.length === 0 ? ( {brands.length === 0 ? (
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: 0 }}>
{t("inspire.noBrands")} {t("inspire.noBrands")}
</p> </p>
) : null} ) : null}

View File

@ -116,7 +116,7 @@ export function MentionsPanel({ accountId, personaId, accounts, personas }: Prop
return ( return (
<div className="hb-stack"> <div className="hb-stack">
<div className="hb-wizard-actions" style={{ justifyContent: "space-between", flexWrap: "wrap" }}> <div className="hb-wizard-actions" style={{ justifyContent: "space-between", flexWrap: "wrap" }}>
<p className="text-muted" style={{ fontSize: "0.875rem", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)", margin: 0 }}>
{t("mentions.hint", { n: pending })} {t("mentions.hint", { n: pending })}
{" · "} {" · "}
<Link to="/app/scout">{t("mentions.scoutLink")}</Link> <Link to="/app/scout">{t("mentions.scoutLink")}</Link>
@ -161,11 +161,11 @@ export function MentionsPanel({ accountId, personaId, accounts, personas }: Prop
: t("mentions.status.skipped")} : t("mentions.status.skipped")}
</Badge> </Badge>
<Badge tone="neutral">@{m.from_username}</Badge> <Badge tone="neutral">@{m.from_username}</Badge>
<span className="text-muted" style={{ fontSize: "0.8rem" }}> <span className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
{formatLocalDateTime(m.created_at)} {formatLocalDateTime(m.created_at)}
</span> </span>
</div> </div>
<p className="text-muted" style={{ fontSize: "0.8rem", margin: "0 0 0.35rem" }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: "0 0 0.35rem" }}>
{m.context_snippet} {m.context_snippet}
{allowHttpUrl(m.permalink) ? ( {allowHttpUrl(m.permalink) ? (
<> <>
@ -208,7 +208,7 @@ export function MentionsPanel({ accountId, personaId, accounts, personas }: Prop
/> />
) : null} ) : null}
{m.status === "replied" && m.draft_text ? ( {m.status === "replied" && m.draft_text ? (
<p className="text-muted" style={{ fontSize: "0.875rem" }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}>
{t("mentions.repliedPrefix", { text: m.draft_text })} {t("mentions.repliedPrefix", { text: m.draft_text })}
</p> </p>
) : null} ) : null}

View File

@ -341,12 +341,12 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
{/* AI 結構分析:有結果就顯示;詳細區在展開時更清楚 */} {/* AI 結構分析:有結果就顯示;詳細區在展開時更清楚 */}
{showAnalysis && post.insight ? ( {showAnalysis && post.insight ? (
<p className="text-muted" style={{ fontSize: "0.875rem" }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}>
{t("posts.insight", { text: post.insight })} {t("posts.insight", { text: post.insight })}
</p> </p>
) : null} ) : null}
{showAnalysis && post.formula_summary ? ( {showAnalysis && post.formula_summary ? (
<p className="text-muted" style={{ fontSize: "0.8rem" }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
{t("posts.review", { text: post.formula_summary })} {t("posts.review", { text: post.formula_summary })}
</p> </p>
) : null} ) : null}
@ -419,7 +419,7 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
{open ? ( {open ? (
<div className="hb-stack" style={{ marginTop: "0.75rem" }}> <div className="hb-stack" style={{ marginTop: "0.75rem" }}>
{busy === `replies:${post.id}` ? ( {busy === `replies:${post.id}` ? (
<p className="text-muted" style={{ fontSize: "0.875rem" }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}>
{t("posts.loadingReplies")} {t("posts.loadingReplies")}
</p> </p>
) : null} ) : null}
@ -485,12 +485,12 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
</Badge> </Badge>
<strong>@{r.username}</strong> <strong>@{r.username}</strong>
{typeof r.like_count === "number" ? ( {typeof r.like_count === "number" ? (
<span className="text-muted" style={{ fontSize: "0.8rem" }}> <span className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
{t("posts.likesN", { n: r.like_count })} {t("posts.likesN", { n: r.like_count })}
</span> </span>
) : null} ) : null}
{kids.length > 0 ? ( {kids.length > 0 ? (
<span className="text-muted" style={{ fontSize: "0.8rem" }}> <span className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
{t("posts.childCount", { n: kids.length })} {t("posts.childCount", { n: kids.length })}
</span> </span>
) : null} ) : null}
@ -510,7 +510,7 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
<div className="hb-inline-badges"> <div className="hb-inline-badges">
{mine ? <Badge tone="brand">{t("posts.mine")}</Badge> : null} {mine ? <Badge tone="brand">{t("posts.mine")}</Badge> : null}
<strong>@{c.username}</strong> <strong>@{c.username}</strong>
<span className="text-muted" style={{ fontSize: "0.75rem" }}> <span className="text-muted" style={{ fontSize: "var(--hb-text-2xs)" }}>
{formatLocalDateTime(c.created_at)} {formatLocalDateTime(c.created_at)}
</span> </span>
</div> </div>

View File

@ -682,7 +682,7 @@ export function PlaysPanel({ accountId, personas: personasProp = [] }: Props) {
{busy === "resolve" ? t("plays.resolving") : t("plays.resolve")} {busy === "resolve" ? t("plays.resolving") : t("plays.resolve")}
</Button> </Button>
</div> </div>
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: 0 }}>
{t("plays.resolveHint")} {t("plays.resolveHint")}
</p> </p>
</div> </div>
@ -691,11 +691,11 @@ export function PlaysPanel({ accountId, personas: personasProp = [] }: Props) {
{targetMode === "own" && selectedPost ? ( {targetMode === "own" && selectedPost ? (
<Card> <Card>
<p className="text-muted" style={{ fontSize: "0.8rem", margin: "0 0 0.35rem" }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: "0 0 0.35rem" }}>
{t("plays.targetOwn")} {t("plays.targetOwn")}
</p> </p>
<p style={{ margin: 0 }}>{selectedPost.text}</p> <p style={{ margin: 0 }}>{selectedPost.text}</p>
<p className="text-muted" style={{ fontSize: "0.8rem", margin: "0.35rem 0 0" }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: "0.35rem 0 0" }}>
{formatLocalDateTime(selectedPost.published_at)} {formatLocalDateTime(selectedPost.published_at)}
{allowHttpUrl(selectedPost.permalink) ? ( {allowHttpUrl(selectedPost.permalink) ? (
<> <>
@ -721,7 +721,7 @@ export function PlaysPanel({ accountId, personas: personasProp = [] }: Props) {
) : null} ) : null}
</div> </div>
<p style={{ margin: "0 0 0.35rem" }}>{external.text_preview}</p> <p style={{ margin: "0 0 0.35rem" }}>{external.text_preview}</p>
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: 0 }}>
{allowHttpUrl(external.url) ? ( {allowHttpUrl(external.url) ? (
<a href={allowHttpUrl(external.url) ?? undefined} target="_blank" rel="noreferrer"> <a href={allowHttpUrl(external.url) ?? undefined} target="_blank" rel="noreferrer">
{external.url} {external.url}
@ -762,7 +762,7 @@ export function PlaysPanel({ accountId, personas: personasProp = [] }: Props) {
<div className="hb-list-row"> <div className="hb-list-row">
<div> <div>
<strong>{s.title}</strong> <strong>{s.title}</strong>
<p className="text-muted" style={{ fontSize: "0.875rem" }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}>
{t("plays.replyCount", { n: s.steps.length })} {t("plays.replyCount", { n: s.steps.length })}
{" · "} {" · "}
{s.steps {s.steps
@ -822,7 +822,7 @@ export function PlaysPanel({ accountId, personas: personasProp = [] }: Props) {
})} })}
</div> </div>
{poolAccounts.length === 0 ? ( {poolAccounts.length === 0 ? (
<p className="text-muted" style={{ fontSize: "0.8rem" }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
{t("plays.noAccounts")} {t("plays.noAccounts")}
</p> </p>
) : null} ) : null}

View File

@ -26,7 +26,7 @@ export function PreviewStep({ draft, accountMap }: Props) {
acc || { acc || {
display_name: t("wizard.preview.unknown"), display_name: t("wizard.preview.unknown"),
username: "?", username: "?",
avatar_color: "#888", avatar_color: "var(--hb-muted)",
} }
} }
/> />

View File

@ -111,12 +111,12 @@ export function ScriptStep({ draft, accounts, onChange }: Props) {
))} ))}
</Select> </Select>
{!ready ? ( {!ready ? (
<p className="text-muted" style={{ fontSize: "0.875rem", marginTop: "0.5rem" }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)", marginTop: "0.5rem" }}>
{t("wizard.script.personaNotReady")} {t("wizard.script.personaNotReady")}
</p> </p>
) : null} ) : null}
{aiError ? ( {aiError ? (
<p className="hb-form-error" role="alert" style={{ fontSize: "0.875rem" }}> <p className="hb-form-error" role="alert" style={{ fontSize: "var(--hb-text-sm)" }}>
{aiError} {aiError}
</p> </p>
) : null} ) : null}

View File

@ -60,7 +60,7 @@ export function TopicStep({ draft, onChange }: Props) {
))} ))}
</Select> </Select>
{!ready ? ( {!ready ? (
<p className="text-muted" style={{ fontSize: "0.875rem" }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}>
{t("wizard.topic.personaNotReady")} {t("wizard.topic.personaNotReady")}
</p> </p>
) : null} ) : null}

File diff suppressed because it is too large Load Diff

View File

@ -20,7 +20,7 @@
z-index: 40; z-index: 40;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: color-mix(in srgb, var(--hb-surface-solid, #fff) 82%, transparent); background: color-mix(in srgb, var(--hb-surface-solid) 82%, transparent);
backdrop-filter: saturate(130%) blur(16px); backdrop-filter: saturate(130%) blur(16px);
-webkit-backdrop-filter: saturate(130%) blur(16px); -webkit-backdrop-filter: saturate(130%) blur(16px);
} }
@ -40,7 +40,7 @@
/* 進行中任務即時進度(有 job 才渲染) */ /* 進行中任務即時進度(有 job 才渲染) */
.hb-job-strip { .hb-job-strip {
border-bottom: 1px solid color-mix(in srgb, var(--hb-brand) 28%, var(--hb-line)); border-bottom: 1px solid color-mix(in srgb, var(--hb-brand) 28%, var(--hb-line));
background: color-mix(in srgb, var(--hb-brand) 8%, var(--hb-surface-solid, #fff)); background: color-mix(in srgb, var(--hb-brand) 8%, var(--hb-surface-solid));
} }
.hb-job-strip__inner { .hb-job-strip__inner {
@ -61,10 +61,10 @@
padding: 0.35rem 0.55rem; padding: 0.35rem 0.55rem;
border-radius: var(--hb-radius, 8px); border-radius: var(--hb-radius, 8px);
border: 1px solid color-mix(in srgb, var(--hb-brand) 22%, var(--hb-line)); border: 1px solid color-mix(in srgb, var(--hb-brand) 22%, var(--hb-line));
background: color-mix(in srgb, var(--hb-surface-solid, #fff) 88%, transparent); background: color-mix(in srgb, var(--hb-surface-solid) 88%, transparent);
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
font-size: 0.8rem; font-size: var(--hb-text-xs);
line-height: 1.35; line-height: 1.35;
} }
@ -82,12 +82,12 @@
.hb-job-strip__status { .hb-job-strip__status {
font-weight: 500; font-weight: 500;
color: var(--hb-brand); color: var(--hb-brand-deep);
font-size: 0.75rem; font-size: var(--hb-text-2xs);
} }
.hb-job-strip__summary { .hb-job-strip__summary {
color: var(--hb-muted, #667); color: var(--hb-muted);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@ -100,8 +100,8 @@
.hb-job-strip__more { .hb-job-strip__more {
align-self: center; align-self: center;
font-size: 0.78rem; font-size: var(--hb-text-xs);
color: var(--hb-brand); color: var(--hb-brand-deep);
text-decoration: none; text-decoration: none;
white-space: nowrap; white-space: nowrap;
padding: 0.25rem 0.4rem; padding: 0.25rem 0.4rem;
@ -130,14 +130,14 @@
.hb-persona-job__sum { .hb-persona-job__sum {
margin: 0 0 0.4rem; margin: 0 0 0.4rem;
font-size: 0.85rem; font-size: var(--hb-text-sm);
} }
.hb-persona-job__link { .hb-persona-job__link {
display: inline-block; display: inline-block;
margin-top: 0.45rem; margin-top: 0.45rem;
font-size: 0.82rem; font-size: var(--hb-text-xs);
color: var(--hb-brand); color: var(--hb-brand-deep);
text-decoration: none; text-decoration: none;
} }
@ -157,7 +157,7 @@
} }
.hb-topbar__brand:hover { .hb-topbar__brand:hover {
color: var(--hb-brand); color: var(--hb-brand-deep);
} }
.hb-topbar__mark, .hb-topbar__mark,
@ -171,7 +171,7 @@
box-shadow: box-shadow:
0 0 0 1px color-mix(in srgb, var(--hb-brand) 24%, transparent), 0 0 0 1px color-mix(in srgb, var(--hb-brand) 24%, transparent),
0 0 16px var(--hb-magic-glow), 0 0 16px var(--hb-magic-glow),
0 5px 14px rgb(43 32 96 / 0.24); 0 5px 14px color-mix(in srgb, var(--hb-ink) 24%, transparent);
transition: transform 0.16s ease, box-shadow 0.16s ease; transition: transform 0.16s ease, box-shadow 0.16s ease;
} }
@ -180,7 +180,7 @@
box-shadow: box-shadow:
0 0 0 1px color-mix(in srgb, var(--hb-magic) 52%, transparent), 0 0 0 1px color-mix(in srgb, var(--hb-magic) 52%, transparent),
0 0 22px var(--hb-magic-glow), 0 0 22px var(--hb-magic-glow),
0 7px 18px rgb(43 32 96 / 0.3); 0 7px 18px color-mix(in srgb, var(--hb-ink) 30%, transparent);
} }
.hb-topbar__title { .hb-topbar__title {
@ -192,7 +192,7 @@
} }
.hb-topbar__name { .hb-topbar__name {
font-size: 0.9rem; font-size: var(--hb-text-sm);
font-weight: 700; font-weight: 700;
letter-spacing: -0.01em; letter-spacing: -0.01em;
overflow: hidden; overflow: hidden;
@ -201,7 +201,7 @@
} }
.hb-topbar__en { .hb-topbar__en {
font-size: 0.62rem; font-size: var(--hb-text-2xs);
font-weight: 600; font-weight: 600;
color: var(--hb-muted); color: var(--hb-muted);
letter-spacing: 0.04em; letter-spacing: 0.04em;
@ -236,7 +236,7 @@
border-radius: 0.55rem; border-radius: 0.55rem;
background: color-mix(in srgb, var(--hb-surface) 92%, var(--hb-surface-muted)); background: color-mix(in srgb, var(--hb-surface) 92%, var(--hb-surface-muted));
color: var(--hb-ink-secondary); color: var(--hb-ink-secondary);
font-size: 0.8rem; font-size: var(--hb-text-xs);
cursor: pointer; cursor: pointer;
transition: border-color 0.14s ease, background 0.14s ease; transition: border-color 0.14s ease, background 0.14s ease;
} }
@ -316,17 +316,17 @@
.hb-topbar__cluster .hb-account__trigger:hover, .hb-topbar__cluster .hb-account__trigger:hover,
.hb-topbar__cluster .hb-account__trigger.is-open { .hb-topbar__cluster .hb-account__trigger.is-open {
background: color-mix(in srgb, var(--hb-brand-soft, var(--hb-surface-muted)) 70%, var(--hb-surface)); background: color-mix(in srgb, var(--hb-brand-soft, var(--hb-surface-muted)) 70%, var(--hb-surface));
color: var(--hb-brand); color: var(--hb-brand-deep);
border-color: transparent; border-color: transparent;
} }
.hb-topbar__cluster .hb-usage-widget__trigger.is-tight { .hb-topbar__cluster .hb-usage-widget__trigger.is-tight {
color: color-mix(in srgb, #b87a12 55%, var(--hb-ink)); color: var(--hb-warning-deep);
} }
.hb-topbar__cluster .hb-usage-widget__trigger.is-tight:hover, .hb-topbar__cluster .hb-usage-widget__trigger.is-tight:hover,
.hb-topbar__cluster .hb-usage-widget__trigger.is-tight.is-open { .hb-topbar__cluster .hb-usage-widget__trigger.is-tight.is-open {
background: color-mix(in srgb, #e8a04a 16%, var(--hb-surface)); background: color-mix(in srgb, var(--hb-warning) 16%, var(--hb-surface));
} }
.hb-topbar__cluster .hb-bell__badge { .hb-topbar__cluster .hb-bell__badge {
@ -348,7 +348,7 @@
} }
a.hb-topbar__chip:hover { a.hb-topbar__chip:hover {
color: var(--hb-brand); color: var(--hb-brand-deep);
border-color: var(--hb-brand); border-color: var(--hb-brand);
} }
@ -382,7 +382,7 @@ a.hb-topbar__chip:hover {
} }
.hb-usage-widget__badge { .hb-usage-widget__badge {
font-size: 0.8rem; font-size: var(--hb-text-xs);
font-weight: 750; font-weight: 750;
letter-spacing: 0.01em; letter-spacing: 0.01em;
line-height: 1; line-height: 1;
@ -402,13 +402,13 @@ a.hb-topbar__chip:hover {
* 比極簡版明顯比整顆光暈小 * 比極簡版明顯比整顆光暈小
*/ */
.hb-usage-widget__trigger.is-plan-starter .hb-usage-widget__badge { .hb-usage-widget__trigger.is-plan-starter .hb-usage-widget__badge {
color: color-mix(in srgb, var(--hb-brand) 88%, #fff 12%); color: var(--hb-brand-deep);
font-weight: 770; font-weight: 770;
animation: hb-plan-text-breathe-starter 2.8s ease-in-out infinite; animation: hb-plan-text-breathe-starter 2.8s ease-in-out infinite;
} }
.hb-usage-widget__trigger.is-plan-pro .hb-usage-widget__badge { .hb-usage-widget__trigger.is-plan-pro .hb-usage-widget__badge {
color: color-mix(in srgb, #e0bc4a 70%, #fff8e0 30%); color: color-mix(in srgb, var(--hb-gold) 70%, var(--hb-gold-bright) 30%);
font-weight: 790; font-weight: 790;
animation: hb-plan-text-breathe-pro 2.8s ease-in-out infinite; animation: hb-plan-text-breathe-pro 2.8s ease-in-out infinite;
} }
@ -433,15 +433,15 @@ a.hb-topbar__chip:hover {
0%, 0%,
100% { 100% {
text-shadow: text-shadow:
0 0 3px color-mix(in srgb, #ffd700 40%, transparent), 0 0 3px color-mix(in srgb, var(--hb-gold-bright) 40%, transparent),
0 0 8px color-mix(in srgb, #e8c76a 28%, transparent), 0 0 8px color-mix(in srgb, var(--hb-gold) 28%, transparent),
0 0 14px color-mix(in srgb, #daa520 14%, transparent); 0 0 14px color-mix(in srgb, var(--hb-gold) 14%, transparent);
} }
50% { 50% {
text-shadow: text-shadow:
0 0 5px color-mix(in srgb, #fff1b0 75%, transparent), 0 0 5px color-mix(in srgb, var(--hb-gold-bright) 75%, transparent),
0 0 12px color-mix(in srgb, #ffd700 55%, transparent), 0 0 12px color-mix(in srgb, var(--hb-gold-bright) 55%, transparent),
0 0 20px color-mix(in srgb, #e8b923 28%, transparent); 0 0 20px color-mix(in srgb, var(--hb-gold) 28%, transparent);
} }
} }
@ -455,13 +455,13 @@ a.hb-topbar__chip:hover {
.hb-usage-widget__trigger.is-plan-pro .hb-usage-widget__badge { .hb-usage-widget__trigger.is-plan-pro .hb-usage-widget__badge {
animation: none; animation: none;
text-shadow: text-shadow:
0 0 4px color-mix(in srgb, #ffd700 55%, transparent), 0 0 4px color-mix(in srgb, var(--hb-gold-bright) 55%, transparent),
0 0 10px color-mix(in srgb, #e8c76a 30%, transparent); 0 0 10px color-mix(in srgb, var(--hb-gold) 30%, transparent);
} }
} }
.hb-usage-widget__sep { .hb-usage-widget__sep {
font-size: 0.75rem; font-size: var(--hb-text-2xs);
font-weight: 600; font-weight: 600;
opacity: 0.4; opacity: 0.4;
line-height: 1; line-height: 1;
@ -469,7 +469,7 @@ a.hb-topbar__chip:hover {
} }
.hb-usage-widget__stat { .hb-usage-widget__stat {
font-size: 0.75rem; font-size: var(--hb-text-2xs);
font-weight: 650; font-weight: 650;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
line-height: 1; line-height: 1;
@ -486,12 +486,12 @@ a.hb-topbar__chip:hover {
} }
.hb-usage-widget__stat--cta { .hb-usage-widget__stat--cta {
color: var(--hb-brand); color: var(--hb-brand-deep);
font-weight: 750; font-weight: 750;
} }
.hb-usage-widget__stat--warn { .hb-usage-widget__stat--warn {
color: color-mix(in srgb, #b87a12 70%, var(--hb-ink)); color: var(--hb-warning-deep);
font-weight: 750; font-weight: 750;
} }
@ -521,7 +521,7 @@ a.hb-topbar__chip:hover {
.hb-usage-widget__eyebrow { .hb-usage-widget__eyebrow {
display: block; display: block;
font-size: 0.7rem; font-size: var(--hb-text-2xs);
font-weight: 650; font-weight: 650;
color: var(--hb-muted); color: var(--hb-muted);
} }
@ -529,20 +529,20 @@ a.hb-topbar__chip:hover {
.hb-usage-widget__head-plan { .hb-usage-widget__head-plan {
display: block; display: block;
margin-top: 0.2rem; margin-top: 0.2rem;
font-size: 1.05rem; font-size: var(--hb-text-lg);
font-weight: 750; font-weight: 750;
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
.hb-usage-widget__inf { .hb-usage-widget__inf {
color: var(--hb-brand); color: var(--hb-brand-deep);
font-weight: 750; font-weight: 750;
} }
.hb-usage-widget__price { .hb-usage-widget__price {
display: block; display: block;
margin-top: 0.25rem; margin-top: 0.25rem;
font-size: 0.85rem; font-size: var(--hb-text-sm);
font-weight: 650; font-weight: 650;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: var(--hb-text); color: var(--hb-text);
@ -562,7 +562,7 @@ a.hb-topbar__chip:hover {
.hb-usage-widget__head-num { .hb-usage-widget__head-num {
display: block; display: block;
margin-top: 0.2rem; margin-top: 0.2rem;
font-size: 1.05rem; font-size: var(--hb-text-lg);
font-weight: 750; font-weight: 750;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
letter-spacing: -0.02em; letter-spacing: -0.02em;
@ -571,13 +571,13 @@ a.hb-topbar__chip:hover {
.hb-usage-widget__cap { .hb-usage-widget__cap {
font-weight: 600; font-weight: 600;
color: var(--hb-muted); color: var(--hb-muted);
font-size: 0.9rem; font-size: var(--hb-text-sm);
} }
.hb-usage-widget__left { .hb-usage-widget__left {
display: block; display: block;
margin-top: 0.2rem; margin-top: 0.2rem;
font-size: 0.75rem; font-size: var(--hb-text-2xs);
color: var(--hb-muted); color: var(--hb-muted);
} }
@ -597,11 +597,11 @@ a.hb-topbar__chip:hover {
} }
.hb-usage-widget__track-fill.is-warn { .hb-usage-widget__track-fill.is-warn {
background: color-mix(in srgb, #e8a04a 82%, var(--hb-brand)); background: var(--hb-warning);
} }
.hb-usage-widget__track-fill.is-over { .hb-usage-widget__track-fill.is-over {
background: color-mix(in srgb, #d4524a 78%, var(--hb-brand)); background: var(--hb-danger);
} }
.hb-usage-widget__bullets { .hb-usage-widget__bullets {
@ -610,14 +610,14 @@ a.hb-topbar__chip:hover {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.3rem; gap: 0.3rem;
font-size: 0.8rem; font-size: var(--hb-text-xs);
line-height: 1.4; line-height: 1.4;
color: var(--hb-text); color: var(--hb-text);
} }
.hb-usage-widget__caps { .hb-usage-widget__caps {
margin: 0; margin: 0;
font-size: 0.72rem; font-size: var(--hb-text-2xs);
font-weight: 650; font-weight: 650;
line-height: 1.4; line-height: 1.4;
color: var(--hb-text); color: var(--hb-text);
@ -632,9 +632,9 @@ a.hb-topbar__chip:hover {
margin: 0; margin: 0;
padding: 0.45rem 0.55rem; padding: 0.45rem 0.55rem;
border-radius: var(--hb-radius); border-radius: var(--hb-radius);
border: 1px solid color-mix(in srgb, #e8a04a 28%, var(--hb-line)); border: 1px solid color-mix(in srgb, var(--hb-warning) 28%, var(--hb-line));
background: color-mix(in srgb, #e8a04a 10%, var(--hb-surface-muted)); background: color-mix(in srgb, var(--hb-warning) 10%, var(--hb-surface-muted));
font-size: 0.75rem; font-size: var(--hb-text-2xs);
font-weight: 650; font-weight: 650;
line-height: 1.4; line-height: 1.4;
color: var(--hb-text); color: var(--hb-text);
@ -654,7 +654,7 @@ a.hb-topbar__chip:hover {
justify-content: center; justify-content: center;
min-height: 2.15rem; min-height: 2.15rem;
padding: 0.35rem 0.5rem; padding: 0.35rem 0.5rem;
font-size: 0.8rem; font-size: var(--hb-text-xs);
text-decoration: none; text-decoration: none;
text-align: center; text-align: center;
} }
@ -666,7 +666,7 @@ a.hb-topbar__chip:hover {
} }
.hb-usage-widget__stat { .hb-usage-widget__stat {
font-size: 0.72rem; font-size: var(--hb-text-2xs);
} }
} }
@ -701,7 +701,7 @@ a.hb-topbar__chip:hover {
.hb-sidebar { .hb-sidebar {
display: none; display: none;
border-right: 1px solid color-mix(in srgb, var(--hb-line-strong, var(--hb-line)) 70%, transparent); border-right: 1px solid color-mix(in srgb, var(--hb-line-strong, var(--hb-line)) 70%, transparent);
background: color-mix(in srgb, var(--hb-surface-solid, #fff) 84%, transparent); background: color-mix(in srgb, var(--hb-surface-solid) 84%, transparent);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
padding: var(--hb-space-4) var(--hb-space-3); padding: var(--hb-space-4) var(--hb-space-3);
@ -709,7 +709,7 @@ a.hb-topbar__chip:hover {
.hb-sidebar__label { .hb-sidebar__label {
margin: 0 0 var(--hb-space-3) var(--hb-space-2); margin: 0 0 var(--hb-space-3) var(--hb-space-2);
font-size: 0.7rem; font-size: var(--hb-text-2xs);
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
@ -729,7 +729,7 @@ a.hb-topbar__chip:hover {
.hb-nav__group-label { .hb-nav__group-label {
margin: 0 0 var(--hb-space-1) var(--hb-space-2); margin: 0 0 var(--hb-space-1) var(--hb-space-2);
font-size: 0.68rem; font-size: var(--hb-text-2xs);
font-weight: 700; font-weight: 700;
letter-spacing: 0.06em; letter-spacing: 0.06em;
color: var(--hb-subtle); color: var(--hb-subtle);
@ -768,20 +768,24 @@ a.hb-topbar__chip:hover {
opacity: 0.88; opacity: 0.88;
} }
.hb-nav__item:hover { .hb-nav__item:hover,
background: var(--hb-brand-soft);
color: var(--hb-brand-deep);
}
.hb-nav__item:focus-visible { .hb-nav__item:focus-visible {
background: var(--hb-brand-soft); background: var(--hb-brand-soft);
color: var(--hb-brand-deep); color: var(--hb-brand-deep);
} }
.hb-nav__item--active { /*
background: var(--hb-brand-soft); * 選中 = 實心主色塊 + 近黑字10.3:1hover 只是淡底
color: var(--hb-brand-deep); * 兩者一眼分得出來也不必再靠一條被圓角切掉的 inset 3px 直線來區別
box-shadow: inset 3px 0 0 var(--hb-brand); * 原本 hover 與選中的底色字色完全相同只差那條線
*/
.hb-nav__item--active,
.hb-nav__item--active:hover,
.hb-nav__item--active:focus-visible {
background: var(--hb-brand);
color: var(--hb-brand-on);
font-weight: 700;
box-shadow: 0 1px 2px color-mix(in srgb, var(--hb-ink) 10%, transparent);
} }
.hb-nav__item--active .hb-nav__ico { .hb-nav__item--active .hb-nav__ico {
@ -868,7 +872,7 @@ a.hb-topbar__chip:hover {
min-height: calc(var(--hb-dock-height) + env(safe-area-inset-bottom, 0px)); min-height: calc(var(--hb-dock-height) + env(safe-area-inset-bottom, 0px));
padding: 0.4rem 0.4rem calc(0.4rem + env(safe-area-inset-bottom, 0px)); padding: 0.4rem 0.4rem calc(0.4rem + env(safe-area-inset-bottom, 0px));
border-top: 1px solid color-mix(in srgb, var(--hb-line-strong, var(--hb-line)) 50%, transparent); border-top: 1px solid color-mix(in srgb, var(--hb-line-strong, var(--hb-line)) 50%, transparent);
background: color-mix(in srgb, var(--hb-surface-solid, #fff) 84%, transparent); background: color-mix(in srgb, var(--hb-surface-solid) 84%, transparent);
backdrop-filter: saturate(160%) blur(22px); backdrop-filter: saturate(160%) blur(22px);
-webkit-backdrop-filter: saturate(160%) blur(22px); -webkit-backdrop-filter: saturate(160%) blur(22px);
} }
@ -887,7 +891,7 @@ a.hb-topbar__chip:hover {
color: var(--hb-muted); color: var(--hb-muted);
text-decoration: none; text-decoration: none;
font: inherit; font: inherit;
font-size: 0.68rem; font-size: var(--hb-text-2xs);
font-weight: 650; font-weight: 650;
padding: 0.3rem 0.15rem; padding: 0.3rem 0.15rem;
text-align: center; text-align: center;
@ -906,10 +910,12 @@ a.hb-topbar__chip:hover {
background: var(--hb-brand-soft); background: var(--hb-brand-soft);
} }
.hb-dock__item--active { /* 與側欄選單同一套:選中是實心主色塊。 */
color: var(--hb-brand-deep); .hb-dock__item--active,
background: var(--hb-brand-soft); .hb-dock__item--active:focus-visible {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--hb-brand) 22%, transparent); color: var(--hb-brand-on);
background: var(--hb-brand);
font-weight: 700;
} }
.hb-dock__icon { .hb-dock__icon {
@ -961,7 +967,7 @@ a.hb-topbar__chip:hover {
border: 0; border: 0;
padding: 0; padding: 0;
margin: 0; margin: 0;
background: rgb(21 40 37 / 0.35); background: color-mix(in srgb, var(--hb-ink) 35%, transparent);
cursor: pointer; cursor: pointer;
} }
@ -1000,7 +1006,7 @@ a.hb-topbar__chip:hover {
.hb-dock-more__title { .hb-dock-more__title {
margin: 0 0 0.65rem; margin: 0 0 0.65rem;
font-size: 0.95rem; font-size: var(--hb-text-base);
font-weight: 700; font-weight: 700;
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: var(--hb-ink); color: var(--hb-ink);
@ -1013,7 +1019,7 @@ a.hb-topbar__chip:hover {
.hb-dock-more__group-label { .hb-dock-more__group-label {
margin: 0 0 0.3rem 0.25rem; margin: 0 0 0.3rem 0.25rem;
font-size: 0.68rem; font-size: var(--hb-text-2xs);
font-weight: 700; font-weight: 700;
letter-spacing: 0.06em; letter-spacing: 0.06em;
color: var(--hb-subtle); color: var(--hb-subtle);
@ -1041,7 +1047,7 @@ a.hb-topbar__chip:hover {
color: var(--hb-ink); color: var(--hb-ink);
font: inherit; font: inherit;
font-weight: 600; font-weight: 600;
font-size: 0.95rem; font-size: var(--hb-text-base);
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
@ -1053,10 +1059,12 @@ a.hb-topbar__chip:hover {
background: var(--hb-brand-soft); background: var(--hb-brand-soft);
} }
.hb-dock-more__row.is-active { .hb-dock-more__row.is-active,
border-color: color-mix(in srgb, var(--hb-brand) 40%, var(--hb-line)); .hb-dock-more__row.is-active:hover {
background: var(--hb-brand-soft); border-color: transparent;
color: var(--hb-brand-deep); background: var(--hb-brand);
color: var(--hb-brand-on);
font-weight: 700;
} }
.hb-dock-more__row .hb-dock__icon { .hb-dock-more__row .hb-dock__icon {
@ -1071,7 +1079,7 @@ a.hb-topbar__chip:hover {
} }
.hb-dock-more__check { .hb-dock-more__check {
font-size: 0.9rem; font-size: var(--hb-text-sm);
font-weight: 700; font-weight: 700;
color: var(--hb-brand-deep); color: var(--hb-brand-deep);
flex-shrink: 0; flex-shrink: 0;
@ -1091,7 +1099,7 @@ a.hb-topbar__chip:hover {
} }
.hb-topbar__name { .hb-topbar__name {
font-size: 0.95rem; font-size: var(--hb-text-base);
} }
.hb-main { .hb-main {

View File

@ -1,64 +1,79 @@
/** /**
* 巡樓 · Lapras Harbor Desk UI * 巡樓 · Lapras Harbor Desk UI
* teal mint primary / soft cyan secondary / coral accent * pale blue 主色 / cream 次色 / burnt orange 點綴Pokémon Palette
* Pokémon Palette 衍生 * 字體Inter + Taipei Sans TC
* 字體Inter + Taipei Sans TC保留 *
* 唯一真相顏色字級間距只在這個檔案定義其他 css tsx 一律吃 var()
* 不寫死色碼與 font-size 否則深淺模式會有一半的畫面不跟著切
* `npm run check:tokens` 把關
*
* 命名規則
* X 當填色底色邊框
* X-on 疊在 X 底色上的文字
* X-soft hover淡底
* X-deep 把該色當文字放在頁面或卡片底色上的深一階只有本身對比不足的
* 顏色才有brand / danger / warningsuccess 自己就過 AA
* 主色是淺藍當文字只有 1.9:1所以主色當文字一律走 --hb-brand-deep
* --hb-subtle 只有 3.8:1僅供裝飾與 18px 以上大字勿用於內文
*
* 兩條防髒規則
* 1. 不要把兩個有彩度的顏色互相 color-mix(H187)配橘(H32)是補色
* 混出來一定是濁褐要調淡就跟 surfacelinetransparent 要一深一淺的
* 同一顏色就用 X X-deep
* 2. 中性色bg / surface / line / muted / subtle全部維持 H180-191 的冷青
* ink 與主色同溫palette 原本的 muted H=44 的暖褐疊在偏冷的白底上
* 會整片發黃發濁暖色只出現在刻意的強調上warningaccent方案金標
*/ */
:root { :root {
color-scheme: light; color-scheme: light;
/* Brand source tokens */
--pp-primary: #20b49c;
--pp-secondary: #69d4c5;
--pp-ring: #22c3a8;
--pp-fg: #152825;
--pp-accent: #ff7b73;
--hb-bg: #fcfdfd; --hb-bg: #fcfdfd;
--hb-surface: rgba(255, 255, 255, 0.9); --hb-surface: rgba(255, 255, 255, 0.9);
--hb-surface-solid: #ffffff; --hb-surface-solid: #ffffff;
--hb-surface-muted: #f2f7f7; --hb-surface-muted: #f2f6f6;
--hb-ink: #152825; --hb-ink: #152628;
--hb-text: #152825; --hb-text: #152628;
--hb-fg: #152825; --hb-fg: #152628;
--hb-ink-secondary: #2a403c; --hb-ink-secondary: #33474a;
--hb-muted: #62847f; --hb-muted: #566869; /* 5.8:1 於 bg、5.4:1 於 surface-muted */
--hb-subtle: #7a9a95; --hb-subtle: #748687; /* 3.8:1 — 裝飾/大字限定 */
--hb-line: color-mix(in srgb, #e0ebe9 88%, transparent); --hb-line: color-mix(in srgb, #e0e9eb 88%, transparent);
--hb-line-strong: #e0ebe9; --hb-line-strong: #e0e9eb;
--hb-scrim: rgb(21 38 40 / 0.55);
--hb-brand: #20b49c; --hb-brand: #8bc5cd;
--hb-brand-hover: #1a9a86; --hb-brand-hover: #76b8c1;
--hb-brand-soft: color-mix(in srgb, #20b49c 13%, #ffffff); --hb-brand-soft: color-mix(in srgb, #8bc5cd 20%, #ffffff);
--hb-brand-on: #ffffff; --hb-brand-on: #0a0a0a; /* 10.3:1 於 brand */
--hb-brand-deep: #178f7d; --hb-brand-deep: #2d5f66; /* 7.0:1 於 bg主色當文字時用 */
--hb-magic: #69d4c5; --hb-magic: #fee5a1; /* 次色奶油黃:只當淡底與裝飾 */
--hb-magic-glow: rgb(32 180 156 / 0.28); --hb-magic-glow: rgb(139 197 205 / 0.2);
--hb-focus: color-mix(in srgb, #22c3a8 78%, #69d4c5); --hb-focus: #2d5f66;
--hb-success: #117d70; --hb-success: #2f7d3f; /* 純綠 H=135與 brand-deep 的青藍明顯分開 */
--hb-success-soft: #e7f7f4; --hb-success-soft: #eaf4ec;
--hb-success-on: #ffffff; /* 5.2:1 */
--hb-danger: #ef4444; --hb-danger: #ef4444;
--hb-danger-soft: #fcecef; --hb-danger-soft: #fdecec;
--hb-warning: #9a7117; --hb-danger-on: #0a0a0a; /* 5.3:1白字只有 3.8:1 */
--hb-warning-soft: #faf3df; --hb-danger-deep: #c0392b; /* 5.3:1 於 bg */
--hb-accent-warm: #ff7b73; --hb-warning: #b96b1c;
--hb-warning-soft: #fbf1e3;
--hb-warning-on: #0a0a0a; /* 4.9:1白字只有 4.1:1 */
--hb-warning-deep: #a35d16; /* 5.1:1 於白卡 */
--hb-accent-warm: #b96b1c; /* 與 warning 同色palette 只有這一個暖點綴 */
--hb-fun-mint: #20b49c; /* 畫布極光:主色的暗一階,只用來鋪背景 */
--hb-fun-mint-soft: #e7f8f5; --hb-brand-dim: #6f9ba3;
--hb-fun-sky: #69d4c5; --hb-aurora-1: color-mix(in srgb, #8bc5cd 20%, transparent);
--hb-fun-sky-soft: #e8faf7; --hb-aurora-2: color-mix(in srgb, #8bc5cd 11%, transparent);
--hb-fun-coral: #ff7b73; --hb-aurora-3: color-mix(in srgb, #6f9ba3 12%, transparent);
--hb-fun-coral-soft: #fff0ed;
--hb-fun-peach: #e8a878; /* 付費方案的金色字光(裝飾用,不當文字色) */
--hb-fun-peach-soft: #fef6e8; --hb-gold: #d9a531;
--hb-fun-lavender: #3aa89a; --hb-gold-bright: #fee5a1;
--hb-fun-lavender-soft: #e8f6f3;
--hb-fun-gold: #d19b34;
--hb-fun-gold-soft: #fbf4e5;
--hb-radius-sm: 0.55rem;
--hb-radius: 0.8rem; --hb-radius: 0.8rem;
--hb-radius-lg: 1rem; --hb-radius-lg: 1rem;
--hb-radius-xl: 1.15rem; --hb-radius-xl: 1.15rem;
@ -66,22 +81,26 @@
--hb-shadow-card: --hb-shadow-card:
0 0 0 1px color-mix(in srgb, var(--hb-line-strong) 70%, transparent), 0 0 0 1px color-mix(in srgb, var(--hb-line-strong) 70%, transparent),
0 1px 2px rgb(21 40 37 / 0.04), 0 1px 2px rgb(21 38 40 / 0.04),
0 9px 28px rgb(32 180 156 / 0.1); 0 9px 28px rgb(21 38 40 / 0.06);
--hb-shadow-soft: 0 4px 18px var(--hb-magic-glow); --hb-shadow-soft: 0 4px 18px var(--hb-magic-glow);
--hb-shadow-float: 0 16px 48px rgb(21 40 37 / 0.12); --hb-shadow-float: 0 16px 48px rgb(21 38 40 / 0.12);
--hb-shadow-glow: 0 0 24px rgb(34 195 168 / 0.3); --hb-shadow-glow: 0 0 24px rgb(139 197 205 / 0.4);
--hb-font-sans: "Inter", "Taipei Sans TC", system-ui, -apple-system, "Segoe UI", --hb-font-sans: "Inter", "Taipei Sans TC", system-ui, -apple-system, "Segoe UI",
"Noto Sans TC", sans-serif; "Noto Sans TC", sans-serif;
--hb-font-en: "Inter", system-ui, -apple-system, sans-serif; --hb-font-en: "Inter", system-ui, -apple-system, sans-serif;
--hb-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
/* 緊湊資訊層;輸入框維持 16px 避免 iOS 縮放。 */ /* 字階只有這 7 之間沒有中間值 0.8 / 0.82 / 0.85 併存看不出差別
只會讓整頁失去節奏輸入框維持 16px 避免 iOS 聚焦時縮放 */
--hb-text-2xs: 0.75rem; /* 12px · badge極小 meta */
--hb-text-xs: 0.8125rem; /* 13px · 次要 meta */ --hb-text-xs: 0.8125rem; /* 13px · 次要 meta */
--hb-text-sm: 0.875rem; /* 14px · 按鈕/輔助 */ --hb-text-sm: 0.875rem; /* 14px · 按鈕/輔助 */
--hb-text-base: 1rem; /* 16px · 內文 */ --hb-text-base: 1rem; /* 16px · 內文 */
--hb-text-lg: 1.125rem; /* 18px · 卡片標題 */ --hb-text-lg: 1.125rem; /* 18px · 卡片標題 */
--hb-text-xl: 1.375rem; /* 22px · 頁標題 */ --hb-text-xl: 1.375rem; /* 22px · 頁標題 */
--hb-text-2xl: 1.75rem; /* 28px · 登入/定價大標 */
--hb-text-input: 1rem; /* 16px · 表單 */ --hb-text-input: 1rem; /* 16px · 表單 */
--hb-space-1: 0.25rem; --hb-space-1: 0.25rem;
@ -103,69 +122,63 @@
--hb-sidebar-width: 15rem; --hb-sidebar-width: 15rem;
--hb-topbar-height: 3.6rem; --hb-topbar-height: 3.6rem;
--hb-dock-height: 3.85rem; --hb-dock-height: 3.85rem;
--hb-aurora-1: color-mix(in srgb, #20b49c 16%, transparent);
--hb-aurora-2: color-mix(in srgb, #69d4c5 12%, transparent);
--hb-aurora-3: color-mix(in srgb, #ff7b73 8%, transparent);
} }
/** /**
* Dark深海綠畫布mint 主色與 coral 點綴 * Dark冷灰藍畫布暖褐色作為 muted palette --muted 就是暖色
* 深底下亮色本身對比就夠所以 -deep 反而是更亮的一階
*/ */
[data-theme="dark"] { [data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
--hb-bg: #141a19; --hb-bg: #141a1a;
--hb-surface: rgba(27, 39, 37, 0.9); --hb-surface: rgba(27, 38, 39, 0.9);
--hb-surface-solid: #1b2725; --hb-surface-solid: #1b2627;
--hb-surface-muted: #253735; --hb-surface-muted: #243132;
--hb-ink: #f4f6f5; --hb-ink: #f4f6f6;
--hb-text: #f4f6f5; --hb-text: #f4f6f6;
--hb-fg: #f4f6f5; --hb-fg: #f4f6f6;
--hb-ink-secondary: #d0dcd9; --hb-ink-secondary: #d3dcdc;
--hb-muted: #98b3b0; --hb-muted: #a9b7b7; /* 8.5:1 */
--hb-subtle: #7a9592; --hb-subtle: #8b9a9a; /* 6.0:1 */
--hb-line: color-mix(in srgb, #2d4340 92%, transparent); --hb-line: color-mix(in srgb, #2d4143 92%, transparent);
--hb-line-strong: #2d4340; --hb-line-strong: #2d4143;
--hb-scrim: rgb(0 0 0 / 0.66);
--hb-brand: #3cddc2; --hb-brand: #89c5cd;
--hb-brand-hover: #55e5cd; --hb-brand-hover: #a3d4da;
--hb-brand-soft: color-mix(in srgb, #3cddc2 14%, #1b2725); --hb-brand-soft: color-mix(in srgb, #89c5cd 14%, #1b2627);
--hb-brand-on: #0a0a0a; --hb-brand-on: #0a0a0a; /* 10.3:1 */
--hb-brand-deep: #69e8d5; --hb-brand-deep: #b7e3e8;
--hb-magic: #69d3c5; --hb-magic: #fee59f;
--hb-magic-glow: rgb(60 221 194 / 0.32); --hb-magic-glow: rgb(137 197 205 / 0.18);
--hb-focus: color-mix(in srgb, #3cddc2 78%, #69d3c5); --hb-focus: #89c5cd;
--hb-success: #74d189; /* 9.4:1純綠 */
--hb-success-soft: #16251a;
--hb-success-on: #0a0a0a;
--hb-danger: #dc2626; --hb-danger: #dc2626;
--hb-danger-soft: #341919; --hb-danger-soft: #341919;
--hb-success: #3cddc2; --hb-danger-on: #ffffff; /* 4.8:1 —— 深底的紅維持白字 */
--hb-success-soft: #142a2b; --hb-danger-deep: #f87171; /* 5.6:1 於卡片;#ef4444 只有 4.1:1 */
--hb-warning: #e0c060; --hb-warning: #e18c37; /* 6.7:1 */
--hb-warning-soft: #2a2414; --hb-warning-soft: #2f2416;
--hb-accent-warm: #ff7c75; --hb-warning-on: #0a0a0a;
--hb-warning-deep: #e18c37; /* 深底不必再加深 */
--hb-accent-warm: #e18c37;
--hb-fun-mint: #3cddc2; --hb-brand-dim: #7fb0b8;
--hb-fun-mint-soft: #172b2c; --hb-aurora-1: color-mix(in srgb, #89c5cd 14%, transparent);
--hb-fun-sky: #69d3c5; --hb-aurora-2: color-mix(in srgb, #89c5cd 7%, transparent);
--hb-fun-sky-soft: #1b2b31; --hb-aurora-3: color-mix(in srgb, #7fb0b8 8%, transparent);
--hb-fun-coral: #ff7c75;
--hb-fun-coral-soft: #342020; --hb-gold: #e1b84f;
--hb-fun-peach: #e8c890; --hb-gold-bright: #fee59f;
--hb-fun-peach-soft: #2a2418;
--hb-fun-lavender: #69d3c5;
--hb-fun-lavender-soft: #1e2f2d;
--hb-fun-gold: #e0c060;
--hb-fun-gold-soft: #2a2418;
--hb-shadow-card: --hb-shadow-card:
0 0 0 1px color-mix(in srgb, #2d4340 90%, transparent), 0 0 0 1px color-mix(in srgb, #2d4143 90%, transparent),
0 10px 36px rgb(0 0 0 / 0.45); 0 10px 36px rgb(0 0 0 / 0.45);
--hb-shadow-soft: 0 4px 22px var(--hb-magic-glow); --hb-shadow-soft: 0 4px 22px var(--hb-magic-glow);
--hb-shadow-float: 0 20px 56px rgb(0 0 0 / 0.55); --hb-shadow-float: 0 20px 56px rgb(0 0 0 / 0.55);
--hb-shadow-glow: 0 0 32px rgb(60 221 194 / 0.28); --hb-shadow-glow: 0 0 32px rgb(137 197 205 / 0.28);
--hb-aurora-1: color-mix(in srgb, #3cddc2 12%, transparent);
--hb-aurora-2: color-mix(in srgb, #69d3c5 8%, transparent);
--hb-aurora-3: color-mix(in srgb, #ff7c75 5%, transparent);
} }

View File

@ -33,54 +33,38 @@
cursor: not-allowed; cursor: not-allowed;
} }
/*
* 主按鈕選中的 tab側欄與 dock 的選中項共用同一個配方實心 --hb-brand +
* --hb-brand-on主色是淺藍漸層到 --hb-brand-deep 會讓近黑字在深端失去對比
* 所以這裡不用漸層深淺兩個主題也不需要各寫一份
*/
.hb-btn--primary { .hb-btn--primary {
background: linear-gradient( background: var(--hb-brand);
165deg,
var(--hb-brand-hover) 0%,
var(--hb-brand) 55%,
var(--hb-brand-deep) 100%
);
color: var(--hb-brand-on); color: var(--hb-brand-on);
box-shadow: box-shadow: var(--hb-shadow-soft);
0 1px 0 rgb(255 255 255 / 0.4) inset,
0 0 0 1px color-mix(in srgb, var(--hb-magic) 24%, transparent),
var(--hb-shadow-soft);
border-color: transparent; border-color: transparent;
} }
/* 深色主按鈕以秘儀紫為底、薄荷法光收邊。 */
[data-theme="dark"] .hb-btn--primary {
background: linear-gradient(
165deg,
color-mix(in srgb, var(--hb-magic) 55%, var(--hb-brand)) 0%,
var(--hb-brand) 100%
);
color: var(--hb-brand-on);
box-shadow:
0 1px 0 rgb(255 255 255 / 0.18) inset,
0 0 0 1px color-mix(in srgb, var(--hb-brand) 25%, transparent),
var(--hb-shadow-soft);
}
.hb-btn--primary:hover:not(:disabled) { .hb-btn--primary:hover:not(:disabled) {
transform: translateY(-0.5px); transform: translateY(-0.5px);
background: var(--hb-brand-hover);
box-shadow: var(--hb-shadow-glow), var(--hb-shadow-soft); box-shadow: var(--hb-shadow-glow), var(--hb-shadow-soft);
} }
.hb-btn--secondary { .hb-btn--secondary {
background: color-mix(in srgb, var(--hb-brand) 10%, var(--hb-surface-solid, #fff)); background: color-mix(in srgb, var(--hb-brand) 10%, var(--hb-surface-solid));
color: var(--hb-ink); color: var(--hb-ink);
border-color: transparent; border-color: transparent;
box-shadow: 0 1px 2px rgb(21 40 37 / 0.04); box-shadow: 0 1px 2px color-mix(in srgb, var(--hb-ink) 6%, transparent);
} }
.hb-btn--secondary:hover:not(:disabled) { .hb-btn--secondary:hover:not(:disabled) {
background: color-mix(in srgb, var(--hb-magic) 22%, var(--hb-surface-solid, #fff)); background: color-mix(in srgb, var(--hb-magic) 22%, var(--hb-surface-solid));
color: var(--hb-ink); color: var(--hb-ink);
} }
.hb-btn--ghost { .hb-btn--ghost {
background: color-mix(in srgb, var(--hb-surface-solid, #fff) 70%, transparent); background: color-mix(in srgb, var(--hb-surface-solid) 70%, transparent);
color: var(--hb-ink-secondary); color: var(--hb-ink-secondary);
border-color: var(--hb-line-strong, var(--hb-line)); border-color: var(--hb-line-strong, var(--hb-line));
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
@ -94,7 +78,7 @@
.hb-btn--danger { .hb-btn--danger {
background: var(--hb-danger); background: var(--hb-danger);
color: #fff; color: var(--hb-danger-on);
} }
.hb-btn--danger:hover:not(:disabled) { .hb-btn--danger:hover:not(:disabled) {
@ -125,7 +109,7 @@
.hb-field__error { .hb-field__error {
font-size: var(--hb-text-sm); font-size: var(--hb-text-sm);
color: var(--hb-danger); color: var(--hb-danger-deep);
line-height: 1.5; line-height: 1.5;
margin: 0; margin: 0;
} }
@ -268,13 +252,13 @@
.hb-badge--danger { .hb-badge--danger {
background: var(--hb-danger-soft); background: var(--hb-danger-soft);
color: var(--hb-danger); color: var(--hb-danger-deep);
border-color: color-mix(in srgb, var(--hb-danger) 16%, transparent); border-color: color-mix(in srgb, var(--hb-danger) 16%, transparent);
} }
.hb-badge--warning { .hb-badge--warning {
background: var(--hb-warning-soft); background: var(--hb-warning-soft);
color: color-mix(in srgb, var(--hb-warning) 88%, var(--hb-ink)); color: var(--hb-warning-deep);
border-color: color-mix(in srgb, var(--hb-warning) 18%, transparent); border-color: color-mix(in srgb, var(--hb-warning) 18%, transparent);
} }
@ -314,7 +298,7 @@
background: color-mix(in srgb, var(--hb-surface) 70%, var(--hb-brand-soft)); background: color-mix(in srgb, var(--hb-surface) 70%, var(--hb-brand-soft));
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
box-shadow: 0 1px 2px rgb(65 164 230 / 0.06); box-shadow: 0 1px 2px color-mix(in srgb, var(--hb-brand) 8%, transparent);
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scrollbar-width: none; scrollbar-width: none;
@ -379,26 +363,18 @@
background: color-mix(in srgb, var(--hb-surface) 60%, transparent); background: color-mix(in srgb, var(--hb-surface) 60%, transparent);
} }
/* segment 樣式的 tab 是「白片浮起」,選中只換字色。 */
.hb-tab.is-active { .hb-tab.is-active {
background: var(--hb-surface); background: var(--hb-surface);
color: var(--hb-brand-deep); color: var(--hb-brand-deep);
box-shadow: 0 1px 4px rgb(8 127 116 / 0.18); box-shadow: 0 1px 4px color-mix(in srgb, var(--hb-brand-deep) 18%, transparent);
} }
/* 選中 tab使用深海 teal維持白字對比。 */ /* 一般 tab 走與側欄選單相同的實心主色塊。 */
.hb-tabs:not(.hb-tabs--segment) .hb-tab.is-active { .hb-tabs:not(.hb-tabs--segment) .hb-tab.is-active {
background: linear-gradient( background: var(--hb-brand);
180deg,
var(--hb-brand),
var(--hb-brand-deep)
);
color: var(--hb-brand-on); color: var(--hb-brand-on);
box-shadow: 0 2px 8px rgb(8 127 116 / 0.28); box-shadow: 0 2px 8px color-mix(in srgb, var(--hb-brand-deep) 24%, transparent);
}
[data-theme="dark"] .hb-tabs:not(.hb-tabs--segment) .hb-tab.is-active {
color: var(--hb-brand-on);
box-shadow: 0 0 16px rgb(60 221 194 / 0.22), 0 2px 8px rgb(0 0 0 / 0.3);
} }
[data-theme="dark"] .hb-badge--brand { [data-theme="dark"] .hb-badge--brand {
@ -457,7 +433,7 @@
.hb-banner-error { .hb-banner-error {
border-color: color-mix(in srgb, var(--hb-danger) 40%, var(--hb-line)); border-color: color-mix(in srgb, var(--hb-danger) 40%, var(--hb-line));
background: color-mix(in srgb, var(--hb-danger) 10%, var(--hb-surface)); background: color-mix(in srgb, var(--hb-danger) 10%, var(--hb-surface));
color: var(--hb-danger); color: var(--hb-danger-deep);
} }
.hb-check-row { .hb-check-row {
@ -587,7 +563,7 @@
border: 0; border: 0;
background: none; background: none;
padding: 0; padding: 0;
color: var(--hb-brand); color: var(--hb-brand-deep);
font: inherit; font: inherit;
font-size: inherit; font-size: inherit;
font-weight: 600; font-weight: 600;
@ -600,7 +576,7 @@
border: 0; border: 0;
background: none; background: none;
padding: 0; padding: 0;
color: var(--hb-brand); color: var(--hb-brand-deep);
font: inherit; font: inherit;
font-size: inherit; font-size: inherit;
font-weight: 600; font-weight: 600;
@ -627,7 +603,7 @@
border-radius: var(--hb-radius); border-radius: var(--hb-radius);
border: 1px solid var(--hb-line); border: 1px solid var(--hb-line);
background: var(--hb-surface); background: var(--hb-surface);
font-size: 0.9rem; font-size: var(--hb-text-sm);
} }
.hb-reply-child.is-mine { .hb-reply-child.is-mine {
@ -688,7 +664,7 @@
} }
.hb-metrics-chip { .hb-metrics-chip {
font-size: 0.8rem; font-size: var(--hb-text-xs);
color: var(--hb-ink-secondary); color: var(--hb-ink-secondary);
white-space: nowrap; white-space: nowrap;
} }
@ -726,11 +702,11 @@
.hb-metrics-cell__val { .hb-metrics-cell__val {
font-weight: 700; font-weight: 700;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-size: 1rem; font-size: var(--hb-text-base);
} }
.hb-metrics-cell__label { .hb-metrics-cell__label {
font-size: 0.7rem; font-size: var(--hb-text-2xs);
color: var(--hb-muted); color: var(--hb-muted);
} }
@ -770,7 +746,7 @@
border: 1px solid var(--hb-line); border: 1px solid var(--hb-line);
background: var(--hb-surface); background: var(--hb-surface);
color: var(--hb-text); color: var(--hb-text);
font-size: 0.85rem; font-size: var(--hb-text-sm);
line-height: 1; line-height: 1;
padding: 0; padding: 0;
cursor: pointer; cursor: pointer;
@ -784,10 +760,10 @@
left: 50%; left: 50%;
top: 50%; top: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
font-size: 0.85rem; font-size: var(--hb-text-sm);
font-weight: 600; font-weight: 600;
color: var(--hb-fg, #111); color: var(--hb-fg);
background: rgba(255, 255, 255, 0.85); background: color-mix(in srgb, var(--hb-surface-solid) 85%, transparent);
border-radius: 999px; border-radius: 999px;
padding: 0.1rem 0.4rem; padding: 0.1rem 0.4rem;
pointer-events: none; pointer-events: none;
@ -799,11 +775,11 @@
z-index: 1; z-index: 1;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
font-size: 0.65rem; font-size: var(--hb-text-2xs);
padding: 0.1rem 0.3rem; padding: 0.1rem 0.3rem;
cursor: pointer; cursor: pointer;
background: var(--hb-danger, #c0392b); background: var(--hb-danger);
color: #fff; color: var(--hb-danger-on);
} }
.hb-thumb-item__retry:disabled { .hb-thumb-item__retry:disabled {
opacity: 0.5; opacity: 0.5;
@ -934,7 +910,7 @@
.hb-compact-row__meta { .hb-compact-row__meta {
margin: 0.3rem 0 0; margin: 0.3rem 0 0;
font-size: 0.78rem; font-size: var(--hb-text-xs);
line-height: 1.45; line-height: 1.45;
word-break: break-word; word-break: break-word;
} }
@ -943,7 +919,7 @@
min-height: 2.4rem; min-height: 2.4rem;
padding: 0.35rem 0.65rem; padding: 0.35rem 0.65rem;
white-space: nowrap; white-space: nowrap;
font-size: 0.8rem; font-size: var(--hb-text-xs);
width: 100%; width: 100%;
} }
@ -1012,7 +988,7 @@
display: none; display: none;
border: 0; border: 0;
background: transparent; background: transparent;
color: var(--hb-brand); color: var(--hb-brand-deep);
font: inherit; font: inherit;
font-size: var(--hb-text-sm); font-size: var(--hb-text-sm);
font-weight: 600; font-weight: 600;
@ -1023,7 +999,7 @@
.hb-tabs--sm .hb-tab { .hb-tabs--sm .hb-tab {
padding: 0.35rem 0.75rem; padding: 0.35rem 0.75rem;
font-size: 0.8rem; font-size: var(--hb-text-xs);
} }
.hb-persona-list { .hb-persona-list {
@ -1122,7 +1098,7 @@
display: block; display: block;
font-size: var(--hb-text-sm); font-size: var(--hb-text-sm);
margin-bottom: 0.35rem; margin-bottom: 0.35rem;
color: var(--hb-brand); color: var(--hb-brand-deep);
} }
.hb-dim-card p { .hb-dim-card p {
@ -1135,5 +1111,5 @@
margin: 0.4rem 0 0; margin: 0.4rem 0 0;
padding-left: 1rem; padding-left: 1rem;
color: var(--hb-muted); color: var(--hb-muted);
font-size: 0.8rem; font-size: var(--hb-text-xs);
} }