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 {
|
||||
Id string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Id string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
// TrackPath 保留給同源前端;TrackUrl 是後端算好的絕對網址,貼到 Threads 用這個。
|
||||
TrackPath string `json:"track_path"`
|
||||
TrackUrl string `json:"track_url"`
|
||||
DestinationUrl string `json:"destination_url"`
|
||||
OutcomeId string `json:"outcome_id,optional"`
|
||||
Label string `json:"label,optional"`
|
||||
|
|
|
|||
|
|
@ -27,12 +27,11 @@ func (l *GetBenchmarkLogic) GetBenchmark(req *types.BenchmarkReq) (*types.Benchm
|
|||
if !ok || uid <= 0 {
|
||||
return nil, fmt.Errorf("unauthorized")
|
||||
}
|
||||
niche := req.Niche
|
||||
if niche == "" {
|
||||
niche = "general"
|
||||
}
|
||||
niche := usecase.NormalizeNiche(req.Niche)
|
||||
var yourEng, yourViews float64
|
||||
// contribute + read from own posts if studio available
|
||||
// 只讀自己的貼文算出對照值。這裡刻意不 ContributeBenchmark:查詢是讀路徑,
|
||||
// 若順手寫入,任何人只要輸入某個利基名稱就能把自己的數字灌進那個桶。
|
||||
// 貢獻的唯一入口是 insights summary(那裡的數字來自使用者自己的已同步貼文)。
|
||||
if l.svcCtx.Studio != nil {
|
||||
posts, err := l.svcCtx.Studio.ListOwnPosts(l.ctx, uid, req.AccountId)
|
||||
if err == nil && len(posts) > 0 {
|
||||
|
|
@ -52,15 +51,14 @@ func (l *GetBenchmarkLogic) GetBenchmark(req *types.BenchmarkReq) (*types.Benchm
|
|||
if views > 0 {
|
||||
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)
|
||||
return &types.BenchmarkData{
|
||||
Available: avail, SampleSize: sample, Niche: niche,
|
||||
MedianEngRate: usecase.Round2(medEng), MedianViews: usecase.Round2(medViews),
|
||||
YourEngRate: usecase.Round2(yourEng), YourViews: usecase.Round2(yourViews),
|
||||
MedianEngRate: usecase.RoundRate(medEng), MedianViews: usecase.Round2(medViews),
|
||||
YourEngRate: usecase.RoundRate(yourEng), YourViews: usecase.Round2(yourViews),
|
||||
PercentileHint: hint, Message: hint,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ func (l *GetInsightsSummaryLogic) GetInsightsSummary(req *types.InsightsSummaryR
|
|||
}
|
||||
top = append(top, types.InsightsPostPublic{
|
||||
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 })
|
||||
|
|
@ -98,7 +98,7 @@ func (l *GetInsightsSummaryLogic) GetInsightsSummary(req *types.InsightsSummaryR
|
|||
e = float64(a.likes+a.replies) / float64(a.views)
|
||||
}
|
||||
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 })
|
||||
|
|
@ -116,7 +116,7 @@ func (l *GetInsightsSummaryLogic) GetInsightsSummary(req *types.InsightsSummaryR
|
|||
}
|
||||
return &types.InsightsSummaryData{
|
||||
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,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,9 +30,6 @@ func (l *CreateUtmLinkLogic) CreateUtmLink(req *types.CreateUtmLinkReq) (*types.
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &types.UtmLinkPublic{
|
||||
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,
|
||||
}, nil
|
||||
out := publicView(l.svcCtx.Config, u)
|
||||
return &out, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,11 +32,7 @@ func (l *ListUtmLinksLogic) ListUtmLinks() (*types.UtmLinkListData, error) {
|
|||
}
|
||||
out := make([]types.UtmLinkPublic, 0, len(list))
|
||||
for _, u := range list {
|
||||
out = append(out, types.UtmLinkPublic{
|
||||
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,
|
||||
})
|
||||
out = append(out, publicView(l.svcCtx.Config, u))
|
||||
}
|
||||
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 ---
|
||||
|
||||
func (s *Service) ContributeBenchmark(ctx context.Context, ownerUID int64, niche string, engRate, avgViews float64) error {
|
||||
niche = strings.TrimSpace(niche)
|
||||
// NormalizeNiche 讓利基桶有機會對得上:大小寫與前後空白不該切出不同的桶。
|
||||
func NormalizeNiche(niche string) string {
|
||||
niche = strings.ToLower(strings.TrimSpace(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)))
|
||||
return s.Repo.UpsertBenchmarkSample(ctx, &domain.BenchmarkSample{
|
||||
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) {
|
||||
niche = strings.TrimSpace(niche)
|
||||
if niche == "" {
|
||||
niche = "general"
|
||||
}
|
||||
niche = NormalizeNiche(niche)
|
||||
list, err := s.Repo.ListBenchmarkSamples(ctx, niche, 500)
|
||||
if err != nil || len(list) < 5 {
|
||||
return len(list), 0, 0, false, "樣本不足 5,暫不顯示中位數(多貢獻幾次成效後再看)"
|
||||
|
|
@ -534,3 +537,9 @@ func toPDFSafe(s string) string {
|
|||
func Round2(v float64) float64 {
|
||||
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)
|
||||
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"`
|
||||
Code string `json:"code"`
|
||||
TrackPath string `json:"track_path"`
|
||||
TrackUrl string `json:"track_url"`
|
||||
DestinationUrl string `json:"destination_url"`
|
||||
OutcomeId string `json:"outcome_id,optional"`
|
||||
Label string `json:"label,optional"`
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"check:tokens": "node scripts/check-tokens.mjs",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run",
|
||||
"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">
|
||||
<defs>
|
||||
<linearGradient id="night" x1="8" y1="5" x2="58" y2="61" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#141A19"/>
|
||||
<stop offset=".58" stop-color="#1B2725"/>
|
||||
<stop offset="1" stop-color="#20B49C"/>
|
||||
<stop stop-color="#141A1A"/>
|
||||
<stop offset=".58" stop-color="#1B2627"/>
|
||||
<stop offset="1" stop-color="#5F9AA3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="orbit" x1="14" y1="14" x2="50" y2="51" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#C8F5EE"/>
|
||||
<stop offset=".52" stop-color="#22C3A8"/>
|
||||
<stop offset="1" stop-color="#3CDDC2"/>
|
||||
<stop stop-color="#DCEEF1"/>
|
||||
<stop offset=".52" stop-color="#6FADB6"/>
|
||||
<stop offset="1" stop-color="#8BC5CD"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="core" cx="0" cy="0" r="1" gradientTransform="translate(32 30) rotate(90) scale(12)">
|
||||
<stop stop-color="#FFFFFF"/>
|
||||
<stop offset=".42" stop-color="#DDFBF7"/>
|
||||
<stop offset="1" stop-color="#3CDDC2" stop-opacity=".12"/>
|
||||
<stop offset=".42" stop-color="#E8F4F6"/>
|
||||
<stop offset="1" stop-color="#8BC5CD" stop-opacity=".12"/>
|
||||
</radialGradient>
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2.4"/>
|
||||
|
|
@ -21,19 +21,19 @@
|
|||
</defs>
|
||||
|
||||
<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="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"/>
|
||||
<ellipse cx="32" cy="31" rx="24" ry="10.5" transform="rotate(-24 32 31)" stroke="#3CDDC2" stroke-opacity=".5" stroke-width="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="#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)"/>
|
||||
<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="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="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="#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="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="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="#DCEEF1"/>
|
||||
<circle cx="50.5" cy="33.5" r="1.5" fill="#FFF"/>
|
||||
<circle cx="14" cy="32" r="1" fill="#69D4C5"/>
|
||||
<rect x="1" y="1" width="62" height="62" rx="17" stroke="#E0EBE9" stroke-opacity=".15" stroke-width="2"/>
|
||||
<circle cx="14" cy="32" r="1" fill="#8BC5CD"/>
|
||||
<rect x="1" y="1" width="62" height="62" rx="17" stroke="#E0E9EB" stroke-opacity=".15" stroke-width="2"/>
|
||||
</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">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="3" y1="2" x2="29" y2="31" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#141A19"/>
|
||||
<stop offset="1" stop-color="#20B49C"/>
|
||||
<stop stop-color="#141A1A"/>
|
||||
<stop offset="1" stop-color="#5F9AA3"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="9" fill="url(#bg)"/>
|
||||
<circle cx="16" cy="15.5" r="9.5" stroke="#69D4C5" 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"/>
|
||||
<circle cx="16" cy="15" r="5" fill="#DDFBF7"/>
|
||||
<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="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="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"/>
|
||||
<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="#8BC5CD" stroke-opacity=".72"/>
|
||||
<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="#F4F6F6"/>
|
||||
<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="#FEE5A1"/>
|
||||
</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 = {
|
||||
display_name: name,
|
||||
username: member?.email || "user",
|
||||
avatar_color: "#20b49c",
|
||||
avatar_color: "var(--hb-brand)",
|
||||
avatar_url: member?.avatar_url,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -377,7 +377,10 @@ export function PersonaWorkbench() {
|
|||
</span>
|
||||
<span className="text-muted hb-persona-item__tone">{toneOf(p) || t("common.dash")}</span>
|
||||
{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}
|
||||
</span>
|
||||
) : 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 })}
|
||||
{selected.guard.banAiTone ? t("persona.banAi") : ""}
|
||||
{!isPersonaReady(selected) ? t("persona.notReadySuffix") : ""}
|
||||
|
|
@ -613,7 +616,7 @@ export function PersonaWorkbench() {
|
|||
|
||||
{hasAnalysis ? (
|
||||
<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 })}
|
||||
{" · "}
|
||||
{selected.style.source === "benchmark"
|
||||
|
|
@ -643,7 +646,7 @@ export function PersonaWorkbench() {
|
|||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted" style={{ fontSize: "0.875rem" }}>
|
||||
<p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}>
|
||||
{t("persona.analyzeHint")}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -652,7 +655,7 @@ export function PersonaWorkbench() {
|
|||
|
||||
{detailTab === "fingerprint" ? (
|
||||
<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")}
|
||||
</p>
|
||||
<Textarea
|
||||
|
|
@ -670,7 +673,7 @@ export function PersonaWorkbench() {
|
|||
|
||||
{detailTab === "preview" ? (
|
||||
<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")}
|
||||
</p>
|
||||
<Button
|
||||
|
|
@ -681,12 +684,12 @@ export function PersonaWorkbench() {
|
|||
{busy === "preview" ? t("persona.previewRunning") : t("persona.tryGen")}
|
||||
</Button>
|
||||
{!isPersonaReady(selected) ? (
|
||||
<p className="text-muted" style={{ fontSize: "0.875rem" }}>
|
||||
<p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}>
|
||||
{t("persona.notReadyMsg")}
|
||||
</p>
|
||||
) : null}
|
||||
{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", {
|
||||
topic: previewTopic,
|
||||
source:
|
||||
|
|
@ -699,7 +702,7 @@ export function PersonaWorkbench() {
|
|||
</p>
|
||||
) : null}
|
||||
{previewNotes ? (
|
||||
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}>
|
||||
<p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: 0 }}>
|
||||
{previewNotes}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -179,12 +179,12 @@ export function ImageAttach({
|
|||
</Button>
|
||||
</div>
|
||||
{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 })}
|
||||
</p>
|
||||
) : null}
|
||||
{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}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export function ReplyComposer({
|
|||
</Select>
|
||||
</div>
|
||||
{!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")}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ function Spark({
|
|||
s?: number;
|
||||
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 (
|
||||
<g opacity={0.9} transform={`translate(${x} ${y}) scale(${s})`}>
|
||||
<path
|
||||
|
|
|
|||
|
|
@ -502,7 +502,8 @@ function mapAccount(raw: Record<string, unknown>): ThreadsAccount {
|
|||
display_name: String(raw.display_name ?? ""),
|
||||
connection: (raw.connection as ThreadsAccount["connection"]) ?? "disconnected",
|
||||
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,
|
||||
error_message: raw.error_message != null ? String(raw.error_message) : undefined,
|
||||
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")}
|
||||
{t("admin.users.tempPwPersist")}
|
||||
</p>
|
||||
<span className="text-muted" style={{ fontSize: "0.78rem" }}>
|
||||
<span className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
|
||||
{revealed.display_name} · {revealed.email}
|
||||
{" · "}
|
||||
{formatLocalDateTime(revealed.created_at)}
|
||||
|
|
@ -396,7 +396,7 @@ export function AdminUsersPage() {
|
|||
</div>
|
||||
</div>
|
||||
{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>
|
||||
</p>
|
||||
) : null}
|
||||
|
|
@ -507,7 +507,7 @@ export function AdminUsersPage() {
|
|||
) : null}
|
||||
</div>
|
||||
{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 })}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
@ -582,7 +582,7 @@ export function AdminUsersPage() {
|
|||
{selected.email}
|
||||
</p>
|
||||
<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")}
|
||||
</Link>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
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";
|
||||
|
||||
type Bench = {
|
||||
|
|
@ -16,19 +16,19 @@ type Bench = {
|
|||
};
|
||||
|
||||
export function BenchmarkPage() {
|
||||
const [niche, setNiche] = useState("general");
|
||||
const [data, setData] = useState<Bench | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
// 利基維度還沒有真來源(帳號/品牌都沒存利基),先只比全站桶;
|
||||
// 給輸入框只會讓使用者打出一個永遠「樣本不足」的空桶。
|
||||
const load = useCallback(async () => {
|
||||
setErr("");
|
||||
try {
|
||||
const q = new URLSearchParams({ niche });
|
||||
setData(await apiRequest<Bench>(`/api/v1/benchmark?${q}`));
|
||||
setData(await apiRequest<Bench>("/api/v1/benchmark"));
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "fail");
|
||||
}
|
||||
}, [niche]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
|
|
@ -36,17 +36,16 @@ export function BenchmarkPage() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="同利基基準" description="匿名聚合中位數(樣本≥5 才顯示)" />
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}>
|
||||
<Input value={niche} onChange={(e) => setNiche(e.target.value)} placeholder="niche" />
|
||||
<PageHeader title="成效基準" description="全站匿名聚合中位數(樣本≥5 才顯示)" />
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<Button type="button" onClick={() => void load()}>
|
||||
查詢
|
||||
重新查詢
|
||||
</Button>
|
||||
</div>
|
||||
{err ? <p className="hb-form-error">{err}</p> : null}
|
||||
{data ? (
|
||||
<Card>
|
||||
<p>利基:{data.niche} · 樣本 {data.sample_size}</p>
|
||||
<p>樣本 {data.sample_size}</p>
|
||||
{data.available ? (
|
||||
<>
|
||||
<p>
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ export function CrewPage() {
|
|||
<Button type="button" onClick={() => void connectAccount()} disabled={Boolean(busy)}>
|
||||
{busy === "create" ? t("crew.connecting") : t("crew.connect")}
|
||||
</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")}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -46,10 +46,10 @@ export function ForgotPasswordPage() {
|
|||
<p className="hb-banner-ok" role="status" style={{ margin: 0 }}>
|
||||
{done.message}
|
||||
</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")}
|
||||
</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")}
|
||||
</p>
|
||||
<Link to={resetPath} className="hb-btn hb-btn--primary">{t("forgot.openReset")}</Link>
|
||||
|
|
@ -69,7 +69,7 @@ export function ForgotPasswordPage() {
|
|||
</div>
|
||||
) : (
|
||||
<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")}
|
||||
</p>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ export function JobDetailPage() {
|
|||
) : null}
|
||||
{job.error ? <p className="hb-form-error">{job.error}</p> : null}
|
||||
{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")}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ export function OutboxDetailPage() {
|
|||
</p>
|
||||
) : null}
|
||||
{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")}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export function PlaybooksPage() {
|
|||
<Badge tone="neutral">{p.author_label}</Badge>
|
||||
<Badge tone="neutral">引用 {p.import_count}</Badge>
|
||||
</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 ? (
|
||||
<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 }}>
|
||||
{okMsg}
|
||||
</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")}
|
||||
</p>
|
||||
<Link to="/login">
|
||||
|
|
@ -81,7 +81,7 @@ export function ResetPasswordPage() {
|
|||
</div>
|
||||
) : (
|
||||
<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")}
|
||||
</p>
|
||||
<Input
|
||||
|
|
@ -113,7 +113,7 @@ export function ResetPasswordPage() {
|
|||
required
|
||||
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")}
|
||||
</p>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -677,7 +677,7 @@ export function ScoutPage() {
|
|||
))}
|
||||
</Select>
|
||||
{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 })}
|
||||
{selectedProduct.pain_points?.[0]
|
||||
? t("scout.painPart", { pain: selectedProduct.pain_points[0] })
|
||||
|
|
@ -685,7 +685,7 @@ export function ScoutPage() {
|
|||
</p>
|
||||
) : null}
|
||||
{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")}{" "}
|
||||
<Link to="/app/brands">{t("nav.brands")}</Link>{" "}
|
||||
{t("scout.noProductsAfter")}
|
||||
|
|
@ -724,7 +724,7 @@ export function ScoutPage() {
|
|||
{runGroups.length > 0 ? (
|
||||
<Card title={t("scout.runs")}>
|
||||
<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 })}
|
||||
</p>
|
||||
<div className="hb-scout-run-list">
|
||||
|
|
@ -785,7 +785,7 @@ export function ScoutPage() {
|
|||
</div>
|
||||
<p className="hb-scout-now__text">{current.text}</p>
|
||||
{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 && allowHttpUrl(current.permalink) ? " · " : ""}
|
||||
{allowHttpUrl(current.permalink) ? (
|
||||
|
|
@ -796,12 +796,12 @@ export function ScoutPage() {
|
|||
</p>
|
||||
) : null}
|
||||
{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}
|
||||
</p>
|
||||
) : null}
|
||||
{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}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
@ -814,7 +814,7 @@ export function ScoutPage() {
|
|||
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")}
|
||||
</p>
|
||||
|
||||
|
|
|
|||
|
|
@ -304,7 +304,7 @@ export function SettingsPage() {
|
|||
<Card title={t("settings.ai")}>
|
||||
{ai ? (
|
||||
<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")}
|
||||
</p>
|
||||
<Select
|
||||
|
|
@ -353,11 +353,11 @@ export function SettingsPage() {
|
|||
}
|
||||
/>
|
||||
{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}
|
||||
</p>
|
||||
) : 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")}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ export function TodayPage() {
|
|||
display_name: accId,
|
||||
connection: "connected",
|
||||
is_usable: true,
|
||||
avatar_color: "#888",
|
||||
avatar_color: "var(--hb-muted)",
|
||||
} as ThreadsAccount);
|
||||
rows.push({
|
||||
account: acc,
|
||||
|
|
@ -338,7 +338,7 @@ export function TodayPage() {
|
|||
</div>
|
||||
</div>
|
||||
{!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")}{" "}
|
||||
<Link to="/app/scout">{t("today.goScout")}</Link>
|
||||
</p>
|
||||
|
|
@ -375,7 +375,7 @@ export function TodayPage() {
|
|||
</ul>
|
||||
</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")}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -482,7 +482,7 @@ export function TodayPage() {
|
|||
))}
|
||||
</ul>
|
||||
{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 })}{" "}
|
||||
<Link to="/app/scout">{t("today.pending.handle")}</Link>
|
||||
</p>
|
||||
|
|
@ -589,7 +589,7 @@ export function TodayPage() {
|
|||
</p>
|
||||
) : (
|
||||
<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", {
|
||||
sent: sentToday,
|
||||
running: runningOutbox.length,
|
||||
|
|
@ -597,13 +597,13 @@ export function TodayPage() {
|
|||
})}
|
||||
</p>
|
||||
{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>{" "}
|
||||
<Link to={`/app/outbox/${o.id}`}>{o.title || o.id.slice(0, 8)}</Link>
|
||||
</p>
|
||||
))}
|
||||
{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">
|
||||
{o.status === "scheduling"
|
||||
? t("today.badge.scheduling")
|
||||
|
|
@ -653,7 +653,7 @@ export function TodayPage() {
|
|||
<li key={row.account.id} className="hb-today-account">
|
||||
<div className="hb-today-account__head">
|
||||
<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 })}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -327,7 +327,7 @@ export function UsagePage() {
|
|||
: ""}
|
||||
</p>
|
||||
{!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")}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ type Link = {
|
|||
id: string;
|
||||
code: string;
|
||||
track_path: string;
|
||||
/** 後端算好的絕對網址;舊版後端沒有這欄,退回 track_path。 */
|
||||
track_url?: string;
|
||||
destination_url: string;
|
||||
outcome_id?: string;
|
||||
label?: string;
|
||||
|
|
@ -14,11 +16,14 @@ type Link = {
|
|||
created_at: number;
|
||||
};
|
||||
|
||||
function trackUrlOf(l: Link): string {
|
||||
return l.track_url || `${window.location.origin}${l.track_path}`;
|
||||
}
|
||||
|
||||
export function UtmLinksPage() {
|
||||
const [list, setList] = useState<Link[]>([]);
|
||||
const [url, setUrl] = useState("");
|
||||
const [label, setLabel] = useState("");
|
||||
const [outcomeId, setOutcomeId] = useState("");
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
|
|
@ -34,11 +39,10 @@ export function UtmLinksPage() {
|
|||
try {
|
||||
await apiRequest("/api/v1/utm/links", {
|
||||
method: "POST",
|
||||
body: { destination_url: url, label, outcome_id: outcomeId || undefined },
|
||||
body: { destination_url: url, label },
|
||||
});
|
||||
setUrl("");
|
||||
setLabel("");
|
||||
setOutcomeId("");
|
||||
await load();
|
||||
setMsg("已建立追蹤連結");
|
||||
} 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 (
|
||||
<>
|
||||
<PageHeader title="UTM 追蹤連結" description="點擊自動計入成果歸因" />
|
||||
<PageHeader title="UTM 追蹤連結" description="貼進貼文的可追蹤連結,累計點擊次數" />
|
||||
{msg ? <p className="text-muted">{msg}</p> : null}
|
||||
<Card title="新建">
|
||||
<div className="hb-stack">
|
||||
<Input label="目標 URL" value={url} onChange={(e) => setUrl(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>
|
||||
|
|
@ -71,11 +79,13 @@ export function UtmLinksPage() {
|
|||
{list.map((l) => (
|
||||
<Card key={l.id} title={l.label || l.code}>
|
||||
<p className="text-muted" style={{ wordBreak: "break-all" }}>
|
||||
追蹤:{window.location.origin}
|
||||
{l.track_path}
|
||||
追蹤:{trackUrlOf(l)}
|
||||
</p>
|
||||
<p>目標:{l.destination_url}</p>
|
||||
<p>點擊:{l.clicks}</p>
|
||||
<Button type="button" onClick={() => void copy(l)}>
|
||||
複製連結
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -81,8 +81,8 @@ export function VerifyEmailPage() {
|
|||
<div className="hb-stack">
|
||||
<div className="hb-verify-status">
|
||||
<span className="hb-verify-status__badge is-pending">{t("verify.pending")}</span>
|
||||
<p style={{ margin: 0, fontSize: "0.9rem" }}>{t("verify.body")}</p>
|
||||
<p className="text-muted" style={{ margin: 0, fontSize: "0.82rem" }}>
|
||||
<p style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>{t("verify.body")}</p>
|
||||
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-xs)" }}>
|
||||
{t("verify.currentAccount", { email: member.email })}
|
||||
{" · "}
|
||||
{role.primaryLabel}
|
||||
|
|
@ -136,7 +136,7 @@ export function VerifyEmailPage() {
|
|||
</Button>
|
||||
</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")}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -271,12 +271,12 @@ export function ComposerPanel({ accountId, personaId, personaReady }: Props) {
|
|||
|
||||
return (
|
||||
<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.hintEnd")}
|
||||
</p>
|
||||
{!personaReady ? (
|
||||
<p className="text-muted" style={{ fontSize: "0.875rem" }}>
|
||||
<p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}>
|
||||
{t("compose.personaOff")}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -425,7 +425,7 @@ export function InsightsPanel({
|
|||
</Select>
|
||||
) : (
|
||||
<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")}
|
||||
{syncedAt ? (
|
||||
<>
|
||||
|
|
@ -576,7 +576,7 @@ export function InsightsPanel({
|
|||
<div className="hb-insights-snap-panel">
|
||||
<div className="hb-insights-snap-meta">
|
||||
<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) })}
|
||||
{selectedSnap.delta.views != null
|
||||
? t("insights.viewsVsPrev", { delta: fmtDelta(selectedSnap.delta.views) })
|
||||
|
|
@ -702,12 +702,12 @@ export function InsightsPanel({
|
|||
{p.text.length > 120 ? "…" : ""}
|
||||
</span>
|
||||
{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}
|
||||
</span>
|
||||
) : null}
|
||||
{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")}
|
||||
</a>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -1249,7 +1249,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
|
|||
);
|
||||
})}
|
||||
{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")}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export function MentionsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
return (
|
||||
<div className="hb-stack">
|
||||
<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 })}
|
||||
{" · "}
|
||||
<Link to="/app/scout">{t("mentions.scoutLink")}</Link>
|
||||
|
|
@ -161,11 +161,11 @@ export function MentionsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
: t("mentions.status.skipped")}
|
||||
</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)}
|
||||
</span>
|
||||
</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}
|
||||
{allowHttpUrl(m.permalink) ? (
|
||||
<>
|
||||
|
|
@ -208,7 +208,7 @@ export function MentionsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
/>
|
||||
) : null}
|
||||
{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 })}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -341,12 +341,12 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
|
||||
{/* AI 結構分析:有結果就顯示;詳細區在展開時更清楚 */}
|
||||
{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 })}
|
||||
</p>
|
||||
) : null}
|
||||
{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 })}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
@ -419,7 +419,7 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
{open ? (
|
||||
<div className="hb-stack" style={{ marginTop: "0.75rem" }}>
|
||||
{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")}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
@ -485,12 +485,12 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
</Badge>
|
||||
<strong>@{r.username}</strong>
|
||||
{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 })}
|
||||
</span>
|
||||
) : null}
|
||||
{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 })}
|
||||
</span>
|
||||
) : null}
|
||||
|
|
@ -510,7 +510,7 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
<div className="hb-inline-badges">
|
||||
{mine ? <Badge tone="brand">{t("posts.mine")}</Badge> : null}
|
||||
<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)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -682,7 +682,7 @@ export function PlaysPanel({ accountId, personas: personasProp = [] }: Props) {
|
|||
{busy === "resolve" ? t("plays.resolving") : t("plays.resolve")}
|
||||
</Button>
|
||||
</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")}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -691,11 +691,11 @@ export function PlaysPanel({ accountId, personas: personasProp = [] }: Props) {
|
|||
|
||||
{targetMode === "own" && selectedPost ? (
|
||||
<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")}
|
||||
</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)}
|
||||
{allowHttpUrl(selectedPost.permalink) ? (
|
||||
<>
|
||||
|
|
@ -721,7 +721,7 @@ export function PlaysPanel({ accountId, personas: personasProp = [] }: Props) {
|
|||
) : null}
|
||||
</div>
|
||||
<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) ? (
|
||||
<a href={allowHttpUrl(external.url) ?? undefined} target="_blank" rel="noreferrer">
|
||||
{external.url}
|
||||
|
|
@ -762,7 +762,7 @@ export function PlaysPanel({ accountId, personas: personasProp = [] }: Props) {
|
|||
<div className="hb-list-row">
|
||||
<div>
|
||||
<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 })}
|
||||
{" · "}
|
||||
{s.steps
|
||||
|
|
@ -822,7 +822,7 @@ export function PlaysPanel({ accountId, personas: personasProp = [] }: Props) {
|
|||
})}
|
||||
</div>
|
||||
{poolAccounts.length === 0 ? (
|
||||
<p className="text-muted" style={{ fontSize: "0.8rem" }}>
|
||||
<p className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
|
||||
{t("plays.noAccounts")}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export function PreviewStep({ draft, accountMap }: Props) {
|
|||
acc || {
|
||||
display_name: t("wizard.preview.unknown"),
|
||||
username: "?",
|
||||
avatar_color: "#888",
|
||||
avatar_color: "var(--hb-muted)",
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -111,12 +111,12 @@ export function ScriptStep({ draft, accounts, onChange }: Props) {
|
|||
))}
|
||||
</Select>
|
||||
{!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")}
|
||||
</p>
|
||||
) : null}
|
||||
{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}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export function TopicStep({ draft, onChange }: Props) {
|
|||
))}
|
||||
</Select>
|
||||
{!ready ? (
|
||||
<p className="text-muted" style={{ fontSize: "0.875rem" }}>
|
||||
<p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}>
|
||||
{t("wizard.topic.personaNotReady")}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -20,7 +20,7 @@
|
|||
z-index: 40;
|
||||
display: flex;
|
||||
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);
|
||||
-webkit-backdrop-filter: saturate(130%) blur(16px);
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
/* 進行中任務即時進度(有 job 才渲染) */
|
||||
.hb-job-strip {
|
||||
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 {
|
||||
|
|
@ -61,10 +61,10 @@
|
|||
padding: 0.35rem 0.55rem;
|
||||
border-radius: var(--hb-radius, 8px);
|
||||
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;
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--hb-text-xs);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
|
|
@ -82,12 +82,12 @@
|
|||
|
||||
.hb-job-strip__status {
|
||||
font-weight: 500;
|
||||
color: var(--hb-brand);
|
||||
font-size: 0.75rem;
|
||||
color: var(--hb-brand-deep);
|
||||
font-size: var(--hb-text-2xs);
|
||||
}
|
||||
|
||||
.hb-job-strip__summary {
|
||||
color: var(--hb-muted, #667);
|
||||
color: var(--hb-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
|
@ -100,8 +100,8 @@
|
|||
|
||||
.hb-job-strip__more {
|
||||
align-self: center;
|
||||
font-size: 0.78rem;
|
||||
color: var(--hb-brand);
|
||||
font-size: var(--hb-text-xs);
|
||||
color: var(--hb-brand-deep);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
padding: 0.25rem 0.4rem;
|
||||
|
|
@ -130,14 +130,14 @@
|
|||
|
||||
.hb-persona-job__sum {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--hb-text-sm);
|
||||
}
|
||||
|
||||
.hb-persona-job__link {
|
||||
display: inline-block;
|
||||
margin-top: 0.45rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--hb-brand);
|
||||
font-size: var(--hb-text-xs);
|
||||
color: var(--hb-brand-deep);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +157,7 @@
|
|||
}
|
||||
|
||||
.hb-topbar__brand:hover {
|
||||
color: var(--hb-brand);
|
||||
color: var(--hb-brand-deep);
|
||||
}
|
||||
|
||||
.hb-topbar__mark,
|
||||
|
|
@ -171,7 +171,7 @@
|
|||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--hb-brand) 24%, transparent),
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -180,7 +180,7 @@
|
|||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--hb-magic) 52%, transparent),
|
||||
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 {
|
||||
|
|
@ -192,7 +192,7 @@
|
|||
}
|
||||
|
||||
.hb-topbar__name {
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--hb-text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
overflow: hidden;
|
||||
|
|
@ -201,7 +201,7 @@
|
|||
}
|
||||
|
||||
.hb-topbar__en {
|
||||
font-size: 0.62rem;
|
||||
font-size: var(--hb-text-2xs);
|
||||
font-weight: 600;
|
||||
color: var(--hb-muted);
|
||||
letter-spacing: 0.04em;
|
||||
|
|
@ -236,7 +236,7 @@
|
|||
border-radius: 0.55rem;
|
||||
background: color-mix(in srgb, var(--hb-surface) 92%, var(--hb-surface-muted));
|
||||
color: var(--hb-ink-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--hb-text-xs);
|
||||
cursor: pointer;
|
||||
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.is-open {
|
||||
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;
|
||||
}
|
||||
|
||||
.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.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 {
|
||||
|
|
@ -348,7 +348,7 @@
|
|||
}
|
||||
|
||||
a.hb-topbar__chip:hover {
|
||||
color: var(--hb-brand);
|
||||
color: var(--hb-brand-deep);
|
||||
border-color: var(--hb-brand);
|
||||
}
|
||||
|
||||
|
|
@ -382,7 +382,7 @@ a.hb-topbar__chip:hover {
|
|||
}
|
||||
|
||||
.hb-usage-widget__badge {
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--hb-text-xs);
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1;
|
||||
|
|
@ -402,13 +402,13 @@ a.hb-topbar__chip:hover {
|
|||
* 比極簡版明顯、比整顆光暈小。
|
||||
*/
|
||||
.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;
|
||||
animation: hb-plan-text-breathe-starter 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.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;
|
||||
animation: hb-plan-text-breathe-pro 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
|
@ -433,15 +433,15 @@ a.hb-topbar__chip:hover {
|
|||
0%,
|
||||
100% {
|
||||
text-shadow:
|
||||
0 0 3px color-mix(in srgb, #ffd700 40%, transparent),
|
||||
0 0 8px color-mix(in srgb, #e8c76a 28%, transparent),
|
||||
0 0 14px color-mix(in srgb, #daa520 14%, transparent);
|
||||
0 0 3px color-mix(in srgb, var(--hb-gold-bright) 40%, transparent),
|
||||
0 0 8px color-mix(in srgb, var(--hb-gold) 28%, transparent),
|
||||
0 0 14px color-mix(in srgb, var(--hb-gold) 14%, transparent);
|
||||
}
|
||||
50% {
|
||||
text-shadow:
|
||||
0 0 5px color-mix(in srgb, #fff1b0 75%, transparent),
|
||||
0 0 12px color-mix(in srgb, #ffd700 55%, transparent),
|
||||
0 0 20px color-mix(in srgb, #e8b923 28%, transparent);
|
||||
0 0 5px color-mix(in srgb, var(--hb-gold-bright) 75%, transparent),
|
||||
0 0 12px color-mix(in srgb, var(--hb-gold-bright) 55%, 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 {
|
||||
animation: none;
|
||||
text-shadow:
|
||||
0 0 4px color-mix(in srgb, #ffd700 55%, transparent),
|
||||
0 0 10px color-mix(in srgb, #e8c76a 30%, transparent);
|
||||
0 0 4px color-mix(in srgb, var(--hb-gold-bright) 55%, transparent),
|
||||
0 0 10px color-mix(in srgb, var(--hb-gold) 30%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.hb-usage-widget__sep {
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--hb-text-2xs);
|
||||
font-weight: 600;
|
||||
opacity: 0.4;
|
||||
line-height: 1;
|
||||
|
|
@ -469,7 +469,7 @@ a.hb-topbar__chip:hover {
|
|||
}
|
||||
|
||||
.hb-usage-widget__stat {
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--hb-text-2xs);
|
||||
font-weight: 650;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
|
|
@ -486,12 +486,12 @@ a.hb-topbar__chip:hover {
|
|||
}
|
||||
|
||||
.hb-usage-widget__stat--cta {
|
||||
color: var(--hb-brand);
|
||||
color: var(--hb-brand-deep);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.hb-usage-widget__stat--warn {
|
||||
color: color-mix(in srgb, #b87a12 70%, var(--hb-ink));
|
||||
color: var(--hb-warning-deep);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
|
|
@ -521,7 +521,7 @@ a.hb-topbar__chip:hover {
|
|||
|
||||
.hb-usage-widget__eyebrow {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
font-size: var(--hb-text-2xs);
|
||||
font-weight: 650;
|
||||
color: var(--hb-muted);
|
||||
}
|
||||
|
|
@ -529,20 +529,20 @@ a.hb-topbar__chip:hover {
|
|||
.hb-usage-widget__head-plan {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
font-size: 1.05rem;
|
||||
font-size: var(--hb-text-lg);
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.hb-usage-widget__inf {
|
||||
color: var(--hb-brand);
|
||||
color: var(--hb-brand-deep);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.hb-usage-widget__price {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--hb-text-sm);
|
||||
font-weight: 650;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--hb-text);
|
||||
|
|
@ -562,7 +562,7 @@ a.hb-topbar__chip:hover {
|
|||
.hb-usage-widget__head-num {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
font-size: 1.05rem;
|
||||
font-size: var(--hb-text-lg);
|
||||
font-weight: 750;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
|
|
@ -571,13 +571,13 @@ a.hb-topbar__chip:hover {
|
|||
.hb-usage-widget__cap {
|
||||
font-weight: 600;
|
||||
color: var(--hb-muted);
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--hb-text-sm);
|
||||
}
|
||||
|
||||
.hb-usage-widget__left {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--hb-text-2xs);
|
||||
color: var(--hb-muted);
|
||||
}
|
||||
|
||||
|
|
@ -597,11 +597,11 @@ a.hb-topbar__chip:hover {
|
|||
}
|
||||
|
||||
.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 {
|
||||
background: color-mix(in srgb, #d4524a 78%, var(--hb-brand));
|
||||
background: var(--hb-danger);
|
||||
}
|
||||
|
||||
.hb-usage-widget__bullets {
|
||||
|
|
@ -610,14 +610,14 @@ a.hb-topbar__chip:hover {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--hb-text-xs);
|
||||
line-height: 1.4;
|
||||
color: var(--hb-text);
|
||||
}
|
||||
|
||||
.hb-usage-widget__caps {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
font-size: var(--hb-text-2xs);
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
color: var(--hb-text);
|
||||
|
|
@ -632,9 +632,9 @@ a.hb-topbar__chip:hover {
|
|||
margin: 0;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: var(--hb-radius);
|
||||
border: 1px solid color-mix(in srgb, #e8a04a 28%, var(--hb-line));
|
||||
background: color-mix(in srgb, #e8a04a 10%, var(--hb-surface-muted));
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid color-mix(in srgb, var(--hb-warning) 28%, var(--hb-line));
|
||||
background: color-mix(in srgb, var(--hb-warning) 10%, var(--hb-surface-muted));
|
||||
font-size: var(--hb-text-2xs);
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
color: var(--hb-text);
|
||||
|
|
@ -654,7 +654,7 @@ a.hb-topbar__chip:hover {
|
|||
justify-content: center;
|
||||
min-height: 2.15rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--hb-text-xs);
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
|
@ -666,7 +666,7 @@ a.hb-topbar__chip:hover {
|
|||
}
|
||||
|
||||
.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 {
|
||||
display: none;
|
||||
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);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
padding: var(--hb-space-4) var(--hb-space-3);
|
||||
|
|
@ -709,7 +709,7 @@ a.hb-topbar__chip:hover {
|
|||
|
||||
.hb-sidebar__label {
|
||||
margin: 0 0 var(--hb-space-3) var(--hb-space-2);
|
||||
font-size: 0.7rem;
|
||||
font-size: var(--hb-text-2xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
|
|
@ -729,7 +729,7 @@ a.hb-topbar__chip:hover {
|
|||
|
||||
.hb-nav__group-label {
|
||||
margin: 0 0 var(--hb-space-1) var(--hb-space-2);
|
||||
font-size: 0.68rem;
|
||||
font-size: var(--hb-text-2xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--hb-subtle);
|
||||
|
|
@ -768,20 +768,24 @@ a.hb-topbar__chip:hover {
|
|||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.hb-nav__item:hover {
|
||||
background: var(--hb-brand-soft);
|
||||
color: var(--hb-brand-deep);
|
||||
}
|
||||
|
||||
.hb-nav__item:hover,
|
||||
.hb-nav__item:focus-visible {
|
||||
background: var(--hb-brand-soft);
|
||||
color: var(--hb-brand-deep);
|
||||
}
|
||||
|
||||
.hb-nav__item--active {
|
||||
background: var(--hb-brand-soft);
|
||||
color: var(--hb-brand-deep);
|
||||
box-shadow: inset 3px 0 0 var(--hb-brand);
|
||||
/*
|
||||
* 選中 = 實心主色塊 + 近黑字(10.3:1);hover 只是淡底。
|
||||
* 兩者一眼分得出來,也不必再靠一條被圓角切掉的 inset 3px 直線來區別
|
||||
* —— 原本 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 {
|
||||
|
|
@ -868,7 +872,7 @@ a.hb-topbar__chip:hover {
|
|||
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));
|
||||
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);
|
||||
-webkit-backdrop-filter: saturate(160%) blur(22px);
|
||||
}
|
||||
|
|
@ -887,7 +891,7 @@ a.hb-topbar__chip:hover {
|
|||
color: var(--hb-muted);
|
||||
text-decoration: none;
|
||||
font: inherit;
|
||||
font-size: 0.68rem;
|
||||
font-size: var(--hb-text-2xs);
|
||||
font-weight: 650;
|
||||
padding: 0.3rem 0.15rem;
|
||||
text-align: center;
|
||||
|
|
@ -906,10 +910,12 @@ a.hb-topbar__chip:hover {
|
|||
background: var(--hb-brand-soft);
|
||||
}
|
||||
|
||||
.hb-dock__item--active {
|
||||
color: var(--hb-brand-deep);
|
||||
background: var(--hb-brand-soft);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--hb-brand) 22%, transparent);
|
||||
/* 與側欄選單同一套:選中是實心主色塊。 */
|
||||
.hb-dock__item--active,
|
||||
.hb-dock__item--active:focus-visible {
|
||||
color: var(--hb-brand-on);
|
||||
background: var(--hb-brand);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hb-dock__icon {
|
||||
|
|
@ -961,7 +967,7 @@ a.hb-topbar__chip:hover {
|
|||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: rgb(21 40 37 / 0.35);
|
||||
background: color-mix(in srgb, var(--hb-ink) 35%, transparent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
|
@ -1000,7 +1006,7 @@ a.hb-topbar__chip:hover {
|
|||
|
||||
.hb-dock-more__title {
|
||||
margin: 0 0 0.65rem;
|
||||
font-size: 0.95rem;
|
||||
font-size: var(--hb-text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--hb-ink);
|
||||
|
|
@ -1013,7 +1019,7 @@ a.hb-topbar__chip:hover {
|
|||
|
||||
.hb-dock-more__group-label {
|
||||
margin: 0 0 0.3rem 0.25rem;
|
||||
font-size: 0.68rem;
|
||||
font-size: var(--hb-text-2xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--hb-subtle);
|
||||
|
|
@ -1041,7 +1047,7 @@ a.hb-topbar__chip:hover {
|
|||
color: var(--hb-ink);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
font-size: var(--hb-text-base);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
|
@ -1053,10 +1059,12 @@ a.hb-topbar__chip:hover {
|
|||
background: var(--hb-brand-soft);
|
||||
}
|
||||
|
||||
.hb-dock-more__row.is-active {
|
||||
border-color: color-mix(in srgb, var(--hb-brand) 40%, var(--hb-line));
|
||||
background: var(--hb-brand-soft);
|
||||
color: var(--hb-brand-deep);
|
||||
.hb-dock-more__row.is-active,
|
||||
.hb-dock-more__row.is-active:hover {
|
||||
border-color: transparent;
|
||||
background: var(--hb-brand);
|
||||
color: var(--hb-brand-on);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hb-dock-more__row .hb-dock__icon {
|
||||
|
|
@ -1071,7 +1079,7 @@ a.hb-topbar__chip:hover {
|
|||
}
|
||||
|
||||
.hb-dock-more__check {
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--hb-text-sm);
|
||||
font-weight: 700;
|
||||
color: var(--hb-brand-deep);
|
||||
flex-shrink: 0;
|
||||
|
|
@ -1091,7 +1099,7 @@ a.hb-topbar__chip:hover {
|
|||
}
|
||||
|
||||
.hb-topbar__name {
|
||||
font-size: 0.95rem;
|
||||
font-size: var(--hb-text-base);
|
||||
}
|
||||
|
||||
.hb-main {
|
||||
|
|
|
|||
|
|
@ -1,64 +1,79 @@
|
|||
/**
|
||||
* 巡樓 · Lapras — Harbor Desk UI
|
||||
* 色:teal mint primary / soft cyan secondary / coral accent
|
||||
* (Pokémon Palette 衍生)
|
||||
* 字體:Inter + Taipei Sans TC(保留)
|
||||
* 色:pale blue 主色 / cream 次色 / burnt orange 點綴(Pokémon Palette)
|
||||
* 字體: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 {
|
||||
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-surface: rgba(255, 255, 255, 0.9);
|
||||
--hb-surface-solid: #ffffff;
|
||||
--hb-surface-muted: #f2f7f7;
|
||||
--hb-ink: #152825;
|
||||
--hb-text: #152825;
|
||||
--hb-fg: #152825;
|
||||
--hb-ink-secondary: #2a403c;
|
||||
--hb-muted: #62847f;
|
||||
--hb-subtle: #7a9a95;
|
||||
--hb-line: color-mix(in srgb, #e0ebe9 88%, transparent);
|
||||
--hb-line-strong: #e0ebe9;
|
||||
--hb-surface-muted: #f2f6f6;
|
||||
--hb-ink: #152628;
|
||||
--hb-text: #152628;
|
||||
--hb-fg: #152628;
|
||||
--hb-ink-secondary: #33474a;
|
||||
--hb-muted: #566869; /* 5.8:1 於 bg、5.4:1 於 surface-muted */
|
||||
--hb-subtle: #748687; /* 3.8:1 — 裝飾/大字限定 */
|
||||
--hb-line: color-mix(in srgb, #e0e9eb 88%, transparent);
|
||||
--hb-line-strong: #e0e9eb;
|
||||
--hb-scrim: rgb(21 38 40 / 0.55);
|
||||
|
||||
--hb-brand: #20b49c;
|
||||
--hb-brand-hover: #1a9a86;
|
||||
--hb-brand-soft: color-mix(in srgb, #20b49c 13%, #ffffff);
|
||||
--hb-brand-on: #ffffff;
|
||||
--hb-brand-deep: #178f7d;
|
||||
--hb-magic: #69d4c5;
|
||||
--hb-magic-glow: rgb(32 180 156 / 0.28);
|
||||
--hb-focus: color-mix(in srgb, #22c3a8 78%, #69d4c5);
|
||||
--hb-brand: #8bc5cd;
|
||||
--hb-brand-hover: #76b8c1;
|
||||
--hb-brand-soft: color-mix(in srgb, #8bc5cd 20%, #ffffff);
|
||||
--hb-brand-on: #0a0a0a; /* 10.3:1 於 brand */
|
||||
--hb-brand-deep: #2d5f66; /* 7.0:1 於 bg,主色當文字時用 */
|
||||
--hb-magic: #fee5a1; /* 次色奶油黃:只當淡底與裝飾 */
|
||||
--hb-magic-glow: rgb(139 197 205 / 0.2);
|
||||
--hb-focus: #2d5f66;
|
||||
|
||||
--hb-success: #117d70;
|
||||
--hb-success-soft: #e7f7f4;
|
||||
--hb-success: #2f7d3f; /* 純綠 H=135,與 brand-deep 的青藍明顯分開 */
|
||||
--hb-success-soft: #eaf4ec;
|
||||
--hb-success-on: #ffffff; /* 5.2:1 */
|
||||
--hb-danger: #ef4444;
|
||||
--hb-danger-soft: #fcecef;
|
||||
--hb-warning: #9a7117;
|
||||
--hb-warning-soft: #faf3df;
|
||||
--hb-accent-warm: #ff7b73;
|
||||
--hb-danger-soft: #fdecec;
|
||||
--hb-danger-on: #0a0a0a; /* 5.3:1;白字只有 3.8:1 */
|
||||
--hb-danger-deep: #c0392b; /* 5.3:1 於 bg */
|
||||
--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-fun-sky: #69d4c5;
|
||||
--hb-fun-sky-soft: #e8faf7;
|
||||
--hb-fun-coral: #ff7b73;
|
||||
--hb-fun-coral-soft: #fff0ed;
|
||||
--hb-fun-peach: #e8a878;
|
||||
--hb-fun-peach-soft: #fef6e8;
|
||||
--hb-fun-lavender: #3aa89a;
|
||||
--hb-fun-lavender-soft: #e8f6f3;
|
||||
--hb-fun-gold: #d19b34;
|
||||
--hb-fun-gold-soft: #fbf4e5;
|
||||
/* 畫布極光:主色的暗一階,只用來鋪背景 */
|
||||
--hb-brand-dim: #6f9ba3;
|
||||
--hb-aurora-1: color-mix(in srgb, #8bc5cd 20%, transparent);
|
||||
--hb-aurora-2: color-mix(in srgb, #8bc5cd 11%, transparent);
|
||||
--hb-aurora-3: color-mix(in srgb, #6f9ba3 12%, transparent);
|
||||
|
||||
/* 付費方案的金色字光(裝飾用,不當文字色) */
|
||||
--hb-gold: #d9a531;
|
||||
--hb-gold-bright: #fee5a1;
|
||||
|
||||
--hb-radius-sm: 0.55rem;
|
||||
--hb-radius: 0.8rem;
|
||||
--hb-radius-lg: 1rem;
|
||||
--hb-radius-xl: 1.15rem;
|
||||
|
|
@ -66,22 +81,26 @@
|
|||
|
||||
--hb-shadow-card:
|
||||
0 0 0 1px color-mix(in srgb, var(--hb-line-strong) 70%, transparent),
|
||||
0 1px 2px rgb(21 40 37 / 0.04),
|
||||
0 9px 28px rgb(32 180 156 / 0.1);
|
||||
0 1px 2px rgb(21 38 40 / 0.04),
|
||||
0 9px 28px rgb(21 38 40 / 0.06);
|
||||
--hb-shadow-soft: 0 4px 18px var(--hb-magic-glow);
|
||||
--hb-shadow-float: 0 16px 48px rgb(21 40 37 / 0.12);
|
||||
--hb-shadow-glow: 0 0 24px rgb(34 195 168 / 0.3);
|
||||
--hb-shadow-float: 0 16px 48px rgb(21 38 40 / 0.12);
|
||||
--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",
|
||||
"Noto Sans TC", 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-sm: 0.875rem; /* 14px · 按鈕/輔助 */
|
||||
--hb-text-base: 1rem; /* 16px · 內文 */
|
||||
--hb-text-lg: 1.125rem; /* 18px · 卡片標題 */
|
||||
--hb-text-xl: 1.375rem; /* 22px · 頁標題 */
|
||||
--hb-text-2xl: 1.75rem; /* 28px · 登入/定價大標 */
|
||||
--hb-text-input: 1rem; /* 16px · 表單 */
|
||||
|
||||
--hb-space-1: 0.25rem;
|
||||
|
|
@ -103,69 +122,63 @@
|
|||
--hb-sidebar-width: 15rem;
|
||||
--hb-topbar-height: 3.6rem;
|
||||
--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"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--hb-bg: #141a19;
|
||||
--hb-surface: rgba(27, 39, 37, 0.9);
|
||||
--hb-surface-solid: #1b2725;
|
||||
--hb-surface-muted: #253735;
|
||||
--hb-ink: #f4f6f5;
|
||||
--hb-text: #f4f6f5;
|
||||
--hb-fg: #f4f6f5;
|
||||
--hb-ink-secondary: #d0dcd9;
|
||||
--hb-muted: #98b3b0;
|
||||
--hb-subtle: #7a9592;
|
||||
--hb-line: color-mix(in srgb, #2d4340 92%, transparent);
|
||||
--hb-line-strong: #2d4340;
|
||||
--hb-bg: #141a1a;
|
||||
--hb-surface: rgba(27, 38, 39, 0.9);
|
||||
--hb-surface-solid: #1b2627;
|
||||
--hb-surface-muted: #243132;
|
||||
--hb-ink: #f4f6f6;
|
||||
--hb-text: #f4f6f6;
|
||||
--hb-fg: #f4f6f6;
|
||||
--hb-ink-secondary: #d3dcdc;
|
||||
--hb-muted: #a9b7b7; /* 8.5:1 */
|
||||
--hb-subtle: #8b9a9a; /* 6.0:1 */
|
||||
--hb-line: color-mix(in srgb, #2d4143 92%, transparent);
|
||||
--hb-line-strong: #2d4143;
|
||||
--hb-scrim: rgb(0 0 0 / 0.66);
|
||||
|
||||
--hb-brand: #3cddc2;
|
||||
--hb-brand-hover: #55e5cd;
|
||||
--hb-brand-soft: color-mix(in srgb, #3cddc2 14%, #1b2725);
|
||||
--hb-brand-on: #0a0a0a;
|
||||
--hb-brand-deep: #69e8d5;
|
||||
--hb-magic: #69d3c5;
|
||||
--hb-magic-glow: rgb(60 221 194 / 0.32);
|
||||
--hb-focus: color-mix(in srgb, #3cddc2 78%, #69d3c5);
|
||||
--hb-brand: #89c5cd;
|
||||
--hb-brand-hover: #a3d4da;
|
||||
--hb-brand-soft: color-mix(in srgb, #89c5cd 14%, #1b2627);
|
||||
--hb-brand-on: #0a0a0a; /* 10.3:1 */
|
||||
--hb-brand-deep: #b7e3e8;
|
||||
--hb-magic: #fee59f;
|
||||
--hb-magic-glow: rgb(137 197 205 / 0.18);
|
||||
--hb-focus: #89c5cd;
|
||||
|
||||
--hb-success: #74d189; /* 9.4:1,純綠 */
|
||||
--hb-success-soft: #16251a;
|
||||
--hb-success-on: #0a0a0a;
|
||||
--hb-danger: #dc2626;
|
||||
--hb-danger-soft: #341919;
|
||||
--hb-success: #3cddc2;
|
||||
--hb-success-soft: #142a2b;
|
||||
--hb-warning: #e0c060;
|
||||
--hb-warning-soft: #2a2414;
|
||||
--hb-accent-warm: #ff7c75;
|
||||
--hb-danger-on: #ffffff; /* 4.8:1 —— 深底的紅維持白字 */
|
||||
--hb-danger-deep: #f87171; /* 5.6:1 於卡片;#ef4444 只有 4.1:1 */
|
||||
--hb-warning: #e18c37; /* 6.7:1 */
|
||||
--hb-warning-soft: #2f2416;
|
||||
--hb-warning-on: #0a0a0a;
|
||||
--hb-warning-deep: #e18c37; /* 深底不必再加深 */
|
||||
--hb-accent-warm: #e18c37;
|
||||
|
||||
--hb-fun-mint: #3cddc2;
|
||||
--hb-fun-mint-soft: #172b2c;
|
||||
--hb-fun-sky: #69d3c5;
|
||||
--hb-fun-sky-soft: #1b2b31;
|
||||
--hb-fun-coral: #ff7c75;
|
||||
--hb-fun-coral-soft: #342020;
|
||||
--hb-fun-peach: #e8c890;
|
||||
--hb-fun-peach-soft: #2a2418;
|
||||
--hb-fun-lavender: #69d3c5;
|
||||
--hb-fun-lavender-soft: #1e2f2d;
|
||||
--hb-fun-gold: #e0c060;
|
||||
--hb-fun-gold-soft: #2a2418;
|
||||
--hb-brand-dim: #7fb0b8;
|
||||
--hb-aurora-1: color-mix(in srgb, #89c5cd 14%, transparent);
|
||||
--hb-aurora-2: color-mix(in srgb, #89c5cd 7%, transparent);
|
||||
--hb-aurora-3: color-mix(in srgb, #7fb0b8 8%, transparent);
|
||||
|
||||
--hb-gold: #e1b84f;
|
||||
--hb-gold-bright: #fee59f;
|
||||
|
||||
--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);
|
||||
--hb-shadow-soft: 0 4px 22px var(--hb-magic-glow);
|
||||
--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-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);
|
||||
--hb-shadow-glow: 0 0 32px rgb(137 197 205 / 0.28);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,54 +33,38 @@
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/*
|
||||
* 主按鈕、選中的 tab、側欄與 dock 的選中項共用同一個配方:實心 --hb-brand +
|
||||
* --hb-brand-on。主色是淺藍,漸層到 --hb-brand-deep 會讓近黑字在深端失去對比,
|
||||
* 所以這裡不用漸層,深淺兩個主題也不需要各寫一份。
|
||||
*/
|
||||
.hb-btn--primary {
|
||||
background: linear-gradient(
|
||||
165deg,
|
||||
var(--hb-brand-hover) 0%,
|
||||
var(--hb-brand) 55%,
|
||||
var(--hb-brand-deep) 100%
|
||||
);
|
||||
background: var(--hb-brand);
|
||||
color: var(--hb-brand-on);
|
||||
box-shadow:
|
||||
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);
|
||||
box-shadow: var(--hb-shadow-soft);
|
||||
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) {
|
||||
transform: translateY(-0.5px);
|
||||
background: var(--hb-brand-hover);
|
||||
box-shadow: var(--hb-shadow-glow), var(--hb-shadow-soft);
|
||||
}
|
||||
|
||||
.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);
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
.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);
|
||||
border-color: var(--hb-line-strong, var(--hb-line));
|
||||
backdrop-filter: blur(8px);
|
||||
|
|
@ -94,7 +78,7 @@
|
|||
|
||||
.hb-btn--danger {
|
||||
background: var(--hb-danger);
|
||||
color: #fff;
|
||||
color: var(--hb-danger-on);
|
||||
}
|
||||
|
||||
.hb-btn--danger:hover:not(:disabled) {
|
||||
|
|
@ -125,7 +109,7 @@
|
|||
|
||||
.hb-field__error {
|
||||
font-size: var(--hb-text-sm);
|
||||
color: var(--hb-danger);
|
||||
color: var(--hb-danger-deep);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -268,13 +252,13 @@
|
|||
|
||||
.hb-badge--danger {
|
||||
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);
|
||||
}
|
||||
|
||||
.hb-badge--warning {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -314,7 +298,7 @@
|
|||
background: color-mix(in srgb, var(--hb-surface) 70%, var(--hb-brand-soft));
|
||||
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;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
|
|
@ -379,26 +363,18 @@
|
|||
background: color-mix(in srgb, var(--hb-surface) 60%, transparent);
|
||||
}
|
||||
|
||||
/* segment 樣式的 tab 是「白片浮起」,選中只換字色。 */
|
||||
.hb-tab.is-active {
|
||||
background: var(--hb-surface);
|
||||
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 {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--hb-brand),
|
||||
var(--hb-brand-deep)
|
||||
);
|
||||
background: var(--hb-brand);
|
||||
color: var(--hb-brand-on);
|
||||
box-shadow: 0 2px 8px rgb(8 127 116 / 0.28);
|
||||
}
|
||||
|
||||
[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);
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, var(--hb-brand-deep) 24%, transparent);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .hb-badge--brand {
|
||||
|
|
@ -457,7 +433,7 @@
|
|||
.hb-banner-error {
|
||||
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));
|
||||
color: var(--hb-danger);
|
||||
color: var(--hb-danger-deep);
|
||||
}
|
||||
|
||||
.hb-check-row {
|
||||
|
|
@ -587,7 +563,7 @@
|
|||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--hb-brand);
|
||||
color: var(--hb-brand-deep);
|
||||
font: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: 600;
|
||||
|
|
@ -600,7 +576,7 @@
|
|||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--hb-brand);
|
||||
color: var(--hb-brand-deep);
|
||||
font: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: 600;
|
||||
|
|
@ -627,7 +603,7 @@
|
|||
border-radius: var(--hb-radius);
|
||||
border: 1px solid var(--hb-line);
|
||||
background: var(--hb-surface);
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--hb-text-sm);
|
||||
}
|
||||
|
||||
.hb-reply-child.is-mine {
|
||||
|
|
@ -688,7 +664,7 @@
|
|||
}
|
||||
|
||||
.hb-metrics-chip {
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--hb-text-xs);
|
||||
color: var(--hb-ink-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
@ -726,11 +702,11 @@
|
|||
.hb-metrics-cell__val {
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 1rem;
|
||||
font-size: var(--hb-text-base);
|
||||
}
|
||||
|
||||
.hb-metrics-cell__label {
|
||||
font-size: 0.7rem;
|
||||
font-size: var(--hb-text-2xs);
|
||||
color: var(--hb-muted);
|
||||
}
|
||||
|
||||
|
|
@ -770,7 +746,7 @@
|
|||
border: 1px solid var(--hb-line);
|
||||
background: var(--hb-surface);
|
||||
color: var(--hb-text);
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--hb-text-sm);
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
|
|
@ -784,10 +760,10 @@
|
|||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--hb-text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--hb-fg, #111);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
color: var(--hb-fg);
|
||||
background: color-mix(in srgb, var(--hb-surface-solid) 85%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
pointer-events: none;
|
||||
|
|
@ -799,11 +775,11 @@
|
|||
z-index: 1;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.65rem;
|
||||
font-size: var(--hb-text-2xs);
|
||||
padding: 0.1rem 0.3rem;
|
||||
cursor: pointer;
|
||||
background: var(--hb-danger, #c0392b);
|
||||
color: #fff;
|
||||
background: var(--hb-danger);
|
||||
color: var(--hb-danger-on);
|
||||
}
|
||||
.hb-thumb-item__retry:disabled {
|
||||
opacity: 0.5;
|
||||
|
|
@ -934,7 +910,7 @@
|
|||
|
||||
.hb-compact-row__meta {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
font-size: var(--hb-text-xs);
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
|
@ -943,7 +919,7 @@
|
|||
min-height: 2.4rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
white-space: nowrap;
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--hb-text-xs);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
|
@ -1012,7 +988,7 @@
|
|||
display: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--hb-brand);
|
||||
color: var(--hb-brand-deep);
|
||||
font: inherit;
|
||||
font-size: var(--hb-text-sm);
|
||||
font-weight: 600;
|
||||
|
|
@ -1023,7 +999,7 @@
|
|||
|
||||
.hb-tabs--sm .hb-tab {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--hb-text-xs);
|
||||
}
|
||||
|
||||
.hb-persona-list {
|
||||
|
|
@ -1122,7 +1098,7 @@
|
|||
display: block;
|
||||
font-size: var(--hb-text-sm);
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--hb-brand);
|
||||
color: var(--hb-brand-deep);
|
||||
}
|
||||
|
||||
.hb-dim-card p {
|
||||
|
|
@ -1135,5 +1111,5 @@
|
|||
margin: 0.4rem 0 0;
|
||||
padding-left: 1rem;
|
||||
color: var(--hb-muted);
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--hb-text-xs);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue