Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions .agents/notes/implemented/feature/2026-09-09-usage-share-image.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Replace the usage ticket card with a fixed-format usage report

Status: implemented
Translation: current

[中文](2026-09-09-usage-share-image.zh.md)

## Abstract

The previous usage share card was a hand-drawn canvas "cinema ticket": 1446 lines
of bespoke rendering with foil palettes, a VHS filter, an isometric skyline, a
WebM encoder, and its own webfont loader, shipped dark behind `SHOW_SHARE_CARD =
false`. Its failure was a product one rather than a technical one — it treated a
recurring, comparable record as a novelty object, so no two cards looked alike and
none read at feed thumbnail size. It is deleted and replaced by a fixed-format
React card that reuses the session share card's capture pipeline, theme pinning,
and backdrop presets while inverting its configuration model: the session card is
an editor with nine appearance knobs because its content has no fixed shape, and
the usage card is a generator with five because its content does. The main
unresolved limit is visual: no automated screenshot acceptance was run, so the
layout is verified by Storybook and typecheck only.

## Decision

**The period is the screen's range, not a private one.** An earlier draft gave the
dialog its own period selector (30 days / past year / all time). That would have
let the card's headline disagree with the KPI tile the user pressed Share from,
and "all time" could only have been served by numbers the 53-week calendar cannot
support. Instead the card takes the range the Usage screen is already showing, and
its hero number is that range's own timeline total. Hourly ranges count intervals
and day-denominated ranges count days across the same four headline cells — the
same split the on-screen summary already makes, now computed once in
`usage-share-stats.ts` so the page and the image cannot drift apart.

**The heatmap is always the past 53 weeks, with the range lit inside it.**
Swapping the block per range was considered and rejected: it doubled the layout
surface and destroyed the comparability that is the card's whole purpose. Lighting
the shared window inside a dimmed year is an idiom the calendar view already uses
for its 30-day window, is honest at every range, and gives short ranges a genuine
story ("this burst, in my year"). The all-time range lights everything, because
nothing in the calendar is out of scope for it.

**Privacy defaults follow the data, not the gesture.** Sharing activity does not
imply sharing spend, so USD is an explicit opt-in switch rather than a field of
the card. Member identification is a second opt-in, is offered only when the range
has more than one contributor, and carries display name and avatar only — the
timeline also holds emails, and `computeUsageShareMemberSlices` never reads them.
A test asserts no email reaches the slices.

**Density is a correctness property here, not a taste one.** The first layout
distributed its five blocks evenly over the portrait's height and left large voids
— which is what a fixed-format card degenerates into when the content is specified
before the canvas. The fix added information rather than padding: month ticks on
the heatmap (a year of texture with no time scale cannot answer "when"), a fourth
headline cell, absolute dates beside the range's name, and absolute token counts
beside each split percentage. The brand mark occupies the one remaining void
beside the headline; it is drawn over the band rather than laid out in it, and is
sized from the band, so it can neither displace a number nor spill onto the rule
below. It is the same `lody-icon.png` the card's brand row and footer use, ghosted
by opacity alone. A first attempt stroked `lody.svg` into an outline, which put two
different jellyfish on one card; there is no vector of the product icon, so CSS
cannot cut a real outline from it, and desaturating plus inverting the raster made
a flat gray sticker rather than a watermark.

**The card declares its own type and spacing scale.** Built element by element it
accumulated ten font sizes (10, 10.5, 11, 11.5, 13, 15px …) whose half-pixel steps
carry no hierarchy, and the portrait band was padded `px-7` against a `px-6`
footer, so the workspace name never lined up with the number above it. An exported
image has no hover state or tooltip to recover a hierarchy that blurred sizes lose,
and two cards a month apart must set the same words identically — so `TEXT` names
five roles and every text node picks one, `PAD_X` is the single horizontal padding
for every band including the footer, and all spacing sits on a 4px grid. Vertical
rhythm is the one permitted divergence, because only the height budget differs
between 4:5 and 16:9; it lives in one `RHYTHM` record of two rows rather than
scattered per element. Both formats are asserted to have zero content overflow.

**Two formats, no more.** Portrait 576×720 and wide 704×396 (1152×1440 / 1408×792
at the pipeline's 2x scale) cover the feed and the inline-preview destinations.
The card is exactly these pixels *including any backdrop* — which is the trap the
first sizing fell into: a framed card is 48px shorter than an unframed one, the
layout had been tuned against the unframed story, and the framed default overflowed
its footer by 23px while flex quietly ate the bottom padding instead of reporting
it. Only the headline band may flex now; every other band is `shrink-0`, so a
layout that does not fit fails visibly rather than silently compressing.

**One capture pipeline for both cards.** `lib/chat-share-image-export.ts` became
`lib/share-image-export.ts` with `copyShareImage` / `exportShareImage(element,
title, fallback)`; `components/chat-share-theme-scope.ts` became
`components/share-theme-scope.ts`. Duplicating ~100 lines of snapdom, font
readiness, and Electron bridge handling into a second module was the alternative
and was rejected. The filename fallback became a required argument so the chat
surface keeps `lody-conversation` while usage gets `lody-usage`.

The share entry sits beside the range selector in `StatsSettingsView` behind an
opt-in `shareCard` prop, and the dialog is lazy-loaded, so the public landing demo
that reuses the same view neither offers an action it cannot perform nor pulls
snapdom and qrcode into its bundle.

## Alternatives not taken

Keeping the ticket renderer behind its flag and restyling it was possible; canvas
was rejected because it re-implements theming, i18n, RTL, and text layout that the
DOM path gets from the design system, and because the exported card cannot then be
covered by Storybook.

Mobile (`MobileStatsSettings`) does not get the entry in this change. It renders
its own layout and would share through `@capacitor/share` rather than a dialog with
a save button, which is a different interaction, not a smaller one.

## Evidence and limits

[The draft specification](../../../../specs/usage-share-image.md) owns the intended
behavior. `tests/usage-share-stats.test.ts` covers window-scoped streaks and
averages, the interval/day trio switch, the all-time lighting rule, the
no-timeline fallback, slice ranking and remainder folding, and the email
exclusion, all on synthetic fixtures with fixed timestamps.
`tests/share-image-export.test.ts` (renamed with its module) continues to cover
browser download cleanup, native save cancellation and failure, and invalid
capture results; it mocks rasterization and establishes no pixel fidelity.
`UsageShareCard.stories.tsx` covers both formats, both subjects, the hourly range,
the cost opt-in, and the bare card; every state was rendered and inspected in
Storybook, and the wide layout was rebuilt after its first version overflowed its
footer. `pnpm --filter @lody/components exec tsgo
--noEmit` passes. No automated screenshot or visual acceptance was run, and the
card has not been exercised against a live workspace.
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# 用固定版式的用量报告替换电影票卡片

Status: implemented
Translation: current

[English](2026-09-09-usage-share-image.md)

## 摘要

此前的用量分享卡片是一张手绘 canvas 的「电影票」:1446 行自定义渲染,带烫金色板、
VHS 滤镜、等距天际线、WebM 编码器和自己的网页字体加载器,并且一直以
`SHOW_SHARE_CARD = false` 处于关闭状态。它的失败是产品层面的而非技术层面的——它把一个
周期性、可比较的记录做成了新奇玩具,于是没有两张卡片长得一样,也没有一张在信息流缩略图
尺寸下读得清。现在它被删除,替换为一张固定版式的 React 卡片:复用会话分享卡片的截图管线、
主题钉定与背景预设,但把配置模型反了过来——会话卡片是一个有九个外观旋钮的编辑器,因为
它的内容没有固定形状;用量卡片是一个只有五个旋钮的生成器,因为它的内容有。目前主要的
未决限制在视觉侧:没有运行自动截图验收,版面仅通过 Storybook 和类型检查确认。

## 决策

**区间取自页面,而不是对话框自己的。** 早先的草案给对话框一个独立的周期选择器
(30 天 / 过去一年 / 全部)。那会让卡片的主数字与用户按下分享时看着的 KPI 卡片对不上,
而「全部」也只能靠 53 周日历无法支撑的数字来兑现。改为:卡片采用用量页面当前展示的区间,
主数字就是该区间时间线自己的总量。同样的四个标题格,小时粒度的区间数区间,按天计的
区间数天——与页面上的摘要本来就在做的区分一致,现在统一收敛到 `usage-share-stats.ts`
计算一次,页面与图片不会各说各话。

**热力图恒为过去 53 周,被分享的区间在其中点亮。** 曾考虑按区间替换这一块,被否决:
它让版面面积翻倍,并摧毁了这张卡片存在的意义——可比性。在暗下去的一年里点亮被分享的
窗口,是日历视图为它的 30 天窗口已经在用的语汇,在任何区间下都诚实,并且给短区间一个
真正的叙事(「这一波,在我这一年里的位置」)。全部时间的区间点亮全部,因为日历里没有
任何一部分不在它的范围内。

**隐私默认值跟随数据,而不是跟随动作。** 分享活跃度并不蕴含分享花费,所以美元是一个
显式开关而非卡片的固有字段。成员身份是第二个显式选项,只有当区间里不止一位贡献者时
才提供,并且只携带显示名与头像——时间线里同时存着邮箱,而
`computeUsageShareMemberSlices` 从不读取它。有一条测试断言没有邮箱进入切片。

**在这里密度是正确性问题,不是审美问题。** 第一版把五个信息块沿竖版高度均匀铺开,
留下大片空洞——当内容先于画布被确定时,固定版式就会退化成这样。修法是加信息而不是加
留白:热力图上的月份刻度(一年的纹理若没有时间轴,就回答不了"什么时候")、第四个
标题格、区间名旁边的绝对日期,以及每条占比旁边的绝对 token 数。品牌标记占据主数字
旁边仅剩的那块空洞;它绘制在信息带之上而非参与排版,并且从所在信息带取高,因此既不会
挤走任何一个数字,也不会溢到下面那条分隔线上。它就是卡片品牌行与页脚用的同一个
`lody-icon.png`,仅靠不透明度做淡化。第一版把 `lody.svg` 描边成轮廓,结果同一张卡上
出现了两只不同的水母;产品图标没有矢量版本,CSS 无法从位图切出真正的轮廓,而去色加
反相只会得到一块扁平的灰贴纸而非水印。

**卡片自带一套字号与间距标尺。** 逐个元素堆出来的版本积累了十种字号
(10、10.5、11、11.5、13、15px……),半像素的差别根本承载不了层级;竖版信息带用
`px-7` 而页脚用 `px-6`,工作区名称从来就没和上面的数字对齐过。导出的图片没有悬停也
没有 tooltip 去补回被模糊掉的层级,而相隔一个月的两张卡必须把同样的词排成同样的样子
——于是 `TEXT` 只命名五个角色,每个文本节点从中挑一个;`PAD_X` 是包括页脚在内所有
信息带唯一的水平内边距;所有间距落在 4px 栅格上。纵向节奏是唯一允许的分歧,因为两种
画幅只有高度预算不同,它收在一条两行的 `RHYTHM` 记录里,而不是散落在各个元素上。
两种画幅都断言了内容零溢出。

**两种画幅,不再多。** 竖版 576×720 与横版 704×396(在管线的 2 倍缩放下为 1152×1440 /
1408×792)覆盖信息流与内联预览两类去处。卡片**连同背景**恰好就是这些像素——第一版
定尺寸时正是栽在这里:带背景的卡片比不带背景的矮 48px,而版面是照着无背景的 story 调的,
于是默认带背景的竖版把页脚顶穿了 23px,flex 却悄悄吃掉底部内边距而不是报出来。现在只有
主数字那一带可以伸缩,其余每一带都是 `shrink-0`,装不下就会显式暴露而不是被压扁。

**两张卡片共用一条截图管线。** `lib/chat-share-image-export.ts` 改名为
`lib/share-image-export.ts`,导出 `copyShareImage` / `exportShareImage(element, title,
fallback)`;`components/chat-share-theme-scope.ts` 改名为
`components/share-theme-scope.ts`。备选方案是把约 100 行 snapdom、字体就绪与 Electron
桥接处理复制进第二个模块,已否决。文件名兜底改为必填参数,因此会话侧保留
`lody-conversation`,用量侧得到 `lody-usage`。

分享入口位于 `StatsSettingsView` 区间选择器旁,由可选的 `shareCard` 属性开启,对话框
按需懒加载;因此复用同一视图的公开落地页演示既不会展示一个它无法执行的动作,也不会把
snapdom 和 qrcode 拉进它的产物。

## 未采纳的方案

保留电影票渲染器并重新配色是可行的;canvas 被否决,是因为它重新实现了设计系统在 DOM
路径上白给的主题、i18n、RTL 与文本排版,并且导出的卡片无法被 Storybook 覆盖。

本次改动不为移动端(`MobileStatsSettings`)加入该入口。它渲染自己的版面,且会通过
`@capacitor/share` 分享而不是一个带保存按钮的对话框,那是另一种交互,而非同一交互的
缩小版。

## 证据与限制

[规格草案](../../../../specs/usage-share-image.md)承载意图行为。
`tests/usage-share-stats.test.ts` 覆盖按窗口统计的连续与均值、区间/天三元组的切换、
全部时间的点亮规则、无时间线时的回退、切片排序与尾部折叠,以及邮箱排除,全部基于
时间戳固定的合成数据。`tests/share-image-export.test.ts`(随模块一同改名)继续覆盖
浏览器下载清理、原生保存的取消与失败,以及无效的截图结果;它 mock 了栅格化,不能证明
像素保真。`UsageShareCard.stories.tsx` 覆盖两种画幅、两种主体、小时粒度区间、花费开关
以及无背景卡片;每种状态都在 Storybook 中渲染并逐一检查过,横版在第一版溢出到
页脚之后被重排。`pnpm --filter @lody/components exec tsgo --noEmit` 通过。没有运行
自动截图或视觉验收,卡片也未在真实工作区数据上验证过。
31 changes: 27 additions & 4 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3255,13 +3255,39 @@
"workspace.usage.activeMembers": "Active members",
"workspace.usage.modelsUsed": "Models used",
"workspace.usage.topModel": "Top model",
"workspace.usage.shareImage.action": "Share usage card",
"workspace.usage.shareImage.dialogTitle": "Share usage card",
"workspace.usage.shareImage.dialogDescription": "PNG image of this range's usage",
"workspace.usage.shareImage.aspect": "Format",
"workspace.usage.shareImage.aspectPortrait": "Portrait (4:5)",
"workspace.usage.shareImage.aspectWide": "Wide (16:9)",
"workspace.usage.shareImage.subject": "Card",
"workspace.usage.shareImage.subjectPersonal": "Workspace",
"workspace.usage.shareImage.subjectTeam": "Workspace and members",
"workspace.usage.shareImage.subjectTeamHint": "The card will show member names and avatars with their share of this range.",
"workspace.usage.shareImage.theme": "Theme",
"workspace.usage.shareImage.themeApp": "Follow app",
"workspace.usage.shareImage.themeLight": "Light",
"workspace.usage.shareImage.themeDark": "Dark",
"workspace.usage.shareImage.backdrop": "Background",
"workspace.usage.shareImage.backdropNone": "None",
"workspace.usage.shareImage.content": "Content",
"workspace.usage.shareImage.showCost": "Include cost",
"workspace.usage.shareImage.showCostHint": "The card will show this range's spend in USD.",
"workspace.usage.shareImage.showQr": "QR code",
"workspace.usage.shareImage.copyImage": "Copy image",
"workspace.usage.shareImage.copied": "Image copied to clipboard",
"workspace.usage.shareImage.exportPng": "Export PNG",
"workspace.usage.shareImage.exportFailed": "Could not complete the image action. Please try again.",
"workspace.usage.shareImage.calendarCaption": "Past 53 weeks",
"workspace.usage.shareImage.windowLit": "{{range}} highlighted",
"workspace.usage.shareImage.unknownMember": "Unknown member",
"workspace.usage.skyline.activeDays": "Active days",
"workspace.usage.skyline.activeIntervals": "Active intervals",
"workspace.usage.skyline.averagePerInterval": "Average per interval",
"workspace.usage.skyline.ascii": "ASCII skyline",
"workspace.usage.skyline.asciiCopied": "ASCII skyline copied",
"workspace.usage.skyline.asciiPreview": "ASCII loading preview",
"workspace.usage.skyline.cardFailed": "Could not create usage share card",
"workspace.usage.skyline.clickForDetails": "Click for details",
"workspace.usage.skyline.clickHint": "Click a day to open its breakdown",
"workspace.usage.skyline.copyAscii": "Copy ASCII skyline",
Expand All @@ -3273,7 +3299,6 @@
"workspace.usage.skyline.dayDetail": "Day detail",
"workspace.usage.skyline.downloadAscii": "Download ASCII skyline",
"workspace.usage.skyline.downloadBinaryStl": "Download binary STL (.stl)",
"workspace.usage.skyline.downloadCard": "Share usage card",
"workspace.usage.skyline.downloadStl": "Download STL skyline",
"workspace.usage.skyline.future": "Future",
"workspace.usage.skyline.heatmap": "Usage heatmap",
Expand All @@ -3288,12 +3313,10 @@
"workspace.usage.skyline.peakDay": "Peak day",
"workspace.usage.skyline.peakInterval": "Peak interval",
"workspace.usage.skyline.peakShare": "{{percent}}% of peak day",
"workspace.usage.skyline.shareCard": "Share usage card",
"workspace.usage.skyline.stlMetalPreview": "Binary STL metal surface preview",
"workspace.usage.skyline.subtitle": "Last 53 weeks of daily usage",
"workspace.usage.skyline.title": "Usage skyline",
"workspace.usage.skyline.total": "Total",
"workspace.usage.skyline.view": "Usage visualization",
"workspace.usage.skyline.windowSubtitle": "Last 30 days, lit inside the last 53 weeks",
"workspace.usage.skyline.webSearches": "{{count}} web searches",
"workspace.usage.skyline.webSearches_one": "{{count}} web search",
Expand Down
Loading
Loading