fix bugs
This commit is contained in:
parent
f46a5cf0a9
commit
475b117c95
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Call Home 會議重點與 Action Items
|
||||||
|
|
||||||
|
**日期:** 2026/07/28
|
||||||
|
|
||||||
|
## 會議重點
|
||||||
|
|
||||||
|
### 1. 整合方向
|
||||||
|
|
||||||
|
Call Home 可拆成三個部分:
|
||||||
|
|
||||||
|
1. **Trigger/State**:事件收集、分類、去重與 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 現有 Recipient/Receiver 設定,不另外開發獨立模組。
|
||||||
|
|
||||||
|
### 3. 目前主要缺口
|
||||||
|
|
||||||
|
- 尚未定義統一 Event Payload 與 Correlation Key。
|
||||||
|
- SCC-FLEX 尚缺完整 Deduplication、Soft/Hard 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。
|
||||||
|
- 初期先支援 Compute/Supermicro Hardware。
|
||||||
|
- CDU、PDU、Liquid Cooling 等產品後續再評估。
|
||||||
|
|
||||||
|
## Action Items
|
||||||
|
|
||||||
|
| Owner | Action Item | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| Daniel | 依 Trigger、Delivery、Service Process 三部分整理整合需求與責任邊界。 | 待整理 |
|
||||||
|
| Daniel | 建立跨團隊討論群組並發布會議記錄。 | 待處理 |
|
||||||
|
| Daniel/PM | 確認 DCM Single Key、Service Key、每台 Host 計費及 Support Advisor 的關係。 | 待確認 |
|
||||||
|
| PM/Service Team | 提供可自動開單的 Event 白名單、必要 Payload/附件及維護 Owner。 | 待確認 |
|
||||||
|
| SSM 團隊 | 提供 Event Mapping、Trigger List、Deduplication、Recovery 與資料收集規則。 | 待指派 |
|
||||||
|
| SCC-FLEX 團隊 | 定義 Event Payload、Correlation Key、Soft/Hard State、Deduplication 與 Idempotency。 | 待排程 |
|
||||||
|
| Daniel/IT/Service Team | 確認 Salesforce API Owner、Ticket Update、Close 與 Ask-to-Close 規則。 | 待確認 |
|
||||||
|
| PM/產品團隊 | 決定 Event 選擇方式及 Ticket List/Ask-to-Close 是否列入產品範圍。 | 待討論 |
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
下一次會議只需要確認四件事:
|
||||||
|
|
||||||
|
1. Service Team Event 白名單。
|
||||||
|
2. SSM 可提供的資料與介面。
|
||||||
|
3. Salesforce Ticket Lifecycle 與責任人。
|
||||||
|
4. License 與產品範圍。
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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), "/")
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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。這些彼此不能混:主色在 H≈187、暖色在 H≈32,是補色關係,
|
||||||
|
* 任何比例混出來都是濁褐。要調淡就跟 surface/line/transparent 混,要同一顏色
|
||||||
|
* 的深淺兩階就用 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}),會變濁。` +
|
||||||
|
`要調淡請跟 surface/line/transparent 混,要深一階請用 -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");
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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)}>
|
||||||
引用
|
引用
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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:1);hover 只是淡底。
|
||||||
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 {
|
||||||
|
|
|
||||||
|
|
@ -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 / warning),success 自己就過 AA。
|
||||||
|
* 主色是淺藍,當文字只有 1.9:1,所以主色當文字一律走 --hb-brand-deep。
|
||||||
|
* --hb-subtle 只有 3.8:1,僅供裝飾與 18px 以上大字,勿用於內文。
|
||||||
|
*
|
||||||
|
* 兩條防髒規則:
|
||||||
|
* 1. 不要把兩個「有彩度」的顏色互相 color-mix。藍(H≈187)配橘(H≈32)是補色,
|
||||||
|
* 混出來一定是濁褐;要調淡就跟 surface/line/transparent 混。要一深一淺的
|
||||||
|
* 同一顏色就用 X 與 X-deep。
|
||||||
|
* 2. 中性色(bg / surface / line / muted / subtle)全部維持 H≈180-191 的冷青,
|
||||||
|
* 跟 ink 與主色同溫。palette 原本的 muted 是 H=44 的暖褐,疊在偏冷的白底上
|
||||||
|
* 會整片發黃發濁。暖色只出現在刻意的強調上:warning、accent、方案金標。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
: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);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue