diff --git a/.agents/notes/implemented/feature/2026-09-09-usage-share-image.md b/.agents/notes/implemented/feature/2026-09-09-usage-share-image.md new file mode 100644 index 000000000..780c15c26 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-usage-share-image.md @@ -0,0 +1,202 @@ +# 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 graphic follows the range — a reversal.** The first decision here was that +the heatmap is always the past 53 weeks with the range lit inside it, on the +grounds that swapping the block per range doubles the layout surface and destroys +comparability. That reasoning still holds for the day-denominated ranges, and they +keep the lit-window calendar. It did not survive contact with the short ranges: a +24h card drew a year with **one cell lit**, which is not a comparable record, it is +a wasted band. + +The Usage screen already speaks three visual languages — an hour skyline for 24h, a +day-by-hour dot grid for 7d, the calendar for the longer windows — so the card now +makes the same split rather than inventing a fourth. Comparability is preserved +where it means something: two 30-day cards still line up, and two 24h cards still +line up, because a card is only ever compared against the same range. The layout +surface is contained by giving the graphic one fixed box (`GRAPHIC_H`) that every +kind fits, so the card's height does not depend on its range. + +That box also settled the week grid's labels. Seven days of hour buckets touch +eight calendar days whenever the window does not start at midnight, so a per-row +label has to disambiguate the repeated weekday — but eight rows in a 58px box leave +7px each, which holds no size on the card's own type scale. The first attempt +reached for an off-scale 8px and produced exactly the squeezed left column the +scale exists to prevent. The rows now carry no label at all: they run oldest to +newest, the grid shares the same left edge as every other band, and the headline +already names the span. + +**Privacy defaults follow the data, not the gesture.** Sharing activity does not +imply sharing spend, so tokens are the default and naming cost as the measure is a +deliberate act. Cost began as a switch that appended a USD figure beside the token +headline; making it the card's *measure* instead is both better product and a +tighter default, because the two units now substitute rather than accumulate — a +cost card cannot leak a token count alongside the spend. Everything follows the +choice: headline, cells, the heatmap's own intensity scale, and both splits, all +derived once with the metric threaded through `usage-share-stats.ts`. + +Cost had to learn the card's numeric language to fit it. Tokens were compact +everywhere (`1.3B`, `42M`) while money was written out in full, and a string that +grows with its value does not belong in a fixed layout: measured in the 16:9 card, +the gap between the headline and the stat cells fell from 208px at `1.3B` to 119px +at `$5,297.05`, 10px at `$123,456,789.01`, and **−18px** — an overlap — at ten +figures. The first fix compacted from a thousand up, which +bounded the width but threw away the thing a cost card is usually chosen to show: +`$5.3K` hides the digits that are the point. `formatUsdCompact` now shortens in two +stages — cents go above a thousand, the figure itself only past a billion — so +`$5,297` and `$1,234,568` survive whole. Measured again on the 16:9 card, the worst +surviving case (`$999,999,999`) leaves a 59px gap where the old formatting +overlapped by 18px. + +Width is per slot, not per card. Preserving digits in the headline pushed the +problem into the stat cells, which have a quarter of its width and rendered +`$42,040…` — an ellipsis on a number is a wrong number, worse than a rounded one, +and `truncate` had been quietly producing it. `formatUsdTight` always compacts +above a thousand and serves the cells and the legend, while the headline keeps +`formatUsdCompact`. + +The same screenshot exposed an API trap: `metric` was a card prop separate from +the `stats` it described, so a caller could pair one metric's figures with the +other's unit — which is exactly what a Storybook control did, rendering 1.26 +billion tokens as `$1.3B`. The metric now lives inside `UsageShareStats`, stamped +by the function that derives it, and the card reads it from there. 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 space beside the headline stays empty.** Six attempts went into filling it. +Five were the brand mark: an outline stroked from `lody.svg` (a different jellyfish +than the product icon, so one card carried two), a low-opacity ghost of +`lody-icon.png` (read as a second logo parked in a corner), a full-height silhouette +(a shadow behind the heatmap and the legend, the densest bands), a top-cropped one +(the bell sliced flat into a smudge under the range chip), and a right-bleeding one +that finally looked deliberate but was still decoration. The sixth put the range's +own bucket profile there, which was at least content — and it was still one graphic +too many next to a card that already carries a year heatmap and a model split. + +The conclusion is the record here: that space is empty by choice. Whitespace beside +a headline is a normal thing for a poster to have, and every attempt to fill it +either repeated a band below or invented something to occupy the reader. + +**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. + +**The footer is a sign-off, and it was measurably thinner than it looked.** Read +against the session card it seemed to be missing things; measured against it, the +usage footer was already identical to that card's `row` variant — 57px, a 33px +mark — and carried one field more, the workspace name. What it was being compared +to was the `stacked` variant at 154px, which a 720px card cannot spend 21% of its +height on and a 396px one cannot fit at all. So the portrait footer takes the +middle: the session card's identity-plus-sub structure (workspace, then `lody.ai` +under it) with a full-size code, 73px, paid for out of the headline band's slack +rather than out of any information band. It deliberately omits that card's EXIF +parameter line, which here would only repeat the bands above. The wide format +keeps the single row. + +The session card's `canvas` footer turned out to be worth taking as well, and it is +the only placement that *gives* the card height rather than taking it: the in-card +band disappears and the sign-off prints on backdrop pixels that were empty frame. +It is a sixth knob on a card whose whole argument is few knobs, which is affordable +because it is a placement rather than a style, and because it degrades honestly — +with no backdrop there is nothing to print on, so it falls back to the in-card +footer exactly as the session card does. The other variants were measured and left: +`stacked` does not fit, `row` is what the wide format already is, `minimal` drops +the workspace name and the code, and `exif` would repeat the bands above. + +**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. diff --git a/.agents/notes/implemented/feature/2026-09-09-usage-share-image.zh.md b/.agents/notes/implemented/feature/2026-09-09-usage-share-image.zh.md new file mode 100644 index 000000000..181fc6f1d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-usage-share-image.zh.md @@ -0,0 +1,149 @@ +# 用固定版式的用量报告替换电影票卡片 + +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 周,被分享的 +区间在其中点亮」,理由是按区间替换会让版面面积翻倍、并摧毁可比性。这个理由对按天计的 +区间依然成立,它们保留了「点亮窗口的年度日历」。但它没能经受住短区间的检验:24h 的卡片 +画出来是一整年里**只有一个格子亮着**——那不是可比的记录,那是一条被浪费的信息带。 + +用量页面本来就有三套视觉语言——24h 的小时天际线、7d 的天×小时点阵、更长窗口的年度 +日历——所以卡片改为沿用同一套划分,而不是另造第四种。可比性在真正有意义的地方被保住了: +两张 30 天的卡仍然对得上,两张 24h 的卡也仍然对得上,因为一张卡永远只会和**同一个区间** +的卡相比。版面面积则由「图形只有一个固定盒子(`GRAPHIC_H`)、三种图形都塞进去」来约束, +所以卡片高度不随区间变化。 + +这个盒子同时也决定了周网格的行标签。7×24 小时只要不从零点开始就会跨 8 个日历日, +于是行标签必须消解重复的星期几——但 8 行塞进 58px 的盒子,每行只剩 7px,卡片自己的 +字号标尺里没有任何一档装得下。第一次尝试伸手去拿了标尺外的 8px,结果正是那条标尺本来 +就要防止的、挤成一团的左侧列。现在行上不放任何标签:它们从旧到新排列,网格与其他每一条 +信息带共用同一个左边缘,而跨度主数字那里已经写明了。 + +**隐私默认值跟随数据,而不是跟随动作。** 分享活跃度并不蕴含分享花费,所以默认口径是 +token,把成本选为度量是一个明确的动作。成本最初是一个「在 token 主数字旁附加一个美元 +数字」的开关;改成让它成为卡片的**度量**,既是更好的产品,也是更紧的默认值——两种单位 +现在是**替换**而非叠加,成本卡不可能在花费旁边泄漏出 token 数。所有东西都跟着这个选择走: +主数字、四个格、热力图自身的强度标尺、以及两种占比,全部由 `usage-share-stats.ts` 一次 +派生并把度量贯穿下去。 + +成本必须学会卡片的数字语言才装得下。token 处处都是紧凑写法(`1.3B`、`42M`),而金额却是 +完整格式——一个长度随数值增长的字符串不属于固定版式。在 16:9 卡上量过:主数字到统计格的 +间距从 `1.3B` 的 208px,降到 `$5,297.05` 的 119px、`$123,456,789.01` 的 10px,到十位数时 +变成 **−18px**,也就是重叠。第一版修法是从一千起就紧凑,宽度是压住了, +却把「选成本口径通常就是为了展示数额」这件事一起扔掉了——`$5.3K` 恰恰藏起了那些位数。 +`formatUsdCompact` 现在分两级缩短:**一千以上去掉分位,十亿以上才压缩数字本身**,于是 +`$5,297`、`$1,234,568` 都完整保留。重新量过 16:9 卡:最坏的完整情形(`$999,999,999`) +仍留有 59px 间距,而旧写法在这个量级已经重叠了 18px。 + +宽度是**每个槽位**的事,不是整张卡的事。在主数字里保住位数,等于把问题推给了统计格—— +它只有主数字四分之一的宽度,于是渲染出 `$42,040…`。数字上的省略号是**错误的数字**, +比四舍五入更糟,而 `truncate` 一直在悄悄制造它。`formatUsdTight` 从一千起一律压缩, +服务统计格和图例;主数字仍用 `formatUsdCompact`。 + +同一张截图还暴露了一个 API 陷阱:`metric` 原本是与它所描述的 `stats` 相互独立的卡片属性, +调用方可以把一种度量的数字配上另一种度量的单位——Storybook 的控件正好做到了这件事, +把 12.6 亿个 token 渲染成了 `$1.3B`。现在度量存放在 `UsageShareStats` 内部,由派生它的 +函数盖章,卡片从那里读取。成员身份是第二个显式选项,只有当区间里不止一位贡献者时 +才提供,并且只携带显示名与头像——时间线里同时存着邮箱,而 +`computeUsageShareMemberSlices` 从不读取它。有一条测试断言没有邮箱进入切片。 + +**在这里密度是正确性问题,不是审美问题。** 第一版把五个信息块沿竖版高度均匀铺开, +留下大片空洞——当内容先于画布被确定时,固定版式就会退化成这样。修法是加信息而不是加 +留白:热力图上的月份刻度(一年的纹理若没有时间轴,就回答不了"什么时候")、第四个 +标题格、区间名旁边的绝对日期,以及每条占比旁边的绝对 token 数。 + +**主数字旁边那块空,就让它空着。** 为了填它一共试了六版。前五版都是品牌标记:从 +`lody.svg` 描出的轮廓(那不是产品图标,于是同一张卡上有两只不同的水母)、`lody-icon.png` +的低不透明度淡化(读作角落里的第二个 logo)、贯穿全高的剪影(热力图和图例这两块最密的 +带背后压上投影)、用上边缘裁切的剪影(伞盖被切平,成了压在区间胶囊底下的污渍),以及 +只从右边出血的那版——它终于看起来是有意为之,但仍然是装饰。第六版放的是区间自己的 +bucket 形状,那至少是内容;可对于一张已经有年度热力图和模型占比的卡片来说,它仍然是 +多出来的一张图。 + +结论就是这条记录本身:那块空是**选择**,不是遗漏。主数字旁边有留白对海报来说再正常不过, +而每一次试图填满它,结果要么重复了下面某一条带,要么凭空造了点东西来占住读者的注意力。 + +**卡片自带一套字号与间距标尺。** 逐个元素堆出来的版本积累了十种字号 +(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`,装不下就会显式暴露而不是被压扁。 + +**页脚是签名,而不是状态栏;而且它「看起来薄」和「量出来薄」是两回事。** 和会话卡放 +在一起看,它像是少了很多东西;但量下来,用量卡的页脚和会话卡的 `row` 变体完全一致—— +57px、33px 的标记——而且还多带一个字段(工作区名)。被拿来比较的其实是 `stacked` 变体, +高 154px:一张 720px 的卡拿不出 21% 的高度给它,396px 的横版更是根本放不下。于是竖版 +页脚取中间值:沿用会话卡「身份 + 副行」的结构(工作区名,下面是 `lody.ai`)配一个足尺 +二维码,73px,这点高度从主数字带的富余里出,不动任何一条信息带。它刻意不要那张卡的 +EXIF 参数行——在这里它只会重复上面已有的数字。横版保持单行。 + +会话卡的 `canvas` 页脚也值得拿过来,而且它是唯一一个**给**卡片腾出高度而不是占用高度的 +放法:卡内那条带整个消失,签名印在本来就空着的背景边距上。这是一张「以少旋钮为论点」的 +卡片上的第六个旋钮,之所以还付得起,是因为它是**位置**而非样式,而且降级方式是诚实的 +——没有背景就没有可印的地方,于是回落到卡内页脚,和会话卡的行为完全一致。其余变体都量 +过后放弃:`stacked` 装不下,`row` 就是横版现在的样子,`minimal` 会丢掉工作区名和二维码, +`exif` 则只会重复上面几条带。 + +**两张卡片共用一条截图管线。** `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` 通过。没有运行 +自动截图或视觉验收,卡片也未在真实工作区数据上验证过。 diff --git a/locales/en.json b/locales/en.json index 43d0186d0..18629627f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -3255,13 +3255,44 @@ "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.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.metric": "Measure", + "workspace.usage.shareImage.metricCostHint": "The whole card is denominated in USD: the headline, the cells, the graphic and the split.", + "workspace.usage.shareImage.footer": "Sign-off", + "workspace.usage.shareImage.footerCard": "Inside the card", + "workspace.usage.shareImage.footerCanvas": "On the background", + "workspace.usage.shareImage.exportFailed": "Could not complete the image action. Please try again.", + "workspace.usage.shareImage.calendarCaption": "Past 53 weeks", + "workspace.usage.shareImage.hoursCaption": "Hour of day", + "workspace.usage.shareImage.weekCaption": "Day by hour", + "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", @@ -3273,7 +3304,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", @@ -3288,12 +3318,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", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 8417d0186..f7ec60a67 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -3255,13 +3255,44 @@ "workspace.usage.activeMembers": "活跃成员", "workspace.usage.modelsUsed": "使用模型", "workspace.usage.topModel": "主要模型", + "workspace.usage.shareImage.action": "分享用量卡片", + "workspace.usage.shareImage.dialogTitle": "分享用量卡片", + "workspace.usage.shareImage.dialogDescription": "当前区间用量的 PNG 图片", + "workspace.usage.shareImage.aspect": "画幅", + "workspace.usage.shareImage.aspectPortrait": "竖版 (4:5)", + "workspace.usage.shareImage.aspectWide": "横版 (16:9)", + "workspace.usage.shareImage.subject": "卡片主体", + "workspace.usage.shareImage.subjectPersonal": "工作区", + "workspace.usage.shareImage.subjectTeam": "工作区与成员", + "workspace.usage.shareImage.subjectTeamHint": "卡片会展示成员的名称、头像及其在本区间的用量占比。", + "workspace.usage.shareImage.theme": "主题", + "workspace.usage.shareImage.themeApp": "跟随应用", + "workspace.usage.shareImage.themeLight": "浅色", + "workspace.usage.shareImage.themeDark": "深色", + "workspace.usage.shareImage.backdrop": "背景", + "workspace.usage.shareImage.backdropNone": "无", + "workspace.usage.shareImage.content": "内容", + "workspace.usage.shareImage.showQr": "二维码", + "workspace.usage.shareImage.copyImage": "复制图片", + "workspace.usage.shareImage.copied": "图片已复制到剪贴板", + "workspace.usage.shareImage.exportPng": "导出 PNG", + "workspace.usage.shareImage.metric": "度量", + "workspace.usage.shareImage.metricCostHint": "整张卡片以美元计价:主数字、四个格、图形和占比都跟着换算。", + "workspace.usage.shareImage.footer": "签名位置", + "workspace.usage.shareImage.footerCard": "卡片内", + "workspace.usage.shareImage.footerCanvas": "背景上", + "workspace.usage.shareImage.exportFailed": "图片操作未能完成,请重试。", + "workspace.usage.shareImage.calendarCaption": "过去 53 周", + "workspace.usage.shareImage.hoursCaption": "按小时", + "workspace.usage.shareImage.weekCaption": "按天 × 小时", + "workspace.usage.shareImage.windowLit": "高亮:{{range}}", + "workspace.usage.shareImage.unknownMember": "未知成员", "workspace.usage.skyline.activeDays": "活跃天数", "workspace.usage.skyline.activeIntervals": "活跃时段", "workspace.usage.skyline.averagePerInterval": "每段平均", "workspace.usage.skyline.ascii": "ASCII 天际线", "workspace.usage.skyline.asciiCopied": "已复制 ASCII 天际线", "workspace.usage.skyline.asciiPreview": "ASCII 加载预览", - "workspace.usage.skyline.cardFailed": "无法生成用量分享卡", "workspace.usage.skyline.clickForDetails": "点击查看明细", "workspace.usage.skyline.clickHint": "点击某一天查看当日明细", "workspace.usage.skyline.copyAscii": "复制 ASCII 天际线", @@ -3273,7 +3304,6 @@ "workspace.usage.skyline.dayDetail": "当日明细", "workspace.usage.skyline.downloadAscii": "下载 ASCII 天际线", "workspace.usage.skyline.downloadBinaryStl": "下载二进制 STL (.stl)", - "workspace.usage.skyline.downloadCard": "分享用量卡片", "workspace.usage.skyline.downloadStl": "下载 STL 天际线", "workspace.usage.skyline.future": "未来", "workspace.usage.skyline.heatmap": "用量热力图", @@ -3288,12 +3318,10 @@ "workspace.usage.skyline.peakDay": "峰值日期", "workspace.usage.skyline.peakInterval": "峰值时段", "workspace.usage.skyline.peakShare": "为峰值日的 {{percent}}%", - "workspace.usage.skyline.shareCard": "分享用量卡片", "workspace.usage.skyline.stlMetalPreview": "二进制 STL 金属表面预览", "workspace.usage.skyline.subtitle": "最近 53 周的每日用量", "workspace.usage.skyline.title": "用量天际线", "workspace.usage.skyline.total": "总计", - "workspace.usage.skyline.view": "用量可视化", "workspace.usage.skyline.windowSubtitle": "最近 30 天,在最近 53 周中高亮", "workspace.usage.skyline.webSearches": "{{count}} 次联网搜索", "workspace.usage.skyline.webSearches_one": "{{count}} 次联网搜索", diff --git a/packages/components/package.json b/packages/components/package.json index 9023024de..56c68141f 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -83,7 +83,6 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@floating-ui/react": "^0.27.4", - "@fontsource/bitcount-grid-double": "^5.3.0", "@fontsource/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", "@hookform/resolvers": "^5.1.1", diff --git a/packages/components/src/components/chat-share-card.tsx b/packages/components/src/components/chat-share-card.tsx index 35503d2bb..f5e814f02 100644 --- a/packages/components/src/components/chat-share-card.tsx +++ b/packages/components/src/components/chat-share-card.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import QRCode from 'qrcode'; import { cn } from '@/lib/utils'; import { MarkdownRenderer } from '@/components/ai-gui/markdown-renderer'; -import { ensureChatShareThemeScopes } from '@/components/chat-share-theme-scope'; +import { ensureShareThemeScopes } from '@/components/share-theme-scope'; import lodyLogo from '@/assets/lody-icon.png'; export interface ChatShareCardMessage { @@ -42,7 +42,7 @@ export interface ChatShareCardProps { * Pins the card to one of the bundled Lody palettes (lody-light / vesper) * instead of following the app theme — the exported image should look the * way the user picked, not the way the app happens to be themed right now. - * Scoped variables come from `ensureChatShareThemeScopes`; `.light-scope` + * Scoped variables come from `ensureShareThemeScopes`; `.light-scope` * also opts out of any ancestor `.dark`. */ theme?: 'light' | 'dark'; @@ -237,7 +237,7 @@ export function ChatShareCard({ const framed = backdrop !== 'none'; // Injects the scoped theme rules before first paint; idempotent no-op after. - ensureChatShareThemeScopes(); + ensureShareThemeScopes(); const themeScopeClass = theme === 'light' ? 'light-scope' : theme === 'dark' ? 'dark-scope' : undefined; diff --git a/packages/components/src/components/sessions/chat-share-image-dialog.tsx b/packages/components/src/components/sessions/chat-share-image-dialog.tsx index 8854488f8..7e9f77c16 100644 --- a/packages/components/src/components/sessions/chat-share-image-dialog.tsx +++ b/packages/components/src/components/sessions/chat-share-image-dialog.tsx @@ -9,7 +9,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f import { Label } from '@/ui/label'; import { Input } from '@/ui/input'; import { Button } from '@/ui/button'; -import { copyChatShareImage, exportChatShareImage } from '@/lib/chat-share-image-export'; +import { copyShareImage, exportShareImage } from '@/lib/share-image-export'; import { Switch } from '@/ui/switch'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/ui/select'; import { @@ -162,7 +162,7 @@ export function ChatShareImageDialog({ setOperation('export'); setExportError(false); try { - await exportChatShareImage(exportRef.current, session?.title); + await exportShareImage(exportRef.current, session?.title, 'lody-conversation'); } catch { setExportError(true); } finally { @@ -180,7 +180,7 @@ export function ChatShareImageDialog({ setExportError(false); setCopied(false); try { - await copyChatShareImage(exportRef.current); + await copyShareImage(exportRef.current); setCopied(true); } catch { setExportError(true); diff --git a/packages/components/src/components/settings/AGENTS.md b/packages/components/src/components/settings/AGENTS.md index 3b7ec04f6..808b1e924 100644 --- a/packages/components/src/components/settings/AGENTS.md +++ b/packages/components/src/components/settings/AGENTS.md @@ -30,6 +30,31 @@ rolls back — is in the root [AGENTS.md](../../../../../AGENTS.md). use the default interface font so they remain readable. - The Codex reset forecast chip in the provider row must not fetch on mount and must pass `nestedInDialog` for its dialog: [../codex-reset/AGENTS.md](../codex-reset/AGENTS.md). +- The usage share card is a fixed-format report, not a second `ChatShareCard`: its two + aspects are exact pixel sizes, its period is the page's selected range, and its + headline is that range's timeline total, so page and image cannot disagree. Derive + every number through `usage-share-stats.ts`, which stamps the metric onto the stats + it derives — never pass a metric beside them — so the headline, cells, graphic + shading and both splits always read one unit. Money is formatted per slot: + `formatUsdCompact` for the headline, `formatUsdTight` for cells and legend rows; + never let `truncate` decide, because an ellipsis on a number is a wrong number. Tokens and member + anonymity are the defaults; cost substitutes for tokens rather than joining them, + and member slices carry display name and avatar only — never an email. Both share cards use the one capture pipeline in `lib/share-image-export.ts` + and the one theme pinning in `components/share-theme-scope.ts`; do not fork either. + `StatsSettingsView` keeps the entry behind the opt-in `shareCard` prop with a lazy + dialog, because the public landing reuses that view. Typography and spacing come + from the card's own `TEXT`, `PAD_X`, and `RHYTHM` constants — never a fresh + `text-[…]` or an off-grid padding. `PAD_X` binds the footer too, so every band + shares one left edge. `ASPECT_SIZE` is the whole exported image including the + backdrop, so a framed card is 48px shorter — size the layout against the framed + case, and keep every band but the headline `shrink-0` so a card that does not fit + overflows visibly instead of eating its own padding. The graphic follows the range — + hour skyline, day-by-hour grid, or the 53-week calendar, matching the Usage + screen — and every kind must fit the one `GRAPHIC_H` box so card height never + depends on range. The space beside the + headline number is empty by choice: six attempts to fill it (five brand-mark + treatments, one range chart) each either repeated a band below or read as + decoration. Leave it alone. ## Agent Roles diff --git a/packages/components/src/components/settings/stats-setting-pure.tsx b/packages/components/src/components/settings/stats-setting-pure.tsx index 2d87ecb9a..6b3870472 100644 --- a/packages/components/src/components/settings/stats-setting-pure.tsx +++ b/packages/components/src/components/settings/stats-setting-pure.tsx @@ -1,7 +1,9 @@ -import { lazy, Suspense, useMemo, type ReactNode } from 'react'; +import { lazy, Suspense, useMemo, useState, type ReactNode } from 'react'; import NumberFlow from '@number-flow/react'; import { useTranslation } from 'react-i18next'; -import { Coins, DollarSign } from 'lucide-react'; +import { Coins, DollarSign, Share2 } from 'lucide-react'; +import { Button } from '@/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/ui/tooltip'; import { formatCompactNumber, formatUsdAmount } from '@/lib/format-compact-number'; import { toIntlLocaleOrEn } from '@/lib/intl-locale'; import { cn } from '@/lib/utils'; @@ -50,6 +52,11 @@ export type StatsSettingsViewProps = { * so large totals ($23,740) fit the tile without clipping. */ costFractionDigits?: number; + /** + * Opt-in share entry. Off by default so the public landing demo neither shows + * an action it cannot perform nor pulls the capture pipeline into its bundle. + */ + shareCard?: boolean; }; const RANGE_ORDER: SettingsUsageRange[] = ['day', 'week', 'month', 'total']; @@ -62,6 +69,12 @@ const UsageCalendarVisualization = lazy(async () => { return { default: module.UsageCalendarVisualization }; }); +// snapdom + qrcode are only needed once someone opens the share dialog. +const UsageShareImageDialog = lazy(async () => { + const module = await import('./usage-share-image-dialog'); + return { default: module.UsageShareImageDialog }; +}); + export function formatTokens(value: number, locale?: string | null): string { return new Intl.NumberFormat(locale ?? 'en').format(Math.round(value)); } @@ -191,8 +204,10 @@ export function StatsSettingsView({ tintModelSeriesLabel, tintMemberSeriesLabel, costFractionDigits = 2, + shareCard = false, }: StatsSettingsViewProps) { const { t, i18n } = useTranslation(); + const [shareOpen, setShareOpen] = useState(false); const locale = toIntlLocaleOrEn(i18n.resolvedLanguage ?? i18n.language); const windowCaption = t(`workspace.usage.window.${range}.long`); const costDigits = Math.max(0, Math.min(2, costFractionDigits)); @@ -211,9 +226,39 @@ export function StatsSettingsView({

{windowCaption}

- +
+ + {shareCard && usageCalendar ? ( + + + + + {t('workspace.usage.shareImage.action')} + + ) : null} +
+ {shareCard && usageCalendar && shareOpen ? ( + + + + ) : null} + {/* KPI overview band — 2 cards with icon watermarks. */}
); diff --git a/packages/components/src/components/settings/ticket-cut-shader.tsx b/packages/components/src/components/settings/ticket-cut-shader.tsx deleted file mode 100644 index 0dc50520a..000000000 --- a/packages/components/src/components/settings/ticket-cut-shader.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import { Canvas, useFrame, useThree } from '@react-three/fiber'; -import * as THREE from 'three'; - -/** - * GLSL ticket tear. - * - * The ticket artwork is uploaded as a texture and torn in the fragment shader: - * the boundary is an fbm-displaced line (so it wanders like a real rip instead of - * a ruler-straight cut), the last few pixels are alpha-eroded with a second, much - * finer noise band to expose paper fibres, and the freshly separated edge gets a - * darkened core plus a light fibre highlight. The stub half is translated in UV - * space as the cut descends, so one draw call renders both pieces. - */ -const VERTEX_SHADER = /* glsl */ ` - varying vec2 vUv; - void main() { - vUv = uv; - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); - } -`; - -const FRAGMENT_SHADER = /* glsl */ ` - precision highp float; - - uniform sampler2D uTex; - uniform float uTearX; // nominal tear position, 0..1 across the ticket - uniform float uCut; // blade travel, 0 = untouched, 1 = fully cut - uniform float uSeparate; // how far the stub has slid away, in UV units - uniform float uAspect; // width / height, to keep noise isotropic - varying vec2 vUv; - - float hash(vec2 p) { - return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); - } - - float noise(vec2 p) { - vec2 i = floor(p); - vec2 f = fract(p); - float a = hash(i); - float b = hash(i + vec2(1.0, 0.0)); - float c = hash(i + vec2(0.0, 1.0)); - float d = hash(i + vec2(1.0, 1.0)); - vec2 u = f * f * (3.0 - 2.0 * f); - return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y; - } - - float fbm(vec2 p) { - float v = 0.0; - float a = 0.5; - for (int i = 0; i < 5; i++) { - v += a * noise(p); - p *= 2.02; - a *= 0.5; - } - return v; - } - - void main() { - vec2 uv = vUv; - - // Two octave bands: a slow wander plus a fine crinkle along the rip. - float wander = fbm(vec2(uv.y * 22.0, 3.7)) - 0.5; - float crinkle = fbm(vec2(uv.y * 130.0, 9.1)) - 0.5; - float tear = uTearX + wander * 0.017 + crinkle * 0.005; - - // The blade enters at the top (v = 1) and travels down. - float bladeY = 1.0 - uCut; - float cutRow = step(bladeY, uv.y); - - float stubSide = step(tear, uv.x); - vec2 sampleUv = uv; - sampleUv.x -= stubSide * uSeparate * cutRow; - - // Do not smear the stub texture back over the main body once it slides. - if (stubSide > 0.5 && sampleUv.x < tear - 0.0015) discard; - - vec4 texel = texture2D(uTex, sampleUv); - - float dist = abs(uv.x - tear); - - // Paper fibres: erode a sub-millimetre band with high frequency noise so the - // separated edge is ragged per-pixel rather than a clean vector boundary. - if (cutRow > 0.5 && dist < 0.0075) { - float fibre = fbm(vec2(uv.y * 220.0, uv.x * 90.0)); - float bite = 1.0 - smoothstep(0.0, 0.0075, dist); - if (fibre < bite * 0.62) discard; - } - - // Torn edge shading: a darker core with a bright fibre lip just inside it. - float core = (1.0 - smoothstep(0.0, 0.006, dist)) * cutRow; - float lip = (1.0 - smoothstep(0.004, 0.012, dist)) * cutRow; - texel.rgb = mix(texel.rgb, texel.rgb * 0.74, core * 0.85); - texel.rgb += vec3(0.10, 0.09, 0.07) * lip * 0.5; - - // Blade head glow, only while the cut is actually travelling. - if (uCut > 0.001 && uCut < 0.999) { - vec2 head = vec2((uv.x - tear) * uAspect, uv.y - bladeY); - float glow = 1.0 - smoothstep(0.0, 0.09, length(head)); - texel.rgb += vec3(0.22, 0.95, 0.38) * glow * 0.75; - - // Thin hot filament riding the seam behind the head. - float seam = (1.0 - smoothstep(0.0, 0.0035, dist)) * cutRow; - texel.rgb += vec3(0.30, 1.0, 0.45) * seam * 0.35; - } - - gl_FragColor = texel; - } -`; - -type CutPlaneProps = { - texture: THREE.Texture; - aspect: number; - playing: boolean; - durationMs: number; - separateBy: number; - onDone?: () => void; -}; - -function CutPlane({ texture, aspect, playing, durationMs, separateBy, onDone }: CutPlaneProps) { - const materialRef = useRef(null); - const startRef = useRef(null); - const doneRef = useRef(false); - const { size } = useThree(); - - const uniforms = useMemo( - () => ({ - uTex: { value: texture }, - uTearX: { value: 0.82 }, - uCut: { value: 0 }, - uSeparate: { value: 0 }, - uAspect: { value: aspect }, - }), - [aspect, texture] - ); - - useEffect(() => { - startRef.current = null; - doneRef.current = false; - if (materialRef.current) { - materialRef.current.uniforms.uCut!.value = 0; - materialRef.current.uniforms.uSeparate!.value = 0; - } - }, [playing, texture]); - - useFrame(({ clock }) => { - const material = materialRef.current; - if (!material || !playing) return; - if (startRef.current === null) startRef.current = clock.elapsedTime; - - const t = Math.min(1, (clock.elapsedTime - startRef.current) / (durationMs / 1000)); - // Blade accelerates in, then eases as it exits the bottom edge. - const cut = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2; - material.uniforms.uCut!.value = cut; - // The stub only drifts once the cut is well underway. - const sep = Math.max(0, (t - 0.55) / 0.45); - material.uniforms.uSeparate!.value = (1 - (1 - sep) ** 3) * separateBy; - - if (t >= 1 && !doneRef.current) { - doneRef.current = true; - onDone?.(); - } - }); - - // R3F's default orthographic camera maps one world unit to one pixel, so the - // plane is sized in pixels and letterboxed to preserve the ticket aspect. - const viewAspect = size.width / Math.max(1, size.height); - const planeW = viewAspect > aspect ? size.height * aspect : size.width; - const planeH = viewAspect > aspect ? size.height : size.width / aspect; - - return ( - - - - - ); -} - -export type TicketCutShaderViewProps = { - /** Ticket artwork (data URL or image URL) to tear. */ - imageUrl: string; - /** Ticket aspect ratio (width / height). */ - aspect?: number; - /** Start the cut. Resetting to false rewinds it. */ - playing?: boolean; - durationMs?: number; - /** How far the stub slides, in UV units. */ - separateBy?: number; - onDone?: () => void; - className?: string; -}; - -/** WebGL view that tears a rendered ticket with a real GLSL shader. */ -export function TicketCutShaderView({ - imageUrl, - aspect = 1200 / 630, - playing = false, - durationMs = 1100, - separateBy = 0.1, - onDone, - className, -}: TicketCutShaderViewProps) { - const [texture, setTexture] = useState(null); - - useEffect(() => { - if (!imageUrl) return undefined; - let disposed = false; - let loaded: THREE.Texture | null = null; - new THREE.TextureLoader().load(imageUrl, (tex) => { - tex.colorSpace = THREE.SRGBColorSpace; - tex.minFilter = THREE.LinearFilter; - tex.generateMipmaps = false; - if (disposed) { - tex.dispose(); - return; - } - loaded = tex; - setTexture(tex); - }); - return () => { - disposed = true; - loaded?.dispose(); - setTexture(null); - }; - }, [imageUrl]); - - return ( -
- - {texture ? ( - - ) : null} - -
- ); -} diff --git a/packages/components/src/components/settings/usage-calendar-visualization.tsx b/packages/components/src/components/settings/usage-calendar-visualization.tsx index 6bc3ac7b4..c270755ad 100644 --- a/packages/components/src/components/settings/usage-calendar-visualization.tsx +++ b/packages/components/src/components/settings/usage-calendar-visualization.tsx @@ -14,22 +14,12 @@ import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; import { Canvas, useFrame, useThree } from '@react-three/fiber'; import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; -import { - Box, - Copy, - Download, - FileText, - LoaderCircle, - MousePointerClick, - Share2, - X, -} from 'lucide-react'; +import { Box, Copy, Download, FileText, MousePointerClick, X } from 'lucide-react'; import i18next from 'i18next'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { Avatar, AvatarFallback, AvatarImage } from '@/ui/avatar'; import { Button } from '@/ui/button'; -import { Popover, PopoverContent, PopoverTrigger } from '@/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/ui/tooltip'; import { formatCompactNumber, formatUsdAmount } from '@/lib/format-compact-number'; import { toIntlLocaleOrEn } from '@/lib/intl-locale'; @@ -62,20 +52,8 @@ import { type UsageCalendarMetric, type UsageCalendarModel, } from './usage-calendar-model'; -import { createUsageShareCard, type UsageShareCardStyle } from './usage-share-card'; -import { scheduleUsageShareCardFontPreload } from './usage-share-card-fonts'; - -type UsageShareCardPreview = { - file: File; - style: UsageShareCardStyle; - url: string; -}; - // Export generation remains available in code while the settings UI focuses on the active views. const SHOW_SKYLINE_EXPORTS = false; -// Share card is hidden while its ticket art is being reworked. The renderer, the -// preview popover, and the Storybook gallery all stay wired up behind this flag. -const SHOW_SHARE_CARD = false; /** * The heatmap paints one theme token at varying alpha instead of a fixed five-step @@ -2136,12 +2114,8 @@ export function UsageCalendarVisualization({ const [collapsingDay, setCollapsingDay] = useState(null); const notifiedDayRef = useRef(null); const [metric, setMetric] = useState('tokens'); - const [shareCardStyle, setShareCardStyle] = useState('isometric'); - const [sharePopoverOpen, setSharePopoverOpen] = useState(false); - const [sharePreview, setSharePreview] = useState(null); - const [isSharePreviewLoading, setIsSharePreviewLoading] = useState(false); - // Exports and the share card are always token-denominated; only the on-screen - // views follow the metric toggle. + // Exports are always token-denominated; only the on-screen views follow the + // metric toggle. const tokenModel = useMemo(() => createUsageCalendarModel(calendar, 'tokens'), [calendar]); const costModel = useMemo(() => createUsageCalendarModel(calendar, 'costUSD'), [calendar]); const model = metric === 'tokens' ? tokenModel : costModel; @@ -2157,10 +2131,6 @@ export function UsageCalendarVisualization({ const ascii = useMemo(() => createUsageSkylineAscii(tokenModel), [tokenModel]); const stem = fileStem(workspaceName || 'lody-usage'); - useEffect(() => { - scheduleUsageShareCardFontPreload(); - }, []); - const selectDay = useCallback( (day: UsageSelectedDay | null) => { setSelectedDay(day); @@ -2209,66 +2179,6 @@ export function UsageCalendarVisualization({ ); }; - const createCard = useCallback(async () => { - const card = await createUsageShareCard( - tokenModel, - workspaceName || t('workspace.usage.title'), - `${formatTokens(tokenModel.totalValue)} ${t('workspace.usage.tokens')}`, - shareCardStyle - ); - const cardKind = shareCardStyle === 'flat' ? 'heatmap' : 'skyline'; - return new File([card], `${stem}-usage-${cardKind}.png`, { type: 'image/png' }); - }, [shareCardStyle, stem, t, tokenModel, workspaceName]); - - useEffect(() => { - let cancelled = false; - if (sharePopoverOpen) { - setIsSharePreviewLoading(true); - setSharePreview(null); - - void createCard() - .then((file) => { - const url = URL.createObjectURL(file); - if (cancelled) { - URL.revokeObjectURL(url); - return; - } - setSharePreview({ file, style: shareCardStyle, url }); - }) - .catch(() => { - if (!cancelled) toast.error(t('workspace.usage.skyline.cardFailed')); - }) - .finally(() => { - if (!cancelled) setIsSharePreviewLoading(false); - }); - } - - return () => { - cancelled = true; - }; - }, [createCard, shareCardStyle, sharePopoverOpen, t]); - - useEffect(() => { - const url = sharePreview?.url; - return () => { - if (url !== undefined) URL.revokeObjectURL(url); - }; - }, [sharePreview]); - - const shareCard = async () => { - try { - const file = sharePreview?.style === shareCardStyle ? sharePreview.file : await createCard(); - if (navigator.canShare?.({ files: [file] })) { - await navigator.share({ files: [file], title: t('workspace.usage.skyline.shareCard') }); - } else { - downloadBlob(file, file.name); - } - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') return; - toast.error(t('workspace.usage.skyline.cardFailed')); - } - }; - return (
@@ -2294,65 +2204,6 @@ export function UsageCalendarVisualization({ { value: 'costUSD', label: t('workspace.usage.cost') }, ]} /> - {SHOW_SHARE_CARD ? ( - - - - - - - - {t('workspace.usage.skyline.shareCard')} - - -
-

- {t('workspace.usage.skyline.shareCard')} -

- -
-
-
- {sharePreview ? ( - {t('workspace.usage.skyline.shareCard')} - ) : null} - {isSharePreviewLoading ? ( - - ) : null} -
-
- -
-
-
-
- ) : null}
diff --git a/packages/components/src/components/settings/usage-share-card-export.ts b/packages/components/src/components/settings/usage-share-card-export.ts deleted file mode 100644 index f4dbe7256..000000000 --- a/packages/components/src/components/settings/usage-share-card-export.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { - renderUsageShareCardFrame, - resolveShareCardConfig, - type UsageShareCardFrameInput, -} from './usage-share-card'; - -export type UsageShareCardVideoOptions = { - /** Total clip length including the hold on the final frame. */ - durationMs?: number; - fps?: number; - /** Fraction of the clip spent animating before holding the final frame. */ - animateFraction?: number; - bitrate?: number; - onProgress?: (fraction: number) => void; -}; - -export type UsageShareCardVideo = { - blob: Blob; - extension: 'mp4' | 'webm'; - mimeType: string; -}; - -type FrameCanvas = { - canvas: HTMLCanvasElement; - context: CanvasRenderingContext2D; - width: number; - height: number; -}; - -function createFrameCanvas(input: UsageShareCardFrameInput): FrameCanvas { - const cfg = resolveShareCardConfig(input.config); - // Keep dimensions even so H.264 encoders accept them. - const width = cfg.width - (cfg.width % 2); - const height = cfg.height - (cfg.height % 2); - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - const context = canvas.getContext('2d'); - if (!context) throw new Error('Canvas rendering is unavailable'); - return { canvas, context, width, height }; -} - -function progressAt(fraction: number, animateFraction: number): number { - if (animateFraction <= 0) return 1; - return Math.min(1, fraction / animateFraction); -} - -/** - * Encode the animated share card to an MP4 via WebCodecs. Resolves `null` when - * WebCodecs or the mp4 muxer is unavailable, so callers can fall back. - */ -async function encodeWithWebCodecs( - frame: FrameCanvas, - input: UsageShareCardFrameInput, - options: Required>, - onProgress?: (fraction: number) => void -): Promise { - if (typeof window === 'undefined' || typeof window.VideoEncoder === 'undefined') return null; - - let muxerModule: typeof import('mp4-muxer'); - try { - muxerModule = await import('mp4-muxer'); - } catch { - return null; - } - const { Muxer, ArrayBufferTarget } = muxerModule; - - const muxer = new Muxer({ - target: new ArrayBufferTarget(), - video: { codec: 'avc', width: frame.width, height: frame.height }, - fastStart: 'in-memory', - }); - - const encoder = new window.VideoEncoder({ - output: (chunk, meta) => muxer.addVideoChunk(chunk, meta), - error: (error) => { - throw error; - }, - }); - encoder.configure({ - codec: 'avc1.4d0028', - width: frame.width, - height: frame.height, - bitrate: options.bitrate, - framerate: options.fps, - }); - - const totalFrames = Math.max(1, Math.round((options.durationMs / 1000) * options.fps)); - const frameDurationUs = Math.round(1_000_000 / options.fps); - - for (let index = 0; index < totalFrames; index += 1) { - const fraction = totalFrames > 1 ? index / (totalFrames - 1) : 1; - renderUsageShareCardFrame( - frame.context, - input, - progressAt(fraction, options.animateFraction) - ); - const videoFrame = new VideoFrame(frame.canvas, { - timestamp: index * frameDurationUs, - duration: frameDurationUs, - }); - encoder.encode(videoFrame, { keyFrame: index % options.fps === 0 }); - videoFrame.close(); - onProgress?.(fraction * 0.95); - // Yield so the encoder queue drains and the UI stays responsive. - if (index % 8 === 7) await new Promise((resolve) => setTimeout(resolve, 0)); - } - - await encoder.flush(); - encoder.close(); - muxer.finalize(); - onProgress?.(1); - - const { buffer } = muxer.target as InstanceType; - return { - blob: new Blob([buffer], { type: 'video/mp4' }), - extension: 'mp4', - mimeType: 'video/mp4', - }; -} - -/** Fallback: record the animated canvas in real time via MediaRecorder. */ -async function encodeWithMediaRecorder( - frame: FrameCanvas, - input: UsageShareCardFrameInput, - options: Required>, - onProgress?: (fraction: number) => void -): Promise { - const candidates = ['video/mp4;codecs=avc1', 'video/mp4', 'video/webm;codecs=vp9', 'video/webm']; - const mimeType = - candidates.find( - (candidate) => - typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(candidate) - ) ?? 'video/webm'; - - const stream = frame.canvas.captureStream(options.fps); - const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: options.bitrate }); - const chunks: BlobPart[] = []; - recorder.ondataavailable = (event) => { - if (event.data.size > 0) chunks.push(event.data); - }; - - const finished = new Promise((resolve) => { - recorder.onstop = () => resolve(); - }); - - recorder.start(); - const start = performance.now(); - await new Promise((resolve) => { - const tick = () => { - const elapsed = performance.now() - start; - const fraction = Math.min(1, elapsed / options.durationMs); - renderUsageShareCardFrame( - frame.context, - input, - progressAt(fraction, options.animateFraction) - ); - onProgress?.(fraction * 0.95); - if (fraction >= 1) { - resolve(); - return; - } - requestAnimationFrame(tick); - }; - requestAnimationFrame(tick); - }); - recorder.stop(); - await finished; - onProgress?.(1); - - const extension = mimeType.startsWith('video/mp4') ? 'mp4' : 'webm'; - return { blob: new Blob(chunks, { type: mimeType }), extension, mimeType }; -} - -/** - * Export the animated usage share card as a short video clip. Prefers an MP4 - * encoded with WebCodecs; falls back to MediaRecorder (mp4 or webm) when - * WebCodecs / the mp4 muxer is unavailable. - */ -export async function exportUsageShareCardVideo( - input: UsageShareCardFrameInput, - options: UsageShareCardVideoOptions = {} -): Promise { - const resolved = { - durationMs: options.durationMs ?? 3000, - fps: options.fps ?? 30, - animateFraction: options.animateFraction ?? 0.82, - bitrate: options.bitrate ?? 8_000_000, - }; - const frame = createFrameCanvas(input); - const viaWebCodecs = await encodeWithWebCodecs(frame, input, resolved, options.onProgress); - if (viaWebCodecs) return viaWebCodecs; - return await encodeWithMediaRecorder(frame, input, resolved, options.onProgress); -} diff --git a/packages/components/src/components/settings/usage-share-card-fonts.ts b/packages/components/src/components/settings/usage-share-card-fonts.ts deleted file mode 100644 index 5d1068505..000000000 --- a/packages/components/src/components/settings/usage-share-card-fonts.ts +++ /dev/null @@ -1,73 +0,0 @@ -const BITCOUNT_FONT_LOAD_SPEC = '600 42px "Bitcount Grid Double"'; -const FONT_READY_TIMEOUT_MS = 900; - -let fontLoadPromise: Promise | null = null; -let idlePreloadHandle: number | null = null; - -function hasDocumentFonts(): boolean { - return typeof document !== 'undefined' && 'fonts' in document; -} - -async function loadUsageShareCardFonts(): Promise { - if (typeof document === 'undefined') return false; - - try { - // This stays in its own Vite chunk. The Usage screen only requests it after idle time or a share action. - await import('@fontsource/bitcount-grid-double/600.css'); - - if (!hasDocumentFonts()) return true; - await document.fonts.load(BITCOUNT_FONT_LOAD_SPEC); - return document.fonts.check(BITCOUNT_FONT_LOAD_SPEC); - } catch { - // Canvas retains the configured fallback stack when a font chunk, CSP, or FontFace API is unavailable. - return false; - } -} - -/** Start the single, cacheable Bitcount font request without making the caller wait. */ -export function preloadUsageShareCardFonts(): Promise { - fontLoadPromise ??= loadUsageShareCardFonts(); - return fontLoadPromise; -} - -/** - * Begin loading after the Usage UI settles. A share action can still call - * {@link waitForUsageShareCardFonts} and promote this work to the foreground. - */ -export function scheduleUsageShareCardFontPreload(): void { - if (typeof window === 'undefined' || fontLoadPromise || idlePreloadHandle !== null) return; - - const start = () => { - idlePreloadHandle = null; - void preloadUsageShareCardFonts(); - }; - - if (typeof window.requestIdleCallback === 'function') { - idlePreloadHandle = window.requestIdleCallback(start, { timeout: 2_000 }); - } else { - idlePreloadHandle = window.setTimeout(start, 300); - } -} - -/** - * Prefer Bitcount for a generated card, but never hold the preview or export - * hostage to a slow connection. The underlying request continues for future cards. - */ -export async function waitForUsageShareCardFonts( - timeoutMs = FONT_READY_TIMEOUT_MS -): Promise { - if (typeof window === 'undefined') return false; - - const fontPromise = preloadUsageShareCardFonts(); - return await new Promise((resolve) => { - let settled = false; - const finish = (loaded: boolean) => { - if (settled) return; - settled = true; - window.clearTimeout(timeout); - resolve(loaded); - }; - const timeout = window.setTimeout(() => finish(false), timeoutMs); - void fontPromise.then(finish, () => finish(false)); - }); -} diff --git a/packages/components/src/components/settings/usage-share-card.ts b/packages/components/src/components/settings/usage-share-card.ts deleted file mode 100644 index a5e09e80d..000000000 --- a/packages/components/src/components/settings/usage-share-card.ts +++ /dev/null @@ -1,1446 +0,0 @@ -import { - parseLodyLogoContours, - USAGE_CALENDAR_COLUMNS, - USAGE_CALENDAR_ROWS, - type UsageCalendarCell, - type UsageCalendarModel, -} from './usage-calendar-model'; -import { waitForUsageShareCardFonts } from './usage-share-card-fonts'; - -export type UsageShareCardStyle = 'flat' | 'isometric'; - -export type LodyMarkStyle = 'sticker' | 'outline' | 'plain'; - -/** The centerpiece graphic. Swap this to recombine the same data into a different story. */ -export type UsageShareCardHeroGraphic = 'trend' | 'heatmap' | 'bars'; - -/** Special-effect treatment for the hero number. `vhs` is a canvas-native CRT/VHS glitch. */ -export type UsageShareCardHeroFx = 'none' | 'vhs'; - -/** Metallic foil stamping applied to the number, chart, sticker and frame. All light-friendly. */ -export type UsageShareCardFoil = 'none' | 'silver' | 'platinum' | 'champagne'; - -/** Ordered light→dark→light stops that read as a reflective metal band. */ -const FOIL_PALETTES: Record, string[]> = { - silver: ['#ffffff', '#a7afba', '#eef1f5', '#6d7783', '#d5dae1', '#8b94a0', '#fbfcfd'], - platinum: ['#eef2f6', '#98a1af', '#dfe4eb', '#646e7d', '#c3cbd5', '#828c9b', '#eef2f6'], - champagne: ['#fdf6e6', '#cda863', '#f4e8c6', '#9c7d3a', '#e4cf99', '#b8934e', '#fdf6e6'], -}; - -/** - * Every visual knob of the usage share card, flattened so Storybook can expose - * each as an individual control (range / color / select). Callers pass a partial - * override; {@link resolveShareCardConfig} fills the rest from - * {@link DEFAULT_USAGE_SHARE_CARD_CONFIG}. Tune values in Storybook, copy the - * emitted JSON, and paste it back here to lock a new default in. - */ -export type UsageShareCardConfig = { - // Canvas + card silhouette - width: number; - height: number; - marginX: number; - marginY: number; - cornerRadius: number; - - // Foil stamping - foil: UsageShareCardFoil; - - // Paper + frame - paperTop: string; - paperBottom: string; - edgeColor: string; - frameInset: number; - showFrame: boolean; - inkColor: string; - mutedInk: string; - accent: string; - accentSoft: string; - showGrain: boolean; - grainOpacity: number; - shadowOpacity: number; - - // Die-cut: perforated tear + stub + optional stamp scallop - showStub: boolean; - tearX: number; // fraction of interior width - tearInset: number; - perfRadius: number; - perfSpacing: number; - notchRadius: number; - scallopEdge: boolean; - scallopRadius: number; - scallopSpacing: number; - - // Typography - fontSans: string; - fontMono: string; - fontDisplay: string; // the workspace-name title face - - // Main content layout - contentPadX: number; - kickerY: number; - kickerText: string; - titleY: number; - heroY: number; - heroSize: number; - unitLabel: string; - subtitleY: number; - heroFx: UsageShareCardHeroFx; - - // Hero graphic - showTrend: boolean; // when false, no centerpiece graphic is drawn at all - heroGraphic: UsageShareCardHeroGraphic; - chartTop: number; - chartHeight: number; - trendLineWidth: number; - trendDotRadius: number; - trendFill: boolean; - showTrendDelta: boolean; - - // Highlight-moment data grid - showStats: boolean; - statsY: number; - - // Heatmap strip - showHeatmap: boolean; - heatmapTop: number; - heatmapHeight: number; - - // Lody sticker on the main body - showMark: boolean; - markStyle: LodyMarkStyle; - markFx: UsageShareCardHeroFx; - markX: number; // center; if < 0 it is measured from the tear line - markY: number; - markSize: number; - markRotation: number; // degrees - markOpacity: number; - markFill: string; - markStroke: string; - markStrokeWidth: number; - - // Stub - showStubStamp: boolean; - stubStampSize: number; - showBarcode: boolean; - serial: string; // empty = auto from usage total -}; - -export const DEFAULT_USAGE_SHARE_CARD_CONFIG: UsageShareCardConfig = { - width: 1200, - height: 630, - marginX: 24, - marginY: 40, - cornerRadius: 30, - - foil: 'silver', - - paperTop: '#f5f7fa', - paperBottom: '#e8ecf2', - edgeColor: '#cfd6df', - frameInset: 18, - showFrame: false, - inkColor: '#39404a', - mutedInk: '#98a1ad', - accent: '#8b93a0', - accentSoft: '#a4bbea', - showGrain: true, - grainOpacity: 0.05, - shadowOpacity: 0.16, - - showStub: true, - tearX: 0.81, - tearInset: 13, - perfRadius: 3.5, - perfSpacing: 21, - notchRadius: 22, - scallopEdge: false, - scallopRadius: 8, - scallopSpacing: 25, - - fontSans: 'Inter, ui-sans-serif, system-ui, sans-serif', - fontMono: '"JetBrains Mono", ui-monospace, monospace', - fontDisplay: '"Bitcount Grid Double", "Bricolage Grotesque", ui-sans-serif, sans-serif', - - contentPadX: 36, - kickerY: 92, - kickerText: 'LODY · USAGE PASS', - titleY: 142, - heroY: 236, - heroSize: 87, - unitLabel: 'TOKENS', - subtitleY: 267, - heroFx: 'none', - - showTrend: true, - heroGraphic: 'bars', - chartTop: 300, - chartHeight: 150, - trendLineWidth: 4, - trendDotRadius: 6, - trendFill: false, - showTrendDelta: true, - - showStats: true, - statsY: 478, - - showHeatmap: true, - heatmapTop: 548, - heatmapHeight: 69, - - showMark: true, - markStyle: 'sticker', - markFx: 'none', - markX: -93, - markY: 108, - markSize: 78, - markRotation: -9, - markOpacity: 1, - markFill: '#262620', - markStroke: '#ffffff', - markStrokeWidth: 10, - - showStubStamp: true, - stubStampSize: 40, - showBarcode: true, - serial: '', -}; - -export function resolveShareCardConfig( - overrides?: Partial -): UsageShareCardConfig { - return { ...DEFAULT_USAGE_SHARE_CARD_CONFIG, ...(overrides ?? {}) }; -} - -export type UsageShareCardInsights = { - weeklyTotals: number[]; - peakCell: UsageCalendarCell | null; - dailyAverage: number; - trendDelta: number | null; -}; - -// Blue ramp matching the Usage screen's `--chart-1`, at the same luminance steps -// the previous green ramp used so the printed density still reads the same. -const HEATMAP_LEVEL_COLORS = ['transparent', '#c6d4f1', '#7698db', '#2f5ebc', '#1a3b89']; - -/** Deterministic PRNG so the paper grain never flickers between renders. */ -function mulberry32(seed: number): () => number { - let state = seed >>> 0; - return () => { - state = (state + 0x6d2b79f5) >>> 0; - let t = state; - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -export function formatUsageCompact(value: number): string { - const abs = Math.abs(value); - if (abs >= 1_000_000) return `${(value / 1_000_000).toFixed(abs >= 100_000_000 ? 0 : 1)}M`; - if (abs >= 1_000) return `${(value / 1_000).toFixed(abs >= 100_000 ? 0 : 1)}K`; - return String(Math.round(value)); -} - -export function computeUsageShareInsights(model: UsageCalendarModel): UsageShareCardInsights { - const weeklyTotals = model.weeks.map((week) => - week.reduce((sum, cell) => (cell.isFuture ? sum : sum + cell.value), 0) - ); - const completed = model.cells.filter((cell) => !cell.isFuture); - const peakCell = completed.reduce( - (peak, cell) => (!peak || cell.value > peak.value ? cell : peak), - null - ); - const dailyAverage = completed.length > 0 ? model.totalValue / completed.length : 0; - - const active = weeklyTotals.filter((_, index) => - model.weeks[index]!.some((cell) => !cell.isFuture) - ); - const window = Math.min(4, Math.floor(active.length / 2)); - let trendDelta: number | null = null; - if (window > 0) { - const recent = active.slice(active.length - window); - const prior = active.slice(active.length - window * 2, active.length - window); - const recentAvg = recent.reduce((a, b) => a + b, 0) / window; - const priorAvg = prior.reduce((a, b) => a + b, 0) / window; - if (priorAvg > 0) trendDelta = (recentAvg - priorAvg) / priorAvg; - } - - return { weeklyTotals, peakCell, dailyAverage, trendDelta }; -} - -function easeOutCubic(t: number): number { - const clamped = Math.min(1, Math.max(0, t)); - return 1 - (1 - clamped) ** 3; -} - -// --- Lody mark ------------------------------------------------------------ - -const lodyContours = parseLodyLogoContours(); -const lodyBounds = (() => { - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (const contour of lodyContours) { - for (const [x, y] of contour) { - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x); - maxY = Math.max(maxY, y); - } - } - return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY }; -})(); - -function traceLodyPath(context: CanvasRenderingContext2D, scale: number) { - const centerX = (lodyBounds.minX + lodyBounds.maxX) / 2; - const centerY = (lodyBounds.minY + lodyBounds.maxY) / 2; - context.beginPath(); - for (const contour of lodyContours) { - const [first, ...rest] = contour; - if (!first) continue; - context.moveTo((first[0] - centerX) * scale, (first[1] - centerY) * scale); - for (const point of rest) { - context.lineTo((point[0] - centerX) * scale, (point[1] - centerY) * scale); - } - context.closePath(); - } -} - -/** Draw the Lody glyph as a die-cut outline sticker centered on (x, y). */ -export function drawLodyMark( - context: CanvasRenderingContext2D, - x: number, - y: number, - size: number, - options: { - rotation?: number; - opacity?: number; - style?: LodyMarkStyle; - fill?: string; - stroke?: string; - strokeWidth?: number; - foil?: string[] | null; - fx?: UsageShareCardHeroFx; - progress?: number; - sizeMultiplier?: number; - } = {} -) { - const { - rotation = 0, - opacity = 1, - style = 'sticker', - stroke = '#ffffff', - strokeWidth = 8, - foil = null, - fx = 'none', - progress = 1, - sizeMultiplier = 1, - } = options; - const adjustedSize = size * sizeMultiplier; - const scale = adjustedSize / lodyBounds.height; - const half = adjustedSize / 2; - // Foil is built in the mark's local (post-translate/rotate) space. - const fill = foil - ? makeFoil(context, -half, -half, adjustedSize, adjustedSize, foil) - : (options.fill ?? '#262620'); - - context.save(); - context.globalAlpha *= opacity; - context.translate(x, y); - context.rotate((rotation * Math.PI) / 180); - context.lineJoin = 'round'; - context.lineCap = 'round'; - - if (fx === 'vhs') { - const markWidth = lodyBounds.width * scale; - const markHeight = lodyBounds.height * scale; - const aberration = 2.4 + Math.sin(progress * 34) * 1.3; - // Keep the die-cut paper halo so the glitchy mark still reads as a sticker. - context.save(); - context.shadowColor = 'rgba(28,26,20,0.22)'; - context.shadowBlur = adjustedSize * 0.14; - context.shadowOffsetY = adjustedSize * 0.05; - traceLodyPath(context, scale); - context.lineWidth = strokeWidth; - context.strokeStyle = stroke; - context.stroke(); - context.restore(); - // RGB channel split. - context.save(); - context.globalCompositeOperation = 'multiply'; - context.save(); - context.translate(-aberration, 0); - traceLodyPath(context, scale); - context.fillStyle = '#ff2b2b'; - context.fill('evenodd'); - context.restore(); - context.save(); - context.translate(aberration, 0); - traceLodyPath(context, scale); - context.fillStyle = '#12e8ff'; - context.fill('evenodd'); - context.restore(); - context.globalAlpha = 0.85; - traceLodyPath(context, scale); - context.fillStyle = '#1b1a24'; - context.fill('evenodd'); - context.restore(); - // Scanlines over the mark box. - context.save(); - context.beginPath(); - context.rect( - -markWidth / 2 - aberration, - -markHeight / 2, - markWidth + aberration * 2, - markHeight - ); - context.clip(); - context.fillStyle = 'rgba(255,255,255,0.4)'; - for (let ly = -markHeight / 2; ly < markHeight / 2; ly += 3) { - context.fillRect(-markWidth / 2 - aberration, ly, markWidth + aberration * 2, 1.2); - } - context.restore(); - } else if (style === 'sticker') { - // A thick same-color-as-paper stroke traces the glyph, producing the white - // die-cut border; the fill then sits inside it. - context.save(); - context.shadowColor = 'rgba(28,26,20,0.22)'; - context.shadowBlur = adjustedSize * 0.14; - context.shadowOffsetY = adjustedSize * 0.05; - traceLodyPath(context, scale); - context.lineWidth = strokeWidth; - context.strokeStyle = stroke; - context.stroke(); - context.restore(); - traceLodyPath(context, scale); - context.fillStyle = fill; - context.fill('evenodd'); - } else if (style === 'outline') { - traceLodyPath(context, scale); - context.lineWidth = strokeWidth; - context.strokeStyle = stroke; - context.stroke(); - } else { - traceLodyPath(context, scale); - context.fillStyle = fill; - context.fill('evenodd'); - } - context.restore(); -} - -// --- Geometry helpers ----------------------------------------------------- - -function roundedRectPath( - context: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number, - radius: number -) { - const r = Math.min(radius, width / 2, height / 2); - context.beginPath(); - context.moveTo(x + r, y); - context.arcTo(x + width, y, x + width, y + height, r); - context.arcTo(x + width, y + height, x, y + height, r); - context.arcTo(x, y + height, x, y, r); - context.arcTo(x, y, x + width, y, r); - context.closePath(); -} - -function punchHole(context: CanvasRenderingContext2D, x: number, y: number, radius: number) { - context.beginPath(); - context.arc(x, y, radius, 0, Math.PI * 2); - context.fill(); -} - -/** A decorative, deterministic QR-style block with three finder eyes and rounded modules. */ -function drawFauxQr( - context: CanvasRenderingContext2D, - x: number, - y: number, - size: number, - seed: number, - foreground: string, - background: string -) { - const modules = 21; - const quiet = 1; - const cell = size / (modules + quiet * 2); - const random = mulberry32(seed >>> 0); - const inFinder = (row: number, col: number) => - (row < 7 && col < 7) || (row < 7 && col >= modules - 7) || (row >= modules - 7 && col < 7); - const px = (col: number) => x + (col + quiet) * cell; - const py = (row: number) => y + (row + quiet) * cell; - - const inset = cell * 0.08; - const finders: Array<[number, number]> = [ - [0, 0], - [0, modules - 7], - [modules - 7, 0], - ]; - context.save(); - - // Data modules + finder outer rings — one accumulated path, one fill. - context.beginPath(); - for (let row = 0; row < modules; row += 1) { - for (let col = 0; col < modules; col += 1) { - if (inFinder(row, col) || random() < 0.52) continue; - context.rect(px(col) + inset, py(row) + inset, cell - inset * 2, cell - inset * 2); - } - } - for (const [row, col] of finders) context.rect(px(col), py(row), cell * 7, cell * 7); - context.fillStyle = foreground; - context.fill(); - - // Finder inner gaps (background) then centres (foreground). - context.beginPath(); - for (const [row, col] of finders) - context.rect(px(col) + cell, py(row) + cell, cell * 5, cell * 5); - context.fillStyle = background; - context.fill(); - - context.beginPath(); - for (const [row, col] of finders) - context.rect(px(col) + cell * 2, py(row) + cell * 2, cell * 3, cell * 3); - context.fillStyle = foreground; - context.fill(); - - context.restore(); -} - -/** Append a smooth Catmull-Rom spline through the given points to the path. */ -function tracedSpline(context: CanvasRenderingContext2D, points: Array<[number, number]>) { - if (points.length === 0) return; - context.moveTo(points[0]![0], points[0]![1]); - for (let i = 0; i < points.length - 1; i += 1) { - const p0 = points[i - 1] ?? points[i]!; - const p1 = points[i]!; - const p2 = points[i + 1]!; - const p3 = points[i + 2] ?? p2; - context.bezierCurveTo( - p1[0] + (p2[0] - p0[0]) / 6, - p1[1] + (p2[1] - p0[1]) / 6, - p2[0] - (p3[0] - p1[0]) / 6, - p2[1] - (p3[1] - p1[1]) / 6, - p2[0], - p2[1] - ); - } -} - -function setLetterSpacing(context: CanvasRenderingContext2D, value: string) { - const mutable = context as CanvasRenderingContext2D & { letterSpacing?: string }; - if ('letterSpacing' in mutable) mutable.letterSpacing = value; -} - -/** A diagonal metallic gradient across the given box, for foil-stamped fills/strokes. */ -function makeFoil( - context: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number, - palette: string[], - angle = -0.32 -): CanvasGradient { - const cx = x + width / 2; - const cy = y + height / 2; - const reach = Math.max(width, height) / 2; - const dx = Math.cos(angle) * reach; - const dy = Math.sin(angle) * reach; - const gradient = context.createLinearGradient(cx - dx, cy - dy, cx + dx, cy + dy); - palette.forEach((stop, index) => gradient.addColorStop(index / (palette.length - 1), stop)); - return gradient; -} - -function foilPalette(cfg: UsageShareCardConfig): string[] | null { - return cfg.foil === 'none' ? null : FOIL_PALETTES[cfg.foil]; -} - -/** A translucent diagonal highlight that sweeps across the card as `progress` advances. */ -function drawSheen( - context: CanvasRenderingContext2D, - geo: CardGeometry, - cfg: UsageShareCardConfig, - progress: number -) { - context.save(); - roundedRectPath(context, geo.left, geo.top, geo.width, geo.height, cfg.cornerRadius); - context.clip(); - const position = 0.12 + 0.76 * easeOutCubic(progress); - const cx = geo.left + geo.width * position; - const band = geo.width * 0.16; - const gradient = context.createLinearGradient(cx - band, geo.top, cx + band, geo.bottom); - gradient.addColorStop(0, 'rgba(255,255,255,0)'); - gradient.addColorStop(0.5, 'rgba(255,255,255,0.4)'); - gradient.addColorStop(1, 'rgba(255,255,255,0)'); - context.fillStyle = gradient; - context.fillRect(geo.left, geo.top, geo.width, geo.height); - context.restore(); -} - -// --- Card layers ---------------------------------------------------------- - -type CardGeometry = { - left: number; - top: number; - right: number; - bottom: number; - width: number; - height: number; - tearPx: number; - contentLeft: number; - contentRight: number; -}; - -function computeGeometry(cfg: UsageShareCardConfig): CardGeometry { - const left = cfg.marginX; - const top = cfg.marginY; - const right = cfg.width - cfg.marginX; - const bottom = cfg.height - cfg.marginY; - const width = right - left; - const tearPx = cfg.showStub ? left + cfg.tearX * width : right; - return { - left, - top, - right, - bottom, - width, - height: bottom - top, - tearPx, - contentLeft: left + cfg.contentPadX, - contentRight: tearPx - cfg.contentPadX, - }; -} - -function drawPaper( - context: CanvasRenderingContext2D, - cfg: UsageShareCardConfig, - geo: CardGeometry -) { - // Soft drop shadow under the whole ticket. - context.save(); - context.shadowColor = `rgba(30,26,18,${cfg.shadowOpacity})`; - context.shadowBlur = 34; - context.shadowOffsetY = 16; - roundedRectPath(context, geo.left, geo.top, geo.width, geo.height, cfg.cornerRadius); - context.fillStyle = cfg.paperTop; - context.fill(); - context.restore(); - - // Paper gradient. - roundedRectPath(context, geo.left, geo.top, geo.width, geo.height, cfg.cornerRadius); - const gradient = context.createLinearGradient(0, geo.top, 0, geo.bottom); - gradient.addColorStop(0, cfg.paperTop); - gradient.addColorStop(1, cfg.paperBottom); - context.fillStyle = gradient; - context.fill(); - - if (cfg.showGrain) { - context.save(); - roundedRectPath(context, geo.left, geo.top, geo.width, geo.height, cfg.cornerRadius); - context.clip(); - const random = mulberry32(0x9e3779b9); - context.fillStyle = `rgba(60,52,36,${cfg.grainOpacity})`; - const count = Math.round((geo.width * geo.height) / 1400); - for (let i = 0; i < count; i += 1) { - const gx = geo.left + random() * geo.width; - const gy = geo.top + random() * geo.height; - context.fillRect(gx, gy, 1, 1); - } - context.restore(); - } - - if (cfg.showFrame) { - const inset = cfg.frameInset; - roundedRectPath( - context, - geo.left + inset, - geo.top + inset, - geo.width - inset * 2, - geo.height - inset * 2, - Math.max(4, cfg.cornerRadius - inset) - ); - context.strokeStyle = cfg.edgeColor; - context.lineWidth = 1.5; - context.stroke(); - } -} - -function drawKicker( - context: CanvasRenderingContext2D, - cfg: UsageShareCardConfig, - geo: CardGeometry -) { - context.save(); - context.fillStyle = cfg.mutedInk; - context.font = `600 15px ${cfg.fontSans}`; - setLetterSpacing(context, '3px'); - context.fillText(cfg.kickerText.toUpperCase(), geo.contentLeft, cfg.kickerY); - setLetterSpacing(context, '0px'); - context.restore(); -} - -function drawTitle( - context: CanvasRenderingContext2D, - cfg: UsageShareCardConfig, - geo: CardGeometry, - workspaceName: string -) { - context.save(); - context.fillStyle = cfg.inkColor; - context.font = `600 42px ${cfg.fontDisplay}`; - const maxWidth = geo.contentRight - geo.contentLeft - (cfg.showMark ? cfg.markSize + 12 : 0); - let label = workspaceName; - while (label.length > 4 && context.measureText(label).width > maxWidth) { - label = label.slice(0, -1); - } - if (label !== workspaceName) label = `${label.trimEnd()}…`; - context.fillText(label, geo.contentLeft, cfg.titleY); - context.restore(); -} - -/** Canvas-native VHS/CRT treatment for a line of text: RGB split + scanlines + a tape tear. */ -function drawVhsText( - context: CanvasRenderingContext2D, - text: string, - x: number, - baselineY: number, - size: number, - width: number, - progress: number -) { - const top = baselineY - size; - const boxHeight = size * 1.16; - const aberration = 3.2 + Math.sin(progress * 34) * 1.6; - const jitter = Math.sin(progress * 51) * 1.4; - const bx = x + jitter; - const boxLeft = x - aberration - 4; - const boxWidth = width + aberration * 2 + 8; - - // RGB channel misalignment; multiply keeps the fringes legible on light paper. - context.save(); - context.globalCompositeOperation = 'multiply'; - context.fillStyle = '#ff2b2b'; - context.fillText(text, bx - aberration, baselineY); - context.fillStyle = '#12e8ff'; - context.fillText(text, bx + aberration, baselineY); - context.globalAlpha = 0.85; - context.fillStyle = '#1b1a24'; - context.fillText(text, bx, baselineY); - context.restore(); - - // Scanlines + a travelling tape-tear, clipped to the readout box. - context.save(); - context.beginPath(); - context.rect(boxLeft, top - 4, boxWidth, boxHeight + 8); - context.clip(); - context.fillStyle = 'rgba(255,255,255,0.42)'; - for (let ly = top; ly < top + boxHeight; ly += 3) { - context.fillRect(boxLeft, ly, boxWidth, 1.3); - } - const tearY = top + ((progress * 2) % 1) * boxHeight; - context.fillStyle = 'rgba(255,255,255,0.7)'; - context.fillRect(boxLeft, tearY, boxWidth, 2.4); - context.restore(); -} - -function drawHero( - context: CanvasRenderingContext2D, - cfg: UsageShareCardConfig, - geo: CardGeometry, - model: UsageCalendarModel, - insights: UsageShareCardInsights, - subtitle: string, - progress: number -) { - const shown = model.totalValue * easeOutCubic(progress); - const foil = foilPalette(cfg); - context.save(); - context.font = `700 ${cfg.heroSize}px ${cfg.fontMono}`; - const heroText = formatUsageCompact(shown); - const heroWidth = context.measureText(heroText).width; - if (cfg.heroFx === 'vhs') { - drawVhsText(context, heroText, geo.contentLeft, cfg.heroY, cfg.heroSize, heroWidth, progress); - } else { - context.fillStyle = foil - ? makeFoil(context, geo.contentLeft, cfg.heroY - cfg.heroSize, heroWidth, cfg.heroSize, foil) - : cfg.inkColor; - context.fillText(heroText, geo.contentLeft, cfg.heroY); - if (foil) { - context.lineWidth = 1; - context.strokeStyle = 'rgba(120,128,140,0.35)'; - context.strokeText(heroText, geo.contentLeft, cfg.heroY); - } - } - - context.fillStyle = cfg.mutedInk; - context.font = `600 20px ${cfg.fontSans}`; - setLetterSpacing(context, '2px'); - context.fillText(cfg.unitLabel.toUpperCase(), geo.contentLeft + heroWidth + 16, cfg.heroY - 6); - setLetterSpacing(context, '0px'); - - // Trend delta chip. - if (cfg.showTrendDelta && insights.trendDelta != null && progress > 0.55) { - const up = insights.trendDelta >= 0; - const chip = `${up ? '▲' : '▼'} ${Math.abs(Math.round(insights.trendDelta * 100))}%`; - context.font = `700 18px ${cfg.fontSans}`; - const chipWidth = context.measureText(chip).width + 26; - const chipX = geo.contentLeft + heroWidth + 20; - const chipY = cfg.heroY + 8; - context.globalAlpha = easeOutCubic((progress - 0.55) / 0.45); - context.fillStyle = up ? cfg.accent : '#c8623f'; - roundedRectPath(context, chipX, chipY, chipWidth, 30, 15); - context.fill(); - context.fillStyle = '#ffffff'; - context.fillText(chip, chipX + 13, chipY + 21); - context.globalAlpha = 1; - } - - context.fillStyle = cfg.mutedInk; - context.font = `400 16px ${cfg.fontSans}`; - context.fillText(subtitle, geo.contentLeft, cfg.subtitleY); - context.restore(); -} - -function drawTrend( - context: CanvasRenderingContext2D, - cfg: UsageShareCardConfig, - geo: CardGeometry, - insights: UsageShareCardInsights, - progress: number -) { - const totals = insights.weeklyTotals; - const maxWeekly = Math.max(1, ...totals); - const chartLeft = geo.contentLeft; - const chartRight = geo.contentRight; - const chartWidth = chartRight - chartLeft; - const top = cfg.chartTop; - const bottom = cfg.chartTop + cfg.chartHeight; - - const points: Array<[number, number]> = totals.map((value, index) => [ - chartLeft + (index / Math.max(1, totals.length - 1)) * chartWidth, - bottom - (value / maxWeekly) * cfg.chartHeight, - ]); - if (points.length < 2) return; - - const eased = easeOutCubic(progress); - const revealX = chartLeft + chartWidth * eased; - const foil = foilPalette(cfg); - const peakIndex = totals.reduce( - (best, value, index) => (value > totals[best]! ? index : best), - 0 - ); - const [peakX, peakY] = points[peakIndex]!; - - context.save(); - - // Faint dotted gridlines for a lightweight data-viz frame. - context.strokeStyle = cfg.edgeColor; - context.lineWidth = 1; - context.globalAlpha = 0.7; - context.setLineDash([2, 5]); - for (const fraction of [0.33, 0.66, 1]) { - const gy = bottom - cfg.chartHeight * fraction; - context.beginPath(); - context.moveTo(chartLeft, gy); - context.lineTo(chartRight, gy); - context.stroke(); - } - context.setLineDash([]); - context.globalAlpha = 1; - - // Reveal clip grows left→right for the draw-on animation. - context.save(); - context.beginPath(); - context.rect(chartLeft - 2, top - 48, revealX - chartLeft + 2, cfg.chartHeight + 96); - context.clip(); - - if (cfg.trendFill) { - context.beginPath(); - tracedSpline(context, points); - context.lineTo(points.at(-1)![0], bottom); - context.lineTo(points[0]![0], bottom); - context.closePath(); - const fill = context.createLinearGradient(0, top, 0, bottom); - fill.addColorStop(0, `${cfg.accent}4d`); - fill.addColorStop(0.55, `${cfg.accent}1c`); - fill.addColorStop(1, `${cfg.accent}00`); - context.fillStyle = fill; - context.fill(); - } - - // The line, with a soft glow for a more polished look. - context.beginPath(); - tracedSpline(context, points); - context.strokeStyle = foil - ? makeFoil(context, chartLeft, top, chartWidth, cfg.chartHeight, foil, 0.32) - : cfg.accent; - context.lineWidth = cfg.trendLineWidth; - context.lineJoin = 'round'; - context.lineCap = 'round'; - context.shadowColor = foil ? 'rgba(60,66,76,0.5)' : `${cfg.accent}66`; - context.shadowBlur = foil ? 5 : 9; - context.shadowOffsetY = foil ? 2 : 3; - context.stroke(); - context.restore(); - - // Peak marker: guide line + dot + value, revealed in sync with the draw-on. - if (revealX >= peakX - 1) { - context.save(); - context.globalAlpha = easeOutCubic(Math.min(1, (revealX - peakX) / 40 + 0.2)); - context.strokeStyle = cfg.edgeColor; - context.lineWidth = 1; - context.setLineDash([2, 4]); - context.beginPath(); - context.moveTo(peakX, peakY + 4); - context.lineTo(peakX, bottom); - context.stroke(); - context.setLineDash([]); - context.fillStyle = foil ? '#8b94a0' : cfg.accent; - context.beginPath(); - context.arc(peakX, peakY, 4, 0, Math.PI * 2); - context.fill(); - context.fillStyle = cfg.mutedInk; - context.font = `600 13px ${cfg.fontSans}`; - context.textAlign = 'center'; - context.fillText(formatUsageCompact(totals[peakIndex]!), peakX, peakY - 12); - context.textAlign = 'start'; - context.restore(); - } - - // Leading dot at the reveal front. - const frontIndex = (points.length - 1) * eased; - const lowIndex = Math.floor(frontIndex); - const highIndex = Math.min(points.length - 1, lowIndex + 1); - const frac = frontIndex - lowIndex; - const dotX = points[lowIndex]![0] + (points[highIndex]![0] - points[lowIndex]![0]) * frac; - const dotY = points[lowIndex]![1] + (points[highIndex]![1] - points[lowIndex]![1]) * frac; - context.fillStyle = '#ffffff'; - context.strokeStyle = foil ? '#8b94a0' : cfg.accent; - context.lineWidth = cfg.trendLineWidth; - context.beginPath(); - context.arc(dotX, dotY, cfg.trendDotRadius, 0, Math.PI * 2); - context.fill(); - context.stroke(); - context.restore(); -} - -function drawBars( - context: CanvasRenderingContext2D, - cfg: UsageShareCardConfig, - geo: CardGeometry, - insights: UsageShareCardInsights, - progress: number -) { - const totals = insights.weeklyTotals; - const maxWeekly = Math.max(1, ...totals); - const chartLeft = geo.contentLeft; - const chartWidth = geo.contentRight - chartLeft; - const bottom = cfg.chartTop + cfg.chartHeight; - const gap = Math.max(1.5, chartWidth / totals.length / 4); - const barWidth = (chartWidth - gap * (totals.length - 1)) / totals.length; - const eased = easeOutCubic(progress); - const foil = foilPalette(cfg); - const barFill = foil - ? makeFoil(context, chartLeft, cfg.chartTop, chartWidth, cfg.chartHeight, foil, 0.32) - : cfg.accent; - - context.save(); - context.strokeStyle = cfg.edgeColor; - context.lineWidth = 1; - context.beginPath(); - context.moveTo(chartLeft, bottom); - context.lineTo(geo.contentRight, bottom); - context.stroke(); - - if (foil) { - context.shadowColor = 'rgba(60,66,76,0.4)'; - context.shadowBlur = 4; - context.shadowOffsetY = 1; - } - for (const [index, value] of totals.entries()) { - const full = (value / maxWeekly) * cfg.chartHeight; - const height = full * eased; - if (height <= 0.5) continue; - const x = chartLeft + index * (barWidth + gap); - const alpha = 0.45 + 0.55 * (value / maxWeekly); - context.globalAlpha = alpha; - context.fillStyle = barFill; - roundedRectPath(context, x, bottom - height, barWidth, height, Math.min(barWidth / 2, 3)); - context.fill(); - } - context.restore(); -} - -function drawHeatmapHero( - context: CanvasRenderingContext2D, - cfg: UsageShareCardConfig, - geo: CardGeometry, - model: UsageCalendarModel, - progress: number -) { - const left = geo.contentLeft; - const width = geo.contentRight - left; - const gap = 4; - const colStride = width / USAGE_CALENDAR_COLUMNS; - const rowStride = cfg.chartHeight / USAGE_CALENDAR_ROWS; - const size = Math.max(2, Math.min(colStride, rowStride) - gap); - const reveal = easeOutCubic(progress) * USAGE_CALENDAR_COLUMNS; - - for (const cell of model.cells) { - if (cell.isFuture || cell.column > reveal) continue; - const x = left + cell.column * colStride; - const y = cfg.chartTop + cell.row * rowStride; - context.fillStyle = cell.level === 0 ? `${cfg.edgeColor}66` : HEATMAP_LEVEL_COLORS[cell.level]!; - roundedRectPath(context, x, y, size, size, Math.min(3, size / 3)); - context.fill(); - } -} - -function drawStats( - context: CanvasRenderingContext2D, - cfg: UsageShareCardConfig, - geo: CardGeometry, - model: UsageCalendarModel, - insights: UsageShareCardInsights, - labels: UsageShareCardLabels -) { - const entries: Array<[string, string]> = [ - [labels.peakDay, formatUsageCompact(insights.peakCell?.value ?? 0)], - [labels.dailyAverage, formatUsageCompact(insights.dailyAverage)], - [labels.activeDays, String(model.activeDays)], - [labels.longestStreak, `${model.longestStreak}d`], - ]; - const left = geo.contentLeft; - const cellWidth = (geo.contentRight - left) / entries.length; - - context.save(); - for (const [index, [label, value]] of entries.entries()) { - const x = left + index * cellWidth; - if (index > 0) { - context.strokeStyle = cfg.edgeColor; - context.lineWidth = 1; - context.beginPath(); - context.moveTo(x, cfg.statsY - 4); - context.lineTo(x, cfg.statsY + 44); - context.stroke(); - } - context.fillStyle = cfg.mutedInk; - context.font = `600 13px ${cfg.fontSans}`; - setLetterSpacing(context, '1px'); - context.fillText(label.toUpperCase(), x + 14, cfg.statsY + 10); - setLetterSpacing(context, '0px'); - context.fillStyle = cfg.inkColor; - context.font = `700 26px ${cfg.fontMono}`; - context.fillText(value, x + 14, cfg.statsY + 42); - } - context.restore(); -} - -function drawHeatmapStrip( - context: CanvasRenderingContext2D, - cfg: UsageShareCardConfig, - geo: CardGeometry, - model: UsageCalendarModel -) { - const left = geo.contentLeft; - const width = geo.contentRight - left; - const gap = 3; - const cell = (width - gap * (USAGE_CALENDAR_COLUMNS - 1)) / USAGE_CALENDAR_COLUMNS; - const rowStride = cfg.heatmapHeight / USAGE_CALENDAR_ROWS; - const size = Math.min(cell, rowStride - 1.2); - for (const c of model.cells) { - if (c.isFuture || c.level === 0) continue; - const x = left + c.column * (cell + gap); - const y = cfg.heatmapTop + c.row * rowStride; - context.fillStyle = HEATMAP_LEVEL_COLORS[c.level]!; - roundedRectPath(context, x, y, size, size, Math.min(2, size / 3)); - context.fill(); - } -} - -function drawMark( - context: CanvasRenderingContext2D, - cfg: UsageShareCardConfig, - geo: CardGeometry, - progress: number -) { - const markX = cfg.markX < 0 ? geo.tearPx + cfg.markX : geo.contentLeft + cfg.markX; - const entrance = easeOutCubic(Math.max(0, Math.min(1, (progress - 0.16) / 0.58))); - const bounce = 1 + Math.sin(entrance * Math.PI) * 0.12; - drawLodyMark(context, markX, cfg.markY, cfg.markSize, { - rotation: cfg.markFx === 'vhs' ? 0 : cfg.markRotation - (1 - entrance) * 14, - opacity: cfg.markOpacity * entrance, - style: cfg.markStyle, - fill: cfg.markFill, - stroke: cfg.markStroke, - strokeWidth: cfg.markStrokeWidth, - foil: cfg.markFx === 'vhs' ? null : foilPalette(cfg), - fx: cfg.markFx, - progress, - sizeMultiplier: (0.62 + entrance * 0.38) * bounce, - }); -} - -function drawStub( - context: CanvasRenderingContext2D, - cfg: UsageShareCardConfig, - geo: CardGeometry, - model: UsageCalendarModel -) { - // Modern e-ticket stub: a small caption, a branded QR block, a serial, and a - // slim barcode — all centred so the strip never reads as skewed. The QR is the - // seed of the future social "scan / drag me in" token. - const centerX = (geo.tearPx + geo.right) / 2; - const stubWidth = geo.right - geo.tearPx; - const serial = - cfg.serial || `NO.${String(Math.round(model.totalValue) % 100000).padStart(5, '0')}`; - - if (cfg.showStubStamp) { - const qrSize = Math.min(stubWidth - 34, 132); - const qrX = centerX - qrSize / 2; - const qrY = geo.top + geo.height * 0.26; - - context.save(); - context.fillStyle = cfg.mutedInk; - context.font = `600 11px ${cfg.fontSans}`; - context.textAlign = 'center'; - setLetterSpacing(context, '2px'); - context.fillText('SCAN · COMPARE', centerX, qrY - 16); - setLetterSpacing(context, '0px'); - context.restore(); - - // A soft quiet-zone panel so the code reads cleanly on textured paper. - context.save(); - const pad = qrSize * 0.08; - roundedRectPath(context, qrX - pad, qrY - pad, qrSize + pad * 2, qrSize + pad * 2, 12); - context.fillStyle = '#ffffff'; - context.globalAlpha = 0.66; - context.fill(); - context.globalAlpha = 1; - context.strokeStyle = cfg.edgeColor; - context.lineWidth = 1; - context.stroke(); - context.restore(); - - drawFauxQr( - context, - qrX, - qrY, - qrSize, - (Math.round(model.totalValue) % 100000) + 7, - cfg.inkColor, - '#ffffff' - ); - - // Branded centre badge with the Lody mark (never glitched here). - const badge = qrSize * 0.28; - context.save(); - roundedRectPath( - context, - centerX - badge / 2, - qrY + qrSize / 2 - badge / 2, - badge, - badge, - badge * 0.3 - ); - context.fillStyle = cfg.paperTop; - context.fill(); - context.restore(); - drawLodyMark(context, centerX, qrY + qrSize / 2, badge * 0.74, { - style: 'plain', - fill: cfg.accent, - foil: foilPalette(cfg), - }); - - context.save(); - context.fillStyle = cfg.mutedInk; - context.font = `500 14px ${cfg.fontMono}`; - context.textAlign = 'center'; - context.fillText(serial, centerX, qrY + qrSize + 30); - context.restore(); - } - - if (cfg.showBarcode) { - const bars = [3, 1, 2, 4, 1, 3, 2, 1, 4, 2, 3, 1, 2, 4, 1, 3, 2, 1]; - const gap = 2; - const barcodeY = geo.bottom - 44; - const totalWidth = bars.reduce((sum, bar) => sum + (bar >= 3 ? 3 : 2) + gap, -gap); - let bx = centerX - totalWidth / 2; - context.fillStyle = cfg.inkColor; - for (const bar of bars) { - const width = bar >= 3 ? 3 : 2; - context.fillRect(bx, barcodeY, width, 26); - bx += width + gap; - } - } -} - -function punchPerforations( - context: CanvasRenderingContext2D, - cfg: UsageShareCardConfig, - geo: CardGeometry -) { - context.save(); - context.globalCompositeOperation = 'destination-out'; - context.fillStyle = '#000'; - - if (cfg.showStub) { - // Tear-line holes. - const startY = geo.top + cfg.tearInset; - const endY = geo.bottom - cfg.tearInset; - for (let y = startY; y <= endY; y += cfg.perfSpacing) { - punchHole(context, geo.tearPx, y, cfg.perfRadius); - } - // Semicircle notches at both ends of the tear. - punchHole(context, geo.tearPx, geo.top, cfg.notchRadius); - punchHole(context, geo.tearPx, geo.bottom, cfg.notchRadius); - } - - if (cfg.scallopEdge) { - const inset = cfg.cornerRadius; - for (let x = geo.left + inset; x <= geo.right - inset; x += cfg.scallopSpacing) { - punchHole(context, x, geo.top, cfg.scallopRadius); - punchHole(context, x, geo.bottom, cfg.scallopRadius); - } - for (let y = geo.top + inset; y <= geo.bottom - inset; y += cfg.scallopSpacing) { - punchHole(context, geo.left, y, cfg.scallopRadius); - punchHole(context, geo.right, y, cfg.scallopRadius); - } - } - context.restore(); -} - -export type UsageShareCardLabels = { - peakDay: string; - dailyAverage: string; - activeDays: string; - longestStreak: string; - stubLabel: string; -}; - -export const DEFAULT_USAGE_SHARE_CARD_LABELS: UsageShareCardLabels = { - peakDay: 'Peak day', - dailyAverage: 'Daily avg', - activeDays: 'Active days', - longestStreak: 'Longest', - stubLabel: 'Lody Usage', -}; - -export type UsageShareCardFrameInput = { - model: UsageCalendarModel; - workspaceName: string; - subtitle: string; - style?: UsageShareCardStyle; - config?: Partial; - labels?: Partial; -}; - -/** - * Draw one frame of the usage share card into an existing 2D context. `progress` - * in [0, 1] drives the reveal animation (count-up, trend draw-on, sticker pop, chip fade); - * pass 1 for the final static frame used by PNG export. - */ -export function renderUsageShareCardFrame( - context: CanvasRenderingContext2D, - input: UsageShareCardFrameInput, - progress = 1 -): void { - const cfg = resolveShareCardConfig(input.config); - const labels = { ...DEFAULT_USAGE_SHARE_CARD_LABELS, ...(input.labels ?? {}) }; - const geo = computeGeometry(cfg); - const insights = computeUsageShareInsights(input.model); - - context.clearRect(0, 0, cfg.width, cfg.height); - drawPaper(context, cfg, geo); - - // Clip the busy content to the main body so nothing bleeds into the stub. - context.save(); - roundedRectPath(context, geo.left, geo.top, geo.width, geo.height, cfg.cornerRadius); - context.clip(); - - context.save(); - context.beginPath(); - context.rect(geo.left, geo.top, geo.tearPx - geo.left, geo.height); - context.clip(); - drawKicker(context, cfg, geo); - drawTitle(context, cfg, geo, input.workspaceName); - drawHero(context, cfg, geo, input.model, insights, input.subtitle, progress); - if (cfg.showTrend) { - if (cfg.heroGraphic === 'heatmap') drawHeatmapHero(context, cfg, geo, input.model, progress); - else if (cfg.heroGraphic === 'bars') drawBars(context, cfg, geo, insights, progress); - else drawTrend(context, cfg, geo, insights, progress); - } - if (cfg.showStats) drawStats(context, cfg, geo, input.model, insights, labels); - if (cfg.showHeatmap) drawHeatmapStrip(context, cfg, geo, input.model); - context.restore(); - - if (cfg.showStub) drawStub(context, cfg, geo, input.model); - if (cfg.showMark) drawMark(context, cfg, geo, progress); - if (cfg.foil !== 'none') drawSheen(context, geo, cfg, progress); - context.restore(); - - punchPerforations(context, cfg, geo); -} - -function createShareCanvas(cfg: UsageShareCardConfig): { - canvas: HTMLCanvasElement; - context: CanvasRenderingContext2D; -} { - const scale = Math.min(2, typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1); - const canvas = document.createElement('canvas'); - canvas.width = cfg.width * scale; - canvas.height = cfg.height * scale; - const context = canvas.getContext('2d'); - if (!context) throw new Error('Canvas rendering is unavailable'); - context.scale(scale, scale); - return { canvas, context }; -} - -/** - * Render the final (static) usage share card to a PNG blob. - * - * `style` picks the subtitle wording and is kept for backward compatibility with - * the earlier flat/isometric switch; the optional `config` override is where the - * Storybook-tuned values are plugged in. - */ -export async function createUsageShareCard( - model: UsageCalendarModel, - workspaceName: string, - totalLabel: string, - style: UsageShareCardStyle = 'isometric', - config?: Partial -): Promise { - await waitForUsageShareCardFonts(); - const cfg = resolveShareCardConfig(config); - const { canvas, context } = createShareCanvas(cfg); - const subtitle = - totalLabel || - (style === 'flat' ? 'Usage heatmap · last 53 weeks' : 'Usage skyline · last 53 weeks'); - renderUsageShareCardFrame(context, { model, workspaceName, subtitle, style, config }, 1); - - return await new Promise((resolve, reject) => { - canvas.toBlob( - (blob) => (blob ? resolve(blob) : reject(new Error('Could not create share card'))), - 'image/png' - ); - }); -} - -export type UsageShareCardPreset = { - label: string; - description: string; - config: Partial; -}; - -/** - * Ready-made combinations across the three axes (shape × theme × hero graphic). - * Each is just a config override, so they compose with anything tuned in Storybook. - */ -export const USAGE_SHARE_CARD_PRESETS: Record = { - ticket: { - label: 'Cinema ticket', - description: 'Warm paper, tear-off stub, trend curve.', - config: { - foil: 'champagne', - paperTop: '#ffffff', - paperBottom: '#f4ebda', - edgeColor: '#dbcfb2', - inkColor: '#1e1717', - mutedInk: '#948d7b', - accent: '#2a5bbb', - markStroke: '#f7f3ea', - trendFill: true, - showHeatmap: true, - }, - }, - stamp: { - label: 'Postage stamp', - description: 'Scalloped die-cut edge, no stub, curve fills the frame.', - config: { scallopEdge: true, showStub: false, cornerRadius: 16, markRotation: -6 }, - }, - kraft: { - label: 'Kraft receipt', - description: 'Recycled-paper browns with a rust accent.', - config: { - paperTop: '#e8dab6', - paperBottom: '#dcc99f', - edgeColor: '#c7b085', - inkColor: '#3a2e1b', - mutedInk: '#8a7857', - accent: '#b5622d', - accentSoft: '#e0a877', - markFill: '#3a2e1b', - markStroke: '#e8dab6', - }, - }, - silverFoil: { - label: 'Silver foil', - description: '珍珠白卡面 + 银箔烫印(数字/曲线/贴纸),带扫光。现在是默认。', - config: {}, - }, - platinumFoil: { - label: 'Platinum foil · bars', - description: '冷调铂金烫印,柱状主视觉。', - config: { - foil: 'platinum', - heroGraphic: 'bars', - paperTop: '#eef1f5', - paperBottom: '#e0e5ec', - edgeColor: '#c4ccd6', - inkColor: '#353b44', - mutedInk: '#8f98a4', - accent: '#7f8896', - markStroke: '#ffffff', - showHeatmap: false, - }, - }, - champagneFoil: { - label: 'Champagne foil', - description: '暖调香槟金烫印,浅色不刺眼。', - config: { - foil: 'champagne', - paperTop: '#fbf6ea', - paperBottom: '#f3ead4', - edgeColor: '#e3d4ae', - inkColor: '#4a4130', - mutedInk: '#a99a76', - accent: '#c6a55c', - markStroke: '#fffdf7', - trendFill: false, - showHeatmap: false, - }, - }, - vhs: { - label: 'VHS readout', - description: '数字用 CRT/VHS 特效(RGB 错位 + 扫描线 + 抖动),复古终端字。', - config: { - heroFx: 'vhs', - fontMono: '"VT323", "JetBrains Mono", ui-monospace, monospace', - fontDisplay: '"Bebas Neue", "Arial Narrow", sans-serif', - heroSize: 100, - heroY: 248, - subtitleY: 274, - }, - }, - contribution: { - label: 'Contribution wall', - description: 'The GitHub-style heatmap as the hero graphic.', - config: { heroGraphic: 'heatmap', showHeatmap: false, chartHeight: 150, chartTop: 296 }, - }, - minimal: { - label: 'Minimal card', - description: 'No stub, no frame, soft rounded corners.', - config: { - showStub: false, - showFrame: false, - showStubStamp: false, - cornerRadius: 44, - scallopEdge: false, - markX: -108, - }, - }, -}; diff --git a/packages/components/src/components/settings/usage-share-card.tsx b/packages/components/src/components/settings/usage-share-card.tsx new file mode 100644 index 000000000..512ba121e --- /dev/null +++ b/packages/components/src/components/settings/usage-share-card.tsx @@ -0,0 +1,835 @@ +import { useEffect, useState, type CSSProperties } from 'react'; +import { useTranslation } from 'react-i18next'; +import QRCode from 'qrcode'; +import { cn } from '@/lib/utils'; +import { + formatCompactNumber, + formatUsdCompact, + formatUsdTight, +} from '@/lib/format-compact-number'; +import { toIntlLocaleOrEn } from '@/lib/intl-locale'; +import { ensureShareThemeScopes } from '@/components/share-theme-scope'; +import { ModelBrandIcon } from '@/components/icons/model-brand-icon'; +import { Avatar, AvatarFallback, AvatarImage } from '@/ui/avatar'; +import lodyLogo from '@/assets/lody-icon.png'; +import { createUsageHeatScale, type UsageCalendarModel } from './usage-calendar-model'; +import type { UsageShareGraphic, UsageShareSlice, UsageShareStats } from './usage-share-stats'; + +/** + * Feed formats, not free-form sizes. Portrait claims the largest area a social + * feed grants; wide is the inline-preview shape for X and for embedding in a + * README or a post. The exported PNG is exactly these pixels at 2x. + */ +export type UsageShareCardAspect = 'portrait' | 'wide'; + +/** Whose record the card is: the workspace as one body, or its members. */ +export type UsageShareCardSubject = 'personal' | 'team'; + +/** Same gradient presets as the session share card, so both read as one product. */ +export type UsageShareCardBackdrop = 'none' | 'lody' | 'aurora' | 'ocean' | 'sunset'; + +/** + * Where the sign-off lives. `card` keeps it inside, under a rule. `canvas` moves it + * onto the backdrop below the card, the way the session card's canvas footer does: + * the only placement that *gives* the card height instead of taking it, since the + * in-card band goes away entirely and the backdrop is otherwise empty pixels. It + * needs a backdrop to sit on, so an unframed card falls back to `card`. + */ +export type UsageShareCardFooter = 'card' | 'canvas'; + +/** + * These are the whole exported image, backdrop included — so a framed card is + * 48px shorter than an unframed one, and the layout has to fit the framed case + * because a backdrop is the default. Both were sized up until the framed + * variant has real headroom rather than landing flush against its footer. + */ +const ASPECT_SIZE: Record = { + portrait: { width: 576, height: 720 }, + wide: { width: 704, height: 396 }, +}; + +export const USAGE_SHARE_BACKDROP_STYLES: Record< + Exclude, + CSSProperties +> = { + lody: { + background: + 'radial-gradient(52% 38% at 18% 12%, rgba(53,200,176,0.45), transparent 70%),' + + 'radial-gradient(48% 36% at 86% 16%, rgba(47,119,191,0.5), transparent 70%),' + + 'radial-gradient(70% 55% at 68% 96%, rgba(31,79,127,0.65), transparent 75%),' + + 'radial-gradient(120% 100% at 50% 50%, transparent 55%, rgba(2,10,18,0.55) 100%),' + + 'linear-gradient(165deg, #0a1c2b 0%, #0c2438 55%, #081626 100%)', + }, + aurora: { background: 'linear-gradient(135deg, #4f46e5 0%, #7c3aed 45%, #db2777 100%)' }, + ocean: { background: 'linear-gradient(135deg, #0369a1 0%, #0891b2 50%, #34d399 100%)' }, + sunset: { background: 'linear-gradient(135deg, #9a3412 0%, #ea580c 45%, #f59e0b 100%)' }, +}; + +export interface UsageShareCardProps { + /** 53-week calendar, used when the range's graphic is the year. */ + calendar: UsageCalendarModel; + stats: UsageShareStats; + /** Which graphic this range draws; see `computeUsageShareGraphic`. */ + graphic: UsageShareGraphic; + /** Model split for the range; empty hides the split block. */ + modelSlices: UsageShareSlice[]; + /** Member split for the range; only read when `subject` is `team`. */ + memberSlices: UsageShareSlice[]; + /** Human label for the range, e.g. "Last 30 days". */ + rangeLabel: string; + workspaceName?: string; + aspect?: UsageShareCardAspect; + subject?: UsageShareCardSubject; + backdrop?: UsageShareCardBackdrop; + shareUrl?: string; + showQr?: boolean; + /** Sign-off placement; `canvas` needs a backdrop and falls back to `card` without one. */ + footer?: UsageShareCardFooter; + /** Pins the exported palette instead of following the app's current theme. */ + theme?: 'light' | 'dark'; + className?: string; + onAssetsReadyChange?: (ready: boolean) => void; +} + +const DEFAULT_SHARE_URL = 'https://lody.ai'; + +/** + * The card's whole type scale. Every text node picks a role from here rather + * than an arbitrary size: an exported image has no hover state or tooltip to + * recover a hierarchy that half-pixel steps blur away, and two cards taken a + * month apart must set the same words at the same size. + */ +const TEXT = { + /** The one number the card exists to deliver. */ + hero: 'text-[54px]', + heroWide: 'text-[32px]', + /** Headline cell values. */ + stat: 'text-[20px]', + statWide: 'text-[15px]', + /** Brand, unit, workspace — anything read before the details. */ + body: 'text-[13px]', + /** Cell labels, legend rows, the range chip. */ + meta: 'text-[11px]', + /** Month ticks and the heatmap caption. */ + micro: 'text-[10px]', +} as const; + +/** + * Horizontal padding is one value for every band including the footer, so the + * brand mark, the hero, the heatmap and the workspace name all share a left + * edge. Vertical padding differs by format because only the height budget does. + */ +const PAD_X = 'px-6'; + +/** + * Vertical rhythm is the one thing the two formats may disagree about, because + * only their height budget differs: 4:5 has room to breathe between bands, 16:9 + * has to fit the same five bands into 40% of the height. Declared here as two + * rows rather than sprinkled per element, so "the wide card is tighter" stays a + * single decision. Every value is on the same 4px grid. + */ +const RHYTHM: Record< + UsageShareCardAspect, + { band: string; padY: string; stack: string; split: string; rows: string; axis: string } +> = { + portrait: { + band: 'gap-5', + padY: 'pt-6 pb-6', + stack: 'space-y-2', + // The 100% bar summarises the legend, so it needs a group-sized gap. At the + // row gap it reads as the list's first item instead of its summary. + split: 'space-y-4', + rows: 'space-y-2', + axis: 'mb-2', + }, + wide: { + band: 'gap-3', + padY: 'pt-3 pb-3', + stack: 'space-y-1', + split: 'space-y-2', + rows: 'space-y-1', + axis: 'mb-1', + }, +}; + +/** + * Every range's graphic occupies the same box, so the card's height never depends + * on which range it describes — the whole point of a fixed format. It matches what + * the 53-week grid renders at (its aspect ratio against the content width), and the + * hourly graphics fit themselves to it rather than the other way round. + */ +const GRAPHIC_H = 'h-[58px]'; + +/** Heatmap geometry in SVG units; the SVG scales to whatever column holds it. */ +const HEAT_CELL = 10; +const HEAT_GAP = 2.6; +const HEAT_COLUMNS = 53; +const HEAT_ROWS = 7; + +function heatFill(intensity: number, lit: boolean): string { + if (intensity <= 0) return 'hsl(var(--muted-foreground) / 0.13)'; + // Days outside the shared range stay legible but recede, so the range the + // headline number describes is the part the eye lands on. + return `hsl(var(--chart-1) / ${(lit ? intensity : intensity * 0.4).toFixed(3)})`; +} + +/** + * Month ticks for the 53-week grid. Without them the heatmap is a texture with no + * time scale — the reader can see a burst but not when it happened. Ticks land on + * the first column of each month and thin out to keep the row legible. + */ +function monthTicks( + calendar: UsageCalendarModel, + locale: string +): Array<{ column: number; label: string }> { + const format = new Intl.DateTimeFormat(locale, { month: 'short', timeZone: 'UTC' }); + const ticks: Array<{ column: number; label: string }> = []; + let previousMonth = -1; + for (const week of calendar.weeks) { + const cell = week.find((candidate) => !candidate.isFuture); + if (!cell) continue; + const month = new Date(cell.dayStartMs).getUTCMonth(); + if (month === previousMonth) continue; + previousMonth = month; + // Skip the first column: its label would be clipped by the card padding. + if (cell.column < 1) continue; + const last = ticks.at(-1); + if (last && cell.column - last.column < 4) continue; + ticks.push({ column: cell.column, label: format.format(new Date(cell.dayStartMs)) }); + } + // The final tick would collide with the right edge. + return ticks.filter((tick) => tick.column <= HEAT_COLUMNS - 3); +} + +function UsageShareHeatmap({ + calendar, + lit, + locale, + axisGap, +}: { + calendar: UsageCalendarModel; + lit: UsageShareStats['litDayStartMs']; + locale: string; + axisGap: string; +}) { + const scale = createUsageHeatScale(calendar); + const width = HEAT_COLUMNS * (HEAT_CELL + HEAT_GAP) - HEAT_GAP; + const height = HEAT_ROWS * (HEAT_CELL + HEAT_GAP) - HEAT_GAP; + return ( +
+ {/* Labels live in HTML, not in the SVG: the grid scales to its column and + SVG text would scale with it, so the two formats would disagree. */} +
+ {monthTicks(calendar, locale).map((tick) => ( + + {tick.label} + + ))} +
+ + {calendar.cells.map((cell) => { + const inWindow = !lit || (cell.dayStartMs >= lit.fromMs && cell.dayStartMs <= lit.toMs); + return ( + + ); + })} + +
+ ); +} + +/** Hour ticks under an hourly graphic, the axis counterpart of the month ticks. */ +function HourAxis({ gap }: { gap: string }) { + return ( +
+ {[0, 6, 12, 18].map((hour) => ( + + {String(hour).padStart(2, '0')} + + ))} +
+ ); +} + +/** + * 24h: one flat bar per hour standing on a baseline — the Usage screen's own hour + * skyline. Height carries magnitude; the fill lightens with share of the peak, so + * a quiet hour still reads as present rather than as a gap. + */ +function UsageShareHours({ values, axisGap }: { values: number[]; axisGap: string }) { + const max = Math.max(...values, 0); + return ( +
+ +
+ {values.map((value, index) => { + const share = max > 0 ? value / max : 0; + return ( +
0 ? `${Math.max(7, share * 100)}%` : '2px', + backgroundColor: + value > 0 + ? `hsl(var(--chart-1) / ${(0.35 + share * 0.55).toFixed(3)})` + : 'hsl(var(--muted-foreground) / 0.16)', + }} + /> + ); + })} +
+
+ ); +} + +/** + * 7d: the same 24 hour tracks stacked seven deep, as dots rather than tiles. A + * circle that grows and brightens with its hour keeps a quiet week readable as + * texture, where a full-bleed grid turns into a wall. + */ +function UsageShareWeekHours({ + rows, + axisGap, +}: { + rows: Array<{ dayStartMs: number; values: number[] }>; + axisGap: string; +}) { + const max = Math.max(0, ...rows.flatMap((row) => row.values)); + return ( +
+ + {/* Rows divide the shared box, so a week fits the same height as a year. + They carry no per-day label on purpose: eight rows in this box leave 7px + each, which cannot hold any size on the card's type scale — the first + attempt used an off-scale 8px and read as a squeezed column. Rows run + oldest to newest, and the headline already names the span. */} +
+ {rows.map((row) => ( +
+
+ {row.values.map((value, hour) => { + const share = max > 0 ? value / max : 0; + const size = value > 0 ? 2.5 + share * 4.5 : 2; + return ( +
+
0 + ? `hsl(var(--chart-1) / ${(0.35 + share * 0.55).toFixed(3)})` + : 'hsl(var(--muted-foreground) / 0.16)', + }} + /> +
+ ); + })} +
+
+ ))} +
+
+ ); +} + +/** 100% bar + legend. One shape for models and members; only the mark differs. */ +function UsageShareSplit({ + slices, + subject, + compact, + locale, + formatValue, + split, + rows: rowGap, +}: { + slices: UsageShareSlice[]; + subject: UsageShareCardSubject; + compact: boolean; + locale: string; + formatValue: (value: number) => string; + /** Bar-to-legend gap and row-to-row gap, from the format's rhythm. */ + split: string; + rows: string; +}) { + if (slices.length === 0) return null; + const percent = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 0 }); + const rows = compact ? slices.slice(0, 2) : slices; + return ( +
+
+ {slices.map((slice, index) => ( +
+ ))} +
+
+ {rows.map((slice, index) => ( +
+ + {subject === 'team' ? ( + + {slice.image ? : null} + + {slice.label.slice(0, 2).toUpperCase()} + + + ) : ( + + )} + + {slice.label} + + {/* Percent alone hides scale: 52% of a quiet week and of a heavy + month are not the same fact, so the row carries both. */} + {compact ? null : ( + + {formatValue(slice.value)} + + )} + + {percent.format(slice.share)} + +
+ ))} +
+
+ ); +} + +function StatCell({ label, value }: { label: string; value: string }) { + return ( +
+
+ {value} +
+
+ {label} +
+
+ ); +} + +/** + * Fixed-format poster for a workspace's usage over one range. Unlike the session + * share card — which is an editor for content of unpredictable shape — this is a + * generator for a report of fixed shape: same blocks every time, only the numbers + * move, so two months' cards can be laid side by side and compared. + * + * Blocks, top to bottom: brand + range, the hero token total, three headline + * cells, the 53-week heatmap with the range's window lit, the model (or member) + * split, and an EXIF-style footer that matches the session card's grammar. + */ +export function UsageShareCard({ + calendar, + stats, + graphic, + modelSlices, + memberSlices, + rangeLabel, + workspaceName, + aspect = 'portrait', + subject = 'personal', + backdrop = 'lody', + shareUrl = DEFAULT_SHARE_URL, + showQr = true, + footer: footerPlacement = 'card', + theme, + className, + onAssetsReadyChange, +}: UsageShareCardProps) { + const { t, i18n } = useTranslation(); + const locale = toIntlLocaleOrEn(i18n.resolvedLanguage ?? i18n.language); + const [qrDataUrl, setQrDataUrl] = useState(null); + + // Injects the scoped theme rules before first paint; idempotent no-op after. + ensureShareThemeScopes(); + const themeScopeClass = + theme === 'light' ? 'light-scope' : theme === 'dark' ? 'dark-scope' : undefined; + + useEffect(() => { + onAssetsReadyChange?.(!showQr || qrDataUrl !== null); + }, [showQr, qrDataUrl, onAssetsReadyChange]); + + useEffect(() => { + if (!showQr) { + setQrDataUrl(null); + return undefined; + } + let cancelled = false; + QRCode.toDataURL(shareUrl, { + margin: 0, + width: 160, + errorCorrectionLevel: 'M', + color: { dark: '#101828', light: '#ffffff' }, + }) + .then((url) => { + if (!cancelled) setQrDataUrl(url); + }) + .catch(() => { + if (!cancelled) setQrDataUrl(null); + }); + return () => { + cancelled = true; + }; + }, [shareUrl, showQr]); + + const wide = aspect === 'wide'; + const size = ASPECT_SIZE[aspect]; + const rhythm = RHYTHM[aspect]; + const framed = backdrop !== 'none'; + // Without a backdrop there is nothing to print the sign-off on. + const onCanvas = footerPlacement === 'canvas' && framed; + // The unit comes with the numbers, so the card cannot be told one thing and + // handed another. + const { metric } = stats; + const slices = subject === 'team' ? memberSlices : modelSlices; + // Every number on the card goes through this, so a cost card can never print a + // token count beside a dollar figure. + /** + * The headline is the subject and gets the metric's own language: compact for + * tokens, digits-to-a-billion for money. + */ + const formatHeadline = (value: number) => + metric === 'tokens' ? formatCompactNumber(value, locale) : formatUsdCompact(value, locale); + /** + * A stat cell has a quarter of the headline's width and a legend row less than + * that, so those always compact. Letting the fuller form through and relying on + * `truncate` produced `$42,040…` — an ellipsis on a number is a wrong number, + * which is worse than a rounded one. + */ + const formatTight = (value: number) => + metric === 'tokens' ? formatCompactNumber(value, locale) : formatUsdTight(value, locale); + + // Same four facts at every range, only the unit changes: how often, how + // consistently, how much on a typical unit, how much at the best one. + const allCells = + stats.trio === 'interval' + ? [ + { label: t('workspace.usage.skyline.activeIntervals'), value: String(stats.activeCount) }, + { label: t('workspace.usage.skyline.longestStreak'), value: String(stats.longestStreak) }, + { + label: t('workspace.usage.skyline.averagePerInterval'), + value: formatTight(stats.average), + }, + { label: t('workspace.usage.skyline.peakInterval'), value: formatTight(stats.peak) }, + ] + : [ + { label: t('workspace.usage.skyline.activeDays'), value: String(stats.activeCount) }, + { label: t('workspace.usage.skyline.longestStreak'), value: String(stats.longestStreak) }, + { label: t('workspace.usage.skyline.dailyAverage'), value: formatTight(stats.average) }, + { label: t('workspace.usage.skyline.peakDay'), value: formatTight(stats.peak) }, + ]; + // 16:9 puts the cells on the hero's baseline, where a fourth would not fit. + const trioCells = wide ? allCells.slice(0, 3) : allCells; + + const heroValue = ( + + {formatHeadline(stats.total)} + + ); + const dayFormat = new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }); + const periodDates = `${dayFormat.format(new Date(stats.periodMs.fromMs))} – ${dayFormat.format( + new Date(stats.periodMs.toMs) + )}`; + + // The unit is named once, by the caption. A card denominated in dollars must not + // also print a token count somewhere, or the reader has to guess which is the + // subject. + const heroUnits = ( + + {metric === 'tokens' ? t('workspace.usage.tokens') : t('workspace.usage.cost')} + + ); + + // Portrait stacks the unit under the number; wide sets it on the same + // baseline, because 16:9 pays for every row of height. + const hero = wide ? ( +
+ {heroValue} + {heroUnits} +
+ ) : ( +
+ {heroValue} +
{heroUnits}
+
+ {periodDates} +
+
+ ); + + const header = ( +
+ + Lody + + {rangeLabel} + +
+ ); + + // One slot, three visual languages — the same split the Usage screen makes, so a + // 24h card is an hour skyline rather than a year with one cell lit. + const heatmap = ( +
+ {graphic.kind === 'hours' ? ( + + ) : graphic.kind === 'weekHours' ? ( + + ) : ( + + )} +
+ + {graphic.kind === 'hours' + ? t('workspace.usage.shareImage.hoursCaption') + : graphic.kind === 'weekHours' + ? t('workspace.usage.shareImage.weekCaption') + : t('workspace.usage.shareImage.calendarCaption')} + + {graphic.kind === 'calendar' && stats.litDayStartMs ? ( + {t('workspace.usage.shareImage.windowLit', { range: rangeLabel })} + ) : null} +
+
+ ); + + /** + * A sign-off, not a status bar. It borrows the session card's identity-plus-sub + * structure — who this is, then where it came from — without that card's EXIF + * parameter line, which would only repeat numbers the bands above already carry. + * 4:5 stacks the two lines and takes a full-size QR; 16:9 has no height to spare + * and keeps the single row. + */ + const footer = ( +
+ + {wide ? ( + <> +
+ {workspaceName?.trim() || 'Lody'} +
+ + lody.ai + + + ) : ( +
+
+ {workspaceName?.trim() || 'Lody'} +
+
+ lody.ai +
+
+ )} + {qrDataUrl ? ( + {t('chatShareCard.qrAlt')} + ) : null} +
+ ); + + const card = ( +
+ {wide ? ( + // Two columns: the number and its trio read as one headline on the + // left, the year and the split as one graphic on the right. Stacking + // all five blocks vertically does not fit 16:9 without shrinking the + // heatmap past the point where a single day is still a square. +
+ {header} +
+ {hero} +
+ {trioCells.map((cell) => ( +
+ + {cell.value} + + {cell.label} +
+ ))} +
+
+ {heatmap} + +
+ ) : ( +
+ {header} + {/* The headline owns this band alone. The space beside and around it is + deliberate: see the AGENTS note before filling it with anything. */} +
{hero}
+
+ {trioCells.map((cell) => ( + + ))} +
+ {heatmap} + +
+ )} + {onCanvas ? null : footer} +
+ ); + + /** + * The canvas sign-off, printed on the backdrop under the card. Its colours are + * fixed rather than themed: it sits on a gradient, not on the card surface, so + * the card's light/dark tokens do not describe what is behind it. + */ + const canvasSignOff = ( +
+ + + {workspaceName?.trim() || 'Lody'} + + lody.ai + {qrDataUrl ? ( + {t('chatShareCard.qrAlt')} + ) : null} +
+ ); + + return ( +
+
{card}
+ {onCanvas ? canvasSignOff : null} +
+ ); +} diff --git a/packages/components/src/components/settings/usage-share-image-dialog.tsx b/packages/components/src/components/settings/usage-share-image-dialog.tsx new file mode 100644 index 000000000..aac1e777c --- /dev/null +++ b/packages/components/src/components/settings/usage-share-image-dialog.tsx @@ -0,0 +1,432 @@ +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Check, Copy, Download, Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/ui/dialog'; +import { Label } from '@/ui/label'; +import { Button } from '@/ui/button'; +import { Switch } from '@/ui/switch'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/ui/select'; +import { copyShareImage, exportShareImage } from '@/lib/share-image-export'; +import { stripRecommended } from '@/components/shared/acp-selector-options'; +import { createUsageCalendarModel, type UsageCalendarMetric } from './usage-calendar-model'; +import { + UsageShareCard, + USAGE_SHARE_BACKDROP_STYLES, + type UsageShareCardAspect, + type UsageShareCardBackdrop, + type UsageShareCardFooter, + type UsageShareCardSubject, +} from './usage-share-card'; +import { + computeUsageShareGraphic, + computeUsageShareMemberSlices, + computeUsageShareModelSlices, + computeUsageShareStats, +} from './usage-share-stats'; +import type { + SettingsUsageCalendarData, + SettingsUsageRange, + SettingsUsageTimelineData, +} from './settings-data-cache'; + +const BACKDROPS: Exclude[] = ['lody', 'aurora', 'ocean', 'sunset']; + +/** + * Scales the fixed-size card down to the preview panel. The card never reflows — + * its whole point is that the exported pixels are the same every time — so the + * preview only transforms it. + */ +function FitPreview({ children }: { children: ReactNode }) { + const containerRef = useRef(null); + const contentRef = useRef(null); + const [scale, setScale] = useState(1); + const [scaledSize, setScaledSize] = useState<{ width: number; height: number } | null>(null); + + useEffect(() => { + const container = containerRef.current; + const content = contentRef.current; + if (!container || !content) return undefined; + const update = () => { + // offsetWidth/offsetHeight ignore the element's own transform, so they + // report the unscaled card size even after we shrink it. + const width = content.offsetWidth; + const height = content.offsetHeight; + if (!width || !height || !container.clientWidth || !container.clientHeight) return; + const next = Math.min(1, container.clientWidth / width, container.clientHeight / height); + setScale(next); + setScaledSize({ width: width * next, height: height * next }); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(container); + observer.observe(content); + return () => observer.disconnect(); + }, []); + + return ( +
+
+
+ {children} +
+
+
+ ); +} + +export interface UsageShareImageDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + calendar: SettingsUsageCalendarData; + /** Timeline for the range the stats page is showing; drives every number. */ + timeline?: SettingsUsageTimelineData; + range: SettingsUsageRange; + workspaceName?: string; +} + +/** + * Preview and export for the workspace usage card. The session share dialog is + * an editor with nine knobs because its content has no fixed shape; this one is + * a generator with five, because its content does — the fewer choices, the more + * two months' cards can be read against each other. + */ +export function UsageShareImageDialog({ + open, + onOpenChange, + calendar, + timeline, + range, + workspaceName, +}: UsageShareImageDialogProps) { + const { t } = useTranslation(); + const [aspect, setAspect] = useState('portrait'); + const [subject, setSubject] = useState('personal'); + const [backdrop, setBackdrop] = useState('lody'); + const [theme, setTheme] = useState<'app' | 'light' | 'dark'>('dark'); + const [footer, setFooter] = useState('card'); + const [metric, setMetric] = useState('tokens'); + const [showQr, setShowQr] = useState(true); + const exportRef = useRef(null); + const exportingRef = useRef(false); + const [exporting, setExporting] = useState(false); + const [operation, setOperation] = useState<'copy' | 'export' | null>(null); + const [assetsReady, setAssetsReady] = useState(false); + const [exportError, setExportError] = useState(false); + const [copied, setCopied] = useState(false); + + // The heatmap's own intensity scale is built from the same metric the card is + // denominated in, so a cost card is shaded by cost rather than by tokens. + const model = useMemo(() => createUsageCalendarModel(calendar, metric), [calendar, metric]); + const stats = useMemo( + () => computeUsageShareStats(model, timeline, range, metric), + [model, timeline, range, metric] + ); + const graphic = useMemo( + () => computeUsageShareGraphic(timeline, range, metric), + [timeline, range, metric] + ); + const modelSlices = useMemo( + () => + computeUsageShareModelSlices( + timeline, + stripRecommended, + t('workspace.usage.skyline.other'), + metric + ), + [timeline, t, metric] + ); + const memberSlices = useMemo( + () => + computeUsageShareMemberSlices( + timeline, + () => t('workspace.usage.shareImage.unknownMember'), + t('workspace.usage.skyline.other'), + metric + ), + [timeline, t, metric] + ); + + // A "team" card that lists one person is just the personal card with a worse + // label, so the mode only opens once the range actually has two contributors. + const teamAvailable = memberSlices.length > 1; + useEffect(() => { + if (!teamAvailable && subject === 'team') setSubject('personal'); + }, [teamAvailable, subject]); + + const run = async (operationKind: 'copy' | 'export') => { + if (!exportRef.current || exportingRef.current || !assetsReady) return; + exportingRef.current = true; + setExporting(true); + setOperation(operationKind); + setExportError(false); + setCopied(false); + try { + if (operationKind === 'copy') { + await copyShareImage(exportRef.current); + setCopied(true); + } else { + await exportShareImage( + exportRef.current, + workspaceName ? `${workspaceName} usage` : undefined, + 'lody-usage' + ); + } + } catch { + setExportError(true); + } finally { + exportingRef.current = false; + setExporting(false); + setOperation(null); + } + }; + + return ( + { + if (!exportingRef.current) onOpenChange(next); + }} + > + + + + {t('workspace.usage.shareImage.dialogTitle')} + + + {t('workspace.usage.shareImage.dialogDescription')} + + + +
+
+
+ + + {metric === 'costUSD' ? ( +

+ {t('workspace.usage.shareImage.metricCostHint')} +

+ ) : null} +
+ +
+ + +
+ +
+ + + {subject === 'team' ? ( +

+ {t('workspace.usage.shareImage.subjectTeamHint')} +

+ ) : null} +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + {BACKDROPS.map((value) => { + const selected = backdrop === value; + return ( + + ); + })} +
+
+ +
+ +
+ + +
+
+
+ +
+ +
+ +
+
+
+
+ +
+ {exportError ? ( +

+ {t('workspace.usage.shareImage.exportFailed')} +

+ ) : copied ? ( +

+ {t('workspace.usage.shareImage.copied')} +

+ ) : null} + + +
+
+
+ ); +} diff --git a/packages/components/src/components/settings/usage-share-stats.ts b/packages/components/src/components/settings/usage-share-stats.ts new file mode 100644 index 000000000..d0f215718 --- /dev/null +++ b/packages/components/src/components/settings/usage-share-stats.ts @@ -0,0 +1,273 @@ +import type { + UsageCalendarCell, + UsageCalendarMetric, + UsageCalendarModel, +} from './usage-calendar-model'; +import type { SettingsUsageRange, SettingsUsageTimelineData } from './settings-data-cache'; + +/** + * The three headline cells under the hero number. Day and week ranges bucket by + * hour, so their trio counts intervals rather than days — the same split the + * on-screen summary already makes, kept in one place so the card and the page + * can never disagree about a number the user is about to publish. + */ +export type UsageShareTrio = 'daily' | 'interval'; + +export type UsageShareSlice = { + id: string; + label: string; + /** In the card's chosen metric — tokens or USD, never both. */ + value: number; + /** Fraction of the range total, in [0, 1]. */ + share: number; + /** Member avatar URL; only ever set for member slices. */ + image?: string | null; +}; + +export type UsageShareStats = { + trio: UsageShareTrio; + /** + * The unit every number here is in. It travels with the numbers rather than + * beside them: a caller that passed stats derived in one metric and a label in + * another would render a token count with a dollar sign in front of it. + */ + metric: UsageCalendarMetric; + /** + * The range's total in the chosen metric. The card is denominated end to end — + * headline, cells, graphic and split all read the same unit — so carrying both + * would invite a card that mixes them. + */ + total: number; + /** Days (or hourly intervals) inside the range that recorded usage. */ + activeCount: number; + /** Longest run of consecutive active days/intervals inside the range. */ + longestStreak: number; + /** Range total divided by its elapsed days/intervals, including quiet ones. */ + average: number; + /** Largest single day/interval in the range. */ + peak: number; + /** Calendar cells the range covers; the card lights these and dims the rest. */ + litDayStartMs: { fromMs: number; toMs: number } | null; + /** + * The range's absolute span, always set. `litDayStartMs` answers "what does the + * heatmap highlight" and is null for all-time; this answers "which dates is this + * card about", which a shared image must state even when nothing is highlighted. + */ + periodMs: { fromMs: number; toMs: number }; +}; + +const MAX_SLICES = 4; + +function streaks(values: number[]): { active: number; longest: number } { + let active = 0; + let longest = 0; + let run = 0; + for (const value of values) { + if (value > 0) { + active += 1; + run += 1; + longest = Math.max(longest, run); + } else { + run = 0; + } + } + return { active, longest }; +} + +/** + * Everything the share card prints, derived from the range the stats page is + * showing. The calendar supplies the 53-week heatmap; the timeline supplies the + * range's own totals, so the hero number always matches the KPI tile the user + * was looking at when they pressed Share. + */ +export function computeUsageShareStats( + calendar: UsageCalendarModel, + timeline: SettingsUsageTimelineData | undefined, + range: SettingsUsageRange, + metric: UsageCalendarMetric = 'tokens' +): UsageShareStats { + const pick = (row: { tokens: number; costUSD: number }) => + metric === 'tokens' ? row.tokens : row.costUSD; + + if (timeline && (range === 'day' || range === 'week')) { + const values = timeline.buckets.map(pick); + const { active, longest } = streaks(values); + const total = pick(timeline.totals); + return { + trio: 'interval', + metric, + total, + activeCount: active, + longestStreak: longest, + average: values.length > 0 ? total / values.length : 0, + peak: values.length > 0 ? Math.max(...values) : 0, + litDayStartMs: { fromMs: timeline.startMs, toMs: timeline.endMs }, + periodMs: { fromMs: timeline.startMs, toMs: timeline.endMs }, + }; + } + + // Day-denominated ranges read the calendar directly, so the heatmap, the + // streak, and the average are all counting the same cells. + const elapsed = calendar.cells.filter((cell: UsageCalendarCell) => !cell.isFuture); + const inRange = timeline + ? elapsed.filter( + (cell) => cell.dayStartMs >= timeline.startMs && cell.dayStartMs <= timeline.endMs + ) + : elapsed; + const window = inRange.length > 0 ? inRange : elapsed; + const values = window.map(pick); + const { active, longest } = streaks(values); + const total = timeline + ? pick(timeline.totals) + : values.reduce((sum, value) => sum + value, 0); + + return { + trio: 'daily', + metric, + total, + activeCount: active, + longestStreak: longest, + average: window.length > 0 ? total / window.length : 0, + peak: values.length > 0 ? Math.max(...values) : 0, + litDayStartMs: + // `total` covers the whole calendar; lighting a window would imply the + // rest is out of scope when it is not. + range === 'total' || !timeline + ? null + : { fromMs: timeline.startMs, toMs: timeline.endMs }, + periodMs: timeline + ? { fromMs: timeline.startMs, toMs: timeline.endMs } + : { + fromMs: window[0]?.dayStartMs ?? 0, + toMs: window.at(-1)?.dayStartMs ?? 0, + }, + }; +} + +/** + * Top model slices for the range, largest first, with everything past + * {@link MAX_SLICES} folded into one remainder slice so the card's legend has a + * fixed height at every range. + */ +export function computeUsageShareModelSlices( + timeline: SettingsUsageTimelineData | undefined, + labelModel: (modelId: string) => string, + otherLabel: string, + metric: UsageCalendarMetric = 'tokens' +): UsageShareSlice[] { + if (!timeline) return []; + const totals = new Map(); + for (const bucket of timeline.buckets) { + for (const item of bucket.byModel) { + const value = metric === 'tokens' ? item.tokens : item.costUSD; + totals.set(item.modelId, (totals.get(item.modelId) ?? 0) + value); + } + } + return foldSlices( + [...totals].map(([modelId, value]) => ({ + id: modelId, + label: labelModel(modelId), + value, + share: 0, + })), + otherLabel + ); +} + +/** + * Top member slices for the range. Members are identified by their display name + * and avatar only — an email is an identifier the card would publish, and the + * user sharing the image is not necessarily the person it identifies. + */ +export function computeUsageShareMemberSlices( + timeline: SettingsUsageTimelineData | undefined, + fallbackLabel: (userId: string) => string, + otherLabel: string, + metric: UsageCalendarMetric = 'tokens' +): UsageShareSlice[] { + if (!timeline) return []; + const totals = new Map(); + for (const bucket of timeline.buckets) { + for (const item of bucket.byUser) { + const value = metric === 'tokens' ? item.tokens : item.costUSD; + totals.set(item.userId, (totals.get(item.userId) ?? 0) + value); + } + } + return foldSlices( + [...totals].map(([userId, value]) => ({ + id: userId, + label: timeline.users?.[userId]?.name?.trim() || fallbackLabel(userId), + value, + share: 0, + image: timeline.users?.[userId]?.image ?? null, + })), + otherLabel + ); +} + +function foldSlices(rows: UsageShareSlice[], otherLabel: string): UsageShareSlice[] { + const sorted = rows.filter((row) => row.value > 0).sort((a, b) => b.value - a.value); + const total = sorted.reduce((sum, row) => sum + row.value, 0); + if (total <= 0) return []; + + const head = sorted.slice(0, MAX_SLICES); + const rest = sorted.slice(MAX_SLICES).reduce((sum, row) => sum + row.value, 0); + const slices = + rest > 0 ? [...head, { id: '__other', label: otherLabel, value: rest, share: 0 }] : head; + return slices.map((row) => ({ ...row, share: row.value / total })); +} + +/** + * Which graphic the card draws for a range. The Usage screen already speaks three + * visual languages — an hour skyline for 24h, a day-by-hour dot matrix for 7d, the + * 53-week calendar for the longer windows — and the card following the same split + * is what makes a 24h card worth looking at. Drawing the year for every range left + * the 24h card with a single lit cell. + */ +export type UsageShareGraphic = + | { kind: 'calendar' } + /** One value per hour of the shared day. */ + | { kind: 'hours'; values: number[] } + /** One row per day, each row one value per hour. */ + | { kind: 'weekHours'; rows: Array<{ dayStartMs: number; values: number[] }> }; + +const HOUR_MS = 60 * 60 * 1000; +const HOURS_PER_DAY = 24; + +/** + * Picks the graphic for the range. Hourly ranges need hour-granular buckets to say + * anything; when the timeline is missing or coarser than an hour the calendar is + * the honest fallback, because it is the one series always present. + */ +export function computeUsageShareGraphic( + timeline: SettingsUsageTimelineData | undefined, + range: SettingsUsageRange, + metric: UsageCalendarMetric = 'tokens' +): UsageShareGraphic { + const hourly = + timeline && timeline.bucketSizeMs <= HOUR_MS && timeline.buckets.length > 0 + ? timeline.buckets + : null; + if (!hourly || (range !== 'day' && range !== 'week')) return { kind: 'calendar' }; + + const pick = (row: { tokens: number; costUSD: number }) => + metric === 'tokens' ? row.tokens : row.costUSD; + if (range === 'day') return { kind: 'hours', values: hourly.map(pick) }; + + // 7d: group the hour buckets into whole days so every row is a real day, and a + // day the range only partly covers still lines its hours up with the others. + const rows = new Map(); + for (const bucket of hourly) { + const date = new Date(bucket.bucketStartMs); + const dayStartMs = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); + const values = rows.get(dayStartMs) ?? new Array(HOURS_PER_DAY).fill(0); + values[date.getUTCHours()] += pick(bucket); + rows.set(dayStartMs, values); + } + return { + kind: 'weekHours', + rows: [...rows.entries()] + .sort(([a], [b]) => a - b) + .map(([dayStartMs, values]) => ({ dayStartMs, values })), + }; +} diff --git a/packages/components/src/components/chat-share-theme-scope.ts b/packages/components/src/components/share-theme-scope.ts similarity index 90% rename from packages/components/src/components/chat-share-theme-scope.ts rename to packages/components/src/components/share-theme-scope.ts index aaace20e8..7275a5620 100644 --- a/packages/components/src/components/chat-share-theme-scope.ts +++ b/packages/components/src/components/share-theme-scope.ts @@ -15,7 +15,7 @@ const scopeRule = (selector: string, scheme: 'light' | 'dark', variables: Record /** * Injects `.light-scope` / `.dark-scope` rules carrying the bundled Lody - * light/dark theme variables, so a `ChatShareCard` can pin the exported + * light/dark theme variables, so a share card can pin the exported * image's palette instead of following whatever theme the app currently has * applied. One-time and idempotent; if a bundled theme fails to resolve the * scope simply inherits the app's variables (graceful degradation). @@ -24,7 +24,7 @@ const scopeRule = (selector: string, scheme: 'light' | 'dark', variables: Record * `.dark-scope` as a dark root and lets `.light-scope` opt out of an ancestor * `.dark`. */ -export function ensureChatShareThemeScopes(): void { +export function ensureShareThemeScopes(): void { if (injected || typeof document === 'undefined') return; injected = true; @@ -42,7 +42,7 @@ export function ensureChatShareThemeScopes(): void { if (sections.length === 0) return; const style = document.createElement('style'); - style.dataset.lodyChatShareThemeScopes = 'true'; + style.dataset.lodyShareThemeScopes = 'true'; style.textContent = sections.join('\n'); document.head.appendChild(style); } diff --git a/packages/components/src/lib/format-compact-number.ts b/packages/components/src/lib/format-compact-number.ts index 0c611bfd3..a529ef825 100644 --- a/packages/components/src/lib/format-compact-number.ts +++ b/packages/components/src/lib/format-compact-number.ts @@ -36,3 +36,47 @@ export function formatUsdAmount( options?.maximumFractionDigits ?? (abs > 0 && abs < 1 ? 3 : 2), }).format(safeValue); } + +/** Below this the cents are the point, not noise. */ +const USD_CENTS_BELOW = 1000; +/** Above this the digits stop being a boast and start being a wall. */ +const USD_COMPACT_FROM = 1_000_000_000; + +/** + * A dollar figure sized for a fixed layout, shortened in two stages rather than + * one. Someone denominating a share card in money usually wants the digits — that + * is the point of choosing cost — so the whole figure survives up to a billion and + * only the cents go, because on a four-figure sum they are noise. Below a thousand + * the cents come back: there they carry the meaning and the string is short anyway. + * A figure past a billion compacts, since by then the digits are a wall and the + * headline shares its row with the stat cells. + */ +export function formatUsdCompact(value: number, locale: string | null | undefined): string { + const safeValue = Number.isFinite(value) ? value : 0; + const abs = Math.abs(safeValue); + if (abs < USD_CENTS_BELOW) return formatUsdAmount(safeValue, locale); + return new Intl.NumberFormat(locale ?? 'en', { + style: 'currency', + currency: 'USD', + ...(abs < USD_COMPACT_FROM + ? { maximumFractionDigits: 0 } + : { notation: 'compact' as const, maximumFractionDigits: 1 }), + }).format(safeValue); +} + +/** + * Money for a slot too narrow to spell a figure out — a stat cell or a legend row, + * which get a quarter of a headline's width or less. Always compact above a + * thousand, so the string cannot outgrow its box; the alternative is `truncate`, + * and an ellipsis on a number renders a different number than the one measured. + */ +export function formatUsdTight(value: number, locale: string | null | undefined): string { + const safeValue = Number.isFinite(value) ? value : 0; + if (Math.abs(safeValue) < USD_CENTS_BELOW) return formatUsdAmount(safeValue, locale); + return new Intl.NumberFormat(locale ?? 'en', { + style: 'currency', + currency: 'USD', + notation: 'compact', + maximumFractionDigits: 1, + }).format(safeValue); +} diff --git a/packages/components/src/lib/chat-share-image-export.ts b/packages/components/src/lib/share-image-export.ts similarity index 77% rename from packages/components/src/lib/chat-share-image-export.ts rename to packages/components/src/lib/share-image-export.ts index a8340ed36..e1595cb62 100644 --- a/packages/components/src/lib/chat-share-image-export.ts +++ b/packages/components/src/lib/share-image-export.ts @@ -1,3 +1,8 @@ +/** + * Shared PNG capture for the product's share cards (session conversation, + * workspace usage). One pipeline so both surfaces get the same font/image + * readiness, the same Electron save/clipboard bridge, and the same failures. + */ import { getImagePreviewExportBridge } from './image-preview-export'; function pinOrderedListValues(element: HTMLElement): () => void { @@ -24,7 +29,7 @@ function pinOrderedListValues(element: HTMLElement): () => void { }; } -async function captureChatShareImage(element: HTMLElement): Promise { +async function captureShareImage(element: HTMLElement): Promise { await document.fonts.ready; await Promise.all(Array.from(element.querySelectorAll('img')).map((image) => image.decode())); const { snapdom } = await import('@zumer/snapdom'); @@ -41,7 +46,7 @@ async function captureChatShareImage(element: HTMLElement): Promise { compress: false, plugins: [ { - name: 'chat-share-hide-scrollbars', + name: 'lody-share-hide-scrollbars', beforeRender(context) { // Replace copied scrollbar rules only in the serialized image. context.scrollbarCSS = @@ -57,10 +62,10 @@ async function captureChatShareImage(element: HTMLElement): Promise { return blob; } -export async function copyChatShareImage(element: HTMLElement): Promise { +export async function copyShareImage(element: HTMLElement): Promise { const bridge = getImagePreviewExportBridge(); if (bridge) { - const blob = await captureChatShareImage(element); + const blob = await captureShareImage(element); const result = await bridge.copyToClipboard({ pngBytes: await blob.arrayBuffer() }); if (!result.copied) throw new Error(result.error || 'Image copy failed'); return; @@ -72,18 +77,27 @@ export async function copyChatShareImage(element: HTMLElement): Promise { // WebKit revokes transient user activation after an await. Start the clipboard // write synchronously and let ClipboardItem await the PNG capture itself. - const png = captureChatShareImage(element); + const png = captureShareImage(element); await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]); } -export async function exportChatShareImage(element: HTMLElement, title?: string): Promise { - const blob = await captureChatShareImage(element); +/** + * Saves the captured PNG. `title` is the user-facing name the card was built + * from (a session title, a workspace name); `fallback` is the surface's own + * stem, used when the title is empty or sanitizes away to nothing. + */ +export async function exportShareImage( + element: HTMLElement, + title: string | undefined, + fallback: string +): Promise { + const blob = await captureShareImage(element); const name = - (title?.trim() || 'lody-conversation') + (title?.trim() || fallback) .replace(/[<>:"/\\|?*\p{Cc}]/gu, '-') .replace(/[. ]+$/g, '') - .slice(0, 120) || 'lody-conversation'; + .slice(0, 120) || fallback; const fileName = `${name}.png`; const bridge = getImagePreviewExportBridge(); if (bridge) { diff --git a/packages/components/src/stories/TicketCutMachine.stories.tsx b/packages/components/src/stories/TicketCutMachine.stories.tsx deleted file mode 100644 index 47e2fb256..000000000 --- a/packages/components/src/stories/TicketCutMachine.stories.tsx +++ /dev/null @@ -1,525 +0,0 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; -import type { Meta, StoryObj } from '@storybook/react'; -import { AnimatePresence, motion, type PanInfo } from 'framer-motion'; -import { - createUsageCalendarModel, - type UsageCalendarModel, -} from '@/components/settings/usage-calendar-model'; -import { - computeUsageShareInsights, - formatUsageCompact, - renderUsageShareCardFrame, - resolveShareCardConfig, - type UsageShareCardConfig, -} from '@/components/settings/usage-share-card'; -import { TicketCutShaderView } from '@/components/settings/ticket-cut-shader'; - -// --- Fixtures -------------------------------------------------------------- - -const DAY_MS = 24 * 60 * 60 * 1000; -const START_MS = Date.UTC(2025, 6, 20); - -function buildModel(seedPhase: number, intensity: number): UsageCalendarModel { - return createUsageCalendarModel({ - startMs: START_MS, - endMs: START_MS + 370 * DAY_MS, - days: Array.from({ length: 371 }, (_, index) => { - const primary = Math.sin(index * 0.43 + seedPhase) * 0.5 + 0.5; - const secondary = Math.sin(index * 0.11 + 1.3 + seedPhase) * 0.5 + 0.5; - const ramp = 0.35 + 0.65 * (index / 364); - const activity = index > 364 ? 0 : Math.round(primary * secondary * ramp * intensity * 180_000); - return { - dayStartMs: START_MS + index * DAY_MS, - date: new Date(START_MS + index * DAY_MS).toISOString().slice(0, 10), - tokens: index % 9 === 0 ? 0 : activity, - costUSD: activity * 0.000012, - isFuture: index > 364, - }; - }), - }); -} - -// --- Ticket rendering ------------------------------------------------------ - -const RENDER_SCALE = 2; - -function renderBaseCanvas( - model: UsageCalendarModel, - name: string, - config: Partial -): HTMLCanvasElement { - const c = resolveShareCardConfig(config); - const canvas = document.createElement('canvas'); - canvas.width = c.width * RENDER_SCALE; - canvas.height = c.height * RENDER_SCALE; - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.scale(RENDER_SCALE, RENDER_SCALE); - renderUsageShareCardFrame(ctx, { model, workspaceName: name, subtitle: 'Tokens · last 53 weeks', config }); - } - return canvas; -} - -function renderTicketUrl(model: UsageCalendarModel, name: string, config: Partial): string { - return renderBaseCanvas(model, name, config).toDataURL('image/png'); -} - -/** Re-render the ticket as if a validator chewed it: stub torn off (frayed edge), - * a punched hole, and a red "割 · VALIDATED" stamp overprinted. */ -function renderCutTicketUrl(model: UsageCalendarModel, name: string, config: Partial): string { - const base = renderBaseCanvas(model, name, config); - const c = resolveShareCardConfig(config); - const W = c.width; - const H = c.height; - const out = document.createElement('canvas'); - out.width = W * RENDER_SCALE; - out.height = H * RENDER_SCALE; - const ctx = out.getContext('2d'); - if (!ctx) return base.toDataURL('image/png'); - ctx.scale(RENDER_SCALE, RENDER_SCALE); - - const tearX = c.marginX + c.tearX * (W - c.marginX * 2); - - // Deterministic ragged tear profile, so the rip looks fibrous rather than zig-zag. - const step = 9; - const rand = (() => { - let s = 0x9e3779b9; - return () => { - s = (s + 0x6d2b79f5) >>> 0; - let t = s; - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; - })(); - const tearPts: Array<[number, number]> = []; - for (let y = 0; y <= H + step; y += step) { - tearPts.push([tearX + (rand() * 16 - 6), Math.min(y, H)]); - } - - // Clip to the main body with the frayed vertical tear on the right. - ctx.save(); - ctx.beginPath(); - ctx.moveTo(0, 0); - ctx.lineTo(tearPts[0]![0], 0); - for (const [tx, ty] of tearPts) ctx.lineTo(tx, ty); - ctx.lineTo(0, H); - ctx.closePath(); - ctx.clip(); - ctx.drawImage(base, 0, 0, base.width, base.height, 0, 0, W, H); - - // Shade the torn edge so it reads as a physical rip (dark core + paper-white fibre). - ctx.strokeStyle = 'rgba(110,100,78,0.55)'; - ctx.lineWidth = 2.5; - ctx.beginPath(); - ctx.moveTo(tearPts[0]![0], 0); - for (const [tx, ty] of tearPts) ctx.lineTo(tx, ty); - ctx.stroke(); - ctx.strokeStyle = 'rgba(255,255,255,0.75)'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(tearPts[0]![0] - 2.5, 0); - for (const [tx, ty] of tearPts) ctx.lineTo(tx - 2.5, ty); - ctx.stroke(); - ctx.restore(); - - // Punched hole near the top-left. - ctx.save(); - ctx.globalCompositeOperation = 'destination-out'; - ctx.beginPath(); - ctx.arc(c.marginX + 34, c.marginY + 34, 11, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - - // Validation stamp — parked over the quiet lower-right of the chart so it never - // covers the workspace name or the hero number. - ctx.save(); - ctx.translate(tearX * 0.8, H * 0.63); - ctx.rotate(-0.21); - ctx.globalAlpha = 0.78; - ctx.strokeStyle = '#c0392b'; - ctx.lineWidth = 5; - ctx.beginPath(); - ctx.arc(0, 0, 84, 0, Math.PI * 2); - ctx.stroke(); - ctx.setLineDash([4, 5]); - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.arc(0, 0, 72, 0, Math.PI * 2); - ctx.stroke(); - ctx.setLineDash([]); - ctx.fillStyle = '#c0392b'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.font = '700 62px "Noto Sans SC", "PingFang SC", system-ui, sans-serif'; - ctx.fillText('割', 0, -12); - ctx.font = '700 15px Inter, sans-serif'; - ctx.fillText('VALIDATED', 0, 40); - ctx.restore(); - - return out.toDataURL('image/png'); -} - -const RIVAL_THEMES: Array<{ name: string; phase: number; intensity: number; config: Partial }> = [ - { name: 'Neo Studio', phase: 2.1, intensity: 1.35, config: {} }, - { name: 'Pixel Foundry', phase: 4.7, intensity: 0.7, config: { fontDisplay: '"Bebas Neue", sans-serif' } }, - { name: 'Umbra Labs', phase: 0.6, intensity: 1.05, config: {} }, -]; - -type Ticket = { - id: string; - name: string; - model: UsageCalendarModel; - config: Partial; - url: string; - cutUrl: string; -}; - -// --- Layout constants ------------------------------------------------------ - -type Phase = 'idle' | 'feeding' | 'processing' | 'ejecting' | 'result'; - -const TICKET_W = 400; -const TICKET_H = (TICKET_W * 630) / 1200; -const MACHINE_W = TICKET_W + 72; -const FACE_H = 258; - -// --- PK bits --------------------------------------------------------------- - -function Bar({ value, color, label, strong }: { value: number; color: string; label: string; strong: boolean }) { - return ( -
- - {label} -
- ); -} - -function Stat({ label, mine, theirs, format }: { label: string; mine: number; theirs: number; format: (v: number) => string }) { - const max = Math.max(mine, theirs, 1); - const youWins = mine >= theirs; - return ( -
- {label} -
- - -
-
- ); -} - -// --- Machine internals (the "processing" theatre) -------------------------- - -function Cog({ size, reverse }: { size: number; reverse?: boolean }) { - const teeth = Array.from({ length: 8 }, (_, i) => i); - return ( - - - {teeth.map((t) => ( - - ))} - - - - - ); -} - -/** Machine chrome layered over the shader view — must never hide the cut itself. */ -function ProcessingTheatre() { - return ( -
- {/* interior vignette so the ticket reads as lit from inside the housing */} -
- {/* mechanism, kept in the corners */} -
-
- {/* LED progress */} -
- {Array.from({ length: 6 }, (_, i) => ( - - ))} -
- {/* validation thunk, late and brief */} - -
-
-
- ); -} - -function Confetti() { - const pieces = useMemo( - () => - Array.from({ length: 22 }, (_, i) => ({ - id: i, - x: (Math.random() - 0.5) * 300, - y: -50 - Math.random() * 160, - rot: Math.random() * 540, - color: ['#2f9e57', '#95d9ad', '#e2c88c', '#26251f', '#c0392b'][i % 5], - delay: Math.random() * 0.15, - })), - [] - ); - return ( - <> - {pieces.map((p) => ( - - ))} - - ); -} - -// --- Machine --------------------------------------------------------------- - -function TicketMachine() { - const [phase, setPhase] = useState('idle'); - const [rival, setRival] = useState(null); - const [collected, setCollected] = useState(0); - const slotRef = useRef(null); - - const you = useMemo(() => { - const model = buildModel(0, 1.2); - return { - id: 'you', - name: 'Acme Robotics', - model, - config: {}, - url: renderTicketUrl(model, 'Acme Robotics', {}), - cutUrl: '', - }; - }, []); - - const rivals = useMemo( - () => - RIVAL_THEMES.map((t, i) => { - const model = buildModel(t.phase, t.intensity); - return { - id: `rival-${i}`, - name: t.name, - model, - config: t.config, - url: renderTicketUrl(model, t.name, t.config), - cutUrl: renderCutTicketUrl(model, t.name, t.config), - }; - }), - [] - ); - - const [pool, setPool] = useState(() => rivals.map((r) => r.id)); - - const startCut = useCallback((ticket: Ticket) => { - setRival(ticket); - setPool((prev) => prev.filter((id) => id !== ticket.id)); - setPhase('feeding'); - window.setTimeout(() => setPhase('processing'), 640); - window.setTimeout(() => { - setPhase('ejecting'); - setCollected((n) => n + 1); - }, 640 + 1150); - window.setTimeout(() => setPhase('result'), 640 + 1150 + 820); - }, []); - - const onRivalDragEnd = useCallback( - (ticket: Ticket) => (_e: unknown, info: PanInfo) => { - const slot = slotRef.current?.getBoundingClientRect(); - if (!slot || phase !== 'idle') return; - const { x, y } = info.point; - if (x >= slot.left - 30 && x <= slot.right + 30 && y >= slot.top - 60 && y <= slot.bottom + 90) startCut(ticket); - }, - [phase, startCut] - ); - - const reset = () => { - setPhase('idle'); - setRival(null); - setPool(rivals.map((r) => r.id)); - }; - - const insights = rival ? computeUsageShareInsights(rival.model) : null; - const youInsights = computeUsageShareInsights(you.model); - const shaking = phase === 'processing'; - - return ( -
-
-

🎟️ Lody 割票机

-

- 把对手的票根喂进投票口 → 机器割票 → 吐出一张验讫的票 → 自动 PK。 -

-

已收藏票根 · {collected}

-
- - {/* Stage: the machine sits at the top; the feed ticket overflows above it (no - reserved gap), and the tray below only claims space once a ticket is out. */} - - {/* Feed ticket (whole) dropping into the top slot */} - - {rival && phase === 'feeding' && ( - - )} - - - {/* Machine body */} - - {/* top slot */} -
- {/* screen */} -
- {phase === 'idle' && ( -
- 把票根拖到投票口 ↑(或点一下票根) -
- )} - {(phase === 'processing' || phase === 'feeding') && rival && ( - <> - {/* The real GLSL tear, seen through the machine window. */} -
- -
- - - )} - {(phase === 'ejecting' || phase === 'result') && ( -
✓ 已验讫
- )} -
- {/* output slot */} -
- - - {/* Ejected CUT ticket coming out of the bottom into the tray */} - - {rival && (phase === 'ejecting' || phase === 'result') && ( - - cut - {phase === 'ejecting' && } - - )} - - - - {/* PK result */} - - {phase === 'result' && rival && insights && ( - -
- PK · 你 vs {rival.name} - = rival.model.totalValue ? '#2f9e57' : '#c0392b', padding: '4px 10px', borderRadius: 999 }}> - {you.model.totalValue >= rival.model.totalValue ? '你赢了 🎉' : '对手更猛 🔥'} - -
- - - String(v)} /> - `${v}d`} /> - -
- )} -
- - {/* Rival pool */} -
-

对手的票根(拖进机器,或点一下)

-
- {rivals - .filter((r) => pool.includes(r.id)) - .map((r) => ( - phase === 'idle' && startCut(r)} - title="拖进机器,或点一下直接割票" - style={{ width: 190, cursor: 'grab', borderRadius: 10, overflow: 'hidden', boxShadow: '0 8px 20px rgba(0,0,0,0.14)', touchAction: 'none' }} - > - {r.name} - - ))} - {pool.length === 0 && phase === 'idle' && ( - 对手票根都割完啦 —— 点「再喂一张」重置。 - )} -
-
-
- ); -} - -const meta: Meta = { - title: 'Settings/TicketCutMachine', - component: TicketMachine, - parameters: { layout: 'fullscreen' }, -}; -export default meta; - -type Story = StoryObj; - -export const Machine: Story = {}; diff --git a/packages/components/src/stories/UsageShareCard.stories.tsx b/packages/components/src/stories/UsageShareCard.stories.tsx index 180a6fafa..18677ab55 100644 --- a/packages/components/src/stories/UsageShareCard.stories.tsx +++ b/packages/components/src/stories/UsageShareCard.stories.tsx @@ -1,550 +1,237 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import { createUsageCalendarModel } from '@/components/settings/usage-calendar-model'; import { - DEFAULT_USAGE_SHARE_CARD_CONFIG, - renderUsageShareCardFrame, - createUsageShareCard, - USAGE_SHARE_CARD_PRESETS, - type UsageShareCardConfig, - type UsageShareCardStyle, -} from '@/components/settings/usage-share-card'; -import { preloadUsageShareCardFonts } from '@/components/settings/usage-share-card-fonts'; -import { exportUsageShareCardVideo } from '@/components/settings/usage-share-card-export'; + createUsageCalendarModel, + USAGE_CALENDAR_CELLS, + type UsageCalendarData, + type UsageCalendarMetric, +} from '@/components/settings/usage-calendar-model'; +import { UsageShareCard } from '@/components/settings/usage-share-card'; +import { + computeUsageShareGraphic, + computeUsageShareMemberSlices, + computeUsageShareModelSlices, + computeUsageShareStats, +} from '@/components/settings/usage-share-stats'; +import type { + SettingsUsageTimelineBucket, + SettingsUsageTimelineData, +} from '@/components/settings/settings-data-cache'; const DAY_MS = 24 * 60 * 60 * 1000; -const START_MS = Date.UTC(2025, 6, 20); - -function wave(index: number, intensity: number): number { - const primary = Math.sin(index * 0.43) * 0.5 + 0.5; - const secondary = Math.sin(index * 0.11 + 1.3) * 0.5 + 0.5; - const ramp = 0.35 + 0.65 * (index / 364); - return primary * secondary * ramp * intensity; -} +const HOUR_MS = 60 * 60 * 1000; +const START_MS = Date.UTC(2025, 8, 7); // Sunday -function buildModel(empty: boolean, intensity: number) { - return createUsageCalendarModel({ +/** Deterministic year of usage: a ramping habit with weekends off and a burst. */ +function buildCalendar(scale = 1): UsageCalendarData { + return { startMs: START_MS, - endMs: START_MS + 370 * DAY_MS, - days: Array.from({ length: 371 }, (_, index) => { + endMs: START_MS + (USAGE_CALENDAR_CELLS - 1) * DAY_MS, + days: Array.from({ length: USAGE_CALENDAR_CELLS }, (_, index) => { const dayStartMs = START_MS + index * DAY_MS; - const activity = empty || index > 364 ? 0 : Math.round(wave(index, intensity) * 180_000); + const weekend = index % 7 === 0 || index % 7 === 6; + const wave = Math.sin(index * 0.21) * 0.5 + 0.5; + const ramp = 0.3 + (0.7 * index) / USAGE_CALENDAR_CELLS; + const burst = index > 300 && index < 330 ? 2.1 : 1; + const tokens = + weekend && index % 13 !== 0 ? 0 : Math.round(wave * ramp * burst * 9_400_000 * scale); return { dayStartMs, date: new Date(dayStartMs).toISOString().slice(0, 10), - tokens: index % 9 === 0 ? 0 : activity, - costUSD: activity * 0.000012, - isFuture: index > 364, + tokens, + costUSD: tokens * 0.0000042, + isFuture: index > 358, }; }), - }); + }; } -function downloadBlob(blob: Blob, fileName: string) { - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = fileName; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - window.setTimeout(() => URL.revokeObjectURL(url), 0); -} - -// --- Webfont loading (Storybook preview only) ----------------------------- +const CALENDAR = buildCalendar(); -const FONT_LINK_ID = 'lody-share-card-fonts'; -const FONT_HREF = - 'https://fonts.googleapis.com/css2?family=Anton&family=Archivo:wght@600;700&family=Bebas+Neue&family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700&family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=Instrument+Serif&family=JetBrains+Mono:wght@500;700&family=Monoton&family=Orbitron:wght@600;800&family=Space+Mono:wght@400;700&family=VT323&display=swap'; - -// Title / display faces, including a few artistic ones. -const FONT_OPTIONS = [ - '"Bitcount Grid Double", "Bricolage Grotesque", sans-serif', - '"Bricolage Grotesque", "Inter", sans-serif', - '"Fraunces", Georgia, serif', - '"Instrument Serif", Georgia, serif', - '"Anton", "Arial Narrow", sans-serif', - '"Bebas Neue", "Arial Narrow", sans-serif', - '"Monoton", cursive', - '"Orbitron", sans-serif', - '"Archivo", "Inter", sans-serif', - 'Inter, sans-serif', +const MODEL_MIX = [ + { modelId: 'claude-opus-5', weight: 0.52 }, + { modelId: 'gpt-5.2-codex', weight: 0.24 }, + { modelId: 'gemini-3-pro', weight: 0.13 }, + { modelId: 'kimi-k2', weight: 0.07 }, + { modelId: 'deepseek-v3', weight: 0.04 }, ]; -// Numeric / mono faces for the hero readout. -const FONT_MONO_OPTIONS = [ - '"JetBrains Mono", ui-monospace, monospace', - '"VT323", "JetBrains Mono", monospace', - '"Orbitron", sans-serif', - '"Space Mono", monospace', +const MEMBER_MIX = [ + { userId: 'u1', name: 'Ada Lovelace', weight: 0.44 }, + { userId: 'u2', name: 'Grace Hopper', weight: 0.31 }, + { userId: 'u3', name: 'Alan Turing', weight: 0.17 }, + { userId: 'u4', name: 'Katherine Johnson', weight: 0.08 }, ]; -function ensureFonts() { - if (typeof document === 'undefined' || document.getElementById(FONT_LINK_ID)) return; - const link = document.createElement('link'); - link.id = FONT_LINK_ID; - link.rel = 'stylesheet'; - link.href = FONT_HREF; - document.head.appendChild(link); -} - -function useFontsReady(): boolean { - const [ready, setReady] = useState(false); - useEffect(() => { - ensureFonts(); - let cancelled = false; - const probes = [ - '600 42px "Bricolage Grotesque"', - '600 42px "Fraunces"', - '400 42px "Instrument Serif"', - '400 42px "Anton"', - '400 42px "Bebas Neue"', - '400 42px "Monoton"', - '800 42px "Orbitron"', - '700 82px "JetBrains Mono"', - '400 82px "VT323"', - '700 42px "Space Mono"', - '600 15px "Inter"', - ]; - const fonts = (document as unknown as { fonts?: FontFaceSet }).fonts; - if (!fonts) { - setReady(true); - } else { - void Promise.all([ - preloadUsageShareCardFonts(), - ...probes.map((probe) => fonts.load(probe).catch(() => undefined)), - ]) - .then(() => fonts.ready) - .then(() => { - if (!cancelled) setReady(true); - }); - } - return () => { - cancelled = true; +/** Buckets end at the calendar's last completed day, the way live data does. */ +function buildBuckets(count: number, stepMs: number, perBucket: number) { + const lastMs = START_MS + 358 * DAY_MS; + return Array.from({ length: count }, (_, index): SettingsUsageTimelineBucket => { + const tokens = Math.round(perBucket * (0.4 + Math.abs(Math.sin(index * 0.7)))); + return { + bucketStartMs: lastMs - (count - 1 - index) * stepMs, + bucketLabel: String(index), + tokens, + costUSD: tokens * 0.0000042, + // Per-model and per-member cost has to be real here: the card can be + // denominated in USD, and a fixture that leaves it at zero would silently + // drop the split block instead of exercising it. + byModel: MODEL_MIX.map((entry) => ({ + modelId: entry.modelId, + tokens: Math.round(tokens * entry.weight), + costUSD: tokens * entry.weight * 0.0000042, + })), + byUser: MEMBER_MIX.map((entry) => ({ + userId: entry.userId, + tokens: Math.round(tokens * entry.weight), + costUSD: tokens * entry.weight * 0.0000042, + })), }; - }, []); - return ready; + }); } -const CONFIG_KEYS = Object.keys(DEFAULT_USAGE_SHARE_CARD_CONFIG) as Array; - -type PlaygroundArgs = UsageShareCardConfig & { - workspaceName: string; - subtitle: string; - style: UsageShareCardStyle; - intensity: number; - empty: boolean; -}; - -function pickConfig(args: PlaygroundArgs): UsageShareCardConfig { - const config = {} as UsageShareCardConfig; - for (const key of CONFIG_KEYS) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (config as any)[key] = args[key]; - } - return config; +function buildTimeline( + range: SettingsUsageTimelineData['range'], + buckets: SettingsUsageTimelineBucket[], + bucketSizeMs: number +): SettingsUsageTimelineData { + const tokens = buckets.reduce((sum, item) => sum + item.tokens, 0); + return { + workspaceId: 'ws', + range, + startMs: buckets[0]?.bucketStartMs ?? START_MS, + endMs: (buckets.at(-1)?.bucketStartMs ?? START_MS) + bucketSizeMs, + bucketSizeMs, + totals: { tokens, costUSD: tokens * 0.0000042 }, + users: Object.fromEntries(MEMBER_MIX.map((entry) => [entry.userId, { name: entry.name }])), + buckets, + }; } -function configDelta(config: UsageShareCardConfig): Partial { - const delta: Partial = {}; - for (const key of CONFIG_KEYS) { - if (config[key] !== DEFAULT_USAGE_SHARE_CARD_CONFIG[key]) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (delta as any)[key] = config[key]; - } - } - return delta; +const MONTH = buildTimeline('month', buildBuckets(30, DAY_MS, 41_000_000), DAY_MS); +/** A spend large enough that spelling it out in a stat cell would not fit. */ +const HUGE_SCALE = 1000; +const HUGE = buildTimeline('month', buildBuckets(30, DAY_MS, 41_000_000 * HUGE_SCALE), DAY_MS); +const HUGE_CALENDAR = buildCalendar(HUGE_SCALE); +const DAY = buildTimeline('day', buildBuckets(24, HOUR_MS, 3_100_000), HOUR_MS); +const WEEK = buildTimeline('week', buildBuckets(7 * 24, HOUR_MS, 1_900_000), HOUR_MS); + +function cardPropsFor( + timeline: SettingsUsageTimelineData, + rangeLabel: string, + metric: UsageCalendarMetric, + // The calendar and the timeline describe the same workspace in production, so a + // fixture that scales one without the other renders a card that cannot exist: + // the headline comes from the timeline and the peak from the calendar window. + calendarData: UsageCalendarData = CALENDAR +) { + const calendar = createUsageCalendarModel(calendarData, metric); + return { + calendar, + stats: computeUsageShareStats(calendar, timeline, timeline.range, metric), + graphic: computeUsageShareGraphic(timeline, timeline.range, metric), + modelSlices: computeUsageShareModelSlices(timeline, (id) => id, 'Other', metric), + memberSlices: computeUsageShareMemberSlices(timeline, () => 'Unknown member', 'Other', metric), + rangeLabel, + workspaceName: 'Loro', + }; } -function Playground(args: PlaygroundArgs) { - const canvasRef = useRef(null); - const rafRef = useRef(null); - const [status, setStatus] = useState(''); - const [videoUrl, setVideoUrl] = useState(null); - - const fontsReady = useFontsReady(); - const config = useMemo(() => pickConfig(args), [args]); - const model = useMemo(() => buildModel(args.empty, args.intensity), [args.empty, args.intensity]); - const delta = useMemo(() => configDelta(config), [config]); - - const frameInput = useMemo( - () => ({ - model, - workspaceName: args.workspaceName, - subtitle: args.subtitle, - style: args.style, - config, - }), - [args.style, args.subtitle, args.workspaceName, config, model] - ); - - const paint = useCallback( - (progress: number) => { - const canvas = canvasRef.current; - if (!canvas) return; - const dpr = Math.min(2, window.devicePixelRatio || 1); - if (canvas.width !== config.width * dpr) canvas.width = config.width * dpr; - if (canvas.height !== config.height * dpr) canvas.height = config.height * dpr; - const context = canvas.getContext('2d'); - if (!context) return; - context.setTransform(dpr, 0, 0, dpr, 0, 0); - renderUsageShareCardFrame(context, frameInput, progress); - }, - [config.height, config.width, frameInput] - ); - - useEffect(() => { - paint(1); - }, [paint, fontsReady]); - - const play = useCallback(() => { - if (rafRef.current) cancelAnimationFrame(rafRef.current); - const start = performance.now(); - const duration = 2600; - const tick = () => { - const elapsed = performance.now() - start; - const progress = Math.min(1, elapsed / (duration * 0.82)); - paint(progress); - if (elapsed < duration) { - rafRef.current = requestAnimationFrame(tick); - } - }; - rafRef.current = requestAnimationFrame(tick); - }, [paint]); - - useEffect(() => () => { - if (rafRef.current) cancelAnimationFrame(rafRef.current); - }, []); - - const copyConfig = useCallback(async () => { - const text = JSON.stringify(delta, null, 2); - try { - await navigator.clipboard.writeText(text); - setStatus('已复制 config delta 到剪贴板 — 贴回给我即可锁定'); - } catch { - setStatus('复制失败,请从下方 JSON 手动复制'); - } - }, [delta]); - - const downloadPng = useCallback(async () => { - const blob = await createUsageShareCard( - model, - args.workspaceName, - args.subtitle, - args.style, - config - ); - downloadBlob(blob, 'lody-usage-card.png'); - }, [args.style, args.subtitle, args.workspaceName, config, model]); +const cardProps = (timeline: SettingsUsageTimelineData, rangeLabel: string) => + cardPropsFor(timeline, rangeLabel, 'tokens'); - const exportVideo = useCallback(async () => { - setStatus('正在编码 MP4…'); - setVideoUrl(null); - try { - const result = await exportUsageShareCardVideo(frameInput, { - onProgress: (fraction) => setStatus(`正在编码 ${result_pct(fraction)}…`), - }); - const url = URL.createObjectURL(result.blob); - setVideoUrl(url); - downloadBlob(result.blob, `lody-usage-card.${result.extension}`); - setStatus(`已导出 ${result.extension.toUpperCase()} (${Math.round(result.blob.size / 1024)} KB)`); - } catch (error) { - setStatus(`导出失败: ${error instanceof Error ? error.message : String(error)}`); - } - }, [frameInput]); - - return ( -
-
- -
- -
- - - - -
- - {status ? ( -

{status}

- ) : null} - - {videoUrl ? ( -
- ); -} +const meta = { + title: 'Settings/UsageShareCard', + component: UsageShareCard, + parameters: { layout: 'centered' }, + argTypes: { + aspect: { control: 'inline-radio', options: ['portrait', 'wide'] }, + subject: { control: 'inline-radio', options: ['personal', 'team'] }, + backdrop: { control: 'select', options: ['none', 'lody', 'aurora', 'ocean', 'sunset'] }, + // Metric is not a control: it belongs to `stats`, and a story that set it + // independently would format one metric's numbers in the other's unit. + stats: { control: false }, + theme: { control: 'inline-radio', options: [undefined, 'light', 'dark'] }, + }, + tags: ['autodocs'], +} satisfies Meta; -function result_pct(fraction: number): string { - return `${Math.round(fraction * 100)}%`; -} +export default meta; +type Story = StoryObj; -const buttonStyle: CSSProperties = { - padding: '8px 14px', - borderRadius: 8, - border: '1px solid #d0d0d5', - background: '#fff', - fontSize: 13, - cursor: 'pointer', +/** The default a user sees when they open the dialog. */ +export const Portrait: Story = { + args: { ...cardProps(MONTH, 'Last 30 days'), aspect: 'portrait', theme: 'dark' }, }; -const primaryButtonStyle: CSSProperties = { - ...buttonStyle, - background: '#2f9e57', - borderColor: '#2f9e57', - color: '#fff', - fontWeight: 600, +export const Wide: Story = { + args: { ...cardProps(MONTH, 'Last 30 days'), aspect: 'wide', theme: 'dark' }, }; -const range = (min: number, max: number, step = 1) => ({ - control: { type: 'range' as const, min, max, step }, -}); -const color = { control: { type: 'color' as const } }; - -const meta: Meta = { - title: 'Settings/UsageShareCard', - component: Playground, - parameters: { layout: 'fullscreen' }, +/** Members replace models in the split block; avatars replace brand marks. */ +export const TeamPortrait: Story = { args: { - ...DEFAULT_USAGE_SHARE_CARD_CONFIG, - workspaceName: 'Acme Robotics', - subtitle: 'Tokens · last 53 weeks', - style: 'isometric', - intensity: 1, - empty: false, - }, - argTypes: { - // Content - workspaceName: { control: 'text' }, - subtitle: { control: 'text' }, - kickerText: { control: 'text' }, - unitLabel: { control: 'text' }, - serial: { control: 'text' }, - style: { control: 'inline-radio', options: ['flat', 'isometric'] }, - foil: { - control: 'inline-radio', - options: ['none', 'silver', 'platinum', 'champagne'], - }, - fontDisplay: { control: 'select', options: FONT_OPTIONS }, - intensity: range(0.2, 1.6, 0.05), - empty: { control: 'boolean' }, - // Shape / die-cut - cornerRadius: range(0, 60), - tearX: range(0.4, 0.95, 0.01), - tearInset: range(0, 60), - perfRadius: range(1, 10, 0.5), - perfSpacing: range(10, 40), - notchRadius: range(0, 50), - scallopEdge: { control: 'boolean' }, - scallopRadius: range(3, 18, 0.5), - scallopSpacing: range(12, 50), - marginX: range(10, 100), - marginY: range(10, 100), - frameInset: range(0, 40), - showFrame: { control: 'boolean' }, - // Palette - paperTop: color, - paperBottom: color, - edgeColor: color, - inkColor: color, - mutedInk: color, - accent: color, - accentSoft: color, - showGrain: { control: 'boolean' }, - grainOpacity: range(0, 0.2, 0.005), - shadowOpacity: range(0, 0.5, 0.01), - // Hero + trend - kickerY: range(50, 140), - titleY: range(90, 200), - heroY: range(150, 320), - heroSize: range(48, 120), - subtitleY: range(180, 340), - showTrend: { control: 'boolean' }, - heroGraphic: { control: 'inline-radio', options: ['trend', 'heatmap', 'bars'] }, - chartTop: range(200, 400), - chartHeight: range(60, 260), - trendLineWidth: range(1, 10, 0.5), - trendDotRadius: range(0, 14, 0.5), - trendFill: { control: 'boolean' }, - showTrendDelta: { control: 'boolean' }, - // Stats + heatmap - showStats: { control: 'boolean' }, - statsY: range(380, 560), - showHeatmap: { control: 'boolean' }, - heatmapTop: range(440, 600), - heatmapHeight: range(20, 90), - // Lody sticker - showMark: { control: 'boolean' }, - markStyle: { control: 'inline-radio', options: ['sticker', 'outline', 'plain'] }, - markFx: { control: 'inline-radio', options: ['none', 'vhs'] }, - markX: range(-260, 320), - markY: range(40, 300), - markSize: range(30, 180), - markRotation: range(-45, 45), - markOpacity: range(0, 1, 0.05), - markFill: color, - markStroke: color, - markStrokeWidth: range(0, 24, 0.5), - // Stub - showStub: { control: 'boolean' }, - showStubStamp: { control: 'boolean' }, - stubStampSize: range(40, 200), - showBarcode: { control: 'boolean' }, - contentPadX: range(16, 100), - fontSans: { control: 'text' }, - fontMono: { control: 'select', options: FONT_MONO_OPTIONS }, - heroFx: { control: 'inline-radio', options: ['none', 'vhs'] }, - width: { table: { disable: true } }, - height: { table: { disable: true } }, + ...cardProps(MONTH, 'Last 30 days'), + aspect: 'portrait', + subject: 'team', + theme: 'dark', }, }; -export default meta; - -type Story = StoryObj; -export const Playground_: Story = { name: 'Playground' }; - -export const StampEdge: Story = { - name: 'Stamp edge', - args: { scallopEdge: true, showStub: false, cornerRadius: 18, markRotation: -6 }, +/** Hourly range: the trio counts intervals and only a sliver of the year lights. */ +export const HourlyRange: Story = { + args: { ...cardProps(DAY, 'Last 24 hours'), aspect: 'portrait', theme: 'light' }, }; -export const Empty: Story = { args: { empty: true } }; - -// --- Preset gallery ------------------------------------------------------- - -function PresetCard({ presetKey }: { presetKey: keyof typeof USAGE_SHARE_CARD_PRESETS }) { - const canvasRef = useRef(null); - const preset = USAGE_SHARE_CARD_PRESETS[presetKey]; - const model = useMemo(() => buildModel(false, 1), []); - const fontsReady = useFontsReady(); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const config = { ...DEFAULT_USAGE_SHARE_CARD_CONFIG, ...preset.config }; - const dpr = Math.min(2, window.devicePixelRatio || 1); - canvas.width = config.width * dpr; - canvas.height = config.height * dpr; - const context = canvas.getContext('2d'); - if (!context) return; - context.setTransform(dpr, 0, 0, dpr, 0, 0); - renderUsageShareCardFrame( - context, - { model, workspaceName: 'Acme Robotics', subtitle: 'Tokens · last 53 weeks', config }, - 1 - ); - }, [model, preset.config, fontsReady]); - - return ( -
-
- -
-
- {preset.label} - — {preset.description} -
preset: {presetKey}
-
-
- ); -} +/** 7d draws the day-by-hour dot matrix, the Usage screen's own idiom for a week. */ +export const WeekRange: Story = { + args: { ...cardProps(WEEK, 'Last 7 days'), aspect: 'portrait', theme: 'dark' }, +}; -function Gallery() { - return ( -
-

- 三个可组合的轴:外形(票根 / 邮票 / 无) × - 主题配色 × 主视觉图(趋势曲线 / 柱状 / - 贡献热力墙)。下面是几个现成组合,喜欢哪个告诉我 preset 名即可,或者去 Playground 微调后 - 「复制 config」。 -

-
- {(Object.keys(USAGE_SHARE_CARD_PRESETS) as Array).map( - (key) => ( - - ) - )} -
-
- ); -} +/** The hourly graphics have to survive 16:9's tighter height too. */ +export const HourlyWide: Story = { + args: { ...cardProps(DAY, 'Last 24 hours'), aspect: 'wide', theme: 'dark' }, +}; -export const PresetGallery: StoryObj = { - name: 'Preset gallery', - render: () => , +export const WeekWide: Story = { + args: { ...cardProps(WEEK, 'Last 7 days'), aspect: 'wide', theme: 'light' }, }; -export const SilverFoil: Story = { - name: 'Silver foil', - args: USAGE_SHARE_CARD_PRESETS.silverFoil!.config, +/** The sign-off moved onto the backdrop, which frees the in-card band entirely. */ +export const CanvasFooter: Story = { + args: { + ...cardProps(MONTH, 'Last 30 days'), + aspect: 'portrait', + footer: 'canvas', + theme: 'dark', + }, }; -export const PlatinumFoil: Story = { - name: 'Platinum foil', - args: USAGE_SHARE_CARD_PRESETS.platinumFoil!.config, + +/** The whole card denominated in dollars: headline, cells, graphic and split. */ +export const CostMetric: Story = { + args: { + ...cardPropsFor(MONTH, 'Last 30 days', 'costUSD'), + aspect: 'portrait', + backdrop: 'sunset', + theme: 'light', + }, }; -export const ChampagneFoil: Story = { - name: 'Champagne foil', - args: USAGE_SHARE_CARD_PRESETS.champagneFoil!.config, + +/** Nine-figure spend: the headline keeps its digits, the narrow slots compact. */ +export const CostMetricLarge: Story = { + args: { + ...cardPropsFor(HUGE, 'Last 30 days', 'costUSD', HUGE_CALENDAR), + aspect: 'portrait', + backdrop: 'sunset', + theme: 'light', + }, }; -export const Kraft: Story = { args: USAGE_SHARE_CARD_PRESETS.kraft!.config }; -export const ContributionWall: Story = { - name: 'Contribution wall', - args: USAGE_SHARE_CARD_PRESETS.contribution!.config, + +/** No backdrop: the card is the whole image, in the pinned light palette. */ +export const Bare: Story = { + args: { + ...cardProps(MONTH, 'Last 30 days'), + aspect: 'portrait', + backdrop: 'none', + theme: 'light', + }, }; -export const Bars: Story = { args: { heroGraphic: 'bars' } }; -export const VhsReadout: Story = { name: 'VHS readout', args: USAGE_SHARE_CARD_PRESETS.vhs!.config }; diff --git a/packages/components/src/stories/UsageShareImageDialog.stories.tsx b/packages/components/src/stories/UsageShareImageDialog.stories.tsx new file mode 100644 index 000000000..0c46947a6 --- /dev/null +++ b/packages/components/src/stories/UsageShareImageDialog.stories.tsx @@ -0,0 +1,123 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState, type ComponentProps } from 'react'; +import { UsageShareImageDialog } from '@/components/settings/usage-share-image-dialog'; +import { USAGE_CALENDAR_CELLS } from '@/components/settings/usage-calendar-model'; +import type { + SettingsUsageCalendarData, + SettingsUsageTimelineBucket, + SettingsUsageTimelineData, +} from '@/components/settings/settings-data-cache'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const START_MS = Date.UTC(2025, 8, 7); // Sunday +const LAST_MS = START_MS + 358 * DAY_MS; + +/** Synthetic year of usage: a ramping habit with weekends mostly off. */ +const CALENDAR: SettingsUsageCalendarData = { + workspaceId: 'ws-demo', + timezone: 'UTC', + startMs: START_MS, + endMs: START_MS + (USAGE_CALENDAR_CELLS - 1) * DAY_MS, + days: Array.from({ length: USAGE_CALENDAR_CELLS }, (_, index) => { + const dayStartMs = START_MS + index * DAY_MS; + const weekend = index % 7 === 0 || index % 7 === 6; + const wave = Math.sin(index * 0.21) * 0.5 + 0.5; + const ramp = 0.3 + (0.7 * index) / USAGE_CALENDAR_CELLS; + const tokens = weekend && index % 13 !== 0 ? 0 : Math.round(wave * ramp * 9_400_000); + return { + dayStartMs, + date: new Date(dayStartMs).toISOString().slice(0, 10), + tokens, + costUSD: tokens * 0.0000042, + isFuture: index > 358, + }; + }), +}; + +const MODEL_MIX = [ + { modelId: 'claude-opus-5', weight: 0.52 }, + { modelId: 'gpt-5.2-codex', weight: 0.24 }, + { modelId: 'gemini-3-pro', weight: 0.13 }, + { modelId: 'kimi-k2', weight: 0.11 }, +]; + +const MEMBER_MIX = [ + { userId: 'u1', name: 'Ada Lovelace', weight: 0.44 }, + { userId: 'u2', name: 'Grace Hopper', weight: 0.31 }, + { userId: 'u3', name: 'Alan Turing', weight: 0.25 }, +]; + +function buildTimeline(memberCount: number): SettingsUsageTimelineData { + const members = MEMBER_MIX.slice(0, memberCount); + const buckets = Array.from({ length: 30 }, (_, index): SettingsUsageTimelineBucket => { + const tokens = Math.round(41_000_000 * (0.4 + Math.abs(Math.sin(index * 0.7)))); + return { + bucketStartMs: LAST_MS - (29 - index) * DAY_MS, + bucketLabel: String(index), + tokens, + costUSD: tokens * 0.0000042, + byModel: MODEL_MIX.map((entry) => ({ + modelId: entry.modelId, + tokens: Math.round(tokens * entry.weight), + costUSD: 0, + })), + byUser: members.map((entry) => ({ + userId: entry.userId, + tokens: Math.round(tokens * entry.weight), + costUSD: 0, + })), + }; + }); + const tokens = buckets.reduce((sum, item) => sum + item.tokens, 0); + return { + workspaceId: 'ws-demo', + range: 'month', + startMs: buckets[0]!.bucketStartMs, + endMs: LAST_MS, + bucketSizeMs: DAY_MS, + totals: { tokens, costUSD: tokens * 0.0000042 }, + users: Object.fromEntries(members.map((entry) => [entry.userId, { name: entry.name }])), + buckets, + }; +} + +const meta = { + title: 'Settings/UsageShareImageDialog', + component: UsageShareImageDialog, + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function DialogHarness(args: ComponentProps) { + const [open, setOpen] = useState(args.open); + return ; +} + +const renderDialog: Story['render'] = (args) => ; + +export const Default: Story = { + args: { + open: true, + onOpenChange: () => {}, + calendar: CALENDAR, + timeline: buildTimeline(3), + range: 'month', + workspaceName: 'Loro', + }, + render: renderDialog, +}; + +/** One contributor: the member mode stays disabled rather than shipping a one-row leaderboard. */ +export const SingleContributor: Story = { + ...Default, + args: { ...Default.args, timeline: buildTimeline(1) }, +}; + +/** No timeline yet: the card falls back to the calendar and drops the split block. */ +export const TimelinePending: Story = { + ...Default, + args: { ...Default.args, timeline: undefined }, +}; diff --git a/packages/components/tests/format-usd-compact.test.ts b/packages/components/tests/format-usd-compact.test.ts new file mode 100644 index 000000000..5cbd4fa64 --- /dev/null +++ b/packages/components/tests/format-usd-compact.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { formatUsdCompact, formatUsdTight } from '../src/lib/format-compact-number'; + +describe('formatUsdCompact', () => { + it('keeps small amounts exact, where the cents are the point', () => { + expect(formatUsdCompact(0, 'en')).toBe('$0.00'); + expect(formatUsdCompact(0.42, 'en')).toBe('$0.42'); + expect(formatUsdCompact(12.5, 'en')).toBe('$12.50'); + expect(formatUsdCompact(999.99, 'en')).toBe('$999.99'); + }); + + it('keeps every digit up to a billion, dropping only the cents', () => { + // Choosing cost as the measure is usually about showing the digits, so the + // figure survives; on a four-figure sum the cents are noise. + expect(formatUsdCompact(1000, 'en')).toBe('$1,000'); + expect(formatUsdCompact(5297.05, 'en')).toBe('$5,297'); + expect(formatUsdCompact(1_234_567.89, 'en')).toBe('$1,234,568'); + expect(formatUsdCompact(999_999_999, 'en')).toBe('$999,999,999'); + }); + + it('compacts past a billion, where the digits become a wall', () => { + expect(formatUsdCompact(1_234_567_890, 'en')).toBe('$1.2B'); + }); + + it('never exceeds the width a fixed-format card budgets for a headline', () => { + // The 16:9 card shares one row between the headline and the stat cells; an + // unbounded figure closed that gap to nothing and then overlapped it. + for (const value of [1e3, 1e6, 999_999_999, 1e12]) { + expect(formatUsdCompact(value, 'en').length).toBeLessThanOrEqual(12); + } + }); + + it('follows the product language rather than the host locale', () => { + expect(formatUsdCompact(120_000_000_000, 'zh-CN')).toContain('亿'); + expect(formatUsdCompact(1_200_000_000, 'en')).toBe('$1.2B'); + }); + + it('treats a non-finite amount as zero rather than printing NaN', () => { + expect(formatUsdCompact(Number.NaN, 'en')).toBe('$0.00'); + }); + + it('gives narrow slots a form that cannot outgrow them', () => { + // A stat cell gets a quarter of the headline's width; spelling the figure out + // there produced "$42,040…", and an ellipsis on a number is a wrong number. + expect(formatUsdTight(176_568, 'en')).toBe('$176.6K'); + expect(formatUsdTight(5_297_047, 'en')).toBe('$5.3M'); + // Small amounts still say exactly what they are. + expect(formatUsdTight(36.88, 'en')).toBe('$36.88'); + for (const value of [1e3, 1e6, 1e9, 1e12]) { + expect(formatUsdTight(value, 'en').length).toBeLessThanOrEqual(8); + } + }); +}); diff --git a/packages/components/tests/chat-share-image-export.test.ts b/packages/components/tests/share-image-export.test.ts similarity index 85% rename from packages/components/tests/chat-share-image-export.test.ts rename to packages/components/tests/share-image-export.test.ts index 2196438c9..6c9290e5f 100644 --- a/packages/components/tests/chat-share-image-export.test.ts +++ b/packages/components/tests/share-image-export.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { copyChatShareImage, exportChatShareImage } from '../src/lib/chat-share-image-export'; +import { copyShareImage, exportShareImage } from '../src/lib/share-image-export'; const mocks = vi.hoisted(() => ({ toBlob: vi.fn(), bridge: vi.fn() })); vi.mock('@zumer/snapdom', () => ({ snapdom: { toBlob: mocks.toBlob } })); @@ -23,7 +23,7 @@ afterEach(() => { vi.unstubAllGlobals(); }); -describe('chat share image export', () => { +describe('share image export', () => { it('pins ordinary, nested, and explicitly restarted list numbers during capture', async () => { const card = document.createElement('div'); card.innerHTML = @@ -34,7 +34,7 @@ describe('chat share image export', () => { captured = Array.from(element.querySelectorAll('li')).map((item) => item.value); return new Blob([]); }); - await expect(exportChatShareImage(card)).rejects.toThrow('PNG encoding failed'); + await expect(exportShareImage(card, undefined, 'lody-conversation')).rejects.toThrow('PNG encoding failed'); expect(captured).toEqual([1, 3, 4, 2, 7, 8]); expect(card.innerHTML).toBe(original); }); @@ -49,7 +49,7 @@ describe('chat share image export', () => { captured = Array.from(element.querySelectorAll('li')).map((item) => item.value); throw new Error('Capture failed'); }); - await expect(exportChatShareImage(card)).rejects.toThrow('Capture failed'); + await expect(exportShareImage(card, undefined, 'lody-conversation')).rejects.toThrow('Capture failed'); expect(captured).toEqual([3, 2, 1, 9, 0, -1]); expect(card.innerHTML).toBe(original); }); @@ -64,7 +64,7 @@ describe('chat share image export', () => { createObjectURL: () => 'blob:share-image', revokeObjectURL: (url: string) => revoked.push(url), }); - await exportChatShareImage(document.createElement('div'), 'Review / rendering'); + await exportShareImage(document.createElement('div'), 'Review / rendering', 'lody-conversation'); expect(downloads).toEqual([{ name: 'Review - rendering.png', url: 'blob:share-image' }]); expect(document.querySelector('a[download]')).toBeNull(); expect(revoked).toEqual([]); @@ -86,7 +86,7 @@ describe('chat share image export', () => { return { saved: false, canceled: true }; }, }); - await exportChatShareImage(document.createElement('div')); + await exportShareImage(document.createElement('div'), undefined, 'lody-conversation'); expect(saved).toEqual({ fileName: 'lody-conversation.png', bytes }); }); @@ -97,7 +97,7 @@ describe('chat share image export', () => { arrayBuffer: async () => new ArrayBuffer(1), }); mocks.bridge.mockReturnValue({ saveAs: async () => ({ saved: false, error: 'Disk full' }) }); - await expect(exportChatShareImage(document.createElement('div'))).rejects.toThrow('Disk full'); + await expect(exportShareImage(document.createElement('div'), undefined, 'lody-conversation')).rejects.toThrow('Disk full'); }); it('hands PNG bytes to Electron clipboard and propagates copy failure', async () => { @@ -110,13 +110,13 @@ describe('chat share image export', () => { }); mocks.bridge.mockReturnValue({ copyToClipboard }); - await copyChatShareImage(document.createElement('div')); + await copyShareImage(document.createElement('div')); expect(copyToClipboard).toHaveBeenCalledWith({ pngBytes: bytes }); mocks.bridge.mockReturnValue({ copyToClipboard: async () => ({ copied: false, error: 'Clipboard busy' }), }); - await expect(copyChatShareImage(document.createElement('div'))).rejects.toThrow( + await expect(copyShareImage(document.createElement('div'))).rejects.toThrow( 'Clipboard busy' ); }); @@ -129,7 +129,7 @@ describe('chat share image export', () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { write } }); vi.stubGlobal('ClipboardItem', TestClipboardItem); - await copyChatShareImage(document.createElement('div')); + await copyShareImage(document.createElement('div')); expect(write).toHaveBeenCalledTimes(1); const item = write.mock.calls[0]![0]![0] as TestClipboardItem; @@ -153,7 +153,7 @@ describe('chat share image export', () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { write } }); vi.stubGlobal('ClipboardItem', TestClipboardItem); - const copy = copyChatShareImage(document.createElement('div')); + const copy = copyShareImage(document.createElement('div')); expect(write).toHaveBeenCalledTimes(1); const item = write.mock.calls[0]![0]![0] as TestClipboardItem; @@ -170,7 +170,7 @@ describe('chat share image export', () => { new Blob(['svg'], { type: 'image/svg+xml' }), ]) { mocks.toBlob.mockResolvedValue(blob); - await expect(exportChatShareImage(document.createElement('div'))).rejects.toThrow( + await expect(exportShareImage(document.createElement('div'), undefined, 'lody-conversation')).rejects.toThrow( 'PNG encoding failed' ); } diff --git a/packages/components/tests/usage-share-stats.test.ts b/packages/components/tests/usage-share-stats.test.ts new file mode 100644 index 000000000..a19db9132 --- /dev/null +++ b/packages/components/tests/usage-share-stats.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from 'vitest'; +import { + createUsageCalendarModel, + USAGE_CALENDAR_CELLS, + type UsageCalendarData, +} from '../src/components/settings/usage-calendar-model'; +import { + computeUsageShareGraphic, + computeUsageShareMemberSlices, + computeUsageShareModelSlices, + computeUsageShareStats, +} from '../src/components/settings/usage-share-stats'; +import type { + SettingsUsageTimelineBucket, + SettingsUsageTimelineData, +} from '../src/components/settings/settings-data-cache'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const HOUR_MS = 60 * 60 * 1000; +const START_MS = Date.UTC(2025, 6, 20); // Sunday, so the grid starts on column 0. + +function createCalendar(tokensByIndex: Record = {}): UsageCalendarData { + return { + startMs: START_MS, + endMs: START_MS + (USAGE_CALENDAR_CELLS - 1) * DAY_MS, + days: Array.from({ length: USAGE_CALENDAR_CELLS }, (_, index) => { + const dayStartMs = START_MS + index * DAY_MS; + const tokens = tokensByIndex[index] ?? 0; + return { + dayStartMs, + date: new Date(dayStartMs).toISOString().slice(0, 10), + tokens, + costUSD: tokens / 1000, + isFuture: false, + }; + }), + }; +} + +function createTimeline( + overrides: Partial & Pick +): SettingsUsageTimelineData { + return { + workspaceId: 'ws', + startMs: START_MS, + endMs: START_MS + 29 * DAY_MS, + bucketSizeMs: DAY_MS, + totals: { tokens: 0, costUSD: 0 }, + buckets: [], + ...overrides, + }; +} + +function bucket( + bucketStartMs: number, + tokens: number, + byModel: SettingsUsageTimelineBucket['byModel'] = [], + byUser: SettingsUsageTimelineBucket['byUser'] = [] +): SettingsUsageTimelineBucket { + return { + bucketStartMs, + bucketLabel: String(bucketStartMs), + tokens, + costUSD: tokens / 1000, + byModel, + byUser, + }; +} + +describe('usage share stats', () => { + it('counts active days and the longest streak inside the shared window only', () => { + // Days 0-2 active, day 3 quiet, days 4-5 active — all inside the window. + // Days 40-46 are a longer run that sits outside it and must not be counted. + const calendar = createUsageCalendarModel( + createCalendar({ + 0: 10, + 1: 20, + 2: 30, + 4: 40, + 5: 50, + 40: 99, + 41: 99, + 42: 99, + 43: 99, + 44: 99, + 45: 99, + 46: 99, + }), + 'tokens' + ); + const timeline = createTimeline({ + range: 'month', + startMs: START_MS, + endMs: START_MS + 9 * DAY_MS, + totals: { tokens: 150, costUSD: 0.15 }, + }); + + const stats = computeUsageShareStats(calendar, timeline, 'month'); + + expect(stats.trio).toBe('daily'); + expect(stats.activeCount).toBe(5); + expect(stats.longestStreak).toBe(3); + expect(stats.peak).toBe(50); + // The hero number is the timeline's own total, so the card and the KPI tile + // the user was looking at can never disagree. + expect(stats.total).toBe(150); + // Ten elapsed days in the window, quiet days included. + expect(stats.average).toBe(15); + expect(stats.litDayStartMs).toEqual({ fromMs: START_MS, toMs: START_MS + 9 * DAY_MS }); + expect(stats.periodMs).toEqual({ fromMs: START_MS, toMs: START_MS + 9 * DAY_MS }); + }); + + it('switches to interval counting for the hourly ranges', () => { + const calendar = createUsageCalendarModel(createCalendar({ 0: 100 }), 'tokens'); + const timeline = createTimeline({ + range: 'day', + bucketSizeMs: HOUR_MS, + totals: { tokens: 240, costUSD: 0.24 }, + buckets: [ + bucket(START_MS, 100), + bucket(START_MS + HOUR_MS, 60), + bucket(START_MS + 2 * HOUR_MS, 0), + bucket(START_MS + 3 * HOUR_MS, 80), + ], + }); + + const stats = computeUsageShareStats(calendar, timeline, 'day'); + + expect(stats.trio).toBe('interval'); + expect(stats.activeCount).toBe(3); + expect(stats.longestStreak).toBe(2); + expect(stats.peak).toBe(100); + expect(stats.average).toBe(60); + }); + + it('lights the whole calendar for the all-time range', () => { + const calendar = createUsageCalendarModel(createCalendar({ 3: 70 }), 'tokens'); + const timeline = createTimeline({ range: 'total', totals: { tokens: 70, costUSD: 0.07 } }); + + const stats = computeUsageShareStats(calendar, timeline, 'total'); + expect(stats.litDayStartMs).toBeNull(); + // Nothing is highlighted, but the card still has to say which dates it covers. + expect(stats.periodMs).toEqual({ fromMs: START_MS, toMs: START_MS + 29 * DAY_MS }); + }); + + it('falls back to the calendar when the range has no timeline yet', () => { + const calendar = createUsageCalendarModel(createCalendar({ 0: 5, 1: 15 }), 'tokens'); + + const stats = computeUsageShareStats(calendar, undefined, 'month'); + + expect(stats.total).toBe(20); + expect(stats.activeCount).toBe(2); + expect(stats.litDayStartMs).toBeNull(); + // No timeline: the span falls back to the calendar's elapsed days. + expect(stats.periodMs).toEqual({ + fromMs: START_MS, + toMs: START_MS + (USAGE_CALENDAR_CELLS - 1) * DAY_MS, + }); + }); + + it('ranks model slices, folds the tail into one remainder, and normalizes shares', () => { + const timeline = createTimeline({ + range: 'month', + buckets: [ + bucket(START_MS, 0, [ + { modelId: 'a', tokens: 50, costUSD: 0 }, + { modelId: 'b', tokens: 20, costUSD: 0 }, + { modelId: 'c', tokens: 10, costUSD: 0 }, + ]), + bucket(START_MS + DAY_MS, 0, [ + { modelId: 'a', tokens: 10, costUSD: 0 }, + { modelId: 'd', tokens: 5, costUSD: 0 }, + { modelId: 'e', tokens: 3, costUSD: 0 }, + { modelId: 'f', tokens: 2, costUSD: 0 }, + ]), + ], + }); + + const slices = computeUsageShareModelSlices(timeline, (id) => id.toUpperCase(), 'Other'); + + expect(slices.map((slice) => slice.id)).toEqual(['a', 'b', 'c', 'd', '__other']); + expect(slices[0]).toMatchObject({ label: 'A', value: 60 }); + // e (3) + f (2) fold together rather than adding legend rows. + expect(slices.at(-1)).toMatchObject({ label: 'Other', value: 5 }); + expect(slices.reduce((sum, slice) => sum + slice.share, 0)).toBeCloseTo(1, 10); + }); + + it('labels members by display name only and never by email', () => { + const timeline = createTimeline({ + range: 'month', + users: { + u1: { name: 'Ada', email: 'ada@example.com', image: 'https://img/1' }, + u2: { email: 'grace@example.com' }, + }, + buckets: [ + bucket( + START_MS, + 0, + [], + [ + { userId: 'u1', tokens: 90, costUSD: 0 }, + { userId: 'u2', tokens: 10, costUSD: 0 }, + ] + ), + ], + }); + + const slices = computeUsageShareMemberSlices(timeline, () => 'Unknown member', 'Other'); + + expect(slices).toEqual([ + { id: 'u1', label: 'Ada', value: 90, share: 0.9, image: 'https://img/1' }, + { id: 'u2', label: 'Unknown member', value: 10, share: 0.1, image: null }, + ]); + expect(JSON.stringify(slices)).not.toContain('@example.com'); + }); + + it('draws hours for 24h, a day-by-hour grid for 7d, and the calendar otherwise', () => { + const hourly = (count: number, range: SettingsUsageTimelineData['range']) => + createTimeline({ + range, + bucketSizeMs: HOUR_MS, + buckets: Array.from({ length: count }, (_, index) => + bucket(START_MS + index * HOUR_MS, index) + ), + }); + + expect(computeUsageShareGraphic(hourly(24, 'day'), 'day')).toEqual({ + kind: 'hours', + values: Array.from({ length: 24 }, (_, index) => index), + }); + + const week = computeUsageShareGraphic(hourly(26, 'week'), 'week'); + expect(week.kind).toBe('weekHours'); + if (week.kind !== 'weekHours') throw new Error('expected weekHours'); + // 26 hourly buckets from a midnight start fall into two calendar days, oldest + // first, and each hour lands on its own index rather than being appended. + expect(week.rows).toHaveLength(2); + expect(week.rows[0]?.dayStartMs).toBe(START_MS); + expect(week.rows[0]?.values).toHaveLength(24); + expect(week.rows[0]?.values[5]).toBe(5); + expect(week.rows[1]?.values.slice(0, 3)).toEqual([24, 25, 0]); + + expect(computeUsageShareGraphic(hourly(24, 'month'), 'month')).toEqual({ kind: 'calendar' }); + }); + + it('falls back to the calendar when a range has no hour-granular series', () => { + const daily = createTimeline({ + range: 'day', + bucketSizeMs: DAY_MS, + buckets: [bucket(START_MS, 10)], + }); + + expect(computeUsageShareGraphic(daily, 'day')).toEqual({ kind: 'calendar' }); + expect(computeUsageShareGraphic(undefined, 'day')).toEqual({ kind: 'calendar' }); + }); + + it('denominates the whole card in the chosen metric', () => { + const calendar = createUsageCalendarModel(createCalendar({ 0: 100 }), 'costUSD'); + const timeline = createTimeline({ + range: 'day', + bucketSizeMs: HOUR_MS, + totals: { tokens: 9000, costUSD: 9 }, + // The helper derives a bucket's cost from its tokens, so these are the + // token counts that make the costs come out at 6 and 3. + buckets: [ + bucket(START_MS, 6000, [{ modelId: 'a', tokens: 6000, costUSD: 6 }], [ + { userId: 'u1', tokens: 6000, costUSD: 6 }, + ]), + bucket(START_MS + HOUR_MS, 3000, [{ modelId: 'b', tokens: 3000, costUSD: 3 }], [ + { userId: 'u2', tokens: 3000, costUSD: 3 }, + ]), + ], + }); + + const stats = computeUsageShareStats(calendar, timeline, 'day', 'costUSD'); + expect(stats.total).toBe(9); + expect(stats.average).toBe(4.5); + expect(stats.peak).toBe(6); + + // The graphic and both splits read the same unit, so no band can disagree. + expect(computeUsageShareGraphic(timeline, 'day', 'costUSD')).toEqual({ + kind: 'hours', + values: [6, 3], + }); + expect( + computeUsageShareModelSlices(timeline, (id) => id, 'Other', 'costUSD').map((s) => s.value) + ).toEqual([6, 3]); + expect( + computeUsageShareMemberSlices(timeline, () => 'Unknown', 'Other', 'costUSD').map( + (s) => s.value + ) + ).toEqual([6, 3]); + + // The unit travels with the numbers, so a caller cannot pair one metric's + // figures with the other's label. + expect(stats.metric).toBe('costUSD'); + + // The same fixtures in tokens produce the token figures, not the dollar ones. + const inTokens = computeUsageShareStats(calendar, timeline, 'day', 'tokens'); + expect(inTokens.total).toBe(9000); + expect(inTokens.metric).toBe('tokens'); + }); + + it('returns no slices when the range recorded no usage', () => { + const empty = createTimeline({ range: 'month', buckets: [bucket(START_MS, 0)] }); + + expect(computeUsageShareModelSlices(empty, (id) => id, 'Other')).toEqual([]); + expect(computeUsageShareMemberSlices(empty, () => 'Unknown', 'Other')).toEqual([]); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3616fdd4d..c18c4aaae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -897,9 +897,6 @@ importers: '@floating-ui/react': specifier: ^0.27.4 version: 0.27.17(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@fontsource/bitcount-grid-double': - specifier: ^5.3.0 - version: 5.3.0 '@fontsource/inter': specifier: ^5.2.8 version: 5.2.8 @@ -1152,6 +1149,9 @@ importers: prosemirror-flat-list: specifier: ^0.7.1 version: 0.7.1 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: 'catalog:' version: 19.2.0 @@ -1230,9 +1230,6 @@ importers: zod: specifier: 4.3.6 version: 4.3.6 - qrcode: - specifier: ^1.5.4 - version: 1.5.4 devDependencies: '@lody/configs': specifier: workspace:* @@ -1273,6 +1270,9 @@ importers: '@types/node': specifier: 'catalog:' version: 24.10.12 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: 'catalog:' version: 19.2.17 @@ -1327,9 +1327,6 @@ importers: vitest: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.12)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2) - '@types/qrcode': - specifier: ^1.5.6 - version: 1.5.6 packages/configs: dependencies: @@ -1588,9 +1585,6 @@ importers: packages: - '@zumer/snapdom@2.24.15': - resolution: {integrity: sha512-4YE+3ekbBFEAxMyHr++wxOSGt/k+n721eeNT9N9Map27A+ra5sBut3qkYyheYlRj9dJ3HtZaUN1/yrh/aCiuKw==} - '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} @@ -3215,9 +3209,6 @@ packages: '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} - '@fontsource/bitcount-grid-double@5.3.0': - resolution: {integrity: sha512-VVmuvCncavTyGrsKF0ERDlp+ZPHf4xIvveD+wmAQjfZms3Eh/0eQ2LKgQ8zlbLgvNjZSny0Jl1pU2A4XkJAKTQ==} - '@fontsource/inter@5.2.8': resolution: {integrity: sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==} @@ -6110,6 +6101,9 @@ packages: '@types/proxy-from-env@1.0.4': resolution: {integrity: sha512-TPR9/bCZAr3V1eHN4G3LD3OLicdJjqX1QRXWuNcCYgE66f/K8jO2ZRtHxI2D9MbnuUP6+qiKSS8eUHp6TFHGCw==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -6690,6 +6684,9 @@ packages: '@zag-js/utils@1.42.0': resolution: {integrity: sha512-Km0r9hY+f6/oCJXrO4nqCIuo+4gTqbloD0V0q7B8Jq8qeWte7HN+YJSagVlk8tfADqFMRgEW4Rug0bYHzrGbVA==} + '@zumer/snapdom@2.24.15': + resolution: {integrity: sha512-4YE+3ekbBFEAxMyHr++wxOSGt/k+n721eeNT9N9Map27A+ra5sBut3qkYyheYlRj9dJ3HtZaUN1/yrh/aCiuKw==} + abbrev@4.0.0: resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} engines: {node: ^20.17.0 || >=22.9.0} @@ -7178,6 +7175,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001769: resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} @@ -7280,6 +7281,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -7710,6 +7714,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} @@ -7828,6 +7836,9 @@ packages: resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -8495,6 +8506,10 @@ packages: resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} engines: {node: '>=18'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -9625,6 +9640,10 @@ packages: resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} engines: {node: '>=14'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -10473,6 +10492,10 @@ packages: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -10481,6 +10504,10 @@ packages: resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -10497,6 +10524,10 @@ packages: resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} engines: {node: '>=8'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -10635,6 +10666,10 @@ packages: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -10918,6 +10953,11 @@ packages: resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} engines: {node: '>=16.0.0'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.15.1: resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} @@ -11287,6 +11327,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resedit@1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -11511,6 +11554,9 @@ packages: server-dom-shim@1.1.0: resolution: {integrity: sha512-oyKhBZtkr/SGB9YE2r0VtQxQCxaVx/Ix1fMz0XMd6K4T1/TMfDs9K2GR9QjpUtD+siyeLXr+3CzzGSvhTI1sEw==} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -12781,6 +12827,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.20: resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} @@ -12890,6 +12939,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -12909,10 +12961,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -12981,76 +13041,8 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} - qrcode@1.5.4: - resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} - engines: {node: '>=10.13.0'} - hasBin: true - - dijkstrajs@1.0.3: - resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} - - pngjs@5.0.0: - resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} - engines: {node: '>=10.13.0'} - - yargs@15.4.1: - resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} - engines: {node: '>=8'} - - cliui@6.0.0: - resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} - - decamelize@1.2.0: - resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} - engines: {node: '>=0.10.0'} - - find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} - - locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} - - p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} - - p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} - - p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} - - require-main-filename@2.0.0: - resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} - - set-blocking@2.0.0: - resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - - which-module@2.0.1: - resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} - - y18n@4.0.3: - resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} - - yargs-parser@18.1.3: - resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} - engines: {node: '>=6'} - - camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} - - '@types/qrcode@1.5.6': - resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} - snapshots: - '@zumer/snapdom@2.24.15': {} - '@adobe/css-tools@4.4.4': {} '@agentclientprotocol/sdk@1.3.0(zod@4.3.6)': @@ -14654,8 +14646,6 @@ snapshots: '@floating-ui/utils@0.2.12': {} - '@fontsource/bitcount-grid-double@5.3.0': {} - '@fontsource/inter@5.2.8': {} '@fontsource/jetbrains-mono@5.2.8': {} @@ -18357,6 +18347,10 @@ snapshots: dependencies: '@types/node': 26.2.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 26.2.0 + '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: '@types/react': 19.2.17 @@ -19079,6 +19073,8 @@ snapshots: '@zag-js/utils@1.42.0': {} + '@zumer/snapdom@2.24.15': {} + abbrev@4.0.0: {} abbrev@5.0.0: {} @@ -19780,6 +19776,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001769: {} ccount@2.0.1: {} @@ -19882,6 +19880,12 @@ snapshots: cli-width@4.1.0: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -20297,6 +20301,8 @@ snapshots: optionalDependencies: supports-color: 11.0.0 + decamelize@1.2.0: {} + decimal.js-light@2.5.1: {} decimal.js@10.6.0: {} @@ -20384,6 +20390,8 @@ snapshots: diff@9.0.0: {} + dijkstrajs@1.0.3: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.2 @@ -21381,6 +21389,11 @@ snapshots: find-up-simple@1.0.1: {} + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -22552,6 +22565,10 @@ snapshots: pkg-types: 2.3.0 quansync: 0.2.11 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -23680,6 +23697,10 @@ snapshots: p-finally@1.0.0: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -23688,6 +23709,10 @@ snapshots: dependencies: yocto-queue: 1.2.2 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -23705,6 +23730,8 @@ snapshots: dependencies: p-finally: 1.0.0 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} package-manager-detector@1.6.0: {} @@ -23838,6 +23865,8 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + pngjs@5.0.0: {} + points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -24178,6 +24207,12 @@ snapshots: pvutils@1.1.5: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.15.1: dependencies: side-channel: 1.1.0 @@ -24669,6 +24704,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resedit@1.7.2: dependencies: pe-library: 0.4.1 @@ -24956,6 +24993,8 @@ snapshots: dependencies: '@lit-labs/ssr-dom-shim': 1.6.0 + set-blocking@2.0.0: {} + set-cookie-parser@2.7.2: {} set-cookie-parser@3.0.1: {} @@ -26661,6 +26700,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 @@ -26767,6 +26808,8 @@ snapshots: xtend@4.0.2: optional: true + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -26777,8 +26820,27 @@ snapshots: yaml@2.8.2: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 @@ -26835,73 +26897,3 @@ snapshots: use-sync-external-store: 1.6.0(react@19.2.0) zwitch@2.0.4: {} - - qrcode@1.5.4: - dependencies: - dijkstrajs: 1.0.3 - pngjs: 5.0.0 - yargs: 15.4.1 - - dijkstrajs@1.0.3: {} - - pngjs@5.0.0: {} - - yargs@15.4.1: - dependencies: - cliui: 6.0.0 - decamelize: 1.2.0 - find-up: 4.1.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - require-main-filename: 2.0.0 - set-blocking: 2.0.0 - string-width: 4.2.3 - which-module: 2.0.1 - y18n: 4.0.3 - yargs-parser: 18.1.3 - - cliui@6.0.0: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 6.2.0 - - decamelize@1.2.0: {} - - find-up@4.1.0: - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 - - locate-path@5.0.0: - dependencies: - p-locate: 4.1.0 - - p-locate@4.1.0: - dependencies: - p-limit: 2.3.0 - - p-limit@2.3.0: - dependencies: - p-try: 2.2.0 - - p-try@2.2.0: {} - - require-main-filename@2.0.0: {} - - set-blocking@2.0.0: {} - - which-module@2.0.1: {} - - y18n@4.0.3: {} - - yargs-parser@18.1.3: - dependencies: - camelcase: 5.3.1 - decamelize: 1.2.0 - - camelcase@5.3.1: {} - - '@types/qrcode@1.5.6': - dependencies: - '@types/node': 26.2.0 diff --git a/site-docs/types/lody-app-components.d.ts b/site-docs/types/lody-app-components.d.ts index 6293bee64..d4809fcb0 100644 --- a/site-docs/types/lody-app-components.d.ts +++ b/site-docs/types/lody-app-components.d.ts @@ -1364,6 +1364,8 @@ declare module '@/components/settings/stats-setting-pure' { tintMemberSeriesLabel?: boolean; /** USD fraction digits for the cost KPI (default 2). Landing uses 0. */ costFractionDigits?: number; + /** Opt-in share-card entry (default false). The landing demo leaves it off. */ + shareCard?: boolean; }; export function StatsSettingsView(props: StatsSettingsViewProps): ReactElement; diff --git a/specs/chat-share-image.md b/specs/chat-share-image.md index 5934c8ed6..31521bde7 100644 --- a/specs/chat-share-image.md +++ b/specs/chat-share-image.md @@ -37,6 +37,6 @@ or change the saved image behavior. Evidence: [selection tests](../packages/components/tests/message-selection.test.tsx), [metadata tests](../packages/shared/tests/conversation-markdown.test.ts), and -[export tests](../packages/components/tests/chat-share-image-export.test.ts), and +[export tests](../packages/components/tests/share-image-export.test.ts), and [interactive story](../packages/components/src/stories/SessionConversationPage.stories.tsx). Automated screenshots were deliberately not run; this draft does not claim visual acceptance. diff --git a/specs/usage-share-image.md b/specs/usage-share-image.md new file mode 100644 index 000000000..8f6479e26 --- /dev/null +++ b/specs/usage-share-image.md @@ -0,0 +1,105 @@ +# Usage card image export + +Status: draft +Translation: pending + +From the workspace Usage screen a user can turn the range they are looking at +into one shareable image. This feature operates locally and publishes nothing: +the card is rendered from data the screen already holds and leaves only as a PNG +the user saves or copies. + +The card is a fixed-format report, not an editor. Its blocks, their order, and +their proportions are the same on every card, so two cards taken a month apart +can be laid side by side and read against each other. The user chooses the frame +— portrait or wide, a backdrop, a pinned or app-following palette — and two +content questions; nothing else about the layout is adjustable. This is the +deliberate difference from [chat image selection and export](chat-share-image.md), +where content of unpredictable shape justifies a large set of appearance controls. + +The period the card describes is the range selected on the Usage screen, and the +headline total is that range's own total, so the card cannot disagree with the +tile the user pressed Share from. The card names that period twice: once as the +range's own words, and once as the absolute dates it covers, because a shared +image outlives the day it was taken and "last 30 days" alone does not survive it. + +Four headline cells carry the same four facts at every range — how often, how +consistently, how much on a typical unit, how much at the best one. Hourly ranges +count active intervals; day-denominated ranges count active days, and their +average is taken over elapsed days including quiet ones. + +The card's graphic follows the range, in the same three visual languages the Usage +screen speaks: an hour skyline for the last 24 hours, a day-by-hour dot grid for +the last 7 days, and the 53-week calendar for the longer windows. A range that has +no hour-granular series falls back to the calendar, which is the one series always +present. Every graphic occupies the same fixed box, so the card's height never +depends on which range it describes. + +The calendar carries month ticks so a burst can be placed in time rather than only +seen, lights the shared range's window and lets the surrounding year recede. The +all-time range lights the whole calendar, because no part of it is out of scope. +The hourly graphics carry an hour axis. The week's rows run oldest to newest and +carry no per-day label: seven days of hours touch eight calendar days whenever the +window does not begin at midnight, and eight rows inside the shared box leave no +room for a legible one. The headline already names the span. + +The card is denominated end to end in one measure, tokens or USD: the headline, +the four cells, the graphic's shading and the split all read the same unit, so no +band can disagree with another and a reader never has to work out which number is +the subject. Tokens are the default, because a workspace's spend is not implied by +a request to share activity; naming cost as the measure is a deliberate act, and it +substitutes for tokens rather than joining them. A deployment that reports no +per-model cost simply has no split block on a cost card, the same as any range +without recorded usage. + +The measure travels with the numbers rather than beside them, so a headline can +never be labelled in one unit and computed in another. + +Money in the headline keeps its digits and loses its cents. Choosing cost as the measure is +usually about the size of the figure, so the whole number survives; on a four-figure +sum the cents are noise, while below a thousand they carry the meaning and come +back. Only past a billion does the figure compact, because by then the digits are a +wall and a fixed layout budgets a fixed width for its headline. A stat cell or a +legend row has a quarter of that width or less, so those always compact: a figure +clipped to fit states a different number than the one measured. The card names the workspace and, by default, no one else. The +member mode is an explicit choice, is offered only when the range has more than +one contributor, and identifies members by display name and avatar; an email is +never drawn onto the image. + +The model split ranks the range's models largest first and folds everything past +the fourth into one remainder slice, so the legend has the same height at every +range. Each row carries both its absolute tokens and its share: a percentage +alone hides scale, and half of a quiet week is not half of a heavy month. A range +without recorded usage simply has no split block. + +The headline number owns its band alone. The space beside and around it is left +empty on purpose: everything the card has to say is already said by the bands below +it, and the alternatives — a brand watermark, a second chart — either repeat what is +there or stand in for content that does not exist. + +The sign-off can sit inside the card or on the backdrop beneath it. On the +backdrop it costs the card nothing — the in-card band goes away and those pixels +were empty frame — so the data gains room; it needs a backdrop to print on, and a +card without one keeps the sign-off inside. Either way it names the workspace it +belongs to, where it came from, and carries a code that opens it. + +The in-card footer is a sign-off rather than a status bar: It borrows the session card's +identity-and-sub structure without that card's parameter line, which would only +repeat numbers the bands above already carry. The wide format keeps the same +content on one row, having no height to spare. + +A chosen backdrop is part of the image, not a border added around it, so a framed +card has less room for its content than an unframed one. The layout is sized for +the framed case, and only the headline band absorbs spare height; every other band +keeps its natural size so a card that cannot fit says so rather than compressing. + +Export and copy reuse the session card's pipeline: both wait for fonts and +images, disable duplicate actions while running, and report a failure that +leaves the preview open for retry. Electron uses its native save dialog and +clipboard bridge; browsers download the file and use the image Clipboard API. +Cancelling the save dialog preserves the preview. + +Evidence: [share statistics tests](../packages/components/tests/usage-share-stats.test.ts), +[export tests](../packages/components/tests/share-image-export.test.ts), and +[card stories](../packages/components/src/stories/UsageShareCard.stories.tsx). +Automated screenshots were deliberately not run; this draft does not claim visual +acceptance.